필터(Filter)와 인터셉터(Interceptor) 차이

송현진·2025년 5월 30일
0

Spring Boot

목록 보기
19/23

Spring Web 애플리케이션을 개발하다 보면 공통적으로 처리해야 할 기능이 생긴다. 예를 들어 로그인 검증, 요청/응답 로깅, 요청 시간 측정, 트랜잭션 처리 등은 서비스 로직과는 별개로 공통 처리가 필요한 기능이다. 이럴 때 Spring에서는 크게 Filter와 Interceptor라는 두 가지 도구를 제공한다.
둘 다 요청을 가로채고 처리할 수 있지만 동작 위치, 역할, 사용 목적이 다르다. 이 글에서는 둘의 차이를 구체적으로 이해하고 실제로 언제 어떤 걸 써야 하는지 직접 코드 예제와 흐름도를 통해 알아보자.

필터(Filter)

J2EE 표준 스펙 기능으로 디스패처 서블릿(Dispatcher Servlet)에 요청이 전달되기 전/후로 작동한다. Filter는 스프링 프레임워크 바깥에서 동작하며 톰캣과 같은 서블릿 컨테이너에 의해 관리된다. 즉, Filter는 스프링의 컨텍스트와 무관하게 요청의 시작점에서 먼저 실행되며 요청을 필터링하거나 수정할 수 있다. 요청 흐름은 아래와 같다.

필터(Filter)의 메소드

필터를 추가하기 위해서는 javax.servlet의 Filter 인터페이스를 구현해야 하며 이는 다음의 3가지 메소드를 가지고 있다.

public interface Filter {

    public default void init(FilterConfig filterConfig) throws ServletException {}

    public void doFilter(ServletRequest request, ServletResponse response,
            FilterChain chain) throws IOException, ServletException;

    public default void destroy() {}
}
  • init
    필터가 최초 생성될 때 한 번 실행된다.
    웹 컨테이너가 1회 init 메소드를 호출하여 필터 객체를 초기화하면 이후의 요청들은 doFilter를 통해 처리된다.
  • doFilter
    url-pattern에 맞는 모든 HTTP 요청이 디스패처 서블릿으로 전달되기 전에 웹 컨테이너에 의해 실행되는 메소드이다. doFilter의 파라미터로는 FilterChaindoFilter를 통해 다음 대상으로 요청을 전달하게 된다. chain.doFilter() 전/후에 우리가 필요한 처리 과정을 넣어줌으로써 원하는 처리를 진행할 수 있다.
  • destroy
    필터 객체를 서비스에서 제거하고 사용하는 자원을 반환하기 위한 메소드이다. 이는 웹 컨테이너에 1번 호출되며 이후에는 이제 doFilter는 더 이상 실행되지 않는다.
@Override
protected void doFilterInternal(HttpServletRequest request,
                                HttpServletResponse response,
                                FilterChain filterChain) throws ServletException, IOException {

    ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(request);
    ContentCachingResponseWrapper wrappedResponse = new ContentCachingResponseWrapper(response);

    String traceId = UUID.randomUUID().toString();
    MDC.put(TRACE_ID, traceId);
    wrappedRequest.setAttribute(TRACE_ID, traceId);

    try {
        filterChain.doFilter(wrappedRequest, wrappedResponse); // 다음 필터 or 디스패처 서블릿으로 요청 전달
        logRequestBody(wrappedRequest, traceId);               // 요청 로그 처리
    } finally {
        wrappedResponse.copyBodyToResponse();                  // 응답 바디 복사
        MDC.clear();                                           // MDC 클리어
    }
}

Filter는 스프링 바깥에서 가장 먼저 실행되며 요청/응답을 감싸거나 차단하는 역할을 한다.

인터셉터(Interceptor)

인터셉터(Interceptor)는 J2EE 표준 스펙인 필터(Filter)와 달리 Spring이 제공하는 기술로써 디스패처 서블릿(Dispatcher Servlet)이 컨트롤러를 호출하기 전과 후에 요청과 응답을 참조하거나 가공할 수 있는 기능을 제공한다. 즉, 웹 컨테이너(서블릿 컨테이너)에서 동작하는 필터와 달리 인터셉터는 스프링 컨텍스트에서 동작을 하는 것이다. 디스패처 서블릿은 핸들러 매핑을 통해 적절한 컨트롤러를 찾도록 요청하는데 그 결과로 실행 체인(HandlerExecutionChain)을 돌려준다. 그래서 이 실행 체인은 1개 이상의 인터셉터가 등록되어 있다면 순차적으로 인터셉터들을 거쳐 컨트롤러가 실행되도록 하고 인터셉터가 없다면 바로 컨트롤러를 실행한다. 인터셉터는 스프링 컨테이너 내에서 동작하므로 필터를 거쳐 프론트 컨트롤러인 디스패처 서블릿이 요청을 받은 이후에 동작하게 되는데 이러한 호출 순서를 그림으로 표현하면 다음과 같다.

