기본적으로 세션은 서버가 저장하는 방식이며, 보통 메모리 또는 외부 스토리지에 저장된다.
서버 측 세션 저장소는 서버를 수평 확장할 때 세션 동기화가 필요하다.
로드밸런싱 시 특정 서버에 세션이 묶이는 세션 고정(session stickiness) 문제가 발생한다.
즉, 고가용성(HA)을 해치고, 수평 확장성을 약화합니다.
세션 복제나 외부 세션 저장소 도입으로 해결할 수 있으나 구조가 복잡해지고 성능 비용이 증가한다.
분산 환경에서는 각 서버 인스턴스가 세션을 개별적으로 보관하므로 동일 사용자의 요청이 다른 서버로 분산될 때 인증 정보가 공유되지 않는다.
이를 해결하기 위해 외부 세션 저장소(예: Redis)가 필요하지만, 구성 복잡성과 네트워크 비용이 증가한다.
MSA는 본질적으로 stateless(무상태) 구조를 지향합니다.
MSA는 각 서비스가 독립적이어야 하지만 세션 기반 인증은 인증 상태를 중앙에 저장하기 때문에 서비스 간 결합도를 증가시키고, 전체 장애 영향도가 증가한다.
서비스 확장성과 독립성을 저해하므로 세션 기반 인증은 MSA 구조와 근본적으로 잘 맞지 않는다.
MSA는 보통 REST API를 기반으로 통신한다. REST 아키텍처의 가장 중요한 원칙 중 하나가 "Stateless(무상태성)"이다.
이 문제를 해결하기 위해, "로그아웃된 토큰의 명단"을 따로 관리하는 것이 블랙리스트이다.
블랙리스트를 쓰는 순간 완벽한 Stateless의 장점 일부를 포기하는 셈이다.
그래서 실무에서는 프로젝트 성격에 따라 두 가지 방법 중 하나를 선택한다.
| 방식 | 1. 블랙리스트 사용 (보안 중시) |
|---|---|
| 로그아웃 로직 | Access Token을 Redis 블랙리스트에 등록 + Refresh Token 삭제 |
| 장점 | 즉시 로그아웃 효과 확실함 (보안성 높음) |
| 단점 | 모든 요청마다 Redis 조회 비용 발생 (오버헤드) |
| 추천 상황 | 금융, 결제, 관리자 페이지 등 보안이 매우 중요한 서비스 |
Q1. DB 없이 JWT를 사용할 수는 없나?
Q2. 토큰 갱신(Refresh) 시 이전 Access Token도 블랙리스트에 넣나?
토큰(Token)은 인증된 사용자의 신원을 식별하거나 접근 권한을 부여하기 위해 발급되는 암호화된 임시 자격 증명이다. 세션과 달리 서버가 인증 상태를 저장하지 않고, 클라이언트가 토큰을 보관하다가 요청 시 헤더에 담아 전송하여 인증을 수행한다. 대표적으로 JWT(Json Web Token) 형식이 가장 많이 사용되며, 이를 활용한 OAuth 2.0 프로토콜 등이 있다.
| 구분 | 내용 |
|---|---|
| Stateless (무상태성) | 서버가 세션 저장소를 관리할 필요가 없어 서버 부하가 적고 확장성(Scale-out)이 우수함 |
| Self-contained (자기 포함) | 토큰 자체에 사용자 정보와 권한이 포함되어 있어 별도의 DB 조회 없이 검증 가능 |
| 분산 환경 적합 | 서버 간 세션 동기화가 필요 없어 MSA(마이크로서비스) 환경에 최적화됨 |
| 높은 호환성 (휴대성) | 쿠키뿐만 아니라 HTTP 헤더에 담아 전송하므로 모바일, 웹 등 다양한 클라이언트에서 사용 용이 |
| 보안 취약점 (탈취 위험) | 한 번 발급되면 유효기간 만료 전까지 서버에서 제어가 어려우므로 HTTPS 사용 및 유효기간 관리가 필수 |
| 표준화된 형식 | JWT 등 표준 규격(RFC 7519)을 따르므로 다른 시스템(Google, Kakao 등)과의 연동이 쉬움 |
Stateless 아키텍처는 서버가 클라이언트의 상태나 세션 정보를 저장하지 않고, 각 요청을 독립적으로 처리하는 구조를 말한다.
요청에는 인증 정보 및 필요한 데이터가 모두 포함되므로, 서버는 요청 자체만으로 사용자 인증 및 로직 처리가 가능하다.
이 구조는 다음과 같은 장점을 가진다.
Self-contained Token(JWT 대표)은 인증에 필요한 사용자 정보, 권한, 만료 시간 등 모든 정보를 토큰 페이로드(Payload) 내부에 포함한다.
서버는 별도 세션 저장 없이 토큰의 서명 검증만으로 요청을 처리할 수 있으므로 완전한 Stateless 구조를 만들 수 있다.
장점
주의점
Stateless 토큰 기반 구조에서는 서버가 인증 상태를 저장하지 않기 때문에, 어떤 서버 인스턴스든 동일한 요청 처리가 가능하다.
이로 인해 다음과 같은 이점이 생긴다.
JWT(JSON Web Token)는 통신 양자 간에 정보를 JSON 형태로 무결성(Integrity)을 보장하며 안전하게 전송하기 위한 토큰 표준 규격이다. 토큰은 Header(헤더)·Payload(내용)·Signature(서명) 세 부분으로 구성되며, 점(.)으로 구분된다. 필요한 인증 데이터를 토큰 내부(Payload)에 직접 포함하는 Self-contained(자기 포함) 구조를 갖는 것이 특징이다.
JWT는 RFC 7519 표준으로 정의되어 있어, 특정 언어나 플랫폼(Java, Python, Node.js 등)에 종속되지 않고 동일한 방식으로 생성 및 검증이 가능하다. 이러한 범용성 덕분에 OAuth 2.0이나 OIDC(OpenID Connect) 같은 현대 인증 프로토콜에서 Access Token 및 ID Token의 표준 포맷으로 널리 활용된다.
RFC 7519는 JSON Web Token(JWT)의 구조, 암호화 방식, 보안 요구사항을 정의한 IETF(Internet Engineering Task Force)의 공식 표준 문서이다. JWT가 어떤 구조(Header·Payload·Signature)로 구성되는지, 데이터를 담는 단위인 클레임(Claim)은 어떻게 정의하는지, 그리고 서명 및 검증 프로세스는 어떻게 수행해야 하는지에 대한 명확한 기술적 규격을 제공한다.
이 표준은 서로 다른 언어(Java, Python, JS 등)와 플랫폼 간에 토큰을 주고받을 때 문제가 없도록 상호운용성(Interoperability)을 보장하는 것을 목표로 한다. 특히 HTTP 헤더나 URL 파라미터로 전송하기 용이하도록 URL-safe하고 간결한(Compact) 방식을 채택하고 있어, OAuth 2.0 및 OpenID Connect(OIDC)와 같은 현대적인 웹/모바일 인증 프로토콜의 기반 기술로 자리 잡았다.
JWT는 단순한 로그인 인증을 넘어, 정보 교환과 권한 제어가 필요한 다양한 영역에서 표준적으로 활용된다.
| 구분 | 활용 사례 | 설명 |
|---|---|---|
| 인증 (Authentication) | 로그인 인증 토큰 | 사용자가 로그인하면 서버가 JWT를 발급하고, 클라이언트는 이후 모든 요청 헤더에 JWT를 담아 본인을 증명함 |
| 인가 (Authorization) | 권한 부여 (Access Control) | 토큰의 Payload에 담긴 클레임(role, scope)을 기반으로 사용자가 해당 API나 리소스에 접근 가능한지 결정함 |
| MSA 서비스 간 통신 | 서비스 간 신뢰성 검증 | 마이크로서비스끼리 내부 통신을 할 때도 JWT를 주고받아, 호출하는 서비스가 신뢰할 수 있는 대상인지 검증함 |
| 모바일·SPA 환경 | 세션 없는 인증 (Stateless) | 브라우저 세션(Cookie) 처리가 까다로운 모바일 앱이나 SPA(Single Page App)에서 서버 상태 저장 없이 인증을 유지함 |
| OAuth2 / OIDC | 표준 토큰 포맷 | 구글, 카카오 등 소셜 로그인(OAuth2) 시 발급되는 Access Token과 사용자 정보를 담은 ID Token의 표준 형식으로 사용됨 |
| 단기 보안 토큰 | 임시 권한 부여 | 이메일 인증 링크, 비밀번호 초기화, 파일 1회 다운로드 등 제한된 시간 동안만 유효한 임시 권한을 부여할 때 유용함 |
| API Gateway | 인증·인가 로직 통합 | 개별 서비스 대신 앞단의 API Gateway가 JWT를 검증하고 라우팅하여, 뒷단 서비스들의 인증 구현 부담을 제거함 |
| S2S (서버 간 인증) | 백엔드·배치 시스템 인증 | 사람이 아닌 서버나 배치 프로그램이 API를 호출할 때도 JWT를 사용하여 별도의 세션 관리 없이 인증을 수행함 |
| IoT / 임베디드 | 기기 인증 (Device Auth) | 메모리나 대역폭이 제한된 IoT 기기가 중앙 서버와 통신할 때, 가볍고 독립적인 JWT를 사용하여 신뢰성을 보장함 |
JWT는 Header.Payload.Signature 형식의 문자열로, 세 부분이 점(.)으로 구분된다.
JSON 기반 인증 정보를 Base64URL로 인코딩하고, 서명을 추가하여 토큰의 무결성을 보장하는 Self-contained 토큰 규격이다.
토큰의 메타데이터를 담는 영역으로, JSON을 Base64URL로 인코딩해 구성한다.
| 필드 | 의미 | 예시 |
|---|---|---|
| typ | 토큰 타입 (Type) | "JWT" |
| alg | 서명 알고리즘 | "HS256"(대칭키), "RS256"(비대칭키) |
| kid | (Optional) 키 식별자 | "key-2025-01" (키 교체 시 사용) |
실제 인증 정보(Claim)를 포함하는 영역이다.
Base64URL 인코딩은 암호화가 아니므로 누구나 내용을 디코딩하여 볼 수 있다.
따라서 비밀번호, 주민번호처럼 민감 데이터는 절대 저장해서는 안 된다.
| 구분 | 설명 |
|---|---|
| 등록된 클레임 (Registered) | RFC 7519에 정의된 표준 클레임 (iss, exp, sub 등) |
| 공개 클레임 (Public) | 충돌 방지를 위해 IANA 등록 또는 URI 기반 네임스페이스 사용 |
| 비공개 클레임 (Private) | 서버·클라이언트 간 협의로 정의한 커스텀 데이터 (userId, email 등) |
서명은 토큰의 무결성(Integrity) 을 보장하는 핵심 요소이다. 헤더와 페이로드를 합친 뒤 비밀키를 사용하여 해싱한다. 만약 해커가 페이로드(권한 등)를 조작하더라도, 비밀키를 모르면 올바른 서명을 생성할 수 없어 서버 검증 단계에서 거부된다.
HMACSHA256(
base64UrlEncode(header) + "." + base64UrlEncode(payload),
secret-key
)
| 구분 | 설명 |
|---|---|
| HS256 | 대칭키(HMAC) 방식. 서버가 하나의 비밀키(Secret Key)로 서명 생성과 검증을 모두 수행. 속도가 빠름. |
| RS256 | 비대칭키(RSA) 방식. 비밀키(Private Key) 로 서명하고, 공개키(Public Key) 로 검증함. 키 관리가 안전함. |
JWT는 URL·HTTP 헤더 등 다양한 환경에서도 문자 손상 없이 안전하게 전송하기 위해
일반 Base64의 변형 버전인 Base64URL 인코딩을 사용한다.
일반 Base64는 +, /, = 같은 문자를 포함하는데,
이는 URL 파라미터나 HTTP 헤더에서 예약어로 인식되거나 깨질 위험이 있다.
Base64URL은 다음 방식으로 이를 해결한다.
+ → - (Minus)/ → _ (Underscore)= 패딩 제거 (JWT 사양에서 패딩 생략 권장)| 구분 | Base64 (Standard) | Base64URL (JWT) |
|---|---|---|
| 사용 문자셋 | A-Z a-z 0-9 + / | A-Z a-z 0-9 - _ |
| 특수문자 처리 | + (62), / (63) 사용 | + → - (Minus), / → _ (Underscore) |
| 패딩(Padding) | 길이 맞춤을 위해 끝에 = 붙임 | 불필요한 크기를 줄이고 오류 방지를 위해 = 생략 |
| URL 안전성 | URL 파싱 시 +가 공백으로 인식되는 등 문제 발생 가능 | URL이나 쿠키, 헤더 어디서든 안전하게 전송 가능 |
| 변환 예시 | Subject? → U3ViamVjdD8= | Subject? → U3ViamVjdD8 (패딩 제거) |
Base64URL은 암호화(Encryption) 가 아니라 인코딩(Encoding) 이다.
| 구분 | 대칭키(HMAC) | 비대칭키(RSA / ECDSA) |
|---|---|---|
| 키 구조 | 하나의 Secret Key를 서명·검증에 함께 사용 | Private Key로 서명, Public Key로 검증 |
| 알고리즘 예 | HS256, HS384, HS512 | RS256, RS384, RS512, ES256, ES384 |
| 보안 수준 | 키 유출 시 누구나 서명 위조 가능 | Private Key만 보호하면 안전. Public Key는 주기적 배포 가능 |
| 속도 | 매우 빠름 | RSA는 느리나 ECDSA(ES256)는 더 빠르고 키 길이도 짧음 |
| 운영 난이도 | 키 하나만 관리 → 단순 | 키 쌍 관리, Public Key 유통 → 복잡 |
| 사용 시점 | 내부 시스템, 단일 서비스 | 외부 API, MSA, OAuth2/OIDC, 서드파티 검증 환경 |
| 검증 방식 | Secret Key로 검증 | Private Key로 서명 → Public Key로 검증 |
Header·Payload를 Base64URL 디코딩한다.alg 값을 읽어 서명 알고리즘을 확인한다.base64Url(header) + "." + base64Url(payload) 를 다시 서명하여 비교한다.| 기준 | HMAC | RSA/EC |
|---|---|---|
| 보안 수준 | Secret Key 유출 시 위험 | Public Key 검증 → 안전 |
| 속도 | 가장 빠름 | RSA는 느림, ECDSA는 빠름 |
| 키 관리 | 단순 | 상대적으로 복잡 |
| 사용 환경 | 단일 서버, 내부 API | 외부 연동, OAuth2/OIDC, MSA |
| 장점 | 단순하고 고속 | 신뢰성·확장성·분리 배포 가능 |
RFC 6750은 OAuth 2.0에서 Bearer Token(Access Token)의 사용 방식을 정의한 표준이다.
Authorization: Bearer <token>POST /login 요청으로 username/password를 전달한다.Authorization: Bearer <JWT>헤더를 추가하여 전달한다.SecurityContextHolder에 저장하고, 컨트롤러가 요청을 처리한다.dependencies {
// 1) JJWT (io.jsonwebtoken) – 가장 널리 쓰이는 간단한 JWT 라이브러리
implementation 'io.jsonwebtoken:jjwt-api:0.12.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5'
runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.5' // JSON 직렬화 지원
// 2) Auth0 java-jwt – 사용성이 좋고 상용 API 서버에서 많이 사용됨
implementation 'com.auth0:java-jwt:4.4.0'
// 3) Nimbus JOSE + JWT – JWS/JWE/JWKS 등 OAuth2/OIDC 표준 구현에 가장 적합
implementation 'com.nimbusds:nimbus-jose-jwt:10.3'
}
| 기준 | jjwt (io.jsonwebtoken) | java-jwt (Auth0) | nimbus-jose-jwt (Nimbus) |
|---|---|---|---|
| 주요 목적 | 간단한 JWT(JWS) 발급/검증 | 실용적 JWT 발급/검증 (Auth0 공식 라이브러리) | JOSE(JWS/JWE/JWK/JWA) 전체 표준 구현체 |
| 난이도 | 쉬움 (API 단순) | 쉬움~보통 (예제 풍부) | 가장 어려움 (표준 스펙 기반 구성) |
| 지원 스펙 | 주로 JWS, JWE 일부 지원 | JWS 중심, JWE 없음 | JWS + JWE + JWK + JWA 완전 지원 |
| Spring Security 연동성 | 직접 JWTProvider 구현해야 함 | 직접 구현 | Spring Security Resource Server의 기본 엔진 |
| Spring 표준과의 거리 | Spring 표준과 무관한 독립 API | 독립 API | Spring Security · OAuth2 Resource Server의 공식 기본 구현체 |
| 실제 실무 사용처 | 자체 로그인/간단 인증 서버 | 웹/모바일 백엔드 JWT 발급 | OAuth2/OIDC 서버, 대규모 인증 서버(Keycloak, Auth0 내부, Cognito 등) |
| 장점 | API 직관적, 러닝커브 매우 낮음 | 예제·문서 풍부, 사용 편함 | 표준 기반, 보안 기능 가장 풍부, OIDC/OAuth2 완전 호환 |
| 단점 | JWE 부족, 확장성 낮음 | JOSE 전체 스펙 부족 | 러닝 커브 높고 구현 복잡 |
| 권장 용도 | 단일 서비스 기반 JWT 인증 서버 | SPA/모바일 앱 백엔드 | OAuth2/OIDC, MSA 인증, Spring Security 실무 |
| Spring Security Resource Server 사용 가능 여부 | ❌ 불가 | ❌ 불가 | ⭕ 기본적으로 Nimbus가 사용됨 |
| OAuth2 / OIDC 전환 용이성 | 낮음 | 중간 | 최고 (표준 기반) |
Spring Boot의 OAuth2/OIDC 구조는 내부적으로 Nimbus JOSE + JWT(nimbus-jose-jwt) 라이브러리를 표준 구현으로 채택한다.
Spring Security의 JwtDecoder, JwtEncoder, OAuth2ResourceServer, 그리고 Spring Authorization Server는 모두 Nimbus를 기반으로 JWS/JWE/JWK 표준을 처리한다.
따라서 JWT 발급·검증을 직접 구현하더라도,
초기 설계부터 Nimbus를 사용하면 → OAuth2/OIDC 표준 구조로 자연스럽게 확장할 수 있다.
Nimbus는 JOSE 전체 스펙(JWS·JWE·JWK·JWA)을 완전 지원하므로:
즉, 자체 로그인 로직으로 시작해도
추후 OAuth2 Authorization Server로 마이그레이션할 때 코드 변경 비용이 최소화된다.
Spring Security가 Nimbus를 표준 엔진으로 사용하는 이유이기도 하다.
security:
jwt:
issuer: "codeit-auth-server" # iss 값
secret: ${SECURITY_JWT_SECRET:change-me-to-32bytes-minimum-random-key}
# HS256(HMAC) 비밀키는 최소 32 bytes(256bit) 이상 필요
access-token-validity-seconds: ${SECURITY_JWT_ACCESS_TOKEN_VALIDITY_SECONDS:3600} # Access Token (1시간)
refresh-token-validity-seconds: ${SECURITY_JWT_REFRESH_TOKEN_VALIDITY_SECONDS:1209600} # Refresh Token (14일)
# Refresh Token을 Redis와 함께 관리한다면 Redis TTL이 실제 만료 기준
header: "Authorization" # 인증 헤더 이름
prefix: "Bearer " # Authorization: Bearer {token}
algorithm: "HS256" # Nimbus → MACSigner / MACVerifier 사용
clock-skew-seconds: 60 # 서버·클라이언트 간 시계 오차 허용(±60초)
application.yml 에 저장하고, 코드에서는 @Value 로 직접 주입해 사용한다.# application.yml
jwt:
secret: "change-this-secret-at-least-32bytes-long----------------"
algorithm: "HS256"
// JwtConfig.java
@Configuration
public class JwtConfig {
@Value("${jwt.secret}")
private String secret;
@Bean
public byte[] jwtSecretKey() {
return secret.getBytes(StandardCharsets.UTF_8);
}
@Bean
public MACSigner macSigner(byte[] jwtSecretKey) throws Exception {
return new MACSigner(jwtSecretKey);
}
@Bean
public MACVerifier macVerifier(byte[] jwtSecretKey) throws Exception {
return new MACVerifier(jwtSecretKey);
}
}
# application.yml
jwt:
secret: "change-this-secret-at-least-32bytes-long----------------"
algorithm: "HS256"
access-token-exp-seconds: 3600 # 1h
refresh-token-exp-seconds: 1209600 # 14d
# application.yml
jwt:
secret: "change-this-secret-at-least-32bytes-long----------------"
issuer: "codeit-auth-server"
algorithm: "HS256"
access-token-exp-seconds: 3600 # 1h
refresh-token-exp-seconds: 1209600 # 14d
@Service
public class JwtTokenService {
@Value("${jwt.issuer}")
private String issuer;
@Value("${jwt.access-token-exp-seconds}")
private long accessTokenExpSeconds;
private final MACSigner signer;
public JwtTokenService(MACSigner signer) {
this.signer = signer;
}
public String createAccessToken(String subject) throws Exception {
Instant now = Instant.now();
Instant exp = now.plusSeconds(accessTokenExpSeconds);
JWTClaimsSet claims = new JWTClaimsSet.Builder()
.issuer(issuer)
.subject(subject)
.issueTime(Date.from(now))
.expirationTime(Date.from(exp))
.build();
JWSObject jws = new JWSObject(
new JWSHeader(JWSAlgorithm.HS256),
new Payload(claims.toJSONObject())
);
jws.sign(signer);
return jws.serialize();
}
}
JWTClaimsSet을 만들고 JWSObject에 담는다.public String createToken(String subject, Map<String, Object> customClaims) throws JOSEException {
Instant now = Instant.now();
Instant exp = now.plusSeconds(expSeconds);
JWTClaimsSet.Builder builder = new JWTClaimsSet.Builder()
.subject(subject)
.issuer(issuer)
.issueTime(Date.from(now))
.expirationTime(Date.from(exp));
if (customClaims != null) {
customClaims.forEach(builder::claim);
}
JWTClaimsSet claims = builder.build();
JWSHeader header = new JWSHeader.Builder(JWSAlgorithm.HS256)
.type(JOSEObjectType.JWT)
.build();
JWSObject jws = new JWSObject(header, new Payload(claims.toJSONObject()));
jws.sign(new MACSigner(secretKey)); // secretKey = byte[]
return jws.serialize();
}
JWSObject.parse 로 JWT 문자열 파싱 이후 MACVerifier 로 서명 검증public boolean validateToken(String token) {
try {
JWSObject jws = JWSObject.parse(token);
// 1) 알고리즘 강제
if (!JWSAlgorithm.HS256.equals(jws.getHeader().getAlgorithm())) {
return false;
}
// 2) 서명 검증
if (!jws.verify(new MACVerifier(secretKey))) {
return false;
}
// 3) 클레임 검증
JWTClaimsSet claims = JWTClaimsSet.parse(jws.getPayload().toJSONObject());
Instant now = Instant.now();
// exp 검증
if (claims.getExpirationTime() == null ||
now.isAfter(claims.getExpirationTime().toInstant())) {
return false;
}
// iss 검증
if (claims.getIssuer() == null || !claims.getIssuer().equals(issuer)) {
return false;
}
return true;
} catch (Exception e) {
return false;
}
}
parse()로 페이로드를 Claims 형태로 변환public JwtObject parse(String token) {
try {
SignedJWT signedJwt = SignedJWT.parse(token);
// 알고리즘 강제
if (!JWSAlgorithm.HS256.equals(signedJwt.getHeader().getAlgorithm())) {
throw new IllegalArgumentException("허용되지 않은 알고리즘");
}
if (!signedJwt.verify(new MACVerifier(secretKey))) {
throw new IllegalArgumentException("서명 검증 실패");
}
JWTClaimsSet claims = signedJwt.getJWTClaimsSet();
Long userId = claims.getLongClaim("userId");
String email = claims.getStringClaim("email");
String username = claims.getSubject();
String role = claims.getStringClaim("role");
return new JwtObject(
claims.getIssueTime().toInstant(),
claims.getExpirationTime().toInstant(),
new UserDto(userId, username, email, Role.valueOf(role)),
token
);
} catch (ParseException | JOSEException e) {
throw new IllegalArgumentException("JWT 파싱 실패", e);
}
}
@Component
public class JwtUtils {
@Value("${jwt.secret}")
private String secret;
@Value("${jwt.issuer}")
private String issuer;
@Value("${jwt.access-token-exp-seconds}")
private long expSeconds;
private byte[] getSecretKey() {
return secret.getBytes(StandardCharsets.UTF_8);
}
// 1) 토큰 생성
public String createToken(String subject, Map<String, Object> claims) throws Exception {
…
}
// 2) 토큰 검증
public boolean validateToken(String token) {
…
}
// 3) subject / claim 추출
public String getSubject(String token) { … }
public String getClaim(String token, String name) { … }
}
@Test
void jwt_생성_테스트() throws Exception {
String token = jwtUtils.createToken("user1", Map.of("role", "ROLE_USER"));
System.out.println("생성된 JWT = " + token);
// 헤더.페이로드.서명 3구조인지
assertEquals(3, token.split("\\.").length);
// alg=HS256 인지 확인
SignedJWT signed = SignedJWT.parse(token);
assertEquals(JWSAlgorithm.HS256, signed.getHeader().getAlgorithm());
}
jwtUtils.validateToken() 을 호출하면 서명/만료 검증을 수행한다.@Test
void jwt_검증_테스트() throws Exception {
String token = jwtUtils.createToken("user1", Map.of());
boolean valid = jwtUtils.validateToken(token);
System.out.println("검증 결과 = " + valid);
assertTrue(valid);
}
@Test
void 만료된_jwt는_실패한다() throws Exception {
// 강제로 만료된 토큰 생성
jwtUtils.setExpSeconds(-10);
String token = jwtUtils.createToken("user1", Map.of());
assertFalse(jwtUtils.validateToken(token));
}
getClaims(), getSubject(), getClaim() 으로 토큰에서 필요한 인증 정보를 꺼낼 수 있다.@Test
void jwt_클레임_추출_테스트() throws Exception {
String token = jwtUtils.createToken(
"user1",
Map.of("role", "ROLE_ADMIN")
);
assertEquals("user1", jwtUtils.getSubject(token));
assertEquals("ROLE_ADMIN", jwtUtils.getClaim(token, "role"));
System.out.println("subject = " + jwtUtils.getSubject(token));
System.out.println("role = " + jwtUtils.getClaim(token, "role"));
}
SecurityFilterChain은 Spring Security 요청 처리의 핵심으로,
HTTP 요청이 들어오면 여러 보안 필터를 순차적으로 적용해 인증·인가 여부를 결정한다.
요청 URL 패턴에 따라 여러 체인 중 하나가 선택되며 체인 내부에는 CsrfFilter, UsernamePasswordAuthenticationFilter, ExceptionTranslationFilter 등 여러 필터가 포함된다.
필터가 인증에 성공하면 SecurityContext에 Authentication이 저장되고
실패 시 예외를 발생시키거나 AuthenticationEntryPoint가 호출된다.
JWT 기반 인증을 적용할 때, 일반적으로 UsernamePasswordAuthenticationFilter 실행 이전에 커스텀 필터를 추가해 요청의 Authorization 헤더에서 Bearer Token을 읽어들이고 검증한다.
@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
// 요청 1개당 단 한 번 실행되는 JWT 필터
}
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll() // 로그인/회원가입은 인증 예외
.anyRequest().authenticated()
)
.formLogin(form -> form.disable())
.httpBasic(basic -> basic.disable());
// UsernamePasswordAuthenticationFilter 앞에서 JWT 인증 수행
http.addFilterBefore(
jwtAuthenticationFilter,
UsernamePasswordAuthenticationFilter.class
);
return http.build();
}
}
| 컴포넌트 | 역할 |
|---|---|
| BearerTokenAuthenticationToken | Authorization 헤더에서 Bearer 값 추출하여 AuthenticationManager로 전달 |
| AuthenticationManager | 적절한 AuthenticationProvider에게 인증 위임 |
| JwtAuthenticationProvider | 서명·만료 여부 검증 후 인증 객체 생성 |
| JwtDecoder | JWS 헤더/서명/클레임 전체 검증을 수행 |
| JwtAuthenticationConverter | JWT 클레임을 권한 정보(Authorities)로 변환 |
| JwtAuthenticationToken | 검증 완료 후 SecurityContext에 저장되는 Authentication 구현체 |
Stateless 모드는 세션 저장소를 전혀 사용하지 않으며
모든 요청이 독립적으로 JWT 기반 인증을 수행한다.
REST API, MSA, SPA 환경에서 표준 구조다.
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// 세션을 아예 쓰지 않음 → STATELESS
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.csrf(csrf -> csrf.disable())
.formLogin(form -> form.disable())
.httpBasic(basic -> basic.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll() // 토큰 발급 관련 경로
.anyRequest().authenticated()
);
http.addFilterBefore(
jwtAuthenticationFilter,
UsernamePasswordAuthenticationFilter.class
);
return http.build();
}
}
OncePerRequestFilter를 상속한다.public class JwtAuthFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res,
FilterChain chain)
throws IOException, ServletException {
// 이후 단계에서:
// 1) Authorization 헤더 추출
// 2) JWT 검증
// 3) SecurityContext 설정 처리
chain.doFilter(req, res);
}
}
String header = req.getHeader("Authorization");
if (header == null || !header.toLowerCase().startsWith("bearer ")) {
chain.doFilter(req, res);
return;
}
// "Bearer " 이후
String token = header.substring(7).trim();
if (jwtUtils.validateToken(token)) {
Authentication auth = jwtUtils.getAuthentication(token);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(auth);
SecurityContextHolder.setContext(context);
}
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtUtils jwtUtils;
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res,
FilterChain chain)
throws IOException, ServletException {
try {
String header = req.getHeader("Authorization");
// Bearer 토큰 파싱
if (header != null && header.toLowerCase().startsWith("bearer ")) {
// "Bearer " 이후
String token = header.substring(7).trim();
// 1) JWT 검증
if (jwtUtils.validateToken(token)) {
Authentication auth = jwtUtils.getAuthentication(token);
// 2) SecurityContext 안전하게 설정
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(auth);
SecurityContextHolder.setContext(context);
}
}
chain.doFilter(req, res);
} catch (Exception e) {
// JWT 에러 발생 시 security context 초기화
SecurityContextHolder.clearContext();
chain.doFilter(req, res);
}
}
}
String header = req.getHeader("Authorization");
if (header == null || !header.startsWith("Bearer ")) {
throw new BadCredentialsException("토큰이 제공되지 않았습니다.");
}
try {
if (!jwtUtils.validateToken(token)) {
throw new BadCredentialsException("JWT 토큰이 유효하지 않습니다.");
}
} catch (ExpiredJwtException e) {
SecurityContextHolder.clearContext();
throw e;
}
try {
String header = req.getHeader("Authorization");
if (header == null || !header.startsWith("Bearer ")) {
throw new BadCredentialsException("토큰이 제공되지 않았습니다.");
}
String token = header.substring(7);
// 내부에서 exp 검증 / alg 검증 / 서명 검증 수행
if (!jwtUtils.validateToken(token)) {
throw new BadCredentialsException("JWT 토큰이 유효하지 않습니다.");
}
Authentication auth = jwtUtils.getAuthentication(token);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(auth);
SecurityContextHolder.setContext(context);
chain.doFilter(req, res);
} catch (AuthenticationException e) {
// Security가 자동으로 EntryPoint 통해 처리
SecurityContextHolder.clearContext();
throw e;
}
@Component
public class JwtAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException ex)
throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("""
{
"error": "UNAUTHORIZED",
"message": "%s"
}
""".formatted(ex.getMessage()));
}
}
JWT 기반 API는 세션을 사용하지 않으며, 클라이언트가 직접 Authorization 헤더에 토큰을 포함해 요청을 보내기 때문에 CSRF 토큰 기반 보호가 필요하지 않다.
http.csrf(csrf -> csrf.disable());
JWT 인증은 완전 Stateless 구조이므로 세션을 생성하거나 저장하지 않도록 설정한다.
http.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
프론트엔드(React, Next.js 등)와 다른 도메인에서 통신할 경우 CORS 설정이 필요하다.
Origin, Header, Method 등을 명시해서 브라우저의 SOP(CORS 정책) 제한을 해결한다.
http.cors(cors -> cors.configurationSource(request -> {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOrigin("http://localhost:3000");
config.addAllowedMethod("*");
config.addAllowedHeader("*");
config.addExposedHeader("Authorization"); // JWT 재발급 시 헤더 노출
config.setAllowCredentials(true);
return config;
}));
로그인 / 회원가입 / 토큰 재발급 API는 인증 없이 접근 가능해야 한다.
그 외 모든 요청은 기본적으로 인증을 요구한다.
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated()
);
Spring Security의 formLogin / httpBasic 은 사용하지 않으므로 비활성화한다.
http.formLogin(form -> form.disable())
.httpBasic(basic -> basic.disable());
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
private final JwtAuthenticationEntryPoint entryPoint;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
// 1) CSRF 비활성화
.csrf(csrf -> csrf.disable())
// 2) Stateless 설정
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// 3) CORS 설정
.cors(cors -> cors.configurationSource(request -> {
CorsConfiguration config = new CorsConfiguration();
config.addAllowedOrigin("http://localhost:3000");
config.addAllowedMethod("*");
config.addAllowedHeader("*");
config.addExposedHeader("Authorization");
config.setAllowCredentials(true);
return config;
}))
// 4) 인증 예외 URL
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated()
)
// 5) 인증 실패 시 EntryPoint 처리
.exceptionHandling(ex -> ex
.authenticationEntryPoint(entryPoint)
)
// 6) 기본 로그인 비활성화
.formLogin(form -> form.disable())
.httpBasic(basic -> basic.disable());
// 7) JWT 필터 등록 (UsernamePasswordAuthenticationFilter 이전)
http.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
UserDetailsService는 로그인 시점에 사용자 정보를 DB에서 조회하고, Spring Security의 인증 절차에서 사용할 UserDetails 객체로 변환한다.
JWT 기반의 Stateless 환경에서는
로그인할 때만 DB 조회 → 이후 요청은 JWT 검증만 수행한다는 점이 중요하다.
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username)
throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() ->
new UsernameNotFoundException("사용자를 찾을 수 없습니다.")
);
// roles()는 "ROLE_" prefix 자동 부여됨
return org.springframework.security.core.userdetails.User
.withUsername(user.getUsername())
.password(user.getPassword())
.roles(user.getRole()) // DB에는 "ADMIN"만 저장해야 맞음
.build();
}
}
@PostMapping("/login")
public TokenResponse login(@RequestBody LoginRequest req) {
UserDetails user = userDetailsService.loadUserByUsername(req.username());
if (!passwordEncoder.matches(req.password(), user.getPassword())) {
throw new BadCredentialsException("비밀번호가 올바르지 않습니다.");
}
String jwt = jwtUtils.createToken(
user.getUsername(),
Map.of("role", user.getAuthorities().iterator().next().getAuthority())
);
return new TokenResponse(jwt);
}
Spring Security의 예외 처리 구조와 정합성을 유지하기 위해
AuthenticationException을 던지고 AuthenticationEntryPoint에서 처리하는 것이 권장된다.
try {
UserDetails user = userDetailsService.loadUserByUsername(req.username());
if (!passwordEncoder.matches(req.password(), user.getPassword())) {
throw new BadCredentialsException("비밀번호 불일치");
}
// 인증 성공 → JWT 생성
String token = jwtUtils.createToken(user.getUsername(), Map.of());
return new TokenResponse(token);
} catch (UsernameNotFoundException | BadCredentialsException e) {
throw new AuthenticationServiceException(e.getMessage(), e);
}
로그인 시 클라이언트가 전달하는 사용자명 / 비밀번호를 명확히 표현하고,
Bean Validation을 적용하여 형식 오류를 컨트롤러 이전 단계에서 차단한다.
public record LoginRequest(
@NotBlank(message = "사용자 이름은 필수입니다")
String username,
@NotBlank(message = "비밀번호는 필수입니다")
String password
) { }
Spring Security에서 로그인 인증은 다음 순서로 진행된다:
AuthenticationManager
→ DaoAuthenticationProvider
→ UserDetailsService.loadUserByUsername()
→ PasswordEncoder.matches()
즉 컨트롤러는 단순히 UsernamePasswordAuthenticationToken을 생성하고
AuthenticationManager에 던지기만 하면 된다.
@RequiredArgsConstructor
@RestController
@RequestMapping("/api/auth")
public class AuthController {
private final AuthenticationManager authenticationManager;
private final JwtUtils jwtUtils;
@PostMapping("/login")
public TokenResponse login(@Valid @RequestBody LoginRequest request) {
UsernamePasswordAuthenticationToken authToken =
new UsernamePasswordAuthenticationToken(
request.username(),
request.password()
);
// 인증 시도 (UserDetailsService + PasswordEncoder가 내부에서 실행)
Authentication authentication =
authenticationManager.authenticate(authToken);
// 권한 리스트 추출
List<String> roles = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.toList();
// JWT 발급
String jwt = jwtUtils.createToken(
authentication.getName(),
Map.of("roles", roles)
);
return new TokenResponse(jwt);
}
public record TokenResponse(String accessToken) {}
}
JWT는 서버가 Stateless 환경에서 인증·인가를 수행할 수 있도록
최소한의 사용자 식별 정보를 포함한다.
String token = Jwts.builder()
.setSubject(user.getId().toString()) // 사용자 ID (권장)
.claim("roles", user.getRoles().stream()
.map(Role::name)
.toList()) // 권한 List<String>
.signWith(key)
.compact();
서비스별로 필요한 정보가 있다면 클레임으로 자유롭게 추가할 수 있다.
String token = Jwts.builder()
.setSubject(user.getId().toString())
.claim("role", "ROLE_USER")
.claim("nickname", user.getNickname()) // 가능
.claim("loginType", user.getLoginType()) // 가능
.signWith(key)
.compact();
JWT는 Base64URL 인코딩이며 누구든 디코딩할 수 있으므로
유출 시 위험한 정보는 절대 넣으면 안 된다.
// 절대 금지
.claim("password", user.getPassword())
.claim("phone", user.getPhone())
.claim("email", user.getEmail()) // 경우에 따라 금지
.claim("address", user.getAddress())
.claim("organizationName", user.getOrgName())
String token = Jwts.builder()
.setSubject(user.getId().toString())
.claim("roles", user.getRoles().stream()
.map(Role::name)
.toList())
.setIssuedAt(new Date())
.setExpiration(Date.from(Instant.now().plusSeconds(3600)))
.signWith(key)
.compact();
Claims claims = jwtUtils.parseClaims(token);
String username = claims.getSubject();
List<String> roles = Optional.ofNullable((List<?>) claims.get("roles"))
.orElseGet(List::of)
.stream()
.map(String::valueOf)
.toList();
SimpleGrantedAuthority로 매핑하여 인가(Authorization) 처리에 활용된다.List<GrantedAuthority> authorities = roles.stream()
.map(SimpleGrantedAuthority::new)
.toList();
SecurityContextHolder에 저장하여 인증된 사용자로 등록한다.SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(auth);
SecurityContextHolder.setContext(context);
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtUtils jwtUtils; // 내부에서 서명/만료/alg 검증 + Claims 파싱까지 한다고 가정
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res,
FilterChain chain)
throws IOException, ServletException {
String header = req.getHeader("Authorization");
// 토큰 없으면 인증 시도 안 하고 다음 필터로
if (header == null || !header.startsWith("Bearer ")) {
chain.doFilter(req, res);
return;
}
String token = header.substring(7).trim();
try {
// 1) 토큰 검증 + Claims 파싱 (서명·만료·alg 검증 포함)
Claims claims = jwtUtils.parseClaims(token);
String username = claims.getSubject();
List<String> roles = Optional.ofNullable((List<?>) claims.get("roles"))
.orElseGet(List::of)
.stream()
.map(String::valueOf)
.toList();
// 2) Roles → Authorities 변환
List<GrantedAuthority> authorities = roles.stream()
.map(SimpleGrantedAuthority::new)
.toList();
// 3) Authentication 생성 후 SecurityContext에 저장
Authentication auth =
new UsernamePasswordAuthenticationToken(username, null, authorities);
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(auth);
SecurityContextHolder.setContext(context);
} catch (Exception e) {
// JWT가 유효하지 않으면 인증 정보 제거하고 그냥 다음 필터로 넘김
SecurityContextHolder.clearContext();
}
chain.doFilter(req, res);
}
}
hasRole, hasAnyRole, hasAuthority 등을 사용해 인증된 사용자의 권한을 검사한다..authorizeHttpRequests(auth -> auth
.requestMatchers("/api/user/**").hasRole("USER") // ROLE_USER 필요
.requestMatchers("/api/editor/**").hasAnyRole("EDITOR", "ADMIN")
.anyRequest().authenticated()
)
공개 URL, 로그인 URL, 사용자 전용 URL 등을 구분하여 경로마다 인가 규칙을 적용한다.
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/auth/**").permitAll() // 로그인/회원가입
.requestMatchers("/api/user/**").hasRole("USER")
.anyRequest().authenticated()
)
/api/admin/**)하고 hasRole("ADMIN")로 보호하는 것이 일반적이다..authorizeHttpRequests(auth -> auth
.requestMatchers("/api/admin/**").hasRole("ADMIN") // 관리자 전용
.anyRequest().authenticated()
)
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.authorizeHttpRequests(auth -> auth
// 공개 엔드포인트
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/auth/**").permitAll()
// ROLE_ADMIN 요구
.requestMatchers("/api/admin/**").hasRole("ADMIN")
// ROLE_USER 요구
.requestMatchers("/api/user/**").hasRole("USER")
.requestMatchers("/api/editor/**").hasAnyRole("EDITOR", "ADMIN")
// 나머지는 인증만 필요
.anyRequest().authenticated()
)
.formLogin(form -> form.disable())
.httpBasic(basic -> basic.disable());
http.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@PreAuthorize, @PostAuthorize, @Secured 등을 사용하려면 다음 설정이 반드시 필요하다.
@Configuration
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig { }
메서드 호출 전에 Spring Security가 AOP 프록시를 통해
인증·인가를 수행한다.
@Service
public class PostService {
// USER만 글 작성 가능
@PreAuthorize("hasRole('USER')")
public void createPost(PostCreateRequest req) {
…
}
// ADMIN만 전체 글 삭제 가능
@PreAuthorize("hasRole('ADMIN')")
public void deleteAllPosts() {
…
}
}
메서드 파라미터 또는 principal을 활용해
리소스 소유자 기반 접근 제어를 구현할 수 있다.
@Service
public class PostService {
@PreAuthorize("hasRole('ADMIN') or #authorId == principal.id")
public void updatePost(Long postId, Long authorId, PostUpdateRequest req) {
…
}
@PreAuthorize("#userId == principal.id")
public UserProfile getUserProfile(Long userId) {
…
}
}
principal.id를 쓰려면 JWT 인증 필터에서 CustomUserPrincipal을 넣어야 한다:
@PreAuthorize("hasRole('ADMIN') or #orgId == principal.orgId")
public ProjectDetail getProjectDetail(Long projectId, Long orgId) { … }
조직 ID(orgId), 구독 플랜(plan), tier 등
도메인 정보를 JWT 클레임 → CustomUserPrincipal로 이동한 뒤
SpEL에서 사용할 수 있다.
@PreAuthorize("hasRole('ADMIN') or #orgId == principal.orgId")
public ProjectDetail getProjectDetail(Long projectId, Long orgId) { … }
@PreAuthorize("hasRole('ADMIN') or #authorId == principal.id")
public void updatePost(Long postId, Long authorId, PostUpdateRequest req) {
…
}
복잡한 규칙은 SpEL Bean으로 분리하는 것이 유지보수에 유리하다.
@Component("authz")
public class AuthorizationChecker {
public boolean isPostOwner(Long postId, Authentication authentication) {
CustomUserPrincipal principal = (CustomUserPrincipal) authentication.getPrincipal();
Long currentUserId = principal.getId();
Long ownerId = findOwner(postId);
return currentUserId.equals(ownerId);
}
}
Access Token 클라이언트가 보호된 API 자원에 접근하기 위해 사용하는 단기 인증 토큰이다. 짧은 만료 시간을 가지며, 유출 시 피해를 줄이기 위해 자체 정보만으로 인증이 완료되는 구조이다.Refresh Token Access Token이 만료되었을 때 새로운 Access Token을 발급받기 위해 사용하는 장기 토큰이다. 서버 저장소에 보관하거나 로테이션 전략과 함께 사용하여 탈취 위험을 최소화하는 방식이다.Access Token이 만료되었을 때 사용자를 다시 로그인 시키지 않고 새로운 Access Token을 발급받게 하는 장기 인증 수단이다.
주로 서버 측 저장소(DB·Redis 등)에 보관하여 탈취 위험을 최소화하며, 재발급·로테이션 등의 관리 기능을 담당한다.
| 구분 | 설명 |
|---|---|
| 수명(Long-lived) | 보통 7일 ~ 30일, 보안 강한 서비스는 2시간~24시간 로테이션 + 14일 이하 유효기간을 사용 |
| 서버 저장(Server-side Store) | DB·Redis 등에 저장하여 유효성 검증 및 강제 로그아웃 가능 |
| 재발급 기능(Re-issue) | Access Token 만료 시 새로운 Access Token 발급에 사용 |
| 보안성(Security Impact) | 유출 시 위험도가 크므로 HttpOnly·Secure 쿠키, 로테이션 전략 필요 |
| 회수·차단 가능(Revocable) | 서버 저장소에서 해당 토큰 삭제하거나 블랙리스트 처리하여 즉시 무효화 |
| 로테이션 전략(Rotation) | 재발급마다 Refresh Token도 교체해 탈취 위험 최소화 |
사용자가 인증된 이후, API 요청 시 자신이 누구인지 증명하기 위해 사용하는 단기 인증 토큰이다.
자체적으로 필요한 정보를 포함하는 Self-contained 구조(JWT)를 많이 사용하며, 만료 시간이 짧아
보안성이 높다
| 구분 | 설명 |
|---|---|
| 수명(Short-lived) | 일반적으로 5분 ~ 30분, 보안이 민감한 서비스는 더 짧게 설정 |
| 자체 포함(Self-contained) | 사용자 정보·권한·만료시간 등 필요한 정보를 토큰 내부에 포함 |
| 서버 저장 불필요 | 자체 정보로 검증 가능해 서버 세션 저장소가 필요 없음 |
| 빠른 검증(Fast Verification) | 서명(Signature) 검증만으로 빠르게 인증 처리 가능 |
| 유출 시 피해 최소화 | 만료가 짧아 탈취되더라도 피해가 제한적 |
| 클라이언트 보관(Client-side) | 로컬스토리지, 메모리, 쿠키 등 클라이언트 측에서 직접 관리 |
| 구분 | Access Token | Refresh Token |
|---|---|---|
| 용도 | API 호출 시 인증·인가 수행 | 새로운 Access Token 재발급 |
| 수명 | 매우 짧음 (5~30분) | 길게 유지 (7~30일) |
| 보관 위치 | 클라이언트 측 저장 | 서버 저장소(DB/Redis) 또는 HttpOnly 쿠키 |
| 유출 위험도 | 짧아 피해 제한적 | 길어 더 위험 → 강한 보호 필요 |
| 검증 방식 | 자체 포함 서명 검증(JWT)으로 즉시 검증 | 서버 저장소에서 유효성 확인 필요 |
| 발급 횟수 | 요청마다 사용 | Access Token 재발급 시 사용 |
| 회수/차단 | 일반적으로 회수 어려움 | 서버에서 삭제하면 즉시 무효화 가능 |
| 항목 | 설명 |
|---|---|
| 단기·장기 분리 전략 | Access Token을 짧게 유지해 탈취 피해 최소화, Refresh Token은 별도 보호로 안정적 관리 |
| 서버 측 무효화 가능 | Refresh Token은 서버 저장소에서 삭제하면 즉시 모든 Access Token 재발급 차단 |
| 로테이션 전략 적용 | Refresh Token 요청마다 새 토큰으로 교체해 탈취 시에도 반복 사용 불가 |
| 재로그인 방지 + 보안 강화 | 사용자 편의성을 유지하면서도 세션 기반보다 유출 위험 통제력이 높음 |
Refresh Token은 인증 시 서버가 장기 보관용 토큰으로 생성하며, 고유 식별자와 만료 정보가 포함된다.
생성된 Refresh Token은 서버 저장소(DB·Redis)에 사용자 계정과 함께 매핑하여 저장한다.
클라이언트에는 HttpOnly·Secure 쿠키 등 안전한 방식으로 전달하며, 이후 Access Token 재발급 요청 시
검증에 사용된다.
Refresh Token은 사용자별 인증 상태를 장기적으로 유지하기 위해 토큰 값 자체가 아닌 해시값, 만료 시각, 폐기 여부 등을 서버 DB에 저장하여 안전하게 관리한다.
하나의 사용자가 여러 기기·세션을 가질 수 있으므로 1:N 구조(User → RefreshToken) 로 설계해 발급 이력과 로테이션을 지원한다.
| 컬럼명 | 타입 | 설명 |
|---|---|---|
| id | BIGINT | Refresh Token 레코드 식별자 |
| user_id | UUID | 사용자 식별자 (User 테이블 FK 매핑) |
| token_hash | VARCHAR(255) | Refresh Token 원문을 해싱한 값 |
| expires_at | TIMESTAMP WITH TIME ZONE | Refresh Token 만료 시각 |
| revoked | BOOLEAN | 토큰 폐기 여부(강제 로그아웃 대응) |
| created_at | TIMESTAMP WITH TIME ZONE | 토큰 생성 시각 |
| updated_at | TIMESTAMP WITH TIME ZONE | 토큰 갱신(로테이션) 시 자동 업데이트 |
Refresh Token을 사용할 때마다 새로운 Refresh Token을 재발급하고 기존 토큰을 즉시 폐기하여 탈취된 토큰의 반복 사용을 방지하는 보안 전략이다.
서버는 DB에 저장된 기존 Refresh Token을 무효화하고 신규 토큰 해시를 저장해 항상 최신 토큰만 유효하도록 유지한다.
public RefreshToken validateRefreshToken(String refreshToken) {
String hash = hashToken(refreshToken);
return refreshTokenRepository.findByTokenHash(hash)
.filter(token -> !token.isRevoked())
.filter(token -> token.getExpiresAt().isAfter(Instant.now()))
.orElseThrow(() -> new RuntimeException("Invalid or expired refresh token"));
}
public String createNewAccessToken(UUID userId) {
return jwtUtils.createAccessToken(userId.toString());
}
revoked=true로 처리한다.@Transactional
public RefreshToken rotateRefreshTokenIfNeeded(RefreshToken oldToken, boolean rotationEnabled) {
if (!rotationEnabled) {
return oldToken; // 기존 Refresh Token 유지
}
// 기존 토큰 폐기
oldToken.setRevoked(true);
// 새 Refresh Token 생성
String newToken = tokenProvider.generateRefreshToken();
RefreshToken newEntity = new RefreshToken(
oldToken.getUserId(),
hashToken(newToken),
Instant.now().plus(Duration.ofDays(14))
);
return refreshTokenRepository.save(newEntity);
}
@PostMapping("/auth/refresh")
public TokenResponse refreshTokens(@RequestBody TokenRefreshRequest req) {
// 1) Refresh Token 검증
RefreshToken token = validateRefreshToken(req.refreshToken());
// 2) 새로운 Access Token 발급
String newAccessToken = createNewAccessToken(token.getUserId());
// 3) Refresh Token 갱신 여부 결정 (예: Rotation 전략 = true)
RefreshToken newRefreshToken = rotateRefreshTokenIfNeeded(token, true);
return new TokenResponse(
newAccessToken,
newRefreshToken.getOriginalTokenValue() // 원문 반환 or HttpOnly Cookie로 전달
);
}
revoked=true 또는 삭제하여 더 이상 재발급이 안 되도록 한다.@Service
@RequiredArgsConstructor
public class AuthService {
private final RefreshTokenRepository refreshTokenRepository;
// 로그아웃 API에서 호출
@Transactional
public void logout(UUID userId) {
refreshTokenRepository.revokeByUserId(userId);
// 필요 시 클라이언트 쿠키 삭제는 컨트롤러/필터에서 처리
}
}
2) 비밀번호 변경 시 토큰 무효화
@Service
@RequiredArgsConstructor
public class UserService {
private final UserRepository userRepository;
private final RefreshTokenRepository refreshTokenRepository;
private final PasswordEncoder passwordEncoder;
@Transactional
public void changePassword(UUID userId, String newPassword) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new RuntimeException("User not found"));
user.setPassword(passwordEncoder.encode(newPassword));
// 비밀번호 변경 시 모든 토큰 무효화
refreshTokenRepository.revokeByUserId(userId);
}
}
@Service
@RequiredArgsConstructor
public class AdminService {
private final RefreshTokenRepository refreshTokenRepository;
@Transactional
public void forceLogoutUser(UUID userId) {
int count = refreshTokenRepository.deleteAllByUserId(userId);
System.out.println("강제 로그아웃 처리된 토큰 수: " + count);
}
}
expiresAt < now 또는 revoked = true 조건으로 정리한다.@Service
@RequiredArgsConstructor
public class TokenCleanupScheduler {
private final RefreshTokenRepository refreshTokenRepository;
// 매일 새벽 3시 정각 실행 예시
@Scheduled(cron = "0 0 3 * * *")
public void cleanTokens() {
int deleted = refreshTokenRepository.deleteExpiredOrRevoked(Instant.now());
System.out.println("정리된 토큰 수: " + deleted);
}
}