Spring Security 필터 체인 아키텍처 — 요청이 SecurityContext를 만나기까지
처음 Spring Security를 붙이면
SecurityFilterChain빈 하나로 모든 인증·인가가 마법처럼 동작합니다. 이 글은 그 마법을 풀어, HTTP 요청이 톰캣의 서블릿 필터에 들어와서 컨트롤러에 도착할 때까지 어떤 필터들이 어떤 순서로 협력하는지를 정리합니다. Spring Security 6 기준이며, Spring Boot 3.x에서 기본으로 적용되는 구성입니다.
Spring Security가 풀어야 했던 첫 문제는 거창하지 않습니다. 서블릿 컨테이너의 필터는 스프링 빈을 모르고, 스프링 빈은 서블릿 컨테이너의 라이프사이클을 모릅니다. 이 단순한 단절을 메우기 위해 DelegatingFilterProxy가 생겼고, 그 위에 보안 책임 하나하나를 분리한 작은 필터들을 줄 세우기 위해 FilterChainProxy가 등장했고, 다시 그 위에 인증·인가의 의미 단위를 분리한 Authentication / AuthenticationManager / SecurityContextHolder가 얹혔습니다. 결국 큰 그림은 얇은 다리(브리지) 두 개와 추상화 레이어 세 개입니다.
이 글에서는 다음 흐름으로 정리합니다.
- 서블릿 필터와 스프링 빈을 잇는
DelegatingFilterProxy - 보안 필터들의 오케스트레이터
FilterChainProxy와SecurityFilterChain - 기본 필터 등록 순서(
FilterOrderRegistration) SecurityContextHolder와 세 가지 저장 전략- 인증 추상화 4총사:
Authentication/AuthenticationManager/ProviderManager/AuthenticationProvider - 폼 로그인의 표준 골격
AbstractAuthenticationProcessingFilter - 보안 예외를 HTTP 응답으로 번역하는
ExceptionTranslationFilter - 인가의 종착역
AuthorizationFilter - 운영에서 자주 부딪히는 함정 다섯 가지
1. 왜 필터인가 — DelegatingFilterProxy의 역할
서블릿 컨테이너(Tomcat, Jetty 등)는 표준 jakarta.servlet.Filter만 압니다. Spring Security가 필터로 동작하려면 web.xml이나 ServletContext에 등록될 수 있어야 하는데, 그러려면 컨테이너가 직접 인스턴스를 만들어 들고 있어야 합니다. 반면 우리가 만들고 싶은 보안 필터는 다른 빈을 주입받고, 트랜잭션 매니저나 메시지 컨버터처럼 ApplicationContext의 일원이어야 합니다.
DelegatingFilterProxy는 이 단절을 잇는 얇은 어댑터입니다. 컨테이너에는 "필터" 한 개로 등록되지만, 실제 호출은 ApplicationContext에서 같은 이름의 빈을 찾아 위임합니다. Spring Boot에서는 SecurityFilterAutoConfiguration이 빈 이름 springSecurityFilterChain(타입은 FilterChainProxy)에 대해 이 프록시를 자동 등록합니다.
flowchart LR
Browser -->|HTTP request| Container[Servlet Container]
Container --> DFP[DelegatingFilterProxy]
DFP -.lookup bean.-> Ctx[ApplicationContext]
Ctx -.springSecurityFilterChain.-> FCP[FilterChainProxy]
DFP -->|delegate doFilter| FCP
FCP --> App[Servlet / DispatcherServlet]
DelegatingFilterProxy가 가져다주는 두 가지 이점:
- 스프링 라이프사이클 사용:
FilterChainProxy는 빈이므로@Autowired,@PostConstruct, AOP 프록시가 모두 통합니다. - 지연 초기화: 서블릿 컨테이너가
init()을 부를 때 ApplicationContext가 준비 안 됐을 수 있는데,DelegatingFilterProxy는 첫 요청까지 빈 조회를 미룹니다.
2. FilterChainProxy — 보안 필터들의 오케스트레이터
DelegatingFilterProxy가 컨테이너와 스프링을 잇는 단 한 개의 다리라면, FilterChainProxy는 그 다리 끝에서 보안 필터 묶음을 결정하는 디스패처입니다. 핵심 책임은 셋입니다.
- 들어온 요청을 어떤
SecurityFilterChain에 태울지 매칭한다. - 매칭된 체인의 필터들을
VirtualFilterChain이라는 내부 구조로 순차 실행한다. - 요청이 끝난 뒤
SecurityContext를 정리해 메모리 누수를 막는다.
요청이 들어오면 doFilter → doFilterInternal 두 단계를 거칩니다. doFilterInternal은 먼저 HttpFirewall로 요청을 검사·정규화한 뒤(예: 디코드된 경로 차단, 백슬래시 제거), 매칭되는 첫 번째 SecurityFilterChain을 골라 그 안의 필터들을 실행합니다.
flowchart TD
Req[HttpServletRequest] --> FCP[FilterChainProxy.doFilter]
FCP --> FW[HttpFirewall validate and wrap]
FW --> Match{Match SecurityFilterChain}
Match -->|chain1 /api/**| Chain1[VirtualFilterChain 1]
Match -->|chain2 fallback| Chain2[VirtualFilterChain 2]
Match -->|no match| Pass[Pass through]
Chain1 --> Filters[Security Filters in order]
Chain2 --> Filters
Filters --> Servlet[Original FilterChain.doFilter]
Servlet --> Cleanup[Clear SecurityContextHolder]
FilterChainProxy가 직접 등록되지 않고 DelegatingFilterProxy에 한 번 더 감싸이는 이유는 위에서 설명한 컨테이너·스프링 단절 때문입니다. FilterChainProxy가 직접 컨테이너에 등록될 수도 있지만, 그러면 FilterChainProxy 자체의 의존성 주입이 어색해집니다.
SecurityFilterChain은 여러 개일 수 있습니다
Spring Security 5.4부터 SecurityFilterChain을 여러 개 빈으로 등록하면 FilterChainProxy가 요청 패턴별로 다른 체인을 적용합니다. 첫 번째로 매칭되는 체인 하나만 동작하므로 순서가 중요합니다.
@Bean
@Order(1)
SecurityFilterChain api(HttpSecurity http) throws Exception {
http.securityMatcher("/api/**")
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()));
return http.build();
}
@Bean
@Order(2)
SecurityFilterChain web(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(Customizer.withDefaults());
return http.build();
}
같은 애플리케이션에서 REST API는 JWT, 웹 페이지는 폼 로그인을 쓰는 구성이 자연스럽게 이렇게 정리됩니다.
3. 한 SecurityFilterChain 안의 표준 필터 순서
매칭된 SecurityFilterChain은 그 안에 보안 필터들을 줄로 들고 있습니다. HttpSecurity DSL이 formLogin(), csrf() 같은 메서드로 등록한 필터들을 합치면 보통 15~20개의 필터가 됩니다.
순서는 임의가 아니라 FilterOrderRegistration이라는 클래스에서 일괄 결정합니다. 이 클래스는 100부터 시작해 100단위로 자리(slot)를 잡아 두고, 알려진 필터 클래스를 그 자리에 미리 매핑해 둡니다.
다음은 일반적인 폼 로그인 + HTTP Basic 구성에서 자주 등장하는 필터들의 표준 순서입니다(소속 자리만 채워진 단순화 버전).
| 순서 | 필터 | 책임 |
|---|---|---|
| 1 | DisableEncodeUrlFilter |
URL에 jsessionid가 박히는 것을 막아 세션 ID 노출 차단 |
| 2 | WebAsyncManagerIntegrationFilter |
@Async/Callable에서도 SecurityContext 전파 |
| 3 | SecurityContextHolderFilter |
요청 처음에 SecurityContext를 적재, 끝에 정리 |
| 4 | HeaderWriterFilter |
X-Frame-Options, X-Content-Type-Options, Strict-Transport-Security 등 보안 헤더 |
| 5 | CorsFilter |
CORS preflight 처리 |
| 6 | CsrfFilter |
CSRF 토큰 검증 |
| 7 | LogoutFilter |
/logout 매칭 시 인증 초기화 |
| 8~ | 인증 필터들 | UsernamePasswordAuthenticationFilter, BasicAuthenticationFilter, BearerTokenAuthenticationFilter 등 |
| 11 | RequestCacheAwareFilter |
인증 후 원래 요청 복원 |
| 12 | SecurityContextHolderAwareRequestFilter |
HttpServletRequest의 getUserPrincipal 등 메서드를 보안 컨텍스트로 연결 |
| 13 | AnonymousAuthenticationFilter |
인증 안 된 요청에 익명 Authentication 주입 |
| 14 | SessionManagementFilter |
세션 고정 공격 방지, 동시 세션 제어 |
| 15 | ExceptionTranslationFilter |
보안 예외 → HTTP 응답 변환 |
| 16 | AuthorizationFilter |
마지막 인가 결정 |
특히 짚어 둘 점이 두 가지입니다.
SecurityContextHolderFilter(5.8+)는SecurityContextPersistenceFilter의 후속입니다. 이전 필터는 응답을 보내기 직전에SecurityContext를 자동으로 세션에 저장했지만, 새 필터는 명시적으로SecurityContextRepository.saveContext를 부르도록 바꿨습니다. 이는@Async호출이나 응답 후처리에서 컨텍스트가 의도치 않게 사라지는 문제를 줄이기 위한 설계 변경입니다.AuthorizationFilter(5.7+)는FilterSecurityInterceptor의 후속입니다. 둘 다 인가의 마지막 단계지만, 새 필터는AuthorizationManager라는 함수형 추상화를 사용해authorizeHttpRequestsDSL과 자연스럽게 결합합니다. 6.x부터는AuthorizationFilter가 사실상 표준입니다.
4. SecurityContextHolder — 인증 결과의 ThreadLocal 저장소
SecurityContextHolderFilter가 적재하는 대상은 SecurityContext이고, 이를 저장하는 곳이 SecurityContextHolder입니다. 이름이 시사하듯 단일 클래스에 정적 메서드로 접근합니다(SecurityContextHolder.getContext().getAuthentication()). 내부적으로는 SecurityContextHolderStrategy를 통해 저장 위치를 추상화합니다.
세 가지 표준 전략:
MODE_THREADLOCAL— 기본값. 현재 스레드에 바인딩된ThreadLocal<SecurityContext>. 서블릿 모델처럼 요청 한 개가 한 스레드에서 처리되는 환경에 자연스러움.MODE_INHERITABLETHREADLOCAL—InheritableThreadLocal을 사용해 자식 스레드가 부모 컨텍스트를 상속. 외부에서 자주 권장하지만, 스레드 풀에서는 위험합니다. 풀의 작업자 스레드는 한 번 만들어지면 재사용되므로, 첫 작업이 남긴 컨텍스트가 다음 사용자에게 새어 나갈 수 있습니다.MODE_GLOBAL— 모든 스레드가 같은 단일 컨텍스트를 공유. 서버 환경에서는 기본적으로 부적합하며, 단일 사용자 데스크톱 애플리케이션에 한정됩니다.
flowchart LR
Req[Request thread] --> Holder[SecurityContextHolder]
Holder --> Strategy{Strategy}
Strategy -->|THREADLOCAL default| TL[ThreadLocal<SecurityContext>]
Strategy -->|INHERITABLE| ITL[InheritableThreadLocal]
Strategy -->|GLOBAL| GS[Single static reference]
TL --> Ctx[SecurityContext]
Ctx --> Auth[Authentication]
@Async나 CompletableFuture.runAsync처럼 새 스레드로 작업을 던질 때는 DelegatingSecurityContextRunnable 같은 데코레이터로 컨텍스트를 명시적으로 전달하는 편이 안전합니다. 이게 WebAsyncManagerIntegrationFilter가 하는 일과 같은 패턴입니다.
5. 인증 추상화 4총사 — Authentication / Manager / ProviderManager / Provider
SecurityContext가 들고 있는 핵심 객체가 Authentication입니다. 이 인터페이스는 인증 시도 전후를 모두 표현하는 다형성 모델입니다.
public interface Authentication extends Principal, Serializable {
Collection<? extends GrantedAuthority> getAuthorities();
Object getCredentials(); // 비번이나 토큰. 인증 후 보통 null로 비움
Object getDetails(); // 요청 IP, 세션 ID 등 부가 정보
Object getPrincipal(); // 사용자(보통 UserDetails)
boolean isAuthenticated();
void setAuthenticated(boolean isAuthenticated);
}
인증 전에는 UsernamePasswordAuthenticationToken("alice", "password123") 처럼 principal=username, credentials=password, authenticated=false 인 토큰이 만들어지고, 인증 후에는 같은 토큰의 principal이 UserDetails로 바뀌고 credentials는 null, authenticated=true가 됩니다. 같은 인터페이스가 인증 시도와 결과 모두를 표현하는 점이 처음 보면 헷갈립니다.
이 토큰을 검증해 달라고 부탁받는 곳이 AuthenticationManager입니다. 인터페이스는 메서드 한 개뿐입니다.
public interface AuthenticationManager {
Authentication authenticate(Authentication authentication) throws AuthenticationException;
}
가장 흔한 구현이 ProviderManager이고, 내부에 List<AuthenticationProvider>를 들고 있습니다. 들어온 토큰을 첫 번째 Provider부터 차례로 보여 주고, "내 담당 토큰이다" 라고 손드는 첫 번째 Provider가 검증합니다.
flowchart TD
Filter[Authentication Filter] -->|UsernamePasswordAuthToken| AM[AuthenticationManager]
AM -.implements.- PM[ProviderManager]
PM -->|supports| P1[DaoAuthenticationProvider]
PM -->|supports| P2[JwtAuthenticationProvider]
PM -->|supports| P3[LdapAuthenticationProvider]
P1 -->|loadUserByUsername| UDS[UserDetailsService]
P1 -->|matches| PE[PasswordEncoder]
PM -->|none matched| Parent[parent AuthenticationManager]
PM -.fallback.-> NotFound[ProviderNotFoundException]
핵심 동작 규칙 네 가지:
supports(Class)로 1차 분기:Provider는 자기가 처리할 토큰 타입을 선언합니다.DaoAuthenticationProvider는UsernamePasswordAuthenticationToken만 받습니다.- 첫 비-null 응답 채택: Provider 하나가 인증을 끝내면 그 결과로 즉시 종료, 다른 Provider는 호출되지 않습니다.
- 부모 매니저로 위임: 모든 Provider가 거부하면
parent가 있으면 그쪽에 다시 묻습니다. 글로벌 인증 매니저와 컨텍스트별 매니저를 계층화할 때 사용합니다. - 마지막 보루: 부모도 못 풀면
ProviderNotFoundException이 던져집니다. 이것은AuthenticationException의 하위 타입이므로ExceptionTranslationFilter에서 401로 번역됩니다.
DaoAuthenticationProvider의 내부는 단순합니다.
UserDetailsService.loadUserByUsername(name)로 DB의 사용자 조회.- 못 찾으면
UsernameNotFoundException(보통BadCredentialsException으로 가려서 던집니다 — 사용자명 존재 여부 노출 회피). - 찾으면
PasswordEncoder.matches(rawPassword, encodedPassword)로 비번 비교. - 일치하면
UsernamePasswordAuthenticationToken(principal=UserDetails, credentials=null, authorities)반환.
비밀번호 비교가 BCryptPasswordEncoder 같은 단방향 해시로 나뉘어 있어 평문 비밀번호가 DB에 직접 저장되지 않습니다. Spring Security 5.0부터 DelegatingPasswordEncoder가 기본이라 여러 알고리즘을 prefix({bcrypt}...)로 식별해 한 DB 안에 공존시킬 수 있습니다.
6. AbstractAuthenticationProcessingFilter — 폼 로그인의 표준 골격
UsernamePasswordAuthenticationFilter(폼 로그인)와 OAuth2/SAML2/일회용 토큰 필터들이 공통으로 상속하는 추상 클래스가 AbstractAuthenticationProcessingFilter입니다. 로그인 처리 URL 매칭, 토큰 추출, 매니저 호출, 결과 분기라는 전형적인 인증 필터의 골격을 들고 있습니다.
flowchart TD
In[doFilter] --> Match{requiresAuthentication match URL}
Match -->|no| Pass[chain.doFilter pass]
Match -->|yes| Attempt[attemptAuthentication]
Attempt --> Outcome{Outcome}
Outcome -->|null| Pass
Outcome -->|return Authentication| Strategy[SessionAuthenticationStrategy onAuthentication]
Strategy --> Success[successfulAuthentication]
Success --> Save[Save SecurityContext]
Success --> RM[RememberMeServices loginSuccess]
Success --> Event[Publish InteractiveAuthenticationSuccessEvent]
Success --> SH[AuthenticationSuccessHandler onAuthenticationSuccess]
Outcome -->|throw AuthenticationException| Fail[unsuccessfulAuthentication]
Fail --> Clear[Clear SecurityContextHolder]
Fail --> RMFail[RememberMeServices loginFail]
Fail --> FH[AuthenticationFailureHandler onAuthenticationFailure]
attemptAuthentication만 서브클래스가 구현하면 위 흐름이 자동으로 굴러갑니다. 폼 로그인이라면 요청 파라미터 username/password를 읽어 토큰을 만들고 AuthenticationManager.authenticate를 호출하는 게 다입니다.
세 가지 결과 분기를 다시 정리하면:
Authentication반환: 정상 인증.SessionAuthenticationStrategy.onAuthentication이 먼저 호출돼 세션 고정 공격 방지(세션 ID 재발급)가 일어나고, 이어서successfulAuthentication이 컨텍스트 저장·이벤트 발행·AuthenticationSuccessHandler호출까지 마무리합니다.null반환: 처리 미완료. 다음 필터로 넘깁니다. OAuth2 redirect를 기다리는 중간 상태에서 흔히 사용됩니다.AuthenticationException던짐: 실패.unsuccessfulAuthentication이 컨텍스트를 비우고AuthenticationFailureHandler를 호출합니다.
successfulAuthentication이 끝나면 chain.doFilter를 호출하지 않습니다. 즉 로그인 처리 URL은 보통 보호 자원에 도달하지 않고, AuthenticationSuccessHandler(예: SimpleUrlAuthenticationSuccessHandler)가 적당한 곳으로 리다이렉트시킵니다.
7. ExceptionTranslationFilter — 보안 예외를 HTTP 응답으로 번역
이 필터는 자기 다음에 위치한 필터들(주로 AuthorizationFilter)이 던지는 두 종류의 보안 예외를 잡아 HTTP 응답으로 변환하는 일을 합니다. 인증·인가는 항상 예외 throwing 모델로 동작하기 때문에 그 예외를 응답으로 번역하는 단일 지점이 필요합니다.
flowchart TD
Pass[try chain.doFilter] -->|nothing thrown| End
Pass -->|AuthenticationException| AE[AuthenticationEntryPoint commence]
AE --> Save[RequestCache saveRequest]
Save --> Resp1[401 or login redirect]
Pass -->|AccessDeniedException| Anon{Anonymous or remember-me}
Anon -->|yes| AE
Anon -->|no| ADH[AccessDeniedHandler handle]
ADH --> Resp2[403 Forbidden]
핵심 두 갈래:
AuthenticationException(인증 실패): 사용자가 아예 로그인하지 않았거나 자격증명 검증이 실패한 경우.AuthenticationEntryPoint.commence가 호출되며, 폼 로그인 구성에서는LoginUrlAuthenticationEntryPoint가/login으로 302 리다이렉트, REST 구성에서는Http403ForbiddenEntryPoint대신 보통 401을 직접 응답하는 커스텀 EntryPoint를 씁니다. 이때RequestCache에 원래 요청을 보관해 두기 때문에 로그인 후 그 페이지로 돌아갈 수 있습니다.AccessDeniedException(인가 실패): 로그인은 했지만 권한이 부족한 경우 —AccessDeniedHandler.handle이 403을 돌려줍니다. 단, 인증 객체가 익명(AnonymousAuthenticationToken)이거나RememberMe로만 인증된 약한 상태라면 "사실상 미인증" 으로 판단해AuthenticationEntryPoint로 다시 보냅니다.
이 분기 덕에 같은 보호 자원 /admin이라도 로그아웃 상태로 접근하면 로그인 페이지로 리다이렉트되고, 일반 사용자로 접근하면 403이 돌아오는 두 가지 자연스러운 동작이 나옵니다.
8. AuthorizationFilter — 인가의 종착역
체인 가장 끝에 자리 잡은 필터입니다. 6.x 기본 구성에서는 AuthorizationFilter가 하나의 AuthorizationManager에 결정을 위임합니다.
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/admin/**").hasRole("ADMIN")
.requestMatchers("/api/**").authenticated()
.anyRequest().permitAll());
위 DSL은 내부적으로 RequestMatcherDelegatingAuthorizationManager를 만들어 매처별로 분기되는 AuthorizationManager 트리를 구성합니다. 매니저는 결과로 AuthorizationDecision(granted: boolean)을 반환하며, 거부 시 AccessDeniedException을 던져 위에서 본 ExceptionTranslationFilter로 흐름이 넘어갑니다.
AuthorizationManager는 함수형 인터페이스에 가까워서 Lambda로 직접 정책을 만들 수도 있습니다.
.requestMatchers("/orders/**").access((authentication, context) -> {
boolean ok = authentication.get().getAuthorities().stream()
.anyMatch(a -> a.getAuthority().equals("ROLE_USER"));
return new AuthorizationDecision(ok);
})
기존 FilterSecurityInterceptor + AccessDecisionManager 모델보다 단순해진 부분은, 결정의 단위가 함수형 인터페이스 한 개로 줄었다는 점입니다. 메서드 보안(@PreAuthorize)도 같은 추상화 위에 있어 Web과 Method 레이어가 동일한 모델을 공유합니다.
9. 운영 함정 5가지
9.1 permitAll은 인증을 면제하지 인가만 면제하는 게 아닙니다
다음 구성은 의도치 않게 모든 요청에 익명 인증을 강제합니다.
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.anyRequest().authenticated());
/public/health로 들어오는 요청도 AnonymousAuthenticationFilter를 거치고 AuthorizationFilter까지 갑니다. 단지 permitAll 매처에서 통과될 뿐입니다. 정적 리소스나 헬스체크는 가능하면 web.ignoring()으로 보안 필터 자체를 건너뛰게 만드는 편이 비용이 더 낮습니다(단, 헤더 보안과 컨텍스트 정리도 같이 빠지는 점은 인지).
9.2 MODE_INHERITABLETHREADLOCAL은 풀 환경에서 위험합니다
SecurityContextHolder.setStrategyName(MODE_INHERITABLETHREADLOCAL)을 켠 채 ThreadPoolTaskExecutor로 작업을 던지면, 작업자 스레드에 한 번 들어간 보안 컨텍스트가 다음 작업으로 누출됩니다. 풀 스레드는 한 번 만들어지면 재사용되기 때문입니다. 비동기에서 인증을 전파하고 싶다면 DelegatingSecurityContextExecutor(기본 매뉴얼 추천 방식)이나 WebAsyncManagerIntegrationFilter가 처리하는 @Async 경로를 쓰는 편이 안전합니다.
9.3 SecurityFilterChain을 여러 개 등록할 때 순서를 빠뜨리는 실수
@Order가 없으면 두 체인의 우선순위가 결정되지 않은 상태에서 securityMatcher가 더 일반적인 쪽이 먼저 매칭돼 의도와 다른 체인이 적용됩니다. REST API용 체인은 @Order(1), 웹용 체인은 @Order(2) 처럼 명시하는 게 안전합니다. 상태 비저장 API에 csrf()가 켜진 채로 적용되면 POST 요청이 모두 403으로 반환되는 식의 사고가 가장 흔합니다.
9.4 successfulAuthentication에 의존해 추가 작업을 그 안에 넣지 마세요
AuthenticationSuccessHandler를 커스터마이즈하지 않고 필터를 상속해 successfulAuthentication을 오버라이드해 부가 작업(감사 로그 등)을 끼워 넣는 패턴은 자주 깨집니다. Spring Security 7로 올라가면서 이 메서드의 시그니처가 변할 수 있고, 이미 InteractiveAuthenticationSuccessEvent라는 깔끔한 이벤트가 발행되므로 @EventListener로 듣는 편이 안정적입니다.
9.5 csrf()를 무조건 끄지 마세요
REST API 한정으로 끄는 것은 일반적이지만, 그 매칭이 너무 넓으면 같은 도메인의 폼 페이지까지 CSRF 보호가 사라집니다. securityMatcher로 /api/**만 분리한 체인에서만 끄고, 사용자 페이지 체인에서는 기본값(CookieCsrfTokenRepository.withHttpOnlyFalse() 같은 명시적 구성)을 유지하는 패턴이 안전합니다.
마무리 — 추상화 한 장 요약
flowchart LR
Req[HTTP Request] --> Bridge[DelegatingFilterProxy]
Bridge --> Orchestrator[FilterChainProxy]
Orchestrator --> Match[SecurityFilterChain match]
Match --> Filters[Security Filters in order]
Filters --> Holder[SecurityContextHolder load]
Filters --> AuthFilter[AbstractAuthenticationProcessingFilter]
AuthFilter --> Manager[AuthenticationManager]
Manager --> Provider[AuthenticationProvider]
Provider --> UDS[UserDetailsService]
Filters --> ETF[ExceptionTranslationFilter]
ETF -.AuthException.-> Entry[AuthenticationEntryPoint]
ETF -.AccessDenied.-> Denied[AccessDeniedHandler]
Filters --> Authz[AuthorizationFilter]
Authz -->|granted| App[DispatcherServlet]
Authz -.denied.-> ETF
Spring Security가 어렵게 느껴지는 이유는 어휘가 많기 때문이지, 구조가 복잡해서가 아닙니다. 컨테이너·스프링 다리(DelegatingFilterProxy) → 보안 오케스트레이터(FilterChainProxy) → 의미 단위로 쪼개진 작은 필터들 → 인증·인가의 함수형 추상화 라는 네 단계만 한 번 통째로 그려 두면, 새 인증 메커니즘(JWT, OAuth2, SAML2, 패스키)을 붙일 때도 어느 자리에 새 필터를 끼워야 하는지가 자연스럽게 보입니다.
부록: 자주 헷갈리는 용어 정리
| 용어 | 한 줄 설명 |
|---|---|
DelegatingFilterProxy |
서블릿 필터로 등록되지만 실제 처리는 같은 이름의 스프링 빈에 위임하는 어댑터 |
FilterChainProxy |
모든 보안 필터를 묶어 실행하는 단일 진입점. Spring Security 자체가 시작되는 빈 |
SecurityFilterChain |
한 매칭 패턴(securityMatcher)에 묶인 보안 필터 묶음. 여러 개 등록 가능 |
VirtualFilterChain |
FilterChainProxy가 매칭된 필터들을 순차 실행하기 위해 만드는 내부 체인 |
SecurityContext |
한 요청의 인증 결과 컨테이너. 보통 Authentication 한 개를 들고 있음 |
SecurityContextHolder |
SecurityContext를 스레드(또는 다른 스코프)에 보관하는 정적 게이트웨이 |
Authentication |
인증 시도와 결과를 같이 표현하는 다형성 객체. principal/credentials/authorities |
AuthenticationManager |
authenticate(Authentication) 한 메서드만 가진 인증의 진입 인터페이스 |
ProviderManager |
가장 흔한 AuthenticationManager 구현. AuthenticationProvider 리스트에 위임 |
AuthenticationProvider |
실제 인증 로직 한 종류(폼/JWT/LDAP/SAML2 등)를 담당 |
UserDetailsService |
사용자 식별자로 영속 사용자 정보(UserDetails)를 조회하는 인터페이스 |
PasswordEncoder |
평문 비번을 인코딩하고, 인코딩된 값과 평문을 비교 |
AuthenticationEntryPoint |
인증되지 않은 사용자에게 어떻게 인증을 시작할지 결정 (로그인 페이지 / 401) |
AccessDeniedHandler |
인증된 사용자가 인가 거부된 경우의 응답 결정 (보통 403) |
AuthorizationFilter |
6.x 표준 인가 필터. AuthorizationManager에 결정을 위임 |
AuthorizationManager |
인가 결정 함수형 추상화. check(Supplier<Authentication>, T) -> AuthorizationDecision |
참고자료
- https://docs.spring.io/spring-security/reference/servlet/architecture.html
- https://docs.spring.io/spring-security/reference/servlet/authentication/architecture.html
- https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/dao-authentication-provider.html
- https://docs.spring.io/spring-security/reference/servlet/authorization/architecture.html
- https://docs.spring.io/spring-security/reference/servlet/authorization/authorize-http-requests.html
- https://docs.spring.io/spring-security/reference/api/java/org/springframework/security/web/access/ExceptionTranslationFilter.html
- https://docs.spring.io/spring-security/reference/api/java/org/springframework/security/core/context/SecurityContextHolder.html
- https://docs.spring.io/spring-security/reference/api/java/org/springframework/security/web/authentication/AbstractAuthenticationProcessingFilter.html
- https://github.com/spring-projects/spring-security/blob/main/web/src/main/java/org/springframework/security/web/FilterChainProxy.java
- https://github.com/spring-projects/spring-security/blob/main/config/src/main/java/org/springframework/security/config/annotation/web/builders/FilterOrderRegistration.java
- https://github.com/spring-projects/spring-security/blob/main/web/src/main/java/org/springframework/security/web/access/ExceptionTranslationFilter.java
- https://github.com/spring-projects/spring-security/blob/main/web/src/main/java/org/springframework/security/web/authentication/AbstractAuthenticationProcessingFilter.java
- https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/filter/DelegatingFilterProxy.html

