프로젝트에서 인증 방식으로 JWT를 도입하면서, Access Token과 Refresh Token을 함께 사용하는 구조를 설계했다.
특히 Refresh Token은 보안을 고려해 MySQL DB에 엔티티 형태로 저장하는 방식을 선택했다.
하지만 개발을 마치고 돌아봤을 때, 문득 이런 의문이 들었다.
“JWT는 원래 Stateless한 인증 방식인데, Refresh Token을 DB에 저장하는 순간 결국 Stateful한 구조로 전환되는 것 아닌가?”
JWT는 토큰 자체에 사용자 정보를 담고 있어, 서버가 별도의 세션 상태를 저장하지 않아도 인증이 가능한 구조다.
하지만 나의 구현 방식은 Refresh Token의 상태를 서버가 기억해야만 작동했다.
이건 결국 JWT의 본래 철학과 어긋나는 방식 아닌가?
처음엔 단순히 "JWT니까 세션보다 가볍고 효율적이겠지"라는 생각으로 도입했지만,
돌이켜보니 핵심 개념부터 모호하게 이해하고 있었고, 설계에도 중요한 고민이 부족했던 것이다.
이 글에서는
먼저 Stateless 의 대해서 쉽게 이해할 수 있도록 Statefull 한 쿠키 & 세션 인증 방식에 대해서 알아보자.

세션은 위 그림처럼 DB에 저장되기 때문에 서버 관리자는 해당 세션에 대한 권한을 가진다. 언제든지 이 세션을 무효화 시킬 수 있는 것이다.
하지만 이러한 방식으로 DB 에 세션 데이터를 저장하게 되면 세션 ID를 검색하고 세션의 상태를 관리해야 한다. 요청이 많아지면 서버에 부하가 심해질 수 있다.

토큰 기반 인증은 서버가 인증 상태를 직접 저장하지 않고, 클라이언트가 보유한 토큰 자체의 유효성(Signature 및 만료 시간 등)을 검증하여 인증을 처리하는 방식이다.
이 방식에서 사용되는 토큰(JWT 등)에는 사용자 정보(예: userId, role 등)가 Payload에 포함되어 있으며, 서버는 이 토큰의 서명(Signature)과 만료 시간(Expiration)을 검증하여 유효한 요청인지 판단한다.
그럼 발급 받은 토큰에서 role 을 admin 으로 위조할 수 있지 않을까? 그러면 이렇게 위조한 토큰을 어떻게 확인할까?
JWT(JSON Web Token)란 인증에 필요한 정보들을 암호화시킨 JSON 토큰을 의미한다. 그리고 JWT 기반 인증은 JWT 토큰(Access Token)을 HTTP 헤더에 실어 서버가 클라이언트를 식별하는 방식이다.

JWT는 사용자 정보를 담고 있는 토큰이며, 서버는 이 토큰을 검증하여 사용자를 인증한다.
Payload에 들어간 정보는 누구나 확인할 수 있지만, Signature가 서버에 의해 검증되므로 조작 여부를 확인할 수 있다.
다만 이 JWT도 제 3자에게 토큰 탈취의 위험성이 있기 때문에, 그대로 사용하는것이 아닌 Access Token, Refresh Token 으로 이중으로 나누어 인증을 하는 방식을 현업에선 취한다.
Access Token 과 Refresh Token은 둘다 똑같은 JWT이다. 다만 토큰이 어디에 저장되고 관리되느냐에 따른 사용 차이일 뿐이다.
정리하면, Access Token은 접근에 관여하는 토큰, Refresh Token은 재발급에 관여하는 토큰의 역할로 사용되는 JWT 라고 할 수 있다.
🧐 근데 Refresh Token 탈취 당하면??
그래서 Refresh Token은 반드시 서버(DB 또는 Redis)에 저장해야 한다. 서버는 클라이언트가 보낸 Refresh Token이 등록된 것과 일치하는지 확인하고, 일치하지 않으면 즉시 무효화하여 탈취된 토큰의 사용을 차단할 수 있다.또한 Refresh Token Rotation 기법을 적용하면, 매 재발급 시 기존 토큰을 폐기하고 새로운 토큰만 유효하게 유지할 수 있어, 한 번만 사용된 토큰이 다시 들어올 경우 탈취 시도로 간주하고 세션을 종료할 수 있다. 이처럼 Refresh Token은 서버가 인증 흐름에 개입해 보안 통제를 가능하게 하는 핵심 요소다.
그럼 MySQL처럼 RDB에 Refresh Token을 저장하는 방식과 Redis처럼 인메모리 데이터베이스에 저장하는 방식의 차이는 뭘까?
MySQL은 데이터를 디스크 기반으로 저장하는 구조이기 때문에 토큰 유효성 검사를 위해 DB에 접근할 때마다 쿼리 I/O 비용이 발생한다.
매 요청마다 DB 상태를 참조하기 때문에 완전한 Stateless 구조는 아니며 서버가 일정 수준의 상태(State)를 유지해야 하므로 Stateful 구조에 가깝다.
Redis는 메모리 기반 데이터 저장소로, 쿼리 없이 즉시 조회가 가능한 키-값 저장소이다.
결과적으로 Redis를 활용하면 서버가 토큰 상태를 유지하더라도 빠르고 확장 가능한 구조를 만들 수 있으며 Stateful한 방식이지만 거의 Stateless처럼 동작할 수 있는 절충점이 된다.
왜 Refresh Token을 사용했는지 Refresh Token을 어디에 저장해야 했는지에 대해서 알아봤다. 이제 Refresh 을 어떻게 관리하는지 알아보자.


