본문으로 건너뛰기

안동민 개발노트

본문 시작

HandlerAdapter 확장

B79가 찾은 핸들러를 유한 레지스트리에서 정확히 하나의 어댑터에 결합하고, 명시적인 ModelViewResult·ResponseHandled 결과가 B78·B77 경계로 이어지는 과정을 검증합니다.

B79의 프런트 컨트롤러는 메서드와 컨텍스트 상대 원시 경로를 정확히 매핑해 Found(handler)를 반환합니다. 하지만 핸들러가 PageController인지, 레거시 호출 규칙을 따르는지는 프런트 컨트롤러가 직접 알 필요가 없습니다.

이 문서의 HandlerAdapter이미 찾은 핸들러를 어떻게 호출할지만 소유합니다. 요청 매핑·404·405·Allow·Servlet 메서드 디스패치는 B79에 그대로 두고, B80은 유한한 핸들러 집합과 어댑터 사이의 결합만 다룹니다.

B79에서 이미 찾은 정확한 handler instance는 construction 때 고정한 identity binding으로 자신의 HandlerAdapter를 얻습니다. PageControllerAdapter는 PageController 호출 결과를 ModelViewResult로 명시해 B78 exact registry와 B77 fixed test View 경계로 이어지고, LegacyControllerAdapter는 ResponseHandled를 반환해 view 경로를 끝냅니다. sealed AdapterResult는 null을 쓰지 않으며 ResponseHandled가 response commit이나 rendering 완료를 뜻한다고 주장하지 않습니다. 이 custom finite registry는 handler마다 정확히 하나의 adapter를 미리 고정하는 반면 Spring MVC는 설치된 HandlerAdapter를 순서대로 검사해 처음 지원하는 adapter를 선택하고 nullable ModelAndView를 사용합니다.

FOUND HANDLER → FROZEN ADAPTER → SEALED RESULT

HandlerAdapter는 핸들러 계약을 명시적 결과로 바꾼다

dispatcher는 구체 controller 계약을 해석하지 않습니다. construction 때 고정한 adapter가 handler를 호출하고, view를 계속할지 끝낼지를 sealed 결과로 돌려줍니다.

고정된 HandlerAdapter가 두 handler 계약을 sealed AdapterResult로 변환하는 구조 B79 Found handler에서 frozen identity binding을 조회한 뒤 PageControllerAdapter와 LegacyControllerAdapter로 갈라집니다. PageControllerAdapter는 ModelViewResult를 반환해 B78 exact registry와 B77 fixed test View로 이어지고, LegacyControllerAdapter는 ResponseHandled를 반환해 view path를 끝냅니다. custom exact-one registry와 Spring ordered-first selection의 차이를 위쪽 비교 영역에 표시합니다. CUSTOM · FINITE REGISTRY exact-one을 construction에서 검증하고 identity binding을 고정 supports once at construction · runtime scan = 0 · sealed non-null result SPRING MVC · DIFFERENT POLICY ordered first-supporting HandlerAdapter overlapping support를 일반적으로 거부하지 않음 · nullable ModelAndView DISPATCHER INPUT VIEW CONTINUATION VIEW PATH TERMINATION B79 FOUND handler exact instance CONSTRUCTION OUTPUT frozen identity binding handler → adapter KNOWN CONTRACT PageController- Adapter invoke + adapt SEALED RESULT ModelViewResult ModelView post-detail + PostPageModel B78 exact view registry fixed logical name B77 fixed test View called once KNOWN CONTRACT LegacyController- Adapter invoke + adapt SEALED RESULT ResponseHandled not null · no commit claim TERMINAL stop without view path registry · View = 0 BOUNDARY GUARDS AdapterResult is sealed and non-null ResponseHandled ≠ committed or rendered validation: B78 · rendering evidence: B77 CALL COUNT selected adapter once exact registry + fixed View once only on ModelViewResult

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 분기로 합치지 않습니다. PageControllerAdapterLegacyControllerAdapter가 각각 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의 HandlerAdaptersupports(Object)handle(request, response, Object)를 제공합니다. Spring의 handle은 nullable ModelAndView를 반환하며, null은 요청이 직접 처리되어 뷰가 필요하지 않을 수 있다는 뜻입니다. 커밋 완료를 뜻하지는 않습니다.