인터셉터(Interceptor)의 메소드

인터셉터를 추가하기 위해서는 org.springframework.web.servletHandlerInterceptor 인터페이스를 구현해야 하며 이는 다음의 3가지 메소드를 가지고 있다.

public interface HandlerInterceptor {
	/*
     * Controller가 호출되기 전에 실행
     */
    default boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
        throws Exception {
        
        return true;
    }

	/*
     * Controller가 호출된 후에 실행
     */
    default void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
        @Nullable ModelAndView modelAndView) throws Exception {
    }

	/*
     * 모든 작업이 완료된 후에 실행
     */
    default void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler,
        @Nullable Exception ex) throws Exception {
    }
}
  • preHandle
    컨트롤러 이전에 처리해야 하는 전처리 작업이나 요청 정보를 가공하거나 추가하는 경우에 사용할 수 있다.
    preHandle의 3번째 파라미터로 Object 타입의 handler는 HandlerMethod라는 객체이며 @RequestMapping이 붙은 메소드의 정보를 추상화한 객체이다. 또한 preHandle의 반환 타입은 boolean인데 반환값이 true이면 다음 단계로 진행이 되지만 false라면 작업을 중단하여 이후의 작업(다음 인터셉터 또는 컨트롤러)은 진행되지 않는다.
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
	// traceId는 요청마다 고유하게 부여해서 로그 추적 시 사용함
    String traceId = MDC.get(TRACE_ID);
    
    // 요청 메서드(GET, POST 등), URI를 가져옴
    String method = request.getMethod();
    String uri = request.getRequestURI();

	// 로그인된 사용자 정보가 있을 경우 userId를 MDC에 저장
    Authentication auth = SecurityContextHolder.getContext().getAuthentication();
    if (auth != null && auth.isAuthenticated()) {
        MDC.put("userId", auth.getName());
    }

	// 로그 메시지를 구성
    String logMessage;

	// 요청 타입이 Multipart면 파일 관련 로그로 출력
    if (request instanceof MultipartHttpServletRequest multipartRequest) {
        logMessage = logMultipartRequest(multipartRequest, traceId);
    } 
    // JSON 요청이면 Filter에서 이미 로깅했음을 표시
    else if (request.getContentType() != null && request.getContentType().contains("application/json")) {
        logMessage = "[Body was logged by Filter]";
    } 
	// 그 외 요청은 컨텐츠 타입 명시
	else {
        logMessage = "[Unsupported or unknown content type: " + request.getContentType() + "]";
    }

    log.info("Multipart-Request: [{}] {} {}\n{}", traceId, method, uri, logMessage);
    return true; // true면 컨트롤러로 진입 가능
}
  • API 서버에서 Multipart 요청(파일 업로드 등)이 들어오는 경우 요청 정보를 콘솔에 출력하고 로깅할 때 사용했음.

  • 인증된 사용자라면 MDCuserId를 넣어서 이후 필터나 로깅 인터셉터에서 사용자 식별 가능하도록 처리했음.

  • 인증 실패 시에는 false를 반환해서 요청 자체를 차단할 수 있음 (예: JWT 토큰 검증도 가능).

  • postHandle
    컨트롤러를 호출된 후에 실행된다.
    그렇기 때문에 컨트롤러 이후에 처리해야 하는 후처리 작업이 있을 때 사용할 수 있다. 이 메소드에는 컨트롤러가 반환하는 ModelAndView 타입의 정보가 제공되는데 최근에는 Json 형태로 데이터를 제공하는 RestAPI 기반의 컨트롤러(@RestController)를 만들면서 자주 사용되지는 않는다. 또한 컨트롤러 내부에서 예외가 발생하면 postHandle은 호출되지 않는다.

  • afterCompletion
    이름 그대로 모든 뷰에서 최종 결과를 생성하는 일을 포함해 모든 작업이 완료된 후에 실행된다. 요청 처리 중에 사용한 리소스를 반환할 때 사용하기에 적합하다. postHandler과 달리 컨트롤러 하위 계층에서 작업을 진행하다가 중간에 예외가 발생하더라도 afterCompletion은 반드시 호출된다.

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
	// 앞에서 넣은 traceId를 다시 꺼냄
    String traceId = MDC.get(TRACE_ID);

	// 응답 객체가 ContentCachingResponseWrapper로 감싸져 있으면 바디를 읽을 수 있음
    if (response instanceof ContentCachingResponseWrapper wrapper) {
    	// 응답 바디를 문자열로 변환해서 로그 출력
        String responseBody = new String(wrapper.getContentAsByteArray(), StandardCharsets.UTF_8);
        log.info("ResponseBody: [{}] {} {}\nBody: {}", traceId, response.getStatus(), response.getContentType(), responseBody);
    } else {
        log.warn("Response body unavailable — wrapper missing");
    }

    MDC.clear(); // MDC에 넣었던 정보는 반드시 정리 (메모리 누수 방지)
}

