디스패처 서블릿 구조
DispatcherServlet의 전략 조정, 인터셉터 생명주기, 예외 해결, 뷰와 응답 본문 분기를 하나의 실행 가능한 MVC 경계로 검증합니다.
Servlet 컨테이너는 애노테이션이 붙은 컨트롤러 메서드를 직접 호출하지 않습니다.
컨테이너가 DispatcherServlet에 요청을 넘기면, 디스패처가 HandlerMapping으로 HandlerExecutionChain을 찾고 HandlerAdapter로 핸들러를 호출합니다. 그 뒤의 결과는 논리 뷰 경로, 응답 본문 경로, 조건부 예외 해결 경로 가운데 하나로 이어집니다.
이 구조를 알면 “컨트롤러가 호출되지 않았다”는 관찰을 곧바로 컨트롤러 버그로 해석하지 않고, 요청이 실제로 멈춘 경계를 찾을 수 있습니다.
REQUEST → STRATEGY COORDINATION → VIEW OR BODY
DispatcherServlet은 전략을 조정하고 결과 경계를 분기한다
DispatcherServlet은 실행 순서를 소유하지만 mapping 조건, handler 시그니처, 본문 변환, view 렌더링의 결정을 직접 구현하지 않습니다.
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가 쓰고, non-null ModelAndView만 view로 보낸다
본문 반환값은 return-value handler가 선택한
HttpMessageConverter로 표현을 쓴 뒤 null
ModelAndView를 반환합니다. non-null 결과만
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의 별도 경계입니다.
전략은 같은 역할이 아니다
DispatcherServlet은 전체 요청을 조정하지만 각 세부 계약을 대신 구현하지 않습니다.
| 경계 | 입력 | 관찰 가능한 결과 |
|---|---|---|
HandlerMapping | 요청 경로·메서드·조건 | 핸들러와 인터셉터를 묶은 HandlerExecutionChain |
RequestMappingHandlerAdapter | 선택된 HandlerMethod | 인자 해결, 컨트롤러 호출, 반환값 처리 |
| 반환값 핸들러 | 컨트롤러 반환값 | 논리 뷰와 모델 또는 이미 처리된 응답 |
ViewResolver | 논리 뷰 이름 | 실행할 View |
HttpMessageConverter | 본문 값과 미디어 타입 | 응답 본문 바이트 |
HandlerExceptionResolver | 매핑·핸들러 처리 중 예외 | 오류 ModelAndView, 이미 처리된 결과, 또는 다음 리졸버 |
HandlerMethodArgumentResolver는 호출 전에 메서드 인자를 만듭니다. 반환값 핸들러는 호출 뒤의 String, ResponseEntity, @ResponseBody 등을 MVC 의미로 해석합니다. ViewResolver는 논리 이름을 View로 바꾸며, 메시지 컨버터는 뷰를 찾지 않고 본문을 읽거나 씁니다.
HandlerExceptionResolver는 성공 경로의 필수 직렬 단계가 아닙니다. 매핑이나 핸들러 처리에서 예외가 발생했을 때만 순서대로 시도됩니다.
뷰와 본문은 반환값 처리 뒤 갈라진다
일반 @Controller 메서드가 String을 반환하면 이 예제에서는 post-detail이라는 논리 뷰 이름입니다. @ResponseBody 또는 @RestController 경계의 값은 반환값 핸들러가 메시지 컨버터로 보내며 ViewResolver를 지나지 않습니다.
Spring Framework 7.0.9의 JacksonJsonHttpMessageConverter는 Jackson 3의 tools.jackson.databind.json.JsonMapper를 사용합니다. 기본 생성자는 ProblemDetail mixin도 설치하므로 setProperty("code", ...)로 추가한 확장 멤버를 JSON 최상위 필드로 쓸 수 있습니다.
다음 단위는 Java 25, Spring Boot BOM 4.1.1, Spring Framework 7.0.9, Servlet 6.1, JUnit 6.0.3, AssertJ 3.27.7 경계의 완결된 하나의 compilation unit입니다. vendor runtime 문자열 25.0.4.1은 실행 영수증에서 별도로 확인해야 하며 Java SE 25 언어·API 버전과 같은 개념이 아닙니다.
MockMvcBuilders.standaloneSetup은 DispatcherServlet과 standalone builder가 구성한 MVC 인프라를 통해 이 fixture가 직접 등록한 컨트롤러·어드바이스·인터셉터·뷰 리졸버·메시지 컨버터를 mock Servlet 요청과 응답으로 실행합니다. 운영 ApplicationContext나 실제 서버를 실행하는 것은 아닙니다.
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은 B78이 소유한 페이지 모델·고정 논리 이름 경계를 소비할 뿐, 실제 JSP나 Thymeleaf 템플릿을 실행하지 않습니다.
두 번째 테스트는 Jackson 3 컨버터의 성공한 쓰기 횟수와 JSON 결과를 관찰하고 ViewResolver와 View가 호출되지 않았음을 함께 확인합니다. 응답 본문 경로에서는 어댑터가 이미 본문을 썼을 수 있으므로 postHandle의 ModelAndView가 null일 수 있고, 그 콜백으로 이미 쓴 본문을 바꾸려 해서는 안 됩니다.
진입한 인터셉터만 완료된다
ENTER IN ORDER → COMPLETE IN REVERSE
진입한 인터셉터만 역순으로 완료된다
preHandle()이 true로 끝난 interceptor만 entered stack에 쌓이며, 후속 callback은 그 stack을 역순으로 사용합니다.
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을
미루고, AsyncHandlerInterceptor의
afterConcurrentHandlingStarted가 thread-bound cleanup
hook을 제공합니다.
entered는 preHandle이 정상적으로 끝나 true를 반환했다는 뜻입니다. afterCompletion은 그 entered stack만 역순으로 정리하며, normal return, resolved exception, preHandle false, async redispatch의 경계를 서로 바꾸어 읽으면 안 됩니다.
preHandle은 등록 순서로 실행됩니다. 각 인터셉터는 자신의 preHandle이 끝나고 true를 반환해야 완료 대상에 진입합니다.
예제에서 outer가 true, inner가 false를 반환하면 컨트롤러와 postHandle, 뷰 작업은 모두 실행되지 않습니다. inner 자신도 afterCompletion을 받지 않고, 이미 진입한 outer만 역순 정리를 받습니다.
핸들러가 성공하면 postHandle과 afterCompletion은 역순입니다. 핸들러 예외가 HandlerExceptionResolver로 해결되면 정상 postHandle은 실행되지 않고, 진입한 인터셉터의 afterCompletion에는 이미 해결된 예외가 아니라 null이 전달됩니다.
이 단위의 증거 범위는 동기 디스패치뿐입니다. 비동기 처리가 시작되면 최초 요청 스레드에서는 postHandle과 afterCompletion이 호출되지 않을 수 있고, 나중에 비동기 디스패치가 다시 일어납니다. 스레드 로컬 정리가 필요하면 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입니다.
확장 지점은 관찰 범위로 구분한다
| 확장 지점 | 알고 있는 범위 | 이 문서의 증거 | 상세 소유권 |
|---|---|---|---|
Servlet Filter | MVC 매핑 전의 Servlet 요청·디스패처 타입 | standalone fixture에는 등록하지 않음 | ch6-1 요청 ID·MDC·관찰 |
HandlerInterceptor | 선택된 MVC 핸들러와 동기 콜백 | 진입, 중단, 역순 완료, 해결된 예외의 null | 이 문서 |
HandlerMethodArgumentResolver | 컨트롤러 메서드 인자 | 변환 실패가 컨트롤러 전에 멈춤 | 이 문서의 경계, ch6-3·ch6-4의 상세 |
ControllerAdvice | MVC 예외와 바인딩 결과 | 한정된 DuplicatePostException → 409 정책 | 이 문서의 경계, ch6-8의 진단 |
| 애플리케이션 서비스 | 웹과 무관한 유스케이스 메서드 | 이 fixture에서 실행하지 않음 | 트랜잭션·서비스 정책 계층 |
Request ID를 인터셉터에서 다시 만들거나 트랜잭션을 디스패처 콜백으로 옮기지 않습니다. B79의 소유권 표가 Request ID는 Filter, 선택된 핸들러 경계는 Interceptor, 트랜잭션은 애플리케이션 서비스에 둔 이유를 이미 설명했습니다. 권한 부여도 인터셉터 한 개로 전체 보안 체인을 대체한다고 가정하지 않습니다.
B80의 정확히 하나인 사용자 정의 HandlerAdapter 레지스트리는 이 문서에서 반복하지 않습니다. 실제 Spring DispatcherServlet은 순서가 있는 어댑터 가운데 핸들러를 지원하는 첫 어댑터를 선택합니다.
B76은 응답 상태·본문·커밋 생명주기, B77은 실제 JSP·Thymeleaf 렌더링과 escaping, B78은 타입이 지정된 페이지 모델과 정확한 뷰 레지스트리, B79는 사용자 정의 라우팅과 404·405·Allow, B80은 사용자 정의 핸들러-어댑터 결합을 소유합니다. 이 문서는 그 위에서 실제 Spring MVC 디스패치 전략의 연결만 검증합니다.
이 standalone fixture는 실제 HTTP 서버나 운영 ApplicationContext를 띄우지 않고 Filter·Spring Security 체인도 등록하지 않았으므로 Servlet 컨테이너 포워드, 프록시·TLS·네트워크까지 증명하지 않습니다. 또한 고정 View만 사용하므로 실제 JSP·Thymeleaf 템플릿 렌더링이나 출력 문맥 escaping도 증명하지 않습니다. 그 범위는 운영 구성 통합 테스트와 실제 서빙 계층 테스트로 확장해야 합니다.
공식 근거
- Spring MVC 처리 순서
- Spring Framework 7.0.9
DispatcherServlet - Spring Framework 7.0.9
HandlerExecutionChain - Spring Framework 7.0.9
HandlerInterceptor - Spring Framework 7.0.9
AsyncHandlerInterceptor - Spring Framework 7.0.9
RequestMappingHandlerAdapter - Spring MVC 메서드 인자
- Spring MVC 반환값
- Spring MVC 예외 리졸버
- Spring Framework 7.0.9
DefaultHandlerExceptionResolver - Spring MVC
MockMvc MockMvc와 end-to-end 테스트 범위- Spring Boot 관리 의존성 좌표
- Java SE 25 API
- JUnit 6.0.3 User Guide
다음 문서에서는 ch6-1의 요청 관찰 경계에서 Filter, 요청 ID, MDC 정리를 검증합니다. 상세 매핑 조건, URI·쿼리·폼·JSON 바인딩, 메시지 컨버터와 CRUD 응답은 ch6-2 이후 문서가 단계별로 소유합니다.