Files
document-haness/docs/clean-architecture-backend-template/analysis/99-cross-scope.md
T
DongHyeonkaandClaude Opus 5 b2963105a8 docs(keycloak-session-store): import the session-storage lab as a new project
The keycloak project ended with four open questions that design could not
settle. A two-VM lab was built to answer them by measurement, and this is
that material: 26 experiments, 125 raw command outputs, 22 browser captures.

Follows the import procedure in README.md.

  source/     the originating repository verbatim — 78 documents, 28 SVGs,
              8 manifests, plus .source-revision recording the commit
  final/      the SSOT
    document.md   729 lines written from the 29 experiment documents, not
                  concatenated: what was predicted, what was measured, and
                  where the measurement itself was wrong
    evidence/raw    125 outputs, flattened to <experiment>__<file> because
                    the originals collided (01-baseline.txt appeared three
                    times) and the audit only globs the top level
    evidence/meta   one per raw file; command and exitCode are null and the
                    README says why rather than inventing them
    evidence/browser  22 captures
    assets/       three diagrams through techviz
    .techviz/     their VizSpecs

A separate project rather than an addition to keycloak: the B-layer answers
that project's four questions, but the A, C and D layers are about cluster
failure, SSO and operations, and one document.md should hold one subject.
The four question records there can point here through 관계.

Recorded rather than papered over: only three of the 28 diagrams were
remade. The repository forbids hand-drawn SVG and forbids titles inside the
canvas; all 28 originals carry both, so converting them is redrawing, not
reformatting. They stay in source/ and the gap is written into the document.

verify-pipeline.py passes. audit-records.py reports no issues.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 22:51:59 +09:00

42 KiB
Raw Blame History

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<k> — 형태를 파싱했고, 23개 문서 전부를 처음부터 끝까지 읽은 뒤이므로 표기 누락이 없다. 앞선 판의 60건은 통독이 실제로는 부분 통독이던 시점의 수치다.

전체 462건은 직접 다시 센 것이 아니라 델타로 조정한 값이다. 앞선 판의 422건에서 23개 리프 몫 60을 빼고 100을 더했다(422 60 + 100 = 462). 나머지 38개 문서는 이번 재작업의 대상이 아니었고, 그 문서들이 쓰는 「모듈 findings 표」 형식은 §17 형식과 파싱 규칙이 달라 두 형식을 함께 세는 스크립트를 이번에 다시 돌리지 않았다. 그러므로 462는 델타가 정확하다는 가정 위에 있고, 422 자체의 재측정은 아니다. 직접 재측정이 필요하면 두 형식을 모두 파싱하는 원래 스크립트를 61개 문서에 다시 돌려야 한다.

P1·P2·P3 내역도 같은 방식으로 조정했다 — P1 291+2=30, P2 13519+31=147, P3 25840+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를 돌려주는데 이 컨테이너의 localhost127.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 계약과 어긋난다. 엔티티의 식별자가 배정값이라 savemerge 로 가고, 파생 기본 키가 유니크 제약과 같은 행을 가리키므로 두 번째 청구가 유니크 위반을 일으키지 않고 커밋된 결과를 덮어쓴다. 테스트 이중의 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-grpcadapter-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-httpclientPOOL_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-experimentalclaimCheckThresholdBytes에 있다 — 필드는 있고 그것을 읽는 액터가 없다. 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-bootstrapeveryLaneMatchesItsContractdocker 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.tryConsumeGrpcHedgingBudget.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-5GrpcBufPolicy: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 가 "검증기가 합의를 요구한다" 로 서술했다(이번에 정정)

GrpcProtoContractValidatorGrpcBufPolicy 는 서로를 가리킨다 — 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 0dev.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-editiongrpc-proto-contract 의존은 그 리프의 compatibility.proto 가 저쪽 스키마 규칙의 관할이라는 선언으로 읽힌다. 다만 그 관할은 코드로 연결되어 있지 않고, grpc-proto-contract 의 커밋 스키마 테스트가 파일 목록을 하드코딩해 이 파일을 판정하지 않는다. 즉 의존 선언이 표현하려던 관계가 실제로는 어느 쪽에도 없다.

