[강의] Spring 토큰 기반 인증/인가

Jerry·2025년 11월 21일

토큰 기반 인증의 개념과 필요성 복습

세션 기반 인증의 복습과 한계점

서버 측 세션 저장소의 확장성 문제

기본적으로 세션은 서버가 저장하는 방식이며, 보통 메모리 또는 외부 스토리지에 저장된다.

서버 측 세션 저장소는 서버를 수평 확장할 때 세션 동기화가 필요하다.

로드밸런싱 시 특정 서버에 세션이 묶이는 세션 고정(session stickiness) 문제가 발생한다.

  • 서버 A가 죽으면 해당 세션의 사용자는 바로 로그아웃됨(세션 유실)
  • 로드밸런서가 트래픽을 균등하게 분산하려 해도, 특정 사용자가 몰려있는 특정 서버에만 트래픽이 집중되는(Hotspot) 현상이 발생할 수 있다.
  • 장애 시 세션 이동이 어려움

즉, 고가용성(HA)을 해치고, 수평 확장성을 약화합니다.

세션 복제나 외부 세션 저장소 도입으로 해결할 수 있으나 구조가 복잡해지고 성능 비용이 증가한다.

분산 환경에서의 세션 관리 문제

분산 환경에서는 각 서버 인스턴스가 세션을 개별적으로 보관하므로 동일 사용자의 요청이 다른 서버로 분산될 때 인증 정보가 공유되지 않는다.
이를 해결하기 위해 외부 세션 저장소(예: Redis)가 필요하지만, 구성 복잡성과 네트워크 비용이 증가한다.

  • Redis가 죽으면 모든 서버의 로그인이 풀려버리는 엄청난 리스크(SPOF: Single Point Of Failure)가 생긴다. 그래서 Redis 자체를 또 이중화/클러스터링 해야 하는 인프라 관리 비용이 발생한다.

MSA 환경에서의 세션 관리의 문제점

MSA는 본질적으로 stateless(무상태) 구조를 지향합니다.

  • 각 서비스가 독립적으로 확장되어야 함
  • 서비스 간 조인, 공유, 동기화가 없을수록 좋음
  • stateless여야 로드밸런서가 자유롭게 트래픽 분산 가능

MSA는 각 서비스가 독립적이어야 하지만 세션 기반 인증은 인증 상태를 중앙에 저장하기 때문에 서비스 간 결합도를 증가시키고, 전체 장애 영향도가 증가한다.

서비스 확장성과 독립성을 저해하므로 세션 기반 인증은 MSA 구조와 근본적으로 잘 맞지 않는다.

MSA는 보통 REST API를 기반으로 통신한다. REST 아키텍처의 가장 중요한 원칙 중 하나가 "Stateless(무상태성)"이다.

  • "서버는 클라이언트의 상태(세션)를 보존하지 않는다."
  • 요청만 보고도 누구인지 알 수 있어야 한다.
  • 이 철학을 지키기 위해 세션 대신 토큰(JWT)을 사용하는 것이 자연스러운 수순이다.

JWT 기반 인증에서의 블랙리스트(Blacklist) 전략

1. 왜 블랙리스트가 필요한가요? (문제점)

  • 사용자가 "로그아웃" 버튼을 눌러도, 해커가 그 직전에 탈취한 Access Token은 (예: 유효기간 1시간) 여전히 유효하다.
  • 서버는 이 토큰이 로그아웃된 토큰인지 알 방법이 없다.

2. 블랙리스트 구현 원리

이 문제를 해결하기 위해, "로그아웃된 토큰의 명단"을 따로 관리하는 것이 블랙리스트이다.

  1. 로그아웃 요청: 클라이언트가 로그아웃을 요청하며 Access Token을 서버에 보낸다.
  2. 저장: 서버는 이 토큰을 Redis 같은 인메모리 DB에 저장한다.
    • Key: 토큰 값 (또는 토큰의 고유 ID인 jti)
    • Value: "logout"
    • TTL (Time To Live): 토큰의 남은 유효기간만큼만 설정
  3. 검증: 이후 요청이 들어오면, 서버(Spring Security Filter)는 토큰 서명을 확인하기 전에 "혹시 이 토큰이 Redis 블랙리스트에 있나?"를 먼저 확인한다.
  4. 자동 삭제: Redis의 TTL 덕분에 토큰의 유효기간이 끝나면 블랙리스트에서도 자동으로 사라져 메모리 낭비가 없다.

3. 실무에서의 딜레마와 대안

블랙리스트를 쓰는 순간 완벽한 Stateless의 장점 일부를 포기하는 셈이다.
그래서 실무에서는 프로젝트 성격에 따라 두 가지 방법 중 하나를 선택한다.

방식1. 블랙리스트 사용 (보안 중시)
로그아웃 로직Access Token을 Redis 블랙리스트에 등록 + Refresh Token 삭제
장점즉시 로그아웃 효과 확실함 (보안성 높음)
단점모든 요청마다 Redis 조회 비용 발생 (오버헤드)
추천 상황금융, 결제, 관리자 페이지 등 보안이 매우 중요한 서비스

4. 자주 묻는 질문 (FAQ) 정리

