JWT deep dive (1/5)

다음 편: [JWT] 2. Stateless 인증이란

JWT deep dive 시리즈

1. 세션의 한계와 토큰의 등장 (현재 글)

2. 2 Stateless 인증이란

3. 3 JWT의 구조 해부

4. 4 서명과 검증의 원리

5. 5 JWT 실전 팁과 면접 대비

웹 애플리케이션에서 "로그인 상태를 유지한다"는 건 생각보다 까다로운 문제다. HTTP는 기본적으로 요청 하나하나가 독립적이라, 이전 요청에서 누가 로그인했는지를 다음 요청에서는 알 수 없다. 세션은 이 문제를 풀기 위해 오랫동안 쓰여온 방식인데, 서버를 여러 대로 늘리는 순간 골치 아픈 문제가 터진다.

세션 기반 인증의 동작 방식

먼저 세션이 어떻게 동작하는지 처음부터 짚어보자.

사용자가 로그인하면, 서버는 해당 사용자의 정보를 자기 메모리(또는 별도 저장소)에 저장하고, 그 저장소의 "열쇠"에 해당하는 세션 ID를 만들어서 클라이언트에게 넘긴다. 이후 클라이언트는 매 요청마다 이 세션 ID를 쿠키에 담아 보내고, 서버는 그걸 보고 "아, 이 사람이구나"하고 식별한다.

sequenceDiagram participant Browser as 브라우저 participant Server as 서버 participant Store as 세션 저장소
(서버 메모리) Browser->>Server: POST /login {email, password} Note over Server: DB에서 사용자 확인 Server->>Store: 세션 생성 Note over Store: session_id: abc123
user_id: 1
role: USER
login_time: ... Store-->>Server: 저장 완료 Server-->>Browser: Set-Cookie: SESSION=abc123 Note over Browser: 브라우저가 쿠키를
자동 저장 Browser->>Server: GET /profile
Cookie: SESSION=abc123 Server->>Store: abc123 세션 조회 Store-->>Server: {user_id: 1, role: USER} Server-->>Browser: 200 OK (프로필 데이터)

이 흐름에서 주목할 점은 두 가지다.

  • 서버가 상태를 보관한다 — 사용자 정보는 서버 쪽 세션 저장소에 있고, 클라이언트는 세션 ID라는 "열쇠"만 들고 다닌다
  • 쿠키가 자동으로 전송된다 — 브라우저가 Set-Cookie로 받은 값을 이후 요청에 자동으로 붙여주니까, 개발자가 별도로 신경 쓸 게 없다

단일 서버 환경에서는 이 구조가 아주 잘 동작한다. 세션 저장소가 서버 메모리에 있으니 조회도 빠르고, 구현도 간단하다.

서버가 여러 대가 되면

문제는 서비스가 성장해서 서버를 여러 대로 늘릴 때 발생한다.

서버가 한 대일 때는 세션 저장소도 하나뿐이니까, 어떤 요청이 오든 항상 같은 저장소를 뒤진다. 그런데 서버가 여러 대가 되면, 각 서버마다 자기만의 세션 저장소를 갖게 된다.

sequenceDiagram participant Browser as 브라우저 participant LB as 로드 밸런서 participant S1 as 서버 1 participant S2 as 서버 2 Browser->>LB: POST /login LB->>S1: 로그인 요청 전달 Note over S1: 세션 생성
abc123 → user_id: 1 S1-->>Browser: Set-Cookie: SESSION=abc123 Note over Browser: 다음 요청... Browser->>LB: GET /profile
Cookie: SESSION=abc123 LB->>S2: 요청 전달 (라운드 로빈) Note over S2: abc123 세션?
...없는데? S2-->>Browser: 401 Unauthorized

로드 밸런서는 보통 라운드 로빈 방식으로 요청을 분배한다. 그래서 로그인은 서버 1에서 했는데, 다음 요청이 서버 2로 가버릴 수 있다. 서버 2에는 abc123 세션이 없으니, 사용자 입장에서는 갑자기 로그아웃이 된 것처럼 느낀다.

이게 다중 서버 환경에서 세션이 부딪히는 근본적인 문제다. "서버가 상태를 보관한다"는 세션의 핵심 특성이, 서버가 여러 대가 되는 순간 약점으로 바뀐다.

해결 시도 1 — Sticky Session

가장 직관적인 해결책은 "같은 사용자는 항상 같은 서버로 보내자"는 것이다. 이걸 Sticky Session이라고 한다.

로드 밸런서가 사용자를 처음 연결한 서버를 기억해두고, 이후 같은 사용자의 요청은 항상 같은 서버로 보낸다.

