스프링 시큐리티 JWT

스프링 시큐리티 JWT - 1. 실습 목표 및 간단한 동작 원리

https://youtu.be/NPRh2v7PTZg?si=9FIfXw6Y2e2KgE-q

스프링 시큐리티 JWT - 1. 실습 목표 및 간단한 동작 원리


Spring Security JWT 인증·인가 완벽 정리: 회원가입부터 로그인, 토큰 발급과 검증까지

Spring Security는 Java와 Spring Boot 환경에서 인증(Authentication)과 인가(Authorization)를 구현하기 위한 보안 프레임워크다.

사용자의 로그인 요청을 검증하고, 인증된 사용자가 접근할 수 있는 리소스를 제한하며, 애플리케이션의 인증 상태를 관리하는 기능을 제공한다.

Spring Security에서 사용되는 대표적인 인증 방식에는 세션 기반 인증과 토큰 기반 인증이 있다.

세션 기반 인증은 일반적으로 사용자가 로그인하면 서버에서 인증 상태를 저장하고, 이후 요청에서 세션 식별자를 이용해 사용자를 확인하는 방식이다.

반면 JWT 기반 인증은 로그인에 성공한 사용자에게 토큰을 발급하고, 클라이언트가 이후 요청마다 해당 토큰을 전달하도록 구성할 수 있다.

예를 들어 다음과 같은 시스템을 생각해보자.

Client
   ↓
POST /api/auth/login
   ↓
Spring Security
   ↓
아이디 및 비밀번호 검증
   ↓
JWT 발급
   ↓
Client에 토큰 반환

로그인 이후에는 발급받은 JWT를 이용해 보호된 API에 접근한다.

Client
   ↓
Authorization: Bearer <JWT>
   ↓
Spring Security
   ↓
JWT 검증
   ↓
인증 정보 구성
   ↓
권한 확인
   ↓
Controller 실행

이때 서버는 매 요청에서 전달된 JWT를 검증해 사용자를 식별할 수 있다.

따라서 서버에 사용자별 로그인 세션을 유지하지 않는 Stateless 인증 구조를 구현할 수 있다.

다만 JWT를 사용한다고 해서 자동으로 보안이 강화되거나 모든 세션 관리 문제가 사라지는 것은 아니다.

토큰의 서명 검증, 유효기간 관리, 권한 처리, 탈취 대응, 로그아웃과 토큰 폐기 등 별도로 고려해야 할 사항이 존재한다.

이번에는 Spring Boot 3.2.1과 Spring Security 6.2.1을 기준으로 JWT 인증·인가 시스템의 전체 구조를 이해하고, 각 구성요소가 어떤 역할을 담당하는지 살펴본다.


개념

인증(Authentication)이란?

인증은 사용자가 누구인지 확인하는 과정이다.

예를 들어 사용자가 다음과 같은 정보를 이용해 로그인을 요청한다고 생각해보자.

{
  "username": "user01",
  "password": "password123!"
}

서버는 전달받은 사용자 정보가 실제로 등록된 사용자의 정보와 일치하는지 검증한다.

일반적으로 다음과 같은 과정을 거친다.

로그인 요청
    ↓
사용자 조회
    ↓
저장된 비밀번호 해시 확인
    ↓
입력한 비밀번호 검증
    ↓
인증 성공 또는 실패

인증이 성공했다는 것은 서버가 해당 요청의 사용자를 확인했다는 의미다.

하지만 인증되었다고 해서 모든 API에 접근할 수 있는 것은 아니다.

접근 가능한 리소스를 결정하는 과정은 인가에서 담당한다.

인가(Authorization)란?

인가는 인증된 사용자가 특정 리소스에 접근할 권한이 있는지 확인하는 과정이다.

예를 들어 다음과 같은 API가 있다고 생각해보자.

API필요한 권한
POST /api/auth/signup인증 불필요
POST /api/auth/login인증 불필요
GET /api/users/me로그인 사용자
GET /api/admin/users관리자
DELETE /api/admin/users/{id}관리자

일반 사용자가 관리자 API에 접근하려고 한다면 인증에는 성공했더라도 권한이 부족하므로 요청이 거부되어야 한다.

일반 사용자
    ↓
JWT 검증 성공
    ↓
ROLE_USER 확인
    ↓
관리자 API 접근 시도
    ↓
ROLE_ADMIN 필요
    ↓
접근 거부

이를 정리하면 다음과 같다.

구분AuthenticationAuthorization
의미사용자 신원 확인접근 권한 확인
핵심 질문누구인가?무엇을 할 수 있는가?
대표적인 정보아이디, 비밀번호, JWTROLE_USER, ROLE_ADMIN
실패 상황유효하지 않은 인증 정보필요한 권한 부족
일반적인 HTTP 응답401 Unauthorized403 Forbidden

인증과 인가는 서로 연결되지만 다른 목적을 가진다.

JWT는 사용자를 인증하는 데 사용할 수 있고, 토큰에서 확인한 권한 정보는 API 인가 판단에 활용할 수 있다.


JWT란?

JWT(JSON Web Token)는 사용자 또는 시스템에 대한 정보를 JSON 형태의 Claim으로 표현하고, 이를 안전하게 전달하기 위한 토큰 형식이다.

JWT는 주로 HTTP 요청의 Authorization 헤더를 통해 전달된다.

GET /api/users/me HTTP/1.1
Host: localhost:8080
Authorization: Bearer <ACCESS_TOKEN>

여기서 Bearer는 해당 토큰을 가지고 있는 클라이언트가 토큰을 제시하는 인증 방식이다.

서버는 전달받은 JWT를 검증한 후 요청을 처리할지 결정한다.

JWT의 기본 구조

일반적으로 서명된 JWT는 세 부분으로 구성된다.

Header.Payload.Signature

각 부분은 마침표로 구분된다.

xxxxx.yyyyy.zzzzz

세 부분의 역할은 다음과 같다.

구성요소역할
Header토큰 유형 및 서명 알고리즘 정보
Payload사용자 식별자, 발급자, 만료 시간 등의 Claim
SignatureHeader와 Payload의 위변조 여부를 검증하기 위한 서명

예를 들어 Header는 다음과 같은 형태를 가질 수 있다.

{
  "alg": "HS256",
  "typ": "JWT"
}

Payload에는 다음과 같은 정보를 포함할 수 있다.

{
  "sub": "user-1001",
  "role": "ROLE_USER",
  "iss": "https://auth.example.com",
  "aud": "example-api",
  "iat": 1790000000,
  "exp": 1790003600
}

각 Claim의 의미는 다음과 같다.

Claim의미
sub토큰의 주체, 일반적으로 사용자 식별자
iss토큰 발급자
aud토큰을 사용할 대상
iat토큰 발급 시간
exp토큰 만료 시간
nbf토큰 사용 가능 시작 시간
role애플리케이션에서 정의한 사용자 권한 정보

여기서 role은 JWT 표준 필수 Claim이 아니라 애플리케이션이 자체적으로 정의할 수 있는 Claim이다.

반면 sub, iss, aud, exp 등은 JWT 표준에서 의미가 정의된 Claim이다.

JWT의 Payload는 암호화된 데이터가 아니다

JWT를 처음 사용할 때 주의해야 할 부분이 있다.

일반적인 서명 JWT의 Header와 Payload는 Base64URL로 인코딩된 데이터다.

Base64URL 인코딩은 암호화가 아니다.

따라서 토큰을 획득한 사람은 Payload의 내용을 확인할 수 있다.

예를 들어 다음과 같은 정보는 토큰에 저장하지 않는 것이 좋다.

{
  "username": "user01",
  "password": "password123!",
  "residentRegistrationNumber": "..."
}

비밀번호나 민감한 개인정보를 JWT에 담는 것은 위험하다.

JWT의 서명은 데이터의 무결성과 발급 주체를 검증하기 위한 것이지 Payload의 내용을 숨기는 기능이 아니다.

민감한 정보는 서버에서 별도로 관리하고, 토큰에는 인증과 인가에 필요한 최소한의 정보만 포함하는 것이 좋다.


