# 20. gRPC platform family — 18 leaf 통합 분석 - **분석 대상 리비전**: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916` (`feat: grpc 기능 deep 구현`, 2026-08-31) - **직전 기준선**: `a24ece9cf797f7ea647e33bf846b115208ed1ba5` — 모듈 01~19의 분석 리비전 - **범위**: `src/grpc/**`(12 leaf) + `src/grpc-advanced/**`(6 leaf) — `modules.json` 등록 **18개** - **분모**: git 추적 파일 **383개** (main Java 260 / 18,726 LOC · test Java 68 · 나머지 build/lockfile/resource/governance) - **가족 로컬 권위 문서**: `src/grpc/CLAUDE.md`(117줄) · `src/grpc-advanced/CLAUDE.md`(77줄) - **테스트 레인**: 18개 leaf `:test` 전량 + 증거 레인 3종 — **전부 BUILD SUCCESSFUL** --- ## 0. 이 문서가 왜 20번인가 — 분석 도중 코드베이스가 이동했다 교차 스코프 분석을 시작하며 레지스트리를 다시 전수로 읽었을 때 **등록 모듈이 62개**인데 `state.json`이 추적하던 것은 **44개**였다. 누락 18개는 전부 gRPC 가족이다. 원인은 분석 누락이 아니라 **리비전 이동**이다: ``` $ git log --oneline -3 21234e38 feat: grpc 기능 deep 구현 <- 현재 HEAD (2026-08-31) a24ece9c feat: web, websocket 어댑터 추가 구현 <- 모듈 01~19의 분석 기준선 01372634 refactor: 각 어댑터터별 리펙토링 진행 $ git diff --stat a24ece9c..HEAD 400 files changed, 40217 insertions(+), 4 deletions(-) ``` 변경 경로는 `src/grpc/**` · `src/grpc-advanced/**` · `modules.json`(18개 항목 추가) · `src/build.gradle`(테스트 클래스패스 조건에 `:grpc:`·`:grpc-advanced:` 추가) · docs 15개뿐이다. **`src/messaging/` 이하는 한 줄도 바뀌지 않았고**, 모듈 01~19가 다룬 어떤 경로도 변경되지 않았다(삭제 4줄은 `src/build.gradle`의 주석 교체분이다). 따라서 앞선 19개 문서와 그 증거는 그대로 유효하다. `state.json`의 `gitRevision`을 HEAD로 올리고 `reanalysis` 블록을 `ADDITIVE_SCOPE`로 채웠으며, 18개 스코프를 추가해 이 문서로 닫는다. --- ## 1. 분모와 커버리지 원장 ### 1.1 등록 leaf 18개 | # | leaf | 파일 | main | test | main LOC | 허용 의존 | runtime_memberships | |---|---|---|---|---|---|---|---| | 1 | `grpc-policy` | 82 | 62 | 18 | 4,781 | 1 | `[]` | | 2 | `grpc-core-api` | 41 | 32 | 7 | 1,897 | **0** | `[]` | | 3 | `grpc-testkit` | 36 | 26 | 8 | 2,313 | 10 | `[]` | | 4 | `grpc-advanced-compat` | 25 | 17 | 5 | 962 | 5 | `[]` | | 5 | `grpc-server` | 24 | 17 | 5 | 1,106 | 2 | `[]` | | 6 | `grpc-advanced-resilience` | 23 | 16 | 4 | 940 | 5 | `[]` | | 7 | `grpc-advanced-streaming` | 20 | 14 | 4 | 833 | 3 | `[]` | | 8 | `grpc-admin` | 19 | 13 | 4 | 913 | 2 | `[]` | | 9 | `grpc-client` | 19 | 13 | 4 | 931 | 2 | `[]` | | 10 | `grpc-codegen` | 16 | 10 | 4 | 725 | 2 | `[]` | | 11 | `grpc-advanced-bootstrap` | 13 | 9 | 2 | 610 | 1 | `[]` | | 12 | `grpc-advanced-edition` | 11 | 6 | 2 | 326 | 3 | `[]` | | 13 | `grpc-discovery` | 11 | 7 | 2 | 409 | 2 | `[]` | | 14 | `grpc-proto-contract` | 11 | 3 | 1 | 605 | 1 | `[]` | | 15 | `grpc-advanced-diagnostics` | 8 | 4 | 1 | 277 | 3 | `[]` | | 16 | `grpc-spring-boot-starter` | 8 | 4 | 1 | 468 | 10 | `[]` | | 17 | `grpc-observability` | 7 | 4 | 1 | 354 | 1 | `[]` | | 18 | `grpc-operation-ledger-jpa` | 7 | 3 | 1 | 276 | 1 | `[]` | | | **합계** | **381** | **260** | **74** | **18,726** | | | 분모 383 = leaf 381 + 가족 공통 문서 2개(`src/grpc/CLAUDE.md`, `src/grpc-advanced/CLAUDE.md`). 미배정 0. **18개 전부 `runtime_memberships: []` — 가족 전체가 build-only다.** 이것이 이 문서의 심각도 축이다(모듈 17 §26.6·모듈 19 §1.1의 원칙 적용). 어떤 배포 아티팩트도 이 코드를 싣고 있지 않으므로, 조립 결함은 **오늘의 사고가 아니라 채택 시점의 부채**로 기록한다. ### 1.2 sub-scope 분할 | # | sub-scope | leaf | 파일 | |---|---|---|---| | 01 | core contracts | `core-api` · `proto-contract` · `codegen` | 68 | | 02 | policy | `policy` | 82 | | 03 | server · client · discovery | `server` · `client` · `discovery` | 54 | | 04 | admin · observability · ledger · 조립 경계 | `admin` · `observability` · `operation-ledger-jpa` · `spring-boot-starter` | 41 | | 05 | testkit · 증거 등급 · 릴리스 게이트 | `testkit` + `src/grpc/CLAUDE.md` | 37 | | 06 | advanced | 6 leaf + `src/grpc-advanced/CLAUDE.md` | 101 | | | **합계** | **18 leaf** | **383** | --- ## 2. 이 가족이 공개한 주장과 검증 결과 `src/grpc/CLAUDE.md`는 기계로 검사 가능한 주장을 여러 개 한다. 모듈 19에서와 같이 **검증을 먼저** 했다. ### 2.1 "`grpc-core-api`는 io.grpc를 이름조차 부르지 않는다" → **성립** ``` grep -rn "io\.grpc" grpc-core-api/src/main → 3 grep -rn "org\.springframework" grpc-core-api/src/main → 0 grep -rn "com\.google\.protobuf" grpc-core-api/src/main → 0 grep -rn "jakarta\.persistence" grpc-core-api/src/main → 0 (test 소스는 네 패턴 모두 0) ``` 3건은 전부 **javadoc 산문**이고, 그 내용이 왜 타입을 쓰지 않는지를 설명한다: > "The canonical gRPC status codes, **mirrored so that `grpc-core-api` stays free of io.grpc**. ... a failure context that names `io.grpc.Status` would put the transport inside the contract that exists to describe what the transport did." 그리고 결정적으로 `grpc-core-api/build.gradle`이 이렇다: ```groovy apply plugin: 'java-library' dependencies { } ``` **의존성 블록이 비어 있다.** io.grpc가 컴파일 클래스패스에 아예 없으므로 이 제약은 문서가 아니라 빌드가 강제한다. 32개 main 파일 1,897 LOC가 Java stdlib만으로 서 있다. ### 2.2 "Stable leaf는 `:grpc-advanced:*`를 참조하지 않는다" → **성립** - 레지스트리: 비-advanced leaf의 `allowed_dependencies`에 advanced id가 등장하는 경우 **0건**. - `grpc-spring-boot-starter`의 `allowed_dependencies` = Stable 10개 leaf뿐. - 소스: `src/grpc` 전체에서 `dev.caskeleton.grpc.advanced` 참조 **0건**. - 문서가 말하는 이중 강제: `verifyCleanArchitectureDependencies`(build time) + `GrpcStableBuildInvariant`·`GrpcAdvancedModuleGuard.requireStableStarterIsClean`(runtime). **단, runtime 절반은 실행되지 않는다.** `GrpcStableBuildInvariant`를 호출하는 프로덕션 경로는 `GrpcPlatformStartupValidator.validateAdvancedIsolation` 하나이고, 그 validator 자체가 조립에서 호출되지 않는다(§3.1). 따라서 오늘 살아 있는 강제는 **build time 한 층**이다. 레지스트리 검사가 실효적이므로 규칙 자체는 성립하지만(위 세 확인), "runtime에도 같은 규칙을 강제한다"는 서술은 현재 상태를 서술하지 않는다. `grpc-advanced`의 CLAUDE.md가 별도 디렉터리·별도 Gradle prefix를 쓴 이유를 명시한다 — "그 불변 조건을 registry의 `allowed_dependencies`만으로 **기계 검증할 수 있게** 하기 위해서." ### 2.3 "모든 grpc leaf의 runtime_memberships가 비어 있다" → **성립** 18개 전부 `[]`. 그리고 `adapter-inbound-grpc`(모듈 15에서 분석한 leaf)의 `allowed_dependencies`는 ``` ["domain-core", "application-core", "shared-contract"] ``` — 이 가족을 **볼 수 없다.** CLAUDE.md가 "현재 `adapter:inbound:grpc`는 이 family에 의존하지 않는다 — registry의 `allowed_dependencies`를 보라"고 적은 그대로다. 이것이 messaging과의 결정적 차이다. messaging은 `messaging-spring-boot-starter`가 `app-bootstrap` 의존으로 들어가면서 18개 leaf가 출하 아티팩트에 실렸고, 그 결과 §MSG-015(서로 모르는 두 스택)가 실재 문제가 됐다. gRPC 가족은 **아직 그 선을 넘지 않았고, 넘지 않았다는 사실을 문서가 정확히 말한다.** ### 2.4 "`GrpcEvidenceGrade`가 in-process 결과로 TLS를 주장하는 것을 거부한다" → **성립** ```java public void requireCertifies(String capability) { Set certified = certifies(); if (!certified.contains(capability)) { throw new IllegalStateException( this + " evidence does not certify '" + capability + "'; it establishes " + ...); } } ``` `CONTRACT`가 증명하는 것은 `service-adapter`·`interceptor-order`·`status-mapping`·`validation`·`idempotency-replay`·`context-propagation` 여섯이고, `tls`·`mutual-tls`·`http2`·`goaway`·`keepalive` 등은 `TRANSPORT`에만 있다. 등급별 집합을 **필드가 아니라 `switch`로 계산**하는 이유까지 적혀 있다 — "an enum with a collection field is a mutable enum as far as any static analysis can tell." ### 2.5 "performance lane은 기본 `test`에서 제외된다" → **성립** ```groovy tasks.named('test') { useJUnitPlatform { excludeTags 'grpc-performance' } } ``` 근거도 적혀 있다 — "a measurement in the release gate is a flaky test on a shared CI runner; it runs when somebody asks for it, by name." ### 2.6 지원 매트릭스가 자기 상태를 정확히 말한다 → **성립** (모듈 19와 정반대) `docs/compatibility/grpc-support-matrix.md`: > "**Not released.** Every `:grpc:*` leaf is `runtime_memberships: []` in the module registry, so the platform is **build-only**: it compiles, its lanes run, and no deployed artifact carries it." 그리고 미해결 릴리스 게이트 입력 두 개(성능 baseline 부재, protoc 미실행에 따른 스키마 codegen)를 스스로 나열한다. **모듈 19 §6.4는 정확히 이 문장의 반대 사례였다** — messaging의 지원 매트릭스는 "모든 leaf가 build-only"라고 적었지만 실제로는 18/25가 출하 중이었고, 가족 CLAUDE.md는 이미 그 문장이 틀렸다고 기록해 두었는데도 운영 문서는 고쳐지지 않았다. gRPC 쪽은 같은 문장이 **사실이다.** `GrpcCompatibilityMatrix.caSkeleton()`의 7개 레인·등급도 문서 표와 **전수 일치**한다(certified 3 / compatibility 2 / watch 2). **판정:** 이 가족의 공개된 주장 6건은 전부 성립한다. 모듈 19에 이어 두 번째 사례이고, `grpc-core-api`의 빈 `dependencies {}`는 이 저장소에서 본 가장 강한 형태의 자기 제약이다. --- ## 3. 발견 ### 3.1 P2 — `GrpcPlatformStartupValidator`가 조립에서 호출되지 않는다 `src/grpc/CLAUDE.md`의 Stable 범위 절: > "`GrpcPlatformStartupValidator`가 Stable catalog에 streaming method가 등록되면 **startup을 거부한다.**" 이 가족의 유일한 조립 지점은 `GrpcPlatformAutoConfiguration`이고(`.imports` 1줄), 그 클래스는 106줄에 `@Bean` **9개**를 등록한다: ``` GrpcExecutorProfile · GrpcServerProfile · GrpcAdmissionController · GrpcServiceHealthRegistry · GrpcReflectionPolicy · GrpcAdminExposurePolicy · GrpcDrainPolicy · GrpcContextBinder · GrpcErrorMapper ``` `GrpcPlatformStartupValidator`(188줄)는 그 목록에 없다. 전수 참조: ``` grpc-spring-boot-starter/src/test/.../GrpcPlatformStartupValidatorTest.java (12개 호출) grpc-spring-boot-starter/src/main/.../GrpcPlatformStartupValidator.java (선언 자신) ``` **main 참조 0.** 클래스는 `final` + `private` 생성자 + static 메서드(`violations(...)`, `requireValid(...)`)이므로 bean이 될 수도 없다 — 누군가 `requireValid`를 호출해야 하고, 호출하는 곳이 없다. **실행되지 않는 규칙이 13개다.** validator 본문을 읽어 전수 확인했다: | 그룹 | 규칙 | 거부 사유 | |---|---|---| | transport·security | 4 | production 트래픽을 받을 수 없는 transport / 배포 환경에서 TLS 비활성 / 배포 환경에서 `trustAllCertificates` / 배포 환경에서 reflection `ENABLED` | | executor | 2 | queue capacity < 1 / pool size < 1 | | methods | 4 | UNARY인데 deadline이 0 / `explicitRetry`가 idempotency 프로파일이 허용하지 않는 조합 / `IDEMPOTENCY_KEY_REQUIRED`인데 operation ledger 비활성 / **`rpcType`이 Stable이 아님** | | channels | 2 | 지원되지 않는 채널 프로파일 / in-process 재시도 소유자가 둘 이상 | | advanced isolation | 1 | Stable starter가 advanced 모듈을 해석함 | 클래스 javadoc이 13개를 고른 기준을 적는다: > "Every rule here is a mistake **whose runtime symptom is either silence or a misattributed failure**: a unary method with no deadline hangs until the client's, an unbounded executor turns overload into unbounded latency, **trust-all in production reports TLS while providing none**, reflection in production publishes the schema, and a keyed method without a ledger accepts idempotency keys it cannot honour. **None of them fails a smoke test.**" 그리고 "Fails once with every violation, so a deployment learns the whole list in one restart" — 한 번에 전부 보고하도록 설계돼 있다. CLAUDE.md가 인용한 "streaming method가 Stable catalog에 등록되면 거부"는 methods 그룹의 네 번째 규칙(`!policy.rpcType().stable()`)이고, §2.2의 runtime 강제는 advanced isolation 그룹의 유일한 규칙이다. **둘 다 실행되지 않는다.** 이 형태는 이 저장소에서 네 번째다 — 모듈 14 §44.2(`WebPlatformStartupValidator`), 모듈 17 §4.1(`WebSocketPlatformStartupValidator`), 모듈 19 §3.5(`KafkaTransactionProfileValidator`), 그리고 여기. 그리고 모듈 18에서 확립한 규칙이 다시 성립한다 — **시작 검증기가 도는지 여부는 그 능력에 자동설정 루트가 있는지와 일치한다**. 여기서는 루트가 **있는데도** 검증기를 부르지 않는 첫 사례다. **채택 시점 실패 시나리오.** 팀이 `runtime_memberships`에 런타임을 추가하고 `ca-skeleton.grpc.platform.enabled=true`로 켠다. Stable catalog에 client-streaming 메서드를 하나 등록한다(Stable 범위 밖이라는 것을 모른 채). 부팅은 성공한다. 그 메서드는 Stable이 보장하지 않는 경로로 실행되고, `grpc-advanced-streaming`의 세션·중복제거·체크포인트 기계는 조립돼 있지 않다. 거부했어야 할 검증기는 존재하고, 테스트도 12개 통과하며, 호출되지 않는다. 증거: `268-grpc-assembly-and-release-gate.txt` ### 3.2 P2 — 릴리스 게이트가 스스로 증거를 읽지 않는다. messaging이 이미 고친 모양을 되풀이한다 `docs/compatibility/grpc-support-matrix.md`: > "`GrpcCompatibilityMatrix.caSkeleton()`은 이 표의 machine-readable form이고, **`GrpcStableReleaseGate`가 certified lane에 결과가 없거나 실패하면 릴리스를 막는다.**" 게이트 자체의 설계는 훌륭하다 — `missingResults` + `missingGrades` + 스키마 판정 + 런북/ADR/지원매트릭스 존재를 합쳐 blocker 목록을 만들고, 문서 부재를 후속 과제가 아니라 **차단 사유**로 둔 근거까지 적는다: > "Documents are a blocker rather than a follow-up. ... shipping the behaviour and writing the runbook afterwards means the first person to meet it is the one who has to work it out at three in the morning." **그런데 게이트가 읽는 증거를 아무도 생산하지 않는다.** ``` new GrpcReleaseEvidence(...) 생성 지점: grpc-testkit/src/test/.../GrpcStableReleaseGateTest.java:28, 84, 103, 127 <- 전부 테스트 GrpcStableReleaseGate / GrpcCompatibilityMatrix 참조 파일: grpc-testkit/src/test/.../GrpcStableReleaseGateTest.java grpc-testkit/src/main/.../GrpcStableReleaseGate.java grpc-testkit/src/main/.../GrpcCompatibilityMatrix.java ``` `GrpcReleaseEvidence`는 record이고 그 다섯 성분 — `gradesRun`, `certifiedCapabilities`, `runbookPresent`, `architectureDecisionRecordsPresent`, `supportMatrixPresent` — 이 **전부 호출자가 넘기는 값**이다. `runbookPresent`는 파일 시스템을 보지 않고, `gradesRun`은 레인 출력에서 파생되지 않는다. `evaluate(...)`에 넘기는 `laneResults`도 `Map`으로 호출자가 만든다. **이것이 messaging 가족이 이미 고친 모양이다.** 모듈 19 §6.6이 인용한 `CompatibilityMatrix.Entry` javadoc: > "Read from the evidence rather than declared. **As a field it was a boolean an author set next to the tier**, and RabbitMQ carried `true` while no fault scenario had ever been executed against it." messaging은 그것을 세 층으로 닫았다 — (a) 레인이 `broker-certification-evidence.jsonl`을 **쓰고**, (b) `verifyMessagingCertificationEvidence` Gradle 태스크가 실행 산출물과 커밋본을 양방향 대조하며, (c) `messaging-certification.yml`이 `src/messaging/**` PR마다 그 게이트를 돌린다. gRPC 가족에는 (a)·(b)·(c) 어느 것도 없다: ``` src/grpc*/*/build.gradle 의 tasks.register → 0건 .github/workflows 28개 중 grpc를 언급하는 것 → 0건 ``` 즉 `GrpcStableReleaseGate`는 **자기 단위 테스트가 유일한 실행 경로인 클래스**다. 지원 매트릭스의 "릴리스를 막는다"는 현재 시제 문장이 그 상태를 서술하지 않는다. 이 가족은 messaging의 MSG-015를 반복하지 않는 것을 목표로 삼았고(§2.3에서 확인했듯 그 목표는 달성했다), **다른 교훈 하나를 옮겨 오지 않았다.** ### 3.3 P2 — 증거 등급 모델 전체가 자동 실행 경로 밖에 있고, CLAUDE.md는 현재 시제로 서술한다 `src/grpc/CLAUDE.md`: > "현재 in-process·Netty·fault lane은 **실제로 실행되어 통과하지만**, 실제 배포 환경에서의 soak·performance baseline은 없다." 앞 절반은 **사실이다.** 직접 돌려 확인했다: ``` ./gradlew :grpc:grpc-testkit:grpcInProcessContractTest \ :grpc:grpc-testkit:grpcNettyContractTest \ :grpc:grpc-testkit:grpcFaultTest → BUILD SUCCESSFUL, GRADLE_EXIT=0 grpcInProcessContractTest classes=1 tests=7 failures=0 skipped=0 grpcNettyContractTest classes=1 tests=9 failures=0 skipped=0 grpcFaultTest classes=1 tests=9 failures=0 skipped=0 ``` **문제는 "실행되어"의 주어다.** `ca.strict-test-lane.gradle`은 레인을 `verification` 그룹의 `Test` 태스크로 등록만 하고 `check`에 연결하지 않는다: ``` tasks.register(lane.name, Test) { group = 'verification'; ... } (check dependsOn 관련 라인 → 0건) ``` 그리고 CI에서 grpc를 이름으로 부르는 워크플로가 없다. `ci-quality-gates.yml`이 `./gradlew check`를 돌리므로 각 leaf의 **기본 `test`**는 CI에서 실행된다(classes=71 tests=579 failures=0 skipped=0으로 통과 확인). 그러나 **네 증거 레인은 `check`에 없고 어떤 워크플로도 이름으로 부르지 않는다.** 결과적으로 이 플랫폼의 CONTRACT/TRANSPORT/FAULT 등급을 뒷받침하는 것은 **25개 테스트**(7+9+9)이고, 그 25개는 누군가 명령을 직접 입력할 때만 돈다. 모듈 18 §4.1c가 `ConditionalTransportCompositionContractTest`의 javadoc에서 인용한 문장이 그대로 적용된다: > "**A gate that is red in a lane nobody runs locally is a gate that reports whatever the last person to run it saw.**" 차이는 이쪽 레인이 **오늘 초록**이라는 것이고, 그것을 확인한 방법이 내가 직접 돌린 것이라는 점이다. 자동화된 관찰자는 없다. *(비교: messaging의 인증 레인도 `test`에서 제외되지만, 전용 CI 워크플로가 게이트를 돌리고 게이트가 레인에 의존한다. gRPC 쪽은 제외만 있고 대체 경로가 없다.)* ### 3.4 P2 — 조립 경계가 정책 객체 9개를 만들고 서버를 만들지 않는다 `GrpcPlatformAutoConfiguration`이 등록하는 9개는 전부 **프로파일·정책·레지스트리**다. 서버도, 인터셉터 체인도, 서비스 어댑터 등록도 없다. 그리고 그것을 담당하는 타입들이 main 참조 0이다: | 타입 | leaf | 역할 (javadoc) | main 참조 | test 참조 | |---|---|---|---|---| | `GrpcServerInterceptorChain` | server | "Builds the server interceptor chain in the Stable order and hands it over in the order gRPC actually wants" | **0** | 1 | | `ProtovalidateGrpcInterceptor` | policy | 요청 검증 인터셉터 | **0** | 1 | | `GrpcIdempotencyInterceptor` | policy | 멱등성 인터셉터 | **0** | 1 | | `GrpcServiceAdapter` | server | typed service adapter SPI | **0** | 1 | | `GrpcRetryCoordinator` · `GrpcRetryOwnershipValidator` | policy | 재시도 소유권 | **0** | 1 | | `GrpcStreamAdmission` · `GrpcSerializedStreamWriter` · `GrpcStreamGapDetector` · `GrpcStreamLifecycleCoordinator` | policy | server streaming 단일 writer·갭 탐지 | **0** | 1 | | `GrpcDrainCoordinator` · `GrpcPlatformSnapshotService` | admin | drain·정책 스냅샷 | **0** | 1 | | `GrpcTypedStubFactory` · `GrpcClientCallContext` | client | typed stub·호출 컨텍스트 | **0** | 1 | `GrpcServerInterceptorChain`의 javadoc이 자기 존재 이유를 이렇게 적는다: > "That reversal is the reason this class exists rather than a list literal at the call site. `ServerInterceptors.intercept` wraps each interceptor around the previous one, so the last one passed is the outermost at runtime — the opposite of how the order reads. **Every codebase that builds this list by hand gets it backwards at least once, and the symptom is an exception boundary that catches nothing.**" 그 클래스를 조립에서 쓰는 곳이 없으므로, 채택자가 인터셉터 목록을 직접 만들면 그 javadoc이 서술한 실수를 그대로 하게 된다. **전체로 보면 260개 main 타입 중 73개가 main 참조 0이다.** 다만 이 숫자는 그대로 결함 수가 아니다 — build-only 라이브러리 가족에서 **공개 API 표면**(채택자가 부르는 타입)이 내부 참조를 갖지 않는 것은 정상이다. 위 표는 그중 **가족 내부의 다른 코드가 불러야 하는 조립·기계 타입**만 골라낸 것이다. ### 3.5 P3 — 저장소 어디에도 참조가 없는 타입 3개 `main = 0`이면서 `test = 0`인 것, 즉 선언 파일 외에 아무 곳에서도 이름이 등장하지 않는 타입: | 타입 | leaf | javadoc이 말하는 용도 | |---|---|---| | `ReactiveGrpcClient` | advanced-compat | "Exposes a unary call as a `Mono` and a server stream as a `Flux`" | | `ReactiveGrpcServerAdapter` | advanced-compat | "Runs a reactive use case behind a gRPC service adapter" | | `GrpcDeadlineExceededException` | **core-api** | "Its own type rather than a generic platform exception **because callers branch on it**" | 앞의 둘은 advanced 가족의 Reactor 표면이고 채택자가 부를 타입이므로 참조 0이 설계와 모순되지는 않는다 — 다만 **테스트도 0**이라 다른 advanced 타입들과 다르다(나머지 advanced 미참조 타입은 전부 `test=1`). 세 번째가 더 구체적이다. `GrpcDeadlineExceededException`은 Stable core-api에 있고, javadoc이 "callers branch on it"이라고 단정하는데 **던지는 코드도 잡는 코드도 테스트도 없다.** `requiresReconciliation()`이 "status code가 답할 수 없는 질문에 답한다"고 적혀 있고, 그 메서드를 부르는 곳이 없다. ### 3.6 P3/기록 — 가족 문서의 `grpc-discovery` 행이 UDS를 빠뜨린다 `src/grpc/CLAUDE.md`의 family 표: > `grpc-discovery` | Static/DNS resolver, pick_first/round_robin, Kubernetes routing profile 코드와 지원 매트릭스는 셋을 말한다: ```java // GrpcResolverType /** A Unix domain socket. One endpoint by construction. */ UNIX("unix", false); // GrpcDiscoveryPolicyValidator:65 "'; Stable schemes are dns, static and unix" ``` ``` docs/compatibility/grpc-support-matrix.md | Stable resolvers | Static, DNS, Unix domain socket | ``` CLAUDE.md 쪽이 덜 완전하다. 드리프트 방향이 **과소 진술**이므로(있는 능력을 빠뜨림) 위험은 낮다 — 모듈 19 §6.3의 P1은 반대 방향(없는 능력을 있다고 적음)이었다. 기록으로 남긴다. --- ## 4. 네 가지 필수 negative-space 탐침 ### 4.1 §8.1 도달성 §3.1·§3.4·§3.5가 결과다. 요약: main 참조 0인 260개 중 73개, 그중 **가족 내부가 불러야 할 조립·검증 타입 15종**이 §3.4 표와 §3.1이다. build-only 등급이 전부를 채택 시점 부채로 만든다. ### 4.2 §8.2 조건부 형제 비교 | 형제 쌍 | 차이 | 판정 | |---|---|---| | `GrpcPlatformAutoConfiguration`(9 bean 등록) vs `GrpcPlatformStartupValidator`(호출 0) | 같은 leaf, 같은 패키지, 4개 파일 중 하나만 조립에 연결 | **P2** §3.1 | | messaging 인증 게이트(레인→manifest→Gradle→CI 4층) vs grpc 릴리스 게이트(테스트 1층) | 같은 설계, 강제 층이 다름 | **P2** §3.2 | | messaging `messagingCertificationTest`(전용 CI 워크플로) vs grpc 4개 레인(`check` 밖, CI 0건) | 둘 다 `test`에서 제외, 대체 경로는 한쪽만 | **P2** §3.3 | | advanced 미참조 타입 대부분(`test=1`) vs Reactor 2종(`test=0`) | 같은 leaf 안의 검증 비대칭 | P3 §3.5 | | `grpc-core-api` 빈 `dependencies{}` vs `messaging-core-api`(deps 0이지만 build.gradle에 명시 없음) | 둘 다 framework-free, grpc 쪽이 더 강한 형태 | 결함 아님 §2.1 | ### 4.3 §8.3 중복 장치 쓸기 이 가족에서는 **중복 장치가 발견되지 않았다.** 확인한 축: - 상태 코드 번역: `GrpcStatusCode`(core-api, 미러) ↔ `GrpcStatusMapping`(policy, 양방향 번역) — CLAUDE.md가 "양방향 번역은 `grpc-policy`의 `GrpcStatusMapping`이 **단독으로 소유**한다"고 선언하고, 실제로 core-api에는 번역 코드가 없다. - 시작 검증: Stable 쪽 `GrpcPlatformStartupValidator` 1개, advanced 쪽 `GrpcAdvancedModuleGuard`·`GrpcXdsStartupGuard`·`GrpcServletStartupValidator`가 각 capability를 나눠 담당 — 겹치지 않는다. - 증거 등급: `GrpcEvidenceGrade` 하나가 등급을 소유하고 `GrpcReleaseEvidence.supports`가 그것을 재사용한다 — 두 번째 등급 어휘가 없다. 모듈 19에서 4건(접근 검사·자격 증명 회전·Kafka producer·인증 증거 검증)이 나온 것과 대비된다. ### 4.4 §8.4 문서·카운트 드리프트 | # | 주장 | 실제 | 판정 | |---|---|---|---| | 1 | 지원 매트릭스: "Not released … build-only" | 18 leaf 전부 `rt=[]` — **사실** | 결함 아님 §2.6 | | 2 | 지원 매트릭스: 7개 레인·등급표 | `GrpcCompatibilityMatrix.caSkeleton()`과 전수 일치 | 결함 아님 §2.6 | | 3 | 지원 매트릭스: Spring Boot 4.0.8 / Stable resolvers Static·DNS·UDS | `src/build.gradle:13` = 4.0.8, `GrpcResolverType`에 UNIX 존재 — 일치 | 결함 아님 | | 4 | 지원 매트릭스: "`GrpcStableReleaseGate`가 릴리스를 막는다" | 게이트를 호출하는 build·CI 경로 0 | **P2** §3.2 | | 5 | CLAUDE.md: "`GrpcPlatformStartupValidator`가 startup을 거부한다" | main 참조 0 | **P2** §3.1 | | 6 | CLAUDE.md: "in-process·Netty·fault lane은 실제로 실행되어 통과한다" | 돌리면 통과(검증함). 자동으로 도는 경로는 없음 | **P2** §3.3 | | 7 | CLAUDE.md: `grpc-discovery` = Static/DNS | 코드·지원매트릭스는 Static/DNS/UDS | P3 §3.6 | | 8 | `state.json` 44 스코프 vs 레지스트리 62 모듈 | 리비전 이동(`a24ece9c` → `21234e38`)이 원인. 이 문서로 해소 | 기록 §0 | --- ## 5. 발견 종합 — P1 0건 · P2 10건 · P3 3건 | 심각도 | § | 발견 | 위치 | |---|---|---|---| | **P2** | 3.1 | `GrpcPlatformStartupValidator`가 유일한 조립 지점에서 호출되지 않는다 (main 참조 0) | `GrpcPlatformAutoConfiguration` | | **P2** | 3.2 | 릴리스 게이트가 읽는 증거를 아무도 생산하지 않는다 — Gradle 태스크 0, CI 워크플로 0. messaging이 이미 닫은 모양의 재발 | `GrpcStableReleaseGate` · `GrpcReleaseEvidence` | | **P2** | 3.3 | 증거 등급 모델(25개 테스트)이 `check` 밖·CI 밖이고, 문서는 현재 시제로 서술한다 | `ca.strict-test-lane.gradle` · `grpc-testkit/build.gradle` | | **P2** | 3.4 | 조립 경계가 정책 객체 9개만 만들고, 인터셉터 체인·서비스 어댑터·스트리밍 기계가 전부 미조립 | `GrpcPlatformAutoConfiguration` | | **P2** | 7.1 | **`GrpcAdmissionController.tryAdmit()` check-then-act** — 조립되는 9개 bean 중 하나이고, 부하 아래에서 지키라고 만든 동시성 경계가 부하 아래에서 샌다. `release()`는 카운터를 음수로 만들 수 있다 | `grpc-server` | | **P2** | 7.2 | `GrpcStreamAdmission`도 같은 TOCTOU. 추가로 `perCaller` 맵이 caller fingerprint마다 자라고 제거되지 않는다 | `grpc-policy` | | **P2** | 7.3 | `GrpcSerializedStreamWriter`의 `DROP_OLDEST`가 **버려지는 메시지가 아니라 들어오는 메시지의 바이트**를 뺀다. 봉투가 크기를 담지 않아 알 방법이 없고, 테스트는 고정 크기 sizer라 결함이 보이지 않는다 | `grpc-policy` | | **P2** | 7.4 | `GrpcCredentialRotationManager`가 CAS 없이 read-then-write — 동시 회전 시 한 세대가 드레인 없이 사라진다. **messaging이 `CredentialRotationContractTest`로 닫은 결함의 재현** | `grpc-policy` | | **P2** | 7.5 | `GrpcOutcomeReplay`가 제거·TTL·개수 상한이 하나도 없는 인메모리 저장소. 커밋한 멱등 연산마다 영구 적재 | `grpc-policy` | | **P2** | 7.6 | `GrpcCompletionReconciler.pending`이 요청 경로에서 동기화 없이 변경되는 `ArrayList` | `grpc-policy` | | P3 | 3.5 | 저장소 어디에도 참조가 없는 타입 3개 (`ReactiveGrpcClient`·`ReactiveGrpcServerAdapter`·`GrpcDeadlineExceededException`) | advanced-compat · core-api | | P3 | 3.6 | 가족 문서의 `grpc-discovery` 행이 UDS resolver를 빠뜨린다 | `src/grpc/CLAUDE.md` | | P3/기록 | 0 | 분석 기준선 이후 리비전이 이동해 18개 모듈이 `state.json` 밖에 있었다 | `state.json` | **P1이 0인 이유는 명확하다** — 18개 leaf 전부 `runtime_memberships: []`이고, 그 사실을 운영 문서가 정확히 공시한다(§2.6). 어떤 배포도 이 코드를 싣지 않으므로 "지금 틀린 동작"이 성립하지 않는다. 다만 §7.1의 `GrpcAdmissionController`는 조립되는 9개 bean 중 하나이므로, 채택하는 날 가장 먼저 청구되는 부채다. ### 5.1 검증된 설계 — 8건 1. `grpc-core-api`의 **빈 `dependencies {}`** — framework-free가 문서가 아니라 클래스패스로 강제됨 (§2.1) 2. Stable → advanced 금지가 레지스트리·소스·빌드·런타임 네 층에서 일치 (§2.2) 3. `adapter-inbound-grpc`가 이 가족을 볼 수 없다 — messaging MSG-015의 재발 방지가 실제로 성립 (§2.3) 4. `GrpcEvidenceGrade`가 in-process 결과로 전송 능력을 주장하는 것을 런타임에 거부 (§2.4) 5. 성능 레인을 기본 `test`에서 제외하고 그 이유를 적음 (§2.5) 6. 지원 매트릭스가 "Not released / build-only"와 미해결 게이트 입력 2건을 스스로 공시 (§2.6) 7. `GrpcCompatibilityMatrix` ↔ 문서 표 전수 일치 (§2.6) 8. 중복 장치 0 — 상태 번역·시작 검증·증거 등급 모두 단일 소유자 (§4.3) ### 5.2 이 가족의 성격 — 계약은 강하고 조립은 아직 없다 이 가족은 messaging을 **명시적으로 참조하며** 만들어졌다. `src/grpc/CLAUDE.md`가 "`messaging:*`의 MSG-015(bridge 부재)를 반복하지 않는 것이 이 family의 목표"라고 적고, 실제로 그 목표는 달성했다 — `adapter-inbound-grpc`가 이 가족에 의존하지 않도록 레지스트리가 막고 있고, 그 사실을 문서가 정확히 말한다. 그런데 옮겨 오지 않은 교훈이 하나 있다. messaging이 값비싸게 배운 것은 "**게이트는 자기가 검사할 증거를 스스로 읽어야 하고, 그 게이트를 CI가 돌려야 한다**"였다(모듈 19 §2.3·§6.6). gRPC 가족은 그 게이트의 *설계*를 더 정교하게 만들었으면서(4등급 증거, 문서 부재를 blocker로) *강제 배선*은 만들지 않았다 — Gradle 태스크 0, CI 워크플로 0, `check` 연결 0. 그래서 이 가족의 조립 층 P2 네 건은 전부 같은 축에 있다: **판정하는 코드는 잘 만들어졌고, 그것을 부르는 코드가 없다.** §3.1(시작 검증기), §3.2(릴리스 게이트), §3.3(증거 레인), §3.4(인터셉터·어댑터)가 모두 그 형태다. build-only 등급이 오늘의 사고를 막고 있고, 채택하는 날 그 넷이 동시에 부채로 청구된다. **그리고 §7이 같은 판정을 구현 층에서 반복한다.** 조립 층의 형태가 "부르는 코드가 없다"였다면 구현 층의 형태는 "원자적으로 하지 않는다"다 — `AtomicInteger`/`AtomicReference`를 쓰면서 `compareAndSet`을 쓰지 않는 것이 세 곳, 경계를 선언하고 유지 장치를 두지 않은 것이 두 곳이다. 그리고 두 층 모두, **정확한 참조 구현이 같은 가족 안에 이미 있다.** --- ## 6. 검증 ### 6.1 테스트 레인 ``` ./gradlew (18개 grpc leaf의 :test 전량) --console=plain → BUILD SUCCESSFUL in 1m 12s · 81 actionable tasks · GRADLE_EXIT=0 XML 집계: classes=71 tests=579 failures=0 errors=0 skipped=0 ./gradlew :grpc:grpc-testkit:grpcInProcessContractTest \ :grpc:grpc-testkit:grpcNettyContractTest \ :grpc:grpc-testkit:grpcFaultTest --console=plain → BUILD SUCCESSFUL · GRADLE_EXIT=0 grpcInProcessContractTest classes=1 tests=7 failures=0 skipped=0 grpcNettyContractTest classes=1 tests=9 failures=0 skipped=0 grpcFaultTest classes=1 tests=9 failures=0 skipped=0 ``` Netty 레인이 실제 소켓을 열고 통과한다 — 컨테이너 안에서도 재현된다. **돌리지 않은 레인:** `grpcPerformanceTest`. 공유 러너에서의 측정이 flaky 게이트가 된다는 이유로 `test`에서 제외돼 있고(§2.5), 분석 컨테이너의 측정값은 baseline이 될 수 없다. ### 6.2 소스 트리 변경 없음 ``` git status --short → (출력 없음) ``` ### 6.3 커버리지 원장 **이 문서의 읽기 깊이는 앞선 모듈들보다 얕다.** 그 사실을 숫자로 적는다. `FULL_READ`는 파일 전문 또는 그에 준하게 읽은 것만 센다. 나머지는 전부 `STRUCTURAL_ONLY`이며, 그 근거는 (a) 파일·패키지 전수 목록, (b) 260개 main 타입 **전수 도달성 스윕**(선언 파일 제외 참조 수를 main/test로 분리 계수), (c) 레지스트리·`build.gradle`·`.imports` 전수 판독, (d) 18 leaf `:test` 전량 + 증거 레인 3종 실행이다. 즉 **조립·경계·거버넌스 층은 전수로 확인했고, 각 leaf의 구현 내부는 읽지 않았다.** | sub-scope | leaf | 파일 | FULL_READ | 전문으로 읽은 것 | |---|---|---|---|---| | 01 core contracts | 3 | 68 | **3** | `grpc-core-api/build.gradle` · `GrpcStatusCode`(javadoc+상수) · `GrpcDeadlineExceededException`(헤더) | | 02 policy | 1 | 82 | **21** | idempotency 4 · streaming 5 · resilience 3 · security 2 · deadline 1 · validation/policy/error/context 전수 스윕 + 후보 본문 확인 (§7) | | 03 server·client·discovery | 3 | 54 | **5** | `GrpcServerInterceptorChain`(헤더+javadoc) · `GrpcResolverType`/`GrpcDiscoveryPolicyValidator`(해당 행) | | 04 admin·observability·ledger·조립 | 4 | 41 | **6** | `GrpcPlatformAutoConfiguration`(106줄 전문) · `GrpcPlatformStartupValidator`(188줄 전문) · `.imports` | | 05 testkit·증거·릴리스 | 1+1 | 37 | **5** | `GrpcEvidenceGrade` · `GrpcStableReleaseGate` · `GrpcReleaseEvidence` · `grpc-testkit/build.gradle` · `src/grpc/CLAUDE.md` | | 06 advanced | 6+1 | 101 | **5** | `src/grpc-advanced/CLAUDE.md` · `ReactiveGrpcClient`(헤더) · `ReactiveGrpcServerAdapter`(헤더) | | | **18** | **383** | **45** | | `STRUCTURAL_ONLY` 338 · EXCLUDED 0 · 미배정 0. *(2026-08-31 보강: 최초 기재는 FULL_READ 16이었다. §7의 구현 내부 판독으로 29개가 추가됐다 — `grpc-policy` 21, 나머지 leaf 8. leaf 귀속분 43 + 가족 거버넌스 문서 2 = 45.)* **이 깊이로 확정할 수 있는 것과 없는 것:** - **확정됨** — §2(공개 주장 6건), §3.1~§3.4(조립·릴리스 게이트·증거 레인·인터셉터 미조립), §3.5(참조 0 타입), §4.1~§4.4. 이 판정들은 전부 도달성·조립·빌드 구성에 대한 것이고 근거가 전수다. - **§7로 확정됨** — `grpc-policy`의 동시성·경계 층. 6건이 나왔고 2건은 검증 중 철회했다(§7.7). - **여전히 확정되지 않음** — 읽기의 초점이 동시성과 경계였으므로, 각 leaf의 **도메인 로직 정확성**은 그 초점 밖이다. 구체적으로 `grpc-proto-contract`의 스키마 규칙 판정(3 main / 605 LOC), `grpc-codegen`의 매니페스트 해시 규약, `grpc-advanced-resilience`의 hedging 적격성·xDS 실패 정책, `grpc-advanced-compat`의 Servlet/gRPC-Web 프로파일 판정은 구조와 도달성만 확인했다. 비교를 위해: 모듈 19(messaging)는 550 파일에 1,284줄 문서, 모듈 14(web)는 638 파일에 1,702줄이었다. 이 문서는 383 파일에 (§7 보강 후) 약 700줄이다. ### 6.4 증거 `evidence/raw/266`–`268` (3개 신규). `264`·`265`는 교차 스코프 준비 중 생성됐고, `264`는 리비전 이동 **이전** 트리 상태에서 만들어져 `.imports` 7개·leaf 44개를 담고 있다 — 이동 이후 값은 `.imports` 8개·leaf 62개이며 `265`와 `268`이 현재 상태를 담는다. --- ## 7. 구현 내부 판독 (2026-08-31 보강) §6.3이 미독으로 기록한 층 — `grpc-policy` 62 main / 4,781 LOC, `grpc-server`·`grpc-client`·`grpc-admin`의 상태 보유 클래스, `grpc-advanced-streaming`·`-bootstrap` — 을 읽었다. **동시성과 경계(bound)에 초점을 두었다.** 앞선 모듈에서 상태·펜싱·순서 결함이 나온 층이 정확히 여기이기 때문이다. 방법: (a) 가족 전체 main 소스에서 mutable 컬렉션·카운터를 보유한 클래스를 전수 추출하고 각 파일의 동기화 마커 수를 병기, (b) `get()` 비교 후 `increment/set`을 수행하는 check-then-act 패턴 전수 추출, (c) 키가 늘기만 하고 제거가 없는 컬렉션 전수 추출. 그 뒤 각 후보를 본문으로 확인했다. ### 7.1 P2 — `GrpcAdmissionController.tryAdmit()`의 동시성 경계가 동시성 아래에서 성립하지 않는다 **이 클래스는 조립된다** — `GrpcPlatformAutoConfiguration`의 9개 bean 중 하나(`grpcAdmissionController`)다. ```java public Decision tryAdmit() { int running = inFlight.get(); if (running < maxConcurrentCalls) { inFlight.incrementAndGet(); // 검사와 증가 사이가 열려 있다 return new Decision(true, ...); } int waiting = queued.get(); if (waiting < maxQueuedCalls) { queued.incrementAndGet(); return new Decision(true, ...); } ... } ``` `AtomicInteger`를 쓰지만 **원자적 연산은 하나도 하지 않는다.** `get()`으로 읽고 비교한 뒤 별도로 `incrementAndGet()`한다. 경계에 있는 N개 스레드가 모두 같은 `running`을 읽고 모두 통과해 모두 증가시킨다 — `inFlight`가 `maxConcurrentCalls`를 최대 N−1만큼 초과한다. 클래스 javadoc이 존재 이유를 이렇게 적는다: > "Rejecting with `RESOURCE_EXHAUSTED` is a better outcome than queueing for two reasons that both matter **under load**... A server that queues instead spends its capacity finishing requests nobody is reading." **부하 아래에서 지키라고 만든 경계가 부하 아래에서 새는 구조다.** 동시 요청이 없을 때는 정확하고, 있을 때 부정확하다. 해제 쪽도 같다: ```java public void release() { if (inFlight.get() > 0) { inFlight.decrementAndGet(); } } ``` `inFlight == 1`일 때 두 스레드가 동시에 `release()`하면 둘 다 `> 0`을 통과해 둘 다 감소시켜 **−1**이 된다. 그 뒤로는 `running < maxConcurrentCalls`가 한 칸 더 쉽게 통과하므로 경계가 영구적으로 느슨해진다. `promoteFromQueue()`는 한 단계 더 나아간다 — `queued`를 줄이고 `inFlight`를 늘리면서 **`inFlight`를 경계와 대조하지 않는다.** 큐에서 승격되는 호출은 동시성 한도를 무조건 통과한다. **같은 가족이 올바른 형태를 이미 갖고 있다.** `GrpcRetryBudget`은 정확한 CAS 루프다: ```java public boolean tryConsume() { while (true) { long observed = tokens.get(); if (observed < tokensPerRetry) return false; if (tokens.compareAndSet(observed, observed - tokensPerRetry)) return true; } } ``` `grpc-policy`의 예산은 CAS로 닫혀 있고 `grpc-server`·`grpc-policy`의 두 admission은 check-then-act다. ### 7.2 P2 — `GrpcStreamAdmission`도 같은 형태이고, per-caller 맵이 줄지 않는다 ```java public boolean tryAdmit(String callerFingerprint) { AtomicInteger callerCount = perCaller.computeIfAbsent(callerFingerprint, key -> new AtomicInteger()); if (callerCount.get() >= maxStreamsPerCaller) return false; if (openStreams.get() >= maxConcurrentStreams) return false; callerCount.incrementAndGet(); openStreams.incrementAndGet(); return true; } ``` §7.1과 동일한 TOCTOU이고, 이쪽은 javadoc이 서술하는 실패 시나리오가 곧 고동시성 상황이다: > "Without a bound, **a client that reconnects on every error opens streams faster than the old ones close.**" 재접속 폭풍은 정의상 동시 요청이 몰리는 상황이고, 그때 경계가 가장 많이 샌다. `release()`도 `get() > 0` 후 `decrementAndGet()`이라 음수로 갈 수 있다. **그리고 `perCaller`에서 엔트리가 제거되지 않는다.** `computeIfAbsent`가 caller fingerprint마다 `AtomicInteger`를 하나 만들고, `release()`는 값을 줄일 뿐 키를 지우지 않는다. 서로 다른 caller 수만큼 맵이 자라고 줄지 않는다 — 이 가족이 `GrpcMetricCardinalityPolicy`로 지표 태그에 대해 명시적으로 막는 것과 같은 종류의 증가이며, 여기에는 그 가드가 없다. ### 7.3 P2 — `GrpcSerializedStreamWriter`의 `DROP_OLDEST`가 잘못된 메시지의 바이트를 뺀다 ```java case DROP_OLDEST -> { GrpcStreamEnvelope dropped = queue.pollFirst(); if (dropped != null) { queuedBytes = Math.max(0L, queuedBytes - nextBytes); // nextBytes = 들어오는 메시지 크기 droppedMessages++; } enqueue(kind, payload, snapshotVersion, resumeToken, nextBytes); yield GrpcStreamWriteResult.DROPPED; } ``` 버려지는 것은 `dropped`인데 빼는 값은 **새로 들어오는 메시지의 크기 `nextBytes`**다. `GrpcStreamEnvelope`는 7개 성분(`streamId`·`sequence`·`kind`·`snapshotVersion`·`resumeToken`·`terminationReason`·`payload`) 중 **크기를 담지 않으므로**, 이 지점에서 버려지는 메시지의 크기를 알 방법이 애초에 없다. `queuedBytes`는 장식이 아니라 판정 입력이다: ```java // GrpcFlowControlPolicy.decide boolean overflowsBytes = queuedBytes + nextMessageBytes > maxQueuedBytes; ``` 그리고 그 바이트 경계의 존재 이유가 javadoc에 있다: > "Both a message count and a byte count, because either alone is unbounded in the other dimension: **a thousand-message bound with no byte bound is a memory limit set by the largest message anyone ever sends.**" **실패 시나리오.** `DROP_OLDEST` 프로파일 + 가변 크기 메시지. 10바이트 메시지를 버리며 10,000바이트를 넣으면 `queuedBytes`는 10,000을 빼고 10,000을 더해 **변화 없음**인데 실제 큐는 9,990바이트 늘었다. 반복되면 `queuedBytes`가 실제보다 계속 낮아지고(`Math.max(0, ...)`로 0에서 멈춘다) 바이트 경계가 발화하지 않게 되어, 큐는 메시지 수 경계까지 임의 크기 메시지로 채워진다 — 바이트 경계가 막으려던 바로 그 상태다. 반대 방향(큰 것을 버리고 작은 것을 넣음)에서는 과대 계상돼 조기 TERMINATE가 된다. **테스트가 이 결함을 볼 수 없는 구성으로 되어 있다.** `GrpcSerializedStreamWriterTest`의 lossy 케이스는 ```java writer(new GrpcFlowControlPolicy(1, 1024L, 1, GrpcSlowConsumerPolicy.DROP_OLDEST), 8L) ``` — sizer가 상수 `8L`이라 모든 메시지 크기가 같고, `maxQueuedMessages=1`이라 발화하는 것은 **개수 경계**다. 크기가 같으면 잘못된 뺄셈이 우연히 옳은 값이 된다. *(Stable 기본값 `GrpcFlowControlPolicy.stable()`은 `TERMINATE`이므로 기본 경로는 영향을 받지 않는다. `DROP_OLDEST`는 opt-in 손실 허용 프로파일이다.)* ### 7.4 P2 — `GrpcCredentialRotationManager`가 CAS 없이 read-then-write 한다. messaging이 고친 결함의 재현이다 ```java public RotationPlan rotate(GrpcCredentialGeneration next, Instant now) { State observed = state.get(); // :89 ... state.set(new State(next, observed.current(), deadline)); // :103 } public void completeDrain() { State observed = state.get(); // :119 state.set(new State(observed.current(), null, null)); // :120 } ``` `AtomicReference`를 쓰지만 `compareAndSet`·`updateAndGet`이 **한 번도 없고** `synchronized`도 없다. 순수한 홀더로만 쓰인다. 두 회전이 동시에 일어나면 둘 다 같은 `observed`를 읽고 둘 다 `supersededBy`를 통과해 둘 다 `set`한다. 나중 것이 앞선 것을 덮으므로 **한 세대가 `draining`에 오르지 못한 채 사라진다** — 그 세대 위의 in-flight 호출은 추적되지도, 드레인되지도 않는다. `completeDrain()`과 `rotate()`가 겹치면 새로 draining이 된 세대가 즉시 잊힌다. **이 클래스의 javadoc이 그 경합을 이미 알고 있다:** > "@throws IllegalArgumentException when `next` does not supersede the current generation — a rotation that goes backwards would reactivate material that was already replaced, and **the usual reason for one is two rotators racing**" 경합의 존재를 적어 두고, 그 경합을 닫는 연산은 쓰지 않았다. **그리고 이것은 messaging이 이미 고친 결함이다.** 모듈 19 §5.4가 인용한 `CredentialRotationContractTest`: > "`resolve` was get → fetch → put → clear **with no synchronization**. Two callers rotating the same credential both read the same old runtime and both fetched a replacement: **one replacement was dropped from the map without ever being cleared — a secret left in memory that nothing owns** — and the loser could clear material the winner was still using." 같은 주제(자격 증명 회전), 같은 결함 형태(공유 상태에 대한 read-then-write), 한 가족은 동시성 계약 테스트까지 만들어 닫았고 다른 가족이 재현했다. `grpc-policy`의 테스트 16개 중 동시성을 다루는 것은 없다. ### 7.5 P2 — `GrpcOutcomeReplay`가 제거 경로 없는 인메모리 저장소다 ```java private final ConcurrentMap storedOutcomes = new ConcurrentHashMap<>(); ``` - `maxInlineBytes`는 **엔트리 하나의 크기**를 제한한다. 엔트리 **개수**를 제한하는 것은 없다. - `remove`·`clear`·evict·TTL이 **하나도 없다**(전수 grep 0건). `size()`만 있고 그 값을 읽는 곳도 없다. - `store()`는 `IDEMPOTENCY_KEY_REQUIRED` 메서드가 커밋될 때마다 호출되므로, 프로세스 수명 동안 **커밋한 멱등 연산 수만큼** 엔트리가 쌓인다. javadoc은 "a small inline store"라고 부르지만 작게 유지하는 장치가 없고, 크기를 넘는 응답은 거부하면서("store it behind an object reference instead") 개수는 거부하지 않는다. **비교 대상이 같은 leaf 안에 있다.** `GrpcClientMessageDeduplicator`는 정확히 이 문제를 피하려고 설계됐고 그 이유를 적는다 — "A set grows without bound for the life of a session... a monotonic applied-sequence answers it in constant space" — 그리고 `endSession()`으로 두 맵을 모두 정리한다. 같은 가족에서 한쪽은 정리하고 한쪽은 하지 않는다. ### 7.6 P2 — `GrpcCompletionReconciler`가 요청 경로에서 동기화 없는 `ArrayList`를 변경한다 ```java private final List pending = new ArrayList<>(); // :25 ... pending.add(new PendingCase(...)); // reconcile(...) 안 — 요청 경로 List.copyOf(pending); // pendingCases() pending.remove(resolved); // clearPending(...) ``` `synchronized`·`Concurrent*`·`volatile`·`Lock` **전부 0건**이고, 단일 스레드 전용이라는 javadoc 표기도 없다. 이 leaf에서 스레드 안전성을 명시적으로 다루는 유일한 클래스는 `GrpcSerializedStreamWriter`이며(그쪽은 9개 마커로 제대로 닫혀 있다), 그 사실이 이 leaf가 동시성을 인지하고 있음을 보여준다. `reconcile(...)`은 완료 결과가 불확실한 호출마다 불린다 — 장애 상황에서 동시에 몰리는 경로다. `ArrayList`에 대한 동시 `add`는 원소 유실 또는 `ArrayIndexOutOfBoundsException`이고, `add` 중의 `List.copyOf`는 `ConcurrentModificationException` 또는 null 원소로 인한 NPE다. 그리고 `pending`이 담는 것은 **결과를 알 수 없어 사람이 조정해야 하는 연산 목록**이므로, 유실은 조정되지 않은 채 잊히는 연산이 된다. ### 7.7 검증 중 철회한 판정 2건 읽기 전 후보로 잡았다가 본문 확인 후 취소한 것들이다. 기록해 둔다. | 후보 | 왜 취소했나 | |---|---| | `GrpcChannelRuntime.draining`이 비-volatile이라 드레인 신호가 요청 스레드에 안 보일 수 있다 | **`private volatile boolean draining`** — 이미 volatile이다. 남는 것은 `release` 계열의 check-then-act뿐이고, 그 경우 카운터가 음수가 되면 `quiescent()`가 영원히 false가 되어 드레인이 **끝나지 않는** 쪽으로 실패한다(조기 완료가 아니라). 호출자의 이중 해제를 전제하므로 별도 결함으로 세지 않는다 | | `GrpcClientMessageDeduplicator`의 두 `ConcurrentMap`이 무한 증가한다 | **`endSession(sessionId)`이 `checkpoints.remove(...)`와 `replayableOutcomes.keySet().removeIf(...)`로 둘 다 정리한다.** 자동 스윕에서 제외로 잡힌 것이 맞았고 내가 과독했다 | ### 7.8 확인된 올바른 설계 (구현 층) 1. **`GrpcResumeTokenCodec`** — 상수 시간 비교(`MessageDigest.isEqual`), 알 수 없는 key id를 현재 키로 폴백하지 않고 거부("turns key rotation into a window in which a token signed by a compromised key still verifies"), malformed·unknown key·verify 실패를 **구별 불가능하게** 반환("telling them apart is a probing oracle"). 그리고 `GrpcResumeToken.requireBounded`가 모든 문자열 필드에서 구분자 `|`를 명시적으로 거부해 인코딩/디코딩 비대칭이 생기지 않는다. 2. **`GrpcCancellationCoordinator`** — 5개 메서드 전부 `synchronized`. 등록이 취소 이후 실패하는 것을 "no new external side effect after cancel"의 구현으로 삼고, `markCommitBoundaryCrossed()`로 커밋 이후 취소를 abort로 오해하지 않게 분리한다. 3. **`GrpcRetryBudget`** — 정확한 CAS 루프. 성공이 토큰을 상한까지 회복시켜 "실패가 전면화되면 재시도가 사실상 0으로 수렴"하는 성질을 만든다. 4. **`GrpcRetryCoordinator`** — 검사 순서가 고정(자격 → 설정 → status → 시도 수 → 남은 데드라인 → 백오프 후 잔여 → 예산)이고, **예산을 마지막에 소모**한다. 어차피 거부할 재시도에 예산을 쓰지 않는다. 5. **`GrpcRetryOwnershipValidator`** — 서비스 설정의 메서드 이름을 카탈로그와 대조한다. "rename `CreateDocument` to `CreateDocumentV2` and the entry stops matching, silently." 6. **`GrpcMetricCardinalityPolicy.retryBucket(attempts)`** — 시도 횟수를 버킷으로 접어 태그 카디널리티를 제한한다. ### 7.9 이 층의 성격 여섯 건 중 **넷이 같은 형태**다 — `AtomicInteger`/`AtomicReference`를 쓰면서 원자적 연산을 하지 않는 것(§7.1·§7.2·§7.4)과, 경계를 선언하고 그 경계를 유지하는 장치를 두지 않는 것(§7.2의 per-caller 맵·§7.5). 그리고 같은 가족 안에 **정확한 참조 구현이 이미 있다** — 예산은 CAS 루프로, 스트림 라이터는 `synchronized`로, 중복제거기는 `endSession`으로 닫혀 있다. §5.2가 조립 층에 대해 내린 판정이 구현 층에도 그대로 적용된다: **판정하는 코드는 잘 만들어졌고, 그것을 정확히 실행하는 부분이 빠져 있다.** 조립 층에서는 "부르는 코드가 없다"였고, 구현 층에서는 "원자적으로 하지 않는다"다. 여전히 P1이 없는 이유는 §5와 같다 — 18 leaf 전부 `runtime_memberships: []`이고 어떤 배포도 이 코드를 싣지 않는다. 다만 §7.1의 `GrpcAdmissionController`는 **조립되는 9개 bean 중 하나**이므로, 채택 시점에 가장 먼저 청구되는 부채다.