[Redis] [Spring] JWT 기반 인증 구조 설계

이영재·2025년 5월 24일

Spring

목록 보기
17/20

0. 들어가며 – 정말 Stateless한 인증 구조였을까?

프로젝트에서 인증 방식으로 JWT를 도입하면서, Access TokenRefresh Token을 함께 사용하는 구조를 설계했다.
특히 Refresh Token은 보안을 고려해 MySQL DB에 엔티티 형태로 저장하는 방식을 선택했다.

하지만 개발을 마치고 돌아봤을 때, 문득 이런 의문이 들었다.

“JWT는 원래 Stateless한 인증 방식인데, Refresh Token을 DB에 저장하는 순간 결국 Stateful한 구조로 전환되는 것 아닌가?”

JWT는 토큰 자체에 사용자 정보를 담고 있어, 서버가 별도의 세션 상태를 저장하지 않아도 인증이 가능한 구조다.
하지만 나의 구현 방식은 Refresh Token의 상태를 서버가 기억해야만 작동했다.
이건 결국 JWT의 본래 철학과 어긋나는 방식 아닌가?

처음엔 단순히 "JWT니까 세션보다 가볍고 효율적이겠지"라는 생각으로 도입했지만,
돌이켜보니 핵심 개념부터 모호하게 이해하고 있었고, 설계에도 중요한 고민이 부족했던 것이다.


이 글에서는

  • JWT의 핵심 개념부터 다시 정리하고,
  • 내가 왜 DB에 Refresh Token을 저장하는 구조를 택했는지 돌아보며,
  • 이후 왜 Redis 기반으로 개선하게 되었는지까지 기록해보려 한다.

1. JWT의 핵심 개념 (Stateless란?)

먼저 Stateless 의 대해서 쉽게 이해할 수 있도록 Statefull 한 쿠키 & 세션 인증 방식에 대해서 알아보자.

1.0 쿠키 & 세션 인증 방식

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

1.1 토큰 인증 방식


토큰 기반 인증은 서버가 인증 상태를 직접 저장하지 않고, 클라이언트가 보유한 토큰 자체의 유효성(Signature 및 만료 시간 등)을 검증하여 인증을 처리하는 방식이다.
이 방식에서 사용되는 토큰(JWT 등)에는 사용자 정보(예: userId, role 등)가 Payload에 포함되어 있으며, 서버는 이 토큰의 서명(Signature)만료 시간(Expiration)을 검증하여 유효한 요청인지 판단한다.

그럼 발급 받은 토큰에서 role 을 admin 으로 위조할 수 있지 않을까? 그러면 이렇게 위조한 토큰을 어떻게 확인할까?

1.2 JWT 이란

JWT(JSON Web Token)란 인증에 필요한 정보들을 암호화시킨 JSON 토큰을 의미한다. 그리고 JWT 기반 인증은 JWT 토큰(Access Token)을 HTTP 헤더에 실어 서버가 클라이언트를 식별하는 방식이다.

  • Header : JWT에서 사용할 알고리즘, 타입 등 (토큰의 메타 정보)
  • Payload : 서버에서 첨부한 사용자 권한 정보
  • Signature : Header와 Payload를 조합해 비밀 키로 서명한 값. 이걸 통해 위변조 여부 검증 가능

JWT는 사용자 정보를 담고 있는 토큰이며, 서버는 이 토큰을 검증하여 사용자를 인증한다.
Payload에 들어간 정보는 누구나 확인할 수 있지만, Signature가 서버에 의해 검증되므로 조작 여부를 확인할 수 있다.

2. Refresh Token을 왜 저장했는가?

다만 이 JWT도 제 3자에게 토큰 탈취의 위험성이 있기 때문에, 그대로 사용하는것이 아닌 Access Token, Refresh Token 으로 이중으로 나누어 인증을 하는 방식을 현업에선 취한다.

Access Token 과 Refresh Token은 둘다 똑같은 JWT이다. 다만 토큰이 어디에 저장되고 관리되느냐에 따른 사용 차이일 뿐이다.

  • Access Token : 클라이언트가 갖고있는 실제로 유저의 정보가 담긴 토큰으로, 클라이언트에서 요청이 오면 서버에서 해당 토큰에 있는 정보를 활용하여 사용자 정보에 맞게 응답을 진행
  • Refresh Token: 새로운 Access Token을 발급해주기 위해 사용하는 토큰으로 짧은 수명을 가지는 Access Token에게 새로운 토큰을 발급해주기 위해 사용. 해당 토큰은 보통 데이터베이스에 유저 정보와 같이 기록.

