스프링 MVC에서 Filter와 Interceptor를 언제 어떻게 나눠 써야 할까
목차
빠른 답
- 서블릿 단위 공통 처리라면 Filter, 컨트롤러 전후 제어라면 Interceptor가 먼저 후보입니다.
- 요청 본문/응답 자체를 직접 다뤄야 하면 Filter가 더 유연합니다.
- 핸들러 정보나 애노테이션 기반 분기가 필요하면 Interceptor가 더 적합합니다.
- 순서 문제와 중복 실행은 실제 장애로 이어지기 쉬우니 등록 위치와 실행 로그를 함께 확인해야 합니다.
빠른 답
- 서블릿 레벨에서 모든 요청과 응답을 공통 처리해야 하면
Filter, 컨트롤러 실행 전후를 제어해야 하면Interceptor가 먼저 후보입니다. - 헤더, 인코딩, CORS, 요청 본문, 응답 감싸기처럼 HTTP 자체를 다뤄야 하면
Filter가 더 맞습니다. - 어떤 컨트롤러 메서드가 호출되는지, 특정 애노테이션이 붙었는지 보고 분기해야 하면
Interceptor가 더 적합합니다. - 스프링 시큐리티를 쓰는 프로젝트라면 인증과 인가의 중심은 보통 시큐리티 필터 체인에 두고,
Filter와Interceptor는 보조 역할로 좁히는 편이 안전합니다.
왜 둘이 자꾸 헷갈릴까
Filter와 Interceptor는 둘 다 "공통 로직을 한곳에 모아 넣는다"는 점에서 비슷합니다. 그래서 로깅, 인증, 권한 확인, 성능 측정 같은 요구가 나오면 둘 중 어디에 넣어도 될 것처럼 보입니다.
하지만 둘은 동작하는 계층이 다릅니다. Filter는 서블릿 컨테이너 쪽에서 동작하고, Interceptor는 스프링 MVC 내부에서 동작합니다. 이 차이 때문에 접근할 수 있는 정보도 다르고, 잘 맞는 책임도 달라집니다.
실무에서는 기능 이름보다 질문을 바꾸는 편이 더 정확합니다.
- 이 로직이 HTTP 요청과 응답 자체를 다뤄야 하는가?
- 아니면 스프링이 선택한 컨트롤러와 핸들러 메서드를 알아야 하는가?
- 컨트롤러 밖에서도 항상 적용되어야 하는가?
- 특정 API, 특정 애노테이션, 특정 핸들러에만 적용되어야 하는가?
이 질문에 답하면 대부분의 선택은 자연스럽게 정리됩니다.
요청이 들어왔을 때 실제로 어떤 순서로 실행될까
위치를 감으로 이해하는 것보다 요청 흐름에 얹어 보는 편이 훨씬 쉽습니다. 일반적인 스프링 MVC 요청은 대략 아래 순서로 흘러갑니다.
- 클라이언트가 HTTP 요청을 보냅니다.
- 서블릿 컨테이너가 요청을 받습니다.
- 등록된
Filter들이 순서대로 실행됩니다. DispatcherServlet이 요청을 전달받습니다.- 핸들러 매핑으로 어떤 컨트롤러 메서드를 호출할지 결정합니다.
HandlerInterceptor#preHandle이 실행됩니다.- 컨트롤러 메서드가 실행됩니다.
HandlerInterceptor#postHandle이 실행됩니다.- 응답 직렬화 또는 뷰 렌더링이 진행됩니다.
HandlerInterceptor#afterCompletion이 실행됩니다.- 마지막으로
Filter를 빠져나가며 후처리가 끝납니다.
여기서 바로 보이는 차이가 있습니다.
Filter는 스프링 MVC에 들어가기 전 단계에 있으므로, 컨트롤러가 무엇인지 몰라도 되는 작업에 강합니다. 반대로 Interceptor는 이미 핸들러가 결정된 뒤이므로, 메서드 정보와 애노테이션을 활용하는 작업에 강합니다.
또 하나 중요한 점은 예외 상황입니다. postHandle은 예외가 터지면 실행되지 않을 수 있지만, afterCompletion은 요청이 끝나는 시점에 호출됩니다. 종료 로그나 정리 작업은 보통 afterCompletion 쪽이 더 안전합니다.
어떤 경우에 Filter를 써야 할까
Filter는 HTTP 레벨의 공통 처리에 잘 맞습니다. 대표적인 예는 다음과 같습니다.
- 요청과 응답 로깅
- 문자 인코딩 설정
- CORS 헤더 추가
- 압축, 캐싱, 공통 보안 헤더
- 요청 본문이나 응답을 감싸는 래퍼 적용
- 스프링 MVC에 들어가기 전 조기 차단
예를 들어 Content-Type, User-Agent, X-Request-Id 같은 헤더를 기록하거나, 모든 응답에 공통 헤더를 넣거나, 요청 시작과 종료 시간을 남기는 작업은 Filter가 자연스럽습니다.
다만 유연한 만큼 실수 여지도 큽니다. 특히 요청 본문을 Filter에서 먼저 읽으면 뒤의 @RequestBody가 비어 버리는 문제가 자주 발생합니다. 본문 로깅이 필요하면 ContentCachingRequestWrapper 같은 래퍼를 고려해야 하고, 단순 조회용 로그와 실제 소비를 구분해야 합니다.
또 Filter는 정적 리소스나 에러 경로까지 걸릴 수 있습니다. URL 패턴과 디스패처 타입을 어떻게 등록했는지 반드시 확인해야 합니다.
어떤 경우에 Interceptor를 써야 할까
Interceptor는 스프링 MVC 핸들러에 가까운 공통 처리에 적합합니다. 대표적인 예는 다음과 같습니다.
- 컨트롤러 진입 전 인증 확인
- 특정 애노테이션 기반 검사
- API별 접근 정책 분기
- 핸들러 단위 실행 시간 측정
- 요청 종료 시 핸들러 기준 로그 정리
가장 큰 장점은 handler 객체에 접근할 수 있다는 점입니다. HandlerMethod로 캐스팅하면 어떤 컨트롤러 클래스와 메서드가 실행되는지 알 수 있고, 메서드나 클래스에 붙은 애노테이션도 검사할 수 있습니다.
예를 들어 @AuthRequired, @AdminOnly, @AuditLog 같은 애노테이션을 기준으로 분기하는 로직은 Interceptor가 훨씬 자연스럽습니다. 반대로 응답 바디를 직접 감싸거나, 서블릿 레벨에서 요청을 바꾸는 작업은 Interceptor보다 Filter가 더 잘 맞습니다.
한 가지 주의할 점은 Interceptor가 권한 정책의 최종 종착지가 되어서는 안 된다는 것입니다. "이 사용자가 이 자원을 수정할 수 있는가" 같은 도메인 인가는 서비스 계층이나 스프링 시큐리티의 메서드 보안에서 다시 검증하는 편이 안전합니다. Interceptor는 주로 진입 조건 검사와 공통 정책 적용에 쓰는 것이 좋습니다.
Spring Boot에서 함께 등록하는 방법
설정은 단순해 보여도 순서와 제외 경로 때문에 실제 장애가 자주 납니다. Filter는 FilterRegistrationBean으로 등록하면 URL 패턴과 순서를 명시하기 쉽고, Interceptor는 WebMvcConfigurer에서 등록하면 적용 범위를 읽기 좋게 유지할 수 있습니다.
먼저 로그를 보기 쉽게 맞춰 두면 실행 순서를 확인하기 편합니다.
# application.yml
logging:
level:
root: INFO
com.example.demo: INFO
pattern:
console: "%d{HH:mm:ss.SSS} %-5level %logger{36} - %msg%n"
이제 Filter와 Interceptor를 함께 등록해 보겠습니다. Filter는 전역 요청 로깅에, Interceptor는 /api/** 인증 검사에 두는 예시입니다.
package com.example.demo.config;
import com.example.demo.web.AuthInterceptor;
import com.example.demo.web.RequestLoggingFilter;
import jakarta.servlet.DispatcherType;
import java.util.EnumSet;
import lombok.RequiredArgsConstructor;
import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.Ordered;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final AuthInterceptor authInterceptor;
@Bean
public FilterRegistrationBean<RequestLoggingFilter> requestLoggingFilter() {
FilterRegistrationBean<RequestLoggingFilter> bean =
new FilterRegistrationBean<>(new RequestLoggingFilter());
bean.setOrder(Ordered.HIGHEST_PRECEDENCE);
bean.addUrlPatterns("/*");
bean.setDispatcherTypes(EnumSet.of(DispatcherType.REQUEST));
bean.setName("requestLoggingFilter");
return bean;
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns("/api/public/**", "/actuator/health", "/error");
}
}
여기서 핵심은 두 가지입니다. Filter는 대개 범위가 넓고, Interceptor는 범위를 더 좁게 가져갑니다. 그리고 /error, 헬스 체크, 공개 API를 제외하지 않으면 장애 분석이 오히려 어려워질 수 있습니다.
실전 코드로 보기: 로깅은 Filter, 핸들러 기반 인증은 Interceptor
먼저 요청 전체를 감싸는 로깅 Filter입니다. 이 코드는 컨트롤러 성공 여부와 관계없이 시작과 종료를 모두 남길 수 있어서 운영 로그에 유용합니다.
package com.example.demo.web;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.web.filter.OncePerRequestFilter;
public class RequestLoggingFilter extends OncePerRequestFilter {
private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class);
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
long startedAt = System.currentTimeMillis();
String method = request.getMethod();
String uri = request.getRequestURI();
log.info("[Filter] start {} {}", method, uri);
try {
filterChain.doFilter(request, response);
} finally {
long elapsed = System.currentTimeMillis() - startedAt;
log.info("[Filter] end {} {} status={} {}ms",
method, uri, response.getStatus(), elapsed);
}
}
}
다음은 HandlerMethod와 애노테이션을 활용하는 Interceptor입니다. 이 예시는 @AuthRequired가 붙은 핸들러만 검사하므로, 단순 URL 매칭보다 의도가 더 분명합니다.
package com.example.demo.web;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@interface AuthRequired {
}
@RestController
@RequestMapping("/api")
class UserController {
@GetMapping("/public/ping")
public String ping() {
return "ok";
}
@AuthRequired
@GetMapping("/private/me")
public String me() {
return "user";
}
}
@Component
public class AuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
if (!(handler instanceof HandlerMethod handlerMethod)) {
return true;
}
boolean needAuth =
handlerMethod.hasMethodAnnotation(AuthRequired.class) ||
handlerMethod.getBeanType().isAnnotationPresent(AuthRequired.class);
if (!needAuth) {
return true;
}
String token = request.getHeader("X-AUTH-TOKEN");
if (!"secret-token".equals(token)) {
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("{\"message\":\"Unauthorized\"}");
return false;
}
return true;
}
}
이렇게 나누면 역할이 깔끔해집니다. Filter는 요청 전체 흐름을 감싸고, Interceptor는 실제로 보호가 필요한 핸들러에만 개입합니다. 나중에 "어디서 막혔는지"를 추적할 때도 계층이 분리되어 있어서 원인 파악이 빨라집니다.
curl과 로그로 실행 순서를 검증하는 방법
설정이 맞아 보여도 실제로는 제외 경로가 빠졌거나, 로그가 중복되거나, 인증이 생각보다 넓게 걸리는 경우가 흔합니다. 그래서 curl로 공개 경로와 보호 경로를 각각 호출해 보는 것이 좋습니다.
curl -i http://localhost:8080/api/public/ping
curl -i http://localhost:8080/api/private/me
curl -i \
-H "X-AUTH-TOKEN: secret-token" \
http://localhost:8080/api/private/me
이때 기대하는 흐름은 다음과 같습니다.
공개 경로인 /api/public/ping은 Filter 로그만 찍히거나, Interceptor가 등록되어 있더라도 인증 차단 없이 통과해야 합니다. 보호 경로인 /api/private/me는 보통 Filter start -> Interceptor preHandle -> Controller -> Interceptor afterCompletion -> Filter end 순으로 관찰됩니다. 토큰이 없으면 컨트롤러까지 가지 않고 401로 끊겨야 정상입니다.
검증할 때 특히 봐야 할 포인트는 아래와 같습니다.
- 공개 경로인데 인증 차단이 된다면
excludePathPatterns를 다시 확인합니다. - 동일 요청 로그가 두 번 찍히면
DispatcherType, 포워드, 에러 디스패치, 프록시 중복을 의심합니다. - 요청 종료 로그가 없다면 예외 처리 흐름이나 응답 커밋 시점을 확인합니다.
@RequestBody가 비어 있다면 앞단Filter에서 본문을 먼저 소비했는지 봅니다.
자주 생기는 실수와 선택 기준
실무에서 가장 많이 생기는 문제는 "가능하니까 한곳에 몰아넣는 것"입니다. 예를 들어 인증, 권한, 로깅, 예외 가공을 모두 Interceptor 하나에 넣기 시작하면 금방 책임이 섞입니다. 반대로 요청 헤더, 응답 헤더, 본문 로깅, 인증 차단을 모두 Filter로 처리하면 스프링 MVC의 장점을 살리기 어렵습니다.
안전한 기준은 아래처럼 잡을 수 있습니다.
- HTTP 요청과 응답 자체를 다루면
Filter - 핸들러 메서드 정보가 필요하면
Interceptor - 비즈니스 권한 판단은 서비스 계층 또는 스프링 시큐리티
- 전역 예외 응답 변환은
@ControllerAdvice
스프링 시큐리티를 쓰는 경우에는 이 기준을 한 번 더 조정해야 합니다. JWT 인증, 세션 인증, 권한 인가는 대개 시큐리티 필터 체인에서 처리하는 것이 정석입니다. 이런 프로젝트에서 별도 Interceptor로 인증을 또 걸면 중복 검사와 책임 충돌이 생기기 쉽습니다. 그 경우 Filter는 진단성 로깅과 공통 헤더 처리에, Interceptor는 MVC 전용 부가 정책에 집중시키는 편이 낫습니다.
결국 핵심은 "어디가 더 강력한가"가 아니라 "이 로직이 어느 계층의 책임인가"입니다. 이 기준으로 보면 선택은 생각보다 단순합니다. HTTP 스펙에 가까우면 Filter, 스프링 MVC 핸들러에 가까우면 Interceptor입니다.
원문 참고
https://www.maeil-mail.kr/question/10
댓글
댓글 쓰기