JWT 시리즈

1. 토큰 기반 인증의 개념과 필요성

2. JWT의 구조와 원리 (현재 글)

이전 편에서 토큰 기반 인증이 왜 필요한지 살펴봤다. 이번 편에서는 그 토큰의 실체인 JWT가 어떻게 생겼고, 어떤 원리로 동작하는지 파고들어 본다.

JWT란 무엇인가

JWT(JSON Web Token)는 JSON 형식으로 인코딩된, 서명이 포함된 토큰이다. RFC 7519 표준으로 정의되어 있으며, API 인증에서 사실상의 표준으로 자리 잡았다.

JWT 이전에는 회사마다 토큰 형식이 제각각이었다. 토큰의 구조, 검증 방식이 통일되지 않으니 상호 운용성도 없었고, 보안 취약점도 많았다. JWT는 이 문제를 해결하기 위해 구조와 검증 방식을 명확하게 표준화한 것이다.

Opaque Token과의 차이

인증 토큰은 크게 두 종류로 나뉜다.

  • Opaque Token (불투명 토큰)7a9f2c8e1b4d3a5f6e8c9d0a 같은 랜덤 문자열이다. 이 문자열 자체에는 아무 정보가 없으므로, 서버가 DB를 조회해야만 누구의 토큰인지 알 수 있다.
  • JWT (Self-contained 토큰) — 토큰 안에 사용자 정보가 JSON으로 들어 있다. 서버는 토큰을 열어보면 바로 사용자를 식별할 수 있다.

이 "Self-contained" 특성 덕분에 JWT는 외부 저장소 조회 없이 인증을 처리할 수 있고, 이것이 마이크로서비스와 분산 환경에서 JWT가 선호되는 이유다.

JWT의 활용 사례

JWT는 API 인증 외에도 다양하게 활용된다.

  • SSO (Single Sign-On) — 인증 서버가 JWT를 발급하면, 여러 서비스가 같은 토큰으로 사용자를 식별한다
  • 서비스 간 정보 교환 — 서명이 포함되어 있으므로 위변조 방지가 보장된 데이터 전달 수단이 된다

JWT의 세 가지 구성 요소

JWT는 점(.)으로 구분된 세 부분으로 이루어져 있다.

eyJhbGciOiJIUzI1NiJ9.eyJ1c2VyX2lkIjoxfQ.SflKxwRJSMeKKF2QT4f
├── Header ──────────┤├── Payload ────────┤├── Signature ──────┤

각 부분은 Base64URL로 인코딩된 JSON이다. 하나씩 살펴보자.

Header

Header는 토큰의 메타데이터를 담는다.

{
  "alg": "HS256",
  "typ": "JWT"
}
  • alg — 서명에 사용할 알고리즘. HS256, RS256, ES256 등이 있다
  • typ — 토큰 타입. 항상 "JWT"

이 JSON을 Base64URL로 인코딩하면 eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9 같은 문자열이 된다.

알고리즘방식특징적합한 상황
HS256대칭키 (HMAC)같은 키로 서명 & 검증단일 서버, 내부 API
RS256비대칭키 (RSA)개인키로 서명, 공개키로 검증마이크로서비스, SSO
ES256비대칭키 (ECDSA)RSA보다 키가 작고 빠름모바일, IoT

Payload

Payload는 실제 데이터인 클레임(Claims)을 담는다. 클레임이란 Key-Value 쌍으로 된 정보 조각이다.

{
  "sub": "user@example.com",
  "user_id": 1,
  "role": "USER",
  "iat": 1669996400,
  "exp": 1670000000
}

클레임은 세 종류로 나뉜다.

등록된 클레임 (Registered Claims)

JWT 표준에서 미리 정의해놓은 예약된 클레임이다. 모두 선택 사항이지만, 상호 운용성을 위해 권장된다.

클레임이름설명
issIssuer토큰 발급자
subSubject토큰 주체 (사용자 식별자)
audAudience토큰 대상 (의도된 수신자)
expExpiration만료 시간 (Unix timestamp)
nbfNot Before이 시간 이전에는 유효하지 않음
iatIssued At발급 시간
jtiJWT ID토큰 고유 식별자

이 중 exp가 가장 중요하다. 서버는 토큰을 받으면 exp를 현재 시간과 비교해서, 만료된 토큰은 자동으로 거부한다.

