[멋사 클라우드 5기] Day 28 - JWT 그런데 Redis를 곁들인

2026. 3. 10. 02:07·Learning Log

최근 Spring Security, JWT, Redis를 학습하며 보안에 대해 조금 더 고민해 볼 수 있었다.

기술 하나하나만 보면 단순해 보이는데, 이들을 조합해서 쓰면 구조는 복잡해지는 한편

훨씬 다양한 문제들을 해결할 수 있게 되는 점이 흥미롭다.
그럼에도 불구하고, 보안에 있어 완벽한 기술이란 없다고 한다.
다만, 최선의 트레이드오프를 결정하기 위해서
어떤 보안 위협을 최우선으로 방어할 것인지,
그리고 프로젝트의 복잡도와 아키텍처 사이에서
어느정도의 타협점을 찾을 것인지 고민하는 것이 중요한 문제라고 느껴졌다.
어제 정리한 내용들과 겹치는 부분이 있지만, 헷갈리던 점을 명확히 짚고 넘어가기 위해 다시 정리했다.


세션 기반 인증의 구조와 한계

세션 기반 인증이란?

HTTP는 Stateless(무상태) 프로토콜이다. 각 요청은 독립적이며, 서버는 이전 요청을 기억하지 않는다.

Stateless란? 서버가 클라이언트의 이전 상태를 기억하지 않는다는 의미다.

그런데 로그인은 "나 이전에 인증했어"라는 상태를 유지해야 한다.
이 간극을 메우기 위해 세션(Session)이 활용된다.

1. 사용자가 id/pw로 로그인 요청
2. 서버가 인증 후 세션 생성 → 서버 메모리에 저장
   { sessionId: "abc123", userId: 42, role: "USER" }
3. 클라이언트에게 sessionId만 쿠키로 전달
4. 이후 요청마다 쿠키에 sessionId 포함
5. 서버는 sessionId로 메모리 조회 → "아, userId 42구나"

단순하고 직관적이다. 지금도 많은 서비스에서 잘 쓰이고 있다.


서버가 한 대일 땐 문제없다

[클라이언트] ──→ [Server 1 (세션 메모리 보유)] ──→ 정상 응답

클라이언트는 항상 같은 서버와 통신하고, 서버 메모리에 세션이 있으니 문제없다.


서버가 여러 대가 되는 순간 문제가 생긴다

서비스가 성장하면 트래픽을 감당하기 위해 Scale-out을 한다.

Scale-out이란? 서버 1대의 성능을 높이는 대신(Scale-up), 서버를 여러 대 추가해 부하를 분산하는 방식이다.
앞단에 로드밸런서(Load Balancer)가 요청을 각 서버에 나눠준다.

[클라이언트]
     │
     ▼
[Load Balancer]
   ┌──┴──┐
   ▼     ▼
[S1]   [S2]   ← 세션이 S1 메모리에만 있음
시나리오:
1. 사용자가 S1으로 로그인 → 세션이 S1 메모리에 저장
2. 다음 요청을 로드밸런서가 S2로 라우팅
3. S2는 그 세션을 모름 → 로그인 풀림 💥

서버가 늘어날수록 이 문제는 심각해진다.


해결하기 위한 시도들

① Sticky Session (고정 세션)

같은 사용자는 항상 같은 서버로 보내는 방식이다.

문제: 특정 서버에 부하가 몰릴 수 있다.
     그 서버가 죽으면 해당 사용자들은 전부 로그인이 풀린다.

② Session Replication (세션 복제)

모든 서버에 세션 데이터를 복제해서 동기화하는 방식이다.

문제: 서버가 늘어날수록 복제 비용이 기하급수적으로 증가한다.
      서버 10대면 모든 세션 변경사항을 10곳에 동기화해야 한다.

두 방식 모두 타당하나 완전한 해결책이 아니다. 그래서 나온 아이디어가 이것이다.

"서버가 상태를 아예 들고 있지 않으면 어떨까?"

이 아이디어에서 JWT가 등장한다.


JWT와 Refresh Token 패턴

왜 Stateless가 가능한가?

세션은 서버 메모리에서 "이 사람이 누구인지" 조회했다. JWT는 토큰 자체에 그 정보가 담겨있다.

세션 방식: 서버가 sessionId → 메모리 조회 → userId 확인
JWT  방식: 서버가 토큰의 서명만 검증 → 토큰 안의 userId 바로 사용

서버가 아무것도 저장하지 않아도 되니, 어느 서버로 요청이 가든 상관없다.

