JWT deep dive (5/5)

이전 편: [JWT] 4. 서명과 검증의 원리

이 문서가 시리즈의 마지막 편입니다.

JWT deep dive 시리즈

1. 1 세션의 한계와 토큰의 등장

2. 2 Stateless 인증이란

3. 3 JWT의 구조 해부

4. 4 서명과 검증의 원리

5. JWT 실전 팁과 면접 대비 (현재 글)

지금까지 세션의 한계, Stateless 인증, JWT의 구조, 서명 원리를 살펴봤다. 마지막 편에서는 실무에서 흔히 저지르는 실수, Refresh Token 전략, 보안 공격 대응, 그리고 면접에서 자주 나오는 질문을 다룬다.

자주 하는 실수

Secret Key를 코드에 하드코딩

개발 중에는 편하지만, 코드가 Git에 올라가는 순간 키가 노출된다.

// 위험 — 키가 코드에 노출됨
private static final String SECRET = "mySecretKey123";
// 안전 — 환경 변수에서 로드
@Value("${jwt.secret}")
private String secret;

Secret Key가 Git 히스토리에 한 번이라도 올라갔다면, 키를 변경해야 한다. git rm으로 파일을 삭제해도 히스토리에는 남아 있기 때문이다.

Secret Key 요건

- 최소 256비트(32바이트) 이상의 충분한 길이

- 추측 불가능한 랜덤 값 (openssl rand -base64 32로 생성)

- 환경 변수, Vault, AWS Secrets Manager 같은 외부 저장소에서 관리

Payload에 너무 많은 정보

토큰은 모든 HTTP 요청의 헤더에 포함된다. Payload가 커지면 매 요청의 트래픽이 그만큼 증가한다.

// 과도한 Payload — 불필요하게 큰 토큰
{
  "user_id": 1,
  "email": "user@example.com",
  "name": "김철수",
  "phone": "010-1234-5678",
  "address": "서울시 강남구 ...",
  "profile_image": "https://cdn.example.com/...",
  "permissions": ["read", "write", "delete", "admin", ...],
  "login_history": [...]
}
// 적절한 Payload — 인증/인가에 필요한 최소 정보
{
  "sub": "user@example.com",
  "user_id": 1,
  "role": "USER",
  "iat": 1669996400,
  "exp": 1670000000
}

원칙은 간단하다. 인증과 인가에 꼭 필요한 정보만 넣는다. 프로필 이미지나 주소 같은 건 필요할 때 DB에서 조회하면 된다.

만료 시간을 너무 길게 설정

Access Token의 만료 시간을 며칠 단위로 설정하면, 토큰이 탈취되었을 때 그 기간 동안 아무런 대응을 할 수 없다. JWT는 발급 후 서버에서 강제로 무효화할 수 없다는 점을 항상 기억해야 한다.

// 위험 — 7일짜리 Access Token
.setExpiration(new Date(System.currentTimeMillis() + 7 * 24 * 3600000))

// 권장 — 30분짜리 Access Token + Refresh Token 전략
.setExpiration(new Date(System.currentTimeMillis() + 30 * 60 * 1000))

보통 Access Token은 15분~1시간 정도가 적절하다. 이렇게 짧게 설정하면 사용자가 자주 재로그인해야 하는 불편함이 생기는데, 이걸 해결하는 게 Refresh Token이다.

Refresh Token 전략

Access Token을 짧게 유지하면서도 사용자 경험을 해치지 않으려면, 두 종류의 토큰을 함께 사용한다.

  • Access Token — API 요청에 사용하는 짧은 수명의 토큰 (15분~1시간)
  • Refresh Token — Access Token을 재발급받기 위한 긴 수명의 토큰 (7일~30일)
sequenceDiagram participant Client as 클라이언트 participant Auth as 인증 서버 participant API as API 서버 Client->>Auth: POST /login {email, password} Auth-->>Client: Access Token (30분) + Refresh Token (7일) Note over Client: Access Token으로 API 호출 Client->>API: GET /profile + Access Token API-->>Client: 200 OK Note over Client: ... 30분 경과 ... Client->>API: GET /orders + Access Token API-->>Client: 401 Token Expired Note over Client: Access Token 만료 감지
→ Refresh Token으로 재발급 Client->>Auth: POST /refresh + Refresh Token Note over Auth: Refresh Token 검증 Auth-->>Client: 새 Access Token (30분) Client->>API: GET /orders + 새 Access Token API-->>Client: 200 OK