정리하면, Access Token은 접근에 관여하는 토큰, Refresh Token은 재발급에 관여하는 토큰의 역할로 사용되는 JWT 라고 할 수 있다.

🧐 근데 Refresh Token 탈취 당하면??
그래서 Refresh Token은 반드시 서버(DB 또는 Redis)에 저장해야 한다. 서버는 클라이언트가 보낸 Refresh Token이 등록된 것과 일치하는지 확인하고, 일치하지 않으면 즉시 무효화하여 탈취된 토큰의 사용을 차단할 수 있다.

또한 Refresh Token Rotation 기법을 적용하면, 매 재발급 시 기존 토큰을 폐기하고 새로운 토큰만 유효하게 유지할 수 있어, 한 번만 사용된 토큰이 다시 들어올 경우 탈취 시도로 간주하고 세션을 종료할 수 있다. 이처럼 Refresh Token은 서버가 인증 흐름에 개입해 보안 통제를 가능하게 하는 핵심 요소다.

3. DB(MySQL) 방식의 한계와 Redis로의 전환

그럼 MySQL처럼 RDB에 Refresh Token을 저장하는 방식과 Redis처럼 인메모리 데이터베이스에 저장하는 방식의 차이는 뭘까?

3.1 MySQL에 Refresh Token을 저장하면

MySQL은 데이터를 디스크 기반으로 저장하는 구조이기 때문에 토큰 유효성 검사를 위해 DB에 접근할 때마다 쿼리 I/O 비용이 발생한다.

  • 실제로 Spring에서 JPA를 사용해 findByToken() 같은 메서드를 통해 DB 조회 쿼리를 실행하게 된다.
  • 이 방식은 인증 과정에서 매번 DB를 거치므로 성능 부하가 발생할 수 있으며 동시에 데이터 정합성과 추적성은 좋지만 응답 지연이나 트래픽 급증 시 병목이 생길 수 있다.

    매 요청마다 DB 상태를 참조하기 때문에 완전한 Stateless 구조는 아니며 서버가 일정 수준의 상태(State)를 유지해야 하므로 Stateful 구조에 가깝다.

3.2 Redis에 Refresh Token을 저장하면

Redis는 메모리 기반 데이터 저장소로, 쿼리 없이 즉시 조회가 가능한 키-값 저장소이다.

  • Refresh Token은 단순한 문자열이고, 만료 시간(TTL)도 명확하기 때문에
    복잡한 조건 없이 단순히 GET key 한 번으로 검증할 수 있다.
  • 이런 구조는 인증 시에도 I/O 지연 없이 빠르게 처리할 수 있고 만료 시간 설정(TTL)을 통해 별도 로직 없이 자동 만료 처리가 가능하다.

    결과적으로 Redis를 활용하면 서버가 토큰 상태를 유지하더라도 빠르고 확장 가능한 구조를 만들 수 있으며 Stateful한 방식이지만 거의 Stateless처럼 동작할 수 있는 절충점이 된다.


왜 Refresh Token을 사용했는지 Refresh Token을 어디에 저장해야 했는지에 대해서 알아봤다. 이제 Refresh 을 어떻게 관리하는지 알아보자.

4. Access Token+ Refresh Token 성공 & 만료 흐름

4.1 로그인

  • 로그인 정보가 맞다면 Access Token과 Refresh Token을 발급한다.

4.2 인증 흐름

  • Access Token이 유효하다면 Refresh Token의 만료여부는 확인되지 않는다.
  • Refresh Token의 만료여부는 클라이언트 측에서 알 수 없고, 보통 서버측에서 관리한다.

    왜 이렇게 설계할까?
    보안 + 효율성의 균형 때문이이다.

    • 매 요청마다 Refresh Token까지 확인하면:
    • Redis 조회 비용 발생 → 성능 저하
    • 매번 민감한 토큰을 사용하는 구조가 됨 → 위험도 상승

    Access Token이 유효할 때는 Refresh Token은 사용되지 않으며, 서버는 그것의 존재조차 확인하지 않는다.

