본문 바로가기

HandlerAdapter 확장

프런트 컨트롤러가 찾은 핸들러를 유한 레지스트리에서 정확히 하나의 어댑터에 결합하고, 명시적인 ModelViewResult·ResponseHandled 결과가 MVC 역할 경계·서블릿·JSP 책임 경계로 이어지는 과정을 검증합니다.

프런트 컨트롤러 발전의 프런트 컨트롤러는 메서드와 컨텍스트 기준의 원시 경로를 정확히 매핑해 Found(handler)를 돌려줍니다. 그런데 그 핸들러가 PageController인지, 예전 방식의 호출 규칙을 따르는지까지 프런트 컨트롤러가 직접 알 필요는 없습니다.

이 글의 HandlerAdapter는 이미 찾은 핸들러를 어떻게 호출할지만 맡습니다. 요청 매핑과 404, 405, Allow, 서블릿 메서드 디스패치는 앞 글의 구조에 그대로 두고, 이 글은 유한한 핸들러 집합과 어댑터를 어떻게 짝짓는지만 다룹니다.

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

frozen binding 조회·adapter handle·ModelViewResult 또는 ResponseHandled·view 여부로 처리 상태가 바뀐다.

B79에서 이미 찾은 정확한 handler instance는 construction 때 고정한 identity binding으로 자신의 HandlerAdapter를 얻습니다. PageControllerAdapter는 PageController 호출 결과를 ModelViewResult로 명시해 MVC 역할 경계 exact registry와 서블릿·JSP 책임 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 instance의 frozen binding을 조회합니다. PageControllerAdapter는 ModelViewResult로 registry와 fixed test View를 계속하고 LegacyControllerAdapter는 ResponseHandled로 view 경로를 끝냅니다. 결과는 응답 커밋을 증명하지 않습니다.Found의 정확한 handler instancefrozen identity bindingPageControllerAdapterPageController 호출LegacyControllerAdapterLegacyController 호출ModelViewResultpost-detail + PostPageModelResponseHandledview 경로 종료exact registry → fixed test View각 1회 · markerregistry · View 호출 0응답 commit 증거는 별도
두 어댑터 결과의 모바일 경로데스크톱의 일곱 노드와 여섯 관계를 세로로 다시 배치한다. 등록된 handler참조의 고정 결합에서 page경로는ModelViewResult 뒤exact registry와fixed view를 호출한다. legacy경로는ResponseHandled뒤registry/view0으로 끝난다. 두 경로는 서로 다른 선택이며 세로로 놓인 모든 노드를 연달아 실행하지 않는다. 어느 결과도HTTP커밋의 증명이 아니다.PageLegacy정확한 handler 참조frozen identitybinding 조회Page 어댑터PageController 호출ModelViewResultpost-detailPostPageModelexact registryfixed test View각 1회 · markerLegacy 어댑터LegacyController 호출ResponseHandledview 경로 종료registry · View호출 0응답 commit은 별도

오른쪽 우회선은 binding에서 Legacy를 선택한 경로입니다. Page 뒤에 Legacy를 실행하는 뜻이 아니며, 두 경우 모두 실제 응답 커밋·HTML·escaping 검증과 구분합니다.

01 · FROZEN IDENTITY BINDING

프런트 컨트롤러 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와 서블릿·JSP 책임 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 호출과 결과 적응만 소유합니다. 입력 검증은 MVC 역할 경계, fixed View의 rendering·escaping 증거는 서블릿·JSP 책임, HandlerMethod와 argument·return-value 처리 같은 실제 Spring 내부는 ch5-9의 범위입니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 정확한 handler instance의 frozen binding을 조회합니다. PageControllerAdapter는 ModelViewResult로 registry와 fixed test View를 계속하고 LegacyControllerAdapter는 ResponseHandled로 view 경로를 끝냅니다. 결과는 응답 커밋을 증명하지 않습니다.
  • 데스크톱의 일곱 노드와 여섯 관계를 세로로 다시 배치한다. 등록된 handler 참조의 고정 결합에서 page 경로는 ModelViewResult 뒤 exact registry와 fixed view를 호출한다. legacy 경로는 ResponseHandled 뒤 registry/view 0으로 끝난다. 두 경로는 서로 다른 선택이며 세로로 놓인 모든 노드를 연달아 실행하지 않는다. 어느 결과도 HTTP 커밋의 증명이 아니다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
