GitHub - HeoJunHyoung/bytemall
Contribute to HeoJunHyoung/bytemall development by creating an account on GitHub.
github.com
1. 들어가며
Spring Security는 강력한 보안 기능을 제공하지만, 그만큼 진입 장벽이 높다. 단순히 인터넷에 있는 설정 코드를 복사해서 붙여넣는 것만으로는 "왜 로그인이 되는지", "왜 특정 요청이 차단되는지", "에러가 나면 어디를 봐야 하는지" 이해하기 어렵다. 특히 Spring Boot 3와 Security 6로 넘어오면서 많은 설정 방식이 변화했기에, 최신 흐름을 명확히 파악하는 것이 중요하다.
본 시리즈에서는 JWT(JSON Web Token)와 Redis를 활용하여 확장성 있는 로컬 로그인 시스템을 구축한다. 본격적인 코드를 작성하기에 앞서, 이번 포스팅에서는 우리가 구현할 보안 시스템의 핵심 작동 원리와 데이터의 흐름을 깊이 있게 다룬다.
2. Spring Security의 근간: 서블릿 필터와 위임 모델

Spring Security를 이해하기 위해 가장 먼저 알아야 할 것은 '위치'다. 웹 애플리케이션으로 들어오는 모든 요청은 가장 먼저 서블릿 컨테이너(Tomcat 등)를 거치게 되는데, Spring Security는 이 요청이 스프링의 핵심인 DispatcherServlet(컨트롤러로 요청을 보내는 관문)에 도달하기 전에 '낚아채는' 방식으로 동작한다.
2.1. DelegatingFilterProxy와 Spring 컨테이너

