[멋사 클라우드 5기] Day 26, 27 - Spring Security & JWT

2026. 3. 9. 01:15·Learning Log

 

 

1. 인증과 인가

개념  의미  비유
인증 (Authentication) "너는 누구인가?"를 확인하는 과정 건물 출입 시 신분증을 보여주는 것
인가 (Authorization) "너는 이것을 할 수 있는가?"를 확인하는 과정 신분증 확인 후 특정 층에 들어갈 권한이 있는지 확인하는 것

예를 들어, 사용자가 로그인하면 인증이 완료된 것이고,
로그인한 사용자가 관리자 페이지에 접근하려 할 때 관리자 권한이 있는지 확인하는 것이 인가이다.


2. 세션 방식의 한계와 JWT의 등장

세션(Session) 방식이란

JWT가 등장하기 전, 대부분의 웹 서버는 세션(Session) 방식으로 사용자를 관리했다.

세션 방식 동작 흐름:

1. 사용자가 로그인 → 서버가 세션 생성 → 서버 메모리에 저장
2. 서버가 클라이언트에게 Session ID(세션 식별자) 발급 (보통 쿠키에 저장)
3. 클라이언트가 요청할 때마다 Session ID를 보냄
4. 서버가 메모리에서 해당 Session ID를 찾아 사용자 확인

세션 방식의 문제점

  • 서버 메모리 부담: 동시 접속자 수가 많을수록 메모리 사용량 급증
  • 수평 확장(Scale-out) 어려움: 서버 A에서 발급한 세션을 서버 B는 모른다
사용자 ---> [서버 A] (세션 있음) ✅
사용자 ---> [서버 B] (세션 없음) ❌ → 다시 로그인 요구

💡 Scale-out(수평 확장)이란? 서버 한 대의 성능을 올리는 대신, 동일한 서버를 여러 대 추가하여 부하를 분산하는 방식이다.
현대의 클라우드 환경에서 매우 일반적이다.

  • MSA 환경에서 복잡성 증가: 마이크로서비스마다 세션을 따로 관리하거나 공유 스토리지(Redis 등)가 필요하다

💡 MSA(Microservices Architecture)란? 하나의 큰 애플리케이션을 여러 개의 작은 독립적인 서비스로 나누는 설계 방식이다.
예를 들어 쇼핑몰을 '상품 서비스', '주문 서비스', '결제 서비스'로 분리하는 것이다.

이러한 한계를 극복하기 위해 서버가 상태를 저장하지 않아도 되는 인증 방식이 필요했고, 그 해답이 바로 JWT이다.


3. JWT란 무엇인가

JWT(JSON Web Token)는 당사자 간에 정보를 JSON 형태로 안전하게 전달하기 위한 개방형 표준(RFC 7519)이다.

💡 RFC(Request for Comments)란? 인터넷 기술의 표준 문서 시리즈다. RFC 7519는 JWT의 공식 명세(spec)다.

 

핵심 특징:

  • 자기완결적(Self-contained): 토큰 자체에 사용자 정보가 담겨 있다.
    서버가 DB나 메모리를 조회하지 않아도 토큰만으로 사용자를 검증할 수 있다.
  • 서명(Signature) 포함: 토큰이 위조되지 않았음을 수학적으로 검증할 수 있다.
  • URL-safe: 토큰이 URL에 포함되어도 깨지지 않는 문자로만 구성된다.

4. JWT의 구조

JWT는 3개의 파트로 구성되며, 각 파트를 점(.)으로 구분한다.

xxxxx.yyyyy.zzzzz
  │      │      │
Header Payload Signature

실제 JWT 예시:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.eyJzdWIiOiJ1c2VyMTIzIiwibmFtZSI6Iuq5jOuhneyekCIsInJvbGUiOiJVU0VSIiwiaWF0IjoxNzAwMDAwMDAwLCJleHAiOjE3MDAwMDM2MDB9
.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

4-1. Header (헤더)

헤더는 토큰의 타입과 서명 알고리즘 정보를 담는다.

{
  "alg": "HS256",
  "typ": "JWT"
}
필드 의미
alg 서명에 사용된 알고리즘 (예: HS256, RS256)
typ 토큰 타입 (항상 "JWT")

이 JSON을 Base64Url 인코딩하면 헤더 파트가 된다.

💡 Base64Url 인코딩이란? 바이너리 데이터나 텍스트를 URL에서 안전하게 사용할 수 있는 64개의 문자(A-Z, a-z, 0-9, -, _)로 변환하는 방식이다. 암호화가 아니라 단순 인코딩이므로, 누구나 디코딩해서 원본 내용을 볼 수 있다.

4-2. Payload (페이로드)

페이로드는 실제로 전달하려는 데이터(클레임) 를 담는다.

💡 클레임(Claim)이란? "주장"이라는 뜻으로, JWT 안에 담긴 키-값 쌍의 정보를 의미한다.
"이 사용자의 ID는 123이다", "이 사용자의 역할은 ADMIN이다" 같은 주장(정보)들이다.

 