왜 이렇게 설계할까?
보안 + 효율성의 균형 때문이이다.
- 매 요청마다 Refresh Token까지 확인하면:
- Redis 조회 비용 발생 → 성능 저하
- 매번 민감한 토큰을 사용하는 구조가 됨 → 위험도 상승
Access Token이 유효할 때는 Refresh Token은 사용되지 않으며, 서버는 그것의 존재조차 확인하지 않는다.

RTR(Rotate-Then-Reject) 기법이란?
- Refresh Token을 재발급 시마다 새로 생성하고 기존 토큰은 즉시 무효화하고
- 이전 토큰이 다시 사용되면 공격으로 간주하는 기법
- Refresh Token 토큰 탈취에 효과적, Access Token 탈취에는 미미
탈취 예시
- Refresh Token 이 탈취되었다고 가정.
- 공격자가 탈취한 Refresh Token으로 Access Token을 재발급 요청
- 서버는 이 Refresh Token을 Redis에서 찾고 → 유효하다고 판단
- Access Token과 새로운 Refresh Token을 발급함 + 기존 Refresh Token은 Redis에서 삭제
- 이제 정상 사용자가 이전에 사용된 Refresh Token으로 요청하면?
- Redis에 이미 없기 때문에 → 재사용 감지됨
- 즉, "이건 탈취된 토큰이 재사용된 것이다" 라고 판단 가능
- 서버는 토큰 탈취를 인지할 수 있음.
- 세션 강제 종료, 로그아웃 처리, 보안 알림 전송 등의 대응 가능
Refresh Token의 만료여부는 Access Token 만료되었을 때 인지가 가능하다.

refresh:{refreshToken}String key = "refresh:" + refreshToken;
String value = user.getUuid().toString();
long ttl = 7L;
redisTemplate.opsForValue().set(key, value, ttl, TimeUnit.DAYS
/auth/reissue 요청 → 현재 Refresh Token 포함String uuid = redisTemplate.opsForValue().get("refresh:" + refreshToken);
Redis에서 Refresh Token은 "refresh:{token}" 형태로 저장된다.
Value에는 사용자 UUID 등 식별자를 넣고, TTL을 설정해 세션 수명을 통제한다.
이 구조는 재발급(RTR), 탈취 탐지, 로그아웃 등 인증 흐름을 안정적으로 통제할 수 있는 실무적 방식이다.
처음 JWT를 도입했을 때는, 세션 없이 인증 상태를 유지할 수 있다는 말이 매력적으로 느껴졌다.
그래서 Access Token과 Refresh Token을 함께 쓰면서도, Refresh Token은 그냥 DB에 저장했다.
그때는 그게 효율적인 방식이라고 생각했지만, 나중에 다른 프로젝트를 진행하면서
이 구조가 Stateless하지 않고, 오히려 서버가 상태를 기억해야만 작동하는 방식이었다는 걸 깨달았다.
이후 보안 개념을 더 공부하면서 Redis 기반의 저장 방식과 RTR(Rotate-Then-Reject) 기법을 도입했다.
Refresh Token을 매번 새로 발급하고, 이전 토큰은 무효화하는 구조를 통해
탈취된 토큰이 재사용되는 상황을 감지하고 차단할 수 있게 되었다.