Stateful 한 JWT 사용법

진성대·2025년 3월 26일
0

사이드 프로젝트

목록 보기
3/7

Rabbit 프로젝트는 대규모 트래픽을 고려한 이커머스 플랫폼으로 설계되었고, 수평 확장을 용이하게 하기 위해 처음부터 인증 시스템을 세션이 아닌 JWT 기반으로 구축했습니다.

JWT의 Stateless 구조의 장점은 뛰어났습니다. 별도의 세션 저장소 없이 인증이 가능하고, 서버 간 세션 공유나 동기화가 필요 없어서 수평확장에 용이했습니다. 하지만 서비스의 기능들을 붙여가면서 몇가지 보안적인 문제가 발생했습니다. 로그아웃이 제대로 되지 않거나, 탈취한 토큰을 막을 수 없다 같은 문제들이였습니다. 이 글에서는 JWT 기반 인증 시스템을 직접 설계하고 개발하면서 어떤 문제를 겪었고, 그것을 어떻게 해결했는지 공유하려고 합니다.

1. 왜 처음부터 JWT를 선택했는가? 그리고 무엇을 놓쳤는가

세션 방식은 로그인 시 사용자의 상태를 Memory나 DB에 저장합니다. 그 덕분에 세션 제어가 편리하지만, 서버를 수평 확장할때 마다 세션 동기화나 공유 저장소 구축등의 부담이 있습니다.

Rabbit 프로젝트는 처음부터 대규모 트래픽을 고려한 수평 확장이 용이한 아키텍처로 설계했기 때문에 로그인 상태를 서버가 가지지 않는 Stateless JWT를 선택했습니다.

Session vs JWT: 인증 구조 비교

항목Session 기반 인증JWT 기반 인증
구조서버가 상태(Session)을 메모리/DB에 저장클라이언트가 상태(Token)를 보관
식별 방식Session ID (JSESSIONID 등)Access Token (Authorization: Bearer ...)
무결성 검증서버 세션 테이블에 직접 접근Token 서명 검증 (HS256, RS256 등)
로그아웃 처리세션 삭제로 즉시 만료Token 자체가 유효기간(exp)까지 유효
서버 확장세션 동기화 필요 (Sticky Session or 공유 저장소)Stateless 구조로 수평 확장 용이
보안 제어상태 기반 제어 가능 (기기별, 관리자 종료 등)별도 상태 저장 없으면 불가 (Blacklist, Redis 필요)
Payload 수정불가 (서버 보관 정보)가능 (JWT는 Base64-encoded JSON)
대표 저장소DB, Memory, Redis쿠키, LocalStorage, Memory

JWT는 클라이언트가 인증 정보를 직접 갖고 있는 Self-contained Token입니다. 토큰 자체에 사용자의 정보, 권한, 만료 시각 등이 담겨 있기 때문에 서버가 인증 상태(Session)를 별도로 저장하지 않아도 됩니다.

이는 아래와 같은 장점으로 나타납니다 :

  1. 서버 무상태(Stateless) 유지 → 수평 확장에 유리
  2. 인증 처리 속도 향상 → DB 혹은 세션 저장소 조회 불필요
  3. 분산 환경에서 공유 캐시 계층이 불필요

하지만 프로젝트가 커지고 로그인 기능이 실제로 활용되기 시작하면서, 아래와 같은 문제들이 드러났습니다.

  1. 사용자가 로그아웃을 해도 Access Token은 여전히 유효
  2. 탈취된 토큰은 만료 시각까지 서버가 감지하거나 막을 수 없음

결과적으로, “JWT가 세션보다 더 낫다”는 판단은 보안문제, 세션관리 같은 문제를 발생시킬 수 있었으며 Stateless에 대한 장점과 단점을 바라볼 수 있는 계기가 되었습니다.

2. Stateless 구조의 근본적인 한계

JWT는 서버가 인증 상태를 기억하지 않는 Stateless 구조이기 때문에, 인증 흐름은 단순하고 빠르지만, 그만큼 보안 통제와 세션 제어가 어려운 구조입니다.

Rabbit 프로젝트에서 경험한 대표적인 문제는 다음과 같습니다.