클레임은 3가지 종류로 나뉜다:

① 등록된 클레임 (Registered Claims) - 표준으로 정의된 예약어

클레임  전체 이름  의미
iss Issuer 토큰 발급자 (예: "my-app")
sub Subject 토큰 주체 (보통 사용자 ID)
aud Audience 토큰 수신자 (예: "mobile-app")
exp Expiration Time 만료 시각 (Unix 타임스탬프)
nbf Not Before 이 시각 이전에는 토큰 사용 불가
iat Issued At 발급 시각
jti JWT ID 토큰 고유 식별자

💡 Unix 타임스탬프란? 1970년 1월 1일 00:00:00 UTC부터 현재까지 경과한 초(second) 의 수다.
예를 들어 1700000000은 2023년 11월 14일을 나타낸다.

② 공개 클레임 (Public Claims) - 충돌 방지를 위해 URI 형식 권장

{
  "https://myapp.com/roles": ["ADMIN", "USER"]
}

③ 비공개 클레임 (Private Claims) - 당사자 간 합의한 커스텀 정보

{
  "userId": "user123",
  "name": "홍길동",
  "role": "USER"
}

전체 Payload 예시:

{
  "sub": "user123",
  "name": "홍길동",
  "role": "USER",
  "iat": 1700000000,
  "exp": 1700003600
}

⚠️ 중요: Payload도 Base64Url로 인코딩될 뿐, 암호화되지 않는다.
누구나 디코딩해서 내용을 볼 수 있으므로, 비밀번호나 카드번호 같은 민감한 정보를 넣으면 절대 안 된다.

4-3. Signature (서명)

서명은 토큰이 위조되지 않았음을 검증하는 핵심 파트다.

서명 생성 공식 (HS256 기준):

Signature = HMAC-SHA256(
  Base64Url(Header) + "." + Base64Url(Payload),
  서버의_비밀키(Secret Key)
)

💡 HMAC(Hash-based Message Authentication Code)이란?
비밀키와 해시 함수를 조합하여 메시지의 무결성(변조 여부)을 검증하는 알고리즘이다.
비밀키를 모르면 같은 서명을 만들 수 없다.

💡 SHA-256이란? SHA(Secure Hash Algorithm)의 256비트 버전이다.
어떤 데이터도 항상 256비트(32바이트)의 고정된 해시값으로 변환한다.
원본 데이터가 1비트라도 바뀌면 해시값이 완전히 달라진다.

서명의 역할:

공격자가 Payload를 수정하려 시도:
  원본:  {"role": "USER"}   → Signature: abc123
  조작:  {"role": "ADMIN"}  → 새 Signature: xyz789  ← 기존 서명과 다름!

서버가 수신 시 서명을 재계산 → 토큰의 서명과 불일치 → 토큰 거부 ✅

5. JWT의 동작 원리

전체 흐름

클라이언트                          서버
    │                                │
    │  1. POST /login                │
    │  { id: "user", pw: "1234" }    │
    │ ─────────────────────────────> │
    │                                │ 2. DB에서 사용자 검증
    │                                │ 3. JWT 생성
    │  4. 200 OK                     │
    │  { token: "eyJ..." }           │
    │ <───────────────────────────── │
    │                                │
    │  5. GET /api/profile           │
    │  Authorization: Bearer eyJ...  │
    │ ─────────────────────────────> │
    │                                │ 6. JWT 서명 검증
    │                                │ 7. Payload에서 사용자 정보 추출
    │  8. 200 OK { ... }             │
    │ <───────────────────────────── │

💡 Bearer 토큰이란? HTTP 인증 스킴(scheme) 중 하나로, "이 토큰을 소지한 자(Bearer)에게 접근을 허용한다"는 의미다.
보통 Authorization: Bearer <token> 헤더 형식으로 전달한다.

서버의 JWT 검증 과정

수신한 JWT: header.payload.signature

1. header와 payload를 추출
2. 서버의 비밀키로 signature를 재계산
3. 재계산한 signature == 수신한 signature ? → 위조 없음 ✅
4. exp(만료 시각) 확인 → 현재 시각보다 이전이면 만료 ❌
5. 검증 성공 → Payload에서 사용자 정보 사용

6. JWT의 종류: JWS vs JWE

JWT는 사용 목적에 따라 두 가지 형태로 구분된다.

구분  이름 특징
JWS JSON Web Signature 서명만 포함 → 내용은 누구나 읽을 수 있음
JWE JSON Web Encryption 내용까지 암호화 → 키 없이는 내용 불가독

💡 일반적으로 "JWT"라고 부르는 것은 대부분 JWS다.


7. Spring Security란 무엇인가

왜 Spring Security를 사용하는가

앞에서 JWT라는 인증 기술을 살펴보았다.
그런데 실제 Spring 기반 애플리케이션에서 인증/인가를 구현하려면, JWT를 검증하는 로직만 있으면 되는 것이 아니다.
보안은 생각보다 고려할 것이 매우 많다.

 