비공개 클레임 (Private Claims)

개발자가 자유롭게 정의하는 커스텀 클레임이다. user_id, role, permissions 같은 것들이 여기에 해당한다. 실무에서 가장 많이 다루게 되는 부분이다.

공개 클레임 (Public Claims)

충돌 방지를 위해 URL 형식으로 작성하는 클레임인데, 실무에서는 거의 사용하지 않는다.

Payload에 민감 정보 금지

JWT는 인코딩되었을 뿐 암호화되지 않는다. Base64URL 디코딩만 하면 Payload 내용을 누구나 볼 수 있다. 비밀번호, 카드번호 같은 민감한 정보는 절대 넣으면 안 된다.

jjwt 라이브러리로 클레임을 생성하고 읽는 코드를 보자.

String jwt = Jwts.builder()
    .setSubject("user@example.com")
    .setIssuedAt(new Date())
    .setExpiration(new Date(System.currentTimeMillis() + 3600000))
    .claim("user_id", 1)
    .claim("role", "USER")
    .signWith(SignatureAlgorithm.HS256, secretKey)
    .compact();
  • .setSubject(), .setIssuedAt(), .setExpiration() — 등록된 클레임을 설정하는 빌더 메서드
  • .claim("user_id", 1) — 비공개 클레임을 Key-Value로 추가

파싱할 때는 이렇게 꺼낸다.

Claims claims = Jwts.parser()
    .setSigningKey(secretKey)
    .parseClaimsJws(jwt)
    .getBody();

String email = claims.getSubject();
Integer userId = claims.get("user_id", Integer.class);
String role = claims.get("role", String.class);
  • .getSubject() — 등록된 클레임 sub를 바로 꺼내는 편의 메서드
  • .get("user_id", Integer.class) — 비공개 클레임은 Key와 타입을 지정해서 꺼낸다

Signature

Signature는 토큰의 무결성을 보장하는 서명이다. 토큰이 중간에 변조되지 않았는지, 신뢰할 수 있는 서버에서 발급했는지를 검증하는 핵심이다.

서명 생성 과정을 시퀀스 다이어그램으로 보면 이렇다.

sequenceDiagram participant H as Header JSON participant P as Payload JSON participant E as Base64URL 인코더 participant HMAC as HMAC-SHA256 participant JWT as 완성된 JWT H->>E: {"alg":"HS256","typ":"JWT"} Note over E: eyJhbGciOiJIUzI1NiJ9 P->>E: {"user_id":1,"role":"USER"} Note over E: eyJ1c2VyX2lkIjoxfQ E->>HMAC: header.payload + secret_key Note over HMAC: 서명 생성
(CPU 연산) HMAC->>JWT: header.payload.signature Note over JWT: 세 부분이 점으로
연결된 문자열

과정을 풀어쓰면 이렇다.

  1. Header와 Payload를 각각 Base64URL 인코딩한다
  2. 두 문자열을 점(.)으로 연결한다
  3. 연결된 문자열을 지정된 알고리즘(예: HMAC-SHA256)과 Secret Key로 서명한다
  4. Header + . + Payload + . + Signature를 합쳐 최종 JWT를 만든다

서명과 검증

서명이 왜 중요한지는 위변조 시나리오를 생각하면 명확해진다.

위변조 방지 원리

공격자가 Payload의 role"USER"에서 "ADMIN"으로 바꿨다고 하자. Payload가 바뀌면 서명도 달라져야 하는데, 공격자는 Secret Key를 모르기 때문에 올바른 서명을 만들 수 없다. 서버는 토큰을 받으면 Header와 Payload로 서명을 다시 계산해서 비교하기 때문에, 변조된 토큰은 즉시 거부된다.

sequenceDiagram participant Attacker as 공격자 participant Server as 서버 participant HMAC as HMAC-SHA256 Attacker->>Server: 변조된 JWT 전송 Note over Attacker: role을 ADMIN으로 변조
하지만 서명은 그대로 Server->>HMAC: header.변조된payload + secret_key Note over HMAC: 예상 서명 생성 HMAC-->>Server: 예상 서명 반환 Note over Server: 예상 서명 ≠ 토큰의 서명
→ 위변조 감지 Server-->>Attacker: 401 Unauthorized