1. 로그아웃시 JWT가 가지는 토큰의 유효성

세션 기반 인증에서는 로그아웃 시 세션 데이터를 서버에서 삭제하기 때문에 즉시 인증이 종료됩니다.

하지만 JWT는 클라이언트가 가진 토큰이 exp까지 유효하므로, 서버 입장에서는 해당 사용자가 로그아웃했는지 알 수 있는 방법이 없습니다.

예) 사용자가 PC방에서 로그인 → 브라우저를 그냥 닫음 → 클라이언트에 토큰이 남아 있음  
→ 15분간 아무 제약 없이 접근 가능

즉, 로그아웃은 사용자의 행위일 뿐, 토큰 관점에서는 아무 일도 일어나지 않은 상태로 처리됩니다.

2. 탈취된 토큰은 무기한으로 유효하다

JWT는 Access Token 자체에 사용자 정보를 포함하고 있으므로, 토큰만 있으면 인증 절차 없이 바로 API를 사용할 수 있습니다.

하지만 이 구조는 토큰이 노출되면, 별도의 상태 체크 없이도 공격이 가능하다는 의미입니다.

예) 사용자의 브라우저에서 XSS 발생 → LocalStorage에서 JWT 탈취  
→ 서버는 토큰이 유효하므로, 공격자를 정상 사용자로 처리

즉, JWT가 한 번 발급되면 서버는 이 토큰이 정상 사용자에게 있는 것인지, 탈취된 것인지, 로그아웃된 것인지 구분할 수 없습니다.

이러한 문제들을 해결하려면, 결국 서버가 일부 상태를 저장하는 구조, 즉 Stateful한 제어 구조를 도입해야 했습니다.

이를 다음과 같이 해결하려고 시도를 했습니다.

  • Access Token은 블랙리스트로 강제 무효화
  • Refresh Token은 Redis 저장 + 회전 감지

3. 왜 Stateful한 구조를 도입했는가?

JWT는 인증 구조의 간편화와 분산 시스템에서의 확장성이라는 장점을 갖고 있지만, "상태가 없는 구조(Stateless)"라는 특성은 보안적인 통제가 어려운 문제를 낳았습니다.

특히, 토큰 탈취, 재사용 공격, 기기 단위 로그아웃 불가능 등의 문제는 실서비스에 도입되었을 때 치명적인 보안 구멍이 될 수 있습니다.

아래는 서버가 최소한의 상태를 가지기까지 고려되었던 대안들입니다.

대안장점치명적 한계
Access Token TTL 2~3분짧은 윈도우로 위험 최소화재로그인 폭주, UX 재앙, Refresh 트래픽 10배↑
매 요청마다 Access Token 재발급탈취 토큰 즉시 폐기네트워크 비용 급증, 캐시 무효화 지옥
전통 세션으로 회귀보안·세션 제어 완벽세션 동기화·DB/Redis 부하, 기존 마이크로서비스 구조랑 충돌
Hybrid: JWT + Redis 상태 최소화확장성 유지 + 세션 통제 가능Redis 한 곳은 필수 (Bus Stop)

여러 대안 중 “JWT + Redis 최소 상태 저장” 구조를 채택하게 된 결정적 이유는, JWT의 확장성과 Redis의 통제력을 동시에 확보할 수 있기 때문입니다.

Redis의 통제력은 곧 토큰 상태를 서버가 직접 기억하고 판단할 수 있게 만든다는 의미합니다.

JWT는 자체 서명된 토큰이기 때문에 토큰이 탈취되거나, 사용자가 로그아웃 하더라도 토큰이 만료되기 전까지는 여전히 유효한 것으로 간주됩니다. 이 문제를 해결하기 위해, Redis를 활용한 상태 기반 구조를 도입했습니다.

1. 로그아웃 시 Access Token을 Redis에 Blacklist로 등록

로그아웃시 Access Token을 Redis에 Blacklisted 상태로 기록합니다.

  • Key 형식: blacklist:{jti}
  • Value: 없음 (존재 여부로 차단 판별)
  • TTL: 해당 토큰의 exp 시각까지 자동 만료

