일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |
- 트랜잭션
- 국제화
- springsecurity
- 소셜로그인
- 토이프로젝트
- 백준
- 메시지
- 스프링
- CI/CD
- 도커
- 재갱신
- 스프링시큐리티
- oauth2
- 스프링부트
- java
- docker
- springsecurityoauth2client
- 프로그래머스
- 오블완
- JIRA
- Spring
- githubactions
- 티스토리챌린지
- yaml-resource-bundle
- 파이썬
- 데이터베이스
- AWS
- 액세스토큰
- 리프레시토큰
- springdataredis
- Today
- Total
땃쥐네
[토이프로젝트] 게시판 시스템(board-system) 19. Redis 연동 본문
이번 글에서는 이전부터 미뤘던
이메일 인증, 리프레시토큰 홀더의 Redis 저장 설정을 해보겠습니다.
1. Redis
레디스는 인메모리 데이터베이스의 대표적인 제품으로, 데이터를 메모리에 저장하는 특징이 있습니다.
디스크에 데이터를 저장하는 관계형데이터베이스들보다 데이터 접근이 빠르기 때문에 주로 캐시로 많이 사용합니다.
127.0.0.1:6379> set key value
OK
127.0.0.1:6379> expire key 10
(integer) 1
127.0.0.1:6379> ttl key
(integer) 5
127.0.0.1:6379> ttl key
(integer) 3
127.0.0.1:6379> ttl key
(integer) -2
127.0.0.1:6379> get key
(nil)
또 레디스는 TTL(Time To Live) 기능을 제공합니다.
일정 시간동안 데이터를 저장하는 기능인데요. expire 명령을 통해 데이터를 일정 시간 뒤에 만료시키는 방식으로 기능이 동작합니다.
위의 블록을 보시면, 저는 key,value로 (key,value)를 저장했고 만료시간을 10초로 지정(expire key 10)했는데
10초 경과 후 get 명령을 통해 조회해보면 데이터가 사라진 것을 볼 수 있어요.
+
사실 엄연히 따지면 저희가 expire 명령을 내리고 그 시간이 지나자마자 바로 데이터가 삭제되는게 아니고, 조회한 시점에 삭제되긴 합니다.(수동적 만료) 그 외에도 사용자가 레디스가 내부적으로 주기적으로 랜덤한 키들을 조회해 만료된 키들을 제거하는 방식(능동적 만료)도 함께 진행되어, TTL 기능이 동작합니다.
2. 로컬 개발환경에서 Redis 사용하기
레디스는 AWS 측 인프라 구성 시, 이미 ElastiCache 제품을 통해 구성을 했고(이 글은 이전의 AWS 인프라 구성 글쪽에 있습니다.) 저는 Redis를 로컬 개발 환경에 구성해볼게요.
Docker Hub 쪽에서 Redis 리포지토리를 확인해보면, 7.4.1 이 지금 사용하기 제일 무난한 것 같습니다.
8버전은 아직 실험적 성향이 강한 것 같고요.
docker pull redis:7.4.1
docker pull 명령을 통해 7.4.1 버전의 레디스 이미지를 가져왔습니다.
mkdir -p ~/docker/redis-compose
vim ~/docker/redis-compose/docker-compose-redis.yml
경로 하나를 만들고 도커 컴포즈 파일을 하나 만들겠습니다.
version: "3.8"
services:
redis:
image: redis:7.4.1
container_name: redis
ports:
- 6379:6379
volumes:
- C:\Users\ttasjwi\data\redis\data:/data
- C:\Users\ttasjwi\data\redis\conf\redis.conf:/usr/local/conf/redis.conf
restart: always
1. image: 이미지를 지정했습니다. 여기서는 redis:7.4.1
2. container_name: 컨테이너 이름을 지정했습니다.
3. ports: 외부포트 6379에 컨테이너 내부포트 6379를 바인딩했습니다.
4. volumes : 외부 디렉토리를 내부 디렉토리에 바인딩했습니다.
5. restart: 로컬 컴퓨터가 켜질 때(윈도우는 도커데스크탑 실행 시) 자동으로 컨테이너가 실행됩니다.
docker compose -f ~/docker/redis-compose/docker-compose-redis.yml up -d
이 명령을 통해 도커 컴포즈를 실행하여, 도커 컨테이너를 실행합니다.
docker exec -it redis bash
redis 컨테이너를 실행해서 접속합니다.
$ redis-cli
127.0.0.1:6379>
redis-cli 명령을 입력하면 레디스를 사용할 수 있습니다.
3. 레디스 접근설정
3.1 의존성
SPRING_BOOT_DATA_REDIS(groupId = "org.springframework.boot", artifactId = "spring-boot-starter-data-redis"),
// jackson date time
JACKSON_DATETIME(groupId = "com.fasterxml.jackson.datatype", artifactId ="jackson-datatype-jsr310"),
레디스를 위해 새로 추가해야하는 의존성들입니다.
스프링 부트 레디스 : "org.springframework.boot:spring-boot-starter-data-redis"
JACKSON_DATETIME: "com.fasterxml.jackson.datatype:jackson-datatype-jsr310"
여기서 JACKSON_DATETIME 은 ZonedDateTime 과 같은 자료형을 JACKSON(json 직렬화 도구) 를 통해 직렬화/역직렬화할 때 필요한 의존성입니다.
3.2 external-redis 모듈
dependencies {
implementation(Dependencies.SPRING_BOOT_DATA_REDIS.fullName)
implementation(Dependencies.KOTLIN_JACKSON.fullName)
implementation(Dependencies.JACKSON_DATETIME.fullName)
implementation(project(":board-system-domain:domain-core"))
implementation(project(":board-system-domain:domain-member"))
implementation(project(":board-system-domain:domain-auth"))
testImplementation(testFixtures(project(":board-system-domain:domain-core")))
testImplementation(testFixtures(project(":board-system-domain:domain-member")))
testImplementation(testFixtures(project(":board-system-domain:domain-auth")))
}
external-redis 모듈의 의존성들입니다.
spring_boot_data_redis, kotlin-jackson(kotlin 에서 jackson objectMapper를 쓰기 위함), jackson_datetime 의존성을 추가했습니다.
3.3 설정파일
spring:
config:
activate:
on-profile: productionSecret
data:
redis:
host: [ElastiCache 주소]
port: 6379
설정 파일에서는 각 프로필 별 Redis 접근 설정을 넣어줍니다.
저의 경우 local, test, production 환경 별 접근 설정을 따로 넣어줬어요. 프로덕션용 레디스 접근설정파일은 반드시 .gitignore 처리 해야합니다.
@ConfigurationProperties(prefix = "spring.data.redis")
public class RedisProperties {
/**
* Database index used by the connection factory.
*/
private int database = 0;
/**
* Connection URL. Overrides host, port, username, and password. Example:
* redis://user:password@example.com:6379
*/
private String url;
/**
* Redis server host.
*/
private String host = "localhost";
/**
* Login username of the redis server.
*/
private String username;
/**
* Login password of the redis server.
*/
private String password;
/**
* Redis server port.
*/
private int port = 6379;
참고로 저는 여기서 spring.data.redis 로 설정해야하는 부분을 spring.redis로 설정하고 배포했다가 실패했었는데요. spring.data.redis 로 시작하게 설정해야합니다!
이유는, 스프링이 자동구성 설정으로 사용하는 RedisProperties 에서 기본값으로 spring.data.redis 설정값을 찾고, 없으면 localhost 6379를 넣어주기 때문인데요. spring.redis 로 설정할 때는 몰랐지만 실제 배포했을 때는 host, port 가 달라서 localhost 6379가 그대로 주입되고, 레디스를 못 찾아서 문제가 터지더라구요. spring.data.redis로 시작하게 설정을 작성해야합니다!
3.4 빌드/배포 스크립트
- name: Redis 설정
uses: supercharge/redis-github-action@1.7.0
with:
redis-version: 7.4.1
- name: Java 21 설정
uses: actions/setup-java@v4
with:
distribution: 'temurin'
java-version: '21'
테스트코드에서 Redis 를 써야하는데 GitHub Actions 환경에선 Redis 설정이 되어 있지 않습니다.
GitHub Actions 환경에서 Redis를 사용할 수 있도록 액션을 새로 추가해줬습니다.
- name: 도커 이미지 풀링 및 컨테이너 실행
run: |
ssh board-ec2 << 'EOF'
set -e
# 필요한 설정 파일들을 서버로 복사합니다.
echo "${{ secrets.DB_CONFIG_PRODUCTION_YML }}" | base64 --decode > /home/ec2-user/config/board-system/db-config-production.yml
echo "${{ secrets.REDIS_CONFIG_PRODUCTIONSECRET_YML }}" | base64 --decode > /home/ec2-user/config/board-system/redis-config-productionSecret.yml
echo "${{ secrets.EMAIL_SEND_CONFIG_PRODUCTIONSECRET_YML }}" | base64 --decode > /home/ec2-user/config/board-system/email-send-config-productionSecret.yml
echo "${{ secrets.PUBLIC_KEY_PRODUCTIONSECRET_PEM }}" > /home/ec2-user/config/board-system/public_key_productionSecret.pem
echo "${{ secrets.PRIVATE_KEY_PRODUCTIONSECRET_PEM }}" > /home/ec2-user/config/board-system/private_key_productionSecret.pem
if [ "${{ env.TARGET_UPSTREAM }}" = "blue" ]; then
echo "${{ secrets.DEPLOY_CONFIG_BLUE_YML }}" | base64 --decode > /home/ec2-user/config/board-system/deploy-config-blue.yml
else
echo "${{ secrets.DEPLOY_CONFIG_GREEN_YML }}" | base64 --decode > /home/ec2-user/config/board-system/deploy-config-green.yml
fi
docker pull ${{ secrets.DOCKERHUB_USERNAME }}/board-be:latest
docker compose -f docker-compose-${{ env.TARGET_UPSTREAM }}.yml up -d
EOF
또, 배포 환경에서 필요한 설정파일을 끌어둘 수 있도록 설정파일 복사 명령도 추가했습니다.
4. 레디스를 사용하여 데이터 저장/수정/삭제하기
본격적으로 레디스를 이용해 데이터를 저장해보겠습니다.
RefreshTokenHolder, EmailVerification 을 관리하는데 사실상 구현 방법이 거의 똑같기 때문에 EmailVerification 기준으로 설명할게요.
4.1 RedisEmailVerification
package com.ttasjwi.board.system.member.domain.service.impl.redis
import com.ttasjwi.board.system.member.domain.model.EmailVerification
import java.time.ZoneId
import java.time.ZonedDateTime
class RedisEmailVerification(
val email: String,
val code: String,
val codeCreatedAt: ZonedDateTime,
val codeExpiresAt: ZonedDateTime,
val verifiedAt: ZonedDateTime?,
val verificationExpiresAt: ZonedDateTime?
) {
companion object {
private val TIME_ZONE = ZoneId.of("Asia/Seoul")
fun from(emailVerification: EmailVerification): RedisEmailVerification {
return RedisEmailVerification(
email = emailVerification.email.value,
code = emailVerification.code,
codeCreatedAt = emailVerification.codeCreatedAt,
codeExpiresAt = emailVerification.codeExpiresAt,
verifiedAt = emailVerification.verifiedAt,
verificationExpiresAt = emailVerification.verificationExpiresAt
)
}
}
fun restoreDomain(): EmailVerification {
return EmailVerification.restore(
email = this.email,
code = this.code,
codeCreatedAt = this.codeCreatedAt.withZoneSameInstant(TIME_ZONE),
codeExpiresAt = this.codeExpiresAt.withZoneSameInstant(TIME_ZONE),
verifiedAt = this.verifiedAt?.withZoneSameInstant(TIME_ZONE),
verificationExpiresAt = this.verificationExpiresAt?.withZoneSameInstant(TIME_ZONE)
)
}
}
우선 기존에 작성해둔 EmailVerification 을 레디스에 저장하기 위한 저장모델 RedisEmailVerification 입니다.
1. RedisEmailVerification.from(...): EmailVerification 도메인 모델을 기반으로 RedisEmailVerification 을 생성합니다.
2. restoreDomain(): RedisEmailVerification 을 기반으로 도메인 객체를 복원합니다.
여기서 주의할 점은 restoreDomain 메서드 부분인데요. 이 메서드는 레디스에서 저장된 이메일 인증 객체를 다시 도메인으로 복원하는 기능인데, 레디스에 실제 저장될 때는 ZonedDateTime이 Instant 처럼 저장됩니다. 지역 시간대 정보가 사라진 채 저장되는 문제가 있어요. 그래서 withZoneSameInstant 메서드를 통해, Asia/Seoul 기준으로 다시 값을 복원해요.
4.2 RedisEmailVerificationSerializer
package com.ttasjwi.board.system.member.domain.service.impl.redis
import com.fasterxml.jackson.databind.ObjectMapper
import org.springframework.data.redis.serializer.RedisSerializer
class RedisEmailVerificationSerializer(
private val objectMapper: ObjectMapper
) : RedisSerializer<RedisEmailVerification> {
override fun serialize(value: RedisEmailVerification?): ByteArray? {
return objectMapper.writeValueAsBytes(value)
}
override fun deserialize(bytes: ByteArray?): RedisEmailVerification? {
if (bytes == null) return null
return objectMapper.readValue(bytes, RedisEmailVerification::class.java)
}
}
RedisEmailVerification 을 직렬화/역직렬화 할 때 사용하기위한 RedisEmailVerificationSerializer 입니다.
ObjectMapper 를 의존성으로 받아서, ObjectMapper를 사용해 데이터를 직렬화/역직렬화 해요. 이렇게 하면 실제 Redis에 저장할 때는 데이터가 json 형태로 저장됩니다.
4.3 RedisEmailVerificationTemplateConfig
package com.ttasjwi.board.system.core.config.redis.template
import com.fasterxml.jackson.databind.SerializationFeature
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.ttasjwi.board.system.member.domain.service.impl.redis.RedisEmailVerification
import com.ttasjwi.board.system.member.domain.service.impl.redis.RedisEmailVerificationSerializer
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.data.redis.connection.RedisConnectionFactory
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.data.redis.serializer.StringRedisSerializer
@Configuration
class RedisEmailVerificationTemplateConfig {
@Bean
fun redisEmailVerificationTemplate(redisConnectionFactory: RedisConnectionFactory): RedisTemplate<String, RedisEmailVerification> {
val redisTemplate = RedisTemplate<String, RedisEmailVerification>()
redisTemplate.connectionFactory = redisConnectionFactory
redisTemplate.keySerializer = StringRedisSerializer()
redisTemplate.valueSerializer = redisEmailVerificationSerializer()
redisTemplate.setEnableTransactionSupport(true)
return redisTemplate
}
private fun redisEmailVerificationSerializer(): RedisEmailVerificationSerializer {
val objectMapper = jacksonObjectMapper()
objectMapper.registerModules(JavaTimeModule())
.configure(SerializationFeature.WRITE_DATES_WITH_ZONE_ID, true)
return RedisEmailVerificationSerializer(objectMapper)
}
}
이메일 인증은 RedisTemplate 를 통해 저장할 예정입닏니다.
이때 사용할 RedisTemplate 설정을 등록하기 위해 작성된 설정 파일입니다.
1. RedisConnectionFactory: Redis 와 접근하는 역할을 담당합니다. 우리가 앞서 설정했던 스프링 설정파일의 host, port 정보를 사용해 자동구성됩니다.
2. StingRedisSerializer : 문자열 직렬화/역직렬화를 위한 RedisSerializer 입니다. 기본적으로 spring redis 의존성을 추가하면 사용할 수 있어요.
3. RedisEmailVerificationSerializer : 우리가 아까 만들어뒀던 레디스 이메일 인증 직렬화기/역직렬화기입니다. 여기서 objectMapper.registerModules(JavaTimeModule()) 이 설정을 해줘야, ZonedDateTime 을 제대로 저장할 수 있어요.
4.4 EmailVerificationStorage
package com.ttasjwi.board.system.member.domain.service.impl
import com.ttasjwi.board.system.member.domain.model.Email
import com.ttasjwi.board.system.member.domain.model.EmailVerification
import com.ttasjwi.board.system.member.domain.service.EmailVerificationAppender
import com.ttasjwi.board.system.member.domain.service.EmailVerificationFinder
import com.ttasjwi.board.system.member.domain.service.impl.redis.RedisEmailVerification
import org.springframework.data.redis.core.RedisTemplate
import org.springframework.stereotype.Component
import java.time.ZonedDateTime
@Component
class EmailVerificationStorage(
private val redisTemplate: RedisTemplate<String, RedisEmailVerification>
) : EmailVerificationAppender, EmailVerificationFinder {
companion object {
private const val PREFIX = "Board-System:EmailVerification:"
}
override fun append(emailVerification: EmailVerification, expiresAt: ZonedDateTime) {
val key = makeKey(emailVerification.email)
val redisModel = RedisEmailVerification.from(emailVerification)
redisTemplate.opsForValue().set(key, redisModel)
redisTemplate.expireAt(key, expiresAt.toInstant())
}
override fun removeByEmail(email: Email) {
val key = makeKey(email)
redisTemplate.delete(key)
}
override fun findByEmailOrNull(email: Email): EmailVerification? {
val key = makeKey(email)
return redisTemplate.opsForValue().get(key)?.restoreDomain()
}
private fun makeKey(email: Email): String {
return PREFIX + email.value
}
}
EmailVerificationAppender, EmailVerificationFinder 를 실제 저장/조회하는 도구입니다.
RedisTemplate 를 통해 데이터를 저장/조회/삭제 할 수 있게 했어요.
makeKey 메서드는 실제 레디스에 저장할 Key 를 만드는 부분입니다.
레디스에 이메일 인증만 저장할게 아니기 때문에 여러 타입들을 구분할 수 있도록 이런 메서드를 만들어둔거에요.
참고로 여기서 레디스에 데이터를 저장하는 append 메서드에서 expireAt 메서드를 호출했는데요. 이 메서드를 호출하면 실제 redis에 expire 명령을 내려서 해당 시점에 데이터를 만료시킬 수 있도록 해요.
4.5 테스트
package com.ttasjwi.board.system.member.domain.service.impl
import com.ttasjwi.board.system.core.time.fixture.timeFixture
import com.ttasjwi.board.system.member.domain.model.fixture.emailFixture
import com.ttasjwi.board.system.member.domain.model.fixture.emailVerificationFixtureVerified
import org.assertj.core.api.Assertions.assertThat
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.DisplayName
import org.junit.jupiter.api.Test
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.boot.test.context.SpringBootTest
import java.time.ZonedDateTime
@SpringBootTest
@DisplayName("EmailVerificationStorage(Appender, Finder) 테스트")
class EmailVerificationStorageTest @Autowired constructor(
private val emailVerificationStorage: EmailVerificationStorage
) {
companion object {
private val TEST_EMAIL = emailFixture("test12345678@test.com")
}
@AfterEach
fun removeResource() {
emailVerificationStorage.removeByEmail(TEST_EMAIL)
}
@Test
@DisplayName("이메일 인증을 저장하고 이메일로 다시 찾을 수 있다")
fun appendAndFind() {
val emailVerification = emailVerificationFixtureVerified(email = TEST_EMAIL.value)
emailVerificationStorage.append(emailVerification, ZonedDateTime.now().plusMinutes(30))
val findEmailVerification = emailVerificationStorage.findByEmailOrNull(emailVerification.email)!!
assertThat(findEmailVerification.email).isEqualTo(emailVerification.email)
assertThat(findEmailVerification.code).isEqualTo(emailVerification.code)
assertThat(findEmailVerification.codeCreatedAt).isEqualTo(emailVerification.codeCreatedAt)
assertThat(findEmailVerification.codeExpiresAt).isEqualTo(emailVerification.codeExpiresAt)
assertThat(findEmailVerification.verifiedAt).isEqualTo(emailVerification.verifiedAt)
assertThat(findEmailVerification.verificationExpiresAt).isEqualTo(emailVerification.verificationExpiresAt)
}
@Test
@DisplayName("이메일 인증을 찾지 못 했다면 null 이 반환된다")
fun notFoundNull() {
val findEmailVerification = emailVerificationStorage.findByEmailOrNull(emailFixture("jest@jest.com"))
assertThat(findEmailVerification).isNull()
}
@Test
@DisplayName("remove 테스트")
fun remove() {
val emailVerification = emailVerificationFixtureVerified(TEST_EMAIL.value)
emailVerificationStorage.append(emailVerification, timeFixture())
emailVerificationStorage.removeByEmail(emailVerification.email)
val findEmailVerification = emailVerificationStorage.findByEmailOrNull(emailVerification.email)
assertThat(findEmailVerification).isNull()
}
}
실제 테스트코드입니다. 스프링부트 테스트를 수행하도록 했어요.
테스트 수행을 끝날 때마다, 다음 테스트를 원자적으로 실행할 수 있도록
@AfterEach 를 사용해, 메 테스트가 끝날 때마다 emailVerificationStorage.removeByEmail 를 호출하도록 했습니다.
실제 테스트가 잘 돌아갑니다.
5. 아키텍처
저는 기존에 이메일 인증, 리프레시토큰 홀더 저장하는 로직을 메모리에 저장하는 로직으로 구현했었는데요.
이번에 레디스로 저장하는 코드를 바꾸는 과정에서 실제 저장을 담당하는 부분이 코드가 바뀌긴 했지만 도메인/애플리케이션 로직을 건드는 코드는 변경하지 않았습니다.
인터페이스의 구현체 코드가 바뀌었지 인터페이스가 바뀌지 않았기 때문입니다.
기술이 바뀐다고해서, 기술을 사용하는 도메인/애플리케이션 로직이 변하는 일은 없어야합니다.
OOP의 5대원칙인 SOLID 에서 OCP(Open-Closed Principle, 개방폐쇄원칙)을 준수합니다.
(+ 하나 사실 변경한게 있긴한데, 리프레시토큰 홀더 삭제 인터페이스를 추가한 부분이 있긴합니다. 이건 기술변경을 이유로 추가한게 아니긴 합니다.)
6. 실행
실제로 배포 환경에 대해 API들을 호출해보니 잘 작동합니다. 레디스 설정이 잘 됐습니다.
이렇게, 기능을 적용하여 레디스에 이메일 인증, 리프레시토큰 홀더와 같이 일정시간동안만 유효해야하는 데이터를 원하는 시간동안 저장할 수 있게 됐습니다.
이어지는 글들에서 추가적인 기능을 더 다뤄보겠습니다.
읽어주셔서 감사합니다!
GitHub 리포지토리
https://github.com/ttasjwi/board-system
관련 PR
Redis 연동
https://github.com/ttasjwi/board-system/pull/49
'Project' 카테고리의 다른 글
[토이프로젝트] 게시판 시스템(board-system) 21. 리프레시토큰을 통한 토큰 재갱신 기능 구현 (0) | 2024.11.19 |
---|---|
[토이프로젝트] 게시판 시스템(board-system) 20. 스프링 시큐리티와 사용자 인증/인가 (2) | 2024.11.18 |
[토이프로젝트] 게시판 시스템(board-system) 18. 로그인 API 구현 - (2) JWT 기술 적용 (0) | 2024.11.14 |
[토이프로젝트] 게시판 시스템(board-system) 17. 로그인 API 구현 - (1) 설계 (0) | 2024.11.13 |
[토이프로젝트] 게시판 시스템(board-system) 16. 회원가입 API 구현 (0) | 2024.11.13 |