Q1. DB 없이 JWT를 사용할 수는 없나?

  • 가능하다. 아니, 원래 JWT는 DB 없이 서명(Signature) 확인만으로 인증하는 것이 정석이다.
  • 하지만 이 경우 '강제 로그아웃'이나 '계정 차단'을 즉시 반영할 수 없기에, 실무에서는 "인증은 DB 없이(Access Token)", "관리는 DB 사용(Refresh Token/Blacklist)"하는 하이브리드 방식을 주로 채택한다.

Q2. 토큰 갱신(Refresh) 시 이전 Access Token도 블랙리스트에 넣나?

  • 보통 넣지 않는다. 갱신을 요청한다는 건 기존 Access Token이 이미 만료(Expired)되었다는 뜻이므로, 서버가 알아서 거부하기 때문이다.
  • 단, 만료 전 미리 갱신하는 정책(Silent Refresh 등)을 사용한다면 보안을 위해 넣을 수도 있다.
  • 오히려 중요한 것은 사용된 Old Refresh Token을 즉시 삭제(Rotation)하여 재사용을 막는 것이다.

토큰 기반 인증의 개념과 특징

토큰의 정의와 특징

토큰의 정의

토큰(Token)은 인증된 사용자의 신원을 식별하거나 접근 권한을 부여하기 위해 발급되는 암호화된 임시 자격 증명이다. 세션과 달리 서버가 인증 상태를 저장하지 않고, 클라이언트가 토큰을 보관하다가 요청 시 헤더에 담아 전송하여 인증을 수행한다. 대표적으로 JWT(Json Web Token) 형식이 가장 많이 사용되며, 이를 활용한 OAuth 2.0 프로토콜 등이 있다.

주요 동작 방식

  1. 발급: 사용자가 로그인에 성공하면 서버는 신원 정보와 권한이 담긴 토큰을 생성하여 발급한다.
  2. 저장: 클라이언트는 이 토큰을 로컬 스토리지나 쿠키 등에 저장한다.
  3. 전송: 이후 요청 시 HTTP 헤더(Authorization: Bearer)에 토큰을 실어 서버로 전송한다.
  4. 검증: 서버는 별도의 저장소 조회 없이 토큰의 서명을 검증하여 요청을 처리한다.

토큰의 주요 특징

구분내용
Stateless (무상태성)서버가 세션 저장소를 관리할 필요가 없어 서버 부하가 적고 확장성(Scale-out)이 우수함
Self-contained (자기 포함)토큰 자체에 사용자 정보와 권한이 포함되어 있어 별도의 DB 조회 없이 검증 가능
분산 환경 적합서버 간 세션 동기화가 필요 없어 MSA(마이크로서비스) 환경에 최적화됨
높은 호환성 (휴대성)쿠키뿐만 아니라 HTTP 헤더에 담아 전송하므로 모바일, 웹 등 다양한 클라이언트에서 사용 용이
보안 취약점 (탈취 위험)한 번 발급되면 유효기간 만료 전까지 서버에서 제어가 어려우므로 HTTPS 사용 및 유효기간 관리가 필수
표준화된 형식JWT 등 표준 규격(RFC 7519)을 따르므로 다른 시스템(Google, Kakao 등)과의 연동이 쉬움

Stateless 아키텍처의 이해

Stateless 아키텍처는 서버가 클라이언트의 상태나 세션 정보를 저장하지 않고, 각 요청을 독립적으로 처리하는 구조를 말한다.
요청에는 인증 정보 및 필요한 데이터가 모두 포함되므로, 서버는 요청 자체만으로 사용자 인증 및 로직 처리가 가능하다.

이 구조는 다음과 같은 장점을 가진다.

  • 서버 확장성 증가 (수평 확장에 최적)
  • 특정 서버 인스턴스 장애가 전체 인증 상태에 영향을 주지 않음
  • 로드밸런서가 세션 고정 없이 자유롭게 분산 가능
  • 쿠버네티스·MSA·컨테이너 기반 환경에서 효율적

토큰의 자체 포함성(Self-contained)

Self-contained Token(JWT 대표)은 인증에 필요한 사용자 정보, 권한, 만료 시간 등 모든 정보를 토큰 페이로드(Payload) 내부에 포함한다.
서버는 별도 세션 저장 없이 토큰의 서명 검증만으로 요청을 처리할 수 있으므로 완전한 Stateless 구조를 만들 수 있다.

장점

  • 인증 처리 시 DB 조회 없이 검증만 하면 되므로 빠르고 확장성이 뛰어남
  • 서비스 인스턴스 수와 관계없이 인증 일관성 유지 가능

주의점

  • 토큰 유출 시 즉시 악용 가능
  • 발급된 JWT를 서버에서 강제로 무효화하기 어려움(revocation 문제)
  • 만료 시간이 길수록 보안 위험 증가 → Access/Refresh Token 전략 필요

서버 부하 분산과 확장성 향상

Stateless 토큰 기반 구조에서는 서버가 인증 상태를 저장하지 않기 때문에, 어떤 서버 인스턴스든 동일한 요청 처리가 가능하다.

