JWT Refresh Token은 액세스 토큰을 짧게 유지하면서도 사용자를 자주 로그아웃시키지 않기 위한 핵심 장치입니다. 앞서 세션 방식을 JWT로 전환하면서 액세스 토큰만 발급하면, “토큰 만료 시간을 길게 잡으면 탈취 위험이 커지고, 짧게 잡으면 사용자가 자꾸 로그인해야 하는” 딜레마에 부딪힙니다. 리프레시 토큰은 바로 이 문제를 풀기 위한 구조입니다.
그런데 실전에서 막상 도입하려 하면 의문이 쏟아집니다. 리프레시 토큰은 어디에 저장해야 하는지, 재발급은 어떤 흐름으로 처리하는지, 그리고 가장 중요한 토큰이 탈취되면 어떻게 막을 것인지입니다. 액세스 토큰은 만료가 짧아 위험이 제한적이지만, 수명이 긴 리프레시 토큰은 탈취당하면 피해가 훨씬 큽니다.
이 글에서는 JWT Refresh Token의 저장 전략부터 재발급 흐름, 그리고 재발급 토큰 회전(RTR)을 활용한 탈취 대응까지 운영 관점에서 정리합니다.
Access Token과 Refresh Token의 역할 분리
토큰 두 개를 쓰는 이유는 수명과 책임을 분리하기 위해서입니다. 각 토큰은 명확히 다른 목적을 가집니다.
액세스 토큰은 실제 API 요청을 인증하는 토큰으로, 수명을 짧게(보통 5~30분) 가져갑니다. 매 요청에 실려 다니므로 노출 위험이 크지만, 만료가 짧아 탈취되어도 피해 시간이 제한됩니다. 리프레시 토큰은 액세스 토큰이 만료됐을 때 새 액세스 토큰을 받아오는 용도로만 쓰이며, 수명이 깁니다(보통 1~2주).
| 구분 | Access Token | Refresh Token |
|---|---|---|
| 용도 | API 요청 인증 | 액세스 토큰 재발급 |
| 수명 | 짧음 (5~30분) | 김 (1~2주) |
| 전송 빈도 | 매 요청 | 재발급 시에만 |
| 탈취 위험도 | 낮음(짧은 수명) | 높음(긴 수명) |
핵심은 리프레시 토큰을 재발급 엔드포인트에서만 사용한다는 점입니다. 일반 API 요청에는 절대 싣지 않아 노출 빈도를 최소화하는 것이 설계의 출발점입니다.
리프레시 토큰은 어디에 저장해야 하는가
리프레시 토큰 저장은 클라이언트 저장 위치와 서버 저장 여부라는 두 축으로 나눠 생각해야 합니다.
클라이언트 측에서는 localStorage보다 HttpOnly + Secure 쿠키가 권장됩니다. localStorage는 자바스크립트로 접근 가능해 XSS 공격에 토큰이 그대로 노출되는 반면, HttpOnly 쿠키는 스크립트가 읽을 수 없어 XSS 탈취를 차단합니다. 여기에 SameSite 속성으로 CSRF 위험까지 완화할 수 있습니다.
서버 측에서는 리프레시 토큰을 저장소(주로 Redis)에 보관하는 것이 실무 정석입니다. JWT의 장점이 무상태(stateless)인데 굳이 저장하는 이유는, 서버가 토큰을 무효화할 수단이 필요하기 때문입니다. 저장해 두지 않으면 탈취된 리프레시 토큰을 강제로 폐기할 방법이 없습니다.
Redis에 userId 또는 토큰 식별자를 키로 저장하면, 만료 시간(TTL)을 토큰 수명과 맞춰 자동 정리할 수 있고 로그아웃 시 즉시 삭제도 가능합니다. 분산 환경에서 모든 인스턴스가 같은 저장소를 바라본다는 점도 Redis를 선택하는 이유입니다.
액세스 토큰 재발급 흐름
재발급 흐름은 액세스 토큰 만료 → 리프레시 토큰으로 재발급 요청 → 검증 후 새 토큰 발급의 순서로 진행됩니다.
전체 동작을 단계별로 정리하면 다음과 같습니다.
- 클라이언트가 API를 요청했는데 액세스 토큰이 만료되어
401응답을 받습니다. - 클라이언트는 리프레시 토큰을 담아
/auth/reissue같은 재발급 엔드포인트를 호출합니다. - 서버는 리프레시 토큰의 서명과 만료를 검증하고, 저장소에 보관된 토큰과 일치하는지 대조합니다.
- 검증을 통과하면 새 액세스 토큰을 발급해 응답합니다.
public TokenResponse reissue(String refreshToken) {
// 1. 토큰 자체 검증 (서명·만료)
jwtProvider.validate(refreshToken);
Long userId = jwtProvider.getUserId(refreshToken);
// 2. 저장소의 토큰과 대조
String saved = redisTemplate.opsForValue().get("RT:" + userId);
if (saved == null || !saved.equals(refreshToken)) {
throw new InvalidTokenException("유효하지 않은 리프레시 토큰입니다.");
}
// 3. 새 토큰 발급
return jwtProvider.issueTokens(userId);
}
여기서 저장소와의 대조 단계가 핵심입니다. 토큰 서명만 검증하면 탈취된 토큰도 통과하지만, 서버에 저장된 값과 비교하면 강제 폐기된 토큰을 걸러낼 수 있습니다. 이 대조가 있어야 다음에 다룰 탈취 대응이 가능해집니다.
토큰 탈취 대응: 리프레시 토큰 회전(RTR)
리프레시 토큰 탈취에 대응하는 가장 효과적인 기법이 **RTR(Refresh Token Rotation, 리프레시 토큰 회전)**입니다. 핵심 아이디어는 재발급할 때마다 리프레시 토큰도 새것으로 교체하고, 기존 토큰은 즉시 무효화하는 것입니다.
이렇게 하면 하나의 리프레시 토큰은 단 한 번만 사용할 수 있습니다(일회용). 만약 공격자가 토큰을 탈취해 재발급에 사용하면, 정상 사용자가 다음에 같은(이미 무효화된) 토큰으로 재발급을 시도할 때 실패하게 됩니다. 반대 순서라도 마찬가지입니다. 이 “이미 사용된 토큰의 재사용 시도”를 탈취 신호로 감지할 수 있다는 점이 RTR의 강력함입니다.
탈취가 의심되면 대응은 단호해야 합니다. 이미 사용된 리프레시 토큰이 다시 들어오면, 해당 사용자의 저장된 리프레시 토큰 전체를 폐기해 정상 사용자와 공격자 모두를 강제 로그아웃시킵니다. 사용자는 다시 로그인하면 되지만, 공격자는 탈취 토큰을 잃습니다.
추가로 액세스 토큰을 블랙리스트로 관리하면, 강제 로그아웃 시 아직 만료되지 않은 액세스 토큰까지 차단할 수 있습니다. 무상태 JWT의 약점인 “즉시 무효화 불가”를 저장소로 보완하는 셈입니다.
실전 적용 시 주의할 점
RTR과 토큰 저장 전략을 도입할 때 현장에서 자주 마주치는 함정을 정리하면 다음과 같습니다.
첫째, 재발급 동시성 문제입니다. 모바일 앱처럼 여러 요청이 동시에 401을 받으면 같은 리프레시 토큰으로 재발급이 중복 호출될 수 있습니다. RTR에서는 먼저 성공한 요청이 토큰을 회전시키므로, 뒤따른 요청이 “이미 사용된 토큰”으로 오인되어 정상 사용자가 로그아웃되는 일이 생깁니다. 짧은 유예 시간(grace period)을 두거나 재발급 요청을 직렬화해 완화합니다.
둘째, 저장소 TTL과 토큰 만료의 일치입니다. Redis TTL을 리프레시 토큰 만료와 맞춰야 만료된 토큰이 저장소에 남아 오작동하는 것을 막습니다.
셋째, 로그아웃 처리입니다. 로그아웃 시 저장된 리프레시 토큰을 삭제하고, 필요하면 사용 중이던 액세스 토큰을 블랙리스트에 등록해 잔여 수명 동안 차단합니다.
이 세 가지만 챙겨도 운영 단계에서 발생하는 토큰 관련 장애의 상당수를 예방할 수 있습니다.
자주 묻는 질문 (FAQ)
Q1. 리프레시 토큰을 localStorage에 저장하면 안 되나요?
가능하지만 권장하지 않습니다. localStorage는 자바스크립트로 접근할 수 있어 XSS 공격 시 토큰이 그대로 탈취됩니다. HttpOnly + Secure 쿠키에 저장하면 스크립트 접근을 차단해 훨씬 안전합니다.
Q2. JWT는 무상태가 장점인데 왜 서버에 저장하나요?
액세스 토큰은 무상태로 두되, 리프레시 토큰만 저장소에 보관하는 절충 방식이 일반적입니다. 탈취된 토큰을 강제 폐기하거나 로그아웃을 즉시 반영하려면 서버가 토큰을 무효화할 수단이 반드시 필요하기 때문입니다.
Q3. RTR을 적용하면 탈취를 완전히 막을 수 있나요?
탈취 자체를 막지는 못하지만, 탈취된 토큰의 재사용을 감지하고 피해를 빠르게 차단할 수 있습니다. 이미 사용된 토큰이 다시 들어오면 탈취로 간주해 사용자의 토큰 전체를 폐기하는 방식으로 대응합니다.
Q4. 액세스 토큰 만료 시간은 어느 정도가 적당한가요?
서비스 성격에 따라 다르지만 보통 15~30분 사이로 설정합니다. 너무 길면 탈취 시 위험 구간이 커지고, 너무 짧으면 재발급 요청이 잦아져 부하가 늘어납니다. 리프레시 토큰 수명과 함께 균형을 맞추시기 바랍니다.
마무리
JWT Refresh Token 설계의 핵심은 수명이 다른 두 토큰의 역할을 분리하고, 긴 수명의 리프레시 토큰을 안전하게 저장·무효화할 수단을 갖추는 것입니다. HttpOnly 쿠키로 클라이언트 노출을 줄이고, Redis 저장으로 서버 측 폐기 능력을 확보하는 것이 출발점입니다.
탈취 대응의 중심에는 RTR이 있습니다. 재발급마다 토큰을 회전시켜 일회용으로 만들면, 이미 사용된 토큰의 재사용을 탈취 신호로 감지해 즉시 차단할 수 있습니다. 여기에 재발급 동시성, TTL 일치, 로그아웃 처리까지 챙기면 운영 환경에서 견고한 인증 구조가 완성됩니다.
앞선 세션-JWT 전환에서 미처 다루지 못한 “그래서 리프레시 토큰은 어떻게 관리하나”라는 질문에, 이 글이 실전 기준의 답이 되기를 바랍니다.
핵심 요약
- 액세스 토큰은 짧게, 리프레시 토큰은 길게 가져가며 역할을 분리합니다.
- 리프레시 토큰은 HttpOnly 쿠키 + 서버(Redis) 저장으로 노출과 무효화를 모두 대비합니다.
- 재발급 시 저장소의 토큰과 대조해야 강제 폐기가 가능합니다.
- RTR로 토큰을 일회용 회전시키면 탈취 재사용을 감지·차단할 수 있습니다.
- 재발급 동시성, TTL 일치, 로그아웃 시 토큰 폐기를 함께 챙겨야 합니다.