인증 과정에서 서버는 토큰을 검증한 후 Redis에 해당 jti가 존재하는지만 확인합니다.

존재할 경우 → 즉시 인증 거부 이 과정을 통해 서버는 토큰의 유효 여부를 상태 기반으로 통제할 수 있게 됩니다.

2. Refresh Token → Redis Registered Session

Refresh Token은 긴 TTL을 가지며 민감한 권한을 부여하는 토큰입니다.

이를 위조하거나 재사용할 경우 보안상 치명적이므로, 토큰이 등록된 상태인지를 반드시 서버가 직접 판단해야 합니다.

  • Key 형식: user:{userId}:refreshToken
  • Value: userId + 발급 시각
  • TTL: 7일

/refresh api 로 요청 시 동작 순서

  1. 클라이언트는 쿠키 혹은 Authorization 헤더로 refreshToken을 서버에 전달합니다.
  2. 서버는 전달받은 토큰을 디코딩 후 userId 추출합니다.
  3. Redis에서 user:{userId}:refreshToken 키를 조회합니다.
  4. 다음 조건을 검증합니다:
    • 키가 존재하지 않으면 → 탈취/재사용 → 인증 실패 처리 (401 Unauthorized)
    • Redis에 저장된 값과 토큰의 정보가 불일치하면 → 위조 → 인증 실패
    • 일치하면 → Access Token 재발급

Redis를 활용하여 Stateless한 JWT 기반 시스템에 최소한의 상태 정보를 부여함으로써, 다음과 같은 실질적인 통제 이점을 확보할 수 있었습니다:

  • 사용자 로그아웃 및 강제 만료가 가능해졌습니다.
  • 탈취된 토큰의 재사용을 차단할 수 있습니다.
  • 기존 JWT의 확장성이 그대로 유지됩니다.

4. Stateful JWT 시스템 아키텍처

기존 JWT를 사용한 시스템 아키텍처 입니다.
AccessToken으로 서버에 인증을 받으며 서버 자원을 활용했으며 AccessToken은 Local Storage에 담아서 사용을 했습니다.

제가 구성한 Stateful 한 시스템 아키텍처는 다음과 같습니다.

작동 구조

  • 로그인 시 Access Token + Refresh Token 발급
  • Access Token은 쿠키(HttpOnly)로 반환
  • Refresh Token은 Redis 저장소에 refresh:{userId}:{deviceId}로 저장
  • 로그아웃 시, Access Token의 jti를 Redis에 blacklist:{jti}로 저장

인증 흐름

  1. Access Token이 유효하면 → 그대로 통과
  2. Access Token 만료 → 쿠키의 Refresh Token으로 재발급 요청
  3. Redis에서 Refresh Token의 존재 및 유효성 확인
  4. 재발급 성공 시 새 AT+RT 반환, 기존 RT는 삭제

Stateless JWT 위에 “상태 2 가지만 서버에 기억” 하는 구조를 올린 결과, Rabbit 인증 흐름은 아래와 같은 3‑계층 아키텍처로 정리되었습니다.

5. Refresh Token 저장 전략

JWT를 이용한 인증 구조에서 Refresh Token의 관리는 인증 시스템의 보안을 결정짓는 중요한 부분입니다. Access Token과 달리 Refresh Token은 길게 설정된 유효기간으로 인해 유출 시 피해가 클 수 있으므로, 더욱 세밀한 관리가 요구됩니다. 따라서 본 시스템에서는 Refresh Token을 Redis에 저장하여 엄격하게 관리하는 구조를 택했습니다.

1. 사용자 ID 기반의 개별 관리

Redis에 토큰을 저장할 때 사용자의 ID를 키로 설정하여 개별적으로 관리합니다(refresh:{userId}). 이를 통해 각 사용자의 Refresh Token 상태를 정확히 추적할 수 있으며, 특정 사용자의 토큰만 즉시 무효화하거나 재발급(Rotation)하는 등의 세부적인 제어가 가능해집니다.

2. TTL을 활용한 자동 만료 처리