22데스크톱의 일곱 노드와 여섯 관계를 세로로 다시 배치한다. 등록된 handler 참조의 고정 결합에서 page 경로는 ModelViewResult 뒤 exact registry와 fixed view를 호출한다. legacy 경로는 ResponseHandled 뒤 registry/view 0으로 끝난다. 두 경로는 서로 다른 선택이며 세로로 놓인 모든 노드를 연달아 실행하지 않는다. 어느 결과도 HTTP 커밋의 증명이 아니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
frozen binding 조회

정확한 handler instance의 binding을 찾습니다.

lookup: identity binding · adapter: 고정등록한 처리 계약을 선택합니다.
adapter 결과

Page는 ModelViewResult, Legacy는 ResponseHandled입니다.

Page: ModelViewResult · Legacy: ResponseHandledadapter가 다른 handler 결과를 명시적으로 바꿉니다.
view 또는 종료

Page는 registry·test View로 가고 Legacy는 view를 생략합니다.

Page: view 실행 · Legacy: view 없음 · 응답 commit: 증명 아님결과 타입이 다음 실행 경계를 정합니다.

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

frozen binding 조회·adapter handle·ModelViewResult 또는 ResponseHandled·view 여부로 처리 상태가 바뀐다.

B79에서 이미 찾은 정확한 handler instance는 construction 때 고정한 identity binding으로 자신의 HandlerAdapter를 얻습니다. PageControllerAdapter는 PageController 호출 결과를 ModelViewResult로 명시해 MVC 역할 경계 exact registry와 서블릿·JSP 책임 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 instance의 frozen binding을 조회합니다. PageControllerAdapter는 ModelViewResult로 registry와 fixed test View를 계속하고 LegacyControllerAdapter는 ResponseHandled로 view 경로를 끝냅니다. 결과는 응답 커밋을 증명하지 않습니다.Found의 정확한 handler instancefrozen identity bindingPageControllerAdapterPageController 호출LegacyControllerAdapterLegacyController 호출ModelViewResultpost-detail + PostPageModelResponseHandledview 경로 종료exact registry → fixed test View각 1회 · markerregistry · View 호출 0응답 commit 증거는 별도
두 어댑터 결과의 모바일 경로데스크톱의 일곱 노드와 여섯 관계를 세로로 다시 배치한다. 등록된 handler참조의 고정 결합에서 page경로는ModelViewResult 뒤exact registry와fixed view를 호출한다. legacy경로는ResponseHandled뒤registry/view0으로 끝난다. 두 경로는 서로 다른 선택이며 세로로 놓인 모든 노드를 연달아 실행하지 않는다. 어느 결과도HTTP커밋의 증명이 아니다.PageLegacy정확한 handler 참조frozen identitybinding 조회Page 어댑터PageController 호출ModelViewResultpost-detailPostPageModelexact registryfixed test View각 1회 · markerLegacy 어댑터LegacyController 호출ResponseHandledview 경로 종료registry · View호출 0응답 commit은 별도

오른쪽 우회선은 binding에서 Legacy를 선택한 경로입니다. Page 뒤에 Legacy를 실행하는 뜻이 아니며, 두 경우 모두 실제 응답 커밋·HTML·escaping 검증과 구분합니다.

01 · FROZEN IDENTITY BINDING

