Skip to main content

Command Palette

Search for a command to run...

Spring Security 필터 체인 아키텍처 — 요청이 SecurityContext를 만나기까지

Updated
12 min read

처음 Spring Security를 붙이면 SecurityFilterChain 빈 하나로 모든 인증·인가가 마법처럼 동작합니다. 이 글은 그 마법을 풀어, HTTP 요청이 톰캣의 서블릿 필터에 들어와서 컨트롤러에 도착할 때까지 어떤 필터들이 어떤 순서로 협력하는지를 정리합니다. Spring Security 6 기준이며, Spring Boot 3.x에서 기본으로 적용되는 구성입니다.

Spring Security가 풀어야 했던 첫 문제는 거창하지 않습니다. 서블릿 컨테이너의 필터는 스프링 빈을 모르고, 스프링 빈은 서블릿 컨테이너의 라이프사이클을 모릅니다. 이 단순한 단절을 메우기 위해 DelegatingFilterProxy가 생겼고, 그 위에 보안 책임 하나하나를 분리한 작은 필터들을 줄 세우기 위해 FilterChainProxy가 등장했고, 다시 그 위에 인증·인가의 의미 단위를 분리한 Authentication / AuthenticationManager / SecurityContextHolder가 얹혔습니다. 결국 큰 그림은 얇은 다리(브리지) 두 개와 추상화 레이어 세 개입니다.

이 글에서는 다음 흐름으로 정리합니다.

  1. 서블릿 필터와 스프링 빈을 잇는 DelegatingFilterProxy
  2. 보안 필터들의 오케스트레이터 FilterChainProxySecurityFilterChain
  3. 기본 필터 등록 순서(FilterOrderRegistration)
  4. SecurityContextHolder와 세 가지 저장 전략
  5. 인증 추상화 4총사: Authentication / AuthenticationManager / ProviderManager / AuthenticationProvider
  6. 폼 로그인의 표준 골격 AbstractAuthenticationProcessingFilter
  7. 보안 예외를 HTTP 응답으로 번역하는 ExceptionTranslationFilter
  8. 인가의 종착역 AuthorizationFilter
  9. 운영에서 자주 부딪히는 함정 다섯 가지

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는 그 다리 끝에서 보안 필터 묶음을 결정하는 디스패처입니다. 핵심 책임은 셋입니다.

  1. 들어온 요청을 어떤 SecurityFilterChain에 태울지 매칭한다.
  2. 매칭된 체인의 필터들을 VirtualFilterChain이라는 내부 구조로 순차 실행한다.
  3. 요청이 끝난 뒤 SecurityContext를 정리해 메모리 누수를 막는다.

요청이 들어오면 doFilterdoFilterInternal 두 단계를 거칩니다. 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 HttpServletRequestgetUserPrincipal 등 메서드를 보안 컨텍스트로 연결
13 AnonymousAuthenticationFilter 인증 안 된 요청에 익명 Authentication 주입
14 SessionManagementFilter 세션 고정 공격 방지, 동시 세션 제어
15 ExceptionTranslationFilter 보안 예외 → HTTP 응답 변환
16 AuthorizationFilter 마지막 인가 결정

특히 짚어 둘 점이 두 가지입니다.

  • SecurityContextHolderFilter(5.8+)는 SecurityContextPersistenceFilter의 후속입니다. 이전 필터는 응답을 보내기 직전에 SecurityContext를 자동으로 세션에 저장했지만, 새 필터는 명시적으로 SecurityContextRepository.saveContext를 부르도록 바꿨습니다. 이는 @Async 호출이나 응답 후처리에서 컨텍스트가 의도치 않게 사라지는 문제를 줄이기 위한 설계 변경입니다.
  • AuthorizationFilter(5.7+)는 FilterSecurityInterceptor의 후속입니다. 둘 다 인가의 마지막 단계지만, 새 필터는 AuthorizationManager라는 함수형 추상화를 사용해 authorizeHttpRequests DSL과 자연스럽게 결합합니다. 6.x부터는 AuthorizationFilter가 사실상 표준입니다.

4. SecurityContextHolder — 인증 결과의 ThreadLocal 저장소

SecurityContextHolderFilter가 적재하는 대상은 SecurityContext이고, 이를 저장하는 곳이 SecurityContextHolder입니다. 이름이 시사하듯 단일 클래스에 정적 메서드로 접근합니다(SecurityContextHolder.getContext().getAuthentication()). 내부적으로는 SecurityContextHolderStrategy를 통해 저장 위치를 추상화합니다.

세 가지 표준 전략:

  • MODE_THREADLOCAL — 기본값. 현재 스레드에 바인딩된 ThreadLocal<SecurityContext>. 서블릿 모델처럼 요청 한 개가 한 스레드에서 처리되는 환경에 자연스러움.
  • MODE_INHERITABLETHREADLOCALInheritableThreadLocal을 사용해 자식 스레드가 부모 컨텍스트를 상속. 외부에서 자주 권장하지만, 스레드 풀에서는 위험합니다. 풀의 작업자 스레드는 한 번 만들어지면 재사용되므로, 첫 작업이 남긴 컨텍스트가 다음 사용자에게 새어 나갈 수 있습니다.
  • MODE_GLOBAL — 모든 스레드가 같은 단일 컨텍스트를 공유. 서버 환경에서는 기본적으로 부적합하며, 단일 사용자 데스크톱 애플리케이션에 한정됩니다.