이로 인해 다음과 같은 이점이 생긴다.

  • 로드밸런서가 특정 서버에 세션을 고정할 필요가 없어 부하가 균등하게 분산됨
  • 신규 서버 인스턴스를 추가해도 세션 동기화 없이 즉시 트래픽 처리 가능
  • 고트래픽 서비스, MSA, 쿠버네티스 환경에서 자동 확장(HPA)과 장애 격리에 최적

JWT의 정의와 표준

정의 및 구조

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

정의 및 역할

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의 주요 활용 사례

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의 구조 이해

JWT의 전체 구조

JWT는 Header.Payload.Signature 형식의 문자열로, 세 부분이 점(.)으로 구분된다.
JSON 기반 인증 정보를 Base64URL로 인코딩하고, 서명을 추가하여 토큰의 무결성을 보장하는 Self-contained 토큰 규격이다.

  • Header: 토큰 타입(JWT) 및 서명 알고리즘 정보
  • Payload: 사용자 정보 및 권한 등 클레임(Claim) 데이터
  • Signature: 헤더·페이로드 변조 여부를 검증하는 서명(Signature)

토큰의 메타데이터를 담는 영역으로, JSON을 Base64URL로 인코딩해 구성한다.

필드의미예시
typ토큰 타입 (Type)"JWT"
alg서명 알고리즘"HS256"(대칭키), "RS256"(비대칭키)
kid(Optional) 키 식별자"key-2025-01" (키 교체 시 사용)

Payload

실제 인증 정보(Claim)를 포함하는 영역이다.
Base64URL 인코딩은 암호화가 아니므로 누구나 내용을 디코딩하여 볼 수 있다.
따라서 비밀번호, 주민번호처럼 민감 데이터는 절대 저장해서는 안 된다.

구분설명
등록된 클레임 (Registered)RFC 7519에 정의된 표준 클레임 (iss, exp, sub 등)
공개 클레임 (Public)충돌 방지를 위해 IANA 등록 또는 URI 기반 네임스페이스 사용
비공개 클레임 (Private)서버·클라이언트 간 협의로 정의한 커스텀 데이터 (userId, email 등)

(참고) 주요 등록된 클레임(Registered Claims)

  • iss (Issuer): 발급자
  • sub (Subject): 토큰 제목 (주로 사용자 ID)
  • aud (Audience): 토큰 대상자
  • exp (Expiration): 만료 시간 (필수 권장)
  • iat (Issued At): 발급 시간
  • jti (JWT ID): 토큰 고유 식별자 (일회용 토큰 처리에 사용)

Signature

서명은 토큰의 무결성(Integrity) 을 보장하는 핵심 요소이다. 헤더와 페이로드를 합친 뒤 비밀키를 사용하여 해싱한다. 만약 해커가 페이로드(권한 등)를 조작하더라도, 비밀키를 모르면 올바른 서명을 생성할 수 없어 서버 검증 단계에서 거부된다.

[서명 생성 공식 (수도 코드)]

HMACSHA256(
  base64UrlEncode(header) + "." + base64UrlEncode(payload),
  secret-key
)
구분설명
HS256대칭키(HMAC) 방식. 서버가 하나의 비밀키(Secret Key)로 서명 생성과 검증을 모두 수행. 속도가 빠름.
RS256비대칭키(RSA) 방식. 비밀키(Private Key) 로 서명하고, 공개키(Public Key) 로 검증함. 키 관리가 안전함.

JWT 인코딩과 디코딩

Base64URL 인코딩의 이해

JWT는 URL·HTTP 헤더 등 다양한 환경에서도 문자 손상 없이 안전하게 전송하기 위해
일반 Base64의 변형 버전인 Base64URL 인코딩을 사용한다.

일반 Base64는 +, /, = 같은 문자를 포함하는데,
이는 URL 파라미터나 HTTP 헤더에서 예약어로 인식되거나 깨질 위험이 있다.

Base64URL은 다음 방식으로 이를 해결한다.

  • +- (Minus)
  • /_ (Underscore)
  • = 패딩 제거 (JWT 사양에서 패딩 생략 권장)
  • 결과적으로 URL-safe한 문자열 이 되어 쿠키, 헤더, URL 어디서든 안전하게 사용 가능

Base64 vs Base64URL 비교

구분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 (패딩 제거)

주의: 인코딩은 암호화가 아니다 (Decoding)

Base64URL은 암호화(Encryption) 가 아니라 인코딩(Encoding) 이다.

  • 누구나 키 없이 쉽게 디코딩할 수 있다
    → 온라인 Base64 디코더에 붙여넣으면 바로 Payload가 노출됨
  • 따라서 JWT의 Header·Payload에는 비밀번호, 주민등록번호 등 민감 정보는 절대 포함하면 안된다.
  • Base64URL의 목적은 데이터 전송 시 깨짐을 방지하는 Transport Safety이며, 기밀성을 보장하는 보안 기술이 아니다

JWT 서명과 검증

대칭키(HMAC) vs 비대칭키(RSA) 서명

