Spring MVC 요청 파이프라인은 어떻게 설계됐는가
Filter와 HandlerInterceptor의 실행 위치 차이부터 비동기 요청에서 ThreadLocal 오염이 발생하는 이유까지, Spring MVC 요청 처리 계층의 설계 원칙을 추적한다.
- 01 DispatcherServlet은 어떻게 HTTP 요청을 응답으로 만드는가
- 02 Spring MVC는 요청을 어떻게 핸들러에 연결하는가
- 03 Spring MVC는 파라미터를 어떻게 바인딩하는가
- 04 Spring MVC는 반환값을 어떻게 HTTP 응답으로 바꾸는가
- 05 Spring MVC 예외는 어떻게 응답이 되는가
- 06 Spring MVC 요청 파이프라인은 어떻게 설계됐는가
- 07 Spring MVC는 어떻게 요청 하나를 처리하는가
Spring MVC의 요청 처리 파이프라인에는 두 개의 횡단 관심사 레이어가 있다. 서블릿 컨테이너 레벨의 Filter와 Spring MVC 레벨의 HandlerInterceptor. 둘 다 “요청을 가로채는” 도구이지만, 실행 위치가 다르고 접근 가능한 정보도 다르다. 이 차이를 모르면 CORS 설정이 Spring Security 앞에서 왜 무너지는지, 비동기 API에서 MDC 로그가 왜 오염되는지 영원히 이해하지 못한다.
두 레이어의 실행 위치
파이프라인의 구조는 다음과 같다.
[클라이언트]
↓
[서블릿 컨테이너]
↓
[Filter Chain] ← javax.servlet.Filter
↓
[DispatcherServlet]
↓
[HandlerInterceptor Chain]
↓
[Controller 메서드]
Filter는 DispatcherServlet을 감싼다. chain.doFilter() 전후로 코드를 실행하므로 Spring MVC 전체를 포함한 예외와 응답을 다룰 수 있다. HandlerInterceptor는 DispatcherServlet 내부에 있다. DispatcherServlet에 도달한 요청에만 실행되며, 정적 리소스를 다른 서블릿이 처리하면 Interceptor는 개입하지 않는다.
가장 중요한 차이는 Controller 정보 접근이다. Filter는 어떤 Controller가 실행될지 알 수 없다. HandlerInterceptor는 preHandle의 Object handler를 HandlerMethod로 캐스팅하면 Controller 클래스, 실행될 메서드, 메서드 어노테이션까지 읽을 수 있다.
HandlerExecutionChain과 실행 순서
여러 Interceptor가 등록될 때 실행 순서는 직관적이지 않다. preHandle은 등록 순서(정방향)이지만, postHandle과 afterCompletion은 역순이다. 스택(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은 지금까지 preHandle이 true를 반환한 Interceptor에 대해서만 역순으로 호출된다. HandlerExecutionChain의 interceptorIndex 필드가 성공한 마지막 인덱스를 추적하기 때문이다.
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
postHandle의 ModelAndView mv 파라미터가 항상 채워져 있다고 가정하면 틀린다. @RestController나 @ResponseBody를 사용하면 ReturnValueHandler가 mavContainer.setRequestHandled(true)를 호출해 응답을 직렬화한 뒤 mv를 null로 반환한다.
postHandle 시점에는 응답 본문이 이미 버퍼에 쓰여진 상태다. View 기반 Controller에서만 mv에 접근해 모델을 조작하는 것이 의미 있다.
afterCompletion의 Exception ex 파라미터도 마찬가지다. @ExceptionHandler가 처리한 예외는 ex가 null이다. HandlerExceptionResolver가 처리하지 못한 예외만 전달된다.
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
DeferredResult나 Callable을 반환하는 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 조건 매칭 메커니즘을 추적한다.