이것이 "서명"이 존재하는 이유다. 서명 덕분에 서버는 토큰의 발급 이후 어떤 변경도 감지할 수 있다.

대칭키(HMAC) 서명

HS256(HMAC-SHA256)은 같은 키로 서명하고 검증하는 방식이다. 구현이 간단하고 성능이 좋아서 단일 서버나 내부 API에서 주로 쓴다.

public String generateToken(User user) {
    return Jwts.builder()
        .setSubject(user.getEmail())
        .claim("user_id", user.getId())
        .setIssuedAt(new Date())
        .setExpiration(new Date(System.currentTimeMillis() + 3600000))
        .signWith(SignatureAlgorithm.HS256, SECRET_KEY)
        .compact();
}
  • .signWith(SignatureAlgorithm.HS256, SECRET_KEY) — 지정한 Secret Key로 HMAC-SHA256 서명을 생성한다

단, Secret Key를 서명과 검증 양쪽에서 모두 써야 하므로, 여러 서비스가 키를 공유해야 한다는 부담이 있다. Key가 노출되면 모든 토큰이 위험해진다.

Secret Key 관리

Secret Key는 코드에 하드코딩하지 말고, 환경 변수나 설정 파일에서 로드해야 한다. 최소 256비트 이상의 충분한 길이를 사용할 것.

비대칭키(RSA) 서명

RS256(RSA-SHA256)은 개인키로 서명하고, 공개키로 검증하는 방식이다. 인증 서버만 개인키를 보관하고, 나머지 서비스들은 공개키만 있으면 토큰을 검증할 수 있다.

graph TD subgraph "인증 서버" PK[개인키 보관] Sign[JWT 서명 생성] PK --> Sign end subgraph "API 서버들" PUB[공개키만 보관] V1[주문 서비스 - 검증] V2[결제 서비스 - 검증] V3[배송 서비스 - 검증] PUB --> V1 PUB --> V2 PUB --> V3 end Sign -->|JWT 발급| V1 Sign -->|JWT 발급| V2 Sign -->|JWT 발급| V3 style PK fill:#f44336,stroke:#333,stroke-width:2px,color:#fff style PUB fill:#4CAF50,stroke:#333,stroke-width:2px,color:#fff

마이크로서비스 환경에서는 이 방식이 유리하다. 개인키가 인증 서버에만 있으니, API 서버가 해킹당해도 새로운 토큰을 위조할 수 없다.

// 인증 서버 — 개인키로 서명
public String generateToken(User user) {
    return Jwts.builder()
        .setSubject(user.getEmail())
        .signWith(SignatureAlgorithm.RS256, privateKey)
        .compact();
}

// API 서버 — 공개키로 검증
public Claims validateToken(String token) {
    return Jwts.parser()
        .setSigningKey(publicKey)
        .parseClaimsJws(token)
        .getBody();
}
  • 서명에는 privateKey, 검증에는 publicKey를 사용한다. 서로 다른 키라는 점이 HMAC과의 핵심 차이다.

HMAC보다 연산이 느리고 키 관리가 복잡하다는 단점이 있지만, 보안성과 확장성 면에서 마이크로서비스에는 RSA가 더 적합하다.

알고리즘 선택 기준

어떤 알고리즘을 써야 할지는 환경에 따라 달라진다.

  • 단일 서버, 내부 APIHS256 — 구현이 간단하고 빠르다. Secret Key를 공유할 대상이 적으니 관리 부담도 낮다.
  • 마이크로서비스, 공개 APIRS256 — 공개키만 배포하면 되므로 서비스 간 키 공유 문제가 없다.
  • 모바일, IoTES256 — RSA보다 키 크기가 작고 성능이 좋아서 리소스가 제한된 환경에 적합하다.

인코딩과 디코딩

JWT에서 쓰는 Base64URL 인코딩을 간단히 짚고 넘어가자.

Base64URL 인코딩

일반 Base64는 +, /, = 문자를 사용하는데, 이들은 URL에서 특별한 의미를 가진다. Base64URL은 +-로, /_로 바꾸고, 패딩(=)을 제거해서 URL에서 안전하게 사용할 수 있게 한 변형이다.

중요한 건 이게 인코딩이지 암호화가 아니라는 점이다. 디코딩은 누구나 할 수 있다.

jwt.io 디버거

