웹 서비스를 만들다 보면 로그인 여부 확인, 요청 로그 남기기, 언어 인코딩 설정처럼 모든 API에 공통으로 들어가야 하는 작업이 생깁니다. 이런 코드를 컨트롤러마다 반복해서 넣는 것은 번거롭고 누락하기도 쉬운데요. 건물에 들어갈 때 출입구에서 신분증을 한 번만 확인하듯, 요청이 컨트롤러에 도착하기 전에 공통 작업을 한 번에 처리하는 도구가 스프링에는 두 가지 있습니다. 바로 서블릿 필터(Filter)와 스프링 인터셉터(Interceptor)입니다.
이번 글은 먼저 서블릿 필터에 대해 써보려고 합니다. 필터가 무엇인지, 어디에서 동작하는지, 어떻게 구현하고 등록하는지, 그리고 자주 쓰는 패턴까지 정리해보도록 하겠습니다.
[Spring] 스프링 인터셉터(Interceptor)란? 개념과 사용법, 등록 방법 총정리
[Spring] 스프링 필터(Filter) vs 인터셉터(Interceptor) 차이점과 선택 기준 총정리
요청이 컨트롤러에 도달하기까지
필터를 이해하려면 먼저 스프링에서 요청이 어떤 순서로 처리되는지 알아야 합니다. 클라이언트가 보낸 요청은 다음 순서로 흘러갑니다.

여기서 핵심은 필터가 가장 먼저 실행된다는 점입니다. 필터는 스프링 바깥쪽, 즉 톰캣 같은 서블릿 컨테이너 단계에서 동작합니다. 반면 인터셉터는 스프링 내부(DispatcherServlet 안)에서 동작하죠. 이 위치 차이가 두 도구의 특성을 대부분 결정합니다. 또 하나 중요한 점은 흐름이 양방향이라는 것입니다. 요청이 들어올 때뿐 아니라 응답이 나갈 때도 필터를 거치기 때문에 요청 전처리와 응답 후처리를 한 쌍으로 묶어서 처리할 수 있겠습니다.
서블릿 필터란?
필터는 자바 서블릿 스펙의 표준 기능입니다. Servlet 2.3부터 등장했으며, jakarta.servlet.Filter 인터페이스를 구현하면 됩니다. 스프링이 없는 일반 서블릿 환경에서도 동작하므로 스프링의 기능이 아니라 자바 웹 표준 레벨의 기능이라고 이해하면 좋습니다.

여기서는 적용 대상이 모든 요청이라는 점을 기억해 두시면 좋을것 같아요. 컨트롤러로 라우팅되는 요청만 거치는 인터셉터와 달리, 필터는 정적 리소스 요청이나 에러 페이지 요청까지 전부 거칩니다.
Filter 인터페이스의 세 가지 메서드

Filter 인터페이스에는 위의 세 가지 메서드가 정의되어 있습니다.
public interface Filter {
default void init(FilterConfig filterConfig) throws ServletException {}
void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException;
default void destroy() {}
}
init()의 파라미터인 FilterConfig는 필터의 설정 정보를 담고 있는 객체입니다. web.xml로 필터를 등록하던 시절에는 XML에 적어둔 초기화 파라미터를 이 객체로 꺼내 썼습니다. 자바 설정으로 등록하는 요즘에는 직접 다룰 일이 많지 않아졌어요.
실제 처리 로직은 doFilter()에 작성합니다. init()과 destroy()는 서버 생명주기 동안 한 번씩만 호출되지만, doFilter()는 요청마다 호출되기 때문에 인증이나 로깅 같은 부가 작업은 모두 이 메서드에서 이루어집니다.
필터 구현하기
요청 처리 시간을 측정하는 간단한 로깅 필터를 만들어 보겠습니다.
import jakarta.servlet.*;
import java.io.IOException;
public class RequestLoggingFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
long start = System.currentTimeMillis();
// 요청 처리 전 로직
chain.doFilter(request, response); // 다음 필터 또는 서블릿으로 요청 전달
// 응답 처리 후 로직
long elapsed = System.currentTimeMillis() - start;
System.out.println("처리 시간: " + elapsed + "ms");
}
}
여기서 두 가지 포인트가 중요합니다.
- 첫째, chain.doFilter()를 호출하지 않으면 요청이 멈춥니다. 이 호출은 "다음 필터나 서블릿에게 요청을 넘겨라"라는 뜻입니다. 이 줄이 빠지면 요청은 컨트롤러까지 도달하지 못하고, 클라이언트는 응답을 받지 못한 채 타임아웃이 납니다.
- 둘째, chain.doFilter() 앞과 뒤에 코드를 배치할 수 있습니다. 앞에 둔 코드는 요청이 컨트롤러로 가기 전에, 뒤에 둔 코드는 응답이 모두 만들어진 후에 실행됩니다. 한 메서드 안에서 전처리와 후처리를 함께 다룰 수 있는 구조입니다.
chain.doFilter()를 꼭 호출해야 하는 이유
왜 다음 필터 호출을 개발자가 직접 해야 하는지, 내부 구조를 보면 이해가 됩니다. FilterChain의 실제 구현체는 톰캣의 ApplicationFilterChain입니다. 이 클래스는 등록된 필터들을 배열로 들고 있고, 현재 몇 번째 필터를 실행 중인지를 pos라는 인덱스로 관리합니다.
ApplicationFilterChain.doFilter()
→ pos가 n(전체 필터 개수)보다 작으면
filters[pos++]의 doFilter(request, response, this) 호출
→ 해당 필터가 내부에서 chain.doFilter()를 호출하면
다시 ApplicationFilterChain.doFilter()로 돌아와 pos를 증가시키며 다음 필터 실행
→ 모든 필터가 끝나면 servlet.service() 호출 → DispatcherServlet 동작
즉 각 필터가 chain.doFilter()를 호출해야만 ApplicationFilterChain이 다시 제어권을 받아 pos를 증가시키고 다음 필터를 실행합니다. 이 호출이 빠지면 체인이 그 자리에서 끊어지는 것입니다. 참고로 테스트에서 필터를 단독으로 돌릴 때는 MockFilterChain이 대신 사용됩니다.
필터 등록 방법 세 가지