직접 구현할 경우 신경 써야 하는 것들:

  • 비밀번호 암호화 및 안전한 저장
  • 세션 관리 및 세션 고정 공격(Session Fixation) 방지
  • CSRF(Cross-Site Request Forgery, 사이트 간 요청 위조) 방지
  • CORS(Cross-Origin Resource Sharing, 교차 출처 리소스 공유) 설정
  • Remember-Me 기능
  • OAuth2 / JWT 기반 인증
  • 권한별 접근 제어
  • 보안 관련 HTTP 헤더 설정

이 모든 것을 직접 구현하면 코드가 복잡해지고, 보안 취약점이 발생할 가능성이 높다. Spring Security는 Spring 기반 애플리케이션의 인증과 인가를 담당하는 보안 프레임워크로, 이러한 보안 기능들을 검증된 방식으로 제공한다. 덕분에 개발자는 비즈니스 로직에 집중할 수 있다.


8. 핵심 아키텍처: 두 컨테이너와 필터 체인

Spring Security의 동작 원리를 이해하려면 전체 구조를 먼저 파악해야 한다.
그런데 그 전에, Spring Security가 왜 이런 구조를 가지게 되었는지를 이해하기 위해 "컨테이너"라는 개념부터 짚고 넘어가자.

8.1 컨테이너(Container)란

컨테이너는 "객체의 생명주기를 대신 관리해주는 환경"이라고 이해하면 된다.

우리가 직접 new로 객체를 만들고, 필요 없으면 버리는 대신,
컨테이너가 "이 객체를 언제 만들고, 언제 초기화하고, 언제 제거할지"를 알아서 관리해주는 것이다.

Java 웹 애플리케이션에는 이런 컨테이너가 두 개 공존한다.

8.2 서블릿 컨테이너 (Servlet Container)

Spring Boot로 프로젝트를 실행하면 내장 Tomcat이 자동으로 뜨는데, 이 Tomcat이 바로 서블릿 컨테이너이다.

서블릿 컨테이너가 관리하는 것들은 다음과 같다.

  • Servlet — HTTP 요청을 받아서 처리하는 객체. Spring MVC의 DispatcherServlet도 결국 하나의 Servlet이다.
  • Filter — Servlet에 요청이 도달하기 전에 가로채는 객체.
  • Listener — 특정 이벤트(서버 시작, 세션 생성 등)를 감지하는 객체.

핵심은, 서블릿 컨테이너는 Java EE(Jakarta EE) 표준 스펙에 따라 동작한다는 것이다.
Tomcat은 Spring이 뭔지 모른다. 그냥 "Servlet 스펙에 맞는 객체"만 관리한다.

┌─── 서블릿 컨테이너 (Tomcat) ───────────────────┐
│                                            │
│  Filter A → Filter B → DispatcherServlet   │
│                                            │
│  "나는 Servlet, Filter, Listener만 안다.      │
│   Spring Bean? 그게 뭔데?"                   │
│                                            │
└────────────────────────────────────────────┘

8.3 스프링 컨테이너 (Spring Container)

ApplicationContext라고도 부른다.
@Component, @Service, @Repository, @Bean 등으로 등록하는 모든 객체(Bean)를 생성하고 관리하는 주체이다.

┌─── 스프링 컨테이너 (ApplicationContext) ───────┐
│                                            │
│  @Service UserService                      │
│  @Repository UserRepository                │
│  @Component JwtTokenProvider               │
│  @Bean SecurityFilterChain                 │
│  @Bean PasswordEncoder                     │
│                                            │
│  "나는 Spring Bean을 관리한다.                 │
│   Servlet Filter? 그건 내 관할이 아닌데?"       │
│                                            │
└────────────────────────────────────────────┘

Spring Security의 모든 보안 로직(SecurityFilterChain, AuthenticationManager, 각종 Provider 등)은 Spring Bean이다.
즉, 스프링 컨테이너 안에 산다.

8.4 두 컨테이너의 충돌: 문제 상황

이제 문제가 보인다.

HTTP 요청이 들어오면

   서블릿 컨테이너가 먼저 처리한다
   (Filter들이 순서대로 실행됨)
         │
         ▼
   그 다음에 DispatcherServlet에 도달한다
   (여기서부터 Spring 세계)
         │
         ▼
   Controller → Service → Repository

Filter는 서블릿 컨테이너가 관리하는 것이고,
Spring Security의 보안 로직은 스프링 컨테이너가 관리하는 Bean이다.

그런데 Spring Security는 Filter 단계에서 보안 처리를 하고 싶다.
Controller에 도달하기 전에 인증/인가를 해야 하니까.
하지만 서블릿 컨테이너는 Spring Bean을 모른다.

8.5 해결: DelegatingFilterProxy

이 문제를 해결하기 위해 DelegatingFilterProxy가 존재한다.
이 녀석은 서블릿 필터이면서 Spring Bean에게 실제 작업을 위임(delegate)하는 다리 역할을 한다.