프런트 컨트롤러 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와 서블릿·JSP 책임 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 호출과 결과 적응만 소유합니다. 입력 검증은 MVC 역할 경계, fixed View의 rendering·escaping 증거는 서블릿·JSP 책임, HandlerMethod와 argument·return-value 처리 같은 실제 Spring 내부는 ch5-9의 범위입니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 정확한 handler instance의 frozen binding을 조회합니다. PageControllerAdapter는 ModelViewResult로 registry와 fixed test View를 계속하고 LegacyControllerAdapter는 ResponseHandled로 view 경로를 끝냅니다. 결과는 응답 커밋을 증명하지 않습니다.
  • 데스크톱의 일곱 노드와 여섯 관계를 세로로 다시 배치한다. 등록된 handler 참조의 고정 결합에서 page 경로는 ModelViewResult 뒤 exact registry와 fixed view를 호출한다. legacy 경로는 ResponseHandled 뒤 registry/view 0으로 끝난다. 두 경로는 서로 다른 선택이며 세로로 놓인 모든 노드를 연달아 실행하지 않는다. 어느 결과도 HTTP 커밋의 증명이 아니다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
22데스크톱의 일곱 노드와 여섯 관계를 세로로 다시 배치한다. 등록된 handler 참조의 고정 결합에서 page 경로는 ModelViewResult 뒤 exact registry와 fixed view를 호출한다. legacy 경로는 ResponseHandled 뒤 registry/view 0으로 끝난다. 두 경로는 서로 다른 선택이며 세로로 놓인 모든 노드를 연달아 실행하지 않는다. 어느 결과도 HTTP 커밋의 증명이 아니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
frozen binding 조회

정확한 handler instance의 binding을 찾습니다.

lookup: identity binding · adapter: 고정등록한 처리 계약을 선택합니다.
adapter 결과

Page는 ModelViewResult, Legacy는 ResponseHandled입니다.

Page: ModelViewResult · Legacy: ResponseHandledadapter가 다른 handler 결과를 명시적으로 바꿉니다.
view 또는 종료

Page는 registry·test View로 가고 Legacy는 view를 생략합니다.

Page: view 실행 · Legacy: view 없음 · 응답 commit: 증명 아님결과 타입이 다음 실행 경계를 정합니다.

그림은 프런트 컨트롤러가 찾은 핸들러 인스턴스로, 미리 고정해 둔 어댑터를 찾는 데서 시작합니다. 그림 속 B79는 앞 글의 프런트 컨트롤러, B78은 MVC 역할 경계의 예제를 가리킵니다. PageControllerAdapter는 ModelViewResult를 돌려주어 정확한 뷰 레지스트리와 고정된 테스트 뷰로 이어지고, LegacyControllerAdapter는 ResponseHandled를 돌려주어 뷰 경로를 끝냅니다. 두 경로는 하나만 선택되는 분기이며, 어느 결과도 응답이 커밋되었다는 뜻은 아닙니다.


핸들러의 호출 방식과 어댑터의 결과

핸들러는 매핑이 고른 실행 대상입니다. 이 예제에는 호출 방식이 다른 핸들러가 두 종류 있습니다.

  • PageController는 이미 검증된 입력을 받아, 앞 글들에서 정한 고정 논리 이름 post-detail과 타입이 있는 PostPageModel을 담은 ModelView를 돌려줍니다.
  • LegacyController는 전달받은 확인용 표식에 값을 남기고 뷰 단계로 넘어가지 않습니다.

두 방식을 프런트 컨트롤러의 instanceof 분기로 합치지 않습니다. PageControllerAdapter와 LegacyControllerAdapter가 각자 supports와 호출 방법을 함께 맡습니다. 이 교재에서 정의한 어댑터의 반환 타입은 null이 될 수 있는 값이 아니라 다음 두 경우로 닫혀 있습니다.

결과다음 단계이 글에서 확인하는 것이 글에서 단정하지 않는 것
ModelViewResult(ModelView)정확한 뷰 레지스트리, 고정된 테스트 뷰논리 이름과 타입이 있는 모델, 호출 횟수와 순서, 원시 타입과 String의 표식JSP·Thymeleaf 실행, 포워드, HTML, 이스케이프
ResponseHandled뷰 경로 종료레거시 핸들러의 표식, 레지스트리와 뷰 호출 0회응답 커밋, HTTP 상태, 본문과 미디어 타입의 정확성

ResponseHandled는 뷰를 찾지 않는다는 뜻만 가집니다. HttpServletResponse.isCommitted()나 실제 네트워크 응답을 대신 확인해 주지 않습니다.

Spring의 HandlerAdapter와 구분하기