[클라이언트]
     │
     ▼
[Load Balancer]
   ┌──┴──┐
   ▼     ▼
[S1]   [S2]   ← 둘 다 토큰 서명만 검증하면 됨. 저장소 불필요.

Scale-out 문제가 깔끔하게 해결된다.


Access Token 만료 시간 딜레마

그런데 JWT는 한 번 발급하면 만료 시간이 될 때까지 항상 유효하다. 서버가 "이 토큰 무효화해줘"를 할 수 없다.
서버에서 저장해서 관리하지 않기 때문이다. 그래서 만료 시간 설정에 딜레마가 생긴다.

  • 짧게 (예: 5분) → 탈취당해도 곧 만료. 보안적으로는 좋음. 하지만 5분마다 재로그인 필요 → UX 최악
  • 길게 (예: 7일) → 편리함. 하지만 탈취당하면 7일 동안 무방비

Refresh Token를 곁들여 보자

이 딜레마를 해결하기 위해 두 종류의 토큰을 함께 쓰는 패턴이 고안됐다.

토큰  역할  만료 시간
Access Token API 요청 시 인증에 사용 짧게 (5~30분)
Refresh Token Access Token 재발급용 길게 (7~30일)
로그인 → Access Token (15분) + Refresh Token (7일) 발급

... 15분 후 Access Token 만료 ...

클라이언트가 Refresh Token 제출 → 서버가 검증 → 새 Access Token 발급

사용자는 재로그인 없이 계속 서비스를 이용하고, Access Token은 항상 짧게 유지된다.


⚠️ 그런데 Refresh Token도 탈취당하면 마찬가지 아닌가?

맞다. Refresh Token도 탈취당하면 문제가 생긴다. 게다가 만료 시간이 더 길다.

더 근본적인 문제가 있다. JWT 특성상 서버는 이 Refresh Token을 무효화할 방법이 없다.

공격자가 Refresh Token 탈취
→ 서버는 이 사실을 알 방법도, 막을 방법도 없음
→ 7일 동안 계속 새 Access Token 발급 가능
→ 사실상 영구 접근

여기서 Redis가 등장한다.


Redis까지 곁들여 보자

Redis란?

Redis(Remote Dictionary Server)는 데이터를 메모리(RAM)에 저장하는 인메모리 데이터 저장소다.

일반 DB(MySQL 등)는 디스크에 저장해 영구적이지만 상대적으로 느리다.

Redis는 메모리에 저장해 매우 빠르고 (1ms 이하), TTL 자동 만료 기능을 갖는다.

Key-Value 구조로 저장한다.

"refresh:allluck"           →  "eyJhbGc...BBB"
"blacklist:eyJhbGc...AAA"   →  "logout"

TTL (Time To Live) — Redis의 핵심 기능

저장할 때 만료 시간을 설정하면, 그 시간이 지나면 Redis가 자동으로 삭제한다.

// 7일 후 자동 삭제
redisTemplate.opsForValue().set("refresh:allluck", token, 7, TimeUnit.DAYS);

이 기능이 JWT와 결합했을 때 강력해진다.


Redis가 없으면 왜 Refresh Token 관리가 안 되는가?

Redis 없이 Refresh Token을 쓰면 서버는 어떤 Refresh Token을 발급했는지 알 방법이 없다.

클라이언트: "이 Refresh Token으로 새 Access Token 주세요"
서버:       "...서명은 유효한데, 내가 실제로 발급한 건지, 이미 무효화된 건지 알 방법이 없네"

비교할 정답지가 없다.


Redis가 생기면 서버가 "통제권"을 갖는다

Redis에 Refresh Token을 저장하는 순간, 서버는 정답지를 갖게 된다.

Redis: { "refresh:allluck" → "eyJhbGc...BBB" }

이제 가능해지는 것들:

  • 클라이언트가 보낸 토큰과 Redis 값 비교 → 불일치 시 거부
  • 비밀번호 변경, 계정 정지 시 Redis에서 삭제 → 즉시 무효화
  • 탈취 의심 시 삭제 → 이후 재발급 요청 전부 차단

Access Token과 Refresh Token의 저장위치에 대해 고민해보자

브라우저에서 토큰을 저장할 수 있는 곳은 크게 세 곳이다.