왜 JWT를 사용하는가?

세션 기반 인증의 동작 방식

먼저 일반적인 세션 기반 인증을 살펴보자.

사용자가 로그인을 요청한다.

POST /login HTTP/1.1
Content-Type: application/x-www-form-urlencoded

username=user01&password=password123!

Spring Security는 사용자를 인증한다.

인증에 성공하면 서버는 세션에 인증 상태를 저장하도록 구성할 수 있다.

사용자 로그인
    ↓
아이디 및 비밀번호 검증
    ↓
인증 성공
    ↓
HttpSession에 인증 상태 저장
    ↓
세션 식별자 발급

클라이언트는 일반적으로 세션 식별자가 포함된 쿠키를 전달한다.

Cookie: JSESSIONID=...

서버는 이 식별자를 이용해 기존 세션을 찾고 저장된 인증 상태를 확인한다.

sequenceDiagram
    participant Client
    participant Server
    participant Session as Session Store

    Client->>Server: 로그인 요청
    Server->>Server: 사용자 인증
    Server->>Session: 인증 상태 저장
    Server-->>Client: JSESSIONID 쿠키

    Client->>Server: 보호된 API 요청 + JSESSIONID
    Server->>Session: 세션 조회
    Session-->>Server: 기존 인증 정보
    Server-->>Client: API 응답

이 구조에서는 서버가 사용자의 인증 상태를 저장하고 관리한다.

애플리케이션 인스턴스가 여러 개라면 세션을 공유하거나 라우팅을 제어하는 방식을 고려해야 할 수 있다.

예를 들어 Redis와 Spring Session을 이용해 여러 서버가 인증 세션을 공유하도록 구성할 수 있다.

JWT 기반 인증의 동작 방식

JWT 기반 인증에서는 일반적으로 로그인에 성공하면 서버가 JWT를 생성한다.

로그인 요청
    ↓
아이디 및 비밀번호 검증
    ↓
인증 성공
    ↓
JWT 생성
    ↓
클라이언트에 반환

이후 클라이언트는 보호된 API를 호출할 때마다 토큰을 전달한다.

Authorization: Bearer <ACCESS_TOKEN>

서버는 매 요청에서 토큰을 검증한다.

sequenceDiagram
    participant Client
    participant Server
    participant Security as Spring Security

    Client->>Server: POST /api/auth/login
    Server->>Security: 사용자 인증 요청
    Security-->>Server: 인증 성공
    Server-->>Client: Access Token 발급

    Client->>Server: GET /api/users/me
    Note over Client,Server: Authorization: Bearer JWT

    Server->>Security: JWT 검증
    Security->>Security: 인증 정보 구성
    Security-->>Server: 인증 완료
    Server-->>Client: 사용자 정보 반환

이 구조에서는 서버가 이전 로그인 요청의 인증 상태를 HttpSession에 저장하지 않아도 JWT를 통해 사용자를 확인할 수 있다.

따라서 여러 서버 인스턴스가 동일한 서명 검증 체계를 사용한다면 각 인스턴스에서 토큰을 독립적으로 검증할 수 있다.

다만 JWT가 무조건 세션보다 좋은 것은 아니다.

서명된 JWT는 토큰의 유효기간이 남아 있는 동안 사용될 수 있으므로, 강제 로그아웃이나 즉각적인 권한 회수에는 추가 설계가 필요하다.


Session 방식과 JWT 방식 비교

구분Session 기반 인증JWT 기반 인증
인증 상태 관리서버 세션에 저장 가능토큰 자체의 Claim 활용
클라이언트 전달 정보세션 식별자JWT
인증 정보 복원서버 측 세션 조회토큰 검증 후 인증 정보 구성
서버 간 확장세션 공유 또는 라우팅 고려공통 검증 키·정책 필요
만료 처리세션 만료JWT의 exp 검증
로그아웃세션 무효화토큰 폐기 전략 필요
권한 변경 반영세션 갱신 정책에 따라 다름기존 토큰 Claim이 만료 전까지 남을 수 있음
주요 위험세션 탈취토큰 탈취

여기서 중요한 점은 두 방식이 서로 완전히 독립적인 보안 체계를 사용하는 것이 아니라는 사실이다.

Spring Security의 인증·인가 구조는 동일하게 활용하면서, 인증 상태를 유지하고 복원하는 방식이 달라지는 것이다.


주요 특징

JWT 인증 시스템의 전체 구성요소

Spring Security와 JWT를 이용한 인증 시스템에는 다음 구성요소가 사용될 수 있다.

구성요소역할
SecurityFilterChain보안 필터 구성 및 인가 규칙 적용
UsernamePasswordAuthenticationFilter사용자 이름과 비밀번호 기반 인증 요청 처리
AuthenticationManager인증 요청 전달 및 인증 수행 관리
AuthenticationProvider실제 인증 방식 구현
UserDetailsService사용자 정보 조회
UserDetailsSpring Security에서 사용할 사용자 정보
PasswordEncoder비밀번호 해시 및 일치 여부 검증
JwtTokenServiceJWT 생성 및 검증을 담당하도록 설계할 수 있는 컴포넌트
JWT 인증 필터요청에 포함된 JWT 검증
SecurityContextHolder현재 요청 처리 흐름의 인증 정보 접근
AuthorizationManager인증된 사용자의 접근 권한 판단

여기서 JwtTokenService와 JWT 인증 필터는 커스텀 구현에서 사용할 수 있는 구성요소 이름이다.

Spring Security가 반드시 해당 이름의 클래스를 제공하는 것은 아니다.

JWT 인증 기능을 구현하는 방법은 크게 다음 두 가지로 나눌 수 있다.

첫 번째는 커스텀 JWT 필터를 직접 작성하는 방식이다.

두 번째는 Spring Security가 제공하는 OAuth2 Resource Server 기능을 사용하는 방식이다.

먼저 전체 동작 구조를 이해하기 쉽도록 커스텀 JWT 필터를 사용하는 형태를 살펴보고, 이후 기본 제공 기능과의 차이도 정리한다.


회원가입 구조

회원가입은 사용자의 정보를 입력받아 데이터베이스에 저장하는 과정이다.

JWT 방식이라고 해서 회원가입 자체가 특별히 달라지는 것은 아니다.

세션 기반 인증에서도 회원 정보를 데이터베이스에 저장하고, JWT 기반 인증에서도 동일하게 저장할 수 있다.

이번에는 MySQL을 사용자 정보 저장소로 사용하고 Spring Data JPA를 통해 데이터베이스에 접근한다고 가정한다.

회원가입 API

클라이언트는 다음 요청을 보낸다.

POST /api/auth/signup HTTP/1.1
Content-Type: application/json

요청 본문은 다음과 같다.

{
  "username": "user01",
  "password": "password123!"
}

서버에서는 다음 순서로 처리한다.

회원가입 요청
    ↓
JoinController
    ↓
JoinService
    ↓
입력값 검증
    ↓
중복 사용자 확인
    ↓
비밀번호 해시 처리
    ↓
User Entity 생성
    ↓
UserRepository
    ↓
MySQL 저장

이를 구조로 표현하면 다음과 같다.

sequenceDiagram
    participant Client
    participant Controller as JoinController
    participant Service as JoinService
    participant Encoder as PasswordEncoder
    participant Repository as UserRepository
    participant DB as MySQL

    Client->>Controller: POST /api/auth/signup
    Controller->>Service: 회원가입 요청 전달

    Service->>Service: 입력값 및 중복 여부 확인
    Service->>Encoder: 비밀번호 해시
    Encoder-->>Service: 해시된 비밀번호

    Service->>Repository: User Entity 저장
    Repository->>DB: INSERT
    DB-->>Repository: 저장 완료

    Repository-->>Service: 회원 정보
    Service-->>Controller: 가입 결과
    Controller-->>Client: HTTP 201 Created

사용자 테이블 구조

예를 들어 다음과 같은 사용자 테이블을 사용할 수 있다.