구분대칭키(HMAC)비대칭키(RSA / ECDSA)
키 구조하나의 Secret Key를 서명·검증에 함께 사용Private Key로 서명, Public Key로 검증
알고리즘 예HS256, HS384, HS512RS256, RS384, RS512, ES256, ES384
보안 수준키 유출 시 누구나 서명 위조 가능Private Key만 보호하면 안전. Public Key는 주기적 배포 가능
속도매우 빠름RSA는 느리나 ECDSA(ES256)는 더 빠르고 키 길이도 짧음
운영 난이도키 하나만 관리 → 단순키 쌍 관리, Public Key 유통 → 복잡
사용 시점내부 시스템, 단일 서비스외부 API, MSA, OAuth2/OIDC, 서드파티 검증 환경
검증 방식Secret Key로 검증Private Key로 서명 → Public Key로 검증

서명 검증 과정

  1. 서버는 JWT를 수신하면 우선 Header·Payload를 Base64URL 디코딩한다.
  2. alg 값을 읽어 서명 알고리즘을 확인한다.
    • 주의: 서버는 토큰 내부 alg 값을 신뢰하지 말고 허용 알고리즘을 서버 설정에서 강제해야 한다. (Algorithm Confusion Attack 방지)
  3. 서버가 보유한 Secret Key(HMAC) 또는 Public Key(RSA/EC)로 base64Url(header) + "." + base64Url(payload) 를 다시 서명하여 비교한다.
  4. 두 서명이 동일하면 변조되지 않은 정상 토큰이며, 다르면 즉시 인증 실패 처리된다.

서명 알고리즘 선택 기준

  • HMAC(HS256)
    • 빠르고 단순
    • 단일 서버 또는 내부 시스템에 적합
    • Secret Key를 여러 서버에 공유해야 하므로 외부 연동에는 취약
  • RSA/ECDSA (RS256, ES256)
    • Private Key만 안전하게 보관하면, Public Key를 자유롭게 배포 가능
    • 서드파티 서비스, MSA, OAuth2/OIDC 등 다자간 환경에서 표준적으로 사용
    • RSA는 상대적으로 느리지만, ECDSA는 더 빠르고 키 길이가 짧아 효율적

알고리즘 선택 요약

기준HMACRSA/EC
보안 수준Secret Key 유출 시 위험Public Key 검증 → 안전
속도가장 빠름RSA는 느림, ECDSA는 빠름
키 관리단순상대적으로 복잡
사용 환경단일 서버, 내부 API외부 연동, OAuth2/OIDC, MSA
장점단순하고 고속신뢰성·확장성·분리 배포 가능

JWT 기반 토큰 인증 과정

RFC 6750 (Bearer Token Usage)

RFC 6750은 OAuth 2.0에서 Bearer Token(Access Token)의 사용 방식을 정의한 표준이다.

  • 클라이언트는 다음 헤더로 토큰을 전달한다:
    Authorization: Bearer <token>
  • 서버는 토큰을 검증해 자원 접근 권한을 확인한다.
  • Bearer Token 특성상 “토큰을 가진 자 = 접근 가능”이므로 반드시 HTTPS 환경에서만 사용해야 한다.

JWT 기반 REST 인증 과정

  1. 클라이언트가 POST /login 요청으로 username/password를 전달한다.
  2. 서버는 인증 성공 시 JWT Access Token(필요 시 Refresh Token 포함)을 발급한다.
  3. 클라이언트는 인증이 필요한 모든 요청에
    Authorization: Bearer <JWT>
    헤더를 추가하여 전달한다.
  4. 서버는 필터(예: Spring Security의 OncePerRequestFilter)를 통해 토큰을 검증한다.
  5. 검증 성공 시 Authentication 객체를 생성해 SecurityContextHolder에 저장하고, 컨트롤러가 요청을 처리한다.

JWT 라이브러리 활용

프로젝트 기본 설정

JWT 라이브러리 선택과 의존성 추가

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 → 간단한 JWT 발급·검증에 적합 (HS256/RS256 모두 지원)
  • Auth0 java-jwt → 문법이 간편하고 학습 난이도가 낮아 API 서버에서 자주 사용
  • Nimbus JOSE + JWT → OAuth2.1 / OIDC / JWE(암호화) / JWKS 자동 로딩 등 표준 기반 토큰 관리가 필요한 경우 가장 강력함
기준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독립 APISpring 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 OAuth 마이그레이션 고려 - nimbus-jose-jwt

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)을 완전 지원하므로:

  • Resource Server / Authorization Server 분리 아키텍처로 확장 가능
  • 외부 인증 서버(Keycloak, Auth0, Cognito)의 JWKS(jwks_uri)와 자동 연동 가능
  • Access Token / ID Token / Client Assertion 등 OAuth2 토큰 표준을 그대로 적용 가능

즉, 자체 로그인 로직으로 시작해도
추후 OAuth2 Authorization Server로 마이그레이션할 때 코드 변경 비용이 최소화된다.

Spring Security가 Nimbus를 표준 엔진으로 사용하는 이유이기도 하다.

application.yml 속성 설정

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초)

JWT 관련 설정 클래스 구현

토큰 서명 키 설정

  • 서명 키(SecretKey)는 HS256 기반 JWT를 발급·검증할 때 반드시 동일해야 한다.
  • 보통 application.yml 에 저장하고, 코드에서는 @Value 로 직접 주입해 사용한다.
  • 최소 글자 크기를 32 bytes 이상으로 설정해야 제한이 없다.
# 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);
    }
}

