필터는 요청이 들어왔을 때 가장 먼저 손대는 자리다. DispatcherServlet도, 컨트롤러도, 스프링 빈도 아직 없다. 서블릿 컨테이너(Tomcat 등) 레벨에서 동작하기 때문이다. 이 위치가 필터의 성격을 결정하고, CORS 처리와 보안 토큰 검증이 인터셉터가 아니라 필터에 사는 이유를 설명한다.

서블릿(Servlet)

클라이언트의 HTTP 요청을 처리하고 응답을 생성하는 자바 서버 측 컴포넌트. Java EE(현 Jakarta EE) 표준의 핵심이며 대부분의 자바 웹 프레임워크의 기반이다.

필터가 잡는 범위

필터의 적용 범위는 모든 요청이다. 컨트롤러로 가는 요청만이 아니라 정적 리소스 요청도, 잘못된 경로로 들어와 에러로 빠지는 요청도 전부 필터를 통과한다. DispatcherServlet 앞단에 있기 때문이다. 예외 없이 모든 요청에 한 번은 적용해야 하는 일이 필터의 영역이다. 문자 인코딩 통일, CORS 헤더, 보안 토큰 검증, 응답 압축, 요청/응답 로깅이 여기 해당한다.

필터는 ServletRequest/ServletResponse 자체를 다룬다. 요청 본문을 읽거나, 응답 스트림을 감싸서(wrapping) 가공하거나, 다음 단계로 넘기는 객체를 통째로 교체할 수 있다. 컨트롤러나 ModelAndView 같은 스프링 개념은 보이지 않고, HTTP 요청/응답의 원초적인 형태를 만진다.

doFilter

필터의 본체는 doFilter 한 메서드다. filterChain.doFilter(request, response)를 기준으로 위가 전처리, 아래가 후처리다.

public class CustomFilter extends GenericFilterBean {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain filterChain)
            throws IOException, ServletException {
        // 전처리: 다음 단계로 가기 전
        filterChain.doFilter(request, response); // 다음 필터 또는 서블릿으로
        // 후처리: 응답이 돌아온 뒤
    }
}

GenericFilterBeanjavax.servlet.Filter를 구현한 스프링의 추상 클래스로, 스프링 설정과 통합된다. 실무에서는 보통 OncePerRequestFilter를 쓴다. 서블릿 컨테이너 내부에서 forward나 include가 일어나 같은 요청이 필터 체인을 여러 번 지나가더라도, 필터 로직을 정확히 요청당 한 번만 실행하도록 보장한다. 인증이나 로깅처럼 요청당 한 번이어야 하는 작업에 필수다.

public class CustomFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        // 전처리
        filterChain.doFilter(request, response);
        // 후처리
    }
}

흔한 버그는 filterChain.doFilter(...) 호출을 빠뜨리는 것이다. 그러면 요청이 다음 단계로 전달되지 않고 멈춘다. 응답은 비고 컨트롤러는 호출되지 않는다.

체인과 순서

컨테이너는 등록된 필터를 체인으로 순차 실행한다. 모든 필터의 전처리를 지나 서블릿을 호출하고, 처리가 끝나면 역순으로 후처리가 돈다(Chain of Responsibility). 순서는 @OrderFilterRegistrationBean.setOrder로 지정하며, 숫자가 낮을수록 먼저 실행된다.

@Order(1)
public class FirstFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        filterChain.doFilter(request, response);
    }
}

등록 방법은 두 가지다. @Component로 자동 등록하거나, FilterRegistrationBean으로 명시적으로 등록한다. 후자는 특정 URL 패턴에만 적용하거나 순서를 못 박을 때 쓴다.

@Configuration
public class FilterConfig {
    @Bean
    public FilterRegistrationBean<LoggingFilter> loggingFilter() {
        FilterRegistrationBean<LoggingFilter> bean = new FilterRegistrationBean<>();
        bean.setFilter(new LoggingFilter());
        bean.addUrlPatterns("/*");
        bean.setOrder(1);
        return bean;
    }
}

CORS와 보안이 필터에 사는 이유

필터의 위치가 쓰임새를 결정한다. 두 예시로 확인한다.

CORS는 preflight인 OPTIONS 요청을 컨트롤러에 닿기 전에 처리해야 한다. OPTIONS는 보통 매핑된 컨트롤러가 없어, DispatcherServlet 안쪽에서 가로채려 하면 이미 늦다. 필터에서 응답 헤더를 붙이고 즉시 끝낸다.

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class CorsFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        response.setHeader("Access-Control-Allow-Origin", "*");
        response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
        response.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");

        if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
            response.setStatus(HttpServletResponse.SC_OK);
            return; // 더 진행하지 않고 종료
        }

        filterChain.doFilter(request, response);
    }
}

보안 토큰 검증도 같은 이유로 필터에 산다. 인증은 컨트롤러가 선택되기 전 가능한 한 일찍 끝나야 한다. Spring Security가 인증 로직을 인터셉터가 아니라 필터 체인으로 구현하는 것도 이 때문이다. 다음은 JWT를 검증해 인증 정보를 SecurityContext에 넣는 필터다.

public class JwtAuthenticationFilter extends OncePerRequestFilter {

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
                                    FilterChain filterChain) throws ServletException, IOException {
        String token = request.getHeader("Authorization");

        if (token != null && token.startsWith("Bearer ")) {
            token = token.substring(7);
            if (jwtUtil.validateToken(token)) {
                String username = jwtUtil.getUsernameFromToken(token);
                UsernamePasswordAuthenticationToken authentication =
                    new UsernamePasswordAuthenticationToken(username, null, Collections.emptyList());
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }
        }

        filterChain.doFilter(request, response);
    }
}

필터와 인터셉터

요청 처리 흐름에서 둘의 위치를 보면 차이가 드러난다.

클라이언트 요청
    ↓
Filter (전처리)         ← 서블릿 컨테이너 레벨
    ↓
DispatcherServlet
    ↓
Interceptor (preHandle) ← 스프링 컨텍스트 레벨, 이미 컨트롤러가 정해짐
    ↓
Controller
    ↓
Interceptor (postHandle / afterCompletion)
    ↓
Filter (후처리)
    ↓
클라이언트 응답

필터는 DispatcherServlet 바깥이라 모든 요청에 닿고 ServletRequest/Response를 직접 가공하지만, 컨트롤러가 무엇인지 모르고 @ControllerAdvice 예외 처리도 받지 못한다. 인터셉터는 안쪽이라 어떤 핸들러가 실행될지 알고 스프링 기능을 쓸 수 있다. 스프링과 무관하게 모든 요청을 가장 먼저 잡아야 하면 필터를 쓴다.

구현 시 주의점

  • 순서가 동작을 바꾼다. @Order/setOrder로 명시적으로 관리한다.
  • doFilter 호출을 빠뜨리면 요청이 멈춘다. 전처리 후 다음으로 넘긴다(의도적으로 끊는 CORS preflight는 예외).
  • 필터 예외는 @ControllerAdvice가 잡지 못한다. 필터는 스프링 MVC 바깥이므로 내부에서 try-catch로 처리한다.
  • 모든 요청을 타므로 무겁게 만들지 않는다. URL 패턴이나 조건으로 필요한 경로에만 적용한다.