Spring Security Filter Chain이란?
Spring Security의 모든 보안 로직은 서블릿 필터 체인 위에서 동작한다. 인증, 인가, CSRF, CORS, 세션 관리 등이 각각 독립된 필터로 구현되어 순서대로 실행된다. Filter Chain의 구조와 커스텀 필터 삽입 방법을 정확히 이해해야 유연한 보안 설계가 가능하다.
1. SecurityFilterChain 구조
Spring Security 6 기준, SecurityFilterChain 빈을 등록하는 방식으로 설정한다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(ex -> ex
.authenticationEntryPoint(jwtAuthEntryPoint)
.accessDeniedHandler(customAccessDeniedHandler)
)
.build();
}
}
실제로 요청이 통과하는 필터 순서는 다음과 같다:
DisableEncodeUrlFilter
WebAsyncManagerIntegrationFilter
SecurityContextHolderFilter
HeaderWriterFilter
CorsFilter
CsrfFilter
LogoutFilter
UsernamePasswordAuthenticationFilter ← JWT 필터를 여기 앞에 삽입
BasicAuthenticationFilter
RequestCacheAwareFilter
SecurityContextHolderAwareRequestFilter
AnonymousAuthenticationFilter
SessionManagementFilter
ExceptionTranslationFilter
AuthorizationFilter
2. 커스텀 JWT 필터 구현
가장 흔한 패턴인 JWT 인증 필터를 구현한다.
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider tokenProvider;
private final UserDetailsService userDetailsService;
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
String token = resolveToken(request);
if (token != null && tokenProvider.validateToken(token)) {
String username = tokenProvider.getUsername(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities()
);
authentication.setDetails(
new WebAuthenticationDetailsSource().buildDetails(request)
);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
filterChain.doFilter(request, response);
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getRequestURI();
return path.startsWith("/api/auth/");
}
private String resolveToken(HttpServletRequest request) {
String bearer = request.getHeader("Authorization");
if (bearer != null && bearer.startsWith("Bearer ")) {
return bearer.substring(7);
}
return null;
}
}
OncePerRequestFilter를 상속하면 요청당 한 번만 실행되고, shouldNotFilter로 특정 경로를 건너뛸 수 있다. Spring Security JWT 기본 설정은 Spring Security JWT 인증 글을 참고하자.
3. 다중 SecurityFilterChain
API와 웹 페이지에 서로 다른 보안 정책을 적용하려면 여러 SecurityFilterChain을 등록한다.
@Configuration
@EnableWebSecurity
public class MultiSecurityConfig {
// API용 (JWT, Stateless)
@Bean
@Order(1)
public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
return http
.securityMatcher("/api/**")
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(STATELESS))
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.build();
}
// 웹 페이지용 (세션, 폼 로그인)
@Bean
@Order(2)
public SecurityFilterChain webFilterChain(HttpSecurity http) throws Exception {
return http
.securityMatcher("/web/**")
.formLogin(form -> form
.loginPage("/web/login")
.defaultSuccessUrl("/web/dashboard")
)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/web/login").permitAll()
.anyRequest().authenticated()
)
.build();
}
// 정적 리소스 (보안 우회)
@Bean
@Order(0)
public SecurityFilterChain staticFilterChain(HttpSecurity http) throws Exception {
return http
.securityMatcher("/css/**", "/js/**", "/images/**")
.authorizeHttpRequests(auth -> auth.anyRequest().permitAll())
.build();
}
}
@Order 값이 낮을수록 우선 매칭된다. securityMatcher로 각 체인의 적용 범위를 명확히 분리해야 한다.
4. 필터 삽입 위치 제어
| 메서드 | 동작 | 사용 시나리오 |
|---|---|---|
addFilterBefore(A, B) |
B 필터 앞에 A 삽입 | JWT를 인증 필터 앞에 |
addFilterAfter(A, B) |
B 필터 뒤에 A 삽입 | 로깅을 인증 후에 |
addFilterAt(A, B) |
B 위치에 A로 교체 | 커스텀 인증 로직 대체 |
// 요청 로깅 필터
@Component
@Slf4j
public class RequestLoggingFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response,
FilterChain chain
) throws ServletException, IOException {
long start = System.currentTimeMillis();
String requestId = UUID.randomUUID().toString().substring(0, 8);
MDC.put("requestId", requestId);
response.setHeader("X-Request-Id", requestId);
try {
chain.doFilter(request, response);
} finally {
long duration = System.currentTimeMillis() - start;
log.info("[{}] {} {} → {} ({}ms)",
requestId, request.getMethod(), request.getRequestURI(),
response.getStatus(), duration);
MDC.clear();
}
}
}
// 보안 필터 체인 앞에 삽입
http.addFilterBefore(requestLoggingFilter, DisableEncodeUrlFilter.class)
5. ExceptionTranslationFilter 에러 처리
필터 체인에서 발생하는 예외는 @ControllerAdvice로 잡히지 않는다. AuthenticationEntryPoint와 AccessDeniedHandler를 커스텀해야 한다.
@Component
public class CustomAuthEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void commence(
HttpServletRequest request,
HttpServletResponse response,
AuthenticationException ex
) throws IOException {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
Map<String, Object> body = Map.of(
"status", 401,
"error", "Unauthorized",
"message", "인증이 필요합니다",
"path", request.getRequestURI()
);
objectMapper.writeValue(response.getOutputStream(), body);
}
}
@Component
public class CustomAccessDeniedHandler implements AccessDeniedHandler {
private final ObjectMapper objectMapper = new ObjectMapper();
@Override
public void handle(
HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException ex
) throws IOException {
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding("UTF-8");
Map<String, Object> body = Map.of(
"status", 403,
"error", "Forbidden",
"message", "접근 권한이 없습니다",
"path", request.getRequestURI()
);
objectMapper.writeValue(response.getOutputStream(), body);
}
}
전역 예외 처리와의 차이점은 Spring 전역 예외 처리 설계 글을 참고하자.
6. 필터 디버깅
# application.yml — 필터 체인 로그 활성화
logging:
level:
org.springframework.security: DEBUG
org.springframework.security.web.FilterChainProxy: TRACE
TRACE 레벨을 켜면 각 요청이 어떤 필터를 통과하는지 순서대로 로그에 출력된다. 운영 환경에서는 반드시 끌 것.
마무리
Spring Security의 Filter Chain은 보안의 근간이다. 커스텀 필터의 삽입 위치를 정확히 제어하고, 다중 체인으로 API·웹·정적 리소스를 분리하며, 필터 레벨 예외 처리를 구현하면 유연하고 견고한 보안 아키텍처를 구축할 수 있다.