Spring Framework 7.0.10의 HandlerAdapter는 supports(Object)와 handle(request, response, Object)를 제공합니다. Spring의 handle은 null이 될 수 있는 ModelAndView를 돌려주며, null은 요청을 직접 처리해서 뷰가 필요하지 않을 수 있다는 뜻일 뿐 커밋이 끝났다는 뜻이 아닙니다.

실제 DispatcherServlet은 설치된 어댑터를 순서대로 확인해 처음으로 지원하는 어댑터를 고릅니다. 여러 어댑터가 같은 핸들러를 지원한다고 해서 시작할 때 오류로 거절하지 않습니다. 지원하는 어댑터가 없으면 선택 과정에서 ServletException이 나지만, 이것만으로 특정한 HTTP 500 응답이 나간다고 단정할 수도 없습니다.

따라서 아래의 정확히 하나라는 검증은 Spring의 동작을 흉내 낸 것이 아닙니다. 앞 글에서 명시적으로 등록한 유한한 핸들러 인스턴스 집합에 적용하는, 이 교재가 정한 더 엄격한 조립 정책입니다.


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

동적인 HandlerMapping이 앞으로 돌려줄 모든 객체를 미리 검사할 수는 없습니다. 반면 앞 글의 RouteTable처럼 등록 목록이 닫혀 있으면, 그 목록에 실제로 들어간 핸들러 인스턴스를 모두 열거할 수 있습니다. 이 글의 레지스트리는 다음 순서를 지킵니다.

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

동일성으로 결합하려고 Java 25의 IdentityHashMap을 일부러 씁니다. 이 자료 구조는 equals로 비교하는 일반적인 Map과 달리 참조가 같은지로 키를 비교하므로, 등록한 객체와 값만 같은 다른 객체를 같은 핸들러로 착각하지 않습니다. 이 선택은 등록된 인스턴스를 다루는 곳에만 씁니다.

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

생성 시 supports 유일성 검사·identity binding 고정 뒤 요청에서는 lookup/handle만 하는 두 시간 경계이다.

custom finite registry는 construction 단계에서 등록된 handler instance와 adapter의 supports 관계를 한 번 평가합니다. 한 handler를 지원하는 adapter가 정확히 하나면 handler identity에 그 adapter를 고정하고, zero 또는 multiple이면 serving 전에 construction exception으로 실패합니다. request 단계에서는 프런트 컨트롤러 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과 단 한 번의 호출만 수행합니다.

등록 단계 검증과 요청 단계 호출등록된 handler와 adapter 쌍의 supports를 생성 시점에 한 번 검사합니다. supporter가 정확히 하나일 때만 identity binding을 고정하고 그 외 구성은 실패합니다. 요청 시 동일한 instance로 lookup하고 supports 재검사 없이 handle을 한 번 호출합니다.조립 시: 유한 등록 인스턴스 × adaptersupports등록 쌍마다 1회supporter 수정확히 1개?1개: identitybinding 고정0개 또는 2개 이상: serving 전 구성 실패요청 시: supports 재검사 0Found handler같은 instancefrozen bindingidentity lookup같은 adapterhandle 1회
조립과 요청 실행을 구분한 모바일 경로기존 데스크톱의 여섯 노드·네 관계이다. 위 영역은유한handler/adaptersupports평가와정확히하나확인뒤identitybinding을고정한다. supporter0또는2이상이면여기서조립실패하여고정단계로진행하지않는다. 아래 영역은요청의등록참조로결합을찾고같은adapter를호출한다. supports재검사는없고두단계를한요청의반복검색으로합치지않는다.조립 시 한 번 검증요청 시 고정 결합 사용1개supports등록 쌍마다 1회supporter 수정확히 1개?handler identitybinding 고정Found handler같은 instancefrozen bindingidentity lookup같은 adapterhandle 1회

supporter가 0개 또는 2개 이상이면 조립 실패입니다. 요청 단계의 supports 재검사는 0회입니다. 이는 custom exact-one 정책이며 HTTP 500이나 Spring ordered-first 동작을 증명하지 않습니다.

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

