[Spring] JWT 기반 인증 리팩토링, 만료되지 않은 토큰 관리를 어떻게 해야할까?

thezz9·2025년 4월 13일
4
post-thumbnail

개요

팀 프로젝트로 진행한 소셜 네트워크 서비스(SNS)와 유사한 뉴스피드 API 서버 구현에서, 회원 기능과 인증/인가 로직을 담당했다. 초기에는 세션 기반 인증으로 구성했지만, 이후 JWT 기반 인증 방식으로 리팩토링 하는 과정에서 문제를 마주하게 됐다.
JWT에 대한 사전 지식이 부족한 상태에서 공부함과 동시에 실시간으로 구현을 하면서, 세션과 다른 토큰의 특성때문에 어쩔 수 없는 한계가 있다는 걸 깨닫게 되었고 그 한계점 해결 과정을 정리해보려 한다.


만료되지 않은 토큰의 위험

문제 발생

JWT 기반으로 로그아웃을 처리한 후에도, 여전히 이전의 토큰 값만 알고 있다면 API 요청이 정상적으로 처리되는 현상이 발생했다.

문제 분석

JWT는 무상태(stateless) 방식이다. 즉, 사용자 인증 정보를 클라이언트 쪽에서 자체적으로 보관한다.
반면, 세션 기반 인증은 서버가 사용자 상태를 유지하기 때문에 언제든지 무효화할 수 있는 상태 유지(stateful) 방식이다.
JWT는 한 번 발급되면 서버가 일방적으로 만료시키기 어렵다. 이게 바로 내가 고민한 "JWT 로그아웃 문제"였다.

문제 해결: Redis 기반 블랙리스트

이 문제를 해결하기 위해, Redis를 활용한 토큰 블랙리스트 등록 방식을 선택했다.

왜 Redis인가?

Redis메모리(RAM) 기반 저장소라서 일반 관계형 데이터베이스보다 읽고 쓰는 속도가 훨씬 빠르다.

평균 응답 속도:

  • Redis: 평균 0.1~1ms 이하
  • RDB: 평균 10~100ms 이상(쿼리, 인덱스에 따라 다름)

하지만 메모리에 데이터를 저장하는 구조이기 때문에 대량의 데이터가 쌓이면 메모리 부족 이슈가 발생할 수 있다. 따라서 데이터 저장 시 보통 TTL(Time To Live)을 설정해 일정 시간이 지나면 자동으로 데이터를 삭제하도록 구성한다.
블랙리스트는 "토큰이 만료되기 전까지만 유효하면 된다"는 특성이 있고, 복잡한 데이터 구조가 필요하지 않다. 따라서 간단한 키-값 구조를 사용하고 TTL 설정 기능을 제공하는 Redis를 사용하는 것이 가장 실용적인 방식이라고 생각했다.

로그아웃 처리 로직

서비스 코드

// 1. 액세스 토큰 유효성 검사
if (!jwtProvider.validate(accessToken)) {
	throw new CustomException(ExceptionCode.INVALID_ACCESS_TOKEN);
}

// 2. 사용자 ID 추출 및 사용자 조회
Long userId = jwtProvider.getUserId(accessToken);
User user = userRepository.findUserByIdOrElseThrow(userId);

// 3. DB에 저장된 리프레시 토큰
String refreshToken = user.getRefreshToken();

// 4. 액세스 토큰 블랙리스트 등록
blackListService.addAccessTokenToBlacklist(accessToken);

// 5. 리프레시 토큰 블랙리스트 등록
blackListService.addRefreshTokenToBlacklist(refreshToken);

// 6. 사용자 엔티티에서 리프레시 토큰 제거
user.deleteRefreshToken();

블랙리스트 등록 메서드 (Access Token)

/**
* Access Token 블랙리스트 등록
* @param accessToken 등록할 accessToken
*/
protected void addAccessTokenToBlacklist(String accessToken) {

	// 1. 액세스 토큰 만료 시간 계산
	long expiration = jwtProvider.getExpiration(accessToken);

	// 2. 액세스 토큰 블랙리스트 등록
	redisTemplate.opsForValue().set(
		"blacklist:access:" + accessToken,
		"logout",
		expiration,
		TimeUnit.MILLISECONDS
	);
}

로직 흐름

1. 액세스 토큰 유효성 검사

  • 클라이언트 요청의 헤더에서 액세스 토큰을 추출하고, 서명이 유효한지 확인한다.

2. 사용자 식별 및 조회

  • 액세스 토큰의 Payload에서 유저 ID를 파싱하고, 해당 ID로 DB에서 사용자를 조회한다.

3. DB에 저장된 리프레시 토큰 조회

  • 사용자의 리프레시 토큰을 MySQL에서 조회한다.

4. 토큰 TTL 계산

  • 토큰 만료 시각 - 현재 시각 으로 남은 유효 시간(Time To Live, TTL)을 계산한다.

5. Redis 블랙리스트 등록

  • 액세스 토큰과 리프레시 토큰을 각각 Redis에 등록하며, TTL을 설정한다.

6. 토큰 블랙리스트 필터링

  • 이후 요청에서 해당 토큰이 Redis에 블랙리스트로 등록되어 있다면, 인증 예외를 발생시켜 접근을 차단한다.

(💡+추가) 꼭 Redis를 써야하는 건 아님.

관계형 데이터 베이스로도 블랙리스트 기능 구현은 할 수 있지만 속도, 관리 측면에서 효율성이 떨어진다.

Redis 서버가 죽으면 블랙리스트도 다 사라지지 않나?
맞다. Redis는 메모리 기반이라 휘발성이다.
그래서 운영 환경에서는 RDB 스냅샷 방식, AOF 방식등을 사용해 영속성을 확보한다고 한다.

RDB 스냅샷: 일정 시점의 Redis 메모리 데이터를 통째로 덤프

  • 장점 - 빠르게 복원 가능, 파일 크기가 작다.
  • 단점 - 설정에 따라 데이터 손실이 발생할 수도 있다.

AOF: Redis에서 수행된 모든 쓰기 명령을 파일에 기록,
서버가 재시작되면 파일을 순서대로 다시 실행해서 복원한다.

  • 장점 - 데이터 손실 거의 없다, 명령어 기반이라 사람이 읽기 쉽다.
  • 단점 - 시간이 지날수록 파일이 커짐, 복원 시 오래 걸릴 수 있다.

(💡++추가) 꼬리를 물고 들어가자면, 블랙리스트는 영속성이 필요할까?

이건 운영 환경에 따라 달라질 수 있는 문제다.
블랙리스트는 어차피 "토큰 만료 시점까지만 유효" 하면 되기 때문에,
장기 보관보다는 TTL을 기반으로 한 자동 만료가 더 실용적일 수 있다.
대부분의 경우에는 Redis에서 제공하는 기본적인 TTL 관리만으로도 충분하다.
나는 이런 특성 때문에, 블랙리스트에 한해서는 영속성 확보가 반드시 필요한 건 아니라고 생각한다.


마무리

JWT는 분산 시스템에서 유용한 인증 수단이지만, 한 번 발급되면 서버가 제어할 수 없다는 점에서 관리의 어려움이 있다.
Redis를 활용한 블랙리스트 방식으로 토큰 무효화를 처리한 경험은 보안적인 관점과 서버 인증 관련 역량을 한 단계 성장시켜줬다는 생각이 든다.

Redis는 하루 빨리 윈도우를 지원하도록 하라...

profile
개발 취준생

0개의 댓글