← all posts
DEV 2026.05.02 · 11 min read Intermediate

Spring MVC 요청 파이프라인은 어떻게 설계됐는가

Filter와 HandlerInterceptor의 실행 위치 차이부터 비동기 요청에서 ThreadLocal 오염이 발생하는 이유까지, Spring MVC 요청 처리 계층의 설계 원칙을 추적한다.


Spring MVC의 요청 처리 파이프라인에는 두 개의 횡단 관심사 레이어가 있다. 서블릿 컨테이너 레벨의 Filter와 Spring MVC 레벨의 HandlerInterceptor. 둘 다 “요청을 가로채는” 도구이지만, 실행 위치가 다르고 접근 가능한 정보도 다르다. 이 차이를 모르면 CORS 설정이 Spring Security 앞에서 왜 무너지는지, 비동기 API에서 MDC 로그가 왜 오염되는지 영원히 이해하지 못한다.

두 레이어의 실행 위치

파이프라인의 구조는 다음과 같다.

[클라이언트]

[서블릿 컨테이너]

[Filter Chain]          ← javax.servlet.Filter

[DispatcherServlet]

[HandlerInterceptor Chain]

[Controller 메서드]

FilterDispatcherServlet을 감싼다. chain.doFilter() 전후로 코드를 실행하므로 Spring MVC 전체를 포함한 예외와 응답을 다룰 수 있다. HandlerInterceptorDispatcherServlet 내부에 있다. DispatcherServlet에 도달한 요청에만 실행되며, 정적 리소스를 다른 서블릿이 처리하면 Interceptor는 개입하지 않는다.

가장 중요한 차이는 Controller 정보 접근이다. Filter는 어떤 Controller가 실행될지 알 수 없다. HandlerInterceptorpreHandleObject handlerHandlerMethod로 캐스팅하면 Controller 클래스, 실행될 메서드, 메서드 어노테이션까지 읽을 수 있다.

HandlerExecutionChain과 실행 순서

여러 Interceptor가 등록될 때 실행 순서는 직관적이지 않다. preHandle은 등록 순서(정방향)이지만, postHandleafterCompletion은 역순이다. 스택(Stack)처럼 동작하기 때문이다.

// preHandle:       auth(0) → rateLimit(1) → logging(2)
// postHandle:      logging(2) → rateLimit(1) → auth(0)
// afterCompletion: logging(2) → rateLimit(1) → auth(0)

preHandle이 중간에 false를 반환하면 체인이 즉시 중단된다. 이때 afterCompletion지금까지 preHandletrue를 반환한 Interceptor에 대해서만 역순으로 호출된다. HandlerExecutionChaininterceptorIndex 필드가 성공한 마지막 인덱스를 추적하기 때문이다.

boolean applyPreHandle(...) throws Exception {
    for (int i = 0; i < this.interceptorList.size(); i++) {
        if (!interceptor.preHandle(request, response, this.handler)) {
            triggerAfterCompletion(request, response, null);
            return false;
        }
        this.interceptorIndex = i;
    }
    return true;
}

순서 제어는 addInterceptors()에서 addInterceptor() 호출 순서로만 결정된다. @Order는 Interceptor 실행 순서와 직접 관련이 없다.

postHandle의 함정 — @ResponseBody에서 mv는 null

postHandleModelAndView mv 파라미터가 항상 채워져 있다고 가정하면 틀린다. @RestController@ResponseBody를 사용하면 ReturnValueHandlermavContainer.setRequestHandled(true)를 호출해 응답을 직렬화한 뒤 mvnull로 반환한다.

postHandle 시점에는 응답 본문이 이미 버퍼에 쓰여진 상태다. View 기반 Controller에서만 mv에 접근해 모델을 조작하는 것이 의미 있다.

afterCompletionException ex 파라미터도 마찬가지다. @ExceptionHandler가 처리한 예외는 exnull이다. HandlerExceptionResolver가 처리하지 못한 예외만 전달된다.

afterCompletion의 ex가 null이라고 정상은 아니다