또한 실제 DispatcherServlet은 설치된 어댑터를 순서대로 확인해 처음 지원하는 어댑터를 선택합니다. 중복 지원을 일반적으로 startup 오류로 거절하지 않습니다. 지원 어댑터가 없으면 선택 과정에서 치명적인 ServletException이 발생하지만, 이 사실만으로 특정 HTTP 500 응답을 단정할 수도 없습니다.

따라서 아래 exact-one 검증은 Spring 동작을 흉내 낸 주장이 아닙니다. B79가 명시적으로 등록한 유한 핸들러 인스턴스 집합에 적용하는, 이 교재의 더 강한 조립 정책입니다.


등록할 때 검증하고 요청할 때 다시 찾지 않기

동적인 HandlerMapping이 앞으로 반환할 모든 객체를 미리 검사할 수는 없습니다. 반면 B79의 RouteTable처럼 등록 목록이 닫혀 있으면 그 목록에 실제로 들어간 핸들러 인스턴스는 열거할 수 있습니다.

이 문서의 레지스트리는 다음 순서를 고정합니다.

  1. 유한한 등록 핸들러 인스턴스와 어댑터 목록을 받습니다.
  2. 각 등록 인스턴스와 각 어댑터 조합에 대해 supports를 생성 시점에 한 번 계산합니다.
  3. 지원 어댑터가 0개거나 2개 이상이면 요청을 받기 전에 구성을 거절합니다.
  4. 정확히 1개이면 객체 동일성을 키로 핸들러→어댑터 결합을 고정합니다.
  5. 실행 시에는 supports를 다시 호출하거나 목록 순서에 기대지 않고, 고정된 어댑터를 정확히 한 번 호출합니다.

동일성 결합에는 Java 25의 IdentityHashMap을 의도적으로 사용합니다. 이 자료구조는 일반적인 equals 기반 Map이 아니라 참조 동일성으로 키를 비교하므로, 등록 객체와 값이 같은 별도 객체를 같은 핸들러로 오인하지 않습니다. 이 선택은 등록 인스턴스 경계에만 한정합니다.

custom finite registry는 construction 단계에서 등록된 handler instance와 adapter의 supports 관계를 한 번 평가합니다. 한 handler를 지원하는 adapter가 정확히 하나면 handler identity에 그 adapter를 고정하고, zero 또는 multiple이면 serving 전에 construction exception으로 실패합니다. request 단계에서는 B79 Found가 돌려준 같은 handler instance로 frozen binding을 조회하고 같은 adapter를 한 번 호출하며 supports를 다시 검사하지 않습니다. 이 실패는 HTTP 500 응답을 뜻하지 않습니다. Spring MVC는 설치된 HandlerAdapter를 순서대로 검사해 처음 지원하는 adapter를 선택하므로 exact-one과 overlap 거부는 이 custom policy에만 해당합니다.

CONSTRUCT ONCE → FREEZE IDENTITY → INVOKE ONCE

등록 시 정확히 하나를 검증하고 요청 시 같은 어댑터를 호출한다

지원 관계의 누락과 중복을 첫 요청까지 미루지 않습니다. 유한한 등록 집합을 한 번 검증해 identity binding을 고정하고, 요청에서는 lookup과 단 한 번의 호출만 수행합니다.

construction exact-one 검증과 request identity lookup의 두 단계 흐름 finite registered handler instance와 adapter 목록에서 supports를 한 번 평가하고 supporter count가 하나인지 검사합니다. zero 또는 multiple이면 construction failure, 하나면 identity binding을 고정합니다. request에서는 B79 Found의 exact handler로 binding을 조회해 같은 adapter를 한 번 호출하며 runtime rescan은 없습니다. Spring MVC의 ordered-first 선택과 다른 custom policy입니다. CUSTOM · EXACT-ONE finite instances · zero/multiple reject · frozen identity binding SPRING MVC · ORDERED-FIRST ordered first-supporting adapter wins PHASE 1 · CONSTRUCTION finite registry only · evaluate once per registered pair PHASE 2 · REQUEST supports rescan = 0 ZERO / MULTIPLE ONE FINITE INPUT registered instances handlers + adapters SUPPORTS MATRIX evaluate once each registered pair supporter count == 1 ? CONSTRUCTION FAILURE zero or multiple supporters before serving · not an HTTP response EXACTLY ONE freeze identity binding handler instance → same adapter B79 FOUND exact handler instance routing already complete IDENTITY LOOKUP read frozen binding no supports rescan RUNTIME CALL invoke same adapter once closed AdapterResult follows SCOPE registered finite instances only · no dynamic/global discovery claim missing handler and 404 remain B79 responsibilities

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 버전은 뒤의 공식 계약 비교 기준입니다.