토큰 만료 시간 설정

  • exp(Expiration) 클레임을 통해 토큰의 만료 시각을 지정 할 수 있다.
  • 보통 초 단위로 yml에 기록하고, 발급 시점에 계산해서 넣는다.
# 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

토큰 발급자(Issuer) 설정

  • JWT의 iss 클레임은 “이 토큰을 누가 발급했는가”를 나타내는 값
  • 인증 서버/리소스 서버가 이 값으로 출처 검증 가능
# 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();
    }
}

JWT 유틸리티 클래스 구현

토큰 생성 메서드 (createToken)

  • JWTClaimsSet을 만들고 JWSObject에 담는다.
  • 서명(MACSigner) 후 문자열로 직렬화(serialize)하여 반환한다.
  • 필수 클레임: sub, iss, iat, exp
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();
}

토큰 검증 메서드 (validateToken)

  • JWSObject.parse 로 JWT 문자열 파싱 이후 MACVerifier 로 서명 검증
  • exp(만료) 검증을 수행하고 필요시 issuer / subject 검증도 추가 가능
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;
    }
}

토큰에서 클레임 추출(파싱) 메서드 (getClaim / getSubject 등)

  • parse()로 페이로드를 Claims 형태로 변환
  • 원하는 클레임(예: sub, roles 등)을 꺼내서 반환
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) {}
}

실습: 기본 JWT 생성 및 검증

JWT 생성 테스트

  • subject와 커스텀 클레임을 전달하면 정상적으로 서명된 JWT 문자열이 생성된다.
  • 생성된 토큰은 JWS 형식(헤더.페이로드.서명) 구조인지 간단하게 검사할 수 있다.
@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());
}

JWT 검증 테스트

  • jwtUtils.validateToken() 을 호출하면 서명/만료 검증을 수행한다.
  • 위변조 되지 않고 만료되지 않은 정상 토큰이면 true 를 반환한다.
@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"));
}

Spring Security와 JWT 통합

Spring Security 설정 복습

SecurityFilterChain의 역할

SecurityFilterChain은 Spring Security 요청 처리의 핵심으로,
HTTP 요청이 들어오면 여러 보안 필터를 순차적으로 적용해 인증·인가 여부를 결정한다.
요청 URL 패턴에 따라 여러 체인 중 하나가 선택되며 체인 내부에는 CsrfFilter, UsernamePasswordAuthenticationFilter, ExceptionTranslationFilter 등 여러 필터가 포함된다.

필터가 인증에 성공하면 SecurityContext에 Authentication이 저장되고
실패 시 예외를 발생시키거나 AuthenticationEntryPoint가 호출된다.

JWT 인증 필터(JwtAuthenticationFilter)

JWT 기반 인증을 적용할 때, 일반적으로 UsernamePasswordAuthenticationFilter 실행 이전에 커스텀 필터를 추가해 요청의 Authorization 헤더에서 Bearer Token을 읽어들이고 검증한다.

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {
    // 요청 1개당 단 한 번 실행되는 JWT 필터
}

SecurityFilterChain 설정 예시

@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();
    }
}

인증 관련 핵심 컴포넌트

컴포넌트역할
BearerTokenAuthenticationTokenAuthorization 헤더에서 Bearer 값 추출하여 AuthenticationManager로 전달
AuthenticationManager적절한 AuthenticationProvider에게 인증 위임
JwtAuthenticationProvider서명·만료 여부 검증 후 인증 객체 생성
JwtDecoderJWS 헤더/서명/클레임 전체 검증을 수행
JwtAuthenticationConverterJWT 클레임을 권한 정보(Authorities)로 변환
JwtAuthenticationToken검증 완료 후 SecurityContext에 저장되는 Authentication 구현체

Stateless 설정

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();
    }
}

JWT 인증 필터 구현

OncePerRequestFilter 확장

  • 요청마다 한 번만 실행되는 커스텀 보안 필터를 구현하기 위해 OncePerRequestFilter를 상속한다.
  • Spring Security 필터 체인에 추가하여 JWT 인증 로직을 중앙에서 처리한다.
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);
    }
}

Authorization 헤더 추출 및 검증

  • 요청 헤더에서 Authorization 값을 읽어 “Bearer ” 형식을 검사하고 유효한 토큰인지 확인한다.
  • 토큰이 없거나 형식이 맞지 않으면 인증을 수행하지 않고 다음 필터로 넘긴다.
String header = req.getHeader("Authorization");
if (header == null || !header.toLowerCase().startsWith("bearer ")) {
	chain.doFilter(req, res);
	return;
}

// "Bearer " 이후
String token = header.substring(7).trim();

JWT 토큰 파싱 및 검증

  • 토큰의 서명, 만료, 클레임을 검증하고 정상적인 경우 Authentication 객체를 생성한다.
  • 생성한 인증 객체를 SecurityContextHolder에 저장하여 이후 필터 및 컨트롤러에서 활용 한다.
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);
        }
    }
}

인증 예외 처리

토큰 누락 시 예외 처리

  • 요청에 Authorization 헤더가 없거나 Bearer 형식이 아닐 경우 인증 절차를 수행하지 않고 즉시 예외를 반환한다.
String header = req.getHeader("Authorization");

if (header == null || !header.startsWith("Bearer ")) {
	throw new BadCredentialsException("토큰이 제공되지 않았습니다.");
}

