땃쥐네

[토이프로젝트] 게시판 시스템(board-system) 19. Redis 연동 본문

Project

[토이프로젝트] 게시판 시스템(board-system) 19. Redis 연동

ttasjwi 2024. 11. 15. 16:37

 

이번 글에서는 이전부터 미뤘던

이메일 인증, 리프레시토큰 홀더의 Redis 저장 설정을 해보겠습니다.


1.  Redis

출처 : https://architecturenotes.co/p/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

 

GitHub - ttasjwi/board-system

Contribute to ttasjwi/board-system development by creating an account on GitHub.

github.com

 

관련 PR

Redis 연동

https://github.com/ttasjwi/board-system/pull/49

 

Feature: (BRD-77) Redis 연동 by ttasjwi · Pull Request #49 · ttasjwi/board-system

JIRA 티켓 BRD-77 작업 내역 저장소에서 RefreshTokenHolder 제거 기능 추가 레디스 연동 이메일 인증 리프레시토큰 홀더

github.com


 

Comments