- analysis/·source-index·state.json 을 final/document.md 제2부·제3부로 접었다. SSOT 는 하나다 - 파일럿 — commit-ambiguity-as-a-result 를 새 기준으로 재선별. 후보 14 → 글감 5 (PROMOTE 5 · MERGE_INTO 3 · KEEP_IN_SSOT 4 · 보류 2). 기록 5건을 다시 썼고 그림 1개를 techviz 로 만들었다 - 재선별이 잡은 것: 제1부 §6.2·§11.1 이 자기 §13.2 와 어긋나 있었다(레인을 안 돌렸다 vs 돌렸다) — 정정. 이미 답이 나와 있던 Question 을 HEAD 재실행 질문으로 다시 세웠다. Concept 이 인용한 코드가 SSOT 에 없어 뺐다 - candidateScope·sourceRepository 기록. 나머지 43개 주제는 재선별 대기(PENDING 905) Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
14 KiB
kind, slug, title, topic, project, status, sourceRevision, evidenceCapturedOn, rootTreeNode, body, assets, evidence, source
| kind | slug | title | topic | project | status | sourceRevision | evidenceCapturedOn | rootTreeNode | body | assets | evidence | source | |||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| CASE | a14-f017-springmvcrouteinventorycollector | 라우트 게이트가 실물을 읽어 오는 한 조각만 아무도 만들지 않는다 | web-inbound-and-http-surface | clean-architecture-backend-template | 게시 전 | 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 | 2026-09-03 | case:a14-f017-springmvcrouteinventorycollector | case-a14-f017-springmvcrouteinventorycollector.body.md |
|
|
|
라우트 게이트가 실물을 읽어 오는 한 조각만 아무도 만들지 않는다
admin/route 패키지가 라우트 목록을 만들고 규칙을 걸고 어긋나면 예외를 던지는 네 조각으로 되어 있다. 그중 셋은 서로를 부르고 시험 하나가 그것들을 돌린다. 실제 디스패처 매핑에서 목록을 읽어 오는 수집기만 만드는 곳이 없다.
관계
- 운영 런북이 시작 검증기의 판정을 근거로 삼는데 그 검증기를 부르는 곳이 없다
같은
admin패키지의 이웃이고 원문도 §44.1·§44.2 로 나란히 적었다. 저쪽은 검증기가 시작 훅에 걸리지 않았고 여기는 수집기가 생성되지 않는다. - 소비자가 없는 fixture 셋 둘 다 이름 검색으로 드러났지만 결과가 다르다. 저쪽은 만들어진 것을 꺼내 쓰는 곳이 없고, 여기는 만드는 곳 자체가 없다.
- @Bean이 있다는 것은 조립 증거가 아니다 거기서는 등록된 빈이 조립을 뜻하지 않는다고 적었다. 여기는 그보다 앞이다. 클래스가 있을 뿐 빈으로도 시험으로도 만들어지지 않는다.
문제
라우트 목록이 릴리스 게이트 역할을 하도록 만들어져 있다. 그 목록이 실제 배포의 라우트로 채워지는지 확인했다.
결론
채워지지 않는다.
패키지의 네 파일 중 셋에는 자기 파일 밖에서 이름을 쓰는 파일이 있다. 수집기 138 줄만 main 도 test 도 0 이다. 다만 그 main 쪽 참조가 서로와 이 수집기뿐이고, 넷 다 스프링 애너테이션이 한 줄도 없어 스캔이 집어 갈 것도 없다. 프로덕션에서 실행되는 자리는 넷 다 없다.
이름이 나오는 곳은 자기 클래스 선언과 생성자, 그리고 2026-08-13 실행 계획 문서 두 줄이다. 그 계획은 같은 자리에 수집기를 둘 적었는데 하나만 만들어졌다.
인벤토리를 새로 만드는 main 파일도, 그 인벤토리에서 예외가 나가는 main 경로도 하나씩뿐이고 둘 다 이 수집기에서 시작한다. 수집기가 불리지 않으므로 목록이 만들어지지 않고, 목록이 없으니 게이트가 볼 것도 없다.
시험은 목록을 손으로 채운다. WebRouteInventoryTest:123 의 도우미가 WebRouteContract 를 직접 만들어 넣고, 핸들러 매핑은 그 파일에 등장하지 않는다. 규칙은 검증되지만 배포가 실제로 내보내는 라우트에 대해서는 아무 말도 하지 않는다.
수집기 자체는 고장 나 있지 않다. 출하 기본 접두를 건 컨텍스트에 물려 보니 게이트가 요구하는 목록을 만들어 내고 미등록 라우트에서 예외가 나온다.
다만 버전은 읽어 내지 못한다. 원인은 경로 접두다. versionOf 는 패턴 첫머리만 보는데 접두가 모든 패턴 앞에 붙으므로, 출하 기본값 /v1 로 뜨는 레인에서는 어느 모양도 걸리지 않는다. /api 로 뜨는 레인에서는 걸릴 모양이 생기지만 그 경로를 선언한 컨트롤러 다섯이 꺼진 게이트 뒤에 있다. 원문에는 없는 대목이고, 수집기가 불리지 않으므로 지금은 드러날 자리도 없다.
원문은 "저장소 전체에서 이 타입 이름이 등장하는 곳은 자기 파일의 클래스 선언과 생성자 두 줄뿐이다"라고 적었다. 저장소 전체로는 틀렸다. git 추적 파일을 다 찾으면 계획 문서 두 줄이 더 있어 넷이다. 두 줄이 맞는 것은 src/ 로 좁혔을 때뿐인데 원문은 그 범위를 적지 않았다. 사례의 무게도 참조 계수보다는 그 138 줄이 게이트의 유일한 입구라는 데 있다.
판정은 P3 이고 원문과 같다. 게이트가 지금 어떤 배포도 막지 않으므로 동작이 달라지지 않는다.
검증 환경
OpenJDK : 21.0.12 Spring Boot : 4.0.8 확인 방식 : 패키지 네 파일의 main·test 참조 계수, git 추적 파일 전체 이름 검색, 계획 문서 대조, 실제 RequestMappingHandlerMapping 에 수집기를 물려 수집과 게이트 실행 소스 수정 : x
재현 조건
원문 근거는 분석 문서의 #L1494 절이다.
- admin/route 네 파일의 줄 수와, 각각을 자기 파일 밖에서 부르는 main·test 자리 수를 센다.
- 수집기 이름을 git 추적 파일 전체에서 찾는다.
- 2026-08-13 실행 계획이 이 자리에 적은 수집기 목록과 소스에 있는 것을 맞춰 본다.
- main 에서 WebRouteInventory 를 새로 만드는 파일과 예외를 던지는 파일을 찾는다.
- requireRegisteredOperations 가 나오는 자리를 main 과 test 로 갈라 센다.
- 시험이 목록을 무엇으로 채우는지 읽고, 그 파일이 핸들러 매핑을 쓰는지 본다.
- 맨 경로와 /api/v1 과 /v1 로 각각 시작하는 컨트롤러 셋을 올리고, 경로 접두만 /v1·""·/api 로 바꿔 가며 컨텍스트를 세 번 띄운다. 각각 핸들러 매핑을 꺼내 수집기에 넘기고, 기본 버전은 경로에 없는 값을 준다.
- 나온 목록을 카탈로그에 두 번 댄다. 한 번은 연산 하나만 등록해 두고, 한 번은 나머지도 등록해 둔다.
본문
수집기 자바독이 자기 역할을 적어 두었다. 애너테이션이 아니라 디스패처가 실제로 참조할 핸들러 매핑에서 읽는다는 것이고, 게이트는 배포가 내보내는 쪽을 봐야 하기 때문이다.
네 조각 중 셋은 서로를 부르고 하나는 아무도 부르지 않는다
:::evidence key="a14-f017-springmvcrouteinventorycollector" alt="저장소 루트에서 돌린 정적 검색 출력 91줄. admin/route 네 파일의 줄 수와 자기 파일 밖에서 그 이름을 쓰는 main·test 파일 수가 표로 나오고 수집기만 양쪽이 0 이며, 그 참조 파일들이 어디에 있는지 목록이 이어진다. 이어서 수집기 자바독의 역할 서술, 그 이름이 git 추적 파일 전체에 나오는 네 줄, 2026-08-13 계획 문서가 적은 수집기 둘과 소스에 실제로 있는 하나, main 에서 인벤토리를 만드는 파일과 예외를 던지는 파일, requireRegisteredOperations 가 나오는 main 한 곳과 test 두 곳, 네 클래스의 스프링 애너테이션 줄 수, versionOf 가 찾는 모양을 선언한 main 두 자리와 그것을 가두는 조건 애너테이션과 그 속성이 저장소에 나오는 두 자리, src 안에서 경로 접두를 정하는 자리 전부와 배포 레인 셋이 고르는 프로파일, 접두가 /api 일 때 정규식에 걸릴 수 있는 컨트롤러 다섯의 이름과 그것을 가두는 속성이 web main 아홉 파일과 저장소 전체 스물아홉 파일에서 참조된다는 수와 그 속성의 값 셋, 시험이 계약을 손으로 만드는 도우미와 그 파일이 핸들러 매핑을 쓰지 않는다는 계수가 보인다." caption="admin/route 네 파일의 참조 파일 수와 계획 문서·경로 접두·게이트 대조 — 91줄 · exit 0" zoom="true"
:::
WebRouteContract 는 main 둘, 나머지 둘은 main 하나씩이고, 셋 다 test 하나씩이다. 수집기 행만 비어 있다.
다만 그 참조 파일이 전부 admin/route 안이다. 패키지 밖에서 이 네 타입을 쓰는 파일은 main 에도 test 에도 없다. 셋이 살아 있다는 것은 서로를 부른다는 뜻이지 누가 쓴다는 뜻이 아니다.
계획 문서에는 수집기가 둘 적혀 있었다. 서블릿 쪽과 리액티브 쪽이다. 리액티브 쪽은 소스에 파일이 없다.
게이트로 가는 길이 여기 하나뿐이다
// SpringMvcRouteInventoryCollector.java:46-50
public WebRouteInventory collect(
RequestMappingHandlerMapping mapping, ApiMajorVersion defaultVersion) {
...
WebRouteInventory inventory = new WebRouteInventory();
main 에서 new WebRouteInventory() 를 쓰는 파일은 이것뿐이다. 그리고 그 인벤토리 클래스가 그 예외를 던지는 유일한 main 파일이다.
// WebRouteInventory.java:50-64
public void requireRegisteredOperations(WebOperationCatalog catalog) {
...
throw new RouteInventoryMismatchException(
"routes serve unregistered operations, so they have no budget, authorization or"
+ " idempotency policy: "
requireRegisteredOperations 가 나오는 자리는 이 선언과 시험 둘이다. 프로덕션에서 부르는 곳은 없다.
시험은 매핑을 보지 않는다
// WebRouteInventoryTest.java:123-127
private static WebRouteContract route(String operation, String method, String path, int version) {
return new WebRouteContract(
new WebRouteId(method + " " + path),
new WebOperationName(operation.length() >= 3 ? operation : "op." + operation),
new ApiMajorVersion(version),
계약을 손으로 만들어 넣는다. 그 파일에 RequestMappingHandlerMapping 은 한 번도 나오지 않는다. 단언은 통과하지만 그 통과가 말하는 대상은 시험이 지어낸 라우트다.
물려서 돌려 보면 동작한다
:::evidence key="a14-f017-springmvcrouteinventorycollector-run" alt="JVM 프로브 출력 28줄. 맨 첫 줄에 OpenJDK 판이 찍히고, 컨트롤러 셋의 성격과 넘긴 기본 버전을 밝힌 두 줄이 이어진다. 그다음 접두 세 값으로 띄운 세 구성이 차례로 나오는데, 각각 매핑 수와 라우트 셋의 경로·연산 이름·버전이 표로 보이고 아래에 한 줄 설명이 붙는다. 출하 기본값에서는 셋 다 기본 버전이고, 접두를 비우면 /api/v1 로 선언한 것이, 접두가 /api 이면 /v1 로 선언한 것이 v1 으로 읽힌다. 끝으로 출하 접두 목록을 카탈로그에 댔을 때 미등록 라우트를 지목하며 던진 예외와 다 등록한 뒤 통과한 결과가 보인다." caption="접두 세 값으로 수집과 게이트를 돌린 결과 — 28줄 · exit 0" zoom="true" :::
컨트롤러 셋을 올렸다. HealthcheckController 처럼 맨 경로를 적은 것, OperationHttpController:43 처럼 /api/v1 로 시작하는 것, FileDownloadController:77 처럼 /v1 로 시작하는 것이다. 접두만 갈아 끼우고 나머지는 세 구성이 같다. 수집기가 매핑에서 라우트를 읽어 핸들러 이름으로 연산 이름을 만들고, 카탈로그에 대면 미등록 라우트를 지목하며 거부한다.
버전만 예외다. PresentationWebConfig:23 이 접두를 모든 컨트롤러 앞에 붙이므로, 접두가 비어 있지 않으면 등록된 패턴은 접두로 시작한다. versionOf 는 패턴 첫머리만 보기 때문에 출하 기본값 /v1 에서는 어느 라우트도 걸리지 않는다.
접두는 이 저장소에서 세 값을 갖는다. 출하 기본값 /v1, app-bootstrap 시험 리소스의 "", 그리고 application-local.yml:164 와 src/.env:112 가 고정하는 /api 다. src/.env:8 이 그 프로파일을 켠다. 프로브를 그 셋으로 돌렸다.
"" 에서는 /api/v1 로 선언한 컨트롤러가 그대로 남아 읽힌다. /api 에서는 /v1 로 선언한 컨트롤러가 /api/v1/... 이 되어 읽힌다. 출하 기본값 /v1 에서만 셋 다 기본 버전으로 떨어진다.
배포 레인이 고르는 접두는 둘이다. 컴포즈의 dev·prod 레인은 PRESENTATION_API_BASE_PATH 를 주지 않으므로 출하 기본값 /v1 로 뜨고, src/.env 가 고르는 로컬 레인은 /api 다. "" 는 app-bootstrap 시험 리소스에만 있다.
/v1 로 뜨는 레인에서는 게이트가 상관없다. 접두가 앞에 붙어 어느 모양도 /api/v 로 시작하지 못하므로, 속성을 켜도 버전은 그대로 기본값이다. 프로브의 첫 구성이 그것을 보인다.
/api 레인에서만 게이트가 실제로 걸린다. 거기서 읽힐 수 있는 것은 /v1 로 선언한 파일서버 컨트롤러 다섯인데 app.fileserver-platform.enabled 가 application.yml:854 기본값으로도 src/.env:161 로도 거짓이다. /api/v1 로 선언한 둘은 접두가 붙어 /api/api/v1/... 이 되므로 자기 게이트와 무관하게 읽히지 않는다.
프로덕션이 이 자리에서 하지 않는 일을 프로브가 대신 한 것뿐이다. 수집도 판정도 돈다. 버전만 이 배포의 경로 모양에서 읽히지 않는다.
확인하지 못한 것
이 클래스가 과거에 조립되었는지 이력에서 확인하지 않았다. 확인한 것은 고정 리비전에서 부르는 곳이 없다는 것까지다.
프로브가 띄운 것은 컨트롤러 셋짜리 최소 컨텍스트이고, 그 셋에는 실제 컨트롤러들이 달고 있는 조건 애너테이션이 없다. 출하 조립의 전체 라우트를 수집해 본 것이 아니므로, 실제 배포에서 이 게이트가 몇 건을 걸러 낼지는 모른다. 보인 것은 수집과 판정이 실제 매핑 위에서 동작한다는 것까지다.
수집기의 deprecationFor 는 프로브가 부르지 않았다. 폐기 정책을 빈 것으로 넘겼으므로 폐기 표시가 붙은 라우트는 이 실행에 없다.