토큰 만료, 서명 검증 실패 시 예외 처리

  • JWT의 exp(만료 시간) 클레임이 현재 시간보다 이전이면 더 이상 유효하지 않은 토큰으로 간주한다.
  • 비정상적으로 변조된 토큰이거나 서버 측 서명 키로 유효하게 검증되지 않을 경우 발생한다.
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;  
}

EntryPoint(인증 실패 응답 통합 처리)

@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()));
    }
}

보안 설정 통합

CSRF 보호 비활성화 (Stateless 환경)

JWT 기반 API는 세션을 사용하지 않으며, 클라이언트가 직접 Authorization 헤더에 토큰을 포함해 요청을 보내기 때문에 CSRF 토큰 기반 보호가 필요하지 않다.

http.csrf(csrf -> csrf.disable());

세션 비활성화 (SessionCreationPolicy.STATELESS)

JWT 인증은 완전 Stateless 구조이므로 세션을 생성하거나 저장하지 않도록 설정한다.

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

CORS 설정 (Cross-Origin 요청 허용)

프론트엔드(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;
}));

인증 예외 URL 설정 (PermitAll)

로그인 / 회원가입 / 토큰 재발급 API는 인증 없이 접근 가능해야 한다.
그 외 모든 요청은 기본적으로 인증을 요구한다.

http.authorizeHttpRequests(auth -> auth
        .requestMatchers("/api/auth/**").permitAll()
        .anyRequest().authenticated()
);

기본 로그인 방식 제거

Spring Security의 formLogin / httpBasic 은 사용하지 않으므로 비활성화한다.

http.formLogin(form -> form.disable())
    .httpBasic(basic -> basic.disable());

최종 SecurityConfig 구현

@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 구현

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();
    }
}

로그인 처리 및 비밀번호 검증

  1. UserDetailsService로 사용자 조회
  2. PasswordEncoder로 비밀번호 비교
  3. JWT 생성 후 클라이언트에 반환
@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);
}

로그인 API 엔드포인트 구현

로그인 요청 DTO 설계

로그인 시 클라이언트가 전달하는 사용자명 / 비밀번호를 명확히 표현하고,
Bean Validation을 적용하여 형식 오류를 컨트롤러 이전 단계에서 차단한다.

public record LoginRequest(
    @NotBlank(message = "사용자 이름은 필수입니다")
    String username,

    @NotBlank(message = "비밀번호는 필수입니다")
    String password
) { }

AuthenticationManager를 통한 사용자 인증

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 토큰 발급 및 응답

  • 인증 성공 시 사용자명(subject)과 권한(roles)을 포함한 JWT를 생성한다.
  • 클라이언트는 이후 모든 요청에서 Authorization: Bearer <JWT> 를 보내 인증을 유지한다.
  • 서버는 세션을 사용하지 않고 완전한 Stateless 방식으로 인증을 처리한다.

JWT에 사용자 정보 포함

사용자 식별 정보 포함 (sub + roles)

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에 절대 넣으면 안 되는 정보

JWT는 Base64URL 인코딩이며 누구든 디코딩할 수 있으므로
유출 시 위험한 정보는 절대 넣으면 안 된다.

// 절대 금지
.claim("password", user.getPassword())
.claim("phone", user.getPhone())
.claim("email", user.getEmail())  // 경우에 따라 금지
.claim("address", user.getAddress())
.claim("organizationName", user.getOrgName())

안전한 JWT 최소 구성 예시

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();

토큰 기반 인가 구현

JWT에서 권한 정보 추출

역할(Role) 클레임 디코딩

  • JWT 토큰의 roles 또는 authorities 클레임을 디코딩하여 사용자 권한 목록을 추출한다.
  • 해당 클레임은 문자열 배열 또는 리스트 형태로 저장되므로 파싱 과정을 거쳐 역할 정보를 확보한다.
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();

GrantedAuthority 변환

  • 디코딩된 역할 문자열을 Spring Security에서 사용하는 GrantedAuthority 형태로 변환한다.
  • 주로 SimpleGrantedAuthority로 매핑하여 인가(Authorization) 처리에 활용된다.
List<GrantedAuthority> authorities = roles.stream()
	.map(SimpleGrantedAuthority::new)
    .toList();

SecurityContext에 권한 정보 설정

  • 권한이 포함된 Authentication 객체를 생성한 뒤 SecurityContextHolder에 저장하여 인증된 사용자로 등록한다.
  • 이후 컨트롤러·인가 필터에서 roles 기반 접근 제어가 정상적으로 동작한다.
SecurityContext context = SecurityContextHolder.createEmptyContext();
context.setAuthentication(auth);
SecurityContextHolder.setContext(context);

JWT에서 권한 정보 추출 최종 예시 코드

@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);
    }
}

URL 기반 인가 설정

1) 역할 기반 접근 제어 설정

  • 요청 URL에 접근할 수 있는 사용자를 역할(Role) 기준으로 제한하는 방식이다.
  • Spring Security에서는 hasRole, hasAnyRole, hasAuthority 등을 사용해 인증된 사용자의 권한을 검사한다.