필터를 구현했으면 서블릿 컨테이너에 등록해야 합니다. 스프링 부트에서는 세 가지 방법이 있습니다.
@Component
@Component
public class RequestLoggingFilter implements Filter {
// ...
}
가장 간단한 방법입니다. 스프링 부트가 Filter 타입의 빈을 발견하면 자동으로 필터로 등록해 줍니다. 다만 모든 URL에 적용되고, 여러 필터가 있을 때 순서를 지정하기 어렵다는 한계가 있습니다.
@WebFilter + @ServletComponentScan
@WebFilter(urlPatterns = "/api/*")
public class RequestLoggingFilter implements Filter {
// ...
}
@ServletComponentScan
@SpringBootApplication
public class Application { ... }
@WebFilter는 서블릿 표준 어노테이션으로, urlPatterns 속성으로 적용할 URL을 지정할 수 있습니다. 이 어노테이션이 동작하려면 메인 클래스에 @ServletComponentScan을 붙여야 합니다. 이 어노테이션은 @ComponentScan처럼 클래스를 스캔하되, 대상이 @WebFilter, @WebServlet, @WebListener 같은 서블릿 객체들이고 이들을 서블릿 컨테이너에 올려주는 역할을 합니다.
주의할 점은 스프링 컨테이너가 아닌 서블릿 컨테이너에 등록되는 것이기 때문에 @Order 어노테이션이 적용되지 않습니다. @WebFilter 자체에도 순서를 지정하는 속성이 없어서 실행 순서를 제어할 수 없습니다.
FilterRegistrationBean (권장)
@Configuration
public class FilterConfig {
@Bean
public FilterRegistrationBean<RequestLoggingFilter> loggingFilter() {
FilterRegistrationBean<RequestLoggingFilter> registration = new FilterRegistrationBean<>();
registration.setFilter(new RequestLoggingFilter());
registration.addUrlPatterns("/api/*");
registration.setOrder(1);
registration.setName("requestLoggingFilter");
return registration;
}
}
FilterRegistrationBean은 필터의 적용 범위를 가장 세밀하게 제어할 수 있는 방법입니다.
setFilter(): 등록할 필터 인스턴스를 지정합니다.addUrlPatterns(): 적용할 URL 패턴을 지정합니다.setOrder(): 여러 필터가 있을 때 실행 순서를 정합니다. 숫자가 작을수록 먼저 실행됩니다.setName(): 필터 이름을 지정합니다.
URL과 순서를 모두 제어해야 하는 실무 환경에서는 FilterRegistrationBean으로 명시적으로 등록하는 것을 권장합니다.
실무 예시: Trace ID 필터
필터가 가장 빛을 발하는 사례 중 하나가 요청 추적용 Trace ID입니다. 같은 요청에서 발생한 모든 로그를 하나의 ID로 묶어주는 패턴입니다.
import org.slf4j.MDC;
import jakarta.servlet.*;
import java.io.IOException;
import java.util.UUID;
public class TraceIdFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
String traceId = UUID.randomUUID().toString().substring(0, 8);
MDC.put("traceId", traceId);
try {
chain.doFilter(request, response);
} finally {
MDC.clear(); // 반드시 정리
}
}
}
MDC(Mapped Diagnostic Context)는 스레드 로컬 기반의 로그 컨텍스트입니다. MDC에 값을 넣어두면 해당 스레드에서 남기는 모든 로그에 자동으로 그 값이 포함됩니다. 로그 패턴에 %X{traceId}를 추가하면 됩니다.
<pattern>%d{HH:mm:ss.SSS} [%X{traceId}] %-5level %logger{36} - %msg%n</pattern>
이렇게 하면 같은 요청에서 나온 로그가 전부 같은 traceId로 묶여 장애 추적이 훨씬 쉬워집니다.
여기서 try / finally로 MDC.clear()를 호출하는 것이 핵심 패턴입니다. 서블릿 컨테이너는 스레드를 재사용하기 때문에, MDC를 지우지 않으면 다음 요청의 로그에 이전 요청의 traceId가 섞여 나옵니다.
이런 작업은 인터셉터보다 필터가 적합합니다. 스프링 바깥 영역에서 발생하는 로그까지 traceId에 포함시키려면 가능한 한 바깥쪽에서 감싸야 하기 때문입니다.
필터에서 스프링 빈이 필요하다면
필터는 서블릿 컨테이너가 관리하기 때문에 원칙적으로 스프링 빈을 직접 주입받기 어렵습니다. 이때 스프링이 제공하는 DelegatingFilterProxy를 사용합니다. 서블릿 컨테이너에는 프록시 필터만 등록해 두고, 실제 처리는 스프링이 관리하는 빈에게 위임하는 방식입니다. 스프링 시큐리티의 springSecurityFilterChain이 바로 이 패턴으로 동작합니다.
다만 스프링 부트에서 FilterRegistrationBean으로 등록하는 경우에는 필터 자체를 @Bean으로 만들어 생성자에서 다른 빈을 받을 수 있으므로, 대부분의 상황에서는 이 방식만으로도 충분합니다.
자주 하는 실수
chain.doFilter() 호출 누락
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
System.out.println("요청 로깅");
// chain.doFilter(request, response); ← 이 줄이 빠지면 요청이 여기서 멈춥니다
}
가장 흔한 실수입니다. 로그만 찍고 다음 단계로 넘기는 호출이 없으면 요청이 컨트롤러에 도달하지 못하고, 클라이언트는 영원히 응답을 받지 못합니다. 의도적으로 요청을 차단하는 경우(인증 실패 등)가 아니라면 chain.doFilter()는 반드시 있어야 합니다.
MDC 정리 누락
앞서 본 것처럼 finally 블록에서 MDC.clear()를 호출하지 않으면 스레드 재사용 시 이전 요청의 값이 남아 있게 됩니다. 스레드 로컬에 값을 넣는 모든 필터는 반드시 정리 코드를 함께 작성해야 합니다.
정리
- 필터는 서블릿 표준 스펙이며, DispatcherServlet 앞 서블릿 컨테이너 레벨에서 동작합니다.
- 정적 리소스를 포함한 모든 요청을 거칩니다.
init()/doFilter()/destroy()중 실제 로직은 요청마다 호출되는doFilter()에 작성합니다.chain.doFilter()를 호출해야 다음 필터로 넘어갑니다. 누락하면 요청이 멈춥니다.- 등록은
FilterRegistrationBean을 사용하면 URL과 순서를 모두 제어할 수 있습니다. - 인코딩, CORS, Trace ID, 요청/응답 래핑처럼 스프링 바깥 영역까지 커버해야 하는 작업에 적합합니다.
다음 글에서는 스프링 MVC 내부에서 동작하는 인터셉터의 개념과 사용법을 다루겠습니다.
'ETC. > Spring' 카테고리의 다른 글
| [Spring] 스프링 필터(Filter) vs 인터셉터(Interceptor) 차이점과 선택 기준 총정리 (0) | 2026.10.04 |
|---|---|
| [Spring] 스프링 인터셉터(Interceptor)란? 개념과 사용법, 등록 방법 총정리 (1) | 2026.10.03 |
| [Spring] 스프링 JPA란 무엇인가? - 동작 원리와 처리 흐름 정리 (1) | 2025.11.19 |
| [Spring] 스프링 MyBatis란 무엇인가? - 동작 원리와 처리 흐름 정리 (1) | 2025.11.18 |