요청이 컨트롤러에 닿기 전에 무슨 일이 일어나는가 — 필터체인 파헤치기
스프링 시큐리티를 의존성에 추가하는 순간 애플리케이션의 요청 처리 방식이 바뀝니다. 정확히는 서블릿 컨테이너와 DispatcherServlet 사이에 FilterChainProxy라는 서블릿 필터가 하나 끼어듭니다. 이 필터가 스프링 시큐리티의 진짜 몸통입니다. 나머지는 전부 이 필터를 설정하기 위한 API입니다.
FilterChainProxy는 필터들의 목록을 관리하는 필터다
이름부터 힌트가 있습니다. FilterChainProxy는 그 자체로 하나의 필터이면서, 내부에 여러 개의 SecurityFilterChain을 갖고 있습니다. 요청이 들어오면 등록된 체인 중 URL 패턴이 일치하는 첫 번째 체인을 찾아 그 안의 필터들을 순서대로 실행합니다.
flowchart TD
A["요청 도착"] --> B["FilterChainProxy"]
B --> C{"어떤 SecurityFilterChain의 패턴과 일치하는가"}
C -->|"/api/**"| D["체인 A의 필터들을 순서대로 실행"]
C -->|"그 외"| E["체인 B의 필터들을 순서대로 실행"]
D --> F["DispatcherServlet"]
E --> F체인 안의 필터 개수는 기본 설정만으로도 10개가 넘습니다. 전부 외울 필요는 없지만, 실무에서 계속 마주치는 세 개는 순서까지 기억해둘 가치가 있습니다.
| 필터 | 하는 일 | 실행 시점 |
|---|---|---|
SecurityContextHolderFilter |
이전 요청에서 저장된 인증 정보를 SecurityContext에 복원 |
가장 먼저 |
UsernamePasswordAuthenticationFilter |
폼 로그인 요청(/login)을 가로채 인증 시도 |
중간 |
AuthorizationFilter |
현재 인증 정보로 이 요청에 접근 권한이 있는지 최종 판단 | 가장 마지막 근처 |
여기서 짚고 넘어갈 오해가 하나 있습니다. "인증(authentication)"과 "인가(authorization)"는 서로 다른 필터가 담당합니다. 로그인해서 신원을 증명하는 일과, 그 신원으로 이 리소스에 접근해도 되는지 판단하는 일은 스프링 시큐리티 내부에서도 분리되어 있습니다. 401(인증 실패)과 403(인가 실패)이 다른 상황을 가리키는 이유가 여기 있고, 이 구분은 10장에서 디버깅할 때 그대로 쓰입니다.
커스텀 필터를 끼워넣는 법
JWT를 검증하는 필터처럼 스프링이 기본 제공하지 않는 로직은 직접 만든 필터를 체인 어딘가에 끼워넣어야 합니다. addFilterBefore로 위치를 지정합니다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http, JwtAuthFilter jwtAuthFilter) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)가 하는 말은 "내 필터를 UsernamePasswordAuthenticationFilter보다 앞에 실행해달라"는 것입니다. 여기서 위치를 틀리면 겪는 일이 상당히 헷갈립니다. JWT 필터를 AuthorizationFilter 뒤에 두면 인가 판단이 이미 끝난 뒤에 인증 정보를 채우는 꼴이라 항상 403이 납니다. 반대로 너무 앞에 두면 다른 필터가 준비해둔 상태를 못 쓰는 경우가 생깁니다. "일단 앞쪽 아무 데나 넣고 되면 넘어간다"는 접근은 나중에 필터를 하나 더 추가할 때 반드시 발목을 잡습니다. 어떤 필터가 무엇을 준비해두고 무엇을 기대하는지 알고 위치를 정해야 합니다.
authorizeHttpRequests 안의 규칙은 위에서부터 순서대로 평가되고, 먼저 일치하는 규칙이 이깁니다. .anyRequest().authenticated()를 맨 위에 써버리면 그 아래 어떤 permitAll()도 무의미해집니다. 실제로 자주 나는 실수입니다 — 회원가입 API를 나중에 추가하면서 규칙을 위에 안 넣고 밑에 넣어서 "분명 permitAll 했는데 401이 난다"고 몇 시간을 헤매는 경우를 여러 번 봤습니다.
요청 하나의 전체 여정
폼 로그인 요청 하나가 필터 체인을 통과하는 과정을 순서대로 보면 이렇습니다.
sequenceDiagram participant C as 클라이언트 participant F as UsernamePasswordAuthenticationFilter participant M as AuthenticationManager participant P as AuthenticationProvider participant D as UserDetailsService C->>F: POST /login (username, password) F->>M: authenticate(token) M->>P: 등록된 Provider에 위임 P->>D: loadUserByUsername(username) D-->>P: UserDetails 반환 P->>P: 비밀번호 비교 P-->>M: 인증된 Authentication 객체 M-->>F: 인증 성공 F->>F: SecurityContext에 저장, 세션에 반영
AuthenticationManager는 직접 인증하지 않습니다. 등록된 AuthenticationProvider 목록에 위임할 뿐입니다. 이 구조 덕분에 "아이디/비밀번호로 인증"과 "OAuth2 토큰으로 인증" 같은 서로 다른 인증 방식을 같은 AuthenticationManager 아래 여러 AuthenticationProvider로 공존시킬 수 있습니다. 8장에서 OAuth2를 붙일 때 이 구조를 다시 보게 됩니다.