3.6 F — 문서가 코드보다 앞서 있다 (18개 리프, 57건)

건수로는 A와 동률 1위인데 P1이 하나뿐이다. 대부분 javadoc·README·주석이 이제는 사실이 아닌 것을 말하는 형태다. adapter-outbound-httpclientBoundedDataBufferFlux가 javadoc이 처리한다고 적은 두 경로가 no-op인 것, adapter-outbound-fileserver의 README 주장이 여덟 개 port 구현 앞에서 성립하지 않는 것(P2)이 대표적이다.

이 저장소의 주석 밀도는 이례적으로 높고 — adapter-outbound-httpclientbuild.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뿐이다.

  1. 같은 자료구조 오용이 두 리프에 있다. GrpcCredentialRotationManagerGrpcChannelRuntimeRegistry 가 각각 AtomicReference 를 조건 없는 set 으로 쓴다. 두 리프의 문서는 각자 자기 쪽만 볼 수 있고, 겹쳐야 "이 가족이 회전을 다루는 방식" 이라는 하나의 사실이 된다. §3.5.

  2. Kafka 트랜잭션 검증의 절반이 다른 리프에 있다. 검증기는 messaging-kafka 가 소유하고, 그것을 시작 시 부르는 배선은 messaging-spring-boot-starter 가 소유한다. 후자에 감싸는 블록이 없어서 전자가 돌지 않는다. 어느 쪽 문서도 혼자서는 "이 검증이 실행되지 않는다" 를 말할 수 없다.

  3. 정책 목록의 가장 강한 성질이 다른 리프의 미완성에 걸려 있다. GrpcMethodPolicyCatalog 의 서술자 대조는 이름 변경을 잡는 장치인데, 서술자를 만드는 grpc-codegen 이 protoc 을 돌리지 않으므로 이 저장소에서는 그 대조를 켤 수 없다. withDescriptorMethods 의 production 호출자는 0 이다.

  4. 같은 문제의 올바른 판본과 틀린 판본이 두 리프에 나란히 있다 — 결정을 그 결정이 판정한 대상에 묶는 것. grpc-codegenGrpcSchemaArtifactPublisher.publish(candidate, decision)decision.allowed() 만 보고 기록한다. PublishDecision 은 자기가 무엇을 판정했는지 들고 있지 않으므로, A 를 평가한 결정으로 B 를 발행할 수 있고 그러면 소비자 게이트와 버전 불변성을 둘 다 우회한다(그쪽 §17.4). grpc-advanced-bootstrapGrpcAdvancedSupportMatrix.apply(decision) 는 정반대다 — 결정의 from 이 현재 등급과 다르면 던지고, 그 이유를 "두 승격이 경합했거나 하나가 재생된 경우" 라고 적는다. 같은 저장소가 같은 형태를 한 번은 맞게, 한 번은 틀리게 썼다. §3.5 의 check-then-act 계열과 같은 뿌리이나 여기서는 경합이 아니라 인자 짝 맞추기가 깨진 자리다.

  5. "실환경 증거" 의 정의와 그 요구가 다른 리프에 있고 서로를 부르지 않는다. grpc-advanced-diagnosticsGrpcAdvancedInfrastructureTestkit 이 능력별로 무엇이 실환경인지 정의한다(gRPC-Web 프록시 · 서블릿 컨테이너 · 멈출 수 있는 xDS 통제 평면 · 코틀린 툴체인). grpc-advanced-bootstrapGrpcAdvancedPromotionEvidence 는 그것을 boolean realEnvironmentTest 하나로 받는다. 두 쪽이 만나지 않으므로 complete(XDS, 7일) 이 통제 평면 없이도 참을 넣는다 — 테스트킷 javadoc 이 경계한 "a suite that runs without the infrastructure passes and establishes nothing" 을 승격 게이트가 그대로 통과시킨다.

  6. 승격 기준이 같은 능력에 대해 두 게이트에서 다르다. EDITION_2024GrpcAdvancedPromotionGate 에서 증거 일곱 항목 + 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)

  7. 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