Spring Boot로 API를 만들다 보면 이런 생각이 든다. "로그인 여부 확인, 로깅, CORS 처리... 이걸 Controller마다 넣기엔 너무 반복인데?"
그 고민을 해결해주는 게 Filter(필터)다.
Filter는 HTTP 요청과 응답을 DispatcherServlet에 도달하기 전후로 가로채서 공통 처리를 할 수 있는 컴포넌트다.
Java EE(Jakarta EE) 표준 스펙인 jakarta.servlet.Filter 인터페이스를 구현해서 만든다.
Spring의 기술이 아니라 Servlet 스펙에 정의된 기술이라는 점이 중요하다. 그래서 Spring Context 밖, Tomcat(Servlet Container) 레벨에서 동작한다.
공통 처리를 Controller에 넣으면 어떤 문제가 생길까?
Filter를 쓰면 요청이 Controller에 도달하기 전에 공통 처리를 한 번에 적용할 수 있다. Controller는 비즈니스 로직에만 집중하면 된다.
실무에서 Filter가 담당하는 대표적인 역할들이다.
InputStream은 한 번만 읽을 수 있어서 로깅용으로 캐싱할 때
POST /api/login 요청이 들어왔을 때 Filter가 어떻게 개입하는지 보자.
[Client]
|
| HTTP Request
↓
[Tomcat - Servlet Container]
|
↓
[FilterChain] ← Filter들이 순서대로 연결된 체인
|
| ① Filter1.doFilter() 실행 (예: LoggingFilter)
| → chain.doFilter() 호출 → 다음 Filter로 넘김
↓
| ② Filter2.doFilter() 실행 (예: CorsFilter)
| → chain.doFilter() 호출 → 다음 Filter로 넘김
↓
| ③ Filter3.doFilter() 실행 (예: JwtAuthFilter)
| → chain.doFilter() 호출 → 다음으로 넘김
↓
[DispatcherServlet]
|
| Spring MVC 처리 (HandlerMapping → Controller → ...)
↓
[FilterChain] ← 응답은 역순으로 다시 통과
|
| ③ Filter3 — chain.doFilter() 이후 코드 실행
↓
| ② Filter2 — chain.doFilter() 이후 코드 실행
↓
| ① Filter1 — chain.doFilter() 이후 코드 실행
↓
[Client]
HTTP Response
핵심은 chain.doFilter() 호출 전은 요청 처리, 호출 후는 응답 처리라는 것이다.
하나의 doFilter() 메서드 안에서 요청과 응답 양방향을 모두 처리할 수 있다.
그리고 Filter는 chain.doFilter()를 호출하지 않으면 요청을 차단한다.
JWT 인증 실패 시 바로 401 응답을 내보내는 방식이 이 원리다.
jakarta.servlet.Filter를 구현하면 된다. 메서드는 세 가지다.
init(FilterConfig filterConfig) — Filter 초기화 시 한 번 호출. 기본 구현이 있어서 필요할 때만 오버라이드doFilter(ServletRequest request, ServletResponse response, FilterChain chain) — 요청마다 호출. 실제 처리 로직이 들어가는 곳destroy() — Filter 종료 시 호출. 리소스 정리용. 필요할 때만 오버라이드
등록된 Filter들이 순서대로 연결된 체인이다.
chain.doFilter(request, response)를 호출하면 다음 Filter로 넘어가고,
더 이상 Filter가 없으면 DispatcherServlet으로 넘어간다.
Spring이 제공하는 추상 클래스로, 요청당 정확히 한 번만 실행됨을 보장한다.
서블릿 내부에서 forward나 include가 발생해도 중복 실행되지 않는다.
실무에서 커스텀 Filter를 만들 때는 Filter 직접 구현보다 OncePerRequestFilter 상속을 주로 사용한다.
Spring Boot에서 Filter를 등록하는 방법은 세 가지가 있다.
@Component를 붙이면 Spring이 자동으로 FilterChain에 등록해준다.
단, 적용 경로나 순서를 세밀하게 제어하기 어렵다는 단점이 있다.
@Component
public class LoggingFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
System.out.println("[요청] " + request.getMethod() + " " + request.getRequestURI());
filterChain.doFilter(request, response);
System.out.println("[응답] status=" + response.getStatus());
}
}
적용 경로, 실행 순서(order)를 정밀하게 제어할 수 있어서 실무에서 주로 사용한다.
@Configuration
public class FilterConfig {
@Bean
public FilterRegistrationBean<LoggingFilter> loggingFilter() {
FilterRegistrationBean<LoggingFilter> bean = new FilterRegistrationBean<>();
bean.setFilter(new LoggingFilter());
bean.addUrlPatterns("/api/*"); // 적용 경로 지정
bean.setOrder(1); // 낮을수록 먼저 실행
bean.setName("loggingFilter");
return bean;
}
}
@WebFilter 애노테이션으로 URL 패턴을 지정하고,
메인 클래스에 @ServletComponentScan을 붙여서 활성화하는 방식이다.
Spring Boot에서는 잘 쓰이지 않는다.
실무에서 가장 많이 보게 되는 패턴이다. 요청 헤더에서 JWT를 꺼내서 유효하면 통과, 아니면 401 응답을 바로 내보낸다.
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
private final JwtProvider jwtProvider;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
String token = resolveToken(request);
if (token != null && jwtProvider.validate(token)) {
// 인증 정보를 SecurityContext에 등록
Authentication auth = jwtProvider.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(auth);
}
// 토큰이 없거나 유효하지 않아도 일단 다음으로 넘김
// 인가는 Spring Security가 별도로 처리
filterChain.doFilter(request, response);
}
private String resolveToken(HttpServletRequest request) {
String bearer = request.getHeader("Authorization");
if (bearer != null && bearer.startsWith("Bearer ")) {
return bearer.substring(7);
}
return null;
}
}
@Component
public class RequestLoggingFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
long start = System.currentTimeMillis();
try {
filterChain.doFilter(request, response); // 요청 처리
} finally {
// finally로 감싸야 예외가 나도 로그가 찍힘
long elapsed = System.currentTimeMillis() - start;
System.out.printf("[%s] %s %s | %dms | status=%d%n",
LocalDateTime.now(),
request.getMethod(),
request.getRequestURI(),
elapsed,
response.getStatus());
}
}
}
Spring Security를 쓸 때 CORS 설정을 Security 설정 안에서 하지 않고 Filter로 직접 처리하는 방식이다.
@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", "https://example.com");
response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
response.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type");
response.setHeader("Access-Control-Allow-Credentials", "true");
// Preflight 요청(OPTIONS)은 바로 200 응답
if ("OPTIONS".equalsIgnoreCase(request.getMethod())) {
response.setStatus(HttpServletResponse.SC_OK);
return; // chain.doFilter() 호출 안 함 → 요청 차단 (여기서 끝)
}
filterChain.doFilter(request, response);
}
}
가장 많이 나오는 질문이다. 결정적인 기준은 Spring Context가 필요하냐다.
DispatcherServlet 앞에서 실행DispatcherServlet이 Controller를 실행하는 전후에 동작선택 기준을 정리하면 이렇다.
@ControllerAdvice는 DispatcherServlet 안에서 동작한다.
Filter는 DispatcherServlet보다 앞에 있기 때문에 Filter 예외는 @ControllerAdvice가 잡지 못한다.
해결 방법은 두 가지다.
try-catch로 직접 처리하고 response에 에러 JSON을 작성// Filter 내부에서 직접 에러 응답 작성
@Override
protected void doFilterInternal(...) throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} catch (JwtException e) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType("application/json;charset=UTF-8");
response.getWriter().write("{\"message\": \"인증이 필요합니다.\"}");
}
}
Spring Security는 자체 FilterChainProxy를 통해 Security Filter들을 관리한다.
@Component로 등록한 Filter와 Spring Security Filter는 별도의 체인이다.
Security 관련 Filter는 반드시 Security 설정 안에서 addFilterBefore(), addFilterAfter()로 등록해야 한다.
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
}
여러 Filter가 등록되어 있을 때 순서가 중요하다. CORS Filter가 JWT Filter보다 먼저 실행되어야 Preflight 요청이 제대로 처리된다.
FilterRegistrationBean.setOrder() — 숫자가 낮을수록 먼저 실행@Order(Ordered.HIGHEST_PRECEDENCE) — @Component 방식에서 가장 먼저 실행하고 싶을 때
HttpServletRequest의 InputStream은 한 번 읽으면 끝이다.
Filter에서 요청 바디를 로깅한 뒤 Controller에서도 읽으려면 바디를 캐싱해야 한다.
Spring은 이를 위한 ContentCachingRequestWrapper를 제공한다.
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain)
throws ServletException, IOException {
// 요청 바디를 캐싱할 수 있는 Wrapper로 감쌈
ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(request);
ContentCachingResponseWrapper wrappedResponse = new ContentCachingResponseWrapper(response);
filterChain.doFilter(wrappedRequest, wrappedResponse);
// chain.doFilter() 이후에 바디 읽기 가능
byte[] requestBody = wrappedRequest.getContentAsByteArray();
System.out.println("Request Body: " + new String(requestBody, StandardCharsets.UTF_8));
// 응답 바디를 실제로 클라이언트에 보내려면 반드시 copyBodyToResponse() 호출
wrappedResponse.copyBodyToResponse();
}
DispatcherServlet에 도달하기 전후로 요청/응답을 가로챈다jakarta.servlet.Filter 인터페이스를 구현하며, 실무에서는 OncePerRequestFilter를 주로 상속한다chain.doFilter() 호출 전은 요청 처리, 호출 후는 응답 처리다chain.doFilter()를 호출하지 않으면 요청이 차단된다@Component 또는 FilterRegistrationBean을 사용한다. 순서와 경로 제어가 필요하면 FilterRegistrationBean이 낫다@ControllerAdvice로 잡히지 않는다 — Filter 내부에서 직접 처리해야 한다
처음에 Filter랑 Interceptor 차이를 물어보는 면접 질문이 왜 나오는지 몰랐다.
근데 실제로 JWT Filter에서 던진 예외가 @ControllerAdvice에 안 잡혀서 삽질한 경험이 생기고 나서야
이 둘의 위치 차이가 얼마나 중요한지 체감했다.
Filter는 Spring 기술이 아니라 Servlet 스펙이다. 이 한 문장을 이해하면 "왜 Spring Context 밖에서 동작하는지", "왜 예외가 안 잡히는지"가 자연스럽게 이해된다. 개념의 위치를 알면 동작 방식이 보인다는 걸 다시 한번 느꼈다.