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

//레디스 설치
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
build.gradle.ktsdependencies {
implementation("org.springframework.boot:spring-boot-starter-data-redis")
}
src/main/resources/application.yml
spring:
data:
redis:
host: localhost
port: 6379
Spring Boot는 기본적으로 RedisTemplate<Object, Object> 와 StringRedisTemplate 를 자동 빈으로 등록합니다.
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 설정
}
}
JdkSerializationRedisSerializer) 를 사용StringRedisTemplate
@Bean
fun stringRedisTemplate(connectionFactory: RedisConnectionFactory): StringRedisTemplate =
StringRedisTemplate(connectionFactory)
대부분 Redis 에서 문자열로 저장하는 경우가 많기 때문에 별도의 객체를 캐싱하려는 용도가 아니면 잘 구현되어있는 StringRedisTemplate 을 사용하는게 편합니다
(공식문서에서도 문자열 작업을 많이 한다면 전용 클래스인 StringRedisTemplate 을 사용하는 것이 더 적합하다고 추천해줍니다.)
객체를 레디스에 넣으려고 기본 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 을 따로 만들어야 할 것 같습니다.
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)
}