컬럼타입 예시설명
idBIGINT사용자 PK
usernameVARCHAR로그인 아이디
passwordVARCHAR비밀번호 해시
roleVARCHAR사용자 권한
created_atDATETIME생성 일시

중요한 것은 비밀번호를 원문 그대로 저장하지 않는다는 점이다.

비밀번호는 BCryptPasswordEncoder와 같은 PasswordEncoder를 이용해 해시한 뒤 저장해야 한다.

다음은 그 개념을 보여주는 코드다.

String encodedPassword = passwordEncoder.encode(
        signupRequest.getPassword()
);

데이터베이스에는 다음과 같은 형태의 해시가 저장된다.

$2a$10$...

이 값은 설명을 위한 형식 예시이며 실제 해시는 실행할 때마다 달라질 수 있다.

중복 아이디 검증

회원가입에서는 동일한 아이디가 중복 저장되지 않도록 검증해야 한다.

하지만 애플리케이션에서 중복 여부를 확인하는 것만으로는 충분하지 않다.

동시에 두 개의 회원가입 요청이 발생할 수 있기 때문이다.

Request A → user01 없음 확인

Request B → user01 없음 확인

Request A → INSERT

Request B → INSERT

따라서 username에는 데이터베이스의 UNIQUE 제약조건도 함께 적용해야 한다.

회원가입의 핵심은 입력값 검증, 비밀번호 해시, 사용자 정보 저장, 중복 방지이며 이 과정은 JWT 인증 여부와 관계없이 필요하다.


로그인 구조

로그인은 회원가입보다 더 많은 Spring Security 구성요소가 관여한다.

이번에는 사용자가 아이디와 비밀번호를 전달하고, 인증에 성공하면 JWT를 반환하는 구조다.

로그인 API

클라이언트는 다음 요청을 보낸다.

POST /api/auth/login HTTP/1.1
Content-Type: application/json

요청 본문은 다음과 같다.

{
  "username": "user01",
  "password": "password123!"
}

서버는 사용자 정보를 검증한다.

인증에 성공하면 JWT를 생성하여 반환한다.

{
  "tokenType": "Bearer",
  "accessToken": "<JWT>",
  "expiresIn": 3600
}

위 응답 구조는 설계 예시다.

expiresIn은 클라이언트가 토큰의 남은 유효기간을 이해할 수 있도록 초 단위로 제공하는 값이다.

이번 구조에서는 Access Token 하나만 발급한다고 가정한다.

Refresh Token을 이용한 재발급 기능은 포함하지 않는다.


Spring Security 로그인 인증 과정

Spring Security에서 아이디와 비밀번호를 이용해 사용자를 인증하는 핵심 구조는 다음과 같다.

flowchart TD
    A["POST /api/auth/login"] --> B["로그인 인증 필터"]
    B --> C["UsernamePasswordAuthenticationToken 생성"]
    C --> D["AuthenticationManager"]

    D --> E["AuthenticationProvider"]
    E --> F["UserDetailsService"]
    F --> G["UserRepository"]
    G --> H["MySQL"]

    H --> G
    G --> F
    F --> E

    E --> I["PasswordEncoder"]
    I --> J{"비밀번호 일치?"}

    J -->|Yes| K["인증 성공"]
    J -->|No| L["인증 실패"]

    K --> M["JWT 생성"]
    M --> N["Access Token 반환"]

    L --> O["HTTP 401"]

각 구성요소의 역할을 순서대로 살펴보자.

1. UsernamePasswordAuthenticationFilter

UsernamePasswordAuthenticationFilter는 사용자 이름과 비밀번호를 이용한 인증 요청을 처리하는 Spring Security 필터다.

일반적인 Form Login에서는 /login 요청에 포함된 사용자 이름과 비밀번호를 읽어 인증을 시도한다.

하지만 기본 UsernamePasswordAuthenticationFilter는 JSON 요청 본문을 자동으로 역직렬화하는 필터가 아니다.

따라서 다음과 같은 JSON 요청을 처리하려면 별도의 구현이 필요할 수 있다.

{
  "username": "user01",
  "password": "password123!"
}

대표적인 방법은 다음과 같다.

방식설명
커스텀 로그인 필터기존 인증 필터를 확장하거나 별도 필터에서 JSON 파싱
로그인 ControllerREST Controller에서 요청을 받고 AuthenticationManager 호출

두 방식 모두 사용할 수 있다.

즉 JWT 인증을 구현한다고 해서 반드시 UsernamePasswordAuthenticationFilter를 직접 상속해야 하는 것은 아니다.

다만 Spring Security 필터 기반의 로그인 인증 구조를 이해하려면 이 구성요소의 역할을 알아두는 것이 좋다.

2. AuthenticationManager

AuthenticationManager는 인증 요청을 처리하기 위한 Spring Security의 핵심 인터페이스다.

로그인 필터나 Controller는 사용자 이름과 비밀번호를 이용해 인증 요청 객체를 생성할 수 있다.

예를 들어 다음과 같다.

Authentication authenticationRequest =
        UsernamePasswordAuthenticationToken.unauthenticated(
                username,
                password
        );

이 객체는 아직 인증되지 않은 상태다.

이후 AuthenticationManager에 전달한다.

Authentication authenticationResult =
        authenticationManager.authenticate(
                authenticationRequest
        );

authenticate()는 인증 요청을 전달하고 성공한 경우 인증 결과를 반환한다.

인증에 실패하면 관련 예외가 발생한다.

여기서 중요한 점은 AuthenticationManager가 직접 MySQL에 SQL을 실행하는 객체가 아니라는 사실이다.

실제 인증 방식은 AuthenticationProvider가 담당한다.

3. AuthenticationProvider

AuthenticationProvider는 전달받은 인증 정보를 실제로 검증하는 역할을 한다.

아이디와 비밀번호 기반 인증에서는 대표적으로 DaoAuthenticationProvider를 사용할 수 있다.

DaoAuthenticationProvider는 UserDetailsService를 이용해 사용자를 조회하고 PasswordEncoder로 비밀번호를 검증한다.

AuthenticationManager
    ↓
DaoAuthenticationProvider
    ↓
UserDetailsService
    ↓
UserRepository
    ↓
MySQL

사용자 정보가 반환되면 다음 작업을 수행한다.

입력한 비밀번호
    ↓
PasswordEncoder.matches()
    ↓
저장된 비밀번호 해시와 비교
    ↓
인증 성공 또는 실패

여기서 비밀번호를 비교하기 위해 저장된 해시를 복호화하는 것은 아니다.

PasswordEncoder는 입력한 원문 비밀번호가 저장된 해시와 일치하는지 검증한다.

4. UserDetailsService

UserDetailsService는 사용자 정보를 조회하는 인터페이스다.

대표적인 메서드는 다음과 같다.

UserDetails loadUserByUsername(String username)
        throws UsernameNotFoundException;

이 메서드에서는 사용자 이름을 기준으로 데이터베이스의 회원 정보를 조회할 수 있다.

loadUserByUsername("user01")
    ↓
UserRepository.findByUsername("user01")
    ↓
MySQL 조회
    ↓
User Entity 반환
    ↓
UserDetails 생성

UserDetails는 Spring Security가 인증에 필요한 사용자 정보를 표현하기 위한 인터페이스다.

일반적으로 다음 정보를 제공한다.

정보의미
Username사용자 식별 정보
Password저장된 비밀번호 해시
Authorities사용자 권한
Account Status계정 잠금 및 활성화 여부 등

Spring Data JPA와 UserDetailsService를 연결하면 MySQL에 저장된 사용자를 대상으로 인증할 수 있다.

5. JWT 발급

사용자 인증에 성공하면 Authentication 객체를 이용해 JWT를 발급할 수 있다.

개념적으로는 다음과 같은 코드가 된다.

Authentication authentication =
        authenticationManager.authenticate(
                UsernamePasswordAuthenticationToken.unauthenticated(
                        username,
                        password
                )
        );

// JWT 발급 서비스는 별도로 구현해야 한다.
String accessToken = jwtTokenService.createAccessToken(
        authentication
);

