이전 글에서 JWT 기반 인증 시스템을 만들었다. 근데 실제로 써보면 금방 문제가 생긴다.
Access Token 만료 시간을 30분으로 잡았는데, 사용자가 결제 앱을 쓰다가 30분마다 다시 로그인해야 하면 미친 거다. 그렇다고 만료 시간을 7일로 늘리면 토큰이 탈취됐을 때 7일 동안 무방비 상태가 된다. 금융 앱에서 그건 말이 안 된다.
그래서 Refresh Token을 도입했고, 이걸 Redis에 저장해서 관리하기로 했다.
AccessToken : 짧은 수명(30분). API 호출에 사용. 만료되면 Refresh Token : 긴 수명(7일). Access Token 재발급 용도로만 사용. Redis에 저장.
Refresh Token을 DB(PostgreSQL)에 저장해도 되긴 하는데, Redis를 선택한 이유가 있다.
1. TTL 자동 만료 : Redis는 키에 만료 시간을 설정하면 알아서 삭제해준다. DB면 만료된 토큰을 주기적으로 지워주는 배치 작업이 필요하다.
2. 속도 : 매 요청 마다 블랙리스트를 체크해야 하는데, DB를 조회하면 느리다. Redis는 메모리 기반이라 조회가 거의 즉시 이루어진다.
3. 단순한 구조 : key-value만 있으면 되는데 굳이 테이블을 만들 필요가 없다.
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, String> redisTemplate(RedisConnectionFactory connectionFactory) {
RedisTemplate<String, String> template = new RedisTemplate<>();
template.setConnectionFactory(connectionFactory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new StringRedisSerializer());
return template;
}
}
기본 RedisTemplate은 JAVA 직렬화를 사용하는데, 그러면 Redis CLI에서 키를 조회했을때, \xac\xed\x00\x05t... 이런식으로 보여서 디버깅이 지옥이다...
StringRedisSerializer를 쓰면 refresh:hong123@ssafy.co.kr 이렇게 사람이 읽을 수 있는 형태로 저장된다.
# Refresh Token 저장
key: "refresh:hong123@ssafy.co.kr"
value: "eyJhbGciOiJIUzM4NCJ9..."
TTL: 604800000ms (7일)
# 블랙리스트 (로그아웃한 Access Token)
key: "blacklist:eyJhbGciOiJIUzM4..."
value: "logout"
TTL: 남은 만료시간
userId별로 Refresh Token이 하나만 존재한다. 재로그인하면 기존 토큰이 덮어씌워지니까, 이전 기기에서의 세션은 자동으로 무효화된다. 별도로 "단일 기기 로그인" 로직을 만들 필요가 없다.
public LoginResponse refresh(RefreshTokenRequest request) {
String refreshToken = request.getRefreshToken();
// 1) JWT 자체 유효성 검증 (서명, 만료)
if (!jwtTokenProvider.validateToken(refreshToken)) {
throw new AuthenticationFailedException("만료되었거나 유효하지 않은 리프레시 토큰입니다.");
}
// 2) 토큰에서 userId 추출
String userId = jwtTokenProvider.getUserId(refreshToken);
// 3) Redis에 저장된 RefreshToken과 비교
String savedToken = redisTemplate.opsForValue().get("refresh:" + userId);
if (savedToken == null || !savedToken.equals(refreshToken)) {
throw new AuthenticationFailedException("유효하지 않은 리프레시 토큰입니다.");
}
// 4) DB에서 사용자 조회
User user = userRepository.findByUserId(userId)
.orElseThrow(() -> new AuthenticationFailedException("사용자를 찾을 수 없습니다."));
// 5) 새 토큰 발급
String newAccessToken = jwtTokenProvider.createAccessToken(user.getUserId(), user.getUserKey());
String newRefreshToken = jwtTokenProvider.createRefreshToken(user.getUserId(), user.getUserKey());
// 6) Redis에 새 RefreshToken 저장 (기존 토큰 교체)
redisTemplate.opsForValue().set(
"refresh:" + user.getUserId(),
newRefreshToken,
refreshTokenExpiry,
TimeUnit.MILLISECONDS
);
return LoginResponse.of(user, newAccessToken, newRefreshToken);
}
여기서 중요한 건 3단계다. JWT 서명이 유효하더라도 Redis에 저장된 토큰과 일치하지 않으면 거부한다.
이게 왜 필요하냐면:
사용자가 다른 기기에서 로그인하면 Redis의 Refresh Token이 바뀐다.
이전 기기의 Refresh Token의 JWT 자체는 유효하지만, Redis에 있는 값과는 다르니까 재발급이 거부된다.
결과적으로, 마지막에 로그인한 기기만 유효하다.
그리고 재발급할 때 Refresh Token도 같이 새로 발급한다. 이걸 Refresh Token Rotation이라고 하는데, Refresh Token이 탈취되더라도 정당한 사용자가 먼저 재발급하면 탈취된 토큰은 Redis의 값과 달라지니까 무효화된다.
public void logout(RefreshTokenRequest request, String accessToken) {
String refreshToken = request.getRefreshToken();
if (!jwtTokenProvider.validateToken(refreshToken)) {
throw new AuthenticationFailedException("유효하지 않은 리프레시 토큰입니다.");
}
String userId = jwtTokenProvider.getUserId(refreshToken);
// Redis에서 RefreshToken 삭제
redisTemplate.delete("refresh:" + userId);
// AccessToken 블랙리스트 등록 (남은 만료시간만큼 TTL 설정)
long remainTime = jwtTokenProvider.getRemainingTime(accessToken);
if (remainTime > 0) {
redisTemplate.opsForValue().set(
"blacklist:" + accessToken,
"logout",
remainTime,
TimeUnit.MILLISECONDS
);
}
}
로그아웃은 두가지를 처리한다.
1. Refresh Token 삭제 : Redis에서 Refresh Token을 지우면 더 이상 재발급 불가.
2. Access Token 블랙리스트 : Access Token은 만료될 때 까지 유효한데, 로그아웃 후에도 사용 가능하면 안 된다. 그래서 남은 만료 시간만큼 Redis에 블랙리스트로 등록한다. TTL을 남은 시간으로 설정하면 만료 시점에 자동으로 삭제되니까 Redis 메모리도 낭비되지 않는다.
그리고 JwtAuthenticationFilter에서 매 요청마다 이 블랙리스트를 체크한다:
if (Boolean.TRUE.equals(redisTemplate.hasKey("blacklist:" + token))) {
filterChain.doFilter(request, response);
return; // 인증 처리 없이 그냥 통과 → 401
}
redisTemplate.hasKey()의 반환값이 Boolean(wrapper)이라 null이 될 수 있다. Boolean.TRUE.equals()로 감싸지 않으면 NullPointerException이 날 수 있다. 처음에 if(redisTemplate.hasKey(...)) 이렇게 했다가 경고가 떠서 수정했다.
application.yaml에서 만료 시간을 밀리초로 설정했는데, Redis에 저장할때 TimeUnit.SECONDS로 잘못 넣었다. 7일이 아니라 7000일 짜리 토큰이 생겨버렸다. 단위를 항상 확인하자.
Redis를 도입하면서 JWT의 한계를 보완할 수 있었다.
