JWT는 은총알이 아니다 - Access/Refresh Token 설계와 대용량 Redis 인증
TL;DR
- 배경: “JWT 쓰면 세션보다 좋다면서요?”로 시작된 인증 설계 회의에서 반복된 실수와 운영 이슈 정리
- 함정 4종: Stateless 환상, localStorage 저장, RT 단일 발급, 로그아웃 미구현
- 탈취 대응: Reuse Detection, 디바이스 핑거프린트, 지리적 이상 감지 → 강제 세션 종료
- 대용량 운영: Redis 블랙리스트 + Bloom Filter, RT는 DB에 풀 저장 / Redis는 핫 데이터만
- 핵심 교훈: “수평 확장이 필요한가?”를 먼저 묻고, 아니라면 세션이 거의 언제나 더 안전하고 단순함
- 대상 독자: Spring Security를 한 번이라도 설정해본 백엔드 개발자
- 범위: Stateless JWT 인증 기준. OAuth2 Resource Server도 설계 원리는 동일
- 키워드: Access Token(AT), Refresh Token(RT), Rotation, Reuse Detection
글 머리말
2년차 개발자가 인증 설계안을 들고 왔습니다.
“JWT로 갈게요. 세션보다 확장성 좋다면서요. Access Token은 localStorage에 넣고, 만료되면 Refresh Token으로 새로 발급받으면 됩니다.”
저는 5년 전에 똑같은 말을 했습니다. 거의 토씨 하나 안 틀리고요. 그 때 아무도 저한테 “왜”라고 안 물어봤고, 저는 잘 작동하니까 통과시켰습니다. 그 설계는 결국 로그아웃 기능이 없는 채로 1년을 운영됐습니다. 관리자 계정이 탈취되고도 14시간 동안 아무도 눈치채지 못했습니다.
시니어가 되고 나서 이 주제에 대한 제 입장이 완전히 바뀌었습니다. JWT는 설계 결정이지 기본값이 아닙니다. 이 글은 그 이후로 코드 리뷰와 설계 회의에서 반복해서 만난 다섯 가지 패턴을 정리한 것입니다.
먼저: 왜 JWT를 쓰는가 (트레이드오프부터)
JWT를 쓰는 이유를 한 줄로 요약하면 “인증 상태를 서버에 저장하지 않기 위해”입니다. 그래서 얻는 것과 잃는 것이 명확합니다.
| 항목 | 세션 (Stateful) | JWT (Stateless) |
|---|---|---|
| 서버 메모리/DB 부하 | 있음 (세션 저장소 필요) | 없음 (토큰이 곧 상태) |
| 수평 확장 | 세션 복제/공유 저장소 필요 | 자연스럽게 됨 |
| 즉시 무효화 | 가능 (세션 삭제) | 어려움 (블랙리스트 필요) |
| 토큰 크기 | 작음 (세션 ID만) | 큼 (payload + signature) |
| 권한 변경 반영 | 즉시 | 토큰 만료까지 지연 |
수평 확장이 필요 없고 단일 도메인 웹 앱이라면 세션이 거의 언제나 더 안전하고 단순합니다. JWT가 필요한 진짜 경우는 두 가지입니다.
- 여러 서비스(마이크로서비스, 외부 파트너)가 같은 토큰을 검증해야 할 때
- 모바일/SPA + 여러 백엔드 + 수평 확장까지 겹칠 때
이게 아닌데 JWT를 고르면, 세션의 단점은 다 피하려다 JWT의 함정은 모두 맞게 됩니다.
함정 1: “JWT는 Stateless라 DB 조회가 없다”는 환상
가장 많이 듣는 주장입니다. 그리고 틀린 말입니다.
// 이상적인 JWT 필터의 그림 (현실엔 거의 없음)
public Authentication authenticate(String token) {
Claims claims = jwtParser.parseClaimsJws(token).getBody();
return new JwtAuthentication(claims.getSubject(), extractRoles(claims));
// DB 조회 없음! 빠름! 확장성 좋음!
}현실은 이렇게 됩니다.
public Authentication authenticate(String token) {
Claims claims = jwtParser.parseClaimsJws(token).getBody();
String userId = claims.getSubject();
// 탈퇴한 사용자인지 확인 → DB 조회
User user = userRepository.findByIdAndStatus(userId, ACTIVE)
.orElseThrow(() -> new DisabledException("탈퇴 계정"));
// 권한이 바뀌었는지 확인 → DB 조회 (또는 캐시)
Set<Role> currentRoles = roleService.getCurrentRoles(userId);
// 블랙리스트 확인 → Redis 조회
if (tokenBlacklist.contains(claims.getId())) {
throw new InvalidTokenException("무효화된 토큰");
}
return new JwtAuthentication(userId, currentRoles);
}결국 요청마다 최소 1~3번 외부 저장소를 조회합니다. 세션 한 번 조회와 뭐가 다른가요? 거의 없습니다. “Stateless”라는 단어에 속아서 세션을 버렸는데, 조회 횟수는 오히려 늘어난 경우를 여러 번 봤습니다.
시니어 관점 체크: “토큰만 검증하면 끝”이라는 설계서가 올라오면 무조건 되묻습니다. “탈퇴 처리는 어떻게 반영합니까? 권한 변경은요? 강제 로그아웃은요?” 답이 “토큰 만료까지 기다립니다”면 그 시스템은 컴플라이언스 감사에서 깨집니다.
함정 2: Access Token을 localStorage에 넣기
“XSS 걱정되니까 HttpOnly Cookie 쓰세요”라는 조언이 인터넷에 많습니다. 그런데 이 조언만 따르면 오히려 더 위험해집니다. XSS와 CSRF는 트레이드오프 관계입니다.
| 저장 위치 | XSS 취약 | CSRF 취약 | 탈취 난이도 |
|---|---|---|---|
| localStorage | O (JS로 읽힘) | X | 낮음 |
| HttpOnly Cookie | X (JS 차단) | O (기본) | 중간 |
| HttpOnly + SameSite=Strict Cookie | X | 거의 X | 높음 |
| Memory (변수) | 부분적 | X | 높음 (새로고침 시 소실) |
실무 기본값은 이렇습니다.
- AT: 메모리 저장 + 만료 15분 이하 (짧으면 탈취되도 피해 시간이 짧음)
- RT: HttpOnly + Secure + SameSite=Strict Cookie + Path 제한 (
/auth/refresh만)
// RT를 쿠키로 내려줄 때
ResponseCookie refreshCookie = ResponseCookie.from("refresh_token", rt)
.httpOnly(true) // JS 접근 차단 (XSS 방어)
.secure(true) // HTTPS만
.sameSite("Strict") // 타 사이트 요청 시 미전송 (CSRF 방어)
.path("/auth/refresh") // 이 경로에만 전송 (공격 면적 축소)
.maxAge(Duration.ofDays(14))
.build();시니어 관점 체크: “localStorage 쓰는 이유가 뭡니까?”로 질문합니다. 답이 “편해서”면 Cookie로 돌립니다. “CORS 때문에”면 대부분 CORS 설정을 잘못 잡은 거라 그걸 먼저 고칩니다.
함정 3: Refresh Token 하나만 발급하고 끝
“RT 있으니까 탈취되도 괜찮아요. 다시 발급받으면 되잖아요.”
아닙니다. RT야말로 탈취되면 가장 치명적입니다. RT 하나만 가져가면 공격자가 AT를 무한히 재발급할 수 있기 때문입니다. 단순 RT 설계의 수명은 길어야 6개월입니다.
RT Rotation + Reuse Detection이 진짜 표준입니다
제대로 된 RT 운영은 이렇게 해야 합니다.
@Transactional
public TokenPair refresh(String oldRt) {
RefreshToken stored = rtRepository.findByToken(oldRt)
.orElseThrow(() -> new InvalidTokenException("존재하지 않는 RT"));
// 핵심 1: 이미 사용한 RT가 또 들어왔다 = 탈취 의심
if (stored.isUsed()) {
// 같은 사용자의 모든 RT 무효화 (연쇄 차단)
rtRepository.revokeAllByUserId(stored.getUserId());
alertService.notifySuspiciousActivity(stored.getUserId());
throw new SecurityException("RT 재사용 감지 - 모든 세션 무효화");
}
// 핵심 2: 쓴 RT는 즉시 사용 처리 (일회용)
stored.markAsUsed();
// 핵심 3: 새 AT + 새 RT 동시 발급 (Rotation)
String newAt = jwtProvider.createAccessToken(stored.getUserId());
String newRt = jwtProvider.createRefreshToken(stored.getUserId());
rtRepository.save(new RefreshToken(newRt, stored.getUserId()));
return new TokenPair(newAt, newRt);
}왜 이 설계가 중요한가를 위협 모델로 풀면 이렇습니다.
- 공격자가 RT 탈취 → 새 AT 발급 → 피해자 RT는 이미 사용됨 표시
- 피해자가 RT로 refresh 시도 → 재사용 감지 → 그 사용자의 모든 RT 무효화
- 공격자도 다음 번 refresh에서 동시에 끊김
- 운영팀은 탈취 시점을 로그로 정확히 포착 가능
이걸 구현 안 하면 RT 유효기간(보통 14~30일) 내내 공격자가 자유롭게 드나듭니다.
시니어 관점 체크: RT를 DB/Redis에 저장하지 않는 설계서는 통과시키지 않습니다. “RT도 JWT니까 검증만 하면 돼요”는 탈취를 감지할 방법이 없다는 뜻입니다.
함정 4: 로그아웃 구현의 덫
“JWT는 Stateless라 로그아웃이 없어요. 클라이언트에서 토큰만 지우면 됩니다.”
감사에서 깨질 대표적인 멘트입니다. 관리자가 “이 사용자 즉시 차단”을 요청했는데 “15분 기다리세요”라고 답할 수 있는 시스템은 없습니다.
로그아웃 구현에는 두 가지 길이 있습니다.
옵션 A: AT 블랙리스트 (Redis)
@PostMapping("/logout")
public void logout(@RequestHeader("Authorization") String bearer,
@CookieValue("refresh_token") String rt) {
String at = bearer.substring(7);
Claims claims = jwtProvider.parse(at);
// AT는 만료시간까지만 블랙리스트에 (Redis TTL로 자동 삭제)
long ttl = claims.getExpiration().getTime() - System.currentTimeMillis();
blacklistRedis.set("bl:" + claims.getId(), "1", ttl, TimeUnit.MILLISECONDS);
// RT는 DB에서 무효화 (Rotation 테이블)
rtRepository.revokeByToken(rt);
}옵션 B: AT 수명을 아예 짧게 (Blacklist 없이)
AT를 2~5분으로 줄이면 블랙리스트 없이도 “5분 내 차단” 보장이 됩니다. 단, RT revoke는 여전히 필요합니다.
어떤 걸 고를지는 트래픽과 보안 요구사항입니다.
- 일반 서비스: AT 15~30분 + RT revoke만 (블랙리스트 없음)
- 금융/의료: AT 5분 + RT revoke + AT 블랙리스트 전부
- 초고 트래픽: AT 15분 + 블랙리스트는 Redis Bloom Filter로 최적화
시니어 관점 체크: “로그아웃은 프론트에서 토큰 지우면 돼요”는 즉시 반려 사유입니다. 실제 위협 시나리오(탈취 신고, 관리자 강제 차단, 비밀번호 변경)를 놓고 몇 분 내에 차단되어야 하는가부터 정해야 합니다.