JWT를 분석할 때는 jwt.io를 쓰면 편리하다. 토큰을 붙여넣으면 Header, Payload를 자동으로 디코딩해서 보여주고, Secret Key를 입력하면 서명 검증까지 해준다.

디버깅 팁

개발 중 토큰 관련 오류가 나면 jwt.io에 토큰을 먼저 붙여넣어 보자. Payload 내용, 만료 시간, 서명 유효 여부를 한눈에 확인할 수 있다.

검증 과정 전체 흐름

JWT를 서버에서 받아 검증하는 전체 과정을 정리하면 이렇다.

sequenceDiagram participant Client as 클라이언트 participant Server as 서버 participant Parser as JWT 파서 Client->>Server: Authorization: Bearer eyJhbG... Server->>Parser: 토큰 분리 (Header.Payload.Signature) Note over Parser: 1단계 — 형식 검증
3개 부분으로 나뉘는가? Note over Parser: 2단계 — 서명 검증
Header+Payload로 서명 재생성
실제 서명과 비교 Note over Parser: 3단계 — Claims 검증
exp 만료 확인
iss 발급자 확인
aud 대상 확인 alt 검증 성공 Parser-->>Server: Claims 반환 Server-->>Client: 200 OK else 검증 실패 Parser-->>Server: 예외 발생 Server-->>Client: 401 Unauthorized end

jjwt 라이브러리는 parseClaimsJws() 호출 시 서명 검증과 만료 시간 확인을 자동으로 처리한다. 추가 검증이 필요하면 직접 코드를 작성하면 된다.

public Claims validateToken(String token) {
    try {
        Claims claims = Jwts.parser()
            .setSigningKey(secretKey)
            .parseClaimsJws(token)
            .getBody();

        String issuer = claims.getIssuer();
        if (!"https://auth.example.com".equals(issuer)) {
            throw new InvalidTokenException("잘못된 발급자");
        }
        return claims;

    } catch (ExpiredJwtException e) {
        throw new TokenExpiredException("토큰이 만료되었다.");
    } catch (MalformedJwtException e) {
        throw new InvalidTokenException("잘못된 토큰 형식이다.");
    } catch (SignatureException e) {
        throw new InvalidTokenException("서명 검증에 실패했다.");
    }
}
  • parseClaimsJws() — 서명과 exp를 자동으로 검증한다. 실패하면 각각 다른 예외를 던진다.
  • ExpiredJwtException, MalformedJwtException, SignatureException — 상황별로 예외를 분리 처리하면 클라이언트에게 정확한 에러 메시지를 줄 수 있다.

자주 하는 실수

Secret Key를 코드에 하드코딩

개발 환경에서는 편하지만, 코드가 Git에 올라가는 순간 키가 노출된다. 반드시 환경 변수(@Value("${jwt.secret}"))나 별도 설정 파일로 관리해야 한다.

Payload에 너무 많은 정보를 담기

토큰은 모든 HTTP 요청의 헤더에 포함된다. Payload가 커지면 매 요청의 트래픽이 증가한다. 인증과 인가에 꼭 필요한 최소한의 정보만 넣자.

만료 시간을 너무 길게 설정

Access Token의 만료 시간을 며칠 단위로 설정하면, 토큰이 탈취되었을 때 그 기간 동안 아무런 대응을 할 수 없다. 보통 15분~1시간 정도가 적절하다.

면접 대비

JWT 면접 Q&A

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

Header는 서명 알고리즘과 토큰 타입을, Payload는 사용자 정보(클레임)를, Signature는 토큰의 무결성을 검증하는 서명을 담는다.

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

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

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

HMAC은 같은 키로 서명과 검증을 하는 대칭키 방식이고, RSA는 개인키로 서명하고 공개키로 검증하는 비대칭키 방식이다. 단일 서버에서는 HMAC이 간편하고, 마이크로서비스에서는 RSA가 보안적으로 유리하다.

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

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

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

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

Q. Access Token의 만료 시간은 어떻게 설정해야 하나?

보통 15분~1시간 정도로 짧게 설정하고, 만료 시 Refresh Token으로 재발급받는 방식을 쓴다. 만료 시간이 길면 토큰 탈취 시 대응 시간이 늘어나고, 너무 짧으면 사용자 경험이 나빠진다.

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

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