여기서 jwtTokenService는 JWT 생성을 담당하도록 설계한 커스텀 컴포넌트의 예시다.

Spring Security가 해당 이름과 메서드를 기본으로 제공하는 것은 아니다.

실제 구현에서는 JWT 라이브러리 또는 Spring Security의 JWT 관련 기능을 이용해 서명, 만료 시간, 사용자 식별자, 권한 등을 설정해야 한다.

로그인 과정에서 가장 중요한 점은 다음과 같다.

아이디와 비밀번호를 검증하는 것은 Spring Security의 인증 체계이고, JWT 발급은 인증 성공 이후 클라이언트에 전달할 인증 수단을 만드는 작업이다.


JWT 인증을 이용한 API 접근 구조

로그인에 성공한 사용자는 JWT를 발급받는다.

이제 해당 토큰으로 보호된 API에 접근할 수 있다.

예를 들어 사용자 정보 조회 API를 호출한다고 생각해보자.

GET /api/users/me HTTP/1.1
Host: localhost:8080
Authorization: Bearer <ACCESS_TOKEN>

서버는 요청에 포함된 JWT를 검증한다.

검증에 성공하면 현재 요청에서 사용할 인증 정보를 구성한다.

그리고 해당 사용자가 API에 접근할 수 있는지 확인한다.

전체 인증·인가 흐름

sequenceDiagram
    participant Client
    participant Filter as JWT 인증 필터
    participant Token as JWT 검증 컴포넌트
    participant Context as SecurityContextHolder
    participant Authz as 인가 처리
    participant Controller

    Client->>Filter: GET /api/users/me
    Note over Client,Filter: Authorization: Bearer JWT

    Filter->>Token: JWT 검증 요청
    Token->>Token: 서명 검증
    Token->>Token: 만료 및 Claim 검증
    Token-->>Filter: 검증된 사용자 정보

    Filter->>Context: Authentication 저장
    Filter->>Authz: 다음 필터로 요청 전달

    Authz->>Context: 인증 정보 확인
    Authz->>Authz: 접근 권한 검증

    alt 권한 있음
        Authz->>Controller: 요청 전달
        Controller-->>Client: HTTP 200
    else 권한 없음
        Authz-->>Client: HTTP 403
    end

    Note over Context: 요청 종료 후 인증 컨텍스트 정리

이 과정에서 핵심은 JWT 검증 이후 Authentication을 현재 요청의 보안 컨텍스트에 설정한다는 것이다.

JWT 인증 필터의 역할

커스텀 JWT 인증 필터를 구현한다면 일반적으로 다음 역할을 담당하도록 구성할 수 있다.

단계동작
1Authorization 헤더 확인
2Bearer 토큰 추출
3JWT 서명 검증
4만료 시간 및 Claim 검증
5사용자 식별자와 권한 정보 확인
6Authentication 생성
7SecurityContext에 Authentication 설정
8다음 필터로 요청 전달

예를 들어 Authorization 헤더에 다음과 같은 값이 들어 있다.

Authorization: Bearer eyJ...

JWT 인증 필터는 Bearer 접두사를 제외한 토큰을 추출한다.

그다음 토큰의 서명이 올바른지, 토큰이 만료되지 않았는지, 이 API에서 사용할 수 있도록 발급된 토큰인지 확인한다.

검증이 끝난 후에는 해당 사용자를 나타내는 Authentication을 생성할 수 있다.

이 객체를 SecurityContext에 설정하면 이후 인가 과정에서 사용할 수 있다.

다만 JWT를 단순히 Base64URL로 디코딩한 결과만 믿고 Authentication을 생성해서는 안 된다.

서명과 유효기간, 발급자, 사용 대상 등을 적절하게 검증한 뒤 인증 정보를 구성해야 한다.


SecurityContextHolder와 Session은 다르다

JWT 인증에서 가장 중요한 개념 중 하나다.

Spring Security에서는 현재 인증된 사용자 정보에 접근하기 위해 SecurityContextHolder를 사용한다.

예를 들어 다음과 같은 코드가 있다.

Authentication authentication =
        SecurityContextHolder
                .getContext()
                .getAuthentication();

이 코드는 현재 보안 컨텍스트에 설정된 인증 정보를 가져온다.

여기서 다음과 같이 오해하기 쉽다.

SecurityContextHolder에 인증 정보를 저장했으므로 서버 세션이 만들어진 것이다.

하지만 이 두 개념은 다르다.

SecurityContextHolder는 현재 실행 흐름에서 인증 정보를 접근할 수 있도록 하는 역할을 담당한다.

반면 HttpSession은 여러 HTTP 요청에 걸쳐 서버 측 상태를 유지하는 메커니즘이다.

둘의 차이는 다음과 같다.

구분SecurityContextHolderHttpSession
목적현재 보안 컨텍스트 관리여러 요청 간 상태 유지
주요 데이터Authentication세션 속성
기본적인 접근 방식ThreadLocal 기반 전략세션 식별자를 통한 서버 측 조회
요청 종료 후필터 체계에서 컨텍스트 정리만료·무효화 전까지 유지 가능
JWT Stateless 인증사용 가능인증 상태 저장에 필수 아님

Stateless 인증에서는 어떻게 동작할까?

JWT 기반 Stateless 인증에서는 요청마다 토큰을 전달하고 검증할 수 있다.

첫 번째 요청
    ↓
JWT 검증
    ↓
SecurityContext에 Authentication 설정
    ↓
인가 및 Controller 실행
    ↓
요청 종료
    ↓
SecurityContext 정리

두 번째 요청이 들어오면 다시 토큰을 검증한다.

두 번째 요청
    ↓
JWT 검증
    ↓
SecurityContext에 Authentication 설정
    ↓
인가 및 Controller 실행
    ↓
요청 종료
    ↓
SecurityContext 정리

이 과정에서 이전 요청의 로그인 정보를 HttpSession에서 복원할 필요가 없다.

Spring Security에서는 다음 설정으로 인증 세션을 생성하지 않는 정책을 지정할 수 있다.

.sessionManagement(session -> session
        .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)

이 설정은 Spring Security가 인증 상태를 위해 HttpSession을 생성하거나 사용하지 않도록 구성한다.

다만 애플리케이션의 다른 기능이 별도로 HttpSession을 생성할 가능성까지 모두 차단한다는 의미는 아니다.

JWT Stateless 인증의 핵심은 요청마다 독립적으로 인증 정보를 검증하고, 이전 요청의 인증 세션에 의존하지 않는다는 것이다.


Stateless와 Stateful의 차이

Stateless와 Stateful은 서버가 이전 요청의 상태를 유지하는 방식에서 차이가 있다.

Stateful 인증

Request 1
    ↓
로그인
    ↓
세션 저장

Request 2
    ↓
세션 조회
    ↓
인증 정보 복원

Request 3
    ↓
세션 조회
    ↓
인증 정보 복원

이 구조에서는 서버가 이전 요청에서 생성된 상태를 저장하고 사용한다.

Stateless 인증

Request 1
    ↓
JWT 전달
    ↓
검증
    ↓
요청 처리

Request 2
    ↓
JWT 전달
    ↓
검증
    ↓
요청 처리

Request 3
    ↓
JWT 전달
    ↓
검증
    ↓
요청 처리

각 요청은 독립적으로 인증에 필요한 정보를 제공한다.

따라서 서버는 이전 요청의 인증 세션을 조회하지 않고도 사용자를 식별할 수 있다.

하지만 Stateless라는 표현을 사용할 때는 주의해야 한다.

JWT 기반 인증에서도 애플리케이션은 여전히 MySQL, Redis, 외부 API 등 다양한 상태 저장 시스템을 사용할 수 있다.

즉 Stateless 인증은 서버가 모든 상태를 저장하지 않는다는 의미가 아니다.

인증 과정에서 클라이언트별 로그인 세션 상태에 의존하지 않는다는 의미에 가깝다.


Spring Security 6에서 JWT 인증 구성하기

이번에는 Spring Security 6에서 JWT 기반 REST API 인증 시스템을 구성할 때 어떤 설정이 필요한지 살펴보자.