사용자가 API 요청을 보낸 후 실제 응답 결과가 뭔지 로그로 출력해야 할 때 사용했다. 에러가 나든 정상 응답이든 무조건 호출되기 때문에 응답 바디 기록 및 에러 상황 추적에 유용했다. 특히 응답 바디를 로깅해서 “특정 요청이 어떤 응답을 받았는지” 기록 남길 때 아주 유용했다.

필터(Filter) vs 인터셉터(Interceptor)

Request, Response 객체 조작 가능 여부

필터는 Request와 Response를 조작할 수 있지만 인터셉터는 조작할 수 없다.

public class MyFilter implements Filter {     
	@Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
        throws IOException, ServletException {
        // 다른 request와 response를 넣어줄 수 있음
        chain.doFilter(request, response);    
    }
}

필터가 다음 필터를 호출하기 위해서는 필터 체이닝(다음 필터 호출)을 해줘야한다. 이때 Request, Response 객체를 넘겨주므로 우리가 원하는 Request, Response 객체를 넣어줄 수 있다.

하지만 인터셉터는 처리 과정이 필터와 다르다.

public class MyInterceptor implements HandlerInterceptor {
	public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
    	throws Exception {
        // Request, Response를 교체할 수 없고 boolean 값만 반환 가능        
        return true;
	}
}

디스패처 서블릿이 여러 인터셉터 목록을 가지고 있고 순차적으로 실행시킨다. 그리고 true를 반환하면 다음 인터셉터가 실행되거나 컨트롤러로 요청이 전달되며 false가 반환되면 요청이 중단된다. 그러므로 다른 Request, Response 객체를 넘겨줄 수 없다.

필터(Filter)와 인터셉터(Interceptor)의 사용 사례

필터(Filter)의 사용 사례

  • 보안 및 인증/인가 관련 작업
  • 모든 요청에 대한 로깅 또는 검사
  • 이미지/데이터 압축 및 문자열 인코딩
  • Spring과 분리되어야 하는 기능

필터는 기본적으로 Spring과 무관하게 전역적으로 처리해야 하는 작업들을 처리할 수 있다. 필터는 인터셉터보다 앞단에서 동작하기 때문에 보안 검사(XSS 방어 등)를 하여 올바른 요청이 아닐 경우 차단할 수 있다. 그러면 Spring 컨테이너까지 요청이 전달되지 못하고 차단되므로 안전성을 더욱 높일 수 있다. 또한 필터는 이미지나 데이터의 압축, 문자열 인코딩과 같이 웹 어플리케이션에 전반적으로 사용되는 기능을 구현하기에 적당하다.

인터셉터(Interceptor)의 사용 사례

  • 세부적인 보안 및 인증/인가 공통 작업
  • API 호출에 대한 로깅 또는 검사
  • Controller로 넘겨주는 정보(데이터)의 가공

인터셉터에서는 클라이언트의 요청과 관련되어 전역적으로 처리해야 하는 작업들을 처리할 수 있다. 대표적으로 세부적으로 적용해야 하는 인증이나 인가와 같이 예를 들어 특정 그룹의 사용자는 어떤 기능을 사용하지 못하는 경우가 있는데 이러한 작업들은 컨트롤러로 넘어가기 전에 검사해야 하므로 인터셉터가 처리하기에 적합하다.

또한 인터셉터는 필터와 다르게 HttpServletRequest나 HttpServletResponse 등과 같은 객체를 제공받으므로 객체 자체를 조작할 수는 없다. 대신 해당 객체가 내부적으로 갖는 값은 조작할 수 있으므로 컨트롤러로 넘겨주기 위한 정보를 가공하기에 용이하다.

📝 배운점

이번에 Filter와 Interceptor를 비교하면서 단순히 “요청을 가로챈다”는 공통점만으로는 둘의 역할과 쓰임을 완전히 이해할 수 없다는 걸 깨달았다. Filter는 Spring 외부 즉 서블릿 컨테이너 수준에서 동작하기 때문에 모든 HTTP 요청을 처리할 수 있고 보안이나 로깅처럼 전역적인 기능 구현에 적합하다. 반면 Interceptor는 Spring MVC 흐름 안에서 Controller 전후에만 동작하므로 인증, 인가, 응답 후처리처럼 컨트롤러와 연관된 비즈니스 전처리/후처리에 유용하다. 실제로 프로젝트에서 로깅과 인증 기능을 분리하면서 Filter에서는 traceId 부여 및 요청 로깅을 Interceptor에서는 사용자 인증과 응답 로깅을 처리해보니 각각의 책임과 위치가 분명하게 구분된다는 걸 체감했다. 앞으로는 공통 기능이 필요할 때 "언제, 어디서 처리되는가"를 기준으로 Filter와 Interceptor를 구분해 적용할 수 있을 것 같다.

참고

profile
개발자가 되고 싶은 취준생

0개의 댓글