프런트 컨트롤러 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는 프런트 컨트롤러, sealed AdapterResult 이후의 view 경계는 첫 번째 그림, 실제 Spring HandlerMethod 처리와 exception resolution은 ch5-9가 소유합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 등록된 handler와 adapter 쌍의 supports를 생성 시점에 한 번 검사합니다. supporter가 정확히 하나일 때만 identity binding을 고정하고 그 외 구성은 실패합니다. 요청 시 동일한 instance로 lookup하고 supports 재검사 없이 handle을 한 번 호출합니다.
  • 기존 데스크톱의 여섯 노드와 네 관계다. 위 영역은 유한한 handler/adapter의 supports 평가와 정확히 하나인지 확인한 뒤 identity binding을 고정한다. supporter가 0 또는 2 이상이면 여기서 조립 실패로 끝나 고정 단계로 진행하지 않는다. 아래 영역은 요청의 등록 참조로 결합을 찾고 같은 adapter를 호출한다. supports 재검사는 없고 두 단계를 한 요청의 반복 검색으로 합치지 않는다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
23기존 데스크톱의 여섯 노드와 네 관계다. 위 영역은 유한한 handler/adapter의 supports 평가와 정확히 하나인지 확인한 뒤 identity binding을 고정한다. supporter가 0 또는 2 이상이면 여기서 조립 실패로 끝나 고정 단계로 진행하지 않는다. 아래 영역은 요청의 등록 참조로 결합을 찾고 같은 adapter를 호출한다. supports 재검사는 없고 두 단계를 한 요청의 반복 검색으로 합치지 않는다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
생성 시 supports 확인

등록 쌍의 supports를 생성 시점에 한 번 검사합니다.

supporter: 정확히 하나 · 나머지: 구성 실패모호한 조립을 시작 전에 차단합니다.
identity binding 고정

같은 handler·adapter instance의 binding을 고정합니다.

연결: frozen identity · supports: 검사 완료요청에 쓸 선택 결과가 저장됩니다.
요청 handle 한 번

요청은 같은 instance로 lookup하고 handle을 한 번 호출합니다.

supports 재검사: 없음 · handle: 1회구성 시 선택을 요청 실행이 재사용합니다.

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

생성 시 supports 유일성 검사·identity binding 고정 뒤 요청에서는 lookup/handle만 하는 두 시간 경계이다.

custom finite registry는 construction 단계에서 등록된 handler instance와 adapter의 supports 관계를 한 번 평가합니다. 한 handler를 지원하는 adapter가 정확히 하나면 handler identity에 그 adapter를 고정하고, zero 또는 multiple이면 serving 전에 construction exception으로 실패합니다. request 단계에서는 프런트 컨트롤러 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과 단 한 번의 호출만 수행합니다.

등록 단계 검증과 요청 단계 호출등록된 handler와 adapter 쌍의 supports를 생성 시점에 한 번 검사합니다. supporter가 정확히 하나일 때만 identity binding을 고정하고 그 외 구성은 실패합니다. 요청 시 동일한 instance로 lookup하고 supports 재검사 없이 handle을 한 번 호출합니다.조립 시: 유한 등록 인스턴스 × adaptersupports등록 쌍마다 1회supporter 수정확히 1개?1개: identitybinding 고정0개 또는 2개 이상: serving 전 구성 실패요청 시: supports 재검사 0Found handler같은 instancefrozen bindingidentity lookup같은 adapterhandle 1회
조립과 요청 실행을 구분한 모바일 경로기존 데스크톱의 여섯 노드·네 관계이다. 위 영역은유한handler/adaptersupports평가와정확히하나확인뒤identitybinding을고정한다. supporter0또는2이상이면여기서조립실패하여고정단계로진행하지않는다. 아래 영역은요청의등록참조로결합을찾고같은adapter를호출한다. supports재검사는없고두단계를한요청의반복검색으로합치지않는다.조립 시 한 번 검증요청 시 고정 결합 사용1개supports등록 쌍마다 1회supporter 수정확히 1개?handler identitybinding 고정Found handler같은 instancefrozen bindingidentity lookup같은 adapterhandle 1회

