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>
This commit is contained in:
DongHyeonka
2026-09-04 22:51:59 +09:00
co-authored by Claude Opus 5
parent 43bccd08a8
commit b2963105a8
5017 changed files with 372751 additions and 4943 deletions
@@ -0,0 +1,716 @@
# 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<String> 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<String, Boolean>`으로 호출자가 만든다.
**이것이 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<C,R>` | 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`를 최대 N1만큼 초과한다.
클래스 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<T> 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<String, byte[]> 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<PendingCase> 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 중 하나**이므로, 채택 시점에 가장 먼저 청구되는 부채다.