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>
42 KiB
99 · 교차 스코프 분석 — 사이클 2
상태: COMPLETE 기준 revision:
21234e38cdb9a926cbc92bb97a2aee2e4a7d2916분모: 등록 리프 62 (COMPLETE 61 · EXCLUDED 1sample-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 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 의 형태다.
- 운영 프로파일에 TLS 와 브로커 인증을 요구해 놓고, 그 둘이 없는 생산자를 만든다.
KafkaProfileValidator는 운영 프로파일이 전송 보안 없이 뜨는 것을 거부하고 그 거부를 테스트가 지킨다 (aProductionKafkaBrokerWithoutTransportSecurityFailsStartup). 그런데 실제로 조립되는KafkaProducer설정에는security.protocol이 없다 — Kafka 기본값PLAINTEXT다. 그 값을 만드는KafkaSecurityConfigurer는 저장소 전역에서 production 호출자가 0 이다. (messaging-spring-boot-starter§17.1) - 지원 문서가
deduplicatedPublish를 지원으로 적고 코드는 거짓이며, 그 차이가 정확히 코드가 경고한 피해다. (messaging-kafka§17.1)
이 둘이 P1 인 이유는 배선 여부와 무관하게 성립하기 때문이다. 나머지 23개 리프의 finding 대부분은 "조립되면 성립하는 결함" 이지만, 이 둘은 messaging 계열이 실제로 배선되는 경로 위에 있다.
그 아래 층위에서 가장 무거운 예 셋:
JpaGrpcOperationLedger.claim의 insert-first 주장이 Spring Data 의save계약과 어긋난다. 엔티티의 식별자가 배정값이라save가merge로 가고, 파생 기본 키가 유니크 제약과 같은 행을 가리키므로 두 번째 청구가 유니크 위반을 일으키지 않고 커밋된 결과를 덮어쓴다. 테스트 이중의save는 INSERT 를 흉내 내 그 차이를 가린다. (grpc-operation-ledger-jpa§17.1)GrpcCredentialRotationManager.completeDrain()이 진행 중인 회전을 되돌린다. 읽기와 쓰기 사이에 회전이 일어나면 방금 교체된 자격증명이 되살아난다. (grpc-policy§17.2)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가 원칙적으로 볼 수 없는 사실을 여기 모은다.
adapter-inbound-grpc·adapter-inbound-websocket이 어떤 배포에도 없다. §2.adapter-outbound-objectstorage가 샘플에만 있다. 출하 애플리케이션에는 없다. §2.messaging-admin-api의 토폴로지 BLOCKING 보장을 배선하려면 인스펙터가 필요한데,messaging-kafka에는 줄 것이 없다. 두 리프의 문서가 각자 자기 쪽 절반만 볼 수 있다 — admin-api는 "@ConditionalOnBean(BrokerTopologyInspector)가 참이 된 적이 없다"를 보고, kafka는 "KafkaTopologyInspector구현이 0"을 본다. 둘을 겹쳐야 같은 하나의 미배선이 된다.adapter-inbound-web의 P1 여섯 건은 web 리프가 아니라app-bootstrap이 원인이다. web 문서가 그 사실을 스스로 지목하지만, 고칠 파일은 다른 리프에 있다.- grpc 블록의 Stable/Advanced 경계는 실제로 강제된다. 사이클 2에서 한 번 반대로 판단했다가
grpc-spring-boot-starter/build.gradle:4-7의 근거 주석을 따라가 세 겹의 강제 (레지스트리allowed_dependencies·verifyCleanArchitectureDependencies·GrpcPlatformStartupValidatorTest:257이 실제build.gradle을 읽는 것)를 확인하고 철회했다 (EVD-326). 남은 것은 카탈로그의 이름 목록이modules.json과 대조되지 않는다는 P3뿐이다.
-
같은 자료구조 오용이 두 리프에 있다.
GrpcCredentialRotationManager와GrpcChannelRuntimeRegistry가 각각AtomicReference를 조건 없는set으로 쓴다. 두 리프의 문서는 각자 자기 쪽만 볼 수 있고, 겹쳐야 "이 가족이 회전을 다루는 방식" 이라는 하나의 사실이 된다. §3.5. -
Kafka 트랜잭션 검증의 절반이 다른 리프에 있다. 검증기는
messaging-kafka가 소유하고, 그것을 시작 시 부르는 배선은messaging-spring-boot-starter가 소유한다. 후자에 감싸는 블록이 없어서 전자가 돌지 않는다. 어느 쪽 문서도 혼자서는 "이 검증이 실행되지 않는다" 를 말할 수 없다. -
정책 목록의 가장 강한 성질이 다른 리프의 미완성에 걸려 있다.
GrpcMethodPolicyCatalog의 서술자 대조는 이름 변경을 잡는 장치인데, 서술자를 만드는grpc-codegen이 protoc 을 돌리지 않으므로 이 저장소에서는 그 대조를 켤 수 없다.withDescriptorMethods의 production 호출자는 0 이다. -
같은 문제의 올바른 판본과 틀린 판본이 두 리프에 나란히 있다 — 결정을 그 결정이 판정한 대상에 묶는 것.
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 계열과 같은 뿌리이나 여기서는 경합이 아니라 인자 짝 맞추기가 깨진 자리다. -
"실환경 증거" 의 정의와 그 요구가 다른 리프에 있고 서로를 부르지 않는다.
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" 을 승격 게이트가 그대로 통과시킨다. -
승격 기준이 같은 능력에 대해 두 게이트에서 다르다.
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) -
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