아직 JWT 생성 및 검증 구현을 추가하기 전 단계이므로 전체 보안 설정의 기본 형태를 먼저 작성한다.

SecurityFilterChain

Spring Security 6에서는 SecurityFilterChain을 Bean으로 등록하여 보안 설정을 구성한다.

package com.example.jwt.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(
            HttpSecurity http
    ) throws Exception {

        http
                .csrf(AbstractHttpConfigurer::disable)

                .formLogin(AbstractHttpConfigurer::disable)

                .httpBasic(AbstractHttpConfigurer::disable)

                .sessionManagement(session -> session
                        .sessionCreationPolicy(
                                SessionCreationPolicy.STATELESS
                        )
                )

                .authorizeHttpRequests(auth -> auth
                        .requestMatchers(
                                HttpMethod.POST,
                                "/api/auth/signup",
                                "/api/auth/login"
                        ).permitAll()

                        .requestMatchers(
                                "/api/admin/**"
                        ).hasRole("ADMIN")

                        .anyRequest().authenticated()
                );

        return http.build();
    }
}

이 설정은 JWT 인증 API에서 사용할 수 있는 기본적인 SecurityFilterChain의 형태다.

하지만 아직 JWT 인증 필터나 Resource Server 설정을 추가하지 않았으므로 이 코드만으로 JWT 인증이 완성되는 것은 아니다.

보호된 API에 접근하려면 이후 JWT 검증과 Authentication 구성 기능을 연결해야 한다.

각 설정의 의미를 살펴보자.

1. SessionCreationPolicy.STATELESS

.sessionManagement(session -> session
        .sessionCreationPolicy(
                SessionCreationPolicy.STATELESS
        )
)

인증 상태를 HttpSession에 저장하지 않는 Stateless 방식으로 구성한다.

JWT는 이후 각 요청에서 전달받아 검증하도록 한다.

2. formLogin 비활성화

.formLogin(AbstractHttpConfigurer::disable)

기본 Form Login 기능을 비활성화한다.

이번 시스템은 HTML 로그인 페이지를 제공하는 웹 애플리케이션이 아니라 JSON 요청을 받는 REST API 서버다.

따라서 사용자 정의 로그인 엔드포인트를 별도로 구현할 예정이다.

3. httpBasic 비활성화

.httpBasic(AbstractHttpConfigurer::disable)

HTTP Basic 인증 기능을 비활성화한다.

이번 시스템에서는 매 요청마다 아이디와 비밀번호를 전달하는 대신 JWT를 사용하기 때문이다.

4. 회원가입 및 로그인 허용

.requestMatchers(
        HttpMethod.POST,
        "/api/auth/signup",
        "/api/auth/login"
).permitAll()

회원가입과 로그인은 아직 JWT가 없는 사용자도 호출할 수 있어야 한다.

따라서 인증을 요구하지 않는 경로로 설정한다.

다만 permitAll()은 해당 API의 비즈니스 검증을 생략한다는 의미가 아니다.

회원가입의 중복 아이디 검증이나 로그인 시 비밀번호 검증은 여전히 필요하다.

5. 관리자 API 제한

.requestMatchers(
        "/api/admin/**"
).hasRole("ADMIN")

관리자 API는 ROLE_ADMIN 권한을 가진 사용자만 접근하도록 제한한다.

Spring Security의 hasRole("ADMIN")은 기본적으로 ROLE_ADMIN 권한을 확인한다.

따라서 JWT에서 권한을 읽어 Authentication을 생성할 때도 같은 형식을 유지해야 한다.

6. 그 외 요청은 인증 필요

.anyRequest().authenticated()

별도로 허용하지 않은 모든 요청에는 인증이 필요하도록 설정한다.

이렇게 하면 실수로 인증이 필요한 API를 공개하는 위험을 줄일 수 있다.

CSRF를 비활성화하는 이유

예제에서는 다음 설정을 사용했다.

.csrf(AbstractHttpConfigurer::disable)

이는 토큰을 Authorization 헤더로 명시적으로 전달하고, 인증에 브라우저가 자동으로 전송하는 쿠키를 사용하지 않는 API 구조를 전제로 한다.

하지만 JWT를 쿠키에 저장하여 인증에 사용한다면 상황이 달라진다.

브라우저가 쿠키를 자동으로 전송하는 구조에서는 CSRF 위협을 다시 검토해야 한다.

따라서 JWT를 사용한다는 이유만으로 모든 애플리케이션에서 CSRF를 무조건 비활성화하는 것은 적절하지 않다.


권한 정보는 어떻게 처리할까?

JWT 인증에 성공했다고 해서 모든 사용자가 동일한 권한을 가져야 하는 것은 아니다.

예를 들어 일반 사용자와 관리자를 구분한다고 생각해보자.

일반 사용자
→ ROLE_USER
관리자
→ ROLE_ADMIN

로그인에 성공하면 JWT에 권한 정보를 포함하도록 설계할 수 있다.

예를 들어 일반 사용자의 JWT Payload는 다음과 같은 형태일 수 있다.

{
  "sub": "user-1001",
  "role": "ROLE_USER",
  "exp": 1790003600
}

관리자 토큰은 다음과 같을 수 있다.

{
  "sub": "admin-1001",
  "role": "ROLE_ADMIN",
  "exp": 1790003600
}

JWT 검증 이후 이 권한 정보를 GrantedAuthority로 변환한다.

예를 들어 다음과 같다.

GrantedAuthority authority =
        new SimpleGrantedAuthority("ROLE_USER");

이후 Spring Security는 API 접근 권한을 확인한다.

JWT 검증 성공
    ↓
ROLE_USER 추출
    ↓
GrantedAuthority 생성
    ↓
Authentication 구성
    ↓
GET /api/admin/users
    ↓
ROLE_ADMIN 필요
    ↓
403 Forbidden

여기서 중요한 것은 JWT Payload에 단순히 "ROLE_ADMIN"이라는 문자열이 존재한다고 해서 자동으로 관리자 권한이 부여되는 것은 아니라는 사실이다.

검증된 토큰의 Claim을 Spring Security가 이해할 수 있는 Authority로 변환하는 과정이 필요하다.


커스텀 JWT 필터와 OAuth2 Resource Server의 차이

JWT 인증 시스템을 구현할 때 반드시 알아두어야 할 선택지가 있다.

Spring Security는 JWT를 검증하기 위한 기본 기능도 제공한다.

따라서 모든 프로젝트에서 커스텀 JWT 필터를 직접 구현해야 하는 것은 아니다.

커스텀 JWT 필터 방식

커스텀 JWT 필터를 사용한다면 다음 기능을 직접 설계할 수 있다.

Authorization 헤더 추출
    ↓
JWT 파싱
    ↓
서명 검증
    ↓
유효기간 검증
    ↓
권한 추출
    ↓
Authentication 생성
    ↓
SecurityContext 설정

장점은 인증 로직을 세밀하게 제어할 수 있다는 것이다.

반면 토큰 검증 로직과 예외 처리, 필터 동작 순서를 직접 관리해야 한다.

OAuth2 Resource Server 방식

Spring Security는 OAuth2 Resource Server 기능을 통해 JWT 기반 Bearer Token 인증을 지원한다.

대표적으로 다음 구성요소를 제공한다.

구성요소역할
BearerTokenAuthenticationFilterBearer Token 추출
JwtAuthenticationProviderJWT 인증 처리
JwtDecoderJWT 파싱 및 검증
JwtAuthenticationConverterJWT Claim을 Authentication 및 Authority로 변환

이 방식에서는 필요한 JwtDecoder를 구성한 뒤 Spring Security의 기본 인증 흐름을 활용할 수 있다.

예를 들어 다음과 같은 설정을 사용할 수 있다.

.oauth2ResourceServer(oauth2 -> oauth2
        .jwt(Customizer.withDefaults())
)

단, 이 설정만 추가해서 모든 JWT가 자동으로 검증되는 것은 아니다.

토큰 발급자 또는 JWT 검증에 필요한 JwtDecoder 구성이 필요하다.

