Spring Filter Chain 등록·순서 심화

Servlet Filter와 Spring의 관계

Spring MVC의 모든 HTTP 요청은 Servlet Filter 체인을 거칩니다. Spring Security, 로깅, 압축, CORS 처리 모두 Filter로 동작합니다. 하지만 Filter의 등록 순서, 실행 흐름, Spring Bean과의 통합 방식을 정확히 이해하지 못하면 보안 필터가 무시되거나 로깅이 누락되는 문제가 발생합니다.

이 글에서는 Filter 등록 3가지 방식, 실행 순서 제어, DelegatingFilterProxy의 내부 동작, OncePerRequestFilter 패턴, 그리고 Spring Security Filter Chain과의 통합까지 심층적으로 다룹니다.

Filter 실행 흐름

요청이 컨트롤러에 도달하기까지의 전체 흐름:

HTTP 요청
  ↓
[Servlet Container (Tomcat)]
  ↓
Filter 1 (CharacterEncodingFilter)
  ↓
Filter 2 (DelegatingFilterProxy → SecurityFilterChain)
  ↓
Filter 3 (커스텀 로깅 필터)
  ↓
[DispatcherServlet]
  ↓
HandlerInterceptor.preHandle()
  ↓
Controller → Service → Repository
  ↓
HandlerInterceptor.postHandle()
  ↓
Filter 3 (후처리)
  ↓
Filter 2 (후처리)
  ↓
Filter 1 (후처리)
  ↓
HTTP 응답

핵심: Filter는 DispatcherServlet 이전에 실행되며, 체인 패턴으로 요청·응답 양방향 처리가 가능합니다.

Filter 등록 3가지 방식

// 방식 1: FilterRegistrationBean (순서 제어 가능, 권장)
@Configuration
public class FilterConfig {

    @Bean
    public FilterRegistrationBean<RequestIdFilter> requestIdFilter() {
        var registration = new FilterRegistrationBean<>();
        registration.setFilter(new RequestIdFilter());
        registration.addUrlPatterns("/api/*");      // 특정 경로만
        registration.setOrder(Ordered.HIGHEST_PRECEDENCE);  // 최우선 실행
        registration.setName("requestIdFilter");
        return registration;
    }

    @Bean
    public FilterRegistrationBean<SlowRequestFilter> slowRequestFilter() {
        var registration = new FilterRegistrationBean<>();
        registration.setFilter(new SlowRequestFilter());
        registration.addUrlPatterns("/*");
        registration.setOrder(Ordered.HIGHEST_PRECEDENCE + 1);  // 두 번째
        return registration;
    }
}

// 방식 2: @Component + @Order (간편하지만 URL 패턴 제어 불가)
@Component
@Order(1)
public class SimpleFilter implements Filter {
    @Override
    public void doFilter(ServletRequest req, ServletResponse res,
                         FilterChain chain) throws IOException, ServletException {
        // 모든 경로에 적용됨
        chain.doFilter(req, res);
    }
}

// 방식 3: @WebFilter + @ServletComponentScan (Servlet 표준)
@WebFilter(urlPatterns = "/api/*", filterName = "legacyFilter")
@Order(10)
public class LegacyFilter implements Filter {
    // @Order가 무시될 수 있음 — 권장하지 않음
}

OncePerRequestFilter: 중복 실행 방지

Forward, Include 등으로 같은 요청이 Filter를 여러 번 통과할 수 있습니다. OncePerRequestFilter는 요청당 정확히 한 번만 실행을 보장합니다:

// Request ID 부여 필터
public class RequestIdFilter extends OncePerRequestFilter {

    private static final String REQUEST_ID_HEADER = "X-Request-ID";
    private static final String REQUEST_ID_ATTRIBUTE = "requestId";

    @Override
    protected void doFilterInternal(HttpServletRequest request,
            HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {

        String requestId = request.getHeader(REQUEST_ID_HEADER);
        if (requestId == null || requestId.isBlank()) {
            requestId = UUID.randomUUID().toString().substring(0, 8);
        }

        // 요청 속성에 저장 (컨트롤러에서 접근 가능)
        request.setAttribute(REQUEST_ID_ATTRIBUTE, requestId);

        // 응답 헤더에 추가
        response.setHeader(REQUEST_ID_HEADER, requestId);

        // MDC에 설정 (로깅에 자동 포함)
        MDC.put("requestId", requestId);
        try {
            chain.doFilter(request, response);
        } finally {
            MDC.remove("requestId");
        }
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        // 헬스체크 경로는 필터 스킵
        return request.getRequestURI().startsWith("/actuator");
    }
}

요청·응답 래핑 필터

요청 본문을 로깅하거나 응답을 수정하려면 래퍼 객체를 사용해야 합니다. 스트림은 한 번만 읽을 수 있기 때문입니다:

// 요청 본문 캐싱 래퍼
public class CachedBodyRequestWrapper extends HttpServletRequestWrapper {
    private final byte[] cachedBody;

    public CachedBodyRequestWrapper(HttpServletRequest request) throws IOException {
        super(request);
        this.cachedBody = request.getInputStream().readAllBytes();
    }

