Spring Security 보안 강화: Redis 기반 Refresh Token Rotation(RTR) 및 블랙리스트 전략 완벽 가이드
1. 서론
지난 포스팅에서 우리는 MSA 및 모바일 환경에서의 확장성을 위해 JWT(Json Web Token) 기반의 인증 아키텍처를 선택하는 것이 유리함을 확인했습니다. 하지만 동시에 JWT가 가진 치명적인 약점인 ‘한번 발급되면 서버가 제어할 수 없다(Stateless)’는 딜레마에 대해서도 논의했습니다. 만약 유효기간이 긴 Refresh Token이 해커에게 탈취당한다면 어떻게 될까요? 해커는 그 토큰이 만료될 때까지 유유히 Access Token을 재발급받으며 사용자의 계정을 마음대로 유린할 수 있습니다.
이러한 보안 사고를 방지하기 위해 금융권이나 대규모 서비스에서는 단순히 Refresh Token을 저장하는 것을 넘어, RTR(Refresh Token Rotation) 전략을 필수적으로 도입하고 있습니다. RTR은 토큰을 사용할 때마다 새로운 토큰으로 교체하여, 탈취된 구 토큰이 사용되는 순간 해킹임을 감지하고 연결된 모든 토큰을 폐기하는 강력한 보안 기법입니다. 또한, 로그아웃한 사용자의 Access Token이 남은 유효기간 동안 악용되는 것을 막기 위해 블랙리스트(Blacklist) 처리도 병행해야 합니다.
오늘은 이 두 가지 핵심 보안 전략을 Spring Security와 Redis를 활용하여 구현하는 방법과 그 내부 동작 원리를 심도 있게 파헤쳐 보겠습니다. 왜 RDBMS가 아닌 Redis여야 하는지, 그리고 토큰 탈취 시나리오에서 시스템이 어떻게 방어 기제로 작동하는지 상세히 알아보겠습니다.
2. 본론
1. Refresh Token Rotation (RTR)의 핵심 원리와 토큰 탈취 감지
기존의 일반적인 Refresh Token 방식은 토큰의 유효기간이 다 될 때까지 동일한 Refresh Token을 계속 사용합니다. 이는 구현이 간단하지만, 탈취되었을 때 방어할 수단이 전무합니다. 반면 Refresh Token Rotation(RTR)은 클라이언트가 Access Token 재발급을 요청할 때마다, 기존의 Refresh Token을 폐기하고 ‘새로운 Refresh Token‘을 발급해서 돌려주는 방식입니다.
[RTR이 탈취를 감지하는 메커니즘]
이 방식이 강력한 이유는 ‘이미 사용된 토큰의 재사용’을 추적함으로써 토큰 탈취를 감지할 수 있기 때문입니다. 시나리오를 통해 살펴보겠습니다.
- 정상 발급: 사용자(A)가 로그인하여
RT(1)을 받습니다. 해커(B)가 이를 탈취합니다. - 해커의 접근: 해커(B)가
RT(1)을 사용하여 Access Token 재발급을 요청합니다. 서버는 이를 정상 요청으로 간주하여 새로운 Access Token과 새로운RT(2)를 해커에게 줍니다. 동시에 서버는RT(1)을 ‘사용됨’ 상태로 바꾸거나 삭제합니다. - 사용자의 접근 (감지 시점): 아무것도 모르는 사용자(A)가 만료된 Access Token을 갱신하기 위해 자신이 가진
RT(1)을 서버에 보냅니다. - 방어 가동: 서버는
RT(1)이 이미 사용되어 교체된 토큰임을 확인합니다. 이는 누군가(해커)가 먼저 토큰을 사용했다는 명백한 증거입니다. - 전체 무효화: 서버는 즉시 위험 상황으로 판단하고, 해당 사용자에게 발급된
RT(2)(해커가 가진 것)를 포함한 모든 Refresh Token 체인을 무효화합니다. 결과적으로 해커는 더 이상 재발급을 받을 수 없게 되어 강제 로그아웃됩니다.
이러한 로직을 구현하기 위해서는 빠른 읽기/쓰기 속도와 데이터의 생명주기(TTL) 관리가 필수적이므로, 인메모리 데이터 저장소인 Redis가 가장 적합한 선택지입니다.
2. 로그아웃 문제를 해결하는 블랙리스트(Blacklist) 전략
JWT의 또 다른 난제는 ‘로그아웃‘입니다. 서버는 Access Token을 발급해 준 이후에는 그 토큰이 유효한지 아닌지 알 방법이 없습니다. 사용자가 로그아웃 버튼을 눌러도, 클라이언트 쪽에서 토큰을 지울 뿐이지 해당 토큰 자체는 만료 시간까지 유효합니다. 만약 누군가 이 토큰을 복사해 두었다면 로그아웃 이후에도 API를 호출할 수 있습니다.
이를 해결하기 위해 블랙리스트 전략을 사용합니다.
- 사용자가 로그아웃을 요청하면, 서버는 헤더에 실려 온 Access Token을 꺼냅니다.
- 이 Access Token을 Redis에 저장합니다. 이때 Key는
blacklist:accessToken, Value는logout, 그리고 가장 중요한 TTL(Time-To-Live)은 해당 토큰의 ‘남은 유효 시간‘만큼 설정합니다. - Spring Security의 필터(
JwtAuthenticationFilter)에서는 요청이 들어올 때마다 해당 토큰이 Redis 블랙리스트에 있는지 확인합니다. 만약 존재한다면, 유효한 서명을 가진 토큰이라도 거부(401 Unauthorized)합니다.
Redis의 TTL 기능을 활용하면 토큰의 수명이 다하는 순간 Redis에서도 자동으로 데이터가 사라지므로, 별도의 배치 작업(Garbage Collection) 없이도 메모리를 효율적으로 관리할 수 있다는 엄청난 장점이 있습니다.
3. Spring Boot와 Redis를 이용한 구현 아키텍처
이제 실제 구현을 위한 설계를 살펴보겠습니다. Redis를 사용하기 위해 spring-boot-starter-data-redis 의존성을 추가하고 RedisTemplate 또는 RedisRepository를 설정해야 합니다.
1. Redis 데이터 구조 설계
Refresh Token은 사용자 ID(Subject)를 키로 하여 저장하며, RTR 구현을 위해 이전 토큰 정보를 관리할 필요가 있을 수 있으나, 일반적으로는 Redis의 덮어쓰기 특성을 이용해 최신 토큰만 유지하는 방식(Strict Mode)을 사용하거나, 토큰 패밀리(Token Family) ID를 부여하여 관리합니다. 여기서는 가장 강력한 보안을 위해 ‘사용자 ID를 Key, Refresh Token을 Value‘로 저장하여 단 하나의 기기만 허용하거나, 기기 식별자를 포함하여 다중 기기를 지원할 수 있습니다.
2. 인증 필터 (JwtAuthenticationFilter) 로직 강화
단순히 서명만 검증하던 로직에 블랙리스트 검사가 추가되어야 합니다.
Java
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) {
String token = resolveToken(request);
// 1. 토큰 유효성 검사 (서명 및 만료 여부)
if (token != null && jwtProvider.validateToken(token)) {
// 2. [핵심] Redis 블랙리스트 확인
// Access Token이 블랙리스트에 등록되어 있는지 조회 (O(1) 속도)
String isLogout = (String) redisTemplate.opsForValue().get(token);
if (ObjectUtils.isEmpty(isLogout)) {
// 블랙리스트에 없으면 정상 인증 처리
Authentication authentication = jwtProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(authentication);
} else {
// 블랙리스트에 있다면 로그아웃된 토큰으로 간주 -> 예외 처리
// 401 Unauthorized 응답 반환
}
}
filterChain.doFilter(request, response);
}
3. 재발급 서비스 (Reissue Service) 로직
RTR을 적용한 재발급 로직입니다.
Java
@Transactional
public TokenDto reissue(String oldRefreshToken) {
// 1. Refresh Token 검증
if (!jwtProvider.validateToken(oldRefreshToken)) {
throw new RuntimeException("Refresh Token이 유효하지 않습니다.");
}
// 2. Access Token에서 User ID 추출
Authentication authentication = jwtProvider.getAuthentication(oldRefreshToken);
// 3. Redis에서 해당 User ID로 저장된 최신 Refresh Token 조회
String savedRefreshToken = redisService.getValues(authentication.getName());
// 4. [RTR 핵심] 탈취 감지 로직
// 클라이언트가 보낸 토큰과 Redis에 저장된 최신 토큰이 다르면?
// 누군가 이미 재발급을 한 번 했다는 뜻 -> 탈취로 간주
if (!savedRefreshToken.equals(oldRefreshToken)) {
redisService.deleteValues(authentication.getName()); // 저장된 토큰 삭제 (강제 로그아웃)
throw new RuntimeException("토큰이 탈취되었습니다. 다시 로그인해주세요.");
}
// 5. 정상적인 경우: 새로운 토큰 쌍 발급 및 Redis 갱신
TokenDto newToken = jwtProvider.generateTokenDto(authentication);
redisService.setValues(authentication.getName(), newToken.getRefreshToken(), Duration.ofDays(14)); // TTL 설정
return newToken;
}
4. Redis 고가용성(High Availability) 고려 사항
이 아키텍처에서 Redis는 인증 시스템의 핵심 심장부입니다. 만약 Redis가 다운되면 모든 사용자의 로그인이 풀리거나 재발급이 불가능해지고, 로그아웃 검증이 마비되어 보안 구멍이 뚫리게 됩니다. 따라서 실무 운영 환경에서는 반드시 단일 노드가 아닌 Redis Sentinel 또는 Redis Cluster 구성을 통해 고가용성을 확보해야 합니다.
- Sentinel: 마스터 노드가 죽으면 슬레이브 노드를 자동으로 승격시켜 장애 조치(Failover)를 수행합니다.
- Cluster: 데이터를 여러 노드에 분산 저장(Sharding)하여 대규모 트래픽을 처리하고, 일부 노드 장애 시에도 서비스가 지속되도록 합니다.
또한, Redis의 maxmemory-policy를 적절히 설정하여(예: volatile-ttl), 메모리가 가득 찼을 때 TTL이 설정된 만료 임박 키부터 삭제되도록 설정해야 인증 서비스의 안정성을 보장할 수 있습니다.
3. 결론
지금까지 JWT 인증 시스템의 보안성을 극대화하기 위한 Refresh Token Rotation(RTR) 전략과 블랙리스트 처리 방법에 대해 알아보았습니다.
요약하자면, RTR은 Refresh Token을 일회용으로 만들어 탈취된 토큰의 수명을 차단하고 탈취 시도를 능동적으로 감지하는 기술입니다. 블랙리스트는 무상태인 JWT에 최소한의 상태(State)를 부여하여 로그아웃된 토큰을 즉각 무효화하는 기술입니다. 이 두 기술의 공통점은 데이터의 빠른 접근과 만료 관리가 필요하다는 것이며, Redis는 이를 구현하기 위한 최적의 도구입니다.
보안은 불편함과의 싸움이라고 하지만, 이렇게 Redis를 활용한 정교한 아키텍처를 구축한다면 사용자 경험(UX)을 해치지 않으면서도 세션 기반 인증 못지않은, 혹은 그 이상의 강력한 보안 체계를 갖출 수 있습니다. 단순히 ‘기능이 동작하는’ 코드를 넘어, ‘해커의 공격 시나리오’까지 고려하는 방어적인 코드를 작성하는 것이야말로 진정한 시니어 개발자로 가는 길임을 명심하시기 바랍니다.
인증과 보안 로직을 탄탄하게 구축했다면, 이제 이 API를 외부와 안전하게 통신하기 위한 문서화가 필요합니다. 다음 포스팅으로 “Spring RestDocs와 Swagger(OpenAPI) 비교 및 RestDocs를 이용한 테스트 기반 API 문서 자동화 구축“에 대해 다뤄보는 것은 어떨까요? “문서와 코드가 따로 노는 문제”를 해결해 보려합니다.