Redis가 제공하는 TTL(Time-To-Live)을 활용하여 Refresh Token의 유효기간을 정확하게 관리합니다. 토큰의 유효기간이 만료되면 Redis에서 자동으로 삭제되기 때문에, 서버는 추가적인 만료 처리 로직을 별도로 구현하지 않아도 됩니다. 이 방식은 시스템 리소스의 효율성을 극대화하는 동시에 오래된 토큰이 남아있는 보안 위험성을 줄이는 데 큰 장점이 있습니다.

이러한 관리 방식을 통해 Refresh Token이 가진 보안 리스크를 최소화하며, 토큰의 유효성과 보안성을 극대화한 안정적인 인증 구조를 제공합니다.

3. JSON 기반의 상태 저장 구조

Refresh Token은 단순 문자열 형태가 아니라, 토큰 자체와 함께 생성 시각(issuedAt), 사용자 접속 IP 등 부가 정보를 포함하여 JSON 형태로 저장됩니다. 예시는 다음과 같습니다:

{
  "refreshToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
  "issuedAt": 1715174986000,
  "clientIP": "192.168.0.1"
}

이러한 구조를 통해 단순 검증 이상의 동작이 가능합니다:

  • 토큰 재사용 여부 감지 (e.g., Rotation 후 이전 토큰 사용 시)
  • 발급 이후 경과 시간 기반 제어 (예: Rotation 최소 주기 보장)
  • 의심스러운 접속 패턴 탐지 (다른 IP에서 발급된 토큰 사용 등)

상태 기반 통제의 이점

Refresh Token을 Redis에 상태 기반으로 저장하는 방식은 Stateless JWT 구조의 단점을 보완합니다. 예를 들어, 사용자가 로그아웃을 수행하면 해당 키를 Redis에서 즉시 삭제하여 세션을 종료시킬 수 있으며, 악의적인 토큰 재사용 시도는 Redis 조회를 통해 쉽게 차단할 수 있습니다.

이런 구조는 사용자 세션을 더 세밀하게 제어하고 관리할 수 있는 인증 시스템을 만들 수 있습니다.

6. Access Token 무효화 전략 (Blacklist)

Access Token은 기본적으로 클라이언트 측에 직접 노출되기 때문에 탈취될 위험이 항상 존재합니다. 하지만 JWT는 기본적으로 서버가 토큰 상태를 관리하지 않기 때문에 한 번 발급된 토큰을 즉각적으로 무효화할 방법이 없습니다. 이 문제를 해결하기 위해  Redis 기반의 블랙리스트(Blacklist) 전략을 적용했습니다.

1. 왜 Blacklist가 필요한가?

JWT의 가장 큰 장점은 Stateless(무상태)라는 점입니다. 하지만 이 특성 때문에 토큰이 탈취되거나 권한이 변경되었을 때, 서버가 이를 즉시 차단할 방법이 없습니다. 이 문제를 해결하기 위해 가장 현실적인 방법이 상태를 서버에 저장하는 Redis를 활용한 블랙리스트 전략이라고 판단했습니다.

2. 구현 방식

Access Token 발급 시 JWT의 고유 식별자(jti)를 부여하여, Redis에 이 jti를 블랙리스트 키로 저장합니다. 블랙리스트에 등록된 토큰은 즉시 인증에서 배제됩니다.

  • 토큰 발급: JWT에 고유한 UUID 기반의 jti를 포함시켜 발급합니다.
  • 토큰 무효화: 사용자가 로그아웃하거나 의심스러운 활동을 감지하면 Redis에 해당 jti를 블랙리스트 키로 등록합니다.
  • 인증 검증: 요청 시마다 서버가 Redis의 블랙리스트를 조회하여 해당 jti가 등록되어 있으면 즉시 인증을 거부합니다.

3. 블랙리스트 데이터 구조

키(Key) 형태값(Value)유효 기간(TTL)
blacklist:{jti}"blacklisted"exp까지의 남은 시간
Redis Key: blacklist:123e4567-e89b-12d3-a456-426614174000
Value: "blacklisted"
TTL: 1800초 (30분)

이러한 블랙리스트 전략은 Stateless 구조의 장점을 유지하면서도 안정적으로 JWT의 안정성을 보장할 수 있습니다.

profile
주니어 개발자

0개의 댓글