# 99 · 교차 스코프 분석 — 사이클 2 > 상태: COMPLETE > 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916` > 분모: 등록 리프 **62** (COMPLETE 61 · EXCLUDED 1 `sample-portfolio`) > 근거: 각 리프 SSOT 문서 61건 · `evidence/raw/` 370건 > 사이클 1의 같은 경로에 초안이 있었고(364줄, 상태 `NOT_STARTED`), 이 문서가 그것을 대체한다. > 초안은 보존하지 않았다 — 덮어쓰기 전에 읽지 않은 것은 이 사이클의 절차 실수다. > 따라서 **사이클 1의 절 번호와 줄 번호는 이 문서에 적용되지 않는다.** `root-tree.md`가 옛 번호로 > 걸어 둔 앵커는 이 문서의 절로 다시 걸었다(§3.1 · §3.4 · §5 · §6 · §2·§4). > 옛 초안에만 있던 서술이 있었다면 그것은 복구되지 않았다. 다만 그 초안이 인용하던 1차 근거는 > 전부 리프 SSOT와 `final/document.md`에 있으며, 이 문서는 그것들을 다시 읽고 작성했다. > **사이클 2 후반 갱신.** 이 문서의 §0~§6 은 18개 리프 재검증까지를 반영해 쓰였다. 그 뒤 같은 사이클에서 > 23개 리프(messaging 5 · grpc 18)의 production 구현을 통독했고, 그 결과를 §1.2 · §3.7 · §4 의 > 6~9항 · §6 에 더했다. > > **그 통독 자체를 다시 했다 — 이 문서의 §0 · §1.2 · §3.5 · §6 숫자가 그래서 바뀌었다.** 앞선 판에서 > 23개 리프를 `FULL_READ_DONE` 으로 표시하고 "395파일 전수 통독" 이라고 적었으나, 실제로 읽은 것은 리프마다 > 일부였다. 그 표시가 사실이 아니었으므로 23개 리프를 파일 단위로 다시 세고 처음부터 다시 읽었다. 정직한 > 분모는 **main 357파일 / 29,542줄 · test 121파일 / 19,756줄**이고, > 각 리프 SSOT 의 Coverage ledger 를 그 숫자로 다시 썼다. §1.2 의 결론 하나가 그 과정에서 뒤집혔다 — > "통독이 만든 새 finding 에 P1 은 없다" 는 서술이 더 이상 참이 아니다. > 지위: 이 문서는 **2차 증거**다. 리프의 사실은 리프 SSOT가 소유하고, 여기서는 리프 경계를 넘을 때만 성립하는 것을 다룬다. --- ## 0. 이 문서가 서 있는 분모 | 항목 | 수 | |---|---:| | 등록 리프 | 62 (COMPLETE 61 · EXCLUDED 1) | | `analysisFile`을 공유하는 리프 | **0** — SSOT 게이트 통과 | | 리프 SSOT가 확정한 finding | **462** (P1 30 · P2 147 · P3 285) — 아래 주 참조 | | finding 0건인 리프 | 8 | | evidence 파일 | 370 | | 끊긴 evidence 참조 | **0** | | 사이클 2 전수 통독 리프 | **23** (messaging 5 · grpc 18) | | 그 23개 리프의 main production | **357파일 / 29,542줄** | | 그 23개 문서의 finding | **100** (P1 2 · P2 31 · P3 67) | | 그중 사이클 1 가족 문서에서 옮겨온 것 | 8 | | 통독에서 처음 나온 것 | **92** (P1 2 · P2 25 · P3 65) | **두 숫자의 근거가 다르다.** 23개 리프의 100건은 이번에 직접 센 것이다 — 23개 문서의 §17 에서 `### 17.n P —` 형태를 파싱했고, 23개 문서 전부를 처음부터 끝까지 읽은 뒤이므로 표기 누락이 없다. 앞선 판의 60건은 통독이 실제로는 부분 통독이던 시점의 수치다. 전체 462건은 **직접 다시 센 것이 아니라 델타로 조정한 값**이다. 앞선 판의 422건에서 23개 리프 몫 60을 빼고 100을 더했다(422 − 60 + 100 = 462). 나머지 38개 문서는 이번 재작업의 대상이 아니었고, 그 문서들이 쓰는 「모듈 findings 표」 형식은 §17 형식과 파싱 규칙이 달라 두 형식을 함께 세는 스크립트를 이번에 다시 돌리지 않았다. 그러므로 **462는 델타가 정확하다는 가정 위에 있고, 422 자체의 재측정은 아니다.** 직접 재측정이 필요하면 두 형식을 모두 파싱하는 원래 스크립트를 61개 문서에 다시 돌려야 한다. P1·P2·P3 내역도 같은 방식으로 조정했다 — P1 29−1+2=30, P2 135−19+31=147, P3 258−40+67=285. --- ## 1. 사이클 2가 실제로 바꾼 것 사이클 2의 재검증 대상은 사이클 1이 남긴 18개 리프 문서였다. 결과는 다음과 같다. **소스는 움직이지 않았다.** 18개 문서가 모두 기준으로 삼은 `a24ece9c`와 현재 HEAD `21234e38` 사이는 커밋 하나이고, 그 커밋은 `src/grpc/**`·`src/grpc-advanced/**`와 공통 파일 둘만 건드렸다. 18개 리프 경로의 변경 파일 수는 전부 **0**이다. 공통 파일 둘도 이 18개에 영향이 없다 — `src/build.gradle`의 변경은 plain-JUnit 테스트 클래스패스 조건에 `:grpc:`·`:grpc-advanced:`를 더한 것뿐이고, `modules.json`은 197줄 순수 추가로 18개 리프 id가 diff에 한 번도 등장하지 않는다 (`EVD-333`). **그래서 재검증의 실질은 재작성이 아니라 재확인이었다.** 18개 문서의 lane을 HEAD에서 다시 돌렸고 (`EVD-334`), 실패 5건이 나왔다. 그 5건에 대한 판정은 이렇다. | 리프 | 실패 | 사이클 1의 판정 | 사이클 2의 재측정 | |---|---|---|---| | `adapter-outbound-fileserver` | 1 | 환경(로케일) | **확인** — `LANG=C.utf8`로 통과, `sun.jnu.encoding` ANSI→UTF-8 | | `app-bootstrap` | 1 | 환경(`jq` 부재) + 가드 비대칭 P3 | **확인**, 그리고 남은 공백을 메움 — 15개 레인 계약을 독립 경로로 검증 | | `adapter-outbound-httpclient` | 3 | **P1 제품 결함** | **철회** — 픽스처의 듀얼스택 호스트명이 원인 | 세 번째가 이 사이클의 유일한 판정 번복이다. 사이클 1은 `ApacheFailureClassifier`의 분기 순서를 읽고 "Apache가 TLS 실패를 `HttpHostConnectException`으로 감싸므로 CONNECT 분기가 TLS 분기를 가린다"고 결론했다. 예외 사슬을 실제로 출력해 보면 그 사슬에 `SSLHandshakeException`이 **없다**. 원인은 `MockHttpServer.uri()`가 호스트명 `localhost`를 돌려주는데 이 컨테이너의 `localhost`가 `127.0.0.1`과 `::1` 양쪽으로 풀리고 `MockWebServer`는 IPv4에만 바인딩한다는 것이었다. Apache의 다중 주소 루프가 첫 주소(127.0.0.1)의 진짜 TLS 실패를 삼키고 마지막 주소(::1)의 연결 거부만 승격시킨다. 접속 호스트를 `127.0.0.1`로 바꾸면 세 건 모두 `TLS_PERMANENT`가 된다 (`EVD-332`). **17/18은 확인, 1/18은 번복.** 이 비율 자체가 사이클 1 문서의 신뢰도에 대한 측정치다. --- ### 1.2 그 뒤에 이어진 전수 통독 — 23개 리프 18개 재검증과 별개로, 사이클 2 는 `ssotReview: FULL_READ_REQUIRED` 로 열려 있던 23개 리프를 닫았다. 그 리프들의 사이클 1 SSOT 는 production 구현을 `STRUCTURAL_ONLY` 로 판정하고 파일 이름·LOC·build.gradle 주석으로 서술한 상태였다. **통독은 두 번에 걸쳐 이뤄졌고, 첫 번째는 통독이 아니었다.** 첫 판에서 23개 리프를 `FULL_READ_DONE` 으로 표시하고 각 SSOT 에 "production N파일 축자 통독 완료" 를 적었으나, 리프마다 읽은 것은 일부였다. 그 상태로는 Coverage ledger 가 사실이 아니므로 23개 리프를 다시 세고 파일 단위로 다시 읽었다. | 대상 | 리프 | main 파일 | main 줄 | test 파일 | test 줄 | |---|---:|---:|---:|---:|---:| | grpc 계열 | 18 | 260 | 18,726 | 55 | 9,383 | | messaging 계열 | 5 | 97 | 10,816 | 66 | 10,373 | | **합계** | **23** | **357** | **29,542** | **121** | **19,756** | 각 SSOT 가 주장을 거는 test 파일도 전부 읽었다. 통독 후 `STRUCTURAL_ONLY` 잔여는 0 이고, 23개 SSOT 의 Coverage ledger 는 위 숫자로 다시 썼다. **결과의 성격.** 통독이 만든 것은 대부분 새로운 사고가 아니라 **이미 알려진 패턴의 정확한 위치**다. 사이클 1 의 가족 문서(19·20)는 두 가족을 각각 하나의 문서로 다루면서 "블록 전체가 미배선" 이라는 층위에서 멈췄고, 통독은 그 블록 안에서 배선되더라도 성립하지 않을 것들을 찾았다. **다만 P1 이 둘 나왔다.** 앞선 판은 "새 finding 에 P1 은 0" 이라고 적었고 그것은 부분 통독의 결과였다. 둘 다 messaging 계열이고, 둘 다 "선언과 실제가 반대인데 관측은 정상" 이라는 §3.2 의 형태다. 1. **운영 프로파일에 TLS 와 브로커 인증을 요구해 놓고, 그 둘이 없는 생산자를 만든다.** `KafkaProfileValidator` 는 운영 프로파일이 전송 보안 없이 뜨는 것을 거부하고 그 거부를 테스트가 지킨다 (`aProductionKafkaBrokerWithoutTransportSecurityFailsStartup`). 그런데 실제로 조립되는 `KafkaProducer` 설정에는 `security.protocol` 이 없다 — Kafka 기본값 `PLAINTEXT` 다. 그 값을 만드는 `KafkaSecurityConfigurer` 는 저장소 전역에서 production 호출자가 0 이다. (`messaging-spring-boot-starter` §17.1) 2. **지원 문서가 `deduplicatedPublish` 를 지원으로 적고 코드는 거짓이며, 그 차이가 정확히 코드가 경고한 피해다.** (`messaging-kafka` §17.1) 이 둘이 P1 인 이유는 배선 여부와 무관하게 성립하기 때문이다. 나머지 23개 리프의 finding 대부분은 "조립되면 성립하는 결함" 이지만, 이 둘은 messaging 계열이 실제로 배선되는 경로 위에 있다. 그 아래 층위에서 가장 무거운 예 셋: 1. **`JpaGrpcOperationLedger.claim` 의 insert-first 주장이 Spring Data 의 `save` 계약과 어긋난다.** 엔티티의 식별자가 배정값이라 `save` 가 `merge` 로 가고, 파생 기본 키가 유니크 제약과 같은 행을 가리키므로 두 번째 청구가 유니크 위반을 일으키지 않고 **커밋된 결과를 덮어쓴다.** 테스트 이중의 `save` 는 INSERT 를 흉내 내 그 차이를 가린다. (`grpc-operation-ledger-jpa` §17.1) 2. **`GrpcCredentialRotationManager.completeDrain()` 이 진행 중인 회전을 되돌린다.** 읽기와 쓰기 사이에 회전이 일어나면 방금 교체된 자격증명이 되살아난다. (`grpc-policy` §17.2) 3. **`GrpcPlatformStartupValidator` 가 시작 시 실행되지 않는다.** 그 검증기가 유일한 소비자인 설정 키 넷 (`transport`·`tls-enabled`·`trust-all-certificates`·`operation-ledger-enabled`)이 아무것도 게이트하지 않는다. (`grpc-spring-boot-starter` §17.1) 셋 다 "조립되면 성립하는 결함" 이고, 블록 수준 서술로는 보이지 않는 층위다. --- ## 2. 배포 지도 — 등록된 것과 배포되는 것의 거리 이 저장소에는 런타임 컴포지션이 둘 있다(`runtime_compositions`: `app-bootstrap`, `sample-portfolio`). 62개 리프의 소속을 그대로 세면 다음과 같다. | 소속 | 리프 수 | |---|---:| | `app-bootstrap`에 속함 | 33 | | `sample-portfolio`에만 속함 | 2 (`adapter-outbound-objectstorage`, `sample-portfolio`) | | **어느 컴포지션에도 속하지 않음** | **27** | 속하지 않는 27개의 내역: - **grpc 블록 18개 전부** — `grpc-*` 9개와 `grpc-advanced-*` 9개. 이는 결함이 아니라 명시된 상태다. `src/grpc/CLAUDE.md:74-98`이 블록의 build-only 상태를 정확히 적고 있고, 사이클 2의 측정은 그 서술의 확인이다 (`EVD-325`). - **messaging 7개** — `schema-avro`, `schema-protobuf`, `kafka-share-experimental`, `pulsar-experimental`, `nats-experimental`, `spring-cloud-stream-bridge`, `testkit`. 실험 어댑터와 선택적 스키마·테스트 지원이라는 성격상 예상되는 목록이다. - **`adapter-inbound-grpc`와 `adapter-inbound-websocket`** — 이 둘은 위 두 범주 어디에도 속하지 않는 일반 인바운드 어댑터인데 어떤 배포에도 들어가지 않는다. 여기서 리프 경계를 넘어야만 보이는 사실이 하나 있다. `adapter-inbound-websocket` 문서는 이 리프의 내부 구조를 충실히 기술하지만, "이 리프가 어떤 배포에도 없다"는 것은 리프 안에서는 보이지 않는다 — `modules.json`의 다른 항목과 대조해야 나온다. 같은 형태로 `adapter-outbound-objectstorage`는 **샘플에만** 있다. 출하 애플리케이션에는 오브젝트 스토리지 어댑터가 없다. --- ## 3. 저장소 전체를 관통하는 패턴 422건을 finding 제목 텍스트에 대해 기계 분류했다. 제목만 읽는 분류이므로 아래 수치는 **하한**이고 census가 아니다 — 268건은 제목이 너무 짧아 어느 유형에도 걸리지 않았다. | 유형 | 건수 | P1 | P2 | P3 | 나타난 리프 | |---|---:|---:|---:|---:|---:| | A. 만들어졌지만 조립되지 않음 | 57 | 8 | 27 | 22 | **23** | | F. 문서·주석·이름이 코드와 다름 | 57 | 1 | 20 | 36 | 18 | | E. 동시성·경합·순서 | 26 | 5 | 8 | 13 | 12 | | B. 선언은 통과하는데 강제하는 주체가 없음 | 14 | 4 | 6 | 4 | 9 | | D. 같은 문제에 메커니즘이 둘 이상 | 12 | 0 | 2 | 10 | 9 | | C. 테스트가 픽스처를 검증함 / 검증 공백 | 11 | 4 | 2 | 5 | 6 | ### 3.1 A — 만들어졌지만 조립되지 않는다 (23개 리프) 이 저장소에서 압도적으로 반복되는 형태다. 세 층위로 나타난다. **층위 1 — 컴포지션 루트가 패키지를 제외한다.** `adapter-inbound-web`의 P1 여섯 건은 전부 하나의 원인으로 수렴한다. 컴포지션 루트가 `mvc.error`·`webflux.error`·`mvc.budget`·`mvc.operation`· `webflux.operation` 다섯 패키지를 컴포넌트 스캔에서 제외하고, 그 결과 RFC 9457 계약 23개 파일, 용량 보호 계층 41개, 멱등 실행 계층 38개가 출하 애플리케이션에 등록되지 않는다. 그 문서 자신이 여섯 번째 finding에서 단일 원인을 지목한다 — "다섯 레인·세 런타임 패리티가 검증하는 것은 픽스처의 조립이고, '플랫폼이 능력을 설치하는가'를 묻는 레인이 없다." **층위 2 — 클래스는 있는데 생성자가 없다.** `messaging-inbox-jdbc-postgresql`의 bounded purge는 구현돼 있고 호출되지 않는다. `messaging-outbox-jdbc-postgresql`은 그 반대 방향으로 같은 형태다 — 무제한 DELETE가 호출되고, 그것을 막는 bounded 오버로드가 호출되지 않는다. `messaging-spring-boot-starter`의 종료 수명주기는 아무도 증가시키지 않는 카운터가 0이 되기를 기다린다. `adapter-outbound-httpclient`의 `POOL_ROUTE_EXCEEDS_TOTAL` 위반 코드는 생성자에 가려 발화할 수 없다. **층위 3 — 블록 전체.** grpc 18개 리프. 이 경우만은 저장소가 그 상태를 문서로 인정하고 있다. 이 세 층위의 차이가 중요하다. 층위 3은 **선언된 미완성**이고, 층위 1·2는 **선언되지 않은 미완성**이다. 같은 저장소가 전자를 다루는 좋은 선례를 갖고 있다 — `MessagingProviderSelection.BROKERS_WITHOUT_A_TRANSPORT`는 rabbit의 미완성에 이름을 붙이고 기동에서 거절하며 "An entry leaves this map on the day its transport does exist"라고 적는다. 층위 1·2에는 그런 이름이 없다. ### 3.2 B — 검증기는 통과시키고, 그 값을 읽는 코드는 없다 (9개 리프) A의 특수형이지만 결과가 다르다. A는 기능이 없는 것이고, B는 **없는 기능이 있다고 보고되는 것**이다. 가장 선명한 사례는 `adapter-outbound-persistence-mongo`다. `MongoClientSettingsFactory`가 저장소 전체에서 호출되지 않아 프로파일의 `tlsRequired`·타임아웃·풀 상한·Stable API·UUID 표현이 드라이버에 도달하지 않는다. 검증기는 "TLS 필수" 선언을 통과시키고, 연결은 평문일 수 있다. 선언과 실제가 반대인데 관측은 정상이다. 같은 형태가 `messaging-pulsar-experimental`의 `claimCheckThresholdBytes`에 있다 — 필드는 있고 그것을 읽는 액터가 없다. `adapter-outbound-persistence-jpa`의 바이트 쿼터도 같다 — persistent `reserved + committed` 값을 실제 admission에서 읽거나 상한과 비교하는 경로가 없다. **사이클 2 가 더한 것 — 능력 선언이 프로파일에서 파생되지 않는다 (3개 어댑터).** 세 실험/출하 브로커 어댑터가 모두 `MessagingCapabilities` 를 **상수** 로 둔다. 그리고 그 상수가 답하는 것과 그 능력이 실제로 성립하는 조건이 갈린다. | 어댑터 | 상수가 답하는 것 | 실제 조건 | |---|---|---| | `messaging-nats-experimental` | `deduplicatedPublish = true` | 프로파일에 중복 제거 창이 있을 때만 `Nats-Msg-Id` 를 보낸다 | | `messaging-kafka` | `brokerTransaction = true` | 트랜잭션 식별자 접두·멱등 생산자·`acks=all`·수동 커밋이 모두 필요하고, 그것을 검사하는 검증기는 주입되지 않는다 | | `messaging-pulsar-experimental` | 전송과 검증기가 `orderedStream` 에 **서로 다른** 값 | Key_Shared 의 순서 단위는 키다 | 첫째가 특히 무겁다. `deduplicatedPublish` 는 능력 열둘 중 부재가 예외를 만드는 유일한 플래그이므로 (`DefaultMessagePublisher:250`), 창 없는 목적지가 그 가드를 통과한다. 이 셋은 B 의 거울상이다. B 는 선언을 통과시키고 읽는 코드가 없는 것이고, 이것은 **읽히는 값이 조건과 무관하게 참** 인 것이다. ### 3.3 C — 레인이 검증하는 것이 픽스처의 조립일 때 (6개 리프) `adapter-inbound-web`의 다섯 레인, `grpc` 경계 규칙이 실제 소스를 보지 않는 것(`EVD-328`), `messaging-testkit`의 인증 매니페스트 드리프트(`EVD-300`)가 같은 계열이다. 여기에 사이클 2가 사례 하나를 보탠다 — `app-bootstrap`의 `everyLaneMatchesItsContract`는 `docker compose` 부재는 skip으로 막고 `jq` 부재는 실패로 낸다. 스크립트는 전제 결손을 `exit 78` (sysexits.h의 `EX_CONFIG`)로, 계약 위반을 `exit 1`로 **구분해서** 알리는데 테스트가 그 구분을 버린다. 결과적으로 아무것도 검사되지 않은 상태가 "레인이 계약과 다르다"로 보고된다 (`EVD-334`). ### 3.4 D — 같은 문제에 메커니즘이 둘 (9개 리프) `messaging-admin-runtime`의 두 토폴로지 스택(`EVD-307`), `messaging-kafka`의 세 층위 재시도 중 하나만 도는 것, grpc의 두 규칙 엔진 중 하나만 급여되는 것(`EVD-329`), `adapter-outbound-httpclient`의 재생 가능성 판정이 호출마다 반사로 재계산되는 것. 이 유형이 P3에 몰려 있는 것(12건 중 10건)은 우연이 아니다. 둘 중 하나는 대개 돌고 있어서 증상이 없다. 비용은 **다음에 고치는 사람이 어느 쪽이 정본인지 모른다**는 데서 나온다. ### 3.5 E — 동시성·경합 (12개 리프) P1 다섯 건이 여기 있다. `adapter-outbound-persistence-jpa`의 outbox stale worker가 owner fencing 없이 newer/terminal 상태를 덮어쓰는 것, provider call recorder가 lease-unaware `save()`를 써서 만료된 holder의 stale projection이 새 holder를 덮는 것, `adapter-outbound-persistence-mongo`의 change stream high-water mark가 "본 위치"여서 failover 중 이벤트가 조용히 영구 소실되는 것, `messaging-admin-runtime`의 재개된 리드라이브가 옮기지 못한 메시지를 영구히 건너뛰는 것. 이 넷의 공통 형태는 **실패가 상태로 남지 않는다**는 것이다. 넷 다 로그도 상태 전이도 남기지 않고, `RUNNING` 또는 성공으로 보이는 채로 데이터가 사라지거나 덮인다. **사이클 2 가 더한 것 — 원자 타입을 쓰면서 비교 후 교체를 하지 않는다 (5곳).** 전수 통독이 찾은 가장 일관된 형태다. 다섯 곳이 `AtomicReference`·`AtomicInteger` 를 선택해 놓고 읽고-판단하고-쓰는 세 단계를 원자적으로 묶지 않는다. | 위치 | 형태 | 결과 | |---|---|---| | `GrpcCredentialRotationManager.rotate`·`completeDrain` | `get()` 후 조건 없는 `set()` | 회전이 되돌아가 교체된 자격증명이 되살아난다 | | `GrpcChannelRuntimeRegistry.rotate` | 〃 (같은 파일의 `install` 은 CAS 를 쓴다) | 덮인 대체본이 배수도 회수도 되지 않는다 | | `GrpcChannelRuntime.finishUnaryCall`·`closeStream` | `get() > 0` 후 별도 감소 | 음수가 되면 `quiescent()` 가 영원히 거짓 → 세대가 회수 불가 | | `GrpcAdmissionController.tryAdmit`·`release` | 〃 | 경계 초과, 그리고 경계가 영구히 느슨해짐 | | `GrpcStreamAdmission.tryAdmit`·`release` | 〃 + caller 별 맵이 줄지 않음 | 재접속 폭풍에서 가장 많이 샌다 | 같은 저장소 안에 정본이 둘 있다 — `GrpcRetryBudget.tryConsume` 과 `GrpcHedgingBudget.tryConsume` 이 정확한 비교 후 교체 루프다. 그리고 `GrpcDemandController` 는 같은 형태를 `synchronized` 로 닫는다. 즉 이 저장소는 올바른 형태를 알고 있고, 다섯 곳에서만 쓰지 않았다. **사이클 2 가 더한 것 — 선언되고 주입되지 않는 검증기 (4곳).** `StartupProfileValidation` 의 javadoc 이 이 형태를 이미 이름 붙였다 — "the context published a validator per broker and validated nothing." 그 수정이 messaging 에 적용됐는데, 같은 형태가 네 곳에 남아 있다. | 검증기 | 상태 | |---|---| | `GrpcPlatformStartupValidator` | 호출자 0 (자기 테스트 제외) | | `KafkaTransactionProfileValidator` | 빈으로 발행되고 `StartupProfileValidation` 에 감싸이지 않음 | | `GrpcApplicationBoundaryRules` | 저장소 소스에 적용하는 코드 없음 | | `GrpcRawApiImportRule` | 〃 (테스트가 인라인 문자열만 판정) | **재통독이 다섯 곳을 더 찾았다.** 그리고 그중 셋은 앞의 넷보다 무겁다 — **문서가 그 검증기를 "빌드를 실패시키는 것" 이라고 단언하기 때문이다.** 아무도 부르지 않는 검증기는 공백이고, 부른다고 적힌 채 아무도 부르지 않는 검증기는 오해다. | 검증기 / 게이트 | 상태 | 그렇게 적은 곳 | |---|---|---| | `GrpcProtoContractValidator` | 코드 호출자 0. `*.gradle`·`*.kts`·`*.yml` 어디에도 없음 | `buf.yaml:3-5` 와 `GrpcBufPolicy:8-10` 이 각각 "이것이 이 저장소의 빌드를 실패시킨다" 고 적는다 | | `GrpcBufPolicy` 의 네 수명주기 태스크 | `bufFormatCheck`·`bufLint`·`bufBuild`·`bufBreaking` 이 어떤 빌드 파일에도 없음 | javadoc 이 "a missing stage is a test failure rather than a stage nobody noticed was gone" 라고 적는다. 테스트는 목록을 리터럴·자기 자신과 비교한다 | | `GrpcAdvancedModuleGuard.requireStableStarterIsClean` | 호출자 0 | javadoc 이 "a runtime that was assembled some other way — a fat jar, a shaded artifact, a test harness — is checked too" 라고 적는다 | | `NatsJetStreamProfileValidator` | 코드 호출자 0 · 테스트 0. 흔적은 javadoc `{@link}` 한 줄 | `NatsJetStreamTransport:35` 가 "refuses the combination at startup" 이라고 적는다 | | `PulsarProfileValidator` | 저장소 전체에서 자기 선언 한 줄 말고 아무 데도 없음 | 그 리프 SSOT §4 가 "검증기가 합의를 요구한다" 로 서술했다(이번에 정정) | `GrpcProtoContractValidator` 와 `GrpcBufPolicy` 는 서로를 가리킨다 — `buf.yaml` 은 CLI 가 없으니 자바 검증기가 게이트라고 하고, `GrpcBufPolicy` 는 태스크 이름이 CI 의 계약이고 자바 검증기가 실제 게이트라고 한다. 두 쪽 다 상대가 게이트라고 말하고, 어느 쪽도 실행되지 않는다. **형태의 이름.** 앞의 넷은 "만들어졌지만 조립되지 않음"(§3.1 A)이고, 이 다섯은 **"조립되었다고 적힌 채 조립되지 않음"** 이다. A 와 F(문서가 코드보다 앞섬)가 같은 지점에서 겹치는 자리이고, 이 저장소의 주석 밀도가 높기 때문에 겹칠 때 특히 비싸다 — 읽는 사람이 게이트의 존재를 근거 있게 믿게 된다. ### 3.8 H — 선언만 있고 코드가 닿지 않는 project 의존 (재통독 신설, 6곳) 리프 SSOT 는 자기 build.gradle 을 읽지만, "이 의존이 실제로 쓰이는가" 는 그 리프의 import 를 전수로 봐야 나온다. 재통독이 그것을 리프마다 확인했고 여섯 곳이 나왔다. | 리프 | 선언 | 실제 import | |---|---|---| | `grpc-advanced-edition` | `grpc-core-api` · `grpc-proto-contract` · `grpc-advanced-bootstrap` | **0** — `dev.caskeleton` import 가 한 줄도 없다 | | `grpc-advanced-diagnostics` | 위 셋 중 `grpc-client` 포함 셋 | bootstrap 만 4줄. `grpc-core-api`·`grpc-client` 0 | | `grpc-spring-boot-starter` | `grpc-proto-contract` · `grpc-codegen` · `grpc-operation-ledger-jpa` (implementation) | 셋 다 0 | | `grpc-advanced-streaming` · `-compat` | `grpc-advanced-bootstrap` | 둘 다 0 | | `grpc-testkit` · `grpc-spring-boot-starter` | `grpc-observability` (api) | 둘 다 0 | `api` 로 선언된 것은 그 모듈을 쓰는 쪽까지 전파된다. `grpc-observability` 의 경우 Micrometer 가 두 모듈을 거쳐 전파되는데, 그 두 모듈은 관측 타입을 하나도 쓰지 않는다. **이 유형이 A 와 다른 점.** A 는 코드가 있고 부르는 곳이 없는 것이고, 이것은 **의존 그래프가 코드보다 넓은 것**이다. 결과는 반대 방향으로 나타난다 — A 는 기능이 없는 것으로, 이것은 경계가 실제보다 느슨해 보이는 것으로. `grpc-spring-boot-starter` 는 이 저장소가 "구성 경계" 라고 이름 붙인 리프이므로, 그 리프의 의존이 실제 조립에 필요한 것보다 넓다는 사실은 그 이름이 주장하는 바에 직접 걸린다. 의도를 읽을 수 있는 경우도 있다 — `grpc-advanced-edition` 의 `grpc-proto-contract` 의존은 그 리프의 `compatibility.proto` 가 저쪽 스키마 규칙의 관할이라는 선언으로 읽힌다. 다만 그 관할은 코드로 연결되어 있지 않고, `grpc-proto-contract` 의 커밋 스키마 테스트가 파일 목록을 하드코딩해 이 파일을 판정하지 않는다. 즉 의존 선언이 표현하려던 관계가 실제로는 어느 쪽에도 없다. ### 3.6 F — 문서가 코드보다 앞서 있다 (18개 리프, 57건) 건수로는 A와 동률 1위인데 P1이 하나뿐이다. 대부분 javadoc·README·주석이 이제는 사실이 아닌 것을 말하는 형태다. `adapter-outbound-httpclient`의 `BoundedDataBufferFlux`가 javadoc이 처리한다고 적은 두 경로가 no-op인 것, `adapter-outbound-fileserver`의 README 주장이 여덟 개 port 구현 앞에서 성립하지 않는 것(P2)이 대표적이다. 이 저장소의 주석 밀도는 이례적으로 높고 — `adapter-outbound-httpclient`의 `build.gradle`은 이 저장소에서 가장 긴 근거 주석을 갖는다 — 그 밀도가 자산인 동시에 부채라는 것이 이 유형의 내용이다. 사고를 인용하는 주석은 그 사고를 다시 겪지 않게 하지만, 코드가 바뀔 때 함께 바뀌지 않으면 **틀린 근거를 권위 있게** 전달한다. --- ### 3.7 G — 전송 계열 가정 (사이클 2 신설) `EVD-332` 가 사이클 1 의 P1 을 철회시킨 원인은 픽스처의 듀얼스택 호스트명이었다. 즉 **IPv4 만 가정한 코드가 IPv6 가 있는 환경에서 다르게 동작한다** 는 형태다. 통독이 같은 형태를 하나 더 찾았다. `GrpcDiagnosticsRedactor.maskAddress` 는 IPv4 정규식 하나만 갖고, 맞지 않는 입력을 **그대로 돌려준다.** 그리고 스냅숏 생성자의 검사가 "마스킹 결과가 입력과 같으면 이미 마스킹된 것" 이므로, IPv6 주소·호스트 이름· 유닉스 소켓 경로가 전부 검사를 통과한다. 이 플랫폼이 겨냥하는 배포 형태가 쿠버네티스이고 헤드리스 레코드의 엔드포인트가 파드 DNS 이름이라는 점에서 도달 가능한 형태다. 두 사례의 공통점은 **주소 표현의 다양성** 이 아니라 **판정의 방향** 이다. 둘 다 "모르는 형태" 를 안전한 쪽이 아니라 통과 쪽으로 접었다. --- ## 4. 리프 경계를 넘을 때만 보이는 것 리프 SSOT가 원칙적으로 볼 수 없는 사실을 여기 모은다. 1. **`adapter-inbound-grpc`·`adapter-inbound-websocket`이 어떤 배포에도 없다.** §2. 2. **`adapter-outbound-objectstorage`가 샘플에만 있다.** 출하 애플리케이션에는 없다. §2. 3. **`messaging-admin-api`의 토폴로지 BLOCKING 보장을 배선하려면 인스펙터가 필요한데, `messaging-kafka`에는 줄 것이 없다.** 두 리프의 문서가 각자 자기 쪽 절반만 볼 수 있다 — admin-api는 "`@ConditionalOnBean(BrokerTopologyInspector)`가 참이 된 적이 없다"를 보고, kafka는 "`KafkaTopologyInspector` 구현이 0"을 본다. 둘을 겹쳐야 **같은 하나의 미배선**이 된다. 4. **`adapter-inbound-web`의 P1 여섯 건은 web 리프가 아니라 `app-bootstrap`이 원인이다.** web 문서가 그 사실을 스스로 지목하지만, 고칠 파일은 다른 리프에 있다. 5. **grpc 블록의 Stable/Advanced 경계는 실제로 강제된다.** 사이클 2에서 한 번 반대로 판단했다가 `grpc-spring-boot-starter/build.gradle:4-7`의 근거 주석을 따라가 세 겹의 강제 (레지스트리 `allowed_dependencies` · `verifyCleanArchitectureDependencies` · `GrpcPlatformStartupValidatorTest:257`이 실제 `build.gradle`을 읽는 것)를 확인하고 철회했다 (`EVD-326`). 남은 것은 카탈로그의 이름 목록이 `modules.json`과 대조되지 않는다는 P3뿐이다. --- 6. **같은 자료구조 오용이 두 리프에 있다.** `GrpcCredentialRotationManager` 와 `GrpcChannelRuntimeRegistry` 가 각각 `AtomicReference` 를 조건 없는 `set` 으로 쓴다. 두 리프의 문서는 각자 자기 쪽만 볼 수 있고, 겹쳐야 "이 가족이 회전을 다루는 방식" 이라는 하나의 사실이 된다. §3.5. 7. **Kafka 트랜잭션 검증의 절반이 다른 리프에 있다.** 검증기는 `messaging-kafka` 가 소유하고, 그것을 시작 시 부르는 배선은 `messaging-spring-boot-starter` 가 소유한다. 후자에 감싸는 블록이 없어서 전자가 돌지 않는다. 어느 쪽 문서도 혼자서는 "이 검증이 실행되지 않는다" 를 말할 수 없다. 8. **정책 목록의 가장 강한 성질이 다른 리프의 미완성에 걸려 있다.** `GrpcMethodPolicyCatalog` 의 서술자 대조는 이름 변경을 잡는 장치인데, 서술자를 만드는 `grpc-codegen` 이 protoc 을 돌리지 않으므로 이 저장소에서는 그 대조를 켤 수 없다. `withDescriptorMethods` 의 production 호출자는 0 이다. 10. **같은 문제의 올바른 판본과 틀린 판본이 두 리프에 나란히 있다 — 결정을 그 결정이 판정한 대상에 묶는 것.** `grpc-codegen` 의 `GrpcSchemaArtifactPublisher.publish(candidate, decision)` 는 `decision.allowed()` 만 보고 기록한다. `PublishDecision` 은 자기가 무엇을 판정했는지 들고 있지 않으므로, A 를 평가한 결정으로 B 를 발행할 수 있고 그러면 소비자 게이트와 버전 불변성을 둘 다 우회한다(그쪽 §17.4). `grpc-advanced-bootstrap` 의 `GrpcAdvancedSupportMatrix.apply(decision)` 는 정반대다 — 결정의 `from` 이 현재 등급과 다르면 던지고, 그 이유를 "두 승격이 경합했거나 하나가 재생된 경우" 라고 적는다. 같은 저장소가 같은 형태를 한 번은 맞게, 한 번은 틀리게 썼다. §3.5 의 check-then-act 계열과 같은 뿌리이나 여기서는 경합이 아니라 **인자 짝 맞추기**가 깨진 자리다. 11. **"실환경 증거" 의 정의와 그 요구가 다른 리프에 있고 서로를 부르지 않는다.** `grpc-advanced-diagnostics` 의 `GrpcAdvancedInfrastructureTestkit` 이 능력별로 무엇이 실환경인지 정의한다(gRPC-Web 프록시 · 서블릿 컨테이너 · 멈출 수 있는 xDS 통제 평면 · 코틀린 툴체인). `grpc-advanced-bootstrap` 의 `GrpcAdvancedPromotionEvidence` 는 그것을 `boolean realEnvironmentTest` 하나로 받는다. 두 쪽이 만나지 않으므로 `complete(XDS, 7일)` 이 통제 평면 없이도 참을 넣는다 — 테스트킷 javadoc 이 경계한 "a suite that runs without the infrastructure passes and establishes nothing" 을 승격 게이트가 그대로 통과시킨다. 12. **승격 기준이 같은 능력에 대해 두 게이트에서 다르다.** `EDITION_2024` 는 `GrpcAdvancedPromotionGate` 에서 증거 일곱 항목 + 7일 담금을 요구받고, `GrpcEdition2024Gate` 에서 호환성 보고서 + 소비자 이관 + ADR 을 요구받는다. 어느 쪽도 상대를 부르지 않고 관계가 문서에도 없다. 그리고 전자에는 별도 결함이 있다 — 30일 담금 갈래가 열거형에 없는 등급(`STABLE_DEFAULT`)을 위해 쓰여, `ADVANCED_STABLE` 이 아닌 목표 전부를 삼킨다. 그 결과 `WATCH → EXPERIMENTAL`(WATCH 가 밟도록 강제된 유일한 첫 걸음)이 30일을 요구하고 `EXPERIMENTAL → ADVANCED_STABLE` 은 7일을 요구한다 — 중간 등급이 상위 등급보다 어렵다. 그 갈래를 실행하는 테스트가 `ADVANCED_STABLE → DISABLED`(철회)를 골라 놓고 이름을 "becoming a Stable default" 라고 붙인 것이 그 뒤틀림의 흔적이다. (`grpc-advanced-bootstrap` §17.4) 9. **Stable 모듈 목록과 레지스트리를 붙드는 장치가 없다.** `GrpcStableModuleCatalog` 의 javadoc 은 `GrpcStableModuleCatalogTest` 가 둘을 함께 붙든다고 적지만, 그 테스트는 `modules.json` 을 읽지 않고 목록을 리터럴과 대조한다. 두 집합은 오늘 일치한다(12 + 6 = 레지스트리의 grpc 계열 18). 어긋난 것은 그 일치를 무엇이 지키는가다. messaging 가족이 같은 형태를 이미 기록했다 — "세는 순간 다시 drift 한다." --- ## 5. 측정 방법에 대해 이 사이클이 배운 것 사이클 2가 만든 판정 번복 한 건과 자기 교정 세 건은 모두 같은 형태의 실수에서 나왔다. **코드를 읽고 런타임의 모양을 추론한 뒤, 그 추론을 측정으로 확인하지 않은 것.** - `EVD-332` — 분기 순서를 읽고 예외 사슬의 모양을 단정했다. 사슬을 출력하니 달랐다. - `EVD-326` — 레지스트리에 검사가 없다고 단정했다. `build.gradle`의 주석이 가리키는 세 곳을 따라가니 있었다. - `PulsarPreSendRejection` — 전역 승인 게이트가 임계값을 읽을 것이라고 가정했다. `PayloadLimitGuard`는 그 필드를 읽지 않았다. - outbox 무제한 DELETE의 서술 — "프로덕션 기본 경로"라고 적었다. 자동설정은 스케줄러를 등록하지 않는다. 네 건 모두 **하나의 추가 측정이면 갈렸다**. 이 저장소의 코드는 자기 근거를 주석으로 남기는 밀도가 높아서 읽는 것만으로 확신이 생기기 쉽고, 바로 그 점이 함정이다. 사이클 3에 남기는 규칙은 하나다 — **런타임의 모양에 대한 주장은 런타임에서 확인한다.** jshell로 사슬을 출력하는 데 든 비용은 몇 분이었고, 그 몇 분이 P1 하나를 지웠다. --- ## 6. 확인하지 못한 것 - **다른 두 전송 분류기.** `ReactorFailureClassifier`·`JdkFailureClassifier`는 개별 실행으로 확인하지 않았다. `EVD-332`가 반증한 것은 "Apache가 TLS 실패를 연결 예외로 감싼다"는 전제이므로 그 전제에 기대던 열린 항목은 소멸하지만, 두 분류기 자체를 측정한 것은 아니다. - **`jq`가 있는 기계에서의 `verify-compose-profile-contracts.sh`.** 파이썬 이식본으로 15개 레인이 계약과 일치함을 확인했으나, 이는 테스트 자신이 경계한 "두 번째 의견"이다. 원본 스크립트 실행이 정본이다. - **422건 중 268건의 기계 분류.** 제목이 짧아 유형 분류가 되지 않았다. §3의 수치는 하한이다. 그리고 그 분류는 **재통독 이전의 422건에 대해 돌린 것**이다. 23개 리프가 60→100 으로 늘어난 뒤 다시 돌리지 않았으므로, §3 의 유형별 건수는 새로 추가된 40건을 반영하지 않는다. §3.5 의 검증기 표와 §3.8 은 기계 분류가 아니라 재통독에서 직접 확인해 손으로 적은 것이다. - **전체 462건의 직접 재측정.** §0 이 밝힌 대로 462는 델타 조정값이고 61개 문서를 다시 센 값이 아니다. - **`sample-portfolio`.** 레지스트리에서 EXCLUDED이며 이 사이클의 분석 대상이 아니다. - **런타임 컴포지션의 실제 기동.** compose 계약은 정적으로만 검증했다. 아무 스택도 기동하지 않았다. - **사이클 2 통독이 만든 92건의 실행 확인.** 전부 코드 통독과 정적 대조로 판정했다. 두 가족 모두 배선 경로가 없거나(grpc 18개 리프) 선택할 수 없어서(rabbit), 실행으로 재현할 대상이 애초에 없다. 예외는 `grpc-testkit` 의 세 레인으로, 이번에 직접 돌려 통과를 확인했다(계약 7 · Netty 9 · 고장 9). - **재통독의 도달성 판정 방법.** 리프마다 `grep` 으로 타입 이름·패키지 이름을 훑어 리프 밖 참조를 셌다. 리플렉션·서비스 로더·문자열 기반 조립으로 닿는 경로가 있다면 이 방법으로는 잡히지 않는다. 이 저장소가 그런 조립을 쓰는 곳은 발견하지 못했으나, 찾아본 것이 아니라 마주치지 않은 것이다. - **23개 리프의 테스트 실행.** 재통독은 테스트 본문을 전부 읽었지만 이번 판에서 다시 돌리지는 않았다. "이 단언은 항상 통과한다" 류의 판정(`messaging-nats-experimental` §17.4 등)은 단언 의미론으로 내린 것이다. 예외는 `grpc-testkit` 의 세 레인으로, 앞선 판에서 직접 돌려 통과를 확인했다. - **`@ConditionalOnBean` 사슬의 실제 평가.** `MessagingReliabilityAutoConfiguration` 이 같은 클래스 안에서 방금 선언한 빈을 조건으로 삼는다. 지금은 그 앞 조건이 만족되지 않아 셋 다 만들어지지 않으므로 결과가 드러나지 않는다. 스프링의 문서화된 제약으로 판정했고 컨텍스트로 재현하지 않았다. --- 사이클 1 초안이 열린 질문 다섯 개를 번호로 관리했다. 그 제목이 `candidate-ledger.json`에 보존되어 있어 아래에 그대로 되살린다 — 초안 본문은 복구되지 않았으므로, 각 항목의 내용은 사이클 2가 실제로 확인한 것과 확인하지 못한 것으로 다시 썼다. ### 남은 질문 1 — 컨테이너·브로커·DB가 필요한 레인의 실제 결과 사이클 2는 Docker 가용을 확인하고(client 29.1.3 / server 29.6.1) Testcontainers 레인을 실제로 돌렸다 — persistence-jpa 477, persistence-mongo 72, cache-redis 435 테스트가 전부 통과했고, kafka 인증 레인과 실 브로커 왕복, Postgres IT, grpc-testkit 엄격 레인 넷도 실행했다. 그러나 **전부는 아니다.** `jpaPlatformFailureTest`는 이 리비전에서 실행하지 않았고(§6의 커밋 모호성 항목), mongo 쪽 컨테이너 레인 중 릴리스를 막지 않는 것들도 실행하지 않았다. 어느 레인이 실행됐고 어느 레인이 아닌지는 `EVD-334`가 목록으로 갖는다. ### 남은 질문 2 — sample-portfolio 내부 레지스트리에서 `EXCLUDED`이며 사용자 지시에 따라 이 사이클의 분석 대상이 아니다. 이 리프에만 속하는 `adapter-outbound-objectstorage`가 출하 애플리케이션에 없다는 사실(§2)은 레지스트리 대조로 확인했지만, 샘플 내부의 도메인 모델과 그 조립은 읽지 않았다. ### 남은 질문 3 — 런타임 관측 compose 계약은 정적으로만 검증했다. 어떤 스택도 기동하지 않았고, 애플리케이션을 부팅해 액추에이터나 조건 평가 리포트를 읽지도 않았다. 부팅 한 번이면 확증되는 정적 추론이 최소 두 건 남아 있다 — messaging 관측 시리즈 부재와 `@ConditionalOnBean` 사슬의 실제 평가 결과다. ### 남은 질문 4 — `@ConditionalOnBean` 실제 평가 순서 `@ConditionalOnBean`이 클래스 파싱 시점에 평가되어 빈이 사라지는 계열의 판정은 코드와 javadoc의 사후 기록에 근거한다. 이 리비전에서 `ConditionEvaluationReport`를 읽어 실제 평가 순서와 결과를 확인하지 않았다. `debug=true`로 부팅 한 번이면 확인된다. ### 남은 질문 5 — 성능·용량 주장 이 사이클은 성능을 측정하지 않았다. 문서에 남은 성능·용량 관련 서술은 전부 코드가 선언한 상한과 그 강제 여부에 대한 것이며, 실제 처리량·지연·자원 사용에 대한 주장은 하지 않는다. httpclient의 성능 레인과 jmh 벤치마크는 기계 의존적이라는 이유로 `check`에서 빠져 있고, 이 사이클에서도 돌리지 않았다. ## 7. 이 사이클의 작업 제약 - 애플리케이션 소스는 한 줄도 수정하지 않았다. - 패키지를 설치하지 않았다(`jq` 포함). - 사용자 워크스페이스의 빌드 산출물을 삭제하지 않았다. - git 상태를 변경하지 않았다 — 커밋·푸시·리셋·클린 없음. - 컨테이너를 기동하지 않았다. Testcontainers를 쓰는 lane은 저장소 자신의 테스트가 기동한 것이다. ## Source anchors ``` src/config/architecture/modules.json (62개 리프의 runtime_memberships) src/build.gradle:477-492 (사이클 1→2 사이 변경 지점) src/grpc/CLAUDE.md:74-98 (grpc 블록 build-only 상태) src/grpc/grpc-spring-boot-starter/build.gradle:4-7 (Stable/Advanced 경계 근거) scripts/verify-compose-profile-contracts.sh:20-30,42-160 src/config/runtime/compose-profile-contracts.json (15개 레인) analysis/01-domain-core.md … analysis/18-app-bootstrap.md (18개 리프 SSOT) analysis/messaging/*.md (25개) analysis/grpc/*.md (18개) analysis/19-messaging-platform.md, analysis/20-grpc-platform.md (가족 문서, INTEGRATION_ONLY) evidence/raw/325-grpc-block-is-entirely-unreachable.txt evidence/raw/326-grpc-stable-advanced-boundary-has-no-registry-check.txt evidence/raw/328-grpc-boundary-rules-never-see-real-source.txt evidence/raw/329-grpc-two-rule-engines-one-fed.txt evidence/raw/332-httpclient-dualstack-localhost-masks-tls-permanent.txt evidence/raw/333-eighteen-docs-source-drift-zero.txt evidence/raw/334-eighteen-leaf-lane-rerun.txt ```