또한 Spring Security의 Resource Server는 JWT를 검증하는 역할을 담당하며, 사용자 이름과 비밀번호로 로그인하여 JWT를 발급하는 기능까지 자동으로 제공하는 것은 아니다.

따라서 다음과 같이 역할을 분리할 수 있다.

로그인 API
    ↓
아이디 및 비밀번호 인증
    ↓
JWT 발급
보호된 API
    ↓
Spring Security Resource Server
    ↓
JWT 검증
    ↓
인증 및 인가

Spring Security는 커스텀 JWT를 사용하는 경우에도 Resource Server 기능을 활용할 수 있도록 지원한다.

두 방식 비교

항목커스텀 JWT 필터OAuth2 Resource Server
JWT 추출직접 구현기본 기능 제공
JWT 검증직접 구성JwtDecoder 활용
Authentication 생성직접 구현기본 인증 체계 활용
예외 처리직접 구성기본 보안 처리 확장 가능
커스터마이징자유도가 높음제공되는 확장 지점 활용
적합한 상황특수한 인증 규칙이 필요한 경우표준적인 Bearer JWT 인증

Spring Security 내부 동작을 이해하기 위해 커스텀 필터를 구현하는 것은 의미가 있다.

하지만 실제 운영 환경에서는 이미 제공되는 검증 기능을 활용할 수 있는지도 함께 검토하는 것이 좋다.


예제

프로젝트 목표

Spring Boot 기반 REST API 서버에서 다음 기능을 구현한다고 가정한다.

기능HTTP MethodAPI설명
회원가입POST/api/auth/signup사용자 등록
로그인POST/api/auth/login아이디·비밀번호 검증 후 JWT 발급
내 정보 조회GET/api/users/me인증된 사용자 정보
관리자 조회GET/api/admin/users관리자 권한 필요

회원 정보는 MySQL에 저장한다.

데이터베이스 접근은 Spring Data JPA를 사용한다.

인증·인가는 Spring Security를 사용한다.

그리고 로그인에 성공하면 JWT Access Token 하나를 반환하는 구조다.


프로젝트 기술 환경

구성 환경은 다음과 같다.

항목기술
LanguageJava 17
Spring Boot3.2.1
Spring Security6.2.1
Build ToolGradle Groovy DSL
DatabaseMySQL
ORMSpring Data JPA
UtilityLombok
APISpring MVC REST API
AuthenticationUsername/Password + JWT
AuthorizationJWT 기반 권한 확인

Spring Boot 3.2.1의 의존성 관리에는 Spring Security 6.2.1이 포함되어 있다.

이 버전 조합은 앞에서 설명한 구조를 재현하기 위한 기준이며, 신규 운영 프로젝트에서 사용할 최신 지원 버전이라는 의미는 아니다.


Gradle 의존성 구성

프로젝트의 기본 build.gradle은 다음과 같이 구성할 수 있다.

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.2.1'
    id 'io.spring.dependency-management' version '1.1.4'
}

group = 'com.example'
version = '0.0.1-SNAPSHOT'

java {
    toolchain {
        languageVersion = JavaLanguageVersion.of(17)
    }
}

repositories {
    mavenCentral()
}

dependencies {

    // REST API
    implementation 'org.springframework.boot:spring-boot-starter-web'

    // Spring Security
    implementation 'org.springframework.boot:spring-boot-starter-security'

    // Spring Data JPA
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

    // Request Validation
    implementation 'org.springframework.boot:spring-boot-starter-validation'

    // MySQL
    runtimeOnly 'com.mysql:mysql-connector-j'

    // Lombok
    compileOnly 'org.projectlombok:lombok'
    annotationProcessor 'org.projectlombok:lombok'

    // Test
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'org.springframework.security:spring-security-test'
}

tasks.named('test') {
    useJUnitPlatform()
}

위 설정은 JWT 인증 시스템의 기본 프로젝트 구성을 위한 것이다.

JWT 생성·검증 기능은 사용할 구현 방식에 따라 추가 의존성이 필요하다.

예를 들어 Spring Security Resource Server를 활용한다면 다음 Starter를 사용할 수 있다.

implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'

반면 커스텀 JWT 필터를 구현하는 경우에는 선택한 JWT 라이브러리와 해당 버전에 맞는 의존성을 추가할 수 있다.

여기서는 JWT 라이브러리와 필터 구현을 아직 선택하지 않았으므로, 기본 프로젝트 구성과 JWT 검증 기능을 구분한다.


프로젝트 패키지 구조

회원가입과 로그인, JWT 검증, 회원 정보 관리를 분리하면 다음과 같은 구조로 구성할 수 있다.

src/main/java/com/example/jwt
│
├── JwtApplication.java
│
├── config
│   └── SecurityConfig.java
│
├── controller
│   ├── JoinController.java
│   ├── AuthController.java
│   └── UserController.java
│
├── service
│   ├── JoinService.java
│   └── UserService.java
│
├── entity
│   └── UserEntity.java
│
├── repository
│   └── UserRepository.java
│
├── dto
│   ├── SignupRequest.java
│   ├── LoginRequest.java
│   └── TokenResponse.java
│
└── security
    ├── CustomUserDetails.java
    ├── CustomUserDetailsService.java
    ├── JwtTokenService.java
    └── JwtAuthenticationFilter.java

이 구조에서 각 패키지의 책임은 다음과 같다.

패키지역할
configSpring Security 설정
controllerHTTP 요청 및 응답 처리
service비즈니스 로직
entity데이터베이스 엔티티
repositoryJPA 데이터 접근
dtoAPI 요청·응답 객체
security사용자 인증, JWT 발급 및 검증

JwtTokenService와 JwtAuthenticationFilter는 커스텀 구현을 선택하는 경우의 예시 구조다.

Resource Server 방식을 사용한다면 직접 구현해야 하는 클래스가 줄어들 수 있다.

실제 프로젝트에서는 모든 보안 로직을 하나의 Controller에 작성하기보다 각각의 책임을 분리하는 편이 유지보수에 유리하다.


API 요청 및 응답 구조

회원가입

요청:

curl -i -X POST \
  http://localhost:8080/api/auth/signup \
  -H "Content-Type: application/json" \
  -d '{
    "username": "user01",
    "password": "password123!"
  }'

의도한 응답 예시는 다음과 같다.

HTTP/1.1 201 Created
Content-Type: application/json
{
  "id": 1,
  "username": "user01",
  "role": "ROLE_USER"
}

회원가입 응답에는 비밀번호 해시를 포함하지 않는 것이 좋다.

로그인

요청:

curl -i -X POST \
  http://localhost:8080/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{
    "username": "user01",
    "password": "password123!"
  }'

의도한 응답은 다음과 같다.

{
  "tokenType": "Bearer",
  "accessToken": "<ACCESS_TOKEN>",
  "expiresIn": 3600
}

이 토큰을 이용해 이후 요청에서 인증을 수행할 수 있다.

인증이 필요한 API

curl -i \
  http://localhost:8080/api/users/me \
  -H "Authorization: Bearer <ACCESS_TOKEN>"

JWT가 유효하고 사용자가 해당 API에 접근할 수 있다면 정상 응답을 반환한다.

예를 들어 다음과 같다.

{
  "id": 1,
  "username": "user01",
  "role": "ROLE_USER"
}

관리자 API

curl -i \
  http://localhost:8080/api/admin/users \
  -H "Authorization: Bearer <ACCESS_TOKEN>"

일반 사용자 토큰에 ROLE_ADMIN 권한이 없다면 접근을 거부해야 한다.

HTTP/1.1 403 Forbidden

위 예시는 전체 API의 계약을 설명하기 위한 것으로, 실제로 동작하려면 회원가입·로그인·JWT 검증 구현을 모두 연결해야 한다.


구조

회원가입, 로그인, JWT 인가의 전체 관계

지금까지 살펴본 내용을 하나의 구조로 정리해보자.