4.3 Access Token 만료 (RTR 기법 적용)

  • Access Token 만료되었을 때 Refresh Token을 재발급하는 구조를 사용한다.
  • 이 방식을 Rotate-Then-Reject 이라고 하는데 Refresh Token 토큰 탈취에 효과적이다.

    RTR(Rotate-Then-Reject) 기법이란?

    • Refresh Token을 재발급 시마다 새로 생성하고 기존 토큰은 즉시 무효화하고
    • 이전 토큰이 다시 사용되면 공격으로 간주하는 기법
    • Refresh Token 토큰 탈취에 효과적, Access Token 탈취에는 미미

    탈취 예시

    1. Refresh Token 이 탈취되었다고 가정.
    2. 공격자가 탈취한 Refresh Token으로 Access Token을 재발급 요청
    3. 서버는 이 Refresh Token을 Redis에서 찾고 → 유효하다고 판단
    4. Access Token과 새로운 Refresh Token을 발급함 + 기존 Refresh Token은 Redis에서 삭제
    5. 이제 정상 사용자가 이전에 사용된 Refresh Token으로 요청하면?
      • Redis에 이미 없기 때문에 → 재사용 감지됨
      • 즉, "이건 탈취된 토큰이 재사용된 것이다" 라고 판단 가능
    6. 서버는 토큰 탈취를 인지할 수 있음.
      • 세션 강제 종료, 로그아웃 처리, 보안 알림 전송 등의 대응 가능

4.4 Refresh Token 재발급 흐름

Refresh Token의 만료여부는 Access Token 만료되었을 때 인지가 가능하다.

  • Refresh Token은 "Access Token을 다시 만들 수 있는 유일한 수단"이며,
  • 만료되었다면 더 이상 인증 상태를 유지할 수 없다. Access Token도 새로 만들 수 없고, 사용자는 다시 로그인해야 한다.

5. Refresh Token 저장 와 관리 방법

5.1 저장 구조

  • Key: refresh:{refreshToken}
    • 토큰 문자열 자체를 Key로 사용
    • 추측이 어려운 Base64 문자열이라 안전함
  • Value: userId 또는 UUID
    • 이 토큰이 어떤 사용자의 것인지 식별하기 위함
  • TTL (Time-To-Live): 7일 (또는 원하는 세션 유지 기간)

5.2 저장 예시 (Spring + RedisTemplate)

String key = "refresh:" + refreshToken;
String value = user.getUuid().toString();
long ttl = 7L;

redisTemplate.opsForValue().set(key, value, ttl, TimeUnit.DAYS

5.3 재발급 시 (RTR 적용)

  • 클라이언트가 /auth/reissue 요청 → 현재 Refresh Token 포함
  • 서버가 Redis에서 해당 토큰으로 조회
String uuid = redisTemplate.opsForValue().get("refresh:" + refreshToken);
  • 유효하면
    • 기존 토큰 Redis에서 삭제 → redisTemplate.delete(key);
    • 새 Refresh Token 생성 → Redis에 저장 (TTL 초기화)
    • 새 Access Token + 새 Refresh Token 응답

5.4 탈취 대응 (재사용 시)

  • 이미 삭제된 Refresh Token이 다시 들어오면
    • Redis에 없음 → 재사용 감지 → 탈취 시도로 간주
    • 세션 강제 종료 or 로그인 차단 처리

Redis에서 Refresh Token은 "refresh:{token}" 형태로 저장된다.
Value에는 사용자 UUID 등 식별자를 넣고, TTL을 설정해 세션 수명을 통제한다.
이 구조는 재발급(RTR), 탈취 탐지, 로그아웃 등 인증 흐름을 안정적으로 통제할 수 있는 실무적 방식이다.

6. 마무리

처음 JWT를 도입했을 때는, 세션 없이 인증 상태를 유지할 수 있다는 말이 매력적으로 느껴졌다.
그래서 Access Token과 Refresh Token을 함께 쓰면서도, Refresh Token은 그냥 DB에 저장했다.
그때는 그게 효율적인 방식이라고 생각했지만, 나중에 다른 프로젝트를 진행하면서
이 구조가 Stateless하지 않고, 오히려 서버가 상태를 기억해야만 작동하는 방식이었다는 걸 깨달았다.

이후 보안 개념을 더 공부하면서 Redis 기반의 저장 방식과 RTR(Rotate-Then-Reject) 기법을 도입했다.
Refresh Token을 매번 새로 발급하고, 이전 토큰은 무효화하는 구조를 통해
탈취된 토큰이 재사용되는 상황을 감지하고 차단할 수 있게 되었다.

0개의 댓글