서블릿 필터(Servlet Filter)는 본래 서블릿 컨테이너의 영역이므로, 스프링 컨테이너 내부의 빈(Bean)을 직접 인식하거나 주입받을 수 없다. 하지만 보안 로직은 서비스 계층이나 DB와 통신해야 하므로 스프링 빈의 도움이 필수적이다. 이 간극을 메우기 위해 Spring Security는 DelegatingFilterProxy라는 특수한 필터를 제공한다.
DelegatingFilterProxy는 말 그대로 “위임자”의 역할을 하며 서블릿 컨테이너와 스프링 컨테이너 사이의 다리 역할을 한다.
- 요청 수신: 서블릿 컨테이너로부터 요청을 받는다.
- 위임: 직접 보안 처리를 하지 않고, 스프링 컨테이너(ApplicationContext)에서 springSecurityFilterChain이라는 이름을 가진 빈(Bean)을 찾아 모든 처리를 위임한다.
- 결과: 이를 통해 보안 필터들은 DI(의존성 주입), AOP 등 스프링의 강력한 기능을 그대로 활용할 수 있게 된다.
2.2. SecurityFilterChain이란 무엇인가
SecurityFilterChain은 단일 필터가 아니다. 요청을 처리하기 위해 촘촘하게 연결된 필터들의 묶음이다. CorsFilter를 시작으로 CsrfFilter, LogoutFilter, UsernamePasswordAuthenticationFilter 등 수십 개의 필터가 순서대로 실행되며, 요청은 이 모든 관문을 통과해야만 비로소 비즈니스 로직(Controller)에 도달할 수 있다.
- 인증 정보를 추출하는 필터
- 인증을 수행하는 필터
- 인가 여부를 판단하는 필터
- 예외를 처리하는 필터
- SecurityContext를 관리하는 필터
Spring Security는 이 필터들을 미리 정의된 순서로 실행한다. 개발자는 이 체인을 수정하거나, 특정 필터를 제거하거나, 새로운 필터를 추가함으로써 보안 전략을 구성하게 된다.
우리의 목표는 이 기본 필터 체인에 'JWT 인증 필터'라는 우리만의 검문소를 끼워 넣는 것이다.
3. Stateful 인증과 Stateless 인증의 근본적인 차이
보안 시스템 설계의 첫 번째 갈림길은 상태 유지 여부다.
3.1. 기본 Form Login 방식
Spring Security의 기본 설정(formLogin)은 세션 기반이다.
- POST /login 요청을 UsernamePasswordAuthenticationFilter가 가로챈다.
- 인증 성공 시, 서버는 세션(Session)을 생성하고 JSESSIONID를 발급한다.
- 이후 요청은 쿠키에 담긴 세션 ID를 통해 서버 메모리의 세션을 조회하여 인증을 유지한다.
이 방식은 전통적인 SSR(Server Side Rendering) 환경에는 적합하지만, 모바일 앱이나 SPA(Single Page Application)와 통신하는 REST API 환경에서는 서버의 확장성을 저해하는 요인이 된다.
3.2. JWT 기반 인증의 요구사항
- JWT 기반 인증은 "서버는 클라이언트의 상태(State)를 기억하지 않는다"는 대전제를 가진다.
- 모든 요청은 스스로 자신을 증명할 수 있는 수단(Token)을 포함해야 한다.
- 인증 상태는 서버 메모리가 아닌, 토큰 자체(Payload)에 포함된다.
- 기본 기능 비활성화: 세션을 생성하는 formLogin, 상태를 유지하는 httpBasic, 세션 탈취 방지용 csrf를 모두 끈다.
- 세션 정책 변경: SessionCreationPolicy.STATELESS 설정을 통해 Security가 세션을 절대 생성하지도, 참조하지도 않도록 강제한다.
- JWT 도입: 세션 ID 대신, 암호화된 Access Token을 통해 사용자를 식별한다.
3.3 인증 처리 방식: UsernamePasswordAuthenticationFilter vs Controller (★)
많은 Spring Security 관련 튜토리얼에서는 UsernamePasswordAuthenticationFilter를 확장(extends)하여 로그인 처리를 구현한다. 이 방식은 Spring Security가 제공하는 전통적인 로그인 흐름이며, 설정만으로 인증 과정을 필터 레벨에서 자동 처리할 수 있다는 장점이 있다.
그러나 이 필터는 본래 Form Login과 세션 기반 인증을 전제로 설계된 컴포넌트다. 요청 포맷은 form-urlencoded를 기준으로 하며, /login 엔드포인트를 암묵적으로 가로채 인증을 수행한다. 인증 과정 또한 필터 내부에서 AuthenticationManager, UserDetailsService, PasswordEncoder가 연쇄적으로 호출되도록 이미 구조가 고정되어 있다.
문제는 이 흐름이 외부에서는 거의 보이지 않는다는 점(Hidden Flow)이다. 로그인 컨트롤러가 존재하지 않기 때문에, 요청이 어디서 시작되어 어떤 컴포넌트를 거쳐 인증이 완료되는지 처음 학습하는 입장에서는 흐름을 파악하기가 매우 어렵다. 특히 JWT 기반 인증처럼 토큰 발급, Redis 저장, 쿠키 설정 등 추가적인 처리가 필요한 경우, 이러한 로직을 필터 내부에 억지로 끼워 넣게 되면서 책임이 과도하게 집중된다.
본 프로젝트는 REST API 기반의 Stateless 아키텍처를 지향한다. 로그인 또한 하나의 명시적인 API로 취급되어야 하며, 요청과 응답의 형태(JSON, HTTP Status, Error Body)를 애플리케이션 레벨에서 명확하게 제어할 필요가 있다.
이러한 이유로 본 프로젝트에서는 Controller에서 로그인 요청을 받고 AuthenticationManager에게 인증을 위임하는 방식을 선택했다. 인증의 핵심 로직과 보안 검증 책임은 여전히 Spring Security에 맡기되, 인증 흐름의 시작과 응답 형태에 대한 제어권은 애플리케이션이 명확히 가져가는 구조다.
3.4. 흐름도 비교: 무엇이 달라지는가?
백문이 불여일견이다. 기존의 필터 기반 방식과 우리가 구현할 컨트롤러 위임 방식의 흐름 차이를 그림으로 비교해 보자.
3.4.1. 기존 방식 (Standard Filter)
Spring Security의 기본 설정(formLogin)을 사용할 때의 흐름이다. 요청이 필터에서 가로채져 컨트롤러까지 도달하지 않는다.
[Client 요청: /login]
⬇
[Security Filter Chain]
⬇
[UsernamePasswordFilter] ✋ "잠깐! 인증은 내가 처리할게."
⬇ (내부 호출)
➡ [AuthenticationManager] (인증 수행)
⬅ (성공/실패 결과 반환)
⬇
[SuccessHandler / FailureHandler] (여기서 응답 처리, Controller 진입 ❌)
3.4.2. 우리의 방식 (Controller Delegation)
필터를 비활성화하고 컨트롤러가 직접 인증 매니저를 호출하는 방식이다.
[Client 요청: /api/auth/login]
⬇
[Security Filter Chain]
⬇ (formLogin.disable + permitAll 설정)
[검사 없이 통과] 🏃💨
⬇
[DispatcherServlet]
⬇
[AuthController] "어서 오세요, 인증 요청이군요."
⬇
[AuthService]
⬇ (주입받은 빈 호출)
➡ [AuthenticationManager] "필터가 부르든 컨트롤러가 부르든 난 똑같이 검증해."
⬅ (인증 결과 반환)
⬇
[AuthController] "성공했네? 쿠키 굽고 JSON 응답 가자."
4. 로그인 요청의 흐름 (Token Issuance)
로그인은 "사용자가 누구인지 증명하고, 접근 권한을 부여받는 과정"이다. 앞서 언급했듯 본 프로젝트에서는 명시적인 제어와 RESTful한 응답 처리를 위해 Controller에서 직접 인증을 처리하는 방식을 택했다. 데이터의 흐름은 다음과 같다.
- 요청 진입 (Controller): 클라이언트가 ID와 비밀번호를 JSON 담아 /api/auth/login으로 POST 요청을 보낸다. 아직은 인증되지 않은 익명의 사용자다.
- 인증 시도 (AuthenticationManager): AuthService는 전달받은 ID/PW를 이용해 UsernamePasswordAuthenticationToken이라는 '인증 요청 객체'를 만들고, 이를 Spring Security의 AuthenticationManager에게 건넨다. 이것은 "이 정보가 맞는지 확인해줘"라는 요청이다.
- 사용자 조회 (UserDetailsService): 매니저의 지시를 받은 CustomUserDetailsService는 DB에 접근하여 해당 username을 가진 사용자가 있는지 찾는다. 존재한다면 비밀번호 등을 포함한 사용자 정보를 UserDetails(AuthMember) 객체로 포장하여 반환한다.
- 검증 및 패스워드 대조: AuthenticationManager는 사용자가 입력한 비밀번호와 DB에서 가져온(암호화된) 비밀번호를 대조한다. 이 과정은 PasswordEncoder에 의해 안전하게 수행된다.
- 토큰 발급 (Token Provider): 인증이 성공하면, AuthService는 JwtTokenProvider를 호출한다. 이곳에서는 사용자의 ID와 권한(Role) 정보를 암호화하여 Access Token과 Refresh Token을 생성한다.
- 상태 저장 (Redis): Access Token은 클라이언트에게 주지만, Refresh Token은 서버(Redis)에도 저장한다. 이는 추후 로그아웃 처리나 토큰 탈취 시 강제 만료를 시키기 위함이다. (Key: 사용자 ID, Value: Refresh Token)
- 응답 반환: 최종적으로 생성된 두 토큰은 HttpOnly Cookie에 담겨 클라이언트에게 전달된다. 이제 사용자는 '로그인된 상태'가 되었다.
5. 심층 분석 B: 인증이 필요한 요청의 흐름 (Token Verification)
로그인 후, 사용자가 "내 정보 조회(GET /api/members/me)"와 같이 보안이 필요한 API를 호출할 때의 흐름이다. 이때는 Controller가 개입하기 전에, 우리가 만든 JwtAuthenticationFilter가 핵심 역할을 수행한다.
- 검문소 도달 (JwtAuthenticationFilter): 모든 HTTP 요청은 DispatcherServlet에 닿기 전, SecurityConfig에 등록한 JwtAuthenticationFilter를 반드시 통과해야 한다. 이 필터는 OncePerRequestFilter를 상속받아, 한 번의 요청 당 딱 한 번만 실행됨을 보장한다.
- 토큰 추출 및 검사: 필터는 요청 헤더(또는 쿠키)를 열어 Access Token이 존재하는지 확인한다. 토큰이 있다면 JwtTokenProvider를 통해 위조되었는지, 만료되지는 않았는지 꼼꼼하게 서명을 검증한다.
- 인증 객체 생성 (Authentication): 토큰이 유효하다면, 토큰 안에 들어있는 사용자 ID와 권한(Role) 정보를 꺼낸다. 그리고 이를 기반으로 Spring Security가 내부적으로 사용하는 신분증인 Authentication 객체를 생성한다.
- SecurityContext 등록: 생성된 Authentication 객체를 SecurityContextHolder라는 전역 저장소에 넣어준다. 이 행위가 바로 "로그인 되었다"고 시스템에 알리는 결정적인 순간이다.
- 통과 및 처리: 이제 요청은 필터를 통과하여 Controller로 이동한다. Controller에서는 @AuthenticationPrincipal 어노테이션을 통해 앞서 저장해 둔 사용자 정보를 손쉽게 꺼내 쓸 수 있다.
만약 토큰이 없거나 유효하지 않다면? 필터는 아무것도 하지 않고 요청을 흘려보낸다. 그 후 뒤이어 등장하는 AuthorizationFilter가 "이 요청은 인증이 필요한데 신분증이 없네?"라고 판단하여 401 Unauthorized 예외를 발생시키고 요청을 차단한다.
'Spring > Security' 카테고리의 다른 글
| [6] Spring Security Local: Sequence Diagram (0) | 2025.12.16 |
|---|---|
| [5] Spring Security Local: 로컬 로그인과 토큰 재발급 (0) | 2025.12.16 |
| [4] Spring Security Local: JWT 발급과 인증 필터 구현 (1) | 2025.12.16 |
| [3] Spring Security Local: 도메인 설계와 UserDetails (0) | 2025.12.16 |
| [2] Spring Security Local: 프로젝트 구조 설계 및 필수 의존성 설정 (0) | 2025.12.16 |