ex == null은 “예외가 없었다”가 아니라 “예외가 처리됐다”를 의미한다. 실제 처리 결과는 response.getStatus()로 확인해야 한다.

CORS와 트레이드오프

CORS 처리는 Filter와 Interceptor 중 어디에 둘지가 실무에서 자주 틀리는 지점이다.

@CrossOrigin은 Spring MVC Interceptor로 처리된다. Spring Security가 없는 환경에서는 충분하다. 하지만 Spring Security가 있으면 Preflight OPTIONS 요청이 SecurityContextPersistenceFilter에서 401을 받고 Interceptor까지 도달하지 못한다. CORS 헤더 없는 401 응답을 받은 브라우저는 이것을 CORS 오류로 해석한다.

해결책은 HttpSecurity.cors()를 활성화해 CorsFilter를 Security Filter보다 앞에 배치하는 것이다.

http.cors(cors -> cors.configurationSource(corsConfigurationSource()));

allowedOrigins("*")allowCredentials(true) 조합은 CORS 스펙상 금지다. 자격증명이 있는 요청에 와일드카드 Origin을 허용하면 보안 취약점이 생기기 때문이다. setAllowedOriginPatterns("*")는 패턴 매칭 후 실제 Origin 값을 헤더에 반환하므로 허용된다.

트레이드오프

Filter는 Servlet 표준이라 Spring 없이도 동작하고 모든 요청을 처리하지만 Controller 정보에 접근할 수 없다. Interceptor는 HandlerMethod 정보와 Spring Bean을 완전히 활용할 수 있지만 DispatcherServlet에 도달한 요청에만 개입한다. Spring Security와 인프라 레벨 처리는 Filter, 비즈니스 로직 기반 권한 검사와 Controller 어노테이션 기반 처리는 Interceptor가 적합하다.

비동기 요청과 AfterConcurrentHandlingStarted

DeferredResultCallable을 반환하는 Controller에서는 Interceptor 콜백 순서가 달라진다.

[요청 스레드 - REQUEST dispatch]
  preHandle() → Controller 반환 DeferredResult
  afterConcurrentHandlingStarted()  ← 요청 스레드에서 리소스 정리
  요청 스레드 반환 (스레드 풀로)

[비동기 스레드 - ASYNC dispatch]
  preHandle() 재호출 → postHandle() → afterCompletion()

HandlerInterceptor를 그냥 구현하면 afterConcurrentHandlingStarted()의 기본 구현이 “아무것도 안 함”이다. preHandle에서 MDC.put()으로 설정한 ThreadLocal이 정리되지 않은 채 스레드가 풀로 반환된다. 다음 요청에서 같은 스레드를 재사용하면 이전 요청의 MDC 데이터가 그대로 남아 있다.

비동기 API가 있는 환경에서는 AsyncHandlerInterceptor를 구현하고 afterConcurrentHandlingStarted()에서 ThreadLocal을 정리해야 한다. MDC 컨텍스트를 비동기 스레드로 전파하려면 preHandle에서 스냅샷을 request.setAttribute()에 저장하고, 비동기 작업에서 복원하는 패턴을 쓴다.

정리

  • Filter는 DispatcherServlet 바깥, Interceptor는 안쪽이다. 실행 위치가 다르면 접근 가능한 정보도 다르다.
  • postHandle은 정방향, afterCompletion은 역순 + 성공한 Interceptor만. 이 차이를 모르면 리소스 정리 버그가 생긴다.
  • Spring Security가 있는 환경의 CORS는 반드시 HttpSecurity.cors()로 CorsFilter를 Security 앞에 배치해야 한다.
  • 비동기 API에서 ThreadLocal을 쓰는 Interceptor는 반드시 AsyncHandlerInterceptor를 구현하고 afterConcurrentHandlingStarted()에서 정리해야 한다.

다음 글에서는 DispatcherServlet이 Handler를 탐색하는 HandlerMapping 구조와 @RequestMapping 조건 매칭 메커니즘을 추적한다.