액세스 토큰 하나로는 왜 부족한가 — 리프레시 토큰과 탈취 대응
5장 끝에서 던진 질문을 다시 가져오겠습니다. 로그아웃한 사용자의 JWT를 무효화할 방법이 없다면, 그 토큰은 서버가 뭘 하든 만료 시각까지는 계속 유효합니다. 이 문제에 대한 가장 흔한 대응이 "액세스 토큰의 유효기간을 아주 짧게 잡는" 것입니다. 30분, 심하면 5분. 그리고 그 짧은 만료를 사용자가 매번 다시 로그인하지 않고도 넘길 수 있도록 리프레시 토큰을 따로 발급합니다.
액세스 토큰과 리프레시 토큰의 역할 분담
액세스 토큰은 API 요청마다 실려가는, 수명이 짧고 검증이 가벼운 토큰입니다. 리프레시 토큰은 액세스 토큰을 재발급받을 때만 쓰이는, 수명이 길고(며칠~몇 주) 훨씬 신중하게 다뤄야 하는 토큰입니다.
sequenceDiagram participant C as 클라이언트 participant S as 서버 participant R as Redis C->>S: 로그인 성공 S->>R: refreshToken 저장 (userId 매핑) S-->>C: accessToken(30분) + refreshToken(2주) Note over C: 30분 뒤 accessToken 만료 C->>S: POST /reissue (refreshToken) S->>R: 저장된 refreshToken과 일치하는지 확인 R-->>S: 일치 S-->>C: 새 accessToken + 새 refreshToken
핵심은 리프레시 토큰은 그냥 발급하고 끝이 아니라 서버가 어딘가에 기억해두고 있다가 재발급 시 대조한다는 점입니다. 액세스 토큰과 다르게 리프레시 토큰은 상태를 갖습니다. 순수 JWT의 "서버가 상태 없이 검증"이라는 장점을 포기하는 대신, 로그아웃이나 탈취 시 무효화할 수단을 얻는 거래입니다.
@Service
public class TokenReissueService {
private final RedisTemplate<String, String> redisTemplate;
private final JwtProvider jwtProvider;
public TokenPair reissue(String refreshToken) {
Claims claims = jwtProvider.parseClaims(refreshToken); // 서명·만료만 검증
String username = claims.getSubject();
String stored = redisTemplate.opsForValue().get("refresh:" + username);
if (stored == null || !stored.equals(refreshToken)) {
// 저장된 값과 다르다 = 이미 재발급에 쓰였거나, 탈취된 토큰이 재사용됐다는 신호
throw new InvalidTokenException("재로그인이 필요합니다");
}
String newAccessToken = jwtProvider.createAccessToken(username);
String newRefreshToken = jwtProvider.createRefreshToken(username);
redisTemplate.opsForValue().set("refresh:" + username, newRefreshToken, Duration.ofDays(14));
return new TokenPair(newAccessToken, newRefreshToken);
}
}리프레시 토큰 회전(Rotation)이 왜 필요한가
위 코드에서 재발급할 때마다 리프레시 토큰 자체도 새로 발급하고 있습니다. 이걸 리프레시 토큰 회전이라고 부릅니다. 굳이 액세스 토큰만 갈아주면 될 걸 리프레시 토큰까지 매번 바꾸는 이유는 탈취 탐지 때문입니다.
공격자가 리프레시 토큰을 훔쳤다고 가정해봅시다. 회전이 없다면 공격자는 그 토큰이 만료될 때까지 계속 액세스 토큰을 재발급받아 쓸 수 있고, 정상 사용자는 이상을 알아챌 방법이 없습니다. 회전이 있다면 상황이 다릅니다 — 정상 사용자든 공격자든 먼저 재발급을 요청하는 쪽이 새 리프레시 토큰을 받고, 저장소의 값이 갱신됩니다. 그러면 늦게 요청한 쪽(원래 사용자든 공격자든)의 토큰은 저장된 값과 달라져 거부됩니다. 이때 "내가 안 썼는데 내 토큰이 무효화됐다"는 신호는 곧 탈취가 의심된다는 신호이므로, 이 시점에 강제로 전체 세션을 로그아웃시키는 정책을 추가할 수 있습니다.
저장 위치: 왜 Redis인가
리프레시 토큰을 데이터베이스 테이블에 저장해도 동작은 합니다. 다만 만료(TTL)를 자동으로 처리해주고, 조회 자체가 매 재발급 요청마다 일어나는 짧은 키-값 조회라는 점에서 Redis 같은 인메모리 저장소가 자연스럽게 어울립니다. SET refresh:user123 <token> EX 1209600 한 줄로 저장과 만료를 동시에 처리할 수 있고, RDB에 부담을 주지 않습니다.
로그아웃은 결국 저장소에서 지우는 것
JWT 자체는 무효화가 안 된다고 앞서 말했지만, 리프레시 토큰이 상태를 갖는 순간 로그아웃도 의미를 갖게 됩니다. 로그아웃 API는 Redis에서 해당 사용자의 리프레시 토큰을 삭제하기만 하면 됩니다. 이미 발급된 액세스 토큰은 남은 수명(최대 30분) 동안은 여전히 유효하지만, 그 짧은 창을 감수하는 게 이 구조의 트레이드오프입니다. 즉시 무효화가 반드시 필요한 서비스(뱅킹 앱의 강제 로그아웃 같은)라면 액세스 토큰 검증 시마다 블랙리스트도 함께 확인해야 하는데, 그건 사실상 "상태를 안 갖는다"는 JWT의 이점을 액세스 토큰에서도 포기하는 것과 같습니다. 그 정도의 즉시성이 정말 필요한지부터 따져보시길 권합니다.