supporter가 0개 또는 2개 이상이면 조립 실패입니다. 요청 단계의 supports 재검사는 0회입니다. 이는 custom exact-one 정책이며 HTTP 500이나 Spring ordered-first 동작을 증명하지 않습니다.

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

프런트 컨트롤러 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는 프런트 컨트롤러, sealed AdapterResult 이후의 view 경계는 첫 번째 그림, 실제 Spring HandlerMethod 처리와 exception resolution은 ch5-9가 소유합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 등록된 handler와 adapter 쌍의 supports를 생성 시점에 한 번 검사합니다. supporter가 정확히 하나일 때만 identity binding을 고정하고 그 외 구성은 실패합니다. 요청 시 동일한 instance로 lookup하고 supports 재검사 없이 handle을 한 번 호출합니다.
  • 기존 데스크톱의 여섯 노드와 네 관계다. 위 영역은 유한한 handler/adapter의 supports 평가와 정확히 하나인지 확인한 뒤 identity binding을 고정한다. supporter가 0 또는 2 이상이면 여기서 조립 실패로 끝나 고정 단계로 진행하지 않는다. 아래 영역은 요청의 등록 참조로 결합을 찾고 같은 adapter를 호출한다. supports 재검사는 없고 두 단계를 한 요청의 반복 검색으로 합치지 않는다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
23기존 데스크톱의 여섯 노드와 네 관계다. 위 영역은 유한한 handler/adapter의 supports 평가와 정확히 하나인지 확인한 뒤 identity binding을 고정한다. supporter가 0 또는 2 이상이면 여기서 조립 실패로 끝나 고정 단계로 진행하지 않는다. 아래 영역은 요청의 등록 참조로 결합을 찾고 같은 adapter를 호출한다. supports 재검사는 없고 두 단계를 한 요청의 반복 검색으로 합치지 않는다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
생성 시 supports 확인

등록 쌍의 supports를 생성 시점에 한 번 검사합니다.

supporter: 정확히 하나 · 나머지: 구성 실패모호한 조립을 시작 전에 차단합니다.
identity binding 고정

같은 handler·adapter instance의 binding을 고정합니다.

연결: frozen identity · supports: 검사 완료요청에 쓸 선택 결과가 저장됩니다.
요청 handle 한 번

요청은 같은 instance로 lookup하고 handle을 한 번 호출합니다.

supports 재검사: 없음 · handle: 1회구성 시 선택을 요청 실행이 재사용합니다.

그림의 위쪽은 조립 단계입니다. 등록된 핸들러와 어댑터의 쌍마다 supports를 한 번 평가하고, 지원하는 어댑터가 정확히 하나일 때만 핸들러의 동일성에 그 어댑터를 고정하며, 0개나 2개 이상이면 서버가 요청을 받기 전에 실패합니다. 아래쪽은 요청 단계로, 프런트 컨트롤러가 돌려준 같은 인스턴스로 결합을 찾아 같은 어댑터의 handle을 한 번 호출하고 supports는 다시 검사하지 않습니다.

구성 실패는 순수 Java 예외로 확인합니다. 이를 HTTP 상태로 바꾸는 일은 서블릿 어댑터나 Spring의 예외 처리가 맡을 일이며, 이 단위 테스트는 그 변환을 다루지 않습니다.


한 파일로 결합과 호출을 확인합니다

아래 파일은 부분 예제를 모은 것이 아니라 모든 협력 객체를 담은 완결된 컴파일 단위입니다. 기준은 Java 25와 Spring Boot 4.1.2 BOM, Spring Framework 7.0.10, JUnit 6.0.3, AssertJ 3.27.7입니다. 직접 만든 레지스트리는 Spring이나 서블릿 객체 없이 실행되며, Spring의 버전은 뒤에서 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 빈 검색, 동적 핸들러 전체
missingAdapterFailsDuringConstruction지원이 0개면 생성 실패, 실행 호출 0회HTTP 500, 예외 리졸버
overlappingAdaptersFailDuringConstruction지원이 2개면 직접 정한 구성 실패Spring의 순서대로 첫 번째를 고르는 동작
dispatchUsesFrozenIdentityBindingWithoutRescanning바깥 목록을 바꾼 뒤에도 같은 결합, 실행 중 supports 증가 0동적인 어댑터 재등록
modelViewResultResolvesAndInvokesFixedViewOnceInOrder어댑터, 컨트롤러, 정확한 레지스트리, 고정 뷰의 순서와 각 1회실제 템플릿, 포워드, 이스케이프
responseHandledSkipsRegistryAndView레거시 표식, 레지스트리와 뷰 0회커밋, 상태, 본문의 정확성
unregisteredHandlerFailsWithoutDownstreamCallsequals가 같아도 다른 인스턴스는 거절, 이후 호출 0회앞 글의 404·405 매핑