┌─── 서블릿 컨테이너 ────────────────────────────────────────────┐
│                                                            │
│  Filter A                                                  │
│     │                                                      │
│  DelegatingFilterProxy  ──── "나는 서블릿 필터인데,             │
│     │                         실제 일은 Spring Bean에게       │
│     │                         시킬게"                        │
│     │                                                      │
│     │    ┌─── 스프링 컨테이너 ─────────────────────────┐       │
│     └───▶│  FilterChainProxy (Spring Bean)         │       │
│          │    ├── SecurityFilter 1                 │       │
│          │    ├── SecurityFilter 2                 │       │
│          │    ├── SecurityFilter 3                 │       │
│          │    └── ...                              │       │
│          └─────────────────────────────────────────┘       │
│     │                                                      │
│  Filter C                                                  │
│     │                                                      │
│  DispatcherServlet                                         │
└────────────────────────────────────────────────────────────┘

DelegatingFilterProxy는 서블릿 컨테이너 입장에서는 평범한 Filter이다. 그래서 서블릿 컨테이너가 관리할 수 있다.

하지만 이 녀석은 자기가 직접 보안 처리를 하지 않는다.
대신 스프링 컨테이너에서 FilterChainProxy라는 Bean을 찾아서, 요청 처리를 위임(delegate)한다.
이름 그대로 "위임하는 필터 프록시"인 것이다.

이렇게 하면 서블릿 컨테이너의 Filter 자리에 끼어들면서도, 실제 보안 로직은 Spring이 관리하는 Bean들이 수행할 수 있게 된다.

8.6 FilterChainProxy와 SecurityFilterChain

FilterChainProxy는 Spring Security의 핵심 엔진이다.
이 안에 하나 이상의 SecurityFilterChain이 등록되어 있다.

SecurityFilterChain은 "이 URL 패턴에 대해서는 이 필터들을 적용하라"는 규칙의 묶음이다.

FilterChainProxy
├── SecurityFilterChain ("/api/**")
│   ├── CorsFilter
│   ├── CsrfFilter (비활성)
│   ├── JwtAuthenticationFilter
│   └── AuthorizationFilter
│
└── SecurityFilterChain ("/**")
    ├── CorsFilter
    ├── CsrfFilter
    ├── UsernamePasswordAuthenticationFilter
    ├── SessionManagementFilter
    └── AuthorizationFilter