저장 위치  특징  탈취 위협
localStorage 새로고침/탭 종료 후에도 영구 유지 XSS 공격으로 탈취 가능
Cookie (일반) JS에서 접근 가능 XSS 공격으로 탈취 가능
메모리 (JS 변수) 페이지 새로고침 시 사라짐 JS로 접근 불가, XSS에 안전
HttpOnly Cookie JS에서 접근 불가, 브라우저가 자동으로 요청에 포함 XSS로 탈취 불가

XSS(Cross-Site Scripting)란? 공격자가 웹페이지에 악성 JS 코드를 심어서, 그 페이지를 방문한 사용자의 브라우저에서 코드가 실행되게 하는 공격이다. localStorage나 일반 쿠키에 저장된 값은 JS로 읽을 수 있어서 그대로 탈취된다.

Access Token을 메모리(JS 변수)에 저장하면 JS 코드가 접근할 수 없으니 XSS 공격으로 훔쳐갈 수가 없다.


하지만 새로고침하면 사라지잖아?!

바로 재발급을 받아서 사용할 수 있다

1. 사용자가 새로고침
2. 메모리의 Access Token 사라짐
3. 앱이 초기화되면서 "Access Token 없네, 로그인 상태 확인 필요"
4. HttpOnly Cookie에 담긴 Refresh Token으로 /reissue 요청 자동 발송
   ← 사용자는 이 과정을 모름. 보통 100ms 이내
5. 새 Access Token 발급 → 메모리에 저장
6. 사용자 입장에선 그냥 새로고침 후 정상 이용

Refresh Token을 HttpOnly Cookie에 저장하는 이유

HttpOnly는 쿠키에 붙일 수 있는 속성 중 하나다.

HttpOnly 속성이 붙은 쿠키는 JS에서 아예 읽을 수 없다.

브라우저가 요청 시 자동으로 포함시켜 줄 뿐이다.

// 이런 코드로 접근 시도해도
document.cookie  // HttpOnly 쿠키는 여기에 안 나온다

즉, XSS 공격자가 JS를 심어도 Refresh Token을 읽어갈 수 없다.


전체 저장 전략의 의도

Access Token  → 메모리 저장
  이유: 수명이 짧아서 새로고침 시 재발급해도 부담 없음
        JS 접근 불가 → XSS 안전

Refresh Token → HttpOnly Cookie 저장
  이유: 수명이 길어서 영구 보관 필요
        HttpOnly → JS 접근 불가 → XSS 안전
        새로고침 시에도 유지되어 자동 재발급에 활용

둘 다 JS에서 읽을 수 없게 설계한 것이다.

Access Token은 메모리로,

Refresh Token은 HttpOnly Cookie로,

각자의 방식으로 XSS를 막는다.

 

그런데 한 가지 아쉬운 점이 있다.

Access Token과 Refresh Token 둘을 비교한다면 보안적으로 더 중요한 대상은 Refresh Token이다. 유효기간이 길기 때문이다. 탈취당하면 만료전까지 계속해서 사용될 우려가 있다. 하지만 Access Token이 만약 메모리에서 관리된다면, 사용자의 페이지 이탈 또는 새로고침 등의 이유로 쉽게 소실될 수 있다. 이때 소실된 Access Token에 대하여 새로 발급받기 위해 Refresh Token을 담은 https 요청을 보내게된다. 재발급 요청이 빈번해진다는 것은 Refresh Token이 네트워크를 타는 빈도도 증가하는 것이다. 아주 미세하게지만 Refresh Token의 탈취 기회가 증가되는게 아닐까?

 

사실 이 부분도 어떻게 보면 트레이드오프라고 볼 수 있다.
Access Token을 메모리에서 관리하는 대신 localStorage에 저장한다고 가정해보자. 새로고침해도 유지가 되므로 재발급 빈도는 낮을 것이다. 하지만 JS로 읽힐 수 있다는 문제로 인해 XSS 공격에 노출되기 쉽다.

Access Token을 HttpOnly Cookie로 관리한다고 가정해보자. 또 다른 문제가 생긴다. 브라우저는 쿠키를 자동으로 요청에 포함시키기 때문에 CSRF 공격에 취약해진다.


즉, Access Token을 메모리에서 관리함으로써 발생할 수 있는 (희박한) 네트워크 노출 위협은 LocalStorage 또는 Cookie로 관리할 때 발생할 수 있는 XSS 위협, CSRF 위협보다는 훨씬 감수할만한 것이다.

시나리오로 확인하기

id:allluck / pw:1234 로 로그인하고, API 요청 후, 로그아웃하는 시나리오


로그인

POST /login { id: "allluck", pw: "1234" }