graph TD subgraph "Sticky Session" LB[로드 밸런서] S1[서버 1] S2[서버 2] S3[서버 3] LB -->|"사용자 A, C"| S1 LB -->|"사용자 B"| S2 LB -->|"사용자 D, E, F, G"| S3 end style S1 fill:#4CAF50,stroke:#333,stroke-width:2px,color:#fff style S2 fill:#4CAF50,stroke:#333,stroke-width:2px,color:#fff style S3 fill:#f44336,stroke:#333,stroke-width:2px,color:#fff

얼핏 깔끔해 보이지만, 두 가지 문제가 있다.

부하 불균형

사용자가 고르게 분배되리란 보장이 없다. 위 다이어그램처럼 서버 3에 활성 사용자가 몰리면 서버 3만 과부하가 걸린다. 로드 밸런서의 존재 이유가 "부하를 고르게 나누는 것"인데, Sticky Session은 이 목적을 훼손한다.

서버 장애 시 세션 유실

서버 3이 죽으면, 그 서버에 고정되어 있던 사용자 D, E, F, G의 세션이 전부 날아간다. 이 사용자들은 모두 다시 로그인해야 한다. 서버를 여러 대 두는 이유가 장애 대응인데, 정작 장애가 나면 한 서버의 사용자 전체가 영향을 받는 셈이다.

해결 시도 2 — 세션 클러스터링 (Redis)

좀 더 세련된 방법은 모든 서버가 하나의 공유 세션 저장소를 쓰는 것이다. 보통 Redis를 많이 사용한다.

graph TD subgraph "세션 클러스터링" LB[로드 밸런서] S1[서버 1] S2[서버 2] S3[서버 3] R[(Redis)] LB --> S1 LB --> S2 LB --> S3 S1 --> R S2 --> R S3 --> R end style R fill:#f44336,stroke:#333,stroke-width:2px,color:#fff style S1 fill:#4CAF50,stroke:#333,stroke-width:2px,color:#fff style S2 fill:#4CAF50,stroke:#333,stroke-width:2px,color:#fff style S3 fill:#4CAF50,stroke:#333,stroke-width:2px,color:#fff

어느 서버로 요청이 가든 같은 Redis를 조회하니까, Sticky Session 문제를 깔끔하게 해결한다. Spring에서는 설정 몇 줄이면 된다.

spring:
  session:
    store-type: redis
  redis:
    host: session-redis.internal
    port: 6379

하지만 이것도 근본적인 한계가 있다.

단일 장애점

모든 서버가 Redis에 의존하니까, Redis가 죽으면 전체 서비스의 인증이 마비된다. Redis를 이중화(Sentinel, Cluster)하면 완화할 수 있지만, 그만큼 인프라 복잡도와 비용이 올라간다.

네트워크 지연

모든 API 요청마다 Redis를 네트워크로 조회해야 한다. 서버 메모리 조회는 마이크로초 단위지만, 네트워크를 타면 밀리초 단위로 느려진다. 요청 하나하나에 이 지연이 붙으니, 트래픽이 많을수록 체감된다.

확장 비용

서버를 10대에서 100대로 늘리면, Redis에 쏟아지는 조회도 10배가 된다. 서버는 쉽게 추가할 수 있지만, Redis 클러스터의 용량과 성능도 함께 확장해야 한다. 서버 확장과 세션 인프라 확장이 커플링되어 있는 것이다.

확장성의 함정

서버를 추가할 때마다 공유 인프라(Redis)의 부하도 선형으로 증가한다. 이건 서버를 100대, 1000대 단위로 운영하는 대규모 환경에서 심각한 병목이 된다.

두 해결책의 공통적인 한계

Sticky Session과 세션 클러스터링, 두 방법 모두 결국 "서버가 상태를 들고 있다"는 전제를 유지한 채 문제를 우회하려는 시도다.

Sticky Session세션 클러스터링 (Redis)
핵심 전략같은 서버로 고정공유 저장소 사용
부하 분산불균형 가능균등 분산 가능
서버 장애해당 서버 세션 유실Redis가 살아있으면 유지
추가 인프라불필요Redis 클러스터 필요
확장 비용낮음Redis 확장과 커플링
근본 문제여전히 상태 보관여전히 상태 보관

어떤 방법을 쓰든, "서버(또는 서버가 의존하는 저장소)가 사용자의 로그인 상태를 기억하고 있어야 한다"는 제약을 벗어나지 못한다.

그렇다면 발상을 전환해보자. 서버가 상태를 아예 기억하지 않으면 어떨까? 사용자가 "나는 누구이고, 어떤 권한이 있다"는 정보를 직접 들고 다니고, 서버는 그걸 검증만 하면 되지 않을까?

이것이 바로 토큰 기반 인증의 핵심 아이디어다. 다음 편에서 이 Stateless 인증이 구체적으로 어떻게 동작하는지 살펴보자.