[kotlin] JWT + Redis 로 Refresh Token 저장 및 재발급/로그아웃 구현하기

dustle·2025년 8월 29일

kotlog

목록 보기
10/11

JWT를 쓰면서 로그아웃을 제대로 처리하려면 Refresh Token(RT) 을 서버 쪽에서 회수할 수 있어야 합니다.
초기에는 DB를 쓰지 않았고, 이번에 Redis를 도입해 빠르고 간단하게 RT를 저장/삭제(로그아웃)하도록 구성했습니다.


redis 설치 (macOS)

//레디스 설치
brew install redis

//실행
brew services start redis

//실행 확인
redis-cli ping
PONG

//현재 들어오는 모든 요청 보기
redis-cli monitor

//set
redis-cli set hello world
OK

//get
redis-cli get hello
world

의존성 / 설정

  • 1) build.gradle.kts
dependencies {
    implementation("org.springframework.boot:spring-boot-starter-data-redis")
}
  • 2) 애플리케이션 설정

src/main/resources/application.yml

spring:
  data:
    redis:
      host: localhost
      port: 6379

Spring Boot는 기본적으로 RedisTemplate<Object, Object>StringRedisTemplate 를 자동 빈으로 등록합니다.


StringRedisTemplate, RedisTemplate<K, V> 의 차이

StringRedisTemplate 은 RedisTemplate<K, V> 를 기반으로 만들어진 문자열 전용 클래스입니다.
Redis 서버와 커넥션을 관리하고, opsForValue, opsForHash, opsForSet 같은 연산 API를 제공 받습니다.

RedisTemplate<K, V>

@Bean
fun connectionFactory(): RedisConnectionFactory = LettuceConnectionFactory()

@Bean
fun redisTemplate(): RedisTemplate<String, Any> {
    return RedisTemplate<Any, Any>().apply {
            this.setConnectionFactory(connectionFactory())
			
            // ... Serializer 설정
        }
}
  • RedisTemplate<K, V> 는 Redis 데이터 접근 코드를 단순화해주는 헬퍼 클래스
  • Redis에 객체를 저장할 때, 객체 ↔ Redis 내부의 이진 데이터 간 자동 직렬화/역직렬화를 수행
  • 기본적으로 객체 직렬화에는 Java 직렬화(JdkSerializationRedisSerializer) 를 사용

StringRedisTemplate

@Bean
fun stringRedisTemplate(connectionFactory: RedisConnectionFactory): StringRedisTemplate =
    StringRedisTemplate(connectionFactory)
  • RedisTemplate<String, String> 의 특수화된 서브클래스
  • 제네릭 고정: <String, String>
  • 직렬화기 key, value, hashKey, hashValue 모두 StringRedisSerializer 로 세팅되어 있음

대부분 Redis 에서 문자열로 저장하는 경우가 많기 때문에 별도의 객체를 캐싱하려는 용도가 아니면 잘 구현되어있는 StringRedisTemplate 을 사용하는게 편합니다

(공식문서에서도 문자열 작업을 많이 한다면 전용 클래스인 StringRedisTemplate 을 사용하는 것이 더 적합하다고 추천해줍니다.)


객체 RedisTemplate 주입 시 cli 에서 바이트로 보이는 문제

객체를 레디스에 넣으려고 기본 RedisTemplate 을 사용할 때 사용자가 커스텀해주지 않으면 바이트 값들이 cli 에서 보여지게 됩니다.

  • 빈 설정하지 않고 테스트
	val redisTemplate: RedisTemplate<Any, Any>	

    data class User(
        val name: String,
        val age: Long,
	): Serializable

    test("객체 테스트") {
        val key = "test:user"
        val user = User(
            name = "test",
            age = 42
        )

        redisTemplate.opsForValue().set(key, user)
        
        val stored = redisTemplate.opsForValue().get(key) as User
        println("stored = $stored")
    }

cli 에서 에러가 날 뿐 앱 코드에선 같은 직렬화기로 역직렬화하니까 정상적으로 객체가 찾아집니다.

하지만 모니터링과 디버깅의 가독성도 문제이며 공식 문서도 자바 네이티브 직렬화는 피하라고 권고합니다(신뢰 못하는 입력 역직렬화 취약점)

만일 객체를 사용해야한다면 커스텀 RedisTemplate 을 따로 만들어야 할 것 같습니다.


RefreshTokenStore 구현

RT 는 보안을 위해 SHA-512 를 통해 해시 처리 후 저장했습니다.

key 는 rt:userId 형식을 사용하였고
Redis 에 저장할 RT 의 CRD 기능을 만들었습니다.

@Component
class RefreshTokenStore(
    private val redis: StringRedisTemplate,
) {
    private fun key(userId: Long) = "rt:$userId"

    fun getSHA512(input: String): String {
        val md: MessageDigest = MessageDigest.getInstance("SHA-512")
        val messageDigest = md.digest(input.toByteArray())

        return messageDigest.joinToString("") { "%02x".format(it) }
    }

    fun save(
        userId: Long,
        refreshToken: String,
        ttl: Long,
    ) {
        val hash = getSHA512(refreshToken)

        redis.opsForValue().set(key(userId), hash, ttl, TimeUnit.SECONDS)
    }

    fun delete(userId: Long) {
        redis.delete(key(userId))
    }

    fun matchRefreshToken(
        refreshToken: String,
        redisRefreshToken: String,
    ): Boolean = redisRefreshToken == getSHA512(refreshToken)

    fun getHash(userId: Long): String? = redis.opsForValue().get(key(userId))
}

로그인 적용

    fun login(loginCommand: LoginCommand): LoginResult {
        val user = userRepository.findByUsername(loginCommand.username)
            ?: throw IllegalArgumentException("Username not found")

        verifyPassword(loginCommand.password, user.password)

        val accessToken = tokenService.generateAccessToken(user.id)
        val refreshToken = tokenService.generateRefreshToken(user.id)

		//RT 레디스 저장
        refreshTokenStore.save(user.id, refreshToken, REDIS_REFRESH_TOKEN_EXPIRE_SECONDS)

        return LoginResult(
            accessToken = accessToken.token,
            refreshToken = refreshToken,
            expiresIn = accessToken.expiresIn
        )
    }

    fun reissue(refreshToken: String): LoginResult {
        tokenService.validateRefreshToken(refreshToken)
        val userId = tokenService.extractUserId(refreshToken)

		//레디스 userId 키로 확인
        val hash = refreshTokenStore.getHash(userId) ?: throw FrontException(FrontErrorCode.REFRESH_TOKEN_NOT_FOUND)

		//같은 RT 인지 확인
        if (!refreshTokenStore.matchRefreshToken(refreshToken, hash)) {
            throw FrontException(FrontErrorCode.REFRESH_TOKEN_NOT_MATCH)
        }

        val accessToken = tokenService.generateAccessToken(userId)
        val refreshToken = tokenService.generateRefreshToken(userId)

		//새로운 RT Redis 저장
        refreshTokenStore.save(userId, refreshToken, REDIS_REFRESH_TOKEN_EXPIRE_SECONDS)

        return LoginResult(
            accessToken = accessToken.token,
            refreshToken = refreshToken,
            expiresIn = accessToken.expiresIn
        )
    }
    
    fun logout(userId: Long) {
        refreshTokenStore.delete(userId)
    }

0개의 댓글