Trace.snapshot()의 List.copyOf는 확인하는 시점의 목록을 바꾸지 못하게 할 뿐, 안쪽 객체까지 고정하지는 않습니다. 실제 모델은 원시 타입과 String만 가진 PostPageModel 레코드입니다.


Spring MVC로 넓힐 때 맡는 곳

실제 Spring MVC에서 애노테이션 메서드는 RequestMappingHandlerAdapter가 HandlerMethod로 다룹니다. 이 어댑터는 인자 리졸버와 반환 값 처리기, 바인더와 모델 초기화, 메시지 컨버터를 조정하며, 임의의 컨트롤러 객체를 ObjectMapper로 직렬화하는 단순한 규칙과는 다릅니다. Spring의 전체 순서는 MVC 처리 순서와 특수 빈 타입에서 볼 수 있으며, 이 글은 그 파이프라인 전체를 실행하지 않습니다.

다루는 내용다루는 글이 글에서 다시 만들지 않는 이유
메서드와 경로 등록, Found·405·404, Allow, 서블릿 service프런트 컨트롤러 발전이 글의 입력은 이미 찾은 핸들러입니다.
입력 검증, PostPageModel, 고정 ModelView, 정확한 레지스트리MVC 역할 경계이 글은 그 구조를 호출 순서로만 잇습니다.
JSP·Thymeleaf, 포워드, 렌더링, 출력 문맥의 이스케이프서블릿·JSP 책임FixedTestView는 원시 타입과 String의 표식만 남깁니다.
응답 상태와 본문, 커밋, sendError, HEAD 본문서블릿 응답 출력ResponseHandled는 뷰 경로의 종료만 뜻합니다.
실제 DispatcherServlet, 인터셉터, 인자와 반환 값 처리, 바인딩, 컨버터, 예외, 406·415디스패처 서블릿 구조Spring 전략 전체가 다룰 범위입니다.

버전은 Spring Boot 관리 의존성 좌표와 시스템 요구 사항, JUnit 6.0.3 사용자 가이드에서 확인합니다. Spring Boot 4.1.2는 Java 17 이상, Java 27까지와 호환되며 Spring Framework 7.0.10 이상을 요구하므로, 이 글의 Java 25 기준은 지원 범위 안에 있습니다.


정리

  • HandlerAdapter는 매핑이 이미 찾은 핸들러를 어떻게 호출할지만 맡고, 프런트 컨트롤러는 핸들러의 구체 타입을 instanceof로 나누지 않습니다.
  • 이 교재의 어댑터는 null 대신 ModelViewResult와 ResponseHandled 가운데 하나를 돌려주며, ResponseHandled는 뷰 경로의 종료만 뜻합니다.
  • 유한한 등록 핸들러마다 지원하는 어댑터가 정확히 하나인지 생성 시점에 확인하고, 0개나 2개 이상이면 요청을 받기 전에 실패합니다.
  • 결합은 IdentityHashMap으로 참조의 동일성에 고정하고, 실행 중에는 supports를 다시 호출하지 않습니다.
  • Spring MVC는 설치된 어댑터를 순서대로 확인해 처음 지원하는 것을 고르고 null이 될 수 있는 ModelAndView를 쓰므로, 이 글의 엄격한 정책을 Spring의 동작으로 일반화하지 않습니다.

연습 문제

1. 반복 등록과 값이 같은 인스턴스

