HandlerAdapter 확장
B79가 찾은 핸들러를 유한 레지스트리에서 정확히 하나의 어댑터에 결합하고, 명시적인 ModelViewResult·ResponseHandled 결과가 B78·B77 경계로 이어지는 과정을 검증합니다.
B79의 프런트 컨트롤러는 메서드와 컨텍스트 상대 원시 경로를 정확히 매핑해 Found(handler)를 반환합니다. 하지만 핸들러가 PageController인지, 레거시 호출 규칙을 따르는지는 프런트 컨트롤러가 직접 알 필요가 없습니다.
이 문서의 HandlerAdapter는 이미 찾은 핸들러를 어떻게 호출할지만 소유합니다. 요청 매핑·404·405·Allow·Servlet 메서드 디스패치는 B79에 그대로 두고, B80은 유한한 핸들러 집합과 어댑터 사이의 결합만 다룹니다.
FOUND HANDLER → FROZEN ADAPTER → SEALED RESULT
HandlerAdapter는 핸들러 계약을 명시적 결과로 바꾼다
dispatcher는 구체 controller 계약을 해석하지 않습니다. construction 때 고정한 adapter가 handler를 호출하고, view를 계속할지 끝낼지를 sealed 결과로 돌려줍니다.
01 · FROZEN IDENTITY BINDING
B79 Found의 정확한 handler instance로 미리 고정한 adapter를 찾는다
custom finite registry는 등록된 adapter의 supports로
construction 때 handler마다 정확히 하나를 검증해 identity
binding을 고정합니다. runtime에는 목록을 다시 훑지 않습니다.
02 · PAGE CONTROLLER PATH
PageControllerAdapter는 ModelViewResult를 명시적으로 반환한다
결과의 ModelView에는 B78의 고정
post-detail 이름과 typed PostPageModel이
들어갑니다. exact registry와 B77 fixed test View가 각각 한 번
이어집니다.
03 · LEGACY CONTROLLER PATH
LegacyControllerAdapter는 null 대신 ResponseHandled를 반환한다
ResponseHandled는 view 경로를 끝낸다는 닫힌 의미만
가집니다. response가 commit되었거나 body·status·media
type·rendering이 완료되었다고 주장하지 않습니다.
04 · CUSTOM ≠ SPRING
exact-one 정책을 Spring MVC의 ordered-first 선택으로 일반화하지 않는다
Spring은 설치된 adapter를 순서대로 검사해 처음 지원하는 구현을
택하고 handle이 nullable ModelAndView를
반환할 수 있습니다. 이 장의 sealed result와 overlap 거부는 더 강한
custom 정책입니다.
adapter는 알려진 handler 호출과 결과 적응만 소유합니다. 입력 검증은 B78, fixed View의 rendering·escaping 증거는 B77, HandlerMethod와 argument·return-value 처리 같은 실제 Spring 내부는 ch5-9의 범위입니다.
핸들러 계약과 어댑터 결과
핸들러는 매핑이 선택한 실행 대상입니다. 이 예제에는 서로 다른 계약 두 개가 있습니다.
PageController는 이미 검증된 입력을 받아 B78의 고정 논리 이름post-detail과 타입 있는PostPageModel을 담은ModelView를 반환합니다.LegacyController는 전달받은 관찰 표식 경계에 값을 남기고 뷰 단계로 진행하지 않습니다.
두 계약을 프런트 컨트롤러의 instanceof 분기로 합치지 않습니다. PageControllerAdapter와 LegacyControllerAdapter가 각각 supports와 호출 방법을 함께 소유합니다.
이 교재의 사용자 정의 반환 타입은 nullable 값이 아니라 다음 두 경우로 닫혀 있습니다.
| 사용자 정의 결과 | 다음 단계 | 이 문서가 관찰하는 것 | 이 문서가 주장하지 않는 것 |
|---|---|---|---|
ModelViewResult(ModelView) | B78 exact registry → B77 fixed test view boundary | 논리 이름·타입 모델·호출 횟수·순서·primitive/String 표식 | JSP·Thymeleaf 실행, forward, HTML, 이스케이프 |
ResponseHandled | 뷰 경로 종료 | 레거시 핸들러 표식, registry/view 호출 0회 | 응답 커밋, HTTP 상태, 본문·미디어 타입의 정확성 |
ResponseHandled는 “뷰를 찾지 않는다”는 뜻만 갖습니다. HttpServletResponse.isCommitted()나 실제 네트워크 응답을 대신 증명하지 않습니다.
Spring의 계약과 구분하기
Spring Framework 7.0.9의 HandlerAdapter는 supports(Object)와 handle(request, response, Object)를 제공합니다. Spring의 handle은 nullable ModelAndView를 반환하며, null은 요청이 직접 처리되어 뷰가 필요하지 않을 수 있다는 뜻입니다. 커밋 완료를 뜻하지는 않습니다.
또한 실제 DispatcherServlet은 설치된 어댑터를 순서대로 확인해 처음 지원하는 어댑터를 선택합니다. 중복 지원을 일반적으로 startup 오류로 거절하지 않습니다. 지원 어댑터가 없으면 선택 과정에서 치명적인 ServletException이 발생하지만, 이 사실만으로 특정 HTTP 500 응답을 단정할 수도 없습니다.
따라서 아래 exact-one 검증은 Spring 동작을 흉내 낸 주장이 아닙니다. B79가 명시적으로 등록한 유한 핸들러 인스턴스 집합에 적용하는, 이 교재의 더 강한 조립 정책입니다.
등록할 때 검증하고 요청할 때 다시 찾지 않기
동적인 HandlerMapping이 앞으로 반환할 모든 객체를 미리 검사할 수는 없습니다. 반면 B79의 RouteTable처럼 등록 목록이 닫혀 있으면 그 목록에 실제로 들어간 핸들러 인스턴스는 열거할 수 있습니다.
이 문서의 레지스트리는 다음 순서를 고정합니다.
- 유한한 등록 핸들러 인스턴스와 어댑터 목록을 받습니다.
- 각 등록 인스턴스와 각 어댑터 조합에 대해
supports를 생성 시점에 한 번 계산합니다. - 지원 어댑터가 0개거나 2개 이상이면 요청을 받기 전에 구성을 거절합니다.
- 정확히 1개이면 객체 동일성을 키로 핸들러→어댑터 결합을 고정합니다.
- 실행 시에는
supports를 다시 호출하거나 목록 순서에 기대지 않고, 고정된 어댑터를 정확히 한 번 호출합니다.
동일성 결합에는 Java 25의 IdentityHashMap을 의도적으로 사용합니다. 이 자료구조는 일반적인 equals 기반 Map이 아니라 참조 동일성으로 키를 비교하므로, 등록 객체와 값이 같은 별도 객체를 같은 핸들러로 오인하지 않습니다. 이 선택은 등록 인스턴스 경계에만 한정합니다.
CONSTRUCT ONCE → FREEZE IDENTITY → INVOKE ONCE
등록 시 정확히 하나를 검증하고 요청 시 같은 어댑터를 호출한다
지원 관계의 누락과 중복을 첫 요청까지 미루지 않습니다. 유한한 등록 집합을 한 번 검증해 identity binding을 고정하고, 요청에서는 lookup과 단 한 번의 호출만 수행합니다.
01 · FINITE CONSTRUCTION INPUT
등록된 handler instance와 adapter 목록만 한 번 평가한다
모든 runtime object나 classpath를 찾는 규칙이 아닙니다. server를
조립할 때 알고 있는 유한한 두 목록에 대해서만 각
supports 관계를 계산합니다.
02 · EXACTLY ONE OR FAIL
supporter가 하나면 계속하고 zero 또는 multiple이면 construction을 중단한다
누락과 중복은 serving 전에 pure construction exception으로 닫습니다. 이는 특정 HTTP status나 literal 500 response를 만들었다는 뜻이 아닙니다.
03 · FREEZE IDENTITY BINDING
정확히 하나인 adapter를 handler identity에 고정한다
equal-looking handler가 아니라 등록된 정확한 instance를 key로
사용합니다. construction 결과는
handler instance → adapter의 불변 binding입니다.
04 · REQUEST LOOKUP + ONE CALL
B79 Found handler로 binding을 찾아 같은 adapter를 한 번 호출한다
요청마다 supports를 재검사하거나 adapter 순서에
의존하지 않습니다. 반면 Spring MVC는 ordered-first selection을
사용하므로 exact-one overlap rejection은 이 custom registry의
규칙입니다.
이 flow는 B79가 정확한 handler를 이미 찾은 뒤만 다룹니다.
path·method·404·405·Allow는 B79, sealed
AdapterResult 이후의 view 경계는 `/1`, 실제 Spring
HandlerMethod 처리와 exception resolution은 ch5-9가 소유합니다.
구성 실패는 순수 Java 예외로 관찰합니다. HTTP 상태로 바꾸는 일은 Servlet 어댑터나 Spring 예외 처리 경계의 책임이며, 이 단위 테스트는 그 변환을 주장하지 않습니다.
하나의 실행 가능한 경계 테스트
아래 파일은 부분 예제 모음이 아니라 모든 협력 객체를 포함한 하나의 완전한 compilation unit입니다. Java 25.0.4.1, Spring Boot BOM 4.1.1, Spring Framework 7.0.9, JUnit 6.0.3, AssertJ 3.27.7 기준으로 고정합니다. 사용자 정의 레지스트리 자체는 Spring·Servlet 객체 없이 실행되며, Spring 버전은 뒤의 공식 계약 비교 기준입니다.
package board.mvc;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import java.util.ArrayList;
import java.util.Collections;
import java.util.IdentityHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.BiConsumer;
import java.util.function.Function;
import org.junit.jupiter.api.Test;
class HandlerAdapterDispatchBoundaryTest {
private static final String POST_DETAIL = "post-detail";
@Test
void registeredHandlersAreBoundToExactlyOneAdapter() {
var trace = new Trace();
var page = new NamedPageController(
"page",
input -> ModelView.postDetail(
new PostPageModel(input.postId(), "Adapter boundary")));
var legacy = new NamedLegacyController(
(input, marker) -> marker.mark("legacy:" + input.postId()));
var pageAdapter = new PageControllerAdapter(trace);
var legacyAdapter = new LegacyControllerAdapter(trace);
var registry = new HandlerAdapterRegistry(
List.of(page, legacy),
List.of(pageAdapter, legacyAdapter));
assertThat(registry.required(page)).isSameAs(pageAdapter);
assertThat(registry.required(legacy)).isSameAs(legacyAdapter);
assertThat(registry.bindingCount()).isEqualTo(2);
assertThat(pageAdapter.supportsCalls()).isEqualTo(2);
assertThat(legacyAdapter.supportsCalls()).isEqualTo(2);
assertThat(pageAdapter.handleCalls()).isZero();
assertThat(legacyAdapter.handleCalls()).isZero();
assertThat(page.calls()).isZero();
assertThat(legacy.calls()).isZero();
}
@Test
void missingAdapterFailsDuringConstruction() {
var trace = new Trace();
var page = new NamedPageController(
"missing",
input -> ModelView.postDetail(
new PostPageModel(input.postId(), "never")));
var unrelated = new LegacyControllerAdapter(trace);
assertThatThrownBy(() -> new HandlerAdapterRegistry(
List.of(page),
List.of(unrelated)))
.isInstanceOf(AdapterConfigurationException.class)
.hasMessage(
"Expected exactly one adapter for "
+ "NamedPageController; found 0");
assertThat(unrelated.supportsCalls()).isOne();
assertThat(unrelated.handleCalls()).isZero();
assertThat(page.calls()).isZero();
assertThat(trace.snapshot()).isEmpty();
}
@Test
void overlappingAdaptersFailDuringConstruction() {
var trace = new Trace();
var page = new NamedPageController(
"overlap",
input -> ModelView.postDetail(
new PostPageModel(input.postId(), "never")));
var first = new PageControllerAdapter(trace);
var second = new PageControllerAdapter(trace);
assertThatThrownBy(() -> new HandlerAdapterRegistry(
List.of(page),
List.of(first, second)))
.isInstanceOf(AdapterConfigurationException.class)
.hasMessage(
"Expected exactly one adapter for "
+ "NamedPageController; found 2");
assertThat(first.supportsCalls()).isOne();
assertThat(second.supportsCalls()).isOne();
assertThat(first.handleCalls()).isZero();
assertThat(second.handleCalls()).isZero();
assertThat(page.calls()).isZero();
assertThat(trace.snapshot()).isEmpty();
}
@Test
void dispatchUsesFrozenIdentityBindingWithoutRescanning() {
var trace = new Trace();
var page = new NamedPageController(
"frozen",
input -> ModelView.postDetail(
new PostPageModel(input.postId(), "Frozen")));
var selected = new PageControllerAdapter(trace);
var unrelated = new LegacyControllerAdapter(trace);
var configured = new ArrayList<HandlerAdapter>();
configured.add(selected);
configured.add(unrelated);
var registry = new HandlerAdapterRegistry(
List.of(page), configured);
var supportsBeforeDispatch = List.of(
selected.supportsCalls(), unrelated.supportsCalls());
var view = new FixedTestView(trace);
var views = new ExactViewRegistry(trace, view);
var dispatcher = new HandlerAdapterDispatcher(registry, views);
var marker = new ObservationMarker();
configured.clear();
configured.add(unrelated);
dispatcher.dispatch(new HandlerInput(42), page, marker);
assertThat(List.of(
selected.supportsCalls(),
unrelated.supportsCalls()))
.isEqualTo(supportsBeforeDispatch);
assertThat(selected.handleCalls()).isOne();
assertThat(unrelated.handleCalls()).isZero();
assertThat(page.calls()).isOne();
assertThat(views.lookupCalls()).isOne();
assertThat(view.renderCalls()).isOne();
assertThat(marker.value()).isEqualTo("post-detail:42");
}
@Test
void modelViewResultResolvesAndInvokesFixedViewOnceInOrder() {
var trace = new Trace();
var pageModel = new PostPageModel(42, "Typed page model");
var page = new NamedPageController("detail", input -> {
trace.add("page-controller");
return ModelView.postDetail(pageModel);
});
var adapter = new PageControllerAdapter(trace);
var registry = new HandlerAdapterRegistry(
List.of(page), List.of(adapter));
var view = new FixedTestView(trace);
var views = new ExactViewRegistry(trace, view);
var dispatcher = new HandlerAdapterDispatcher(registry, views);
var marker = new ObservationMarker();
dispatcher.dispatch(new HandlerInput(42), page, marker);
assertThat(trace.snapshot()).containsExactly(
"page-adapter",
"page-controller",
"view-registry",
"fixed-view");
assertThat(adapter.handleCalls()).isOne();
assertThat(page.calls()).isOne();
assertThat(views.lookupCalls()).isOne();
assertThat(views.lastLogicalName()).isEqualTo(POST_DETAIL);
assertThat(view.renderCalls()).isOne();
assertThat(view.lastModel()).isExactlyInstanceOf(PostPageModel.class);
assertThat(view.lastModel()).isEqualTo(pageModel);
assertThat(marker.writes()).isOne();
assertThat(marker.value()).isEqualTo("post-detail:42");
}
@Test
void responseHandledSkipsRegistryAndView() {
var trace = new Trace();
var legacy = new NamedLegacyController((input, marker) -> {
trace.add("legacy-controller");
marker.mark("legacy:" + input.postId());
});
var adapter = new LegacyControllerAdapter(trace);
var registry = new HandlerAdapterRegistry(
List.of(legacy), List.of(adapter));
var view = new FixedTestView(trace);
var views = new ExactViewRegistry(trace, view);
var dispatcher = new HandlerAdapterDispatcher(registry, views);
var marker = new ObservationMarker();
dispatcher.dispatch(new HandlerInput(42), legacy, marker);
assertThat(trace.snapshot()).containsExactly(
"legacy-adapter", "legacy-controller");
assertThat(adapter.handleCalls()).isOne();
assertThat(legacy.calls()).isOne();
assertThat(views.lookupCalls()).isZero();
assertThat(view.renderCalls()).isZero();
assertThat(marker.writes()).isOne();
assertThat(marker.value()).isEqualTo("legacy:42");
}
@Test
void unregisteredHandlerFailsWithoutDownstreamCalls() {
var trace = new Trace();
var registered = new NamedPageController(
"same-value",
input -> ModelView.postDetail(
new PostPageModel(input.postId(), "registered")));
var equalButUnregistered = new NamedPageController(
"same-value",
input -> ModelView.postDetail(
new PostPageModel(input.postId(), "unregistered")));
var adapter = new PageControllerAdapter(trace);
var registry = new HandlerAdapterRegistry(
List.of(registered), List.of(adapter));
var supportsAfterConstruction = adapter.supportsCalls();
var view = new FixedTestView(trace);
var views = new ExactViewRegistry(trace, view);
var dispatcher = new HandlerAdapterDispatcher(registry, views);
var marker = new ObservationMarker();
assertThat(equalButUnregistered)
.isEqualTo(registered)
.isNotSameAs(registered);
assertThatThrownBy(() -> dispatcher.dispatch(
new HandlerInput(42),
equalButUnregistered,
marker))
.isInstanceOf(UnregisteredHandlerException.class)
.hasMessage(
"Unregistered handler instance: "
+ "NamedPageController");
assertThat(adapter.supportsCalls())
.isEqualTo(supportsAfterConstruction);
assertThat(adapter.handleCalls()).isZero();
assertThat(registered.calls()).isZero();
assertThat(equalButUnregistered.calls()).isZero();
assertThat(views.lookupCalls()).isZero();
assertThat(view.renderCalls()).isZero();
assertThat(marker.writes()).isZero();
assertThat(trace.snapshot()).isEmpty();
}
record HandlerInput(long postId) {}
record PostPageModel(long id, String title) {
PostPageModel {
Objects.requireNonNull(title, "title");
}
}
record ModelView(String viewName, PostPageModel model) {
ModelView {
if (!POST_DETAIL.equals(viewName)) {
throw new IllegalArgumentException(
"Unknown logical view: " + viewName);
}
Objects.requireNonNull(model, "model");
}
static ModelView postDetail(PostPageModel model) {
return new ModelView(POST_DETAIL, model);
}
}
sealed interface AdapterResult
permits ModelViewResult, ResponseHandled {}
record ModelViewResult(ModelView modelView)
implements AdapterResult {
ModelViewResult {
Objects.requireNonNull(modelView, "modelView");
}
}
enum ResponseHandled implements AdapterResult {
INSTANCE
}
@FunctionalInterface
interface PageController {
ModelView handle(HandlerInput input);
}
@FunctionalInterface
interface LegacyController {
void handle(HandlerInput input, ObservationMarker marker);
}
interface HandlerAdapter {
boolean supports(Object handler);
AdapterResult handle(
HandlerInput input,
Object handler,
ObservationMarker marker);
}
interface ViewBoundary {
void render(PostPageModel model, ObservationMarker marker);
}
static final class PageControllerAdapter
implements HandlerAdapter {
private final Trace trace;
private final AtomicInteger supportsCalls = new AtomicInteger();
private final AtomicInteger handleCalls = new AtomicInteger();
PageControllerAdapter(Trace trace) {
this.trace = Objects.requireNonNull(trace, "trace");
}
@Override
public boolean supports(Object handler) {
supportsCalls.incrementAndGet();
return handler instanceof PageController;
}
@Override
public AdapterResult handle(
HandlerInput input,
Object handler,
ObservationMarker marker) {
handleCalls.incrementAndGet();
trace.add("page-adapter");
var controller = (PageController) handler;
return new ModelViewResult(controller.handle(input));
}
int supportsCalls() {
return supportsCalls.get();
}
int handleCalls() {
return handleCalls.get();
}
}
static final class LegacyControllerAdapter
implements HandlerAdapter {
private final Trace trace;
private final AtomicInteger supportsCalls = new AtomicInteger();
private final AtomicInteger handleCalls = new AtomicInteger();
LegacyControllerAdapter(Trace trace) {
this.trace = Objects.requireNonNull(trace, "trace");
}
@Override
public boolean supports(Object handler) {
supportsCalls.incrementAndGet();
return handler instanceof LegacyController;
}
@Override
public AdapterResult handle(
HandlerInput input,
Object handler,
ObservationMarker marker) {
handleCalls.incrementAndGet();
trace.add("legacy-adapter");
var controller = (LegacyController) handler;
controller.handle(input, marker);
return ResponseHandled.INSTANCE;
}
int supportsCalls() {
return supportsCalls.get();
}
int handleCalls() {
return handleCalls.get();
}
}
static final class HandlerAdapterRegistry {
private final Map<Object, HandlerAdapter> bindings;
HandlerAdapterRegistry(
List<?> registeredHandlers,
List<? extends HandlerAdapter> adapters) {
Objects.requireNonNull(
registeredHandlers, "registeredHandlers");
Objects.requireNonNull(adapters, "adapters");
var candidates = List.copyOf(adapters);
var seen = new IdentityHashMap<Object, Boolean>();
var mutableBindings =
new IdentityHashMap<Object, HandlerAdapter>();
for (var handler : registeredHandlers) {
Objects.requireNonNull(handler, "registered handler");
if (seen.put(handler, Boolean.TRUE) != null) {
continue;
}
var supporting = new ArrayList<HandlerAdapter>();
for (var adapter : candidates) {
if (adapter.supports(handler)) {
supporting.add(adapter);
}
}
if (supporting.size() != 1) {
throw new AdapterConfigurationException(
"Expected exactly one adapter for "
+ handler.getClass().getSimpleName()
+ "; found "
+ supporting.size());
}
mutableBindings.put(handler, supporting.getFirst());
}
bindings = Collections.unmodifiableMap(mutableBindings);
}
HandlerAdapter required(Object handler) {
Objects.requireNonNull(handler, "handler");
if (!bindings.containsKey(handler)) {
throw new UnregisteredHandlerException(
"Unregistered handler instance: "
+ handler.getClass().getSimpleName());
}
return bindings.get(handler);
}
int bindingCount() {
return bindings.size();
}
}
static final class HandlerAdapterDispatcher {
private final HandlerAdapterRegistry adapters;
private final ExactViewRegistry views;
HandlerAdapterDispatcher(
HandlerAdapterRegistry adapters,
ExactViewRegistry views) {
this.adapters = Objects.requireNonNull(adapters, "adapters");
this.views = Objects.requireNonNull(views, "views");
}
void dispatch(
HandlerInput input,
Object handler,
ObservationMarker marker) {
var adapter = adapters.required(handler);
var result = Objects.requireNonNull(
adapter.handle(input, handler, marker),
"adapter result");
switch (result) {
case ModelViewResult page -> {
var modelView = page.modelView();
var view = views.resolve(modelView.viewName());
view.render(modelView.model(), marker);
}
case ResponseHandled ignored -> {
// The handler completed this tutorial boundary.
}
}
}
}
static final class ExactViewRegistry {
private final Trace trace;
private final ViewBoundary fixedView;
private final AtomicInteger lookupCalls = new AtomicInteger();
private String lastLogicalName;
ExactViewRegistry(Trace trace, ViewBoundary fixedView) {
this.trace = Objects.requireNonNull(trace, "trace");
this.fixedView = Objects.requireNonNull(fixedView, "fixedView");
}
ViewBoundary resolve(String logicalName) {
lookupCalls.incrementAndGet();
trace.add("view-registry");
lastLogicalName = logicalName;
if (!POST_DETAIL.equals(logicalName)) {
throw new IllegalArgumentException(
"Unknown logical view: " + logicalName);
}
return fixedView;
}
int lookupCalls() {
return lookupCalls.get();
}
String lastLogicalName() {
return lastLogicalName;
}
}
static final class FixedTestView implements ViewBoundary {
private final Trace trace;
private final AtomicInteger renderCalls = new AtomicInteger();
private PostPageModel lastModel;
FixedTestView(Trace trace) {
this.trace = Objects.requireNonNull(trace, "trace");
}
@Override
public void render(
PostPageModel model,
ObservationMarker marker) {
renderCalls.incrementAndGet();
trace.add("fixed-view");
lastModel = Objects.requireNonNull(model, "model");
marker.mark(POST_DETAIL + ":" + model.id());
}
int renderCalls() {
return renderCalls.get();
}
PostPageModel lastModel() {
return lastModel;
}
}
static final class NamedPageController
implements PageController {
private final String equalityKey;
private final Function<HandlerInput, ModelView> action;
private final AtomicInteger calls = new AtomicInteger();
NamedPageController(
String equalityKey,
Function<HandlerInput, ModelView> action) {
this.equalityKey = Objects.requireNonNull(
equalityKey, "equalityKey");
this.action = Objects.requireNonNull(action, "action");
}
@Override
public ModelView handle(HandlerInput input) {
calls.incrementAndGet();
return action.apply(input);
}
int calls() {
return calls.get();
}
@Override
public boolean equals(Object other) {
return other instanceof NamedPageController page
&& equalityKey.equals(page.equalityKey);
}
@Override
public int hashCode() {
return equalityKey.hashCode();
}
}
static final class NamedLegacyController
implements LegacyController {
private final BiConsumer<HandlerInput, ObservationMarker> action;
private final AtomicInteger calls = new AtomicInteger();
NamedLegacyController(
BiConsumer<HandlerInput, ObservationMarker> action) {
this.action = Objects.requireNonNull(action, "action");
}
@Override
public void handle(
HandlerInput input,
ObservationMarker marker) {
calls.incrementAndGet();
action.accept(input, marker);
}
int calls() {
return calls.get();
}
}
static final class ObservationMarker {
private String value;
private int writes;
void mark(String nextValue) {
Objects.requireNonNull(nextValue, "nextValue");
if (writes != 0) {
throw new IllegalStateException(
"Observation marker already written");
}
value = nextValue;
writes++;
}
String value() {
return value;
}
int writes() {
return writes;
}
}
static final class Trace {
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 AdapterConfigurationException
extends IllegalStateException {
AdapterConfigurationException(String message) {
super(message);
}
}
static final class UnregisteredHandlerException
extends IllegalArgumentException {
UnregisteredHandlerException(String message) {
super(message);
}
}
}일곱 테스트가 닫는 범위
| 테스트 | 관찰하는 출력 | 의도적으로 관찰하지 않는 것 |
|---|---|---|
registeredHandlersAreBoundToExactlyOneAdapter | Page·Legacy 등록 인스턴스마다 정확히 하나의 고정 결합 | Spring bean 검색, 동적 핸들러 전체 |
missingAdapterFailsDuringConstruction | 지원 0개면 생성 실패, 실행 호출 0회 | HTTP 500, 예외 리졸버 |
overlappingAdaptersFailDuringConstruction | 지원 2개면 사용자 정의 구성 실패 | Spring의 ordered-first 동작 |
dispatchUsesFrozenIdentityBindingWithoutRescanning | 외부 목록 변경 뒤에도 같은 결합, runtime supports 증가 0 | 동적 어댑터 재등록 |
modelViewResultResolvesAndInvokesFixedViewOnceInOrder | adapter → controller → exact registry → fixed view, 각 1회 | 실제 템플릿·forward·이스케이프 |
responseHandledSkipsRegistryAndView | 레거시 표식, registry/view 0회 | 커밋·상태·본문 정확성 |
unregisteredHandlerFailsWithoutDownstreamCalls | equals가 같아도 다른 인스턴스는 거절, 하위 호출 0회 | B79의 404·405 매핑 |
Trace.snapshot()의 List.copyOf는 관찰 시점 목록을 바꾸지 못하게 할 뿐이며 중첩 객체를 deep-freeze한다고 주장하지 않습니다. 실제 모델은 primitive와 String만 가진 PostPageModel 레코드입니다.
Spring MVC로 확장할 때의 소유권
실제 Spring MVC에서 애노테이션 메서드는 RequestMappingHandlerAdapter가 HandlerMethod로 다룹니다. 이 어댑터는 인자 리졸버, 반환 값 핸들러, 바인더와 모델 초기화, 메시지 컨버터를 조정합니다. 임의의 컨트롤러 객체를 단순히 ObjectMapper로 직렬화하는 규칙과 같지 않습니다.
Spring의 전체 순서는 MVC processing sequence와 special bean types에서 확인할 수 있습니다. 이 문서는 그 전체 파이프라인을 실행했다고 주장하지 않습니다.
| 경계 | 소유 문서 | B80에서 재현하지 않는 이유 |
|---|---|---|
메서드·경로 등록, Found·405·404, Allow, Servlet service | B79 | B80 입력은 이미 찾은 핸들러입니다. |
입력 검증, PostPageModel, 고정 ModelView, exact registry | B78 | B80은 그 계약을 호출 순서로만 연결합니다. |
| JSP·Thymeleaf, forward, 렌더링, 출력 문맥 이스케이프 | B77 | FixedTestView는 primitive/String 표식만 남깁니다. |
응답 상태·본문·커밋·sendError·HEAD 본문 | B76 | ResponseHandled는 뷰 종료만 뜻합니다. |
실제 DispatcherServlet, 인터셉터, 인자·반환 처리, 바인딩, 컨버터, 예외, 406·415 | 다음 ch5-9 | Spring 전략 전체의 소유 범위입니다. |
버전 기준은 Spring Boot 4.1.1 관리 좌표와 시스템 요구 사항, JUnit 6.0.3 사용자 가이드에서 확인합니다. Boot 4.1.1은 Java 17부터 26까지와 Spring Framework 7.0.9 이상을 지원하므로 이 문서의 Java 25.0.4.1 기준은 지원 범위 안에 있습니다.
연습 문제
같은 PageController 인스턴스를 두 B79 경로에 등록해도 어댑터 결합은 하나만 생성되어야 합니다. 반대로 equals가 같은 두 컨트롤러 인스턴스는 서로 다른 등록 대상으로 유지하세요.
검증할 항목은 다음 세 가지입니다.
- 같은 인스턴스가 등록 목록에 반복되어도
supports행렬은 그 인스턴스에 대해 한 번만 계산됩니다. - 값이 같지만 참조가 다른 두 인스턴스는 각각 정확히 하나의 어댑터 결합을 가집니다.
- 실행 시점에는 어떤 경우에도
supports호출 수가 증가하지 않습니다.
다음 문서에서는 이 작은 사용자 정의 결합과 달리, 실제 Spring DispatcherServlet이 ordered HandlerAdapter 목록, 인터셉터, 인자·반환 값 처리, 메시지 컨버터, 예외 리졸버를 어떤 순서로 조정하는지 살펴봅니다.