flowchart TD
    A["Client"] --> B{"요청 종류"}

    B -->|회원가입| C["JoinController"]
    C --> D["JoinService"]
    D --> E["PasswordEncoder"]
    D --> F["UserRepository"]
    F --> G[("MySQL")]

    B -->|로그인| H["로그인 인증 처리"]
    H --> I["AuthenticationManager"]
    I --> J["AuthenticationProvider"]
    J --> K["UserDetailsService"]
    K --> F

    J --> L{"인증 성공?"}

    L -->|Yes| M["JWT 발급"]
    L -->|No| N["401 Unauthorized"]

    M --> O["Access Token 반환"]

    B -->|보호된 API| P["JWT 인증 처리"]
    P --> Q{"토큰 유효?"}

    Q -->|No| N
    Q -->|Yes| R["SecurityContext 설정"]

    R --> S{"권한 충분?"}
    S -->|No| T["403 Forbidden"]
    S -->|Yes| U["Controller 실행"]

여기서 각 단계의 핵심을 정리하면 다음과 같다.

회원가입

사용자의 정보를 검증하고 비밀번호를 해시한 뒤 MySQL에 저장한다.

로그인

아이디와 비밀번호를 검증하고 인증에 성공하면 JWT를 발급한다.

API 인증

클라이언트가 전달한 JWT의 유효성을 검증하고 현재 요청의 인증 정보를 구성한다.

API 인가

인증된 사용자의 권한을 확인하고 보호된 리소스에 대한 접근을 허용하거나 거부한다.

이 네 가지가 JWT 기반 REST API 보안 시스템의 기본 구성이다.


실무에서의 활용

JWT 인증 시스템은 구조만 구현하는 것으로 끝나지 않는다.

실제 운영 환경에서는 토큰의 수명, 사용자 정보의 변경, 권한 관리, 키 관리, 네트워크 보안 등을 함께 고려해야 한다.

1. JWT를 사용한다고 서버 상태가 완전히 사라지는 것은 아니다

JWT는 요청마다 토큰을 검증할 수 있도록 하는 방식이다.

하지만 사용자 정보는 여전히 MySQL에 저장될 수 있다.

또한 토큰 강제 폐기, 중복 로그인 제어, 의심스러운 인증 차단 등을 구현하려면 Redis와 같은 저장소를 사용할 수도 있다.

따라서 JWT를 Stateless 인증이라고 부르더라도 모든 상태 저장소가 불필요해지는 것은 아니다.

2. JWT의 유효기간을 적절하게 설정해야 한다

JWT는 발급 시 만료 시간을 지정할 수 있다.

예를 들어 다음과 같다.

{
  "sub": "user-1001",
  "exp": 1790003600
}

서버는 토큰 검증 과정에서 만료 시간을 확인해야 한다.

유효기간이 지나치게 길면 토큰이 탈취되었을 때 오랜 시간 악용될 수 있다.

반대로 너무 짧으면 사용자가 자주 다시 로그인해야 할 수 있다.

따라서 서비스의 위험 수준과 사용자 경험을 고려해야 한다.

3. Access Token 하나만 사용하면 재인증 문제가 발생한다

Access Token 하나만 발급하는 구조에서는 토큰이 만료되면 일반적으로 다시 로그인해야 한다.

Refresh Token을 사용하면 Access Token이 만료되었을 때 새로운 토큰을 발급하는 구조를 만들 수 있다.

하지만 Refresh Token에도 저장, 갱신, 탈취 대응, 폐기 정책이 필요하다.

따라서 단순한 인증 시스템에서는 Access Token 하나로 시작할 수 있지만, 실제 서비스에서는 토큰 재발급 전략을 별도로 검토해야 한다.

4. JWT 로그아웃은 세션 로그아웃과 다르다

세션 기반 인증에서는 서버가 세션을 무효화하면 해당 세션 식별자를 이용한 인증을 차단할 수 있다.

반면 서명된 JWT는 유효기간이 남아 있다면 계속 검증에 성공할 수 있다.

예를 들어 사용자가 로그아웃했더라도 이미 발급된 토큰이 남아 있다면 다음 상황이 발생할 수 있다.

로그아웃 요청
    ↓
클라이언트에서 토큰 삭제
    ↓
기존 JWT는 만료되지 않음
    ↓
탈취된 토큰으로 API 접근 가능성

이를 보완하기 위해 짧은 토큰 유효기간, 토큰 폐기 목록, 사용자별 토큰 버전 관리 등을 고려할 수 있다.

OWASP 역시 JWT 사용 시 강제 폐기와 재사용 방지 정책을 별도로 검토하도록 안내한다.

5. 사용자의 권한이 변경되면 기존 JWT도 고려해야 한다

예를 들어 일반 사용자가 관리자 권한을 부여받았다고 생각해보자.

데이터베이스의 권한이 변경되었다.

MySQL

ROLE_USER
    ↓
ROLE_ADMIN

하지만 기존 JWT에는 여전히 다음 정보가 들어 있을 수 있다.

{
  "role": "ROLE_USER"
}

JWT 검증 과정에서 데이터베이스를 다시 조회하지 않는다면 기존 토큰은 여전히 이전 권한을 나타낸다.

반대 상황은 더 중요하다.

관리자 권한을 회수했는데 기존 JWT에 ROLE_ADMIN이 남아 있다면 만료 전까지 관리자 권한으로 접근할 수 있는 문제가 생길 수 있다.

따라서 권한 변경의 즉시 반영이 중요한 서비스에서는 JWT만 신뢰할 것인지, 중요한 요청에서 최신 권한을 추가로 확인할 것인지 정책을 결정해야 한다.

6. JWT 서명 키를 소스 코드에 직접 작성하지 않는다

JWT의 보안은 서명 검증과 키 관리에 크게 의존한다.

예를 들어 HMAC 기반 JWT는 발급자와 검증자가 공유하는 비밀키를 사용한다.

이 키가 노출되면 공격자가 유효한 서명을 생성할 위험이 있다.

따라서 다음과 같은 방식은 피해야 한다.

String secret = "my-secret-key";

실제 운영에서는 충분히 강력한 키를 사용하고 환경변수, Secret Manager, Vault 등 안전한 저장 수단을 활용해야 한다.

또한 서비스가 여러 개라면 비밀키를 모든 서비스에 배포할지, 비대칭키를 사용해 검증용 공개키만 배포할지 고려할 수 있다.

7. 사용자 식별자는 변경 가능성을 고려한다

JWT의 sub에 사용자 이름을 저장할 수 있다.

하지만 사용자 이름이 변경될 수 있다면 문제가 발생할 수 있다.

예를 들어:

기존 username
→ user01
변경된 username
→ new-user01

기존 JWT에는 user01이 남아 있을 수 있다.

따라서 식별자가 자주 변경되는 서비스에서는 사용자 이름보다 변경되지 않는 내부 사용자 ID를 사용하는 방법을 고려할 수 있다.

8. 모든 요청에서 데이터베이스를 조회할 필요는 없지만, 조회가 필요한 경우는 존재한다

JWT의 장점 중 하나는 서명과 Claim을 검증하여 데이터베이스 조회 없이 인증할 수 있다는 것이다.

하지만 모든 요청에서 데이터베이스 조회를 생략하는 것이 항상 최선은 아니다.

예를 들어 다음 상황에서는 최신 사용자 상태를 확인할 필요가 있다.

사용자 계정 정지

관리자 권한 회수

회원 탈퇴

중요 거래 승인

따라서 일반적인 조회 API는 토큰 정보로 처리하고, 보안상 중요한 기능은 최신 사용자 상태를 추가로 확인하는 방식도 고려할 수 있다.

9. JWT Payload에는 필요한 정보만 저장한다

토큰에는 사용자 식별자, 유효기간, 발급자, 권한 등 인증에 필요한 정보만 포함하는 것이 좋다.

사용자의 주소, 전화번호, 주민등록번호, 비밀번호 같은 민감한 정보를 저장해서는 안 된다.

또한 Payload가 커질수록 HTTP 요청의 Header 크기도 증가한다.

따라서 토큰을 데이터 저장소처럼 사용하는 것은 적절하지 않다.