    @Override
    public ServletInputStream getInputStream() {
        return new CachedBodyInputStream(cachedBody);
    }

    @Override
    public BufferedReader getReader() {
        return new BufferedReader(
            new InputStreamReader(getInputStream(), StandardCharsets.UTF_8));
    }

    public String getBody() {
        return new String(cachedBody, StandardCharsets.UTF_8);
    }
}

// 요청·응답 로깅 필터
public class HttpLoggingFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request,
            HttpServletResponse response, FilterChain chain)
            throws ServletException, IOException {

        // 요청 래핑 (본문 재읽기 가능)
        var wrappedRequest = new CachedBodyRequestWrapper(request);

        // 응답 래핑 (본문 캡처)
        var wrappedResponse = new ContentCachingResponseWrapper(response);

        long start = System.currentTimeMillis();

        try {
            chain.doFilter(wrappedRequest, wrappedResponse);
        } finally {
            long duration = System.currentTimeMillis() - start;

            log.info("HTTP {} {} → {} ({}ms) | body: {}",
                request.getMethod(),
                request.getRequestURI(),
                wrappedResponse.getStatus(),
                duration,
                truncate(wrappedRequest.getBody(), 500)
            );

            // 반드시 호출! 안 하면 응답 본문이 비어서 전송됨
            wrappedResponse.copyBodyToResponse();
        }
    }

    @Override
    protected boolean shouldNotFilter(HttpServletRequest request) {
        String uri = request.getRequestURI();
        return uri.startsWith("/actuator") || uri.startsWith("/static");
    }

    private String truncate(String s, int max) {
        return s.length() > max ? s.substring(0, max) + "..." : s;
    }
}

DelegatingFilterProxy 내부 동작

Spring Security의 핵심 연결 고리인 DelegatingFilterProxy의 동작 원리:

// Servlet Container는 Spring Bean을 모릅니다.
// DelegatingFilterProxy가 이 둘을 연결합니다:

// 1. Servlet Container에 "springSecurityFilterChain" 이름으로 등록
// 2. 요청이 오면 ApplicationContext에서 같은 이름의 Bean을 찾음
// 3. 찾은 Bean(FilterChainProxy)에 요청을 위임

// FilterChainProxy는 여러 SecurityFilterChain을 관리
// 요청 URL에 따라 적절한 체인 선택

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    // /api/** 요청용 필터 체인
    @Bean
    @Order(1)
    public SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
        return http
            .securityMatcher("/api/**")
            .csrf(csrf -> csrf.disable())
            .sessionManagement(sm -> sm.sessionCreationPolicy(STATELESS))
            .addFilterBefore(new JwtAuthFilter(), UsernamePasswordAuthenticationFilter.class)
            .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
            .build();
    }

    // 웹 페이지용 필터 체인
    @Bean
    @Order(2)
    public SecurityFilterChain webFilterChain(HttpSecurity http) throws Exception {
        return http
            .securityMatcher("/**")
            .formLogin(Customizer.withDefaults())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/login", "/static/**").permitAll()
                .anyRequest().authenticated())
            .build();
    }
}

Filter vs Interceptor vs AOP

특성 Filter Interceptor AOP
실행 시점 DispatcherServlet 이전 Handler 실행 전후 메서드 실행 전후
Spring Bean 접근 DelegatingFilterProxy 필요 ✅ 직접 주입 ✅ 직접 주입
요청/응답 조작 ✅ 래퍼로 가능 제한적
적합한 용도 보안, 인코딩, 압축 인증, 로깅, 권한 트랜잭션, 캐싱, 감사
URL 패턴 지정 ❌ (어노테이션/패키지 기반)

프로덕션 필터 순서 가이드

// 권장 필터 실행 순서
// 1. Request ID 부여 (최우선)
// 2. MDC/로깅 컨텍스트 설정
// 3. CORS 처리
// 4. Security Filter Chain
// 5. 요청 로깅 (보안 필터 이후 → 인증 정보 포함)
// 6. Rate Limiting
// 7. 압축 (GZip)

@Bean
public FilterRegistrationBean<RequestIdFilter> requestIdFilter() {
    var reg = new FilterRegistrationBean<>(new RequestIdFilter());
    reg.setOrder(Ordered.HIGHEST_PRECEDENCE);
    return reg;
}

@Bean
public FilterRegistrationBean<HttpLoggingFilter> loggingFilter() {
    var reg = new FilterRegistrationBean<>(new HttpLoggingFilter());
    reg.setOrder(Ordered.HIGHEST_PRECEDENCE + 10);  // Security 이후
    reg.addUrlPatterns("/api/*");
    return reg;
}

마무리

Spring Filter Chain은 HTTP 요청 처리의 첫 번째 관문입니다. FilterRegistrationBean으로 순서를 명시적으로 제어하고, OncePerRequestFilter로 중복 실행을 방지하며, ContentCachingResponseWrapper로 응답을 안전하게 캡처하면 견고한 필터 체인을 구축할 수 있습니다.

관련 글로 Spring HandlerInterceptor 심화Spring Security Filter Chain도 함께 참고하세요.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux