본문 바로가기

디스패처 서블릿 구조

DispatcherServlet의 전략 조정, 인터셉터 생명 주기, 예외 해결, 뷰와 응답 본문 분기를 하나의 실행 가능한 MVC 경계로 검증합니다.

서블릿 컨테이너는 애노테이션이 붙은 컨트롤러 메서드를 직접 호출하지 않습니다. 컨테이너가 DispatcherServlet에 요청을 넘기면, 디스패처가 HandlerMapping으로 HandlerExecutionChain을 찾고 HandlerAdapter로 핸들러를 호출합니다. 그 뒤의 결과는 논리 뷰 경로와 응답 본문 경로, 조건에 따라 시도하는 예외 해결 경로 가운데 하나로 이어집니다. 이 구조를 알면 컨트롤러가 호출되지 않았다는 현상을 곧바로 컨트롤러의 버그로 보지 않고, 요청이 실제로 멈춘 단계를 찾을 수 있습니다.

DispatcherServlet은 전략을 조정하고 결과 경계를 분기한다

mapping·실행 체인·인자/model·handler 반환·converter 또는 view/예외의 다단계 처리이다.

Servlet request는 DispatcherServlet 하나에 들어옵니다. DispatcherServlet은 HandlerMapping에서 handler와 interceptor 목록을 담은 HandlerExecutionChain을 얻고, 그 handler를 RequestMappingHandlerAdapter에 위임합니다. Adapter는 argument resolver, binder와 model 초기화, HandlerMethod 호출, return-value handler를 조정합니다. 본문 반환값은 return-value handler가 선택한 HttpMessageConverter로 표현을 쓰고 null ModelAndView를 반환해 view 탐색을 생략합니다. non-null ModelAndView만 ViewResolver와 View.render로 이어집니다. Mapping 또는 handler 처리 중 생긴 dispatch exception에는 HandlerExceptionResolver 체인을 조건부로 시도하며, ExceptionHandlerExceptionResolver가 ControllerAdvice의 ExceptionHandler를 발견할 수 있습니다. resolver의 null은 다음 resolver를 뜻하고 모두 해결하지 못하면 예외가 전파됩니다. View rendering failure가 같은 resolver 체인으로 다시 들어간다고 주장하지 않습니다.

REQUEST → STRATEGY COORDINATION → VIEW OR BODY

DispatcherServlet은 전략을 조정하고 결과 경계를 분기한다

DispatcherServlet은 실행 순서를 소유하지만 mapping 조건, handler 시그니처, 본문 변환, view 렌더링의 결정을 직접 구현하지 않습니다.

DispatcherServlet의 전략과 뷰·본문 분기HandlerMapping에서 handler와 interceptor의 실행 체인을 얻고 RequestMappingHandlerAdapter가 인자, binding, model, 호출과 반환값 처리를 조정합니다. 본문 반환은 converter가 쓰고 null ModelAndView로 view를 생략합니다. 논리 view와 model은 ViewResolver와 View로 이어집니다. dispatch 예외 해결은 조건부 별도 경계입니다.DispatcherServletrequest 조정HandlerMappinghandler +interceptorsRequestMappingHandlerAdapterinvoke + return 처리논리 view + modelViewResolver → View이 fixture: fixed test View@ResponseBodyHttpMessageConverternull ModelAndView: view 생략
Spring MVC 전략과 결과의 모바일 분기기존 데스크톱 그림과 같은 다섯 노드·네 관계이다. DispatcherServlet은 mapping에서 실행 체인을 얻고 adapter에 위임한다. 반환값 처리 뒤 뷰와 model 경로는 resolver와 view를 호출한다. 본문 경로는 converter로 쓰고 view를 생략한다. 두 결과는 선택이며 아래 두 노드를 연달아 실행하지 않는다. 예외 해결은 정상 흐름의 필수 단계가 아닌 별도 조건부 경계이다.뷰본문DispatcherServletrequest 조정HandlerMappinghandler +interceptorsRequestMappingHandlerAdapterinvoke + return 처리논리 view + modelViewResolver → Viewfixed test View@ResponseBody메시지 컨버터null ModelAndView

본문 반환은 converter가 쓰고 view를 생략합니다. 논리 view 경로의 fixed test View와 실제 JSP·Thymeleaf 렌더링·escaping은 구분합니다. 예외 리졸버는 정상 경로의 필수 직렬 단계가 아닙니다.

01 · COORDINATE

DispatcherServlet은 순서를 조정하고 전략이 결정을 소유한다

Servlet request가 들어오면 HandlerMapping에서 handler와 interceptor 목록을 담은 HandlerExecutionChain을 얻습니다. DispatcherServlet은 선택 조건이나 handler 계약을 직접 구현하지 않습니다.

02 · ADAPTER INTERNAL

RequestMappingHandlerAdapter 안에서 인자와 반환값을 해석한다

argument resolver가 HandlerMethod 인자를 만들고 binder, model 초기화, controller 호출, return-value handler가 이어집니다. @ControllerAdvice의 @InitBinder와 @ModelAttribute hook도 이 binding·model 경계에 참여할 수 있습니다.

03 · EXCEPTION RESOLUTION

mapping 또는 handler 처리 exception에만 resolver를 조건부로 시도한다

ExceptionHandlerExceptionResolver는 advice의 @ExceptionHandler를 발견할 수 있습니다. 한 resolver의 null은 다음 resolver를 뜻하고, 모두 null이면 예외가 전파됩니다. view rendering failure가 이 체인으로 다시 들어간다고 일반화하지 않습니다.

04 · RESULT BOUNDARY

body는 converter가 쓰고, 논리 view와 model이 있는 결과를 view로 보낸다

본문 반환값은 return-value handler가 선택한 HttpMessageConverter로 표현을 쓴 뒤 null ModelAndView를 반환합니다. 이 fixture의 논리 이름·model 결과는 ViewResolver와 View.render로 이어지며, null이나 empty 결과는 view를 생략한다는 뜻일 뿐 commit·status 성공 보장이 아닙니다.

DispatcherServlet은 lifecycle 순서만 조정합니다. message converter 선택은 adapter의 return-value handler, view rendering은 ViewResolver와 View, dispatch exception 해석은 조건부 resolver 체인의 책임이며 실제 template rendering·escaping은 B77의 별도 경계입니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • HandlerMapping에서 handler와 interceptor의 실행 체인을 얻고 RequestMappingHandlerAdapter가 인자, binding, model, 호출과 반환값 처리를 조정합니다. 본문 반환은 converter가 쓰고 null ModelAndView로 view를 생략합니다. 논리 view와 model은 ViewResolver와 View로 이어집니다. dispatch 예외 해결은 조건부 별도 경계입니다.
  • 기존 데스크톱 그림과 같은 다섯 노드·네 관계이다. DispatcherServlet은 mapping에서 실행 체인을 얻고 adapter에 위임한다. 반환값 처리 뒤 뷰와 model 경로는 resolver와 view를 호출한다. 본문 경로는 converter로 쓰고 view를 생략한다. 두 결과는 선택이며 아래 두 노드를 연달아 실행하지 않는다. 예외 해결은 정상 흐름의 필수 단계가 아닌 별도 조건부 경계이다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
mapping과 실행 체인

HandlerMapping에서 handler·interceptor chain을 얻습니다.

산출물: 실행 체인 · adapter: RequestMappingHandlerAdapter요청 처리 전략이 준비됩니다.
인자·model·handler

adapter가 binding·model·인자와 handler 반환을 조정합니다.

입력: 해석된 인자 · 산출물: handler 반환handler의 입력과 출력이 MVC 계약으로 바뀝니다.
본문 또는 view

본문은 converter·null ModelAndView, 논리 view는 resolver·View입니다.

본문: view 생략 · 논리 view: resolver·View · 예외: 조건부 별도반환 종류가 응답 경로를 정합니다.

DispatcherServlet은 전략을 조정하고 결과 경계를 분기한다

mapping·실행 체인·인자/model·handler 반환·converter 또는 view/예외의 다단계 처리이다.

Servlet request는 DispatcherServlet 하나에 들어옵니다. DispatcherServlet은 HandlerMapping에서 handler와 interceptor 목록을 담은 HandlerExecutionChain을 얻고, 그 handler를 RequestMappingHandlerAdapter에 위임합니다. Adapter는 argument resolver, binder와 model 초기화, HandlerMethod 호출, return-value handler를 조정합니다. 본문 반환값은 return-value handler가 선택한 HttpMessageConverter로 표현을 쓰고 null ModelAndView를 반환해 view 탐색을 생략합니다. non-null ModelAndView만 ViewResolver와 View.render로 이어집니다. Mapping 또는 handler 처리 중 생긴 dispatch exception에는 HandlerExceptionResolver 체인을 조건부로 시도하며, ExceptionHandlerExceptionResolver가 ControllerAdvice의 ExceptionHandler를 발견할 수 있습니다. resolver의 null은 다음 resolver를 뜻하고 모두 해결하지 못하면 예외가 전파됩니다. View rendering failure가 같은 resolver 체인으로 다시 들어간다고 주장하지 않습니다.

REQUEST → STRATEGY COORDINATION → VIEW OR BODY

DispatcherServlet은 전략을 조정하고 결과 경계를 분기한다

DispatcherServlet은 실행 순서를 소유하지만 mapping 조건, handler 시그니처, 본문 변환, view 렌더링의 결정을 직접 구현하지 않습니다.

DispatcherServlet의 전략과 뷰·본문 분기HandlerMapping에서 handler와 interceptor의 실행 체인을 얻고 RequestMappingHandlerAdapter가 인자, binding, model, 호출과 반환값 처리를 조정합니다. 본문 반환은 converter가 쓰고 null ModelAndView로 view를 생략합니다. 논리 view와 model은 ViewResolver와 View로 이어집니다. dispatch 예외 해결은 조건부 별도 경계입니다.DispatcherServletrequest 조정HandlerMappinghandler +interceptorsRequestMappingHandlerAdapterinvoke + return 처리논리 view + modelViewResolver → View이 fixture: fixed test View@ResponseBodyHttpMessageConverternull ModelAndView: view 생략
Spring MVC 전략과 결과의 모바일 분기기존 데스크톱 그림과 같은 다섯 노드·네 관계이다. DispatcherServlet은 mapping에서 실행 체인을 얻고 adapter에 위임한다. 반환값 처리 뒤 뷰와 model 경로는 resolver와 view를 호출한다. 본문 경로는 converter로 쓰고 view를 생략한다. 두 결과는 선택이며 아래 두 노드를 연달아 실행하지 않는다. 예외 해결은 정상 흐름의 필수 단계가 아닌 별도 조건부 경계이다.뷰본문DispatcherServletrequest 조정HandlerMappinghandler +interceptorsRequestMappingHandlerAdapterinvoke + return 처리논리 view + modelViewResolver → Viewfixed test View@ResponseBody메시지 컨버터null ModelAndView

본문 반환은 converter가 쓰고 view를 생략합니다. 논리 view 경로의 fixed test View와 실제 JSP·Thymeleaf 렌더링·escaping은 구분합니다. 예외 리졸버는 정상 경로의 필수 직렬 단계가 아닙니다.

01 · COORDINATE

DispatcherServlet은 순서를 조정하고 전략이 결정을 소유한다

Servlet request가 들어오면 HandlerMapping에서 handler와 interceptor 목록을 담은 HandlerExecutionChain을 얻습니다. DispatcherServlet은 선택 조건이나 handler 계약을 직접 구현하지 않습니다.

02 · ADAPTER INTERNAL

RequestMappingHandlerAdapter 안에서 인자와 반환값을 해석한다

argument resolver가 HandlerMethod 인자를 만들고 binder, model 초기화, controller 호출, return-value handler가 이어집니다. @ControllerAdvice의 @InitBinder와 @ModelAttribute hook도 이 binding·model 경계에 참여할 수 있습니다.

03 · EXCEPTION RESOLUTION

mapping 또는 handler 처리 exception에만 resolver를 조건부로 시도한다

ExceptionHandlerExceptionResolver는 advice의 @ExceptionHandler를 발견할 수 있습니다. 한 resolver의 null은 다음 resolver를 뜻하고, 모두 null이면 예외가 전파됩니다. view rendering failure가 이 체인으로 다시 들어간다고 일반화하지 않습니다.

04 · RESULT BOUNDARY

body는 converter가 쓰고, 논리 view와 model이 있는 결과를 view로 보낸다

본문 반환값은 return-value handler가 선택한 HttpMessageConverter로 표현을 쓴 뒤 null ModelAndView를 반환합니다. 이 fixture의 논리 이름·model 결과는 ViewResolver와 View.render로 이어지며, null이나 empty 결과는 view를 생략한다는 뜻일 뿐 commit·status 성공 보장이 아닙니다.

DispatcherServlet은 lifecycle 순서만 조정합니다. message converter 선택은 adapter의 return-value handler, view rendering은 ViewResolver와 View, dispatch exception 해석은 조건부 resolver 체인의 책임이며 실제 template rendering·escaping은 B77의 별도 경계입니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • HandlerMapping에서 handler와 interceptor의 실행 체인을 얻고 RequestMappingHandlerAdapter가 인자, binding, model, 호출과 반환값 처리를 조정합니다. 본문 반환은 converter가 쓰고 null ModelAndView로 view를 생략합니다. 논리 view와 model은 ViewResolver와 View로 이어집니다. dispatch 예외 해결은 조건부 별도 경계입니다.
  • 기존 데스크톱 그림과 같은 다섯 노드·네 관계이다. DispatcherServlet은 mapping에서 실행 체인을 얻고 adapter에 위임한다. 반환값 처리 뒤 뷰와 model 경로는 resolver와 view를 호출한다. 본문 경로는 converter로 쓰고 view를 생략한다. 두 결과는 선택이며 아래 두 노드를 연달아 실행하지 않는다. 예외 해결은 정상 흐름의 필수 단계가 아닌 별도 조건부 경계이다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
mapping과 실행 체인

HandlerMapping에서 handler·interceptor chain을 얻습니다.

산출물: 실행 체인 · adapter: RequestMappingHandlerAdapter요청 처리 전략이 준비됩니다.
인자·model·handler

adapter가 binding·model·인자와 handler 반환을 조정합니다.

입력: 해석된 인자 · 산출물: handler 반환handler의 입력과 출력이 MVC 계약으로 바뀝니다.
본문 또는 view

본문은 converter·null ModelAndView, 논리 view는 resolver·View입니다.

본문: view 생략 · 논리 view: resolver·View · 예외: 조건부 별도반환 종류가 응답 경로를 정합니다.

그림은 DispatcherServlet이 순서만 조정하고 결정은 각 전략에 맡기는 모습을 보여 줍니다. HandlerMapping에서 핸들러와 인터셉터를 담은 실행 체인을 얻으면, RequestMappingHandlerAdapter가 인자와 바인딩, 모델, 호출과 반환 값 처리를 조정합니다. 본문으로 반환된 값은 메시지 컨버터가 쓰고 null인 ModelAndView로 뷰 탐색을 건너뛰며, 논리 뷰와 모델은 ViewResolver와 View로 이어집니다. 두 결과는 하나만 선택되는 분기이고, 예외 해결은 정상 흐름에 반드시 끼는 단계가 아닙니다. 그림 속 B77은 서블릿·JSP 책임을 가리킵니다.


전략은 같은 역할이 아닙니다

DispatcherServlet은 요청 전체를 조정하지만 각 세부 동작을 대신 구현하지 않습니다.

구성 요소입력확인할 수 있는 결과
HandlerMapping요청 경로와 메서드, 조건핸들러와 인터셉터를 묶은 HandlerExecutionChain
RequestMappingHandlerAdapter선택된 HandlerMethod인자 해석, 컨트롤러 호출, 반환 값 처리
반환 값 처리기컨트롤러의 반환 값논리 뷰와 모델, 또는 이미 처리된 응답
ViewResolver논리 뷰 이름실행할 View
HttpMessageConverter본문 값과 미디어 타입응답 본문의 바이트
HandlerExceptionResolver매핑이나 핸들러 처리 중의 예외오류 ModelAndView, 이미 처리된 결과, 또는 다음 리졸버

HandlerMethodArgumentResolver는 호출 전에 메서드 인자를 만들고, 반환 값 처리기는 호출 뒤의 String이나 ResponseEntity, @ResponseBody 값을 MVC의 의미로 해석합니다. ViewResolver는 논리 이름을 View로 바꾸며, 메시지 컨버터는 뷰를 찾지 않고 본문을 읽거나 씁니다. HandlerExceptionResolver는 성공 경로에 반드시 거치는 단계가 아니라, 매핑이나 핸들러 처리에서 예외가 났을 때만 순서대로 시도됩니다.

더 깊이: 전략 빈이 없을 때 DispatcherServlet이 쓰는 기본값

DispatcherServlet은 시작할 때 initStrategies에서 multipart 리졸버, 로케일 리졸버, 핸들러 매핑, 핸들러 어댑터, 예외 리졸버, 뷰 이름 번역기, 뷰 리졸버, 플래시 맵 관리자를 차례로 준비합니다. 핸들러 매핑과 어댑터는 detectAllHandlerMappings와 detectAllHandlerAdapters가 기본값 true라서 컨텍스트와 그 조상 컨텍스트에서 해당 타입의 빈을 모두 찾고, 매핑은 순서 값으로 정렬해 둡니다. 맞는 빈이 하나도 없을 때만 같은 패키지의 DispatcherServlet.properties에 적힌 기본 구현을 씁니다. Spring Framework 7.0.10의 이 파일에 적힌 기본값은 다음과 같습니다.

전략빈이 없을 때의 기본 구현
HandlerMappingBeanNameUrlHandlerMapping, RequestMappingHandlerMapping, RouterFunctionMapping
HandlerAdapterHttpRequestHandlerAdapter, SimpleControllerHandlerAdapter, RequestMappingHandlerAdapter, HandlerFunctionAdapter
HandlerExceptionResolverExceptionHandlerExceptionResolver, ResponseStatusExceptionResolver, DefaultHandlerExceptionResolver
RequestToViewNameTranslatorDefaultRequestToViewNameTranslator
ViewResolverInternalResourceViewResolver
LocaleResolverAcceptHeaderLocaleResolver
FlashMapManagerSessionFlashMapManager

파일의 주석은 이 목록이 맞는 빈을 찾지 못했을 때의 대체값일 뿐이며 애플리케이션 개발자가 고치라고 둔 것이 아니라고 밝힙니다. Boot의 MVC 자동 구성처럼 전략을 빈으로 등록하는 구성에서는 이 파일이 쓰이지 않고, 등록된 빈과 그 순서가 실제 동작을 정합니다. 그래서 핸들러 매핑 빈을 하나 직접 등록하면 기본 목록에 더해지는 것이 아니라 탐색된 빈 목록이 그대로 쓰인다는 점에 주의합니다.


뷰와 본문은 반환 값 처리 뒤에 갈라집니다

일반 @Controller 메서드가 돌려준 String은 이 예제에서 post-detail이라는 논리 뷰 이름입니다. @ResponseBody나 @RestController의 값은 반환 값 처리기가 메시지 컨버터로 보내며, ViewResolver를 지나지 않습니다.

Spring Framework 7.0.10의 JacksonJsonHttpMessageConverter는 Jackson 3의 tools.jackson.databind.json.JsonMapper를 씁니다. 기본 생성자는 ProblemDetail용 믹스인(mixin)도 설치하므로, setProperty("code", ...)로 더한 확장 멤버를 JSON의 최상위 필드로 쓸 수 있습니다.

다음 파일은 Java 25와 Spring Boot 4.1.2 BOM, Spring Framework 7.0.10, 서블릿 6.1, JUnit 6.0.3, AssertJ 3.27.7을 기준으로 하는 완결된 컴파일 단위입니다. MockMvcBuilders.standaloneSetup은 DispatcherServlet과 독립형 빌더가 구성한 MVC 구성 요소를 거쳐, 이 예제가 직접 등록한 컨트롤러와 어드바이스, 인터셉터, 뷰 리졸버, 메시지 컨버터를 모의 서블릿 요청과 응답으로 실행합니다. 운영 환경의 ApplicationContext나 실제 서버를 띄우는 것은 아닙니다.

src/test/java/board/mvc/DispatcherServletBoundaryTest.java
package board.mvc;

import static org.assertj.core.api.Assertions.assertThat;
import static org.springframework.test.web.servlet.request
        .MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request
        .MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result
        .MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result
        .MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result
        .MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result
        .MockMvcResultMatchers.model;
import static org.springframework.test.web.servlet.result
        .MockMvcResultMatchers.status;
import static org.springframework.test.web.servlet.result
        .MockMvcResultMatchers.view;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicInteger;
import org.junit.jupiter.api.Test;
import org.springframework.core.ResolvableType;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpOutputMessage;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ProblemDetail;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotWritableException;
import org.springframework.http.converter.json
        .JacksonJsonHttpMessageConverter;
import org.springframework.stereotype.Controller;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation
        .MethodArgumentTypeMismatchException;
import org.springframework.web.servlet.HandlerInterceptor;
import org.springframework.web.servlet.ModelAndView;
import org.springframework.web.servlet.View;
import org.springframework.web.servlet.ViewResolver;

class DispatcherServletBoundaryTest {
    private static final String POST_DETAIL = "post-detail";

    @Test
    void viewSuccessRunsInterceptorsAndFixedViewInExactOrder()
            throws Exception {
        var fixture = fixture();
        var expectedPage =
                new PostPageModel(42L, "Dispatcher boundary");

        fixture.mvc()
                .perform(get("/mvc/posts/42"))
                .andExpect(status().isOk())
                .andExpect(view().name(POST_DETAIL))
                .andExpect(model().attribute(
                        "post", expectedPage))
                .andExpect(content().string(
                        "post-detail:42"));

        assertThat(fixture.ledger().snapshot())
                .containsExactly(
                        "outer.pre:true",
                        "inner.pre:true",
                        "controller.view",
                        "inner.post:model-view",
                        "outer.post:model-view",
                        "view.resolve:post-detail",
                        "view.render",
                        "inner.after:null",
                        "outer.after:null");
        assertThat(fixture.counters().viewCalls()).isOne();
        assertThat(fixture.counters().totalCalls()).isOne();
        assertThat(fixture.views().lookupCalls()).isOne();
        assertThat(fixture.views().lastLogicalName())
                .isEqualTo(POST_DETAIL);
        assertThat(fixture.view().renderCalls()).isOne();
        assertThat(fixture.view().lastPage())
                .isExactlyInstanceOf(PostPageModel.class)
                .isEqualTo(expectedPage);
        assertThat(fixture.converter().successfulWrites()).isZero();
    }

    @Test
    void responseBodyUsesConverterAndSkipsViewResolver()
            throws Exception {
        var fixture = fixture();

        fixture.mvc()
                .perform(get("/mvc/body")
                        .accept(MediaType.APPLICATION_JSON))
                .andExpect(status().isOk())
                .andExpect(content().contentTypeCompatibleWith(
                        MediaType.APPLICATION_JSON))
                .andExpect(jsonPath("$.id").value(42))
                .andExpect(jsonPath("$.status").value("ok"));

        assertThat(fixture.ledger().snapshot())
                .containsExactly(
                        "outer.pre:true",
                        "inner.pre:true",
                        "controller.body",
                        "converter.write",
                        "inner.post:null",
                        "outer.post:null",
                        "inner.after:null",
                        "outer.after:null");
        assertThat(fixture.counters().bodyCalls()).isOne();
        assertThat(fixture.counters().totalCalls()).isOne();
        assertThat(fixture.converter().successfulWrites()).isOne();
        assertThat(fixture.views().lookupCalls()).isZero();
        assertThat(fixture.view().renderCalls()).isZero();
        assertThat(fixture.outer().nullModelAndViewPostCalls())
                .isOne();
        assertThat(fixture.inner().nullModelAndViewPostCalls())
                .isOne();
    }

    @Test
    void secondPreHandleFalseCompletesOnlyEnteredOuterInterceptor()
            throws Exception {
        var fixture = fixture();

        fixture.mvc()
                .perform(get("/mvc/blocked"))
                .andReturn();

        assertThat(fixture.ledger().snapshot())
                .containsExactly(
                        "outer.pre:true",
                        "inner.pre:false",
                        "outer.after:null");
        assertThat(fixture.counters().totalCalls()).isZero();
        assertThat(fixture.outer().preCalls()).isOne();
        assertThat(fixture.inner().preCalls()).isOne();
        assertThat(fixture.outer().postCalls()).isZero();
        assertThat(fixture.inner().postCalls()).isZero();
        assertThat(fixture.outer().afterCalls()).isOne();
        assertThat(fixture.inner().afterCalls()).isZero();
        assertThat(fixture.views().lookupCalls()).isZero();
        assertThat(fixture.view().renderCalls()).isZero();
        assertThat(fixture.converter().successfulWrites()).isZero();
    }

    @Test
    void resolvedControllerExceptionSkipsPostHandleAndCompletesWithNullFailure()
            throws Exception {
        var fixture = fixture();

        fixture.mvc()
                .perform(get("/mvc/failure")
                        .accept(MediaType.APPLICATION_PROBLEM_JSON))
                .andExpect(status().isConflict())
                .andExpect(content().contentTypeCompatibleWith(
                        MediaType.APPLICATION_PROBLEM_JSON))
                .andExpect(jsonPath("$.status").value(409))
                .andExpect(jsonPath("$.code")
                        .value("POST_DUPLICATE"));

        assertThat(fixture.ledger().snapshot())
                .containsExactly(
                        "outer.pre:true",
                        "inner.pre:true",
                        "controller.failure",
                        "advice.duplicate",
                        "converter.write",
                        "inner.after:null",
                        "outer.after:null");
        assertThat(fixture.counters().failureCalls()).isOne();
        assertThat(fixture.counters().adviceCalls()).isOne();
        assertThat(fixture.outer().postCalls()).isZero();
        assertThat(fixture.inner().postCalls()).isZero();
        assertThat(fixture.outer().afterCalls()).isOne();
        assertThat(fixture.inner().afterCalls()).isOne();
        assertThat(fixture.outer().lastCompletionFailure()).isNull();
        assertThat(fixture.inner().lastCompletionFailure()).isNull();
        assertThat(fixture.views().lookupCalls()).isZero();
        assertThat(fixture.view().renderCalls()).isZero();
        assertThat(fixture.converter().successfulWrites()).isOne();
    }

    @Test
    void argumentConversionFailureReturns400BeforeController()
            throws Exception {
        var fixture = fixture();

        var result = fixture.mvc()
                .perform(get("/mvc/convert/not-a-number"))
                .andExpect(status().isBadRequest())
                .andReturn();

        assertThat(result.getResolvedException())
                .isInstanceOf(
                        MethodArgumentTypeMismatchException.class);
        assertThat(fixture.ledger().snapshot())
                .containsExactly(
                        "outer.pre:true",
                        "inner.pre:true",
                        "inner.after:null",
                        "outer.after:null");
        assertThat(fixture.counters().totalCalls()).isZero();
        assertThat(fixture.outer().preCalls()).isOne();
        assertThat(fixture.inner().preCalls()).isOne();
        assertThat(fixture.outer().postCalls()).isZero();
        assertThat(fixture.inner().postCalls()).isZero();
        assertThat(fixture.outer().afterCalls()).isOne();
        assertThat(fixture.inner().afterCalls()).isOne();
        assertThat(fixture.outer().lastCompletionFailure()).isNull();
        assertThat(fixture.inner().lastCompletionFailure()).isNull();
        assertThat(fixture.views().lookupCalls()).isZero();
        assertThat(fixture.view().renderCalls()).isZero();
        assertThat(fixture.converter().successfulWrites()).isZero();
    }

    @Test
    void methodMismatchReturns405WithAllowBeforeInterceptors()
            throws Exception {
        var fixture = fixture();

        fixture.mvc()
                .perform(post("/mvc/method"))
                .andExpect(status().isMethodNotAllowed())
                .andExpect(header().string(
                        HttpHeaders.ALLOW, "GET"));

        assertThat(fixture.ledger().snapshot()).isEmpty();
        assertThat(fixture.outer().preCalls()).isZero();
        assertThat(fixture.inner().preCalls()).isZero();
        assertThat(fixture.counters().totalCalls()).isZero();
        assertThat(fixture.views().lookupCalls()).isZero();
        assertThat(fixture.view().renderCalls()).isZero();
        assertThat(fixture.converter().successfulWrites()).isZero();
    }

    @Test
    void missingHandlerReturns404WithoutDownstreamCalls()
            throws Exception {
        var fixture = fixture();

        fixture.mvc()
                .perform(get("/mvc/missing"))
                .andExpect(status().isNotFound());

        assertThat(fixture.ledger().snapshot()).isEmpty();
        assertThat(fixture.outer().preCalls()).isZero();
        assertThat(fixture.inner().preCalls()).isZero();
        assertThat(fixture.counters().totalCalls()).isZero();
        assertThat(fixture.counters().adviceCalls()).isZero();
        assertThat(fixture.views().lookupCalls()).isZero();
        assertThat(fixture.view().renderCalls()).isZero();
        assertThat(fixture.converter().successfulWrites()).isZero();
    }

    private static SpringFixture fixture() {
        var ledger = new EventLedger();
        var counters = new ControllerCounters();
        var view = new FixedTestView(ledger);
        var views = ExactViewRegistry.postDetailOnly(
                ledger, view);
        var converter = new RecordingJacksonConverter(
                ledger);
        var outer = new RecordingInterceptor(
                "outer", ledger, null);
        var inner = new RecordingInterceptor(
                "inner", ledger, "/mvc/blocked");
        var controller = new BoundaryController(
                ledger, counters);
        var advice = new BoundaryAdvice(
                ledger, counters);

        MockMvc mvc = MockMvcBuilders
                .standaloneSetup(controller)
                .setControllerAdvice(advice)
                .addInterceptors(outer, inner)
                .setViewResolvers(views)
                .setMessageConverters(converter)
                .build();

        return new SpringFixture(
                mvc,
                ledger,
                counters,
                outer,
                inner,
                views,
                view,
                converter);
    }

    record SpringFixture(
            MockMvc mvc,
            EventLedger ledger,
            ControllerCounters counters,
            RecordingInterceptor outer,
            RecordingInterceptor inner,
            ExactViewRegistry views,
            FixedTestView view,
            RecordingJacksonConverter converter) {}

    record PostPageModel(long id, String title) {
        PostPageModel {
            if (id <= 0) {
                throw new IllegalArgumentException(
                        "id must be positive");
            }
            title = Objects.requireNonNull(title, "title");
        }
    }

    record PostResponse(long id, String status) {
        PostResponse {
            if (id <= 0) {
                throw new IllegalArgumentException(
                        "id must be positive");
            }
            status = Objects.requireNonNull(status, "status");
        }
    }

    @Controller
    @RequestMapping("/mvc")
    static final class BoundaryController {
        private final EventLedger ledger;
        private final ControllerCounters counters;

        BoundaryController(
                EventLedger ledger,
                ControllerCounters counters) {
            this.ledger = Objects.requireNonNull(
                    ledger, "ledger");
            this.counters = Objects.requireNonNull(
                    counters, "counters");
        }

        @GetMapping("/posts/{id}")
        String view(
                @PathVariable("id") long id,
                Model model) {
            counters.recordView();
            ledger.add("controller.view");
            model.addAttribute(
                    "post",
                    new PostPageModel(
                            id, "Dispatcher boundary"));
            return POST_DETAIL;
        }

        @ResponseBody
        @GetMapping(
                value = "/body",
                produces = MediaType.APPLICATION_JSON_VALUE)
        PostResponse body() {
            counters.recordBody();
            ledger.add("controller.body");
            return new PostResponse(42L, "ok");
        }

        @GetMapping("/blocked")
        String blocked() {
            counters.recordBlocked();
            ledger.add("controller.blocked");
            return POST_DETAIL;
        }

        @GetMapping("/failure")
        String failure() {
            counters.recordFailure();
            ledger.add("controller.failure");
            throw new DuplicatePostException(
                    "A post already exists for that day");
        }

        @ResponseBody
        @GetMapping(
                value = "/convert/{id}",
                produces = MediaType.APPLICATION_JSON_VALUE)
        PostResponse convert(
                @PathVariable("id") long id) {
            counters.recordConversion();
            ledger.add("controller.convert");
            return new PostResponse(id, "converted");
        }

        @ResponseBody
        @GetMapping(
                value = "/method",
                produces = MediaType.APPLICATION_JSON_VALUE)
        PostResponse method() {
            counters.recordMethod();
            ledger.add("controller.method");
            return new PostResponse(42L, "method");
        }
    }

    @RestControllerAdvice
    static final class BoundaryAdvice {
        private final EventLedger ledger;
        private final ControllerCounters counters;

        BoundaryAdvice(
                EventLedger ledger,
                ControllerCounters counters) {
            this.ledger = Objects.requireNonNull(
                    ledger, "ledger");
            this.counters = Objects.requireNonNull(
                    counters, "counters");
        }

        @ExceptionHandler(DuplicatePostException.class)
        ResponseEntity<ProblemDetail> duplicate(
                DuplicatePostException failure) {
            Objects.requireNonNull(failure, "failure");
            counters.recordAdvice();
            ledger.add("advice.duplicate");

            var problem = ProblemDetail.forStatusAndDetail(
                    HttpStatus.CONFLICT,
                    "A post already exists for that day");
            problem.setTitle("Duplicate post");
            problem.setProperty(
                    "code", "POST_DUPLICATE");

            return ResponseEntity
                    .status(HttpStatus.CONFLICT)
                    .contentType(
                            MediaType.APPLICATION_PROBLEM_JSON)
                    .body(problem);
        }
    }

    static final class RecordingInterceptor
            implements HandlerInterceptor {
        private final String name;
        private final EventLedger ledger;
        private final String rejectedPath;
        private final AtomicInteger preCalls =
                new AtomicInteger();
        private final AtomicInteger postCalls =
                new AtomicInteger();
        private final AtomicInteger nullModelAndViewPostCalls =
                new AtomicInteger();
        private final AtomicInteger afterCalls =
                new AtomicInteger();
        private Exception lastCompletionFailure;

        RecordingInterceptor(
                String name,
                EventLedger ledger,
                String rejectedPath) {
            this.name = Objects.requireNonNull(name, "name");
            this.ledger = Objects.requireNonNull(
                    ledger, "ledger");
            this.rejectedPath = rejectedPath;
        }

        @Override
        public boolean preHandle(
                HttpServletRequest request,
                HttpServletResponse response,
                Object handler) {
            preCalls.incrementAndGet();
            var admitted = rejectedPath == null
                    || !rejectedPath.equals(
                            request.getRequestURI());
            ledger.add(
                    name + ".pre:" + admitted);
            return admitted;
        }

        @Override
        public void postHandle(
                HttpServletRequest request,
                HttpServletResponse response,
                Object handler,
                ModelAndView modelAndView) {
            postCalls.incrementAndGet();
            var shape = modelAndView == null
                    ? "null"
                    : "model-view";
            if (modelAndView == null) {
                nullModelAndViewPostCalls.incrementAndGet();
            }
            ledger.add(name + ".post:" + shape);
        }

        @Override
        public void afterCompletion(
                HttpServletRequest request,
                HttpServletResponse response,
                Object handler,
                Exception failure) {
            afterCalls.incrementAndGet();
            lastCompletionFailure = failure;
            ledger.add(
                    name
                            + ".after:"
                            + (failure == null
                                    ? "null"
                                    : failure
                                            .getClass()
                                            .getSimpleName()));
        }

        int preCalls() {
            return preCalls.get();
        }

        int postCalls() {
            return postCalls.get();
        }

        int nullModelAndViewPostCalls() {
            return nullModelAndViewPostCalls.get();
        }

        int afterCalls() {
            return afterCalls.get();
        }

        Exception lastCompletionFailure() {
            return lastCompletionFailure;
        }
    }

    static final class RecordingJacksonConverter
            extends JacksonJsonHttpMessageConverter {
        private final EventLedger ledger;
        private final AtomicInteger successfulWrites =
                new AtomicInteger();

        RecordingJacksonConverter(EventLedger ledger) {
            this.ledger = Objects.requireNonNull(
                    ledger, "ledger");
        }

        @Override
        protected void writeInternal(
                Object value,
                ResolvableType type,
                HttpOutputMessage output,
                Map<String, Object> hints)
                throws IOException,
                        HttpMessageNotWritableException {
            super.writeInternal(
                    value, type, output, hints);
            successfulWrites.incrementAndGet();
            ledger.add("converter.write");
        }

        int successfulWrites() {
            return successfulWrites.get();
        }
    }

    static final class ExactViewRegistry
            implements ViewResolver {
        private final EventLedger ledger;
        private final View fixedView;
        private final AtomicInteger lookupCalls =
                new AtomicInteger();
        private String lastLogicalName;

        private ExactViewRegistry(
                EventLedger ledger,
                View fixedView) {
            this.ledger = Objects.requireNonNull(
                    ledger, "ledger");
            this.fixedView = Objects.requireNonNull(
                    fixedView, "fixedView");
        }

        static ExactViewRegistry postDetailOnly(
                EventLedger ledger,
                View fixedView) {
            return new ExactViewRegistry(
                    ledger, fixedView);
        }

        @Override
        public View resolveViewName(
                String viewName,
                Locale locale) {
            Objects.requireNonNull(locale, "locale");
            lookupCalls.incrementAndGet();
            lastLogicalName = viewName;
            ledger.add("view.resolve:" + viewName);
            if (!POST_DETAIL.equals(viewName)) {
                throw new IllegalArgumentException(
                        "Unknown logical view: " + viewName);
            }
            return fixedView;
        }

        int lookupCalls() {
            return lookupCalls.get();
        }

        String lastLogicalName() {
            return lastLogicalName;
        }
    }

    static final class FixedTestView implements View {
        private final EventLedger ledger;
        private final AtomicInteger renderCalls =
                new AtomicInteger();
        private PostPageModel lastPage;

        FixedTestView(EventLedger ledger) {
            this.ledger = Objects.requireNonNull(
                    ledger, "ledger");
        }

        @Override
        public void render(
                Map<String, ?> model,
                HttpServletRequest request,
                HttpServletResponse response)
                throws IOException {
            renderCalls.incrementAndGet();
            ledger.add("view.render");
            var value = model.get("post");
            if (!(value instanceof PostPageModel page)) {
                throw new IllegalArgumentException(
                        "post must be PostPageModel");
            }
            lastPage = page;
            response.setContentType(
                    MediaType.TEXT_PLAIN_VALUE);
            response.getWriter().write(
                    POST_DETAIL + ":" + page.id());
        }

        int renderCalls() {
            return renderCalls.get();
        }

        PostPageModel lastPage() {
            return lastPage;
        }
    }

    static final class ControllerCounters {
        private final AtomicInteger total =
                new AtomicInteger();
        private final AtomicInteger view =
                new AtomicInteger();
        private final AtomicInteger body =
                new AtomicInteger();
        private final AtomicInteger blocked =
                new AtomicInteger();
        private final AtomicInteger failure =
                new AtomicInteger();
        private final AtomicInteger conversion =
                new AtomicInteger();
        private final AtomicInteger method =
                new AtomicInteger();
        private final AtomicInteger advice =
                new AtomicInteger();

        void recordView() {
            total.incrementAndGet();
            view.incrementAndGet();
        }

        void recordBody() {
            total.incrementAndGet();
            body.incrementAndGet();
        }

        void recordBlocked() {
            total.incrementAndGet();
            blocked.incrementAndGet();
        }

        void recordFailure() {
            total.incrementAndGet();
            failure.incrementAndGet();
        }

        void recordConversion() {
            total.incrementAndGet();
            conversion.incrementAndGet();
        }

        void recordMethod() {
            total.incrementAndGet();
            method.incrementAndGet();
        }

        void recordAdvice() {
            advice.incrementAndGet();
        }

        int totalCalls() {
            return total.get();
        }

        int viewCalls() {
            return view.get();
        }

        int bodyCalls() {
            return body.get();
        }

        int failureCalls() {
            return failure.get();
        }

        int adviceCalls() {
            return advice.get();
        }
    }

    static final class EventLedger {
        private final List<String> events =
                new ArrayList<>();

        void add(String event) {
            events.add(
                    Objects.requireNonNull(
                            event, "event"));
        }

        List<String> snapshot() {
            return List.copyOf(events);
        }
    }

    static final class DuplicatePostException
            extends RuntimeException {
        DuplicatePostException(String message) {
            super(message);
        }
    }
}

첫 번째 테스트는 등록 순서의 preHandle, 컨트롤러, 역순의 postHandle, post-detail의 정확한 조회, 고정된 테스트 뷰, 역순의 afterCompletion을 하나의 이벤트 열로 확인합니다. PostPageModel은 MVC 역할 경계에서 만든 페이지 모델과 고정 논리 이름을 그대로 쓸 뿐, 실제 JSP나 Thymeleaf 템플릿을 실행하지 않습니다.

두 번째 테스트는 Jackson 3 컨버터가 성공적으로 쓴 횟수와 JSON 결과를 보고, ViewResolver와 View가 호출되지 않았는지도 함께 확인합니다. 응답 본문 경로에서는 어댑터가 이미 본문을 썼을 수 있으므로 postHandle이 받는 ModelAndView가 null일 수 있고, 그 콜백으로 이미 쓴 본문을 바꾸려 해서는 안 됩니다.


진입한 인터셉터만 완료됩니다

진입한 인터셉터만 역순으로 완료된다

A/B pre·handler·B/A post·view·B/A after의 실제 정상 이벤트 순서가 있다.

HandlerMapping이 HandlerExecutionChain을 반환한 동기 dispatch에서 DispatcherServlet은 outer Interceptor A와 inner Interceptor B의 preHandle을 등록 순서로 호출합니다. A와 B가 모두 true이면 adapter와 controller가 정상 반환한 뒤 postHandle은 B에서 A 순서로 실행되고, 결과 처리와 필요한 view rendering 뒤 afterCompletion도 B에서 A 순서로 실행됩니다. A는 true이고 B가 false이면 B 자신은 entered interceptor가 아니므로 handler, postHandle, result 또는 view 경계가 호출되지 않고 A만 afterCompletion을 받습니다. Resolver가 controller exception을 처리한 경우 postHandle은 생략되고 진입한 interceptor의 afterCompletion에는 null failure가 전달될 수 있습니다. Async handling이 시작된 초기 dispatch는 postHandle과 afterCompletion을 호출하지 않고, 완료 후 redispatch가 있을 때 콜백이 다시 실행됩니다. timeout 또는 network error로 redispatch가 없으면 완료 콜백도 없습니다. AsyncHandlerInterceptor의 afterConcurrentHandlingStarted가 thread-bound cleanup 경계를 제공합니다.

ENTER IN ORDER → COMPLETE IN REVERSE

진입한 인터셉터만 역순으로 완료된다

preHandle()이 true로 끝난 interceptor만 entered stack에 쌓이며, 후속 callback은 그 stack을 역순으로 사용합니다.

다음 두 그림은 서로 다른 동기 경우를 비교합니다. 화살표는 시간 순서이며 A가 B를 직접 호출한다는 뜻은 아닙니다. 첫 그림은 뷰 경로가 성공한 경우만 다룹니다.

뷰 경로의 동기 정상 반환과 역순 완료화살표는 호출 주체 사이의 메시지가 아닌 이벤트의 시간 순서이다. A와 B가 true를 반환하고 handler가 ModelAndView로 정상 반환한다. B.post, A.post 다음 등록 View를 렌더링하고 B.after(null), A.after(null)로 끝난다. 별도 본문 경로의 converter 쓰기 순서를 이 뷰 그림으로 추정하지 않는다. async나 실패는 이 정상 경로에 포함되지 않는다.A.pre(true)완료 대상 진입B.pre(true)완료 대상 진입handler 정상 반환ModelAndViewB.post역순 반환 콜백A.post역순 반환 콜백등록 View뷰 렌더링B.after(null)역순 완료A.after(null)역순 완료
뷰 경로의 동기 정상 반환과 역순 완료화살표는 호출 주체 사이의 메시지가 아닌 이벤트의 시간 순서이다. A와 B가 true를 반환하고 handler가 ModelAndView로 정상 반환한다. B.post, A.post 다음 등록 View를 렌더링하고 B.after(null), A.after(null)로 끝난다. 별도 본문 경로의 converter 쓰기 순서를 이 뷰 그림으로 추정하지 않는다. async나 실패는 이 정상 경로에 포함되지 않는다.A.pre(true)완료 대상 진입B.pre(true)완료 대상 진입handler 정상 반환ModelAndViewB.post역순 반환 콜백A.post역순 반환 콜백등록 View뷰 렌더링B.after(null)역순 완료A.after(null)역순 완료
B가 거절한 동기 디스패치화살표는 시간 순서이다. A.pre는 true로 완료 대상에 진입하고 B.pre는 false로 거절한다. A.after(null)만 호출되며 B.after, handler, postHandle, view는 없다. 이 그림에서 거절이 HTTP 403 같은 특정 응답을 만든다고 주장하지 않는다.A.pre(true)완료 대상 진입B.pre(false)완료 대상 아님A.after(null)진입한 A만 완료
B가 거절한 동기 디스패치화살표는 시간 순서이다. A.pre는 true로 완료 대상에 진입하고 B.pre는 false로 거절한다. A.after(null)만 호출되며 B.after, handler, postHandle, view는 없다. 이 그림에서 거절이 HTTP 403 같은 특정 응답을 만든다고 주장하지 않는다.A.pre(true)완료 대상 진입B.pre(false)완료 대상 아님A.after(null)진입한 A만 완료

동기 정상 반환 · 시간 순서

두 preHandle이 true인 경우

A.pre(true) → B.pre(true) → handler 다음, B.post → A.post가 실행됩니다. 필요한 결과 처리·View 뒤 B.after(null) → A.after(null)로 완료합니다.

동기 중단 · 시간 순서

A가 true이고 B가 false인 경우

A.pre(true) → B.pre(false) → A.after(null)로 끝납니다. B는 완료 대상에 진입하지 않았으므로 B.after, handler, postHandle, View가 모두 실행되지 않습니다.

01 · SELECTED HANDLER SCOPE

HandlerExecutionChain을 얻은 동기 dispatch에서 callback이 시작된다

preHandle()은 interceptor 등록 순서로 실행됩니다. 자신의 호출이 정상적으로 끝나고 true를 반환한 interceptor만 entered stack에 들어갑니다. Mapping 전에 끝난 404나 405에는 이 chain이 진입하지 않습니다.

02 · NORMAL RETURN

A와 B가 true이면 postHandle과 afterCompletion은 B에서 A로 간다

HandlerAdapter와 controller가 정상 반환한 뒤 postHandle은 B→A, 결과 처리와 필요한 view rendering 뒤 afterCompletion도 B→A 순서입니다. Body path에서는 ModelAndView가 null일 수 있고 postHandle은 이미 쓴 body를 바꾸는 경계가 아닙니다.

03 · PREHANDLE FALSE

A는 true이고 B가 false이면 B는 완료 대상이 아니다

Handler, postHandle, result와 view 경계는 호출되지 않습니다. B 자신의 preHandle이 false였으므로 B는 entered stack에 없고, 먼저 true를 반환한 A만 afterCompletion(null)을 받습니다.

04 · EXCEPTION / ASYNC GUARD

resolved exception과 async dispatch는 정상 반환 선과 구분한다

Resolver가 exception을 처리하면 postHandle은 생략되고 진입한 stack만 역순으로 완료되며 failure 인자는 null일 수 있습니다. Async handling이 시작된 초기 dispatch는 postHandle과 afterCompletion을 호출하지 않습니다. 완료 후 redispatch에서 콜백이 다시 실행되지만, timeout·network error로 redispatch가 없으면 완료 콜백도 없습니다. AsyncHandlerInterceptor의 afterConcurrentHandlingStarted가 thread-bound cleanup hook을 제공합니다. timeout·network error의 완료는 WebAsyncManager의 callback interceptor로 별도 검증합니다.

entered는 preHandle이 정상적으로 끝나 true를 반환했다는 뜻입니다. afterCompletion은 그 entered stack만 역순으로 정리하며, normal return, resolved exception, preHandle false, async redispatch의 경계를 서로 바꾸어 읽으면 안 됩니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 화살표는 호출 주체 사이의 메시지가 아닌 이벤트의 시간 순서이다. A와 B가 true를 반환하고 handler가 ModelAndView로 정상 반환한다. B.post, A.post 다음 등록 View를 렌더링하고 B.after(null), A.after(null)로 끝난다. 별도 본문 경로의 converter 쓰기 순서를 이 뷰 그림으로 추정하지 않는다. async나 실패는 이 정상 경로에 포함되지 않는다.
  • 화살표는 시간 순서이다. A.pre는 true로 완료 대상에 진입하고 B.pre는 false로 거절한다. A.after(null)만 호출되며 B.after, handler, postHandle, view는 없다. 이 그림에서 거절이 HTTP 403 같은 특정 응답을 만든다고 주장하지 않는다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
A·B preHandle

A와 B가 true를 반환하여 handler로 진행합니다.

순서: A.pre→B.pre · 결과: 모두 true정상 handler 실행이 허용됩니다.
handler와 postHandle

handler가 ModelAndView로 정상 반환하고 B.post 다음 A.post가 실행됩니다.

handler: ModelAndView · post 순서: B→A반환 모델을 두 interceptor가 역순으로 처리합니다.
View 렌더링

등록 View가 ModelAndView를 렌더링합니다.

View: 렌더링 · afterCompletion: 아직 대기모델이 정상 화면으로 출력됩니다.
역순 완료 callback

B.after(null) 다음 A.after(null)로 정상 요청을 끝냅니다.

after 순서: B→A · 예외 인자: null · 범위: 동기·정상 View 경로렌더링 뒤 완료 callback이 역순으로 실행됩니다.

진입한 인터셉터만 역순으로 완료된다

A/B pre·handler·B/A post·view·B/A after의 실제 정상 이벤트 순서가 있다.

HandlerMapping이 HandlerExecutionChain을 반환한 동기 dispatch에서 DispatcherServlet은 outer Interceptor A와 inner Interceptor B의 preHandle을 등록 순서로 호출합니다. A와 B가 모두 true이면 adapter와 controller가 정상 반환한 뒤 postHandle은 B에서 A 순서로 실행되고, 결과 처리와 필요한 view rendering 뒤 afterCompletion도 B에서 A 순서로 실행됩니다. A는 true이고 B가 false이면 B 자신은 entered interceptor가 아니므로 handler, postHandle, result 또는 view 경계가 호출되지 않고 A만 afterCompletion을 받습니다. Resolver가 controller exception을 처리한 경우 postHandle은 생략되고 진입한 interceptor의 afterCompletion에는 null failure가 전달될 수 있습니다. Async handling이 시작된 초기 dispatch는 postHandle과 afterCompletion을 호출하지 않고, 완료 후 redispatch가 있을 때 콜백이 다시 실행됩니다. timeout 또는 network error로 redispatch가 없으면 완료 콜백도 없습니다. AsyncHandlerInterceptor의 afterConcurrentHandlingStarted가 thread-bound cleanup 경계를 제공합니다.

ENTER IN ORDER → COMPLETE IN REVERSE

진입한 인터셉터만 역순으로 완료된다

preHandle()이 true로 끝난 interceptor만 entered stack에 쌓이며, 후속 callback은 그 stack을 역순으로 사용합니다.

다음 두 그림은 서로 다른 동기 경우를 비교합니다. 화살표는 시간 순서이며 A가 B를 직접 호출한다는 뜻은 아닙니다. 첫 그림은 뷰 경로가 성공한 경우만 다룹니다.

뷰 경로의 동기 정상 반환과 역순 완료화살표는 호출 주체 사이의 메시지가 아닌 이벤트의 시간 순서이다. A와 B가 true를 반환하고 handler가 ModelAndView로 정상 반환한다. B.post, A.post 다음 등록 View를 렌더링하고 B.after(null), A.after(null)로 끝난다. 별도 본문 경로의 converter 쓰기 순서를 이 뷰 그림으로 추정하지 않는다. async나 실패는 이 정상 경로에 포함되지 않는다.A.pre(true)완료 대상 진입B.pre(true)완료 대상 진입handler 정상 반환ModelAndViewB.post역순 반환 콜백A.post역순 반환 콜백등록 View뷰 렌더링B.after(null)역순 완료A.after(null)역순 완료
뷰 경로의 동기 정상 반환과 역순 완료화살표는 호출 주체 사이의 메시지가 아닌 이벤트의 시간 순서이다. A와 B가 true를 반환하고 handler가 ModelAndView로 정상 반환한다. B.post, A.post 다음 등록 View를 렌더링하고 B.after(null), A.after(null)로 끝난다. 별도 본문 경로의 converter 쓰기 순서를 이 뷰 그림으로 추정하지 않는다. async나 실패는 이 정상 경로에 포함되지 않는다.A.pre(true)완료 대상 진입B.pre(true)완료 대상 진입handler 정상 반환ModelAndViewB.post역순 반환 콜백A.post역순 반환 콜백등록 View뷰 렌더링B.after(null)역순 완료A.after(null)역순 완료
B가 거절한 동기 디스패치화살표는 시간 순서이다. A.pre는 true로 완료 대상에 진입하고 B.pre는 false로 거절한다. A.after(null)만 호출되며 B.after, handler, postHandle, view는 없다. 이 그림에서 거절이 HTTP 403 같은 특정 응답을 만든다고 주장하지 않는다.A.pre(true)완료 대상 진입B.pre(false)완료 대상 아님A.after(null)진입한 A만 완료
B가 거절한 동기 디스패치화살표는 시간 순서이다. A.pre는 true로 완료 대상에 진입하고 B.pre는 false로 거절한다. A.after(null)만 호출되며 B.after, handler, postHandle, view는 없다. 이 그림에서 거절이 HTTP 403 같은 특정 응답을 만든다고 주장하지 않는다.A.pre(true)완료 대상 진입B.pre(false)완료 대상 아님A.after(null)진입한 A만 완료

동기 정상 반환 · 시간 순서

두 preHandle이 true인 경우

A.pre(true) → B.pre(true) → handler 다음, B.post → A.post가 실행됩니다. 필요한 결과 처리·View 뒤 B.after(null) → A.after(null)로 완료합니다.

동기 중단 · 시간 순서

A가 true이고 B가 false인 경우

A.pre(true) → B.pre(false) → A.after(null)로 끝납니다. B는 완료 대상에 진입하지 않았으므로 B.after, handler, postHandle, View가 모두 실행되지 않습니다.

01 · SELECTED HANDLER SCOPE

HandlerExecutionChain을 얻은 동기 dispatch에서 callback이 시작된다

preHandle()은 interceptor 등록 순서로 실행됩니다. 자신의 호출이 정상적으로 끝나고 true를 반환한 interceptor만 entered stack에 들어갑니다. Mapping 전에 끝난 404나 405에는 이 chain이 진입하지 않습니다.

02 · NORMAL RETURN

A와 B가 true이면 postHandle과 afterCompletion은 B에서 A로 간다

HandlerAdapter와 controller가 정상 반환한 뒤 postHandle은 B→A, 결과 처리와 필요한 view rendering 뒤 afterCompletion도 B→A 순서입니다. Body path에서는 ModelAndView가 null일 수 있고 postHandle은 이미 쓴 body를 바꾸는 경계가 아닙니다.

03 · PREHANDLE FALSE

A는 true이고 B가 false이면 B는 완료 대상이 아니다

Handler, postHandle, result와 view 경계는 호출되지 않습니다. B 자신의 preHandle이 false였으므로 B는 entered stack에 없고, 먼저 true를 반환한 A만 afterCompletion(null)을 받습니다.

04 · EXCEPTION / ASYNC GUARD

resolved exception과 async dispatch는 정상 반환 선과 구분한다

Resolver가 exception을 처리하면 postHandle은 생략되고 진입한 stack만 역순으로 완료되며 failure 인자는 null일 수 있습니다. Async handling이 시작된 초기 dispatch는 postHandle과 afterCompletion을 호출하지 않습니다. 완료 후 redispatch에서 콜백이 다시 실행되지만, timeout·network error로 redispatch가 없으면 완료 콜백도 없습니다. AsyncHandlerInterceptor의 afterConcurrentHandlingStarted가 thread-bound cleanup hook을 제공합니다. timeout·network error의 완료는 WebAsyncManager의 callback interceptor로 별도 검증합니다.

entered는 preHandle이 정상적으로 끝나 true를 반환했다는 뜻입니다. afterCompletion은 그 entered stack만 역순으로 정리하며, normal return, resolved exception, preHandle false, async redispatch의 경계를 서로 바꾸어 읽으면 안 됩니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 화살표는 호출 주체 사이의 메시지가 아닌 이벤트의 시간 순서이다. A와 B가 true를 반환하고 handler가 ModelAndView로 정상 반환한다. B.post, A.post 다음 등록 View를 렌더링하고 B.after(null), A.after(null)로 끝난다. 별도 본문 경로의 converter 쓰기 순서를 이 뷰 그림으로 추정하지 않는다. async나 실패는 이 정상 경로에 포함되지 않는다.
  • 화살표는 시간 순서이다. A.pre는 true로 완료 대상에 진입하고 B.pre는 false로 거절한다. A.after(null)만 호출되며 B.after, handler, postHandle, view는 없다. 이 그림에서 거절이 HTTP 403 같은 특정 응답을 만든다고 주장하지 않는다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
A·B preHandle

A와 B가 true를 반환하여 handler로 진행합니다.

순서: A.pre→B.pre · 결과: 모두 true정상 handler 실행이 허용됩니다.
handler와 postHandle

handler가 ModelAndView로 정상 반환하고 B.post 다음 A.post가 실행됩니다.

handler: ModelAndView · post 순서: B→A반환 모델을 두 interceptor가 역순으로 처리합니다.
View 렌더링

등록 View가 ModelAndView를 렌더링합니다.

View: 렌더링 · afterCompletion: 아직 대기모델이 정상 화면으로 출력됩니다.
역순 완료 callback

B.after(null) 다음 A.after(null)로 정상 요청을 끝냅니다.

after 순서: B→A · 예외 인자: null · 범위: 동기·정상 View 경로렌더링 뒤 완료 callback이 역순으로 실행됩니다.

그림은 두 동기 경우를 비교하며, 화살표는 A가 B를 호출한다는 뜻이 아니라 이벤트의 시간 순서입니다. 위쪽은 A와 B가 모두 true를 돌려주고 핸들러가 ModelAndView로 정상 반환한 경우로, B.post, A.post, 뷰 렌더링, B.after(null), A.after(null) 순서로 끝납니다. 아래쪽은 A가 true, B가 false를 돌려준 경우로, A.after(null)만 호출되고 B.after와 핸들러, postHandle, 뷰는 없습니다. 이 거절이 403 같은 특정 응답을 만든다는 뜻은 아닙니다.

preHandle은 등록 순서대로 실행되고, 각 인터셉터는 자신의 preHandle이 정상적으로 끝나 true를 돌려줘야 완료 대상에 들어갑니다. 예제에서 바깥(outer) 인터셉터가 true, 안쪽(inner) 인터셉터가 false를 돌려주면 컨트롤러와 postHandle, 뷰 작업은 모두 실행되지 않습니다. 안쪽 인터셉터 자신도 afterCompletion을 받지 않고, 이미 진입한 바깥 인터셉터만 역순 정리를 받습니다.

핸들러가 성공하면 postHandle과 afterCompletion은 역순입니다. 핸들러의 예외를 HandlerExceptionResolver가 해결하면 정상 postHandle은 실행되지 않고, 진입한 인터셉터의 afterCompletion에는 이미 해결된 예외가 아니라 null이 전달됩니다.

이 예제가 다루는 범위는 동기 디스패치뿐입니다. 비동기 처리가 시작되면 처음 요청을 받은 스레드에서는 postHandle과 afterCompletion이 호출되지 않을 수 있고, 정상적으로 완료된 뒤 비동기 디스패치가 다시 일어납니다. 시간 초과나 네트워크 오류로 컨테이너가 다시 디스패치하지 않으면 postHandle과 afterCompletion도 호출되지 않습니다. 이런 종료는 WebAsyncManager의 콜백 인터셉터로 따로 확인하고, 스레드 로컬을 정리해야 한다면 AsyncHandlerInterceptor.afterConcurrentHandlingStarted의 동작까지 따로 확인합니다.


실패 위치와 HTTP 상태 결정을 나눕니다

실패한 위치마다 어디까지 실행되었는지와 누가 HTTP 응답을 정했는지를 나눠 봅니다.

멈춘 위치대표적인 실패 신호호출되지 않은 단계HTTP 응답을 정하는 곳
매핑 탐색핸들러 없음, 메서드나 consumes, produces 조건 불일치실행 체인, 컨트롤러DispatcherServlet과 구성된 예외 리졸버가 대표적으로 404·405·415·406으로 표현
인자 해석과 변환MethodArgumentTypeMismatchException 같은 변환 실패컨트롤러, 정상 반환 값 처리기본이나 사용자 정의 리졸버의 정책이 이 예제의 400으로 표현
컨트롤러DuplicatePostException 같은 애플리케이션 예외정상 postHandle, 정상 뷰 경로이 글의 어드바이스만 DuplicatePostException을 409 ProblemDetail로 결정

HandlerMapping이나 인자 리졸버가 상태 코드를 본래부터 가지고 있는 것은 아닙니다. 매핑이나 변환의 예외와, 그것을 HTTP로 옮기는 리졸버를 구분해야 합니다. 컨트롤러의 예외에도 404나 409, 500이라는 의미가 들어 있지 않으며, 웹 어댑터가 정책을 명시해야 합니다.

이 예제에서 POST /mvc/method는 405와 정확한 Allow: GET을 만들고 인터셉터에 닿기 전에 끝납니다. /mvc/missing도 404에서 끝나 이후의 호출이 모두 0입니다. 반면 /mvc/convert/not-a-number는 실행 체인을 찾은 뒤 인자 변환에서 실패하므로, 두 preHandle과 역순의 afterCompletion(null)은 실행되지만 컨트롤러와 postHandle, 뷰의 호출은 0입니다.


확장 지점은 다루는 범위로 구분합니다

확장 지점아는 범위이 글에서 확인한 것자세히 다루는 곳
서블릿 FilterMVC 매핑 전의 서블릿 요청과 디스패처 타입독립형 예제에는 등록하지 않음MVC 요청 관찰의 요청 ID와 MDC
HandlerInterceptor선택된 MVC 핸들러와 동기 콜백진입, 중단, 역순 완료, 해결된 예외의 null이 글
HandlerMethodArgumentResolver컨트롤러 메서드 인자변환 실패가 컨트롤러 전에 멈춤요청 파라미터 바인딩과 요청 본문·JSON 변환
ControllerAdviceMVC 예외와 바인딩 결과한정된 DuplicatePostException의 409 정책MVC 실패 경계 진단
애플리케이션 서비스웹과 무관한 사용 사례 메서드이 예제에서 실행하지 않음트랜잭션과 서비스 정책 계층

요청 ID를 인터셉터에서 다시 만들거나 트랜잭션을 디스패처의 콜백으로 옮기지 않습니다. 프런트 컨트롤러 발전의 표에서 요청 ID는 필터, 선택된 핸들러가 필요한 일은 인터셉터, 트랜잭션은 애플리케이션 서비스에 두는 이유를 이미 설명했습니다. 권한 부여도 인터셉터 하나가 보안 체인 전체를 대신한다고 가정하지 않습니다. HandlerAdapter 확장의 정확히 하나라는 어댑터 레지스트리는 여기서 되풀이하지 않으며, 실제 Spring DispatcherServlet은 순서가 있는 어댑터 가운데 핸들러를 지원하는 첫 어댑터를 고릅니다.

정리하면 응답의 상태와 본문, 커밋의 생명 주기는 서블릿 응답 출력에서, 실제 JSP·Thymeleaf 렌더링과 이스케이프는 서블릿·JSP 글에서, 타입이 있는 페이지 모델과 정확한 뷰 레지스트리는 MVC 역할 글에서, 직접 만든 라우팅과 404·405·Allow는 프런트 컨트롤러 글에서, 직접 만든 핸들러와 어댑터의 결합은 HandlerAdapter 글에서 다뤘습니다. 이 글은 그 위에서 실제 Spring MVC 디스패치 전략이 어떻게 이어지는지만 확인합니다.

이 독립형 예제는 실제 HTTP 서버나 운영 환경의 ApplicationContext를 띄우지 않고 필터나 Spring Security 체인도 등록하지 않았으므로, 서블릿 컨테이너의 포워드와 프록시, TLS, 네트워크까지 보여 주지 않습니다. 고정된 View만 쓰므로 실제 템플릿 렌더링이나 출력 문맥의 이스케이프도 보여 주지 않습니다. 그 범위는 운영 구성의 통합 테스트와 실제 서버를 띄우는 테스트로 넓혀야 합니다.

이 글의 내용은 Spring MVC 처리 순서, Spring Framework 7.0.10의 DispatcherServlet과 HandlerExecutionChain, HandlerInterceptor, AsyncHandlerInterceptor, RequestMappingHandlerAdapter, DefaultHandlerExceptionResolver, Spring MVC의 메서드 인자와 반환 값, 예외 리졸버, MockMvc와 end-to-end 테스트와의 차이, Spring Boot 관리 의존성 좌표, Java SE 25 API, JUnit 6.0.3 사용자 가이드를 기준으로 합니다.


정리

  • DispatcherServlet은 실행 순서만 조정하고, 매핑과 인자 해석, 반환 값 처리, 뷰 해석, 본문 변환, 예외 해결은 각 전략이 결정합니다.
  • 일반 @Controller의 String은 논리 뷰 이름이 되어 ViewResolver와 View로 가고, @ResponseBody 값은 메시지 컨버터가 쓰며 뷰 탐색을 건너뜁니다.
  • preHandle은 등록 순서로 실행되고, true를 돌려준 인터셉터만 완료 대상에 들어가 postHandle과 afterCompletion을 역순으로 받습니다.
  • 리졸버가 예외를 해결하면 정상 postHandle은 생략되고 afterCompletion에는 null이 전달되며, 비동기 처리는 별도로 확인합니다.
  • 매핑과 변환, 컨트롤러 가운데 어디서 멈췄는지와 HTTP 상태를 누가 정했는지를 나누면, 컨트롤러가 호출되지 않은 이유를 찾을 수 있습니다.

연습 문제

1. 세 인터셉터의 진입과 완료

동기 디스패치에서 outer, middle, inner 세 인터셉터를 이 순서로 등록했습니다. 매핑은 성공했고, 각 preHandle과 완료 콜백은 예외를 던지지 않습니다. outer와 middle은 true, inner는 false를 돌려줍니다. 호출된 세 preHandle 가운데 누가 afterCompletion을 받고, 어떤 순서로 실행되나요? 컨트롤러와 postHandle, 메시지 컨버터, 뷰도 실행되나요?

해설 보기

preHandle은 outer, middle, inner 순서로 호출되지만, 완료 대상은 true를 돌려준 outer와 middle뿐입니다. 따라서 afterCompletion(null)은 middle, outer 순서입니다. inner는 거절하는 호출을 끝냈어도 완료 대상에 들어가지 않으며, 컨트롤러와 모든 postHandle, 컨버터, 뷰는 실행되지 않습니다. 호출된 적이 있다는 것과 완료 대상에 들어갔다는 것을 구분해야 합니다.

이 풀이는 공식 문서의 동기 콜백 규칙을 세 인터셉터에 적용한 예상입니다. 위 실행 파일은 인터셉터 두 개로 만든 예제이므로, 세 인터셉터로 바꾼 경우는 테스트를 고쳐 실행해 확인합니다.

2. 해결된 예외와 완료 콜백

이번에는 세 인터셉터가 모두 true를 돌려주고, 컨트롤러의 예외를 어드바이스가 정상적으로 409 ProblemDetail로 해결했습니다. 본문 직렬화와 응답 처리도 성공하며 비동기 처리는 시작되지 않습니다. postHandle과 afterCompletion은 각각 어떻게 되고, 완료 콜백의 예외 인자는 무엇인가요?

해설 보기

세 인터셉터가 모두 진입했지만, 컨트롤러가 정상 반환하지 않았으므로 정상 postHandle은 실행되지 않습니다. 예외가 해결되고 본문 처리도 성공했으므로 afterCompletion은 inner, middle, outer 순서로 실행되며 예외 인자는 모두 null입니다. 처음에 예외가 났다는 사실만으로 완료 콜백에 그 예외가 남는다고 단정하지 않습니다. 리졸버가 실패하거나 본문 쓰기가 실패하는 경우는 여기의 성공 전제에서 뺐습니다.

3. 멈춘 위치 찾기

이 글의 예제에 ① POST /mvc/method, ② GET /mvc/missing, ③ GET /mvc/convert/not-a-number를 보냅니다. 각 요청의 상태 코드와, 두 인터셉터의 preHandle이 실행되는지, 컨트롤러가 호출되는지를 구하세요.

해설 보기

① 경로는 있지만 메서드가 맞지 않아 매핑 단계에서 405와 Allow: GET으로 끝나므로, 인터셉터와 컨트롤러는 실행되지 않습니다. ② 핸들러를 찾지 못해 404로 끝나며, 역시 인터셉터와 컨트롤러는 실행되지 않습니다. ③ 실행 체인을 찾았으므로 두 preHandle이 실행되지만, 인자 변환에서 실패해 컨트롤러는 호출되지 않고 리졸버가 400으로 표현합니다. 두 인터셉터는 진입했으므로 afterCompletion(null)을 역순으로 받고, postHandle과 뷰는 실행되지 않습니다.

이것으로 5장을 마칩니다. 5장에서는 웹 서버와 WAS, 서블릿의 생명 주기와 요청·응답에서 시작해, 서블릿과 JSP의 역할을 나누고 MVC 구조와 프런트 컨트롤러, HandlerAdapter를 직접 만든 뒤 실제 DispatcherServlet의 구조까지 살펴봤습니다. 다음 장의 MVC 요청 관찰부터는 필터와 요청 ID, MDC 정리에서 시작해 매핑 조건과 파라미터·JSON 바인딩, 메시지 컨버터, 게시판 CRUD 응답까지 Spring MVC의 요청과 응답을 단계별로 다룹니다.