10. JWT는 반드시 HTTPS 환경에서 전달해야 한다

JWT는 Bearer Token으로 전달되는 경우가 많다.

따라서 토큰을 소유한 사람이 유효기간 동안 해당 토큰을 제시할 수 있다.

네트워크 구간에서 토큰이 노출되지 않도록 HTTPS를 사용해야 한다.

또한 로그에 Authorization 헤더 전체를 출력하지 않도록 주의해야 한다.

Authorization: Bearer <ACCESS_TOKEN>

이 값이 로그 시스템에 저장되면 운영자나 로그 접근 권한이 있는 사람이 토큰을 확인할 수 있다.

따라서 민감한 인증 헤더는 마스킹하거나 수집 대상에서 제외하는 것이 좋다.

11. 401과 403을 구분해야 한다

JWT 인증을 구현할 때 자주 발생하는 실수 중 하나는 모든 인증·인가 오류를 같은 HTTP 상태 코드로 반환하는 것이다.

하지만 인증 실패와 권한 부족은 의미가 다르다.

상황일반적인 상태 코드
JWT 없음401
JWT 만료401
JWT 서명 검증 실패401
JWT 형식 오류401
인증 성공, 관리자 권한 없음403

예를 들어 인증되지 않은 사용자가 관리자 API에 접근했다면 401이 적절할 수 있다.

반면 유효한 일반 사용자 토큰으로 관리자 API에 접근했다면 403이 적절하다.

이 구분을 통해 클라이언트도 로그인 유도가 필요한지, 권한이 부족한지 판단할 수 있다.

12. JWT를 사용할 때 CORS와 CSRF는 구분해야 한다

CORS는 브라우저의 교차 출처 요청에 대한 접근 정책과 관련이 있다.

CSRF는 브라우저가 자동으로 전송하는 인증 정보를 악용해 사용자가 의도하지 않은 요청을 발생시키는 공격과 관련이 있다.

따라서 JWT 기반 API라고 해서 CORS나 CSRF 문제가 모두 사라지는 것은 아니다.

특히 JWT를 쿠키에 저장한다면 브라우저의 쿠키 자동 전송 특성을 고려해야 한다.

반면 Authorization 헤더를 이용하는 구조에서도 XSS에 의한 토큰 탈취 위험은 별도로 관리해야 한다.

13. JWT 인증 필터의 예외 처리 범위를 명확하게 정한다

JWT 검증에 실패한 경우 애플리케이션이 일관된 오류 응답을 반환하도록 설계해야 한다.

예를 들어 다음과 같은 상황을 구분할 수 있다.

Authorization 헤더 없음
Bearer 형식 오류
JWT 서명 오류
JWT 만료

이 오류를 모두 동일한 내부 예외 메시지로 노출할 필요는 없다.

외부 응답은 보안 정책에 맞게 통일하고, 내부 로그에서는 필요한 범위의 원인 정보를 확인할 수 있도록 구성하는 편이 좋다.

특히 서명 검증에 사용하는 비밀키나 토큰 전체를 오류 메시지에 포함해서는 안 된다.

14. JWT 검증과 사용자 조회의 책임을 구분한다

JWT 인증 필터에서 모든 비즈니스 로직을 처리하는 것은 적절하지 않다.

JWT 인증 필터의 주요 책임은 토큰을 검증하고 현재 요청에 필요한 인증 정보를 구성하는 것이다.

반면 최신 사용자 정보 조회나 주문 처리 등은 각 Service 계층에서 담당하는 편이 좋다.

JWT 인증 필터
    ↓
사용자 인증 확인
    ↓
Controller
    ↓
Service
    ↓
비즈니스 처리

이렇게 하면 인증 로직과 비즈니스 로직을 분리할 수 있다.

15. 소셜 로그인과 JWT는 서로 대체되는 개념이 아니다

소셜 로그인은 일반적으로 OAuth2 또는 OpenID Connect를 이용해 외부 인증 제공자의 인증 결과를 활용하는 구조다.

반면 JWT는 인증 결과나 사용자 정보를 표현하는 토큰 형식이다.

따라서 소셜 로그인에 성공한 이후 애플리케이션에서 자체 Access Token을 발급하는 구조를 만들 수도 있다.

Client
    ↓
외부 인증 제공자
    ↓
OAuth2 / OpenID Connect 인증
    ↓
애플리케이션 사용자 확인
    ↓
자체 Access Token 발급

즉 OAuth2와 JWT는 반드시 둘 중 하나를 선택해야 하는 관계가 아니다.

인증 흐름과 토큰 형식은 서로 다른 개념이다.


정리

Spring Security와 JWT를 이용한 REST API 인증·인가 시스템은 크게 네 가지 흐름으로 구성할 수 있다.

첫 번째는 회원가입이다.

회원가입 요청
    ↓
JoinController
    ↓
JoinService
    ↓
PasswordEncoder
    ↓
UserRepository
    ↓
MySQL

회원가입에서는 사용자 정보를 검증하고 비밀번호를 해시한 뒤 데이터베이스에 저장한다.

두 번째는 로그인이다.

로그인 요청
    ↓
UsernamePasswordAuthenticationFilter
    ↓
AuthenticationManager
    ↓
AuthenticationProvider
    ↓
UserDetailsService
    ↓
UserRepository
    ↓
MySQL
    ↓
비밀번호 검증
    ↓
JWT 발급

로그인에서는 아이디와 비밀번호를 확인하고 인증에 성공하면 JWT를 반환한다.

세 번째는 JWT 인증이다.

API 요청
    ↓
Authorization 헤더
    ↓
JWT 추출
    ↓
서명 및 유효기간 검증
    ↓
Authentication 생성
    ↓
SecurityContext 설정

클라이언트는 이후 요청마다 JWT를 전달하고, 서버는 토큰을 검증해 현재 요청의 인증 정보를 구성한다.

네 번째는 인가다.

SecurityContext
    ↓
Authentication 조회
    ↓
GrantedAuthority 확인
    ↓
API 접근 권한 판단
    ↓
Controller 실행 또는 접근 거부

Spring Security는 현재 요청의 인증 정보를 이용해 특정 API에 접근할 수 있는지 판단한다.

여기서 반드시 기억해야 할 부분은 JWT 방식에서도 SecurityContextHolder를 사용하지만, 이것이 HttpSession을 생성한다는 의미는 아니라는 점이다.

Stateless 인증에서는 요청마다 JWT를 검증해 인증 정보를 구성하고, 요청이 종료되면 해당 보안 컨텍스트를 정리할 수 있다.

또한 JWT는 로그인에 성공한 사용자를 식별하는 수단이지, 자체적으로 모든 보안 문제를 해결하는 기술은 아니다.

실제 운영 환경에서는 토큰 유효기간, 서명 검증, 키 관리, 로그아웃, 권한 변경, 토큰 탈취 대응 등을 함께 설계해야 한다.

마지막으로 JWT 인증을 구현할 때는 커스텀 필터 방식뿐 아니라 Spring Security의 OAuth2 Resource Server 기능도 고려할 수 있다.

커스텀 필터는 인증 과정을 직접 제어할 수 있다는 장점이 있지만, 검증 로직과 보안 예외 처리를 직접 책임져야 한다.

반면 Resource Server는 Bearer Token 추출, JWT 검증, Authentication 변환 등의 기능을 기본적으로 제공한다.

따라서 시스템 요구사항에 따라 적절한 방식을 선택하는 것이 중요하다.

한 줄 요약

Spring Security JWT 인증은 로그인 시 아이디와 비밀번호를 검증해 JWT를 발급하고, 이후 요청마다 전달받은 JWT의 서명과 유효기간을 확인한 뒤 SecurityContextHolder에 인증 정보를 구성하여 인가를 수행하는 방식이다. 이때 인증 상태를 HttpSession에 저장하지 않는 Stateless 구조를 구현할 수 있지만, 토큰 만료·권한 변경·로그아웃·탈취 대응은 별도로 설계해야 한다.


© 2020. All rights reserved.

SIKSIK