src/test/java/board/mvc/HandlerAdapterDispatchBoundaryTest.java
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);
        }
    }
}

일곱 테스트가 닫는 범위

테스트관찰하는 출력의도적으로 관찰하지 않는 것
registeredHandlersAreBoundToExactlyOneAdapterPage·Legacy 등록 인스턴스마다 정확히 하나의 고정 결합Spring bean 검색, 동적 핸들러 전체
missingAdapterFailsDuringConstruction지원 0개면 생성 실패, 실행 호출 0회HTTP 500, 예외 리졸버
overlappingAdaptersFailDuringConstruction지원 2개면 사용자 정의 구성 실패Spring의 ordered-first 동작
dispatchUsesFrozenIdentityBindingWithoutRescanning외부 목록 변경 뒤에도 같은 결합, runtime supports 증가 0동적 어댑터 재등록
modelViewResultResolvesAndInvokesFixedViewOnceInOrderadapter → controller → exact registry → fixed view, 각 1회실제 템플릿·forward·이스케이프
responseHandledSkipsRegistryAndView레거시 표식, registry/view 0회커밋·상태·본문 정확성
unregisteredHandlerFailsWithoutDownstreamCallsequals가 같아도 다른 인스턴스는 거절, 하위 호출 0회B79의 404·405 매핑

Trace.snapshot()List.copyOf는 관찰 시점 목록을 바꾸지 못하게 할 뿐이며 중첩 객체를 deep-freeze한다고 주장하지 않습니다. 실제 모델은 primitive와 String만 가진 PostPageModel 레코드입니다.


Spring MVC로 확장할 때의 소유권

실제 Spring MVC에서 애노테이션 메서드는 RequestMappingHandlerAdapterHandlerMethod로 다룹니다. 이 어댑터는 인자 리졸버, 반환 값 핸들러, 바인더와 모델 초기화, 메시지 컨버터를 조정합니다. 임의의 컨트롤러 객체를 단순히 ObjectMapper로 직렬화하는 규칙과 같지 않습니다.

Spring의 전체 순서는 MVC processing sequencespecial bean types에서 확인할 수 있습니다. 이 문서는 그 전체 파이프라인을 실행했다고 주장하지 않습니다.

경계소유 문서B80에서 재현하지 않는 이유
메서드·경로 등록, Found·405·404, Allow, Servlet serviceB79B80 입력은 이미 찾은 핸들러입니다.
입력 검증, PostPageModel, 고정 ModelView, exact registryB78B80은 그 계약을 호출 순서로만 연결합니다.
JSP·Thymeleaf, forward, 렌더링, 출력 문맥 이스케이프B77FixedTestView는 primitive/String 표식만 남깁니다.
응답 상태·본문·커밋·sendError·HEAD 본문B76ResponseHandled는 뷰 종료만 뜻합니다.
실제 DispatcherServlet, 인터셉터, 인자·반환 처리, 바인딩, 컨버터, 예외, 406·415다음 ch5-9Spring 전략 전체의 소유 범위입니다.

버전 기준은 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가 같은 두 컨트롤러 인스턴스는 서로 다른 등록 대상으로 유지하세요.

검증할 항목은 다음 세 가지입니다.

  1. 같은 인스턴스가 등록 목록에 반복되어도 supports 행렬은 그 인스턴스에 대해 한 번만 계산됩니다.
  2. 값이 같지만 참조가 다른 두 인스턴스는 각각 정확히 하나의 어댑터 결합을 가집니다.
  3. 실행 시점에는 어떤 경우에도 supports 호출 수가 증가하지 않습니다.

다음 문서에서는 이 작은 사용자 정의 결합과 달리, 실제 Spring DispatcherServlet이 ordered HandlerAdapter 목록, 인터셉터, 인자·반환 값 처리, 메시지 컨버터, 예외 리졸버를 어떤 순서로 조정하는지 살펴봅니다.