위 예시를 보면, /api/** 경로와 그 외 경로에 서로 다른 보안 정책을 적용할 수 있다.
API 요청에는 JWT 기반 인증을, 웹 페이지 요청에는 세션 기반 인증을 적용하는 식이다.

8.7 두 컨테이너 정리

  서블릿 컨테이너  스프링 컨테이너
대표 예시 Tomcat, Jetty ApplicationContext
관리 대상 Servlet, Filter, Listener @Component, @Bean 등 모든 Spring Bean
기반 스펙 Java EE / Jakarta EE Spring Framework
요청 처리 순서 먼저 (Filter 단계) 나중에 (Controller 단계)

두 컨테이너는 별개의 세계이고, DelegatingFilterProxy가 두 세계를 연결하는 다리 역할을 한다.
Spring Security의 모든 필터가 Controller보다 먼저 실행될 수 있는 이유가 바로 이 구조 덕분이다.

8.8 전체 아키텍처 다이어그램

[클라이언트 요청]
       │
       ▼
┌─────────────────────────┐
│   Servlet Filter Chain  │  ← 서블릿 컨테이너(Tomcat 등)가 관리
│   ┌───────────────────┐ │
│   │ DelegatingFilter  │ │  ← Spring Security의 진입점
│   │ Proxy             │ │
│   └───────┬───────────┘ │
│           │             │
│   ┌───────▼───────────┐ │
│   │ FilterChainProxy  │ │  ← 실제 Security 필터들을 관리
│   │ ┌───────────────┐ │ │
│   │ │ Security      │ │ │
│   │ │ Filter Chain  │ │ │  ← 여러 보안 필터가 순서대로 실행
│   │ │ (15개 이상)     │ │ │
│   │ └───────────────┘ │ │
│   └───────────────────┘ │
└─────────────────────────┘
       │
       ▼
  [DispatcherServlet → Controller]

9. Security Filter Chain의 주요 필터들

SecurityFilterChain 안에는 약 15개 이상의 필터가 정해진 순서대로 실행된다.

주요 필터 실행 순서

요청(Request) 들어옴
    │
    ▼
[SecurityContextPersistenceFilter]
    │  → 이전 요청에서 저장된 SecurityContext를 복원한다
    ▼
[CorsFilter]
    │  → CORS 정책을 확인한다
    ▼
[CsrfFilter]
    │  → CSRF 토큰을 검증한다
    ▼
[LogoutFilter]
    │  → 로그아웃 요청이면 로그아웃 처리를 한다
    ▼
[UsernamePasswordAuthenticationFilter]
    │  → 로그인 요청이면 아이디/비밀번호로 인증을 시도한다
    ▼
[DefaultLoginPageGeneratingFilter]
    │  → 커스텀 로그인 페이지가 없으면 기본 로그인 페이지를 생성한다
    ▼
[BasicAuthenticationFilter]
    │  → HTTP Basic 인증 헤더가 있으면 처리한다
    ▼
[SessionManagementFilter]
    │  → 세션 관련 처리를 한다 (세션 고정 공격 방지 등)
    ▼
[ExceptionTranslationFilter]
    │  → 보안 예외를 적절한 HTTP 응답으로 변환한다
    ▼
[AuthorizationFilter]
    │  → 최종적으로 해당 리소스에 접근할 권한이 있는지 확인한다
    ▼
  Controller에 도달

각 필터는 자기가 처리할 요청이 아니면 아무것도 하지 않고 다음 필터로 넘긴다.
예를 들어, UsernamePasswordAuthenticationFilter는 POST /login 요청일 때만 동작하고, 그 외의 요청은 그냥 통과시킨다.


10. 인증(Authentication) 동작 원리

인증은 Spring Security에서 가장 중요한 부분이다.
사용자가 로그인할 때 내부에서 어떤 일이 벌어지는지 단계별로 살펴보자.

10.1 인증 처리 흐름

[사용자: POST /login (username, password)]
           │
           ▼
┌──────────────────────────────────────┐
│ UsernamePasswordAuthenticationFilter │
│   → UsernamePasswordAuthentication   │
│     Token 생성 (미인증 상태)             │
└──────────────┬───────────────────────┘
               │
               ▼
┌──────────────────────────┐
│    AuthenticationManager │  ← 인증 작업을 총괄하는 관리자
│    (ProviderManager)     │
└──────────────┬───────────┘
               │ 적절한 Provider에게 위임
               ▼
┌──────────────────────────┐
│  AuthenticationProvider  │  ← 실제 인증 로직 수행
│  (DaoAuthentication      │
│   Provider)              │
│                          │
│  1. UserDetailsService   │  ← DB에서 사용자 정보 조회
│     .loadUserByUsername()│
│  2. PasswordEncoder      │  ← 비밀번호 일치 여부 확인
│     .matches()           │
└──────────────┬───────────┘
               │
          인증 성공 시
               │
               ▼
┌───────────────────────────┐
│ SecurityContextHolder     │
│  └── SecurityContext      │
│       └── Authentication  │  ← 인증된 사용자 정보 저장
│            ├── Principal  │     (이후 어디서든 꺼내 쓸 수 있음)
│            ├── Credentials│
│            └── Authorities│
└───────────────────────────┘

10.2 각 구성 요소 상세 설명

Authentication 객체

Authentication은 인증 정보를 담는 객체이다. 크게 세 가지 정보를 가지고 있다.

속성  설명
Principal 인증된 사용자 자체를 나타낸다. 보통 UserDetails 객체가 들어간다
Credentials 비밀번호 등 인증에 사용된 자격 증명이다. 인증 후에는 보안을 위해 비워진다
Authorities 사용자에게 부여된 권한 목록이다. ROLE_USER, ROLE_ADMIN 등이 들어간다

이 객체는 인증 전에는 "사용자가 인증을 요청한 정보"를, 인증 후에는 "인증이 완료된 사용자의 정보"를 담는다.

AuthenticationManager

AuthenticationManager는 인터페이스이며, 기본 구현체는 ProviderManager이다. 이 녀석의 역할은 단순하다.
자신이 가지고 있는 여러 AuthenticationProvider 중 해당 인증 요청을 처리할 수 있는 Provider를 찾아서 인증 작업을 위임하는 것이다.

왜 직접 인증하지 않고 Provider에게 위임할까? 인증 방식이 여러 가지일 수 있기 때문이다. 아이디/비밀번호 인증, OAuth2 인증, JWT 인증 등 각기 다른 방식을 각각의 Provider가 처리하도록 분리한 것이다.

AuthenticationProvider

AuthenticationProvider는 실제로 인증 로직을 수행하는 컴포넌트이다.
가장 일반적으로 사용되는 구현체는 DaoAuthenticationProvider이다.

DaoAuthenticationProvider는 내부적으로 두 가지를 사용한다.

1) UserDetailsService — 사용자 정보를 어디서 가져올지를 정의한다.

public interface UserDetailsService {
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

이 인터페이스를 구현하여 DB, LDAP, 메모리 등 원하는 곳에서 사용자 정보를 가져올 수 있다.
가장 흔한 패턴은 DB에서 사용자를 조회하는 것이다.

2) PasswordEncoder — 비밀번호를 안전하게 비교한다.

사용자가 입력한 비밀번호(평문)와 DB에 저장된 비밀번호(암호화된 형태)를 비교한다. Spring Security는 기본적으로 BCryptPasswordEncoder를 권장한다.

SecurityContextHolder

인증에 성공하면 Authentication 객체가 SecurityContext에 저장되고,
이 SecurityContext는 SecurityContextHolder가 관리한다.

SecurityContextHolder는 기본적으로 ThreadLocal 전략을 사용한다. ThreadLocal이란 각 쓰레드(Thread)마다 독립적인 저장 공간을 제공하는 기술이다. 웹 요청은 각각 별도의 쓰레드에서 처리되므로, 요청 A의 인증 정보와 요청 B의 인증 정보가 섞이지 않는다.

// 현재 인증된 사용자 정보를 꺼내는 방법
SecurityContext context = SecurityContextHolder.getContext();
Authentication authentication = context.getAuthentication();
String username = authentication.getName();
Collection<? extends GrantedAuthority> authorities = authentication.getAuthorities();

이 코드는 Controller, Service 등 어디에서든 현재 요청의 인증된 사용자 정보에 접근할 수 있게 해준다.


11. 인가(Authorization) 동작 원리

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

11.1 권한의 종류

Spring Security에서 권한은 GrantedAuthority 인터페이스로 표현된다. 보통 문자열 형태이며, 두 가지 관례가 있다.

유형  형식  예시  용도
Role ROLE_ 접두사 필수 ROLE_USER, ROLE_ADMIN 역할 기반의 넓은 권한 구분
Authority 접두사 없음 READ_ARTICLE, DELETE_USER 세밀한 기능 단위 권한 구분

ROLE_ 접두사는 Spring Security가 내부적으로 Role을 구분하기 위한 관례이다. hasRole("ADMIN")이라고 작성하면, 내부적으로 ROLE_ADMIN을 가지고 있는지 확인한다.

11.2 인가 처리 방식

Spring Security는 두 가지 수준에서 인가를 수행할 수 있다.

1) URL 기반 인가 (HTTP Request Level)

SecurityFilterChain 설정에서 URL 패턴별로 접근 권한을 지정한다. AuthorizationFilter가 이를 처리한다.

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests(auth -> auth
        // /api/admin/** 경로는 ADMIN 역할만 접근 가능
        .requestMatchers("/api/admin/**").hasRole("ADMIN")
        // /api/user/** 경로는 USER 또는 ADMIN 역할이 접근 가능
        .requestMatchers("/api/user/**").hasAnyRole("USER", "ADMIN")
        // /api/public/** 경로는 누구나 접근 가능 (인증 불필요)
        .requestMatchers("/api/public/**").permitAll()
        // 그 외 모든 요청은 인증 필요
        .anyRequest().authenticated()
    );
    return http.build();
}

2) 메서드 기반 인가 (Method Level)

컨트롤러나 서비스의 메서드에 어노테이션을 붙여서 권한을 체크한다.

@PreAuthorize("hasRole('ADMIN')")
public void deleteUser(Long userId) {
    // ADMIN 역할이 있는 사용자만 이 메서드를 호출할 수 있다
}

@PreAuthorize("hasRole('USER') and #userId == authentication.principal.id")
public UserProfile getProfile(Long userId) {
    // USER 역할이면서, 자기 자신의 프로필만 조회할 수 있다
}

@PreAuthorize는 메서드 실행 전에 권한을 확인하고, @PostAuthorize는 메서드 실행 후에 결과를 보고 권한을 확인한다. 메서드 레벨 보안을 사용하려면 @EnableMethodSecurity 어노테이션을 설정 클래스에 추가해야 한다.


12. Spring Security + JWT 통합 구현

이제 이론을 바탕으로, Spring Security 위에서 JWT 기반 인증을 구현하는 방법을 살펴보자. 최근 프로젝트에서는 서버 렌더링 대신 프론트엔드(React, Vue 등) + 백엔드 API 구조가 일반적이고, 이 경우 세션 대신 JWT를 사용하는 것이 보편적이다.

12.1 세션 vs JWT 비교

항목  세션 기반  JWT 기반
상태 저장 위치 서버 메모리 클라이언트 (토큰 자체)
Stateless 여부 Stateful Stateless
확장성 서버 간 세션 공유 필요 서버 간 공유 불필요
보안 취약점 세션 하이재킹 토큰 탈취
주 사용 환경 서버 렌더링 웹 SPA + REST API, 모바일

12.2 JWT 인증 흐름 (Spring Security 관점)

[로그인 요청]
POST /api/auth/login { username, password }
         │
         ▼
   서버: 인증 성공 → JWT 토큰 생성 → 클라이언트에 반환
         │
         ▼
[이후 모든 API 요청]
GET /api/user/profile
Header: Authorization: Bearer eyJhbGciOiJI...
         │
         ▼
   서버: JWT 검증 → SecurityContext에 인증 정보 설정 → 요청 처리

 

12.3 JWT 인증 필터

이 필터는 매 요청마다 실행되며, 요청 헤더에 JWT가 있으면 검증하고 SecurityContext에 인증 정보를 설정한다.
Spring Security의 필터 체인에 끼워넣는 커스텀 필터이다.

💡 OncePerRequestFilter란? 하나의 HTTP 요청에 대해 딱 한 번만 실행되는 것을 보장하는 필터 기반 클래스이다.
일반 Filter는 포워딩(forward) 등으로 같은 요청 내에서 여러 번 실행될 수 있지만, OncePerRequestFilter는 이를 방지한다.

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;
    private final UserDetailsService userDetailsService;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                     HttpServletResponse response,
                                     FilterChain filterChain)
            throws ServletException, IOException {

        // 1. 요청 헤더에서 토큰 추출
        String token = resolveToken(request);

        // 2. 토큰이 존재하고 유효하면
        if (token != null && jwtTokenProvider.validateToken(token)) {

            // 3. 토큰에서 사용자 이름 추출
            String username = jwtTokenProvider.getUsername(token);

            // 4. DB에서 사용자 정보 조회
            UserDetails userDetails =
                userDetailsService.loadUserByUsername(username);

            // 5. 인증 객체 생성 (이미 토큰으로 인증되었으므로 credentials는 null)
            UsernamePasswordAuthenticationToken authentication =
                new UsernamePasswordAuthenticationToken(
                    userDetails,
                    null,
                    userDetails.getAuthorities()
                );

            // 6. SecurityContext에 인증 정보 저장
            SecurityContextHolder.getContext()
                .setAuthentication(authentication);
        }

        // 7. 다음 필터로 요청 전달
        filterChain.doFilter(request, response);
    }

    /**
     * Authorization 헤더에서 "Bearer " 접두사를 제거하고 토큰만 추출한다.
     */
    private String resolveToken(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (bearerToken != null && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

12.4 JWT용 SecurityConfig

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class JwtSecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

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

        http
            // REST API이므로 CSRF 비활성화
            .csrf(csrf -> csrf.disable())

            // 세션을 사용하지 않음 (JWT는 stateless)
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            )

            // URL별 접근 권한
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/**").permitAll()
                .anyRequest().authenticated()
            )

            // JWT 필터를 UsernamePasswordAuthenticationFilter 앞에 추가
            // 이렇게 하면 JWT 필터가 먼저 실행되어 토큰 기반 인증을 처리한다
            .addFilterBefore(jwtAuthenticationFilter,
                UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

핵심은 .addFilterBefore() 메서드이다.
이 메서드로 우리가 만든 JWT 필터를 Spring Security 필터 체인의 원하는 위치에 삽입할 수 있다.
여기서는 UsernamePasswordAuthenticationFilter 앞에 넣었으므로, 폼 로그인 필터보다 먼저 JWT 인증을 시도하게 된다.


13. Access Token & Refresh Token 전략

실무에서는 토큰을 두 종류로 나눠 사용한다.

토큰 종류  유효 기간  역할
Access Token 짧게 (15분 ~ 1시간) API 호출 시 인증에 사용
Refresh Token 길게 (7일 ~ 30일) 만료된 Access Token 재발급에 사용

왜 두 종류를 쓰는가?

  • Access Token만 사용하면:
    • 유효 기간이 짧으면 → 사용자가 자주 로그인해야 함 😥
    • 유효 기간이 길면 → 토큰 탈취 시 오랫동안 악용 가능 😱
  • Refresh Token을 도입하면:
    • Access Token은 짧은 수명 → 탈취되어도 피해 최소화
    • Refresh Token으로 새 Access Token 자동 발급 → 사용자 편의성 유지
Access Token 만료 시 재발급 흐름:

클라이언트                        서버
    │  GET /api/data              │
    │  Authorization: Bearer [만료된 AT] 
    │ ────────────────────────>   │
    │  401 Unauthorized           │
    │ <────────────────────────   │
    │                             │
    │  POST /api/token/refresh    │
    │  { refreshToken: "eyJ..." } │
    │ ────────────────────────>   │
    │                             │ Refresh Token 검증
    │  200 OK                     │
    │  { accessToken: "eyJ..." }  │
    │ <────────────────────────   │
    │                             │
    │  GET /api/data              │
    │  Authorization: Bearer [새 AT] 
    │ ────────────────────────>   │

14. CORS와 CSRF 이해하기

14.1 CORS (Cross-Origin Resource Sharing)

CORS는 다른 출처(Origin)의 리소스에 접근하는 것을 제어하는 브라우저의 보안 정책이다.

"출처(Origin)"란 프로토콜 + 도메인 + 포트의 조합이다.

https://example.com:443    ← 이것이 하나의 출처
http://example.com:80      ← 프로토콜이 다르므로 다른 출처
https://api.example.com    ← 도메인이 다르므로 다른 출처
https://example.com:8080   ← 포트가 다르므로 다른 출처

프론트엔드가 http://localhost:3000에서 실행되고, 백엔드 API가 http://localhost:8080에서 실행되면,
포트가 다르므로 다른 출처이다. 브라우저는 기본적으로 다른 출처로의 요청을 차단한다.

Spring Security에서 CORS 설정:

@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http
        .cors(cors -> cors.configurationSource(corsConfigurationSource()))
        // ... 나머지 설정
    ;
    return http.build();
}

@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();

    config.setAllowedOrigins(List.of("http://localhost:3000"));
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
    config.setAllowedHeaders(List.of("*"));
    config.setAllowCredentials(true);

    UrlBasedCorsConfigurationSource source =
        new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/**", config);

    return source;
}

14.2 CSRF (Cross-Site Request Forgery)

CSRF는 사용자가 의도하지 않은 요청을 악의적인 사이트가 대신 보내는 공격이다.

예시 시나리오:

  1. 사용자가 은행 사이트에 로그인한 상태이다 (세션 쿠키가 브라우저에 존재).
  2. 사용자가 악의적인 사이트를 방문한다.
  3. 악의적인 사이트에 숨겨진 코드가 은행 사이트로 송금 요청을 보낸다.
  4. 브라우저가 자동으로 세션 쿠키를 포함시키므로, 은행 서버는 정상적인 요청으로 인식한다.

Spring Security는 기본적으로 CSRF 방어가 활성화되어 있다. CSRF 토큰이라는 임의의 값을 발행하고, 모든 상태 변경 요청(POST, PUT, DELETE 등)에 이 토큰을 포함하도록 요구한다. 악의적인 사이트는 이 토큰을 알 수 없으므로 공격이 차단된다.


언제 CSRF를 비활성화해도 되는가?

REST API가 세션 쿠키 대신 JWT를 사용하고, 클라이언트가 매 요청마다 Authorization 헤더에 토큰을 직접 넣는 경우에는 CSRF 공격이 성립하지 않는다. 브라우저가 자동으로 토큰을 보내지 않기 때문이다. 따라서 이 경우 .csrf(csrf -> csrf.disable())로 비활성화해도 안전하다.


15. 예외 처리

Spring Security에서 발생하는 보안 예외는 크게 두 가지이다.

예외 발생 상황  HTTP 상태 코드
AuthenticationException 인증 실패 (로그인 실패, 토큰 만료 등) 401 Unauthorized
AccessDeniedException 인가 실패 (권한 부족) 403 Forbidden

ExceptionTranslationFilter가 이 예외들을 잡아서 적절한 응답으로 변환한다.


📎 핵심 요약

JWT = Header.Payload.Signature
       (Base64Url 인코딩)

✅ 서명으로 위조 방지
✅ 서버가 상태를 저장하지 않음 (Stateless)
✅ 확장성 뛰어남 (Scale-out, MSA)

⚠️ Payload는 암호화되지 않으므로 민감정보 금지
⚠️ 만료 전 강제 무효화 어려움 → Refresh Token + 블랙리스트로 보완
⚠️ 비밀키는 절대 외부 노출 금지

Spring Security에서의 핵심 흐름:

요청 → DelegatingFilterProxy → FilterChainProxy
     → SecurityFilterChain (JWT 필터 포함)
     → JWT 검증 → SecurityContext에 인증 정보 저장
     → Controller에 도달
  1. 요청이 들어오면 필터 체인을 순서대로 통과한다.
  2. JWT 인증 필터에서 토큰을 검증하고 사용자가 누구인지 확인한다.
  3. 인가 필터에서 이 사용자가 이 리소스에 접근할 수 있는지 확인한다.
  4. 모든 검증을 통과하면 Controller에 요청이 도달한다.

'Learning Log' 카테고리의 다른 글

[멋사 클라우드 5기] Day 29 & 30 - 게시판 구현을 통해 확인한 Spring Data JPA  (0) 2026.03.12
[멋사 클라우드 5기] Day 28 - JWT 그런데 Redis를 곁들인  (0) 2026.03.10
[멋사 클라우드 5기] Day 25 - Spring Data JPA (3)  (0) 2026.03.05
[멋사 클라우드 5기] Day 24 - Spring Data JPA (2)  (0) 2026.03.04
[멋사 클라우드 5기] Day 23 - Spring Data JPA (1)  (1) 2026.03.03
'Learning Log' 카테고리의 다른 글
  • [멋사 클라우드 5기] Day 29 & 30 - 게시판 구현을 통해 확인한 Spring Data JPA
  • [멋사 클라우드 5기] Day 28 - JWT 그런데 Redis를 곁들인
  • [멋사 클라우드 5기] Day 25 - Spring Data JPA (3)
  • [멋사 클라우드 5기] Day 24 - Spring Data JPA (2)
allluck777
allluck777
allluck777
    • 분류 전체보기 (41)
      • AWS (0)
      • Network (0)
      • Linux (0)
      • Docker (0)
      • Project (4)
        • CloudNote (4)
      • Learning Log (34)
      • Lecture (3)
        • 스프링 입문 - 코드로 배우는 스프링 부트, 웹 .. (3)
  • 전체
    오늘
    어제
  • hELLO· Designed By정상우.v4.10.6
allluck777
[멋사 클라우드 5기] Day 26, 27 - Spring Security & JWT
상단으로

티스토리툴바