서버가 생성한 토큰:
Access Token  (15분 만료): eyJhbGc...AAA
Refresh Token (7일 만료):  eyJhbGc...BBB

Redis에 Refresh Token 저장

KEY:   "refresh:allluck"
VALUE: "eyJhbGc...BBB"
TTL:   604800초 (7일)

클라이언트에게 발급:

Access Token:  eyJhbGc...AAA  ← 메모리에 저장
Refresh Token: eyJhbGc...BBB  ← HttpOnly Cookie에 저장

일반 API 요청 (Access Token 유효)

GET /api/mypage + Authorization: Bearer eyJhbGc...AAA

서버:
1. 서명 검증 → 유효
2. 만료 시간 확인 → 유효
3. 블랙리스트 확인: Redis에 "blacklist:eyJhbGc...AAA" 없음
4. 정상 응답 ✅

Redis 상태 변화 없음.


Access Token 만료 → 재발급

POST /reissue + Refresh Token eyJhbGc...BBB

서버:
1. Refresh Token 서명 검증 → 유효
2. 토큰에서 userId 추출 → "allluck"
3. Redis 조회: GET "refresh:allluck" → "eyJhbGc...BBB"
4. 클라이언트 값 == Redis 값 → 일치 ✅
5. 새 Access Token 발급: eyJhbGc...CCC

Redis 상태 변화 없음. Refresh Token은 그대로 유지.


로그아웃

POST /logout + Authorization: Bearer eyJhbGc...CCC

서버:
1. Access Token 남은 만료 시간 계산 → 720초
2. Access Token 블랙리스트 등록
3. Refresh Token 삭제

Redis 변화:

# 블랙리스트 추가
SET "blacklist:eyJhbGc...CCC"  →  "logout"  (TTL: 720초)

# Refresh Token 삭제
DEL "refresh:allluck"

로그아웃 후 공격 시도

탈취한 Access Token으로 API 요청:

서버:
1. 서명 검증 → 유효 (서명은 멀쩡함)
2. 만료 확인 → 아직 유효 (720초 남음)
3. 블랙리스트 확인: "blacklist:eyJhbGc...CCC" 존재 → 거부 ❌
→ 401 Unauthorized

탈취한 Refresh Token으로 재발급 시도:

서버:
1. 서명 검증 → 유효
2. userId 추출 → "allluck"
3. Redis 조회: "refresh:allluck" → null (삭제됨) → 거부 ❌
→ 401 Unauthorized

그런데, 이건 Stateless로 보기힘들지 않을까?

Redis에 상태를 저장하는 순간, 완전한 Stateless는 아니다.

정확히 구분하면 이렇다.

순수 JWT          → 완전한 Stateless  (하지만 로그아웃 무효화 불가)
JWT + Redis       → 부분적 Stateless  (보안 확보, 대신 상태 일부 존재)
순수 Session      → 완전한 Stateful

제대로된 로그아웃을 구현하기 위해서는 어느정도의 트레이드오프를 인정하고,
JWT + Redis 구조로 만들어야 한다.

완벽한 Stateless 보다 실제로 안전한 인증이 더 중요하기 때문이다.


그럼 세션의 Scale-out 문제가 다시 생기는 건가?

Redis를 쓰면 서버에 상태가 생기는 거니까 세션과 같은 문제가 발생하는게 않을까?

조금 다르다. 세션과 Redis의 구조는 다르기 때문이다.


세션 문제의 본질

세션은 각 서버의 메모리에 저장된다.

사용자가 S1으로 로그인 → 세션이 S1 메모리에 저장
로드밸런서가 다음 요청을 S2로 라우팅
→ S2에는 그 세션이 없음 → 인증 실패 💥

서버가 늘어날수록 데이터가 흩어진다.


Redis는 외부 공유 저장소다

Redis는 서버 안에 있지 않다. 모든 서버가 바라보는 독립적인 외부 저장소다.

Server 1 ─┐
Server 2 ─┼──→ Redis (중앙 저장소)
Server 3 ─┘

어느 서버로 요청이 가든, 모두 같은 Redis를 조회한다.

사용자가 S1으로 로그인 → Refresh Token이 Redis에 저장
로드밸런서가 다음 요청을 S3으로 라우팅
→ S3도 같은 Redis를 조회 → 정상 동작 ✅

Redis 자체의 고가용성

그럼 Redis가 단일 장애점(Single Point of Failure)이 되지 않냐는 의문이 생긴다.

