HandlerAdapter 확장
B79가 찾은 핸들러를 유한 레지스트리에서 정확히 하나의 어댑터에 결합하고, 명시적인 ModelViewResult·ResponseHandled 결과가 MVC 역할 경계·서블릿·JSP 책임 경계로 이어지는 과정을 검증합니다.
B79의 프런트 컨트롤러는 메서드와 컨텍스트 상대 원시 경로를 정확히 매핑해 Found(handler)를 반환합니다. 하지만 핸들러가 PageController인지, 레거시 호출 규칙을 따르는지는 프런트 컨트롤러가 직접 알 필요가 없습니다.
이 문서의 HandlerAdapter는 이미 찾은 핸들러를 어떻게 호출할지만 소유합니다. 요청 매핑·404·405·Allow·Servlet 메서드 디스패치는 B79에 그대로 두고, B80은 유한한 핸들러 집합과 어댑터 사이의 결합만 다룹니다.
HandlerAdapter는 핸들러 계약을 명시적 결과로 바꾼다
frozen binding 조회·adapter handle·ModelViewResult 또는 ResponseHandled·view 여부로 처리 상태가 바뀐다.
FOUND HANDLER → FROZEN ADAPTER → SEALED RESULT
HandlerAdapter는 핸들러 계약을 명시적 결과로 바꾼다
dispatcher는 구체 controller 계약을 해석하지 않습니다. construction 때 고정한 adapter가 handler를 호출하고, view를 계속할지 끝낼지를 sealed 결과로 돌려줍니다.
오른쪽 우회선은 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: ResponseHandled | adapter가 다른 handler 결과를 명시적으로 바꿉니다. |
| view 또는 종료 Page는 registry·test View로 가고 Legacy는 view를 생략합니다. | Page: view 실행 · Legacy: view 없음 · 응답 commit: 증명 아님 | 결과 타입이 다음 실행 경계를 정합니다. |
HandlerAdapter는 핸들러 계약을 명시적 결과로 바꾼다
frozen binding 조회·adapter handle·ModelViewResult 또는 ResponseHandled·view 여부로 처리 상태가 바뀐다.
FOUND HANDLER → FROZEN ADAPTER → SEALED RESULT
HandlerAdapter는 핸들러 계약을 명시적 결과로 바꾼다
dispatcher는 구체 controller 계약을 해석하지 않습니다. construction 때 고정한 adapter가 handler를 호출하고, view를 계속할지 끝낼지를 sealed 결과로 돌려줍니다.
오른쪽 우회선은 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: ResponseHandled | adapter가 다른 handler 결과를 명시적으로 바꿉니다. |
| view 또는 종료 Page는 registry·test View로 가고 Legacy는 view를 생략합니다. | Page: view 실행 · Legacy: view 없음 · 응답 commit: 증명 아님 | 결과 타입이 다음 실행 경계를 정합니다. |
핸들러 계약과 어댑터 결과
핸들러는 매핑이 선택한 실행 대상입니다. 이 예제에는 서로 다른 계약 두 개가 있습니다.
PageController는 이미 검증된 입력을 받아 B78의 고정 논리 이름post-detail과 타입 있는PostPageModel을 담은ModelView를 반환합니다.LegacyController는 전달받은 관찰 표식 경계에 값을 남기고 뷰 단계로 진행하지 않습니다.
두 계약을 프런트 컨트롤러의 instanceof 분기로 합치지 않습니다. PageControllerAdapter와 LegacyControllerAdapter가 각각 supports와 호출 방법을 함께 소유합니다.
이 교재의 사용자 정의 반환 타입은 nullable 값이 아니라 다음 두 경우로 닫혀 있습니다.
| 사용자 정의 결과 | 다음 단계 | 이 문서가 관찰하는 것 | 이 문서가 주장하지 않는 것 |
|---|---|---|---|
ModelViewResult(ModelView) | MVC 역할 경계 exact registry → 서블릿·JSP 책임 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이 아니라 참조 동일성으로 키를 비교하므로, 등록 객체와 값이 같은 별도 객체를 같은 핸들러로 오인하지 않습니다. 이 선택은 등록 인스턴스 경계에만 한정합니다.
등록 시 정확히 하나를 검증하고 요청 시 같은 어댑터를 호출한다
생성 시 supports 유일성 검사·identity binding 고정 뒤 요청에서는 lookup/handle만 하는 두 시간 경계이다.
CONSTRUCT ONCE → FREEZE IDENTITY → INVOKE ONCE
등록 시 정확히 하나를 검증하고 요청 시 같은 어댑터를 호출한다
지원 관계의 누락과 중복을 첫 요청까지 미루지 않습니다. 유한한 등록 집합을 한 번 검증해 identity binding을 고정하고, 요청에서는 lookup과 단 한 번의 호출만 수행합니다.
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만 하는 두 시간 경계이다.
CONSTRUCT ONCE → FREEZE IDENTITY → INVOKE ONCE
등록 시 정확히 하나를 검증하고 요청 시 같은 어댑터를 호출한다
지원 관계의 누락과 중복을 첫 요청까지 미루지 않습니다. 유한한 등록 집합을 한 번 검증해 identity binding을 고정하고, 요청에서는 lookup과 단 한 번의 호출만 수행합니다.
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회 | 구성 시 선택을 요청 실행이 재사용합니다. |
구성 실패는 순수 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 | 프런트 컨트롤러 | 이 문서 입력은 이미 찾은 핸들러입니다. |
입력 검증, PostPageModel, 고정 ModelView, exact registry | MVC 역할 경계 | B80은 그 계약을 호출 순서로만 연결합니다. |
| JSP·Thymeleaf, forward, 렌더링, 출력 문맥 이스케이프 | 서블릿·JSP 책임 | FixedTestView는 primitive/String 표식만 남깁니다. |
응답 상태·본문·커밋·sendError·HEAD 본문 | 서블릿 응답 출력 | 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 인스턴스를 두 프런트 컨트롤러 경로에 등록해도 어댑터 결합은 하나만 생성되어야 합니다. 반대로 equals가 같은 두 컨트롤러 인스턴스는 서로 다른 등록 대상으로 유지하세요.
검증할 항목은 다음 세 가지입니다.
- 같은 인스턴스가 등록 목록에 반복되어도
supports행렬은 그 인스턴스에 대해 한 번만 계산됩니다. - 값이 같지만 참조가 다른 두 인스턴스는 각각 정확히 하나의 어댑터 결합을 가집니다.
- 실행 시점에는 어떤 경우에도
supports호출 수가 증가하지 않습니다.
독립 확인
페이지 컨트롤러 인스턴스 A와 B는 equals 값이 같지만 참조가 다릅니다. 레거시 컨트롤러 L과 함께 등록 목록을 [A, A, B, L]로 만들고, page·legacy 어댑터를 각각 하나씩 넣습니다. 각 어댑터는 자신의 계약만 지원하며, 두 개를 함께 구현하는 핸들러는 없다고 가정합니다. 조립 시 각 어댑터의 supports 횟수와 총 결합 수는 얼마인가요?
조립이 끝난 뒤 A·B·L을 각각 한 번씩 실행합니다. 호출마다 새 ObservationMarker를 사용하며, 페이지 핸들러는 유효한 ModelView를 반환합니다. 실행 때문에 supports가 몇 번 더 불리고, page·legacy 어댑터와 뷰는 각각 몇 번 호출되나요? A와 값만 같은 미등록 인스턴스 C를 넘기면 새 지원 검색으로 구제되나요?
동일성과 지원 행렬을 나누는 풀이
등록 목록의 항목은 네 개지만 참조가 서로 다른 핸들러는 A·B·L 세 개입니다. 두 번째 A는 seen 검사에서 건너뛰므로 각 어댑터의 supports는 3회, 전체 지원 평가는 6회입니다. A와 B는 값이 같아도 참조가 다르므로 합치지 않습니다. 각 인스턴스가 어댑터 하나에 결합해 총 결합 수는 3입니다.
실행 중에는 supports가 더 불리지 않습니다. page 어댑터는 A와 B에서 2회, legacy 어댑터는 L에서 1회 호출됩니다. 페이지 결과만 뷰를 거치므로 exact registry와 fixed view는 각각 2회이며, 레거시 경로에서는 둘 다 0회입니다. 새로운 marker를 쓰는 조건 덕분에 서로 다른 실행의 단일 기록 제한도 섞이지 않습니다.
C는 등록된 참조가 아니므로 required에서 UnregisteredHandlerException이 발생합니다. adapter handle, controller, registry, view를 호출하거나 supports를 재검사하지 않습니다. 이 결과는 이 예제의 고정 조립 정책이며, 실제 Spring의 ordered-first 선택과는 다릅니다. 횟수는 원 코드를 추적한 예상으로, Java 25·JUnit을 새로 실행한 결과는 아닙니다.
다음 문서에서는 이 작은 사용자 정의 결합과 달리, 실제 Spring DispatcherServlet이 ordered HandlerAdapter 목록, 인터셉터, 인자·반환 값 처리, 메시지 컨버터, 예외 리졸버를 어떤 순서로 조정하는지 살펴봅니다.