필터와 인터셉터 — 요청 전후에 공통 로직을 끼워넣는 두 가지 방법
모든 요청에 대해 인증을 체크하거나, 요청/응답을 로깅하거나, 실행 시간을 측정하고 싶습니다. 이런 공통 로직을 매 컨트롤러마다 넣을 수는 없으니, Filter와 Interceptor 중 어떤 걸 써야 할까요?
개념 정의
Filter 는 서블릿 스펙의 일부로, 서블릿 컨테이너(톰캣) 레벨에서 HTTP 요청/응답을 가로채는 컴포넌트입니다. HandlerInterceptor 는 스프링 MVC 레벨에서 핸들러(컨트롤러) 실행 전후에 동작하는 컴포넌트입니다.
왜 필요한가
공통 관심사를 컨트롤러마다 반복하면 유지보수가 어렵습니다.
// 나쁜 예: 모든 컨트롤러에 인증 코드 중복
@GetMapping("/users")
public List<User> getUsers(HttpServletRequest request) {
if (!authService.isAuthenticated(request)) {
throw new UnauthorizedException();
}
return userService.findAll();
}
Filter나 Interceptor를 사용하면 한 곳에서 공통 로직을 처리하고, 컨트롤러는 비즈니스 로직에만 집중합니다.
내부 동작
실행 순서
클라이언트 요청
→ Filter1.doFilter() (전처리)
→ Filter2.doFilter() (전처리)
→ DispatcherServlet
→ Interceptor1.preHandle()
→ Interceptor2.preHandle()
→ Controller 실행
→ Interceptor2.postHandle()
→ Interceptor1.postHandle()
→ 뷰 렌더링
→ Interceptor2.afterCompletion()
→ Interceptor1.afterCompletion()
← DispatcherServlet
→ Filter2.doFilter() (후처리)
→ Filter1.doFilter() (후처리)
클라이언트 응답
동작 범위 비교
| 구분 | Filter | HandlerInterceptor |
|---|---|---|
| 관리 주체 | 서블릿 컨테이너 | 스프링 MVC |
| 동작 위치 | DispatcherServlet 외부 | DispatcherServlet 내부 |
| 스프링 빈 접근 | 가능 (DelegatingFilterProxy) | 직접 가능 |
| 대상 | 모든 요청 (정적 리소스 포함) | 핸들러에 매핑된 요청만 |
| 예외 처리 | @ControllerAdvice 범위 밖 | @ControllerAdvice로 처리 가능 |
| 요청/응답 수정 | 가능 (래퍼) | 제한적 |
코드 예제
Filter 구현
@Component
@Order(1)
public class RequestLoggingFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
long startTime = System.currentTimeMillis();
// 전처리
log.info("[요청] {} {}", httpRequest.getMethod(), httpRequest.getRequestURI());
이어서 필터 체인을 통해 요청을 다음 단계로 전달하는 부분입니다.
// 다음 필터 또는 서블릿으로 진행
chain.doFilter(request, response);
// 후처리
long duration = System.currentTimeMillis() - startTime;
HttpServletResponse httpResponse = (HttpServletResponse) response;
log.info("[응답] {} {} - {}ms", httpResponse.getStatus(),
httpRequest.getRequestURI(), duration);
}
}
FilterRegistrationBean으로 세밀한 설정
@Configuration
public class FilterConfig {
@Bean
public FilterRegistrationBean<RequestLoggingFilter> loggingFilter() {
FilterRegistrationBean<RequestLoggingFilter> registration =
new FilterRegistrationBean<>();
registration.setFilter(new RequestLoggingFilter());
registration.addUrlPatterns("/api/*"); // 특정 URL만 적용
registration.setOrder(1); // 실행 순서
registration.setName("loggingFilter");
return registration;
}
}
요청 본문 읽기 (ContentCachingRequestWrapper)
@Component
public class RequestBodyLoggingFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
// 요청 본문을 캐싱하는 래퍼로 교체
ContentCachingRequestWrapper wrappedRequest =
new ContentCachingRequestWrapper(request);
ContentCachingResponseWrapper wrappedResponse =
new ContentCachingResponseWrapper(response);
이어서 필터 체인을 통해 요청을 다음 단계로 전달하는 부분입니다.
filterChain.doFilter(wrappedRequest, wrappedResponse);
// 후처리에서 본문 읽기
String requestBody = new String(wrappedRequest.getContentAsByteArray());
String responseBody = new String(wrappedResponse.getContentAsByteArray());
log.info("Request Body: {}", requestBody);
log.info("Response Body: {}", responseBody);
// 응답 본문을 다시 써줘야 클라이언트가 받을 수 있음
wrappedResponse.copyBodyToResponse();
}
}
HandlerInterceptor 구현
@Component
public class AuthInterceptor implements HandlerInterceptor {
private final TokenService tokenService;
public AuthInterceptor(TokenService tokenService) {
this.tokenService = tokenService;
}
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
// 핸들러 정보 활용 가능
if (handler instanceof HandlerMethod handlerMethod) {
// 커스텀 어노테이션 체크
if (handlerMethod.hasMethodAnnotation(Public.class)) {
return true; // 인증 불필요
}
}
이어서 나머지 구현 부분입니다.
String token = request.getHeader("Authorization");
if (token == null || !tokenService.isValid(token)) {
response.setStatus(HttpStatus.UNAUTHORIZED.value());
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write("{\"message\":\"인증이 필요합니다\"}");
return false; // 컨트롤러 실행 중단
}
이어서 나머지 구현 부분입니다.
// 인증 정보를 request에 저장
request.setAttribute("userId", tokenService.getUserId(token));
return true; // 계속 진행
}
@Override
public void postHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler,
ModelAndView modelAndView) throws Exception {
// 컨트롤러 정상 실행 후 (뷰 렌더링 전)
}
이어서 나머지 어노테이션 기반 구현부입니다.
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) throws Exception {
// 항상 실행 (예외 발생해도)
// 리소스 정리에 적합
if (ex != null) {
log.error("요청 처리 중 예외 발생: {}", request.getRequestURI(), ex);
}
}
}
인터셉터 등록
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final AuthInterceptor authInterceptor;
private final ExecutionTimeInterceptor executionTimeInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
// 인증 인터셉터
registry.addInterceptor(authInterceptor)
.addPathPatterns("/api/**") // 적용할 URL
.excludePathPatterns("/api/auth/**") // 제외할 URL
.order(1); // 실행 순서
이어서 나머지 구현 부분입니다.
// 실행 시간 측정 인터셉터
registry.addInterceptor(executionTimeInterceptor)
.addPathPatterns("/**")
.order(2);
}
}
실무 적용 가이드
[Filter가 적합한 경우]
├── 요청/응답 본문 로깅 (래퍼 필요)
├── 인코딩 설정 (CharacterEncodingFilter)
├── CORS 설정 (CorsFilter)
├── 보안 (Spring Security — Filter 기반)
└── 요청/응답 압축
[Interceptor가 적합한 경우]
├── 인증/인가 (핸들러 정보 활용 가능)
├── API 호출 로깅 (컨트롤러 메서드 정보 필요)
├── 실행 시간 측정
├── 공통 모델 데이터 주입
└── API 버전 체크
실행 시간 측정 인터셉터
@Component
public class ExecutionTimeInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) {
request.setAttribute("startTime", System.currentTimeMillis());
return true;
}
이어서 응답 객체를 구성하여 클라이언트에 반환하는 부분입니다.
@Override
public void afterCompletion(HttpServletRequest request,
HttpServletResponse response,
Object handler,
Exception ex) {
long startTime = (long) request.getAttribute("startTime");
long duration = System.currentTimeMillis() - startTime;
if (handler instanceof HandlerMethod handlerMethod) {
String controller = handlerMethod.getBeanType().getSimpleName();
String method = handlerMethod.getMethod().getName();
log.info("[{}#{}] {}ms - {}", controller, method, duration,
response.getStatus());
}
}
}
주의할 점
1. Filter에서 request body를 읽으면 컨트롤러에서 다시 읽을 수 없다
HttpServletRequest의 getInputStream()은 한 번만 읽을 수 있습니다. Filter에서 요청 바디를 로깅하려고 읽으면, 컨트롤러에서 @RequestBody가 빈 값을 받아 HttpMessageNotReadableException이 발생합니다. ContentCachingRequestWrapper로 요청을 감싸서 바디를 캐싱해야 합니다.
2. 인터셉터의 preHandle()에서 예외를 던지면 @ExceptionHandler가 동작하지 않을 수 있다
HandlerInterceptor.preHandle()에서 예외를 던지면 컨트롤러에 도달하기 전이므로 @ControllerAdvice의 @ExceptionHandler가 동작하지 않을 수 있습니다. 인터셉터에서는 예외 대신 response.sendError()로 직접 응답하거나, afterCompletion()에서 에러를 처리하는 패턴을 사용하세요.
3. Filter와 Interceptor의 실행 순서를 혼동하면 인증 로직이 의도대로 동작하지 않는다
Filter는 DispatcherServlet 이전에, Interceptor는 이후에 실행됩니다. Spring Security의 FilterChain보다 먼저 실행되어야 하는 로직(CORS, 요청 ID 생성)은 Filter로, 인증된 사용자 정보를 기반으로 하는 로직(권한 체크, 감사 로그)은 Interceptor로 구현해야 합니다.
정리
- Filter 는 서블릿 레벨에서 모든 요청을 가로채고, Interceptor 는 스프링 MVC 레벨에서 핸들러 전후에 동작합니다
- Filter는 요청/응답 자체를 수정할 수 있고(래퍼), Interceptor는 핸들러(컨트롤러) 메타데이터에 접근할 수 있습니다
preHandle()이false를 반환하면 컨트롤러가 실행되지 않습니다afterCompletion()은 예외 발생 시에도 항상 실행되므로 리소스 정리에 적합합니다- 요청 본문 로깅은 Filter + ContentCachingRequestWrapper, API 레벨 로직은 Interceptor가 적합합니다