Redis 자체도 Scale-out이 가능하다.

  • Redis Sentinel: 마스터-슬레이브 구조로 고가용성 확보. 마스터가 죽으면 슬레이브가 자동으로 마스터 승격
  • Redis Cluster: 데이터를 여러 노드에 분산 저장

애플리케이션 서버와 Redis를 독립적으로 각각 확장할 수 있다.


그럼 애초에 세션 + Redis면 됐던 거 아닌가?

맞다. 그리고 실제로 그렇게도 한다.

Spring Session + Redis라는 이름으로 이미 널리 쓰이는 구조라고 한다.

세션 ID만 쿠키로 클라이언트에 전달
세션 데이터는 Redis에 저장
→ 어느 서버로 요청이 가도 Redis에서 꺼내면 됨
→ Scale-out 문제 해결

그러면 JWT는 왜 쓰는거야...?

JWT가 세션의 "유일한 해결책"인 것은 아니다. JWT만의 진짜 장점이 따로 있다.

① MSA 환경에서의 독립적 인증

마이크로서비스 환경에서 서비스가 수십 개라면, 각 서비스마다 Redis 연결을 붙이는 게 부담이다. JWT는 서명 검증만으로 Redis 연결없이 독립적으로 인증이 가능하다.

Session + Redis 방식:
서비스 A → Redis 조회 → 인증
서비스 B → Redis 조회 → 인증
서비스 C → Redis 조회 → 인증  ← 모든 서비스가 Redis에 의존

JWT 방식 (이상적):
서비스 A → 서명 검증만 → 인증  ← Redis 불필요
서비스 B → 서명 검증만 → 인증
서비스 C → 서명 검증만 → 인증

② 모바일 / 서드파티 환경

세션은 쿠키 기반이라 브라우저에 최적화되어 있다. 모바일 앱이나 외부 API 클라이언트에서는 JWT가 훨씬 다루기 편하다.

③ 단순한 서비스라면 Session + Redis가 오히려 나을 수 있다

모놀리식 웹 서비스라면 JWT의 복잡한 토큰 관리, 블랙리스트 처리 없이 세션 만료시키면 끝이다.


트레이드오프를 알고 선택하자

각 방식의 특성을 비교하면 이렇다.

항목  Session  Session + Redis  순수 JWT  JWT + Redis
Scale-out ❌ 취약 ✅ ✅ ✅
로그아웃 무효화 ✅ ✅ ❌ ✅
강제 로그아웃 ✅ ✅ ❌ ✅
MSA 환경 ❌ △ ✅ △
모바일/서드파티 ❌ ❌ ✅ ✅
인프라 복잡도 낮음 중간 낮음 중간
완전한 Stateless ❌ ❌ ✅ ❌

어떤 기술이 더 좋다는 건 없다. 서비스의 규모, 구조, 클라이언트 환경에 따라 선택이 달라진다.

'Learning Log' 카테고리의 다른 글

[멋사 클라우드 5기] Day 31 & 32 - OSI 7 Layers  (1) 2026.03.16
[멋사 클라우드 5기] Day 29 & 30 - 게시판 구현을 통해 확인한 Spring Data JPA  (0) 2026.03.12
[멋사 클라우드 5기] Day 26, 27 - Spring Security & JWT  (0) 2026.03.09
[멋사 클라우드 5기] Day 25 - Spring Data JPA (3)  (0) 2026.03.05
[멋사 클라우드 5기] Day 24 - Spring Data JPA (2)  (0) 2026.03.04
'Learning Log' 카테고리의 다른 글
  • [멋사 클라우드 5기] Day 31 & 32 - OSI 7 Layers
  • [멋사 클라우드 5기] Day 29 & 30 - 게시판 구현을 통해 확인한 Spring Data JPA
  • [멋사 클라우드 5기] Day 26, 27 - Spring Security & JWT
  • [멋사 클라우드 5기] Day 25 - Spring Data JPA (3)
allluck777
allluck777
allluck777
    • 분류 전체보기 (41)
      • AWS (0)
      • Network (0)
      • Linux (0)
      • Docker (0)
      • Project (4)
        • CloudNote (4)
      • Learning Log (34)
      • Lecture (3)
        • 스프링 입문 - 코드로 배우는 스프링 부트, 웹 .. (3)
  • 전체
    오늘
    어제
  • hELLO· Designed By정상우.v4.10.6
allluck777
[멋사 클라우드 5기] Day 28 - JWT 그런데 Redis를 곁들인
상단으로

티스토리툴바