flowchart LR
    Req[Request thread] --> Holder[SecurityContextHolder]
    Holder --> Strategy{Strategy}
    Strategy -->|THREADLOCAL default| TL[ThreadLocal&lt;SecurityContext&gt;]
    Strategy -->|INHERITABLE| ITL[InheritableThreadLocal]
    Strategy -->|GLOBAL| GS[Single static reference]
    TL --> Ctx[SecurityContext]
    Ctx --> Auth[Authentication]

@AsyncCompletableFuture.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 인 토큰이 만들어지고, 인증 후에는 같은 토큰의 principalUserDetails로 바뀌고 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는 자기가 처리할 토큰 타입을 선언합니다. DaoAuthenticationProviderUsernamePasswordAuthenticationToken만 받습니다.
  • 첫 비-null 응답 채택: Provider 하나가 인증을 끝내면 그 결과로 즉시 종료, 다른 Provider는 호출되지 않습니다.
  • 부모 매니저로 위임: 모든 Provider가 거부하면 parent가 있으면 그쪽에 다시 묻습니다. 글로벌 인증 매니저와 컨텍스트별 매니저를 계층화할 때 사용합니다.
  • 마지막 보루: 부모도 못 풀면 ProviderNotFoundException이 던져집니다. 이것은 AuthenticationException의 하위 타입이므로 ExceptionTranslationFilter에서 401로 번역됩니다.

DaoAuthenticationProvider의 내부는 단순합니다.

  1. UserDetailsService.loadUserByUsername(name)로 DB의 사용자 조회.
  2. 못 찾으면 UsernameNotFoundException (보통 BadCredentialsException으로 가려서 던집니다 — 사용자명 존재 여부 노출 회피).
  3. 찾으면 PasswordEncoder.matches(rawPassword, encodedPassword)로 비번 비교.
  4. 일치하면 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

참고자료

More from this blog

JVM은 컨테이너의 CPU와 메모리 한계를 어떻게 알아낼까

8코어 노드에 컨테이너를 띄웠는데 ForkJoinPool이 스레드를 한두 개만 만들어요. 메모리는 넉넉히 줬는데 컨테이너가 자꾸 OOMKilled로 죽고요. 분명히 같은 JAR인데 로컬에서는 멀쩡하다가 쿠버네티스에만 올리면 이상해져요. 이 글은 "왜 컨테이너 속 JVM은 다르게 행동하는가"를 cgroup이라는 진짜 경계선과, JVM이 그 경계를 읽어내는 내

May 21, 202615 min read

ThreadPoolExecutor는 언제 스레드를 새로 만들까 — execute()의 3단계

Executors.newFixedThreadPool(10) 한 줄을 쓰면서도, 11번째 작업이 오면 스레드가 11개로 늘어날 거라고 막연히 기대해 본 적 없으신가요. 실제로는 큐가 먼저 무한히 쌓이고 스레드는 영원히 10개에 머물러요. 이 글은 ThreadPoolExecutor가 작업을 받았을 때 "스레드를 새로 만들지, 큐에 넣을지, 거부할지"를 결정하는

May 21, 202617 min read

자바 synchronized는 어떻게 동작할까 — 모니터, 락 인플레이션, 그리고 사라진 biased locking

synchronized 키워드 하나로 스레드 안전을 얻는 동안, JVM 안에서는 객체 헤더의 비트를 뒤집고, 스택에 락 레코드를 쌓고, 경합이 생기면 네이티브 모니터로 승격하는 일이 벌어져요. 이 글은 그 한 번의 잠금이 객체 헤더부터 ObjectMonitor까지 어떤 경로를 거치는지, 그리고 한때 있었다가 JDK 18에서 사라진 biased locking

May 19, 202616 min read

JVM 객체 할당의 비밀 — TLAB, Bump-the-Pointer, 그리고 할당이 거의 공짜인 이유

Java에서 new를 호출하면 무슨 일이 벌어질까요? "힙에 메모리를 잡는다"는 한 문장 뒤에는 스레드마다 자기만의 분양 구역을 나눠 갖는 정교한 설계가 숨어 있어요. 이 글은 HotSpot JVM이 객체 할당을 어떻게 "거의 공짜"로 만드는지 그 내부를 따라가 보려는 글이에요. JVM 메모리 동작 원리에 관심 있는 분께 권해요. 자바를 쓰다 보면 객체를

May 15, 202614 min read

Java Zero-Copy — FileChannel.transferTo, sendfile, 그리고 Kafka가 디스크를 네트워크로 흘려보내는 방법

"파일을 읽어서 소켓으로 보낸다." 한 줄짜리 요구사항이에요. 그런데 이 한 줄 뒤에서 데이터는 메모리를 네 번이나 복사하고, CPU는 커널과 유저 공간을 네 번이나 들락거려요. Kafka처럼 초당 수십만 건을 흘려보내야 하는 시스템에서 이 비용은 그냥 넘길 수가 없어요. 이 글은 그 복사를 한 겹씩 벗겨내는 zero-copy의 동작 원리를 따라가요. 전통

May 15, 202617 min read

끄적끄적 테크 블로그

165 posts

물류 회사에 다니고 있는 개발자 블로그입니다. 개발을 너무 좋아해서 정신없이 작업하다가 중간에 끄적거리며 내용들을 몇개 적어봅니다 ㅎㅎ