.authorizeHttpRequests(auth -> auth
        .requestMatchers("/api/user/**").hasRole("USER")        // ROLE_USER 필요
        .requestMatchers("/api/editor/**").hasAnyRole("EDITOR", "ADMIN")
        .anyRequest().authenticated()
)

2) 경로별 권한 설정

공개 URL, 로그인 URL, 사용자 전용 URL 등을 구분하여 경로마다 인가 규칙을 적용한다.

.authorizeHttpRequests(auth -> auth
        .requestMatchers("/api/public/**").permitAll()
        .requestMatchers("/api/auth/**").permitAll()  // 로그인/회원가입
        .requestMatchers("/api/user/**").hasRole("USER")
        .anyRequest().authenticated()
)

3) 관리자 전용 엔드포인트 보호

  • 시스템 설정, 사용자 관리 등 민감한 기능을 제공하는 엔드포인트는 ADMIN 역할만 접근하도록 제한한다.
  • URL 패턴을 명확히 분리(/api/admin/**)하고 hasRole("ADMIN")로 보호하는 것이 일반적이다.
.authorizeHttpRequests(auth -> auth
        .requestMatchers("/api/admin/**").hasRole("ADMIN") // 관리자 전용
        .anyRequest().authenticated()
)

URL 기반 인가 설정 최종 예시코드

@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();
}

메서드 수준 보안 적용

0) 사전 준비 — @EnableMethodSecurity 선언

@PreAuthorize, @PostAuthorize, @Secured 등을 사용하려면 다음 설정이 반드시 필요하다.

@Configuration
@EnableMethodSecurity(prePostEnabled = true)
public class SecurityConfig { }

1) @PreAuthorize 기반 역할/권한 검사

메서드 호출 전에 Spring Security가 AOP 프록시를 통해
인증·인가를 수행한다.

@Service
public class PostService {

    // USER만 글 작성 가능
    @PreAuthorize("hasRole('USER')")
    public void createPost(PostCreateRequest req) {}

    // ADMIN만 전체 글 삭제 가능
    @PreAuthorize("hasRole('ADMIN')")
    public void deleteAllPosts() {}
}

2) SpEL 기반 동적 인가

메서드 파라미터 또는 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) {}

3) JWT 클레임 기반 접근 제어

조직 ID(orgId), 구독 플랜(plan), tier 등
도메인 정보를 JWT 클레임 → CustomUserPrincipal로 이동한 뒤
SpEL에서 사용할 수 있다.

@PreAuthorize("hasRole('ADMIN') or #orgId == principal.orgId")
public ProjectDetail getProjectDetail(Long projectId, Long orgId) {}

4) 리소스 소유자 기반 접근 제어

@PreAuthorize("hasRole('ADMIN') or #authorId == principal.id")
public void updatePost(Long postId, Long authorId, PostUpdateRequest req) {}

5) 커스텀 인가 표현식 사용

복잡한 규칙은 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);
    }
}

Refresh Token 패턴 구현

Refresh Token의 개념

Access Token과 Refresh Token의 개념

  • Access Token 클라이언트가 보호된 API 자원에 접근하기 위해 사용하는 단기 인증 토큰이다. 짧은 만료 시간을 가지며, 유출 시 피해를 줄이기 위해 자체 정보만으로 인증이 완료되는 구조이다.
  • Refresh Token Access Token이 만료되었을 때 새로운 Access Token을 발급받기 위해 사용하는 장기 토큰이다. 서버 저장소에 보관하거나 로테이션 전략과 함께 사용하여 탈취 위험을 최소화하는 방식이다.

Access Token과 Refresh Token 발급의 상세 프로세스

  1. 사용자 인증: 아이디/비밀번호 검증 후 인증 성공
  2. 토큰 발급: 서버가 Access Token(단기) + Refresh Token(장기) 생성
  3. API 요청: 클라이언트는 Access Token으로 보호된 자원 요청
  4. 토큰 만료 감지: 서버가 Access Token 만료를 감지해 401 반환
  5. 재발급 요청: 클라이언트가 Refresh Token으로 새로운 Access Token 요청
  6. 새 토큰 발급: Refresh Token 검증 후 신규 Access Token 반환

Refresh 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도 교체해 탈취 위험 최소화

Access Token의 짧은 유효 기간과 제한

사용자가 인증된 이후, API 요청 시 자신이 누구인지 증명하기 위해 사용하는 단기 인증 토큰이다.
자체적으로 필요한 정보를 포함하는 Self-contained 구조(JWT)를 많이 사용하며, 만료 시간이 짧아
보안성이 높다

구분설명
수명(Short-lived)일반적으로 5분 ~ 30분, 보안이 민감한 서비스는 더 짧게 설정
자체 포함(Self-contained)사용자 정보·권한·만료시간 등 필요한 정보를 토큰 내부에 포함
서버 저장 불필요자체 정보로 검증 가능해 서버 세션 저장소가 필요 없음
빠른 검증(Fast Verification)서명(Signature) 검증만으로 빠르게 인증 처리 가능
유출 시 피해 최소화만료가 짧아 탈취되더라도 피해가 제한적
클라이언트 보관(Client-side)로컬스토리지, 메모리, 쿠키 등 클라이언트 측에서 직접 관리

두 토큰의 관계

구분Access TokenRefresh 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 생성 및 저장

Refresh Token은 인증 시 서버가 장기 보관용 토큰으로 생성하며, 고유 식별자와 만료 정보가 포함된다.
생성된 Refresh Token은 서버 저장소(DB·Redis)에 사용자 계정과 함께 매핑하여 저장한다.
클라이언트에는 HttpOnly·Secure 쿠키 등 안전한 방식으로 전달하며, 이후 Access Token 재발급 요청 시
검증에 사용된다.

데이터베이스 모델 설계

Refresh Token은 사용자별 인증 상태를 장기적으로 유지하기 위해 토큰 값 자체가 아닌 해시값, 만료 시각, 폐기 여부 등을 서버 DB에 저장하여 안전하게 관리한다.
하나의 사용자가 여러 기기·세션을 가질 수 있으므로 1:N 구조(User → RefreshToken) 로 설계해 발급 이력과 로테이션을 지원한다.

컬럼명타입설명
idBIGINTRefresh Token 레코드 식별자
user_idUUID사용자 식별자 (User 테이블 FK 매핑)
token_hashVARCHAR(255)Refresh Token 원문을 해싱한 값
expires_atTIMESTAMP WITH TIME ZONERefresh Token 만료 시각
revokedBOOLEAN토큰 폐기 여부(강제 로그아웃 대응)
created_atTIMESTAMP WITH TIME ZONE토큰 생성 시각
updated_atTIMESTAMP WITH TIME ZONE토큰 갱신(로테이션) 시 자동 업데이트

토큰 회전(Rotation) 전략

Refresh Token을 사용할 때마다 새로운 Refresh Token을 재발급하고 기존 토큰을 즉시 폐기하여 탈취된 토큰의 반복 사용을 방지하는 보안 전략이다.
서버는 DB에 저장된 기존 Refresh Token을 무효화하고 신규 토큰 해시를 저장해 항상 최신 토큰만 유효하도록 유지한다.

토큰 갱신 API 구현

1) Refresh Token 검증

  • 클라이언트가 전달한 Refresh Token을 해시 비교 또는 DB 조회로 유효성·만료 여부를 검증한다.
  • 유효하지 않거나 만료·폐기된 경우 즉시 401 또는 403을 반환하고 재발급을 허용하지 않는다.
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"));
}

2) 새로운 Access Token 발급

  • 인증 정보(User ID, Roles 등)를 기반으로 새로운 Access Token(JWT)을 생성한다.
  • 기존 Access Token은 재사용하지 않고 매번 새 토큰을 발급한다.
public String createNewAccessToken(UUID userId) {
	return jwtUtils.createAccessToken(userId.toString());
}

3) Refresh Token 갱신 여부 결정

  • 회전(Rotation) 전략 사용 시: Refresh Token도 새로 만들고 기존 토큰은 revoked=true로 처리한다.
  • 미사용 시: 기존 Refresh Token을 그대로 두고 Access Token만 재발급한다.
@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);
}

토큰 갱신 API 구현 최종 코드

@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로 전달
    );
}

토큰 무효화 전략

1) 로그아웃 시 토큰 무효화

  • 사용자가 로그아웃하면, 해당 사용자의 현재 Refresh Token을 revoked=true 또는 삭제하여 더 이상 재발급이 안 되도록 한다.
  • Access Token은 짧은 수명이므로 별도 저장 없이 만료를 기다리고, 필요 시 블랙리스트를 추가로 사용할 수 있다.
@Service
@RequiredArgsConstructor
public class AuthService {

	private final RefreshTokenRepository refreshTokenRepository;

	// 로그아웃 API에서 호출
	@Transactional
	public void logout(UUID userId) {
		refreshTokenRepository.revokeByUserId(userId);
		// 필요 시 클라이언트 쿠키 삭제는 컨트롤러/필터에서 처리
	}
}

2) 비밀번호 변경 시 토큰 무효화

  • 비밀번호가 변경되면 기존 모든 토큰은 더 이상 신뢰할 수 없으므로 해당 사용자의 모든 Refresh Token을 일괄 무효화해야 한다.
  • 이렇게 하면 비밀번호가 유출되어도 변경 이후에는 이전 토큰들이 더 이상 사용될 수 없다.
@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);
	}
}

3) 강제 로그아웃 구현 (관리자/보안 이벤트)

  • 관리자나 보안 시스템에서 특정 사용자를 강제 로그아웃시키기 위해, 해당 사용자의 모든 Refresh Token을 무효화하거나 삭제한다.
  • 필요하면 Audit 로그를 남겨 추후 보안 분석에 활용할 수 있다.
@Service
@RequiredArgsConstructor
public class AdminService {

	private final RefreshTokenRepository refreshTokenRepository;

	@Transactional
	public void forceLogoutUser(UUID userId) {
		int count = refreshTokenRepository.deleteAllByUserId(userId);
		System.out.println("강제 로그아웃 처리된 토큰 수: " + count);
	}
}

4) 스케줄 기반 무효화 (배치 정리)

  • 만료 시간이 지난 토큰과 이미 revoked 된 토큰을 주기적으로 삭제해서 DB 용량과 보안 리스크를 줄인다.
  • 스케줄러는 분·시 단위로 실행되며, 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);
	}
}
profile
Backend engineer

0개의 댓글