같은 PageController 인스턴스를 두 경로에 등록해도 어댑터 결합은 하나만 생겨야 하고, equals가 같은 두 컨트롤러 인스턴스는 서로 다른 등록 대상으로 남아야 합니다. 이 요구를 테스트로 확인할 때 검증할 항목 세 가지를 들고, 지금의 HandlerAdapterRegistry가 이를 어떻게 처리하는지 설명하세요.

해설 보기

첫째, 같은 인스턴스가 등록 목록에 되풀이되어도 그 인스턴스에 대한 supports는 어댑터마다 한 번만 계산됩니다. 둘째, 값은 같지만 참조가 다른 두 인스턴스는 각각 정확히 하나의 어댑터 결합을 가집니다. 셋째, 실행 시점에는 어떤 경우에도 supports 호출 횟수가 늘지 않습니다.

지금의 레지스트리는 IdentityHashMap으로 만든 seen에 이미 본 참조를 기록하고, 같은 참조가 다시 나오면 continue로 건너뜁니다. 결합도 IdentityHashMap에 담으므로 값이 같은 다른 인스턴스는 별도의 키가 됩니다. 실행할 때는 required가 고정된 맵에서 어댑터를 꺼내기만 하므로 supports를 부르지 않습니다. 테스트에서는 어댑터마다 supports 호출을 세는 카운터를 두고, 생성 직후와 실행 뒤의 값을 비교하면 됩니다.

2. 지원 횟수와 호출 횟수 세기

페이지 컨트롤러 인스턴스 A와 B는 equals가 같지만 참조가 다릅니다. 레거시 컨트롤러 L과 함께 등록 목록을 [A, A, B, L]로 만들고, 페이지 어댑터와 레거시 어댑터를 하나씩 넣습니다. 각 어댑터는 자기 방식의 핸들러만 지원하며, 두 방식을 함께 구현하는 핸들러는 없습니다. ① 조립할 때 각 어댑터의 supports 횟수와 총 결합 수는 얼마인가요? ② 조립 뒤 A, B, L을 한 번씩 실행합니다. 호출마다 새 ObservationMarker를 쓰고, 페이지 핸들러는 올바른 ModelView를 돌려줍니다. 실행 때문에 supports가 몇 번 더 불리고, 두 어댑터와 뷰는 각각 몇 번 호출되나요? ③ A와 값만 같은 등록되지 않은 인스턴스 C를 넘기면 어떻게 되나요?

해설 보기

① 목록의 항목은 네 개지만 참조가 서로 다른 핸들러는 A, B, L 세 개입니다. 두 번째 A는 seen 검사에서 건너뛰므로 각 어댑터의 supports는 3회, 전체 지원 평가는 6회입니다. A와 B는 값이 같아도 참조가 다르므로 합치지 않고, 각 인스턴스가 어댑터 하나에 결합해 총 결합 수는 3입니다.

② 실행 중에는 supports가 더 불리지 않습니다. 페이지 어댑터는 A와 B에서 2회, 레거시 어댑터는 L에서 1회 호출됩니다. 페이지 결과만 뷰를 거치므로 정확한 레지스트리와 고정 뷰는 각각 2회이고, 레거시 경로에서는 둘 다 0회입니다. 호출마다 새 표식을 쓰므로 표식은 한 번만 기록할 수 있다는 제한에 실행끼리 서로 걸리지 않습니다.

③ C는 등록된 참조가 아니므로 required에서 UnregisteredHandlerException이 납니다. 어댑터의 handle과 컨트롤러, 레지스트리, 뷰를 호출하지 않고 supports를 다시 검사해 구제하지도 않습니다. 이것은 이 예제가 정한 고정 조립 정책이며, 순서대로 첫 번째를 고르는 Spring의 선택과는 다릅니다. 이 횟수는 코드를 따라간 예상이므로 테스트를 실행해 확인합니다.

다음 글 디스패처 서블릿 구조에서는 이 작은 결합과 달리, 실제 Spring DispatcherServlet이 순서가 있는 HandlerAdapter 목록과 인터셉터, 인자와 반환 값 처리, 메시지 컨버터, 예외 리졸버를 어떤 순서로 조정하는지 살펴봅니다.