이 방식의 핵심은 역할 분리다.

  • Access Token이 탈취되면? 30분이면 만료되니까 피해 범위가 제한된다
  • Refresh Token이 탈취되면? 이건 심각하다. 그래서 Refresh Token은 더 안전하게 관리해야 한다

Refresh Token 보관 위치

Access Token은 보통 메모리(JavaScript 변수)에 둔다. 페이지를 새로고침하면 사라지지만, XSS 공격으로부터 비교적 안전하다.

Refresh Token은 HttpOnly 쿠키에 저장하는 것이 일반적이다. JavaScript에서 접근할 수 없으니 XSS에 안전하고, 브라우저가 자동으로 전송하니 구현도 간편하다.

저장 위치XSS 방어CSRF 방어적합한 토큰
JavaScript 변수 (메모리)비교적 안전해당 없음Access Token
localStorage취약해당 없음권장하지 않음
HttpOnly 쿠키안전별도 대응 필요Refresh Token
Refresh Token Rotation

Refresh Token을 사용할 때마다 새로운 Refresh Token을 발급하고, 이전 것은 무효화하는 전략이다. Refresh Token이 탈취되었을 때, 정상 사용자와 공격자 중 누군가의 Refresh Token이 무효화되면서 탈취를 감지할 수 있다.

보안 공격과 대응

alg: none 공격

JWT의 alg 필드를 "none"으로 설정하면 서명 없이 토큰을 만들 수 있다. 오래된 라이브러리 중에는 이걸 허용하는 경우가 있었다.

// 공격자가 만든 Header
{
  "alg": "none",
  "typ": "JWT"
}

서명이 없으니 누구나 유효한 토큰을 만들 수 있다. 대응은 간단하다. 서버 측에서 허용할 알고리즘을 명시적으로 제한하면 된다.

Jwts.parser()
    .setAllowedClockSkewSeconds(0)
    .setSigningKey(secretKey)
    .parseClaimsJws(token);
  • jjwt 같은 현대 라이브러리는 기본적으로 alg: none을 거부한다. 하지만 직접 구현하거나 오래된 라이브러리를 쓸 때는 주의해야 한다.

토큰 탈취 (XSS, 네트워크 스니핑)

토큰은 클라이언트가 보관하고 있으므로, 탈취 위험이 세션보다 높다.

  • XSS 방어localStorage에 토큰을 저장하면 스크립트로 접근 가능하다. HttpOnly 쿠키를 사용하거나, Content Security Policy(CSP) 헤더를 설정한다
  • 네트워크 스니핑 방어 — 반드시 HTTPS를 사용한다. HTTP에서는 Authorization 헤더가 평문으로 전송된다
  • 탈취 후 대응 — Access Token 만료 시간을 짧게 유지하고, Refresh Token Rotation을 적용한다

토큰 강제 무효화

JWT는 Stateless이므로 기본적으로 서버에서 강제 무효화할 수 없다. 하지만 실무에서는 계정 정지, 비밀번호 변경 시 강제 로그아웃이 필요한 경우가 있다.

이때 사용하는 게 블랙리스트 방식이다. 무효화할 토큰의 jti(JWT ID)를 Redis에 저장하고, 검증 시 블랙리스트에 있는지 확인한다.

sequenceDiagram participant Client as 클라이언트 participant Server as 서버 participant Redis as Redis
(블랙리스트) Client->>Server: 요청 + JWT Server->>Redis: jti가 블랙리스트에 있는가? alt 블랙리스트에 없음 Redis-->>Server: 없음 Note over Server: 정상 검증 진행 Server-->>Client: 200 OK else 블랙리스트에 있음 Redis-->>Server: 있음 (무효화된 토큰) Server-->>Client: 401 Unauthorized end

이 방식은 Stateless의 장점을 일부 포기하는 트레이드오프다. 다만 전체 세션을 Redis에서 관리하는 것과 달리, 블랙리스트는 무효화된 소수의 토큰만 저장하므로 부담이 훨씬 적다. 블랙리스트 항목은 해당 토큰의 exp 시간이 지나면 자동으로 삭제하면 된다.

JWT 활용 사례

API 인증 외에도 JWT가 활용되는 곳이 있다.

SSO (Single Sign-On)

인증 서버가 JWT를 발급하면, 여러 서비스가 같은 토큰으로 사용자를 식별한다. 서비스마다 별도의 인증 체계를 구축할 필요가 없고, 한 번 로그인으로 여러 서비스를 이용할 수 있다.

graph TD subgraph "SSO 환경" Auth[인증 서버
JWT 발급] S1[메일 서비스] S2[캘린더 서비스] S3[드라이브 서비스] Auth -->|"JWT 발급"| S1 Auth -->|"같은 JWT"| S2 Auth -->|"같은 JWT"| S3 end style Auth fill:#42A5F5,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

서비스 간 정보 교환

JWT는 서명이 포함되어 있으므로, 위변조가 불가능한 데이터 전달 수단이 된다. 인증 목적이 아니더라도, 서비스 간에 신뢰할 수 있는 데이터를 주고받아야 할 때 활용할 수 있다.

면접 대비

JWT 면접 Q&A

Q. JWT의 세 가지 구성 요소와 각각의 역할은?

Header는 서명 알고리즘과 토큰 타입을 지정하고, Payload는 사용자 정보와 만료 시간 같은 클레임을 담으며, Signature는 Header와 Payload가 변조되지 않았음을 보증하는 서명이다.

Q. JWT는 암호화된 토큰인가?

아니다. JWT는 Base64URL로 인코딩된 것이지 암호화된 것이 아니다. 디코딩하면 Payload의 내용을 누구나 볼 수 있으므로, 민감한 정보를 넣으면 안 된다. 암호화가 필요하면 JWE(JSON Web Encryption)를 사용해야 한다.

Q. HMAC(HS256)과 RSA(RS256)의 차이는?

HMAC은 같은 키로 서명과 검증을 하는 대칭키 방식이고, RSA는 개인키로 서명하고 공개키로 검증하는 비대칭키 방식이다. HMAC은 검증 권한을 가진 쪽이 위조도 할 수 있지만, RSA는 공개키로는 검증만 가능하고 위조는 불가능하다. 단일 서버에서는 HMAC이 간편하고, 마이크로서비스에서는 RSA가 보안적으로 유리하다.

Q. JWT의 Payload를 변조하면 어떻게 되나?

Payload가 바뀌면 서명이 일치하지 않게 된다. 서버는 Header+Payload로 서명을 재계산해서 토큰의 서명과 비교하므로, 변조된 토큰은 검증 단계에서 거부된다. 공격자는 Secret Key를 모르기 때문에 변조된 Payload에 맞는 올바른 서명을 만들 수 없다.

Q. JWT를 서버에서 즉시 무효화할 수 있는가?

기본적으로는 불가능하다. JWT는 Stateless이므로 서버가 발급한 토큰의 상태를 추적하지 않는다. 강제 무효화가 필요하면 블랙리스트 방식(Redis에 무효화할 토큰의 jti를 저장)을 사용하는데, 이는 Stateless의 장점을 일부 포기하는 트레이드오프다.

Q. Access Token과 Refresh Token은 왜 분리하나?

Access Token은 짧은 수명(15분~1시간)으로 탈취 시 피해를 제한하고, Refresh Token은 긴 수명(7일~30일)으로 사용자 경험을 유지한다. Access Token이 만료되면 Refresh Token으로 새 Access Token을 발급받는다. 역할을 분리함으로써 보안과 편의성을 동시에 확보할 수 있다.

Q. alg 필드를 none으로 설정하면 어떻게 되나? (함정)

alg: "none"은 서명 없이 JWT를 만든다는 뜻으로, 유명한 JWT 공격 기법이다. 서버가 이를 허용하면 누구나 유효한 토큰을 만들 수 있다. 반드시 서버 측에서 허용할 알고리즘을 명시적으로 제한해야 한다.

Q. JWT 토큰이 탈취되면 어떻게 대응하나? (함정)

JWT 자체를 즉시 무효화할 수는 없다. 그래서 사전에 대비하는 것이 핵심이다. Access Token 만료를 짧게 유지하고, Refresh Token Rotation을 적용하며, HTTPS 필수 사용과 HttpOnly 쿠키로 탈취 자체를 어렵게 만든다. 사후 대응이 필요하면 블랙리스트를 사용하되, 이것이 Stateless를 일부 포기하는 트레이드오프임을 인지해야 한다.