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,253 @@
# grpc-admin 완전 해부
> 상태: COMPLETE
> 재오픈 게이트: cycle 2 — `src/main` production 12파일 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음.
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/grpc/grpc-admin`
> SSOT owner: `grpc-admin`
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지
- `allowed_dependencies`: `["grpc-core-api", "grpc-server"]`
- `runtime_memberships`: **`[]`** — build-only
| 파일 | LOC |
|---|---:|
| `GrpcDrainCoordinator` | 162 |
| `GrpcServiceHealthRegistry` | 154 |
| `GrpcPlatformSnapshotService` | 115 |
| `GrpcReflectionPolicy` | 74 |
| `GrpcPlatformSnapshot` · `GrpcAdminExposurePolicy` | 71 · 71 |
| `GrpcHealthPolicy` · `GrpcDrainResult` · `GrpcDrainPolicy` | 49 · 48 · 43 |
| `GrpcHealthState` · `GrpcReflectionMode` | 38 · 32 |
| `GrpcReflectionAccessDecision` · `GrpcDrainPhase` | 28 · 28 |
| test 4파일 | 490 |
### Coverage ledger
| scope | count | disposition | reason |
|---|---:|---|---|
| `main/java/**` | 12 | `FULL_READ` | 전 본문 축자 확인 |
| `test/java/**` | 4 | `FULL_READ` | 490줄 |
| `build.gradle` | 1 | `FULL_READ` | 8줄 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
`UNCLASSIFIED` 0.
---
## 1. 모듈의 정체
```groovy
// build.gradle:3-4
// Operational surface: the standard health registry, the reflection exposure policy, the drain
// coordinator, and the secret-free runtime policy snapshot an administrator reads.
```
## 2. 건강 레지스트리 — 낙관에서 시작하지 않는다
모든 등록 서비스가 `UNKNOWN` 에서 시작한다.
> "A registry that starts optimistic reports ready during startup, receives traffic before the first
> dependency check has run, and fails the requests that arrive in that window — the window being
> exactly the moment a rollout is shifting traffic onto the instance."
그리고 배수 중에는 `markServing`·`markNotServing` 이 무시된다.
> "a service that reports itself healthy after the drain has started would be routed traffic the
> instance has already promised not to take."
전역 상태 계산은 세 단계다 — 임계 의존이 하나라도 불건강하면 `NOT_SERVING`, 아니면 하나라도 `NOT_SERVING` 이면 `NOT_SERVING`, 하나라도 `SERVING` 이면 `SERVING`, 그 밖에는 `UNKNOWN`.
## 3. 배수 순서
```
READINESS_FALSE → HEALTH_DRAINING → REJECT_NEW_ADMISSION → DRAIN_UNARY → SIGNAL_STREAMS → FORCE_CANCEL
```
`beginDrain` 이 앞의 둘을 한 번에 수행하고, 그 전에 `rejectNewAdmission` 을 부르면 던진다.
> "refusing calls before readiness has flipped produces errors for traffic that routing is still
> sending"
조정자는 잠들지 않는다.
> "It is given the current moment and the counts, and returns whether the phase is done; the waiting
> belongs to the caller, which is what makes every branch of this testable without a clock."
예산은 누적이다 — 스트림 신호 완료 판정이 `unaryDrainBudget + streamSignalBudget` 을 기준으로 한다.
## 4. 두 게이트 규칙이 세 곳에 같은 형태로 있다
| 타입 | 두 게이트 |
|---|---|
| `GrpcReflectionPolicy`(ADMIN_ONLY) | 관리 네트워크 + 관리 역할 |
| `GrpcAdminExposurePolicy` | 〃 |
| `GrpcChannelDiagnosticsPolicy`(grpc-advanced-diagnostics) | 〃 |
> "a role check alone lets an admin credential leaked to the public network enumerate the schema,
> and a network check alone lets anyone who reaches the admin network do it."
그리고 반사 가시성과 메서드 인가를 분리한다.
> "A method that reflection reveals is not thereby callable, and a method reflection hides is not
> thereby protected. Conflating the two produces a schema treated as a secret and an authorization
> check nobody wrote."
## 5. 스냅숏
권한이 없으면 편집본이 아니라 빈 값을 돌려준다.
> "Empty rather than a redacted snapshot: a partial answer tells an unauthorized caller which
> services exist."
내용이 아니라 해시를 싣는다. 그리고 판본과 시각을 필수로 요구한다 — 사고 중의 질문은 "무엇이 도는가" 가 아니라 "무엇이 바뀌었는가" 다.
`driftAgainstRelease` 가 양방향을 본다 — 릴리스에 있고 인스턴스에 없는 채널, 인스턴스에 있고 릴리스에 없는 채널을 모두 보고한다.
## 10. 테스트 레인
네 테스트 490줄. 배수 순서와 예산, 건강 전이, 반사 결정, 스냅숏 게이트와 표류를 확인한다.
## 12. negative-space probes
**12.1 도달성.** build-only. 이 리프의 타입 중 셋(`GrpcServiceHealthRegistry`·`GrpcReflectionPolicy`·`GrpcAdminExposurePolicy`·`GrpcDrainPolicy`)은 `grpc-spring-boot-starter` 자동 설정이 빈으로 만든다. `GrpcDrainCoordinator`·`GrpcPlatformSnapshotService` 는 만들지 않는다.
**12.2 대조군.** 비밀 필드 패턴이 두 리프에 따로 있다 — 이쪽의 `SECRET_FIELD`(9종)와 `grpc-advanced-diagnostics``SENSITIVE_FIELD`(10종). 겹치지만 같지 않다. 이쪽에는 `api[_-]?key`·`passphrase` 가 있고 저쪽에는 `payload`·`metadata`·`trace_id` 가 있다. 두 표면이 다르므로 목록이 다른 것 자체는 합리적이다.
**12.4 드리프트.** build.gradle 이 서술한 네 요소가 전부 존재한다. 드리프트 없음.
## 16. 확인하지 못한 것
- 실제 서버를 띄워 배수를 돌리지 않았다. 배선 경로가 없다.
- `GrpcAdmissionController` 를 배수 중에 호출해 §17.1 을 재현하지 않았다. 두 클래스의 공개 표면으로 판정했다.
- §17.4 의 교차를 실행으로 재현하지 않았다. `markServing` 의 확인과 실행이 분리되어 있고 `beginDraining` 의 두 문장 사이에 창이 있다는 것으로 판정했다.
## 17. 손볼 것
### 17.1 P2 — `rejectNewAdmission()` 이 단계만 기록하고 아무것도 거절하지 않는다
```java
public void rejectNewAdmission() {
requireStarted();
phasesRun.add(GrpcDrainPhase.REJECT_NEW_ADMISSION);
}
public boolean admittingNewCalls() {
return !phasesRun.contains(GrpcDrainPhase.REJECT_NEW_ADMISSION);
}
```
javadoc 은 "Starts refusing new calls" 라고 적는다. 실제로 하는 일은 단계 목록에 표식을 넣는 것뿐이다.
조정자는 `GrpcAdmissionController` 를 협력자로 들고 있는데, 그것을 쓰는 곳은 `inFlightAdmitted()` 의 조회 하나다.
그리고 승인 제어기의 공개 표면에 승인을 멈추는 메서드가 없다.
```
GrpcAdmissionController — tryAdmit · promoteFromQueue · release · inFlight · queued · rejected
```
`close`·`drain`·`refuseNew` 에 해당하는 것이 없다. 그러므로 배수가 시작된 뒤에도 `tryAdmit()` 은 용량이 남아 있는 한 계속 승인한다.
`admittingNewCalls()` 은 그 사실과 무관하게 거짓을 돌려준다 — 표식을 읽기 때문이다. 운영자나 상위 코드가 이 값을 보고 "더 이상 받지 않는다" 고 읽으면 틀린 답을 얻는다.
**테스트가 이것을 볼 수 없다.** 배수 테스트가 단언하는 것은 `admittingNewCalls()` 의 값이고, 단계 이후에 `tryAdmit()` 이 거절되는지는 어느 테스트도 묻지 않는다.
**수정.** 승인 제어기에 승인 중단 상태를 두고(`stopAdmitting()` 과 그것을 보는 `tryAdmit`), 조정자의 `rejectNewAdmission` 이 그것을 부르게 한다. 지금 형태에서는 배수 순서를 지키는 장치가 순서 표식만 갖고 있다.
### 17.2 P3 — 비밀 필드 검사가 스냅숏의 네 구획 중 하나에만 적용된다
```java
List<String> forbidden = GrpcAdminExposurePolicy.forbiddenFields(channelProfileHashes);
if (!forbidden.isEmpty()) {
throw new IllegalArgumentException("a platform snapshot must not carry " + forbidden + "; hashes and names only");
}
```
메시지는 "a platform snapshot must not carry …" 로 스냅숏 전체를 말한다. 검사 대상은 `channelProfileHashes` 하나다.
같은 채널 이름 공간을 쓰는 두 맵이 더 있다 — `resolverAndLoadBalancerByChannel`, `retryOwnerByChannel`. 그리고 `registeredServices` 목록과 `serviceHealth` 맵이 있다. 어느 것도 검사되지 않는다.
세 맵의 키 집합이 같아야 한다는 요구가 없으므로, 어떤 채널이 나머지 두 맵에만 있으면 그 이름은 검사를 지나지 않는다.
`grpc-advanced-diagnostics` 의 스냅숏은 같은 형태의 자기 검사를 두 구획(주소 목록, 자원 판본 키)에 적용한다. 두 리프의 규율이 갈린다.
수정은 네 구획 전부를 같은 검사에 넣는 것이다. 값이 아니라 키를 보는 검사이므로 비용이 낮다.
### 17.3 P3 — 배수 조정자가 가변이고 동기화가 없다
`phasesRun`(`ArrayList`), `startedAt`, `completedUnaryCalls`, `signalledStreams` 가 평범한 필드다. `synchronized`·`volatile`·동시 자료구조가 없다.
같은 리프의 건강 레지스트리는 정반대다 — `ConcurrentHashMap` 둘과 `volatile boolean draining`. 즉 이 리프는 동시성을 인지하고 있고 한 클래스에만 적용했다.
조정자의 javadoc 이 대기를 호출자에게 맡긴다고 적으므로 단일 호출자 전제로 읽을 수 있다. 다만 그 전제가 자바독에 적혀 있지 않고, `unaryDrainComplete` 는 반복 호출을 전제한 형태라 종료 훅과 상태 조회가 다른 스레드에서 닿기 쉽다.
수정은 단일 스레드 전제를 자바독에 적거나, 형제 클래스와 같은 수준으로 맞추는 것이다.
### 17.4 P2 — 배수 시작이 확인 후 실행이라, 배수 중에 한 서비스가 다시 `SERVING` 이 될 수 있다
§17.3 은 조정자가 동기화 없이 가변이고 건강 레지스트리는 "정반대" 라고 적었다. 레지스트리 쪽을 다시 읽으면 자료구조는 정반대이지만 규율은 같은 자리에서 깨진다.
```java
public void markServing(String serviceName) {
requireServiceName(serviceName);
if (draining) { return; } // ← 확인
states.put(serviceName, GrpcHealthState.SERVING); // ← 실행
recomputeGlobal();
}
public void beginDraining() {
draining = true; // ①
states.replaceAll((service, state) -> GrpcHealthState.DRAINING); // ②
}
```
`draining``volatile` 이므로 가시성은 문제가 아니다. 문제는 순서다. 건강 검사 스레드가 ① 이전에 `if (draining)` 을 통과하고 ② 이후에 `states.put(..., SERVING)` 을 실행하면, 그 서비스는 배수 중에 `SERVING` 으로 남는다. 그리고 같은 호출이 이어서 `recomputeGlobal()` 을 부르므로 **전역 상태까지 `SERVING` 으로 돌아간다**`anyNotServing` 이 거짓이고 `anyServing` 이 참이기 때문이다.
그러면 `ready()` 가 참을 답하고, 로드밸런서는 이 인스턴스로 다시 트래픽을 보낸다. 이 클래스의 javadoc 이 막겠다고 한 것이 정확히 그것이다.
> "Ignored while draining: a service that reports itself healthy after the drain has started would be
> routed traffic the instance has already promised not to take."
창은 좁다. 건강 검사는 주기적이고 배수는 한 번이므로, 겹치려면 검사가 배수 시작을 가로질러야 한다. 그리고 겹치는 순간이 정확히 롤아웃 중 — 즉 트래픽이 옮겨지는 중 — 이라는 것이 이 가족의 다른 자리에서 반복해서 나오는 논거다(§17.1 의 "readiness 를 먼저" 도 같은 창을 다룬다).
`recordDependencyHealth` 도 같은 형태다 — `dependencyHealth.put(...)` 뒤에 `if (!draining) recomputeGlobal();` 을 부르므로, 같은 교차에서 전역을 되살릴 수 있다.
**시험이 보지 못하는 이유.** 레지스트리 시험은 단일 스레드이고, `beginDraining()` 뒤에 `markServing` 을 부르는 사례는 순차적으로만 확인한다 — 그 경로에서는 가드가 정확히 작동한다.
**수정.** 상태 전이를 하나의 원자 연산으로 만든다 — `states.computeIfPresent(service, (k, v) -> draining ? v : SERVING)` 처럼 `draining` 을 맵 연산 안에서 읽거나, `beginDraining``replaceAll` 을 마친 뒤 한 번 더 `replaceAll` 을 돌려 늦게 들어온 쓰기를 덮는다. 후자는 창을 좁힐 뿐이므로 전자가 맞다.
**등급.** `GrpcServiceHealthRegistry``grpc-spring-boot-starter` 가 빈으로 만드는 셋 중 하나다(§12.1). 다만 `beginDraining()` 을 부르는 production 코드는 `GrpcDrainCoordinator` 이고 그것은 조립되지 않으므로, 오늘 이 창이 열리지는 않는다. 배수를 배선하는 날 §17.1 과 함께 봐야 한다.
### 확인된 설계(문제 아님)
- **모든 서비스를 `UNKNOWN` 에서 시작하는 것과 그 근거.**
- **배수 중 건강 보고를 무시하는 것.**
- **읽기 준비 해제를 승인 거절보다 먼저 두고, 순서를 어기면 던지는 것.**
- **조정자가 잠들지 않고 시각과 개수를 인자로 받는 것** — 모든 분기가 시계 없이 검증 가능하다.
- **두 게이트 규칙을 세 정책에 같은 형태로 둔 것.**
- **반사 가시성과 메서드 인가를 분리한 것.**
- **권한 없는 호출에 편집본이 아니라 빈 값을 주는 것.**
- **스냅숏이 내용이 아니라 해시를 싣고, 판본과 시각을 요구하는 것.**
- **표류 비교가 양방향인 것.**
---
## Source anchors
```
src/grpc/grpc-admin/build.gradle:1-8
main/java/…/admin/GrpcDrainCoordinator.java:1-162
main/java/…/admin/GrpcServiceHealthRegistry.java:1-154
main/java/…/admin/GrpcPlatformSnapshotService.java:1-115
main/java/…/admin/GrpcReflectionPolicy.java:1-74
main/java/…/admin/GrpcPlatformSnapshot.java:1-71
main/java/…/admin/GrpcAdminExposurePolicy.java:1-71
main/java/…/admin/(GrpcHealthPolicy · GrpcDrainResult · GrpcDrainPolicy · GrpcHealthState · GrpcReflectionMode · GrpcReflectionAccessDecision · GrpcDrainPhase)
test/java/…/admin/(GrpcDrainCoordinatorTest · GrpcPlatformSnapshotServiceTest · GrpcServiceHealthRegistryTest · GrpcReflectionPolicyTest)
src/grpc/grpc-server/…/server/GrpcAdmissionController.java (공개 표면 대조)
```
@@ -0,0 +1,359 @@
# grpc-advanced-bootstrap 완전 해부
> 상태: COMPLETE
> 재오픈 게이트: cycle 2 — `src/main` production 9파일 610줄, test 2파일 279줄 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음.
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/grpc-advanced/grpc-advanced-bootstrap`
> SSOT owner: `grpc-advanced-bootstrap`
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지
- `allowed_dependencies`: `["grpc-core-api"]` — Advanced 는 Stable 공개 타입에 의존하고 그 반대는 없다
- `runtime_memberships`: **`[]`** — build-only
| 파일 | LOC | 패키지 |
|---|---:|---|
| `GrpcAdvancedFeatureFlags` | 105 | bootstrap |
| `GrpcAdvancedModuleGuard` | 84 | bootstrap |
| `GrpcAdvancedCapability` | 65 | bootstrap |
| `GrpcAdvancedCapabilityDisabledException` | 42 | bootstrap |
| `GrpcCapabilityGrade` | 38 | bootstrap |
| `GrpcAdvancedPromotionGate` | 85 | release |
| `GrpcAdvancedPromotionEvidence` | 75 | release |
| `GrpcAdvancedSupportMatrix` | 66 | release |
| `GrpcAdvancedPromotionDecision` | 50 | release |
| **main 합계** | **610** | |
### Coverage ledger
| scope | count | disposition | reason |
|---|---:|---|---|
| `main/java/**` | 9 | `FULL_READ` | 610줄 전 본문 |
| `test/java/**` | 2 | `FULL_READ` | 279줄(150+129) · 테스트 17개 |
| `build.gradle` | 1 | `FULL_READ` | 12줄 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
`UNCLASSIFIED` 0.
---
## 1. 모듈의 정체
```groovy
// build.gradle:3-9
// The Advanced boundary itself: capability grades, the `ca-skeleton.grpc.advanced.*` feature-flag
// contract, the module guard that refuses an unflagged capability, and the per-capability
// promotion gate.
// This leaf depends on Stable public types and never the other way round.
```
## 2. 능력 15종과 등급 4종
능력을 하나씩 등급 매기는 것이 설계다.
> "Bundling them under one 'advanced' flag makes enabling gRPC-Web — a compatibility bridge with a
> proxy in front of it — the same decision as enabling xDS, which brings a control plane and its
> outage modes. They are not the same decision, and a single switch is how the second one gets made
> by accident."
| 등급 | 시작 가능 | production 별도 승인 |
|---|---|---|
| `ADVANCED_STABLE` | 예 | 아니오 |
| `EXPERIMENTAL` | 예 | **예** |
| `WATCH` | 아니오 | — |
| `DISABLED` | 아니오 | — |
기본 등급 분포는 `ADVANCED_STABLE` 11, `EXPERIMENTAL` 3(`HEDGING`·`CUSTOM_LOAD_BALANCER`·`XDS`), `WATCH` 1(`EDITION_2026`)이다.
`EXPERIMENTAL` 에 두 번째 승인을 요구하는 근거가 적혀 있다.
> "The flag says somebody wanted the feature; the approval says somebody accepted that its failure
> modes are not fully characterised, which is a different person's decision on most teams."
## 3. 게이트가 세 조건을 순서대로 본다
```java
if (!flags.flagSet(capability)) "its feature flag is not set"
if (!grade.startable()) "it is graded WATCH, which cannot start"
if (production && requiresApproval && !approved) "production needs a separate approval"
```
> "Collapsing them into one boolean produces a 'not enabled' message for three situations with three
> different remedies."
`requireStableStarterIsClean` 이 같은 불변식을 런타임에서도 확인한다 — 팻 자, 셰이드 산출물, 테스트 하네스처럼 다른 방식으로 조립된 런타임을 위해서다. **다만 그 메서드를 부르는 런타임이 없다**(§12.1). 지금 그 검사를 실행하는 것은 이 리프의 자기 테스트뿐이다.
## 4. 승격 게이트
증거는 능력마다 따로 기록된다.
> "a shared record makes promoting one of them promote whichever others happened to be measured at
> the same time."
일곱 항목(호환성·보안 검토·고장·성능·ADR·런북·실환경 테스트)과 담금 기간을 본다. 임계값이 둘이다.
```
ADVANCED_STABLE_SOAK = 7일
STABLE_DEFAULT_SOAK = 30일
```
> "becoming a Stable default means every deployment gets it, which additionally puts its
> dependencies on every classpath and its failure modes in every on-call rotation."
그리고 `WATCH``EXPERIMENTAL` 을 먼저 거쳐야 한다.
`GrpcAdvancedSupportMatrix.apply` 는 결정의 시작 등급이 현재 등급과 다르면 거부한다 — 두 승격이 경합했거나 하나가 재생된 경우다.
## 10. 테스트 레인
두 테스트 279줄, 17개 테스트. 게이트의 세 조건, 능력별 개별 깃발, `WATCH` 거부, production 이중 승인, 활성 집합, 승격 임계값 둘, `WATCH` 선행 규칙, 매트릭스 경합 거부를 확인한다.
`capabilitiesDraggedAlong` 이 항상 빈 목록을 돌려주고, 그 메서드가 존재하는 이유를 javadoc 이 적는다 — "the method exists so a test can assert that rather than a comment claiming it". 실제로 그 테스트가 있다.
## 12. negative-space probes
**12.1 도달성.** Advanced 가족 전체가 배선되지 않는다. 리프 안에서도 절반만 쓰인다.
의존 선언은 다섯이다 — `grpc-advanced-diagnostics` · `-streaming` · `-compat` · `-edition` · `-resilience` 가 각각 `api project(':grpc-advanced:grpc-advanced-bootstrap')`.
그중 실제로 타입을 부르는 것은 둘뿐이다.
| 호출 지점 | 무엇을 |
|---|---|
| `GrpcChannelDiagnosticsPolicy:47,53` | `GrpcAdvancedModuleGuard.available(flags, CHANNEL_DIAGNOSTICS)` · `…(flags, XDS)` |
| `GrpcXdsStartupGuard:33` | `GrpcAdvancedModuleGuard.available(flags, XDS)` |
`-streaming` · `-compat` · `-edition` 은 의존만 선언하고 참조가 없다.
그리고 리프 밖에서 불리는 것은 `available` **하나뿐**이다.
- `GrpcAdvancedModuleGuard.require(...)` — 던지는 형태. 외부 호출자 0. 세 갈래 거부 메시지 전체가 자기 테스트에서만 실행된다. `GrpcAdvancedCapabilityDisabledException` 도 마찬가지다.
- `requireStableStarterIsClean(...)` — 외부 호출자 0. javadoc 이 겨냥한 "다르게 조립된 런타임"이 이 검사를 부르지 않는다.
- `advancedModules()` — 외부 호출자 0.
- **`release` 패키지 4파일 276줄 전체** — 리프 밖 참조 0. 승격 게이트·증거·결정·지원 매트릭스를 만드는 곳이 자기 테스트 말고 없다.
즉 이 리프에서 실행 경로에 걸려 있는 것은 `GrpcAdvancedCapability` · `GrpcCapabilityGrade` · `GrpcAdvancedFeatureFlags` · `GrpcAdvancedModuleGuard.available` 네 조각이고, 나머지 절반은 선언이다.
**12.2 대조군.** 능력을 하나씩 등급 매기는 형태가 messaging 의 `CompatibilityMatrix`(어댑터별 STABLE/EXPERIMENTAL/EXTENSION)와 같은 계열이다. 차이는 이쪽이 시작 가능 여부와 production 승인 요구를 등급 자체의 속성으로 둔 점이다.
**12.4 드리프트.** build.gradle 이 서술한 네 요소(등급·깃발 계약·모듈 가드·승격 게이트)가 전부 존재한다. 드리프트 없음.
## 16. 확인하지 못한 것
- `verifyCleanArchitectureDependencies` 를 이 리비전에서 실행하지 않았다.
- 등급 재정의로 `WATCH` 능력을 켜는 것을 실행으로 재현하지 않았다(§17.1). 코드 경로로 판정했다.
- 테스트를 실행하지 않았다. 17개 전부 본문으로만 확인했다. §17.4 의 담금 역전도 `evaluate` 본문과 두 테스트가 고른 숫자(60일 · `DISABLED`)로 판정한 것이다.
- `-streaming` · `-compat` · `-edition` 이 이 리프를 의존만 하고 쓰지 않는다는 것은 타입 이름 grep 으로 판정했다. 각 리프 SSOT 에서 다시 본다.
## 17. 손볼 것
### 17.1 P3 — 등급 재정의에 하한이 없어 "켤 수 없다" 는 등급이 켜질 수 있다
`GrpcCapabilityGrade` 의 javadoc 이 두 등급을 단정한다.
```
WATCH — "Tracked, not implemented. Cannot be enabled."
DISABLED — "Withdrawn or refused. Cannot be enabled."
```
그런데 등급은 런타임에 갈아끼울 수 있다.
```java
public GrpcAdvancedFeatureFlags withGrade(GrpcAdvancedCapability capability, GrpcCapabilityGrade grade) {
grades.put(capability, grade);
return this;
}
```
`withGrade(EDITION_2026, ADVANCED_STABLE).enable(EDITION_2026)` 이면 가드의 두 번째 조건이 통과한다.
등급 올리기 자체는 의도된 기능이다 — 테스트 `a deployment may raise a capability's grade on its own evidence``HEDGING`(EXPERIMENTAL)을 `ADVANCED_STABLE` 로 올린다. 문제는 그 재정의에 하한이 없다는 것이다.
- `EXPERIMENTAL` 을 올리는 것은 "실패 양식이 충분히 규명되지 않은 것을 감수한다" 는 판단이고 배포가 자기 증거로 내릴 수 있다.
- `WATCH` 를 올리는 것은 다르다. 그 등급의 뜻이 "추적할 뿐 구현되지 않았다" 이므로 배포가 가질 자기 증거가 없다.
그리고 승격 게이트는 `WATCH``EXPERIMENTAL` 을 먼저 거쳐야 한다는 규칙을 갖는데, 런타임 재정의는 그 게이트를 지나지 않는다. 같은 리프 안에 문이 둘이고 증거 규칙은 한쪽에만 있다.
수정은 `withGrade` 가 현재 등급이 `startable()` 인 능력에만 적용되게 하거나, `WATCH`·`DISABLED` 에서 올리는 재정의를 거부하는 것이다.
### 17.2 P3 — 승격 게이트가 하향 전이도 승격 규칙으로 판정하고, javadoc 이 약속한 거부는 없다
```java
/** @throws IllegalArgumentException when the transition is not one this gate governs */
public static GrpcAdvancedPromotionDecision evaluate(
GrpcAdvancedPromotionEvidence evidence, GrpcCapabilityGrade from, GrpcCapabilityGrade to) {
if (from == to) { throw new IllegalArgumentException("a promotion changes the grade"); }
```
던지는 경우는 널과 `from == to` 둘뿐이다. "이 게이트가 다루는 전이가 아닐 때" 라는 조건에 해당하는 검사가 없다.
그래서 하향 전이가 승격 규칙으로 판정된다.
```
evaluate(none(XDS), ADVANCED_STABLE, DISABLED)
→ to != ADVANCED_STABLE 이므로 requiredSoak = 30일
→ 증거 일곱 항목 부재 + 담금 부족으로 blockers 여덟
→ 결정: 거부
```
능력을 철회하려는 결정이 증거 부족을 이유로 막힌다. 방향이 뒤집혀 있다.
지금은 도달성이 낮다 — 이 게이트를 부르는 production 코드가 없고 테스트도 상향 전이만 넣는다. 기록하는 이유는 javadoc 이 그 거부를 이미 약속했다는 점이다.
수정은 `to.ordinal()` 이 아니라 등급의 서열을 명시한 뒤 상향 전이만 받고 나머지는 던지는 것이다. 철회는 별도 경로가 필요하다.
### 17.3 P3 — 깃발 홀더가 가변이고 동기화가 없다
`GrpcAdvancedFeatureFlags` 는 두 `EnumMap``enable`·`withGrade` 로 갱신하고, `available`·`active` 가 같은 맵을 읽는다. `synchronized`·`volatile`·동시 자료구조가 없다.
시작 시 전부 설정하고 그 뒤로 읽기만 한다면 안전 공개 문제만 남는다. 다만 두 메서드가 `this` 를 돌려주는 유창한 형태라 런타임 중 갱신을 권하는 모양이고, `active()` 는 순회 중 갱신에 노출된다.
같은 저장소가 이 형태를 다른 리프에서 결함으로 기록했다(`GrpcCompletionReconciler` 의 동기화 없는 `ArrayList`). 여기서는 등급과 깃발이 요청 경로에서 읽히므로 같은 노출이 생길 수 있다.
수정은 홀더를 불변으로 만들고 `enable`·`withGrade` 가 새 인스턴스를 돌려주게 하는 것이다. 이 저장소가 다른 곳에서 쓰는 형태다(`GrpcProtoStyleManifest.allowingWellKnownTypes` 등).
### 17.4 P2 — 30일 담금이 열거형에 없는 등급을 위해 쓰였고, 그 결과 `WATCH → EXPERIMENTAL` 이 `→ ADVANCED_STABLE` 보다 어렵다
`GrpcAdvancedPromotionGate` javadoc 의 모형은 등급 둘이다.
> "Reaching **Advanced Stable** means the capability works and is documented; becoming a **Stable
> default** means every deployment gets it… The second needs the first plus a longer soak."
그리고 상수도 둘이다.
```java
public static final Duration ADVANCED_STABLE_SOAK = Duration.ofDays(7);
public static final Duration STABLE_DEFAULT_SOAK = Duration.ofDays(30);
```
그런데 `GrpcCapabilityGrade` 의 값은 `ADVANCED_STABLE` · `EXPERIMENTAL` · `WATCH` · `DISABLED` 넷이다. **"Stable default" 라는 등급이 없다.**
선택은 이렇게 적혀 있다.
```java
Duration requiredSoak =
to == GrpcCapabilityGrade.ADVANCED_STABLE ? ADVANCED_STABLE_SOAK : STABLE_DEFAULT_SOAK;
```
`ADVANCED_STABLE` 이 아닌 **나머지 전부**가 30일 갈래로 떨어진다 — `EXPERIMENTAL`, `WATCH`, `DISABLED`. 존재하지 않는 등급을 위해 만든 갈래가 존재하는 세 등급을 삼켰다.
**따라오는 역전.** `missing()` 검사도 목표 등급과 무관하게 일곱 항목을 전부 요구한다. 그래서:
| 전이 | 필요한 증거 | 필요한 담금 |
|---|---|---|
| `EXPERIMENTAL → ADVANCED_STABLE` | 일곱 전부 | **7일** |
| `WATCH → EXPERIMENTAL` | 일곱 전부 | **30일** |
`WATCH` 능력이 밟도록 강제된 유일한 첫 걸음이(같은 메서드의 셋째 blocker: "it becomes EXPERIMENTAL before anything else") 상위 등급보다 엄격하다. 그리고 `WATCH` 의 뜻은 "추적할 뿐 구현되지 않았다" 이므로, 정의상 담금 기록이 가장 적은 등급에 가장 긴 담금을 요구한다.
**테스트가 이 뒤틀림을 그대로 보여 준다.**
```java
@DisplayName("becoming a Stable default needs a longer soak than becoming Advanced Stable")
void theStableDefaultThresholdIsHigher() {
GrpcAdvancedPromotionGate.evaluate(weekLongSoak,
GrpcCapabilityGrade.ADVANCED_STABLE, GrpcCapabilityGrade.DISABLED) // ← 철회 전이
.blockers() .contains("requires 30");
}
```
30일 갈래를 실행하려고 고른 전이가 `ADVANCED_STABLE → DISABLED`, 즉 **철회**다. 이름은 "Stable default 가 되는 것"이라고 말한다. 겨냥한 등급이 열거형에 없으니 그것을 밟을 방법이 없었고, 남은 것 중 아무거나 골라야 했다는 흔적이다.
그리고 `WATCH → EXPERIMENTAL` 을 확인하는 테스트는 담금을 60일로 준다.
```java
GrpcAdvancedPromotionEvidence.complete(EDITION_2026, Duration.ofDays(60))
```
7일로 줬다면 통과하지 않는다. 30일 요구가 레인에 걸리지 않는 이유가 이 숫자 선택이다.
**§17.2 와의 관계.** §17.2 는 이 갈래의 *증상* 하나(철회 전이가 승격 규칙으로 판정되는 것)를 기록했다. 원인은 목표 등급별 요구 사항이 없다는 것이고, 그래서 상향 전이 안에서도 순서가 뒤집혔다.
**수정.** 목표 등급마다 요구 사항을 명시한다.
```java
record Requirement(Set<String> evidence, Duration soak) {}
static Requirement requirementFor(GrpcCapabilityGrade to) { } // EXPERIMENTAL 은 더 얕게
```
`STABLE_DEFAULT` 를 실제로 표현하려면 등급으로 추가하거나(그러면 `GrpcAdvancedCapability` 를 떠나 Stable 기본값이 된다는 뜻이므로 별도 개념이 맞다) 이 게이트가 다루지 않는다고 적고 상수를 지운다. 지금은 이름만 있고 대상이 없다.
### 17.5 P3 — `capabilitiesDraggedAlong` 은 독립성을 증명하지 않는다. 상수를 상수와 비교한다
```java
/** Always empty, and the method exists so a test can assert that rather than a comment claiming it */
public static List<GrpcAdvancedCapability> capabilitiesDraggedAlong(GrpcAdvancedCapability promoted) {
if (promoted == null) throw ;
return List.of();
}
```
javadoc 이 스스로 밝히듯 본문은 무조건 빈 목록이다. 그것을 단언하는 테스트는 리터럴이 리터럴임을 확인한다 — 증거를 능력마다 따로 기록했다는 §4 의 설계 속성과는 아무 연결이 없다. 설계가 무너져 `apply` 가 다른 능력의 등급을 바꾸게 되어도 이 메서드는 여전히 빈 목록을 돌려준다.
**진짜 증거는 같은 테스트의 다른 줄에 있다.**
```java
matrix.apply(evaluate(complete(HEDGING, 7일), EXPERIMENTAL, ADVANCED_STABLE));
assertThat(matrix.gradeOf(HEDGING)).isEqualTo(ADVANCED_STABLE);
assertThat(matrix.gradeOf(GRPC_WEB)).isEqualTo(webBefore); // ← 이 줄이 독립성을 붙든다
```
승격을 실제로 적용하고 다른 능력의 등급이 그대로임을 확인한다. 이쪽은 설계가 무너지면 깨진다.
**이 문서의 이전 판정을 고친다.** 앞선 판에서 이 메서드를 "주석이 주장하는 대신 테스트가 붙든다"는 확인된 설계로 분류했다. 다시 읽으니 붙드는 것은 옆줄이고, 이 메서드는 그 옆줄이 있다는 사실을 가린다.
**수정.** 메서드를 지우고 단언을 매트릭스 비교 쪽으로 남긴다. 남겨 둔다면 실제로 매트릭스를 훑어 등급이 바뀐 다른 능력을 돌려주게 만든다 — 그때 비로소 이름이 하는 말과 본문이 맞는다.
### 17.6 P3 — 예외가 들고 있는 능력이 `transient` 라 역직렬화 뒤 사라진다
```java
public class GrpcAdvancedCapabilityDisabledException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final transient GrpcAdvancedCapability capability;
public GrpcAdvancedCapability capability() { return capability; }
```
`transient` 는 보통 직렬화 가능하지 않은 필드를 담은 `Serializable` 클래스에 대한 정적 분석 경고를 끄려고 붙인다. 그런데 열거형은 언제나 직렬화 가능하다 — 여기서 `transient` 가 막을 문제가 애초에 없다.
대가는 있다. 예외가 직렬화를 거쳐 오면 `capability()``null` 이다. 메시지 문자열은 살아남으므로 사람이 읽는 데는 지장이 없고, 그래서 눈에 띄지 않는다.
**등급.** 이 예외를 던지는 `require` 자체가 리프 밖에서 불리지 않으므로(§12.1) 오늘 도달하지 않는다. `transient` 를 지우는 것이 수정 전부다.
### 확인된 설계(문제 아님)
- **능력별 개별 등급과 개별 깃발** — 하나의 스위치가 두 번째 결정을 사고로 만들지 않는다.
- **`EXPERIMENTAL` 의 production 이중 승인** — 원하는 사람과 감수하는 사람이 다르다.
- **가드가 세 조건을 순서대로 보고 각각 다른 메시지를 내는 것.**
- **증거를 능력마다 따로 기록한 것** — 공유 기록은 하나의 승격이 다른 것을 함께 올린다.
- **두 담금 임계값** — 한 배포에서 일주일 돈 것과 모든 배포에 나가는 것은 같은 주장이 아니다.
- **매트릭스가 시작 등급 불일치를 거부하는 것** — 경합과 재생을 구분해 준다.
- **빈 컬렉션에 대한 `EnumSet.copyOf` 함정을 삼항으로 피한 것.**
- **`GrpcAdvancedSupportMatrix.apply` 가 결정의 `from` 을 현재 등급과 대조하는 것** — 이 저장소가 같은 문제를 반대로 푼 자리가 있어서 대비된다. `grpc-codegen``GrpcSchemaArtifactPublisher.publish(candidate, decision)` 는 결정이 어느 후보를 판정한 것인지 확인하지 않아 짝이 어긋날 수 있다(그쪽 §17.4). 이쪽은 결정이 자기가 밟고 선 상태를 들고 있고 적용 시점에 대조한다 — 같은 형태의 올바른 판본이다.
- **`ADVANCED_STABLE` 11 · `EXPERIMENTAL` 3 · `WATCH` 1 의 분포가 능력 성격과 맞는 것** — 제어 평면을 끌고 오는 `XDS`, 부하를 복제하는 `HEDGING`, 이름 해석을 갈아끼우는 `CUSTOM_LOAD_BALANCER` 만 이중 승인 대상이다.
---
## Source anchors
```
src/grpc-advanced/grpc-advanced-bootstrap/build.gradle:1-12
main/java/…/bootstrap/GrpcAdvancedFeatureFlags.java:1-105
main/java/…/bootstrap/GrpcAdvancedModuleGuard.java:1-84
main/java/…/bootstrap/GrpcAdvancedCapability.java:1-65
main/java/…/bootstrap/GrpcCapabilityGrade.java:1-38
main/java/…/bootstrap/GrpcAdvancedCapabilityDisabledException.java:1-42
main/java/…/release/GrpcAdvancedPromotionGate.java:1-85
main/java/…/release/GrpcAdvancedPromotionEvidence.java:1-75
main/java/…/release/GrpcAdvancedSupportMatrix.java:1-66
main/java/…/release/GrpcAdvancedPromotionDecision.java:1-50
test/java/…/release/GrpcAdvancedPromotionGateTest.java:1-150
test/java/…/bootstrap/GrpcAdvancedModuleGuardTest.java:1-129
grpc-advanced/grpc-advanced-diagnostics/…/GrpcChannelDiagnosticsPolicy.java:46-53 (available 소비)
grpc-advanced/grpc-advanced-resilience/…/xds/GrpcXdsStartupGuard.java:33-38 (available 소비)
```
@@ -0,0 +1,224 @@
/shared/codebase/clean-architecture-backend-template/src/grpc-advanced/grpc-advanced-compat/src/main/resources/envoy/envoy.yaml
---
# grpc-advanced-compat 완전 해부
> 상태: COMPLETE
> 재오픈 게이트: cycle 2 — `src/main` production 17파일 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음.
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/grpc-advanced/grpc-advanced-compat`
> SSOT owner: `grpc-advanced-compat`
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지
- `runtime_memberships`: **`[]`** — build-only
- 다섯 다리: gRPC-Web · Servlet · Spring Integration · Reactor · Kotlin
| 패키지 | 파일 | LOC |
|---|---:|---:|
| `web` | 4 | 220 |
| `kotlin` | 3 | 164 |
| `servlet` | 3 | 152 |
| `reactor` | 4 | 233 |
| `integration` | 3 | 193 |
### Coverage ledger
| scope | count | disposition | reason |
|---|---:|---|---|
| `main/java/**` | 17 | `FULL_READ` | 962줄 전 본문 |
| `main/resources/envoy/envoy.yaml` | 1 | `FULL_READ` | 70줄 전문. 참조 프록시 설정 — 이전 판의 ledger 에 아예 없었다 |
| `test/java/**` | 5 | `FULL_READ` | 523줄 |
| `build.gradle` | 1 | `FULL_READ` | 전문 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
`UNCLASSIFIED` 0.
---
## 1. 모듈의 정체와 코틀린 레인의 처리
```groovy
// build.gradle:6-10
// No Kotlin source set (adaptation D7): this repository has no Kotlin toolchain, so the Kotlin lane
// is expressed as a Java-side boundary contract whose compatibility gate fails closed until a real
// toolchain lane exists. Everything the gate would otherwise assert — one schema source, coroutine
// cancellation propagation, Flow backpressure inside the Stable buffer limits, evidence type
// preservation — is a checkable contract without it.
```
그리고 게이트가 그 판단을 코드로 반복한다.
> "Fails closed in this repository, and says so rather than reporting a pass it cannot justify…
> a gate that reported success anyway would put an unverified claim in the support matrix."
`blockers` 는 다섯 항목을 낸다. 넷은 프로파일에서 확인 가능하고, 다섯째가 툴체인 레인 부재다. `supportableHere()` 는 상수 거짓이다.
이 처리가 이 저장소의 다른 곳(`grpc-advanced-diagnostics` 의 인프라 테스트킷 계약)과 같은 원칙이다 — 인프라 없이 도는 묶음은 통과하고 아무것도 세우지 않는다.
## 2. 다리마다 무엇을 거절하는가
| 다리 | 거절 |
|---|---|
| gRPC-Web | 브라우저에 노출된 메서드 중 gRPC-Web 이 나를 수 없는 RPC 종류 |
| Servlet | 컨테이너가 제공하지 않는 전송 설정 요구 |
| Kotlin | 다섯 블로커(툴체인 레인 포함) |
| Spring Integration | 변환기 없는 다리, 허용 목록 밖 헤더 |
| Reactor | (§17.2) |
gRPC-Web 의 근거:
> "Two schemas — one for browsers, one for services — is how a field ends up meaning something
> different depending on which client asked, and the divergence is only visible to whoever reads
> both files."
Servlet 의 근거:
> "Refuses rather than warns, because the setting would otherwise be accepted and ignored. A
> keepalive configured on a Servlet deployment does nothing, the connections behave as the container
> decides, and the investigation starts from the assumption that the setting is in force."
그리고 Servlet 실행이 Netty 인증을 대신할 수 없다는 것을 상수로 못박는다.
## 3. Spring Integration 다리가 무엇을 약속하지 않는가
> "A Spring Integration `Message` accumulates headers as it moves through a flow — routing keys,
> correlation ids, errors channels, whatever a transformer added — and copying them onto gRPC
> metadata sends a service's internal plumbing across the network."
> "The bridge does not add durability. Spring Integration channels can look like a broker, and a
> bridge that implied acknowledgement or redelivery semantics would be promising something gRPC does
> not do."
변환기가 없는 다리는 생성자가 거부한다 — 반사에 맡기는 것이 예상 밖 타입이 유선에 닿는 경로다.
## 12. negative-space probes
**12.1 도달성.** Advanced 가족이므로 배선 경로가 없다. 그 위에 이 리프에는 테스트조차 없는 타입이 둘 있다(§17.2).
**12.2 대조군 — 메타데이터 경로 둘.** `grpc-client``GrpcClientMetadataPolicy.materialize` 는 허용 목록으로 거른 뒤 `budget.check(accepted)` 를 부른다. 이 리프의 `GrpcIntegrationBridgePolicy.metadataFrom` 은 허용 목록으로 거르고 예산을 부르지 않는다(§17.1).
**12.3 리프 전체의 외부 참조가 0 이다.** 재통독에서 다섯 패키지를 각각 확인했다.
```
dev.caskeleton.grpc.advanced.{web, servlet, integration, reactor, kotlin} → 리프 밖 참조 0
```
같은 Advanced 가족의 `grpc-advanced-bootstrap` 조차 이 리프의 타입을 하나도 부르지 않는다. Advanced 는 기능 플래그로 도달한다는 것이 이 가족의 규약인데, 그 플래그가 가리킬 대상이 배선되어 있지 않다.
**12.4 드리프트.** build.gradle 이 서술한 다섯 다리와 코틀린 레인의 처리 방식이 코드와 일치한다. 다만 ledger 가 `main/resources` 를 세지 않고 있었다(§17.3 의 재료가 거기 있다).
## 16. 확인하지 못한 것
- 실제 브라우저·프록시·서블릿 컨테이너로 어떤 다리도 돌리지 않았다. 그 인프라가 필요하다는 것이 이 가족의 기록이다.
- 코틀린 툴체인이 없으므로 코틀린 계약 넷을 실행으로 확인할 수 없다.
## 17. 손볼 것
### 17.1 P3 — 통합 다리의 메타데이터 조립이 메타데이터 예산을 검사하지 않는다
```java
public Map<GrpcMetadataKey, String> metadataFrom(Map<String, Object> messageHeaders) {
Map<GrpcMetadataKey, String> metadata = new LinkedHashMap<>();
headerAllowlist.forEach(key -> {
Object value = messageHeaders.get(key.name());
if (value != null) { metadata.put(key, String.valueOf(value)); }
});
return Map.copyOf(metadata);
}
```
허용 목록으로 키를 거르지만 값의 크기도, 합계도 보지 않는다.
클래스 javadoc 자신이 예산을 이 정책의 이유 중 하나로 든다 — 흐름의 내부 배관을 네트워크로 보내면 "it counts against the metadata budget."
그리고 같은 저장소의 다른 메타데이터 경로는 예산을 검사한다.
```java
// GrpcClientMetadataPolicy.materialize
proposed.forEach((key, value) -> { if (allowed.contains(key) && value != null) accepted.put(key, value); });
budget.check(accepted); // ← 이 줄이 이 다리에는 없다
```
`String.valueOf(value)` 이므로 헤더 값이 임의의 객체일 때 그 문자열 표현이 그대로 실린다. Spring Integration 헤더에는 컬렉션이나 도메인 객체가 흔히 들어가므로 값 하나가 클 수 있다.
수정은 이 record 에 `GrpcMetadataBudget` 를 성분으로 추가하고 `metadataFrom` 끝에서 검사하는 것이다. 형태가 이미 옆 리프에 있다.
### 17.2 P3 — 반응형 표면 두 타입은 테스트조차 없다
```
ReactiveGrpcClient 저장소 전체에서 등장하는 파일 1개 (자기 자신)
ReactiveGrpcServerAdapter 저장소 전체에서 등장하는 파일 1개 (자기 자신)
```
이 가족의 다른 미참조 Advanced 타입은 전부 테스트가 하나씩 있다 — 같은 패키지의 `GrpcReactorCancellationBridge` 는 2개 파일, `GrpcReactorContextBridge` 는 4개 파일에 등장한다.
두 타입은 채택자가 부를 표면이므로 production 참조 0 이 설계와 모순되지는 않는다. 어긋나는 것은 검증이다. 채택자용 표면이면 그 계약이 무엇인지를 테스트가 붙들어야 하고, 이 가족은 다른 곳에서 정확히 그렇게 한다.
`ReactiveGrpcClient` 의 javadoc 이 "Exposes a unary call as a `Mono` and a server stream as a `Flux`" 라고 적는데, 그 사상이 취소와 배압에서 어떻게 동작하는지는 어디에서도 확인되지 않는다. 같은 리프의 `GrpcReactorCancellationBridge` 가 취소 전파를 다루므로 둘을 함께 검증할 자리가 이미 있다.
### 17.3 P3 — 저장소가 참조 프록시 설정을 갖고 있는데, 그것을 판정할 코드에 넣지 않는다
이 리프에는 두 가지가 함께 있다.
- `GrpcWebProxyContract.violations(profile, exposedHeaders, allowedOrigins)` — 프록시 설정이 브라우저 클라이언트에게 통할지 판정하는 코드.
- `src/main/resources/envoy/envoy.yaml` — 그 설정의 참조 구현.
그리고 설정 파일 자신이 그 관계를 주장한다.
```yaml
# Shipped as a resource rather than as documentation prose because GrpcWebProxyContract asserts
# against it: the CORS allowlist, the exposed trailer headers and the TLS termination are the three
# things a browser client silently fails without, and a contract nobody checks is a contract that
# drifts from whatever is actually deployed.
```
`GrpcWebProxyContract` 는 이 파일에 대해 아무것도 단언하지 않는다. 판정기는 시험에서 리터럴 집합을 받고, 참조 설정은 시험에서 문자열 포함으로만 확인된다.
```java
// GrpcWebCompatibilityGateTest
assertThat(GrpcWebProxyContract.violations(profile, requiredExposedHeaders(), Set.of("*"))) // ← 리터럴
.anySatisfy(v -> assertThat(v).contains("defeats the profile's allowlist"));
String envoy = resource("envoy/envoy.yaml");
assertThat(envoy)
.contains("expose_headers: \"grpc-status,grpc-message") // ← 부분 문자열
.contains("exact: \"https://app.example.com\"");
```
그래서 참조 설정이 `grpc-status` 를 노출하는지는 문자열이 확인하고, 그 노출이 **충분한지**`requiredExposedHeaders()` 가 정의하는데, 둘을 잇는 코드가 없다. 필수 트레일러 목록이 늘어나면 판정기는 새 항목을 요구하고 참조 설정은 옛 문자열로 계속 통과한다.
이 리프의 다른 판정기들과 다른 점은 재료가 이미 저장소에 있다는 것이다 — grpc-testkit §17.5·grpc-server §17.1 은 스캔할 대상 자체를 만들어야 하지만, 여기서는 파일 하나를 파싱하면 된다.
수정은 시험이 `envoy.yaml``expose_headers``allow_origin`(`exact:`)을 뽑아 `GrpcWebProxyContract.violations` 에 넣고 비어 있음을 단언하는 것이다. 그러면 참조 설정과 계약이 한 곳에서 함께 움직인다.
### 확인된 설계(문제 아님)
- **코틀린 게이트가 닫힌 실패를 하고 그 이유를 말하는 것** — 정당화할 수 없는 통과를 보고하지 않는다.
- **코틀린 계약 넷을 툴체인 없이도 확인 가능하게 만든 것** — 나중에 필요한 것은 레인 추가이지 계약 작성이 아니다.
- **브라우저와 기본 클라이언트가 한 스키마를 쓰게 한 것.**
- **Servlet 이 제공하지 않는 설정을 경고가 아니라 거절로 다룬 것.**
- **Servlet 실행이 Netty 인증을 대신하지 못한다고 못박은 것.**
- **통합 다리가 브로커 의미론을 약속하지 않는다고 상수로 밝힌 것.**
- **변환기 없는 다리를 생성자가 거부한 것.**
- **다리가 생성된 스텁·서비스 API 를 대체하지 않는다고 밝힌 것.**
---
## Source anchors
```
src/grpc-advanced/grpc-advanced-compat/build.gradle:1-14
main/java/…/kotlin/GrpcKotlinCompatibilityGate.java:1-69
main/java/…/web/GrpcWebCompatibilityGate.java:1-56
main/java/…/servlet/GrpcServletStartupValidator.java:1-51
main/java/…/integration/GrpcIntegrationBridgePolicy.java:1-78
main/java/…/reactor/(ReactiveGrpcClient · ReactiveGrpcServerAdapter · GrpcReactorCancellationBridge · GrpcReactorContextBridge)
main/java/…/web/(GrpcWebProfile · GrpcWebProxyContract · GrpcWebRpcSupport)
main/java/…/servlet/(GrpcServletCompatibilityProfile · GrpcServletCapabilityMatrix)
main/java/…/kotlin/(GrpcCoroutineContextBridge · GrpcKotlinProfile)
main/java/…/integration/(GrpcIntegrationInboundGateway · GrpcIntegrationOutboundGateway)
src/grpc/grpc-client/…/GrpcClientMetadataPolicy.java (예산 검사 대비)
```
@@ -0,0 +1,265 @@
# grpc-advanced-diagnostics 완전 해부
> 상태: COMPLETE
> 재오픈 게이트: cycle 2 — `src/main` production 4파일 277줄, test 1파일 229줄과 픽스처 1개 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음.
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/grpc-advanced/grpc-advanced-diagnostics`
> SSOT owner: `grpc-advanced-diagnostics`
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지
- `allowed_dependencies`: `["grpc-core-api", "grpc-client", "grpc-advanced-bootstrap"]`
- `runtime_memberships`: **`[]`** — build-only (Advanced 가족 전체가 그렇다)
| 파일 | LOC |
|---|---:|
| `GrpcAdvancedInfrastructureTestkit` | 87 |
| `GrpcDiagnosticsRedactor` | 72 |
| `GrpcChannelDiagnosticsSnapshot` | 63 |
| `GrpcChannelDiagnosticsPolicy` | 55 |
| **main 합계** | **277** |
| `GrpcChannelDiagnosticsPolicyTest` | 229 |
| `xds/control-plane-snapshot.json` | 24 |
### Coverage ledger
| scope | count | disposition | reason |
|---|---:|---|---|
| `main/java/**` | 4 | `FULL_READ` | 전 본문 축자 확인 |
| `test/java/**` | 1 | `FULL_READ` | 229줄 · 테스트 11개 |
| `test/resources/xds/*.json` | 1 | `FULL_READ` | 24줄 픽스처 |
| `build.gradle` | 1 | `FULL_READ` | 10줄 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
`UNCLASSIFIED` 0.
---
## 1. 모듈의 정체
진단 표면(Channelz·CSDS)과 그것을 게시 가능하게 만드는 편집기, 그리고 고급 능력이 무엇을 상대로 검증되어야 하는지를 이름 짓는 테스트킷 계약을 담는다.
편집기 javadoc 이 왜 이것이 필요한지 적는다.
> "Channelz is unusually dangerous to expose because it is genuinely useful: it holds every socket's
> local and remote address, the security details of each connection, and per-call state… once the
> endpoint exists the whole of it is one authorization mistake away from being readable."
## 2. 두 겹의 게이트
`GrpcChannelDiagnosticsPolicy` 는 네트워크와 역할 두 게이트를 모두 요구하고, 하나라도 비면 생성자가 거부한다.
> "diagnostics need both a network and a role gate; Channelz holds every socket's peer and security
> detail, so either gate alone is the whole surface"
그리고 등록 판정이 능력 깃발에 걸려 있다. CSDS 는 Channelz 가 켜져 있고 xDS 도 켜져 있을 때만 등록된다.
> "A CSDS service on a deployment that does not use xDS answers every query with nothing, which is
> harmless, and advertises a control-plane surface that does not exist, which is not."
## 3. 스냅숏이 스스로를 검사한다
`GrpcChannelDiagnosticsSnapshot` 정규 생성자가 두 가지를 거부한다.
```java
maskedSocketAddresses.stream()
.filter(address -> !address.equals(GrpcDiagnosticsRedactor.maskAddress(address)))
// 마스킹되지 않은 주소
xdsResourceVersions.keySet().stream()
.filter(GrpcDiagnosticsRedactor::forbiddenField)
// 금지된 필드 이름
```
즉 편집을 거치지 않은 값으로는 스냅숏을 만들 수 없다. §17.1 이 그 검사의 범위를 다룬다.
## 4. 마스킹의 형태
주소는 버리지 않고 가린다.
> "An operator has to be able to tell two subchannels apart, and a stable mask does that without
> publishing where they point… The last two octets go; the first two stay, because 'which subnet'
> is a real diagnostic question and 'which host' is not one the diagnostics endpoint should answer."
## 5. 인프라 없는 증거를 거부하는 계약
`GrpcAdvancedInfrastructureTestkit` 이 능력별로 필요한 실제 인프라를 이름 짓는다.
| 능력 | 필요 인프라 |
|---|---|
| `GRPC_WEB` | gRPC-Web 프록시 |
| `SERVLET_COMPAT` | 서블릿 컨테이너 |
| `XDS` | 멈출 수 있는 xDS 통제 평면 |
| `KOTLIN` | 코틀린 툴체인 |
| 나머지 11종 | 없음 |
근거가 javadoc 에 있다.
> "gRPC-Web without a proxy tests a code path no browser will take; a Servlet profile without a
> container tests the profile object; xDS without a control plane cannot exercise the case that
> matters, which is the control plane going away. In all three, a suite that runs without the
> infrastructure passes and establishes nothing, which is worse than not having one."
## 10. 테스트 레인
11개 테스트 229줄. 두 게이트, CSDS 조건부 등록, 금지 필드 제거, 마스킹, 스냅숏 거부와 수용, 커밋된 xDS 픽스처의 편집, 능력별 인프라 목록을 확인한다.
## 12. negative-space probes
**12.1 도달성.** Advanced 가족이므로 배선 경로가 없다. 리프 밖 참조도 없고, 이 리프를 의존 선언한 모듈도 없다.
```
$ grep -rn "advanced.diagnostics" --include=*.java src/ | grep -v grpc-advanced-diagnostics/
grpc-core-api/…/GrpcStableModuleCatalog.java:42: "grpc-advanced-diagnostics"); ← 목록 안의 문자열
$ grep -rn "grpc-advanced-diagnostics" --include=*.gradle src/
(매치 없음)
```
방향을 뒤집으면 이 리프는 `grpc-advanced-bootstrap` 의 실제 소비자 둘 중 하나다 — `GrpcChannelDiagnosticsPolicy``GrpcAdvancedModuleGuard.available` 을 두 번 부른다(그쪽 §12.1). 이 가족에서 리프끼리 실제로 코드가 닿는 몇 안 되는 자리다.
**12.2 선언된 의존 셋 중 둘이 쓰이지 않는다.**
```groovy
api project(':grpc:grpc-core-api') // import 0
api project(':grpc:grpc-client') // import 0
api project(':grpc-advanced:grpc-advanced-bootstrap') // import 4줄
```
리프의 자바 4파일이 갖는 `dev.caskeleton` import 는 넷뿐이고 전부 bootstrap 것이다.
```
GrpcAdvancedInfrastructureTestkit.java:3 GrpcAdvancedCapability
GrpcChannelDiagnosticsPolicy.java:3,4,5 GrpcAdvancedCapability · GrpcAdvancedFeatureFlags · GrpcAdvancedModuleGuard
```
`grpc-client` 는 특히 눈에 띈다 — Channelz 진단이 채널을 다루는 주제이므로 의존 선언은 자연스럽게 읽히는데, 이 리프의 스냅숏은 채널 타입을 쓰지 않고 `String channelProfile``String connectivityState` 로 받는다. 진단 값 객체가 채널 타입에서 독립적인 것 자체는 설계로 읽히고, 그렇다면 남은 것은 쓰이지 않는 의존 선언이다.
같은 형태를 세 리프에서 기록했다 — `grpc-advanced-edition` §12.2(셋 다 미사용), `grpc-spring-boot-starter` §12.3(셋 미사용), `grpc-observability` §12.1(두 모듈이 이 리프를 `api` 로 노출하면서 쓰지 않음).
**12.2 대조군.** 이 저장소의 다른 편집기와 비교하면 방향이 같다 — `grpc-observability` 의 태그 정책은 허용 목록으로, 이쪽은 금지 패턴 + 마스킹으로 같은 문제(내용이 관측 표면으로 새는 것)를 푼다.
**12.3 대조군 — 같은 두 리터럴이 두 리프에 있다.**
```java
// grpc-advanced-diagnostics: GrpcChannelDiagnosticsPolicy.standard()
new GrpcChannelDiagnosticsPolicy(Set.of("admin"), Set.of("ROLE_PLATFORM_ADMIN"));
// grpc-spring-boot-starter: GrpcPlatformAutoConfiguration.grpcReflectionPolicy(...)
new GrpcReflectionPolicy(properties.getReflectionMode(),
java.util.Set.of("admin"), java.util.Set.of("ROLE_PLATFORM_ADMIN"));
```
관리 네트워크 이름과 관리 역할 이름이 같은 값으로 두 곳에 손으로 적혀 있고, 둘을 묶는 상수가 없다. 하나를 바꾸면 다른 하나가 남는다. 스타터 쪽은 그 리터럴이 설정 표면에 노출되지 않는다는 별도 문제도 있다(그쪽 §17.4).
**12.4 드리프트.** build.gradle 이 서술한 세 요소(Channelz/CSDS 진단, 편집기, 인프라 테스트킷 계약)가 전부 존재한다. 드리프트 없음.
## 16. 확인하지 못한 것
- 실제 Channelz 서비스를 띄워 스냅숏을 만들지 않았다. 배선 경로가 없다.
- IPv6 주소로 스냅숏을 만들어 §17.1 을 실행으로 재현하지 않았다. 정규식과 생성자 검사로 판정했다.
- 테스트를 실행하지 않았다. 11개 전부 본문으로만 확인했다.
- 두 의존이 쓰이지 않는다는 것(§12.2)은 `^import dev.caskeleton` grep 으로 판정했다.
## 17. 손볼 것
### 17.1 P2 — 마스킹이 IPv4 만 알고, 그 결과 "마스킹되지 않은 주소" 검사가 나머지 형태를 전부 통과시킨다
```java
private static final Pattern IPV4_WITH_PORT =
Pattern.compile("\\b(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})\\.(\\d{1,3})(:\\d{1,5})?\\b");
public static String maskAddress(String address) {
if (address == null || address.isBlank()) { return "unknown"; }
return IPV4_WITH_PORT.matcher(address)
.replaceAll(m -> m.group(1) + "." + m.group(2) + ".x.x");
}
```
IPv4 가 아닌 주소는 패턴에 맞지 않아 **입력 그대로 반환된다.**
그리고 스냅숏 생성자의 검사는 이렇게 되어 있다.
```java
.filter(address -> !address.equals(GrpcDiagnosticsRedactor.maskAddress(address)))
```
마스킹 결과가 입력과 같으면 이미 마스킹된 것으로 판정한다. 그러므로 IPv4 가 아닌 주소는 전부 이 검사를 통과한다.
| 입력 | `maskAddress` 결과 | 생성자 판정 |
|---|---|---|
| `10.4.13.201:9090` | `10.4.x.x` | 거부(마스킹 필요) |
| `10.4.x.x` | `10.4.x.x` | 수용 |
| `[2001:db8::4:13:201]:9090` | 입력 그대로 | **수용** |
| `pod-3.svc.cluster.local:8080` | 입력 그대로 | **수용** |
| `unix:/var/run/grpc.sock` | 입력 그대로 | **수용** |
세 번째와 네 번째가 문제다. 이 플랫폼이 겨냥하는 배포 형태가 쿠버네티스이고(`grpc-discovery` 전체가 그 주제다), 헤드리스 레코드의 엔드포인트는 파드 DNS 이름이며 이중 스택 클러스터에서는 IPv6 주소다. 편집기가 막으려 한 것이 정확히 그것이다 — "a diagnostics endpoint that publishes peer addresses publishes every tenant's connection."
`unix` 소켓 경로도 통과한다. 그것은 호스트 파일 시스템 경로다.
**테스트가 이것을 볼 수 없다.** 테스트의 주소 리터럴이 전부 IPv4 다 — `10.4.13.201:9090` · `10.9.13.201` · `10.4.x.x` · `10.5.x.x`. IPv6 도 호스트 이름도 없다.
**수정.** 마스킹을 형태별로 나눈다. IPv6 는 앞 두 그룹만 남기고 나머지를 `:x:x` 로, 호스트 이름은 최상위 라벨 몇 개만 남기고, 그 밖의 형태는 `unknown` 으로 접는다. 그리고 검사를 "결과가 입력과 같으면 통과" 가 아니라 "알려진 마스킹 형태와 일치해야 통과" 로 뒤집는다. 지금 형태는 마스킹이 모르는 입력을 전부 안전하다고 판정한다.
### 17.2 P3 — 금지 필드 검사가 키에만 적용되고 값에는 적용되지 않는다
```java
xdsResourceVersions.keySet().stream().filter(GrpcDiagnosticsRedactor::forbiddenField)
```
`redact(...)` 도 같다 — 금지 이름의 키를 버리고, 남은 값은 주소 필드일 때만 마스킹한다. 값 자체가 자격증명 형태인지는 보지 않는다.
`grpc-observability` 의 태그 정책은 값도 본다(UUID·`sha256:`·`bearer ` 패턴). 같은 저장소의 두 관측 편집기가 값 검사에서 갈린다.
xDS 자원 버전은 보통 짧은 숫자나 해시라 도달성이 낮다. 기록하는 이유는 두 편집기의 규율이 다르다는 점이다.
### 17.3 P3 — "실환경 증거" 가 두 리프에 반씩 있고 서로 만나지 않는다
이 리프가 능력별로 무엇이 실환경인지 정의한다.
```java
public static Set<Infrastructure> requiredFor(GrpcAdvancedCapability capability) { }
public static List<String> missingInfrastructure(GrpcAdvancedCapability capability, Set<Infrastructure> available) { }
```
그리고 `grpc-advanced-bootstrap` 이 승격 증거로 그것을 요구한다.
```java
public record GrpcAdvancedPromotionEvidence(
GrpcAdvancedCapability capability, , boolean realEnvironmentTest) { }
// ^^^^^^^^^^^^^^^^^^^^^^^^^^ 불리언 하나
```
`GrpcAdvancedPromotionGate.evaluate` 는 그 불리언이 거짓이면 "xds has no real environment test" 를 차단 사유로 낸다. 그 불리언을 무엇으로 채워야 하는지는 그쪽에서 답하지 않고, 답하는 코드가 이 리프에 있는데 두 쪽이 서로를 부르지 않는다.
결과: `GrpcAdvancedPromotionEvidence.complete(XDS, 7일)``realEnvironmentTest = true` 를 그냥 넣는다. xDS 통제 평면이 실제로 있었는지와 무관하다. 이 리프의 javadoc 이 경계한 상태 — "a suite that runs without the infrastructure passes and establishes nothing" — 를 승격 게이트가 그대로 통과시킬 수 있다.
**왜 P3 인가.** 두 리프 모두 배선되지 않았고 승격은 사람이 수행한다. 다만 이 두 조각이 존재하는 이유가 "그 판단을 코드로 적어 두는 것" 이므로, 판단의 절반이 다른 절반을 부르지 않는 것은 그 목적에 어긋난다. `grpc-advanced-edition` §17.2 가 같은 가족에서 같은 모양을 기록했다 — 두 승격 게이트가 서로를 부르지 않는다.
**수정.** `GrpcAdvancedPromotionEvidence.realEnvironmentTest` 를 불리언 대신 `Set<Infrastructure> availableInfrastructure` 로 바꾸고, 게이트가 `missingInfrastructure(capability, available)` 를 불러 그 결과를 차단 사유에 합친다. 그러면 "실환경 테스트를 했다" 가 선언이 아니라 능력별 목록에 대한 대조가 된다. 의존 방향도 맞는다 — 이 리프가 이미 bootstrap 을 의존하므로, 게이트가 이쪽을 부르려면 방향을 뒤집거나 `Infrastructure` 열거형을 bootstrap 으로 옮겨야 한다는 점은 함께 정해야 한다.
### 확인된 설계(문제 아님)
- **두 게이트를 모두 요구하고 하나만 있으면 생성자가 거부하는 것.**
- **CSDS 를 xDS 사용 시에만 등록하는 것과 그 근거** — 존재하지 않는 통제 평면 표면을 광고하지 않는다.
- **주소를 버리지 않고 가리는 판단** — 두 서브채널을 구별할 수 있어야 한다.
- **스냅숏이 스스로 편집 여부를 검사하는 것** — 편집을 우회한 값으로는 만들 수 없다(형태 범위는 §17.1).
- **능력별로 필요한 실제 인프라를 이름 지은 것** — 인프라 없이 통과하는 묶음은 없는 것보다 나쁘다. (승격 게이트와의 연결 없음은 §17.3.)
- **`requiredFor` 의 switch 가 15개 능력을 전부 나열하고 `default` 를 두지 않은 것** — 능력이 하나 늘면 이 파일이 컴파일되지 않는다. 새 능력이 조용히 "인프라 불필요" 로 분류되지 않는다.
- **픽스처가 금지 대상 셋을 일부러 담고 있는 것** — 통제 평면 토큰·피어 인증서·원시 소켓 주소. 파일 안 주석이 그 의도를 적고("so the redactor is tested against data shaped like the real thing rather than against a string somebody invented for the assertion"), 테스트가 편집 전에 그 셋이 실제로 들어 있는지부터 단언한 뒤 편집 결과를 본다.
---
## Source anchors
```
src/grpc-advanced/grpc-advanced-diagnostics/build.gradle:1-10
main/java/…/diagnostics/GrpcAdvancedInfrastructureTestkit.java:1-87
main/java/…/diagnostics/GrpcDiagnosticsRedactor.java:1-72
main/java/…/diagnostics/GrpcChannelDiagnosticsSnapshot.java:1-63
main/java/…/diagnostics/GrpcChannelDiagnosticsPolicy.java:1-55
test/java/…/diagnostics/GrpcChannelDiagnosticsPolicyTest.java:1-229
test/resources/xds/control-plane-snapshot.json:1-24
```
@@ -0,0 +1,271 @@
# grpc-advanced-edition 완전 해부
> 상태: COMPLETE
> 재오픈 게이트: cycle 2 — `src/main` production 6파일 326줄, 스키마 리소스 1개 28줄, test 2파일 211줄 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음.
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/grpc-advanced/grpc-advanced-edition`
> SSOT owner: `grpc-advanced-edition`
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지
- `allowed_dependencies`: `["grpc-core-api", "grpc-proto-contract", "grpc-advanced-bootstrap"]`
- `runtime_memberships`: **`[]`** — build-only
| 파일 | LOC |
|---|---:|
| `GrpcEditionCompatibilityReport` | 72 |
| `GrpcEdition2026WatchReport` | 70 |
| `GrpcEdition2024Gate` | 61 |
| `GrpcEdition2026Guard` | 48 |
| `GrpcEdition2024Policy` | 47 |
| `GrpcEdition2026Status` | 28 |
| **main java 합계 (6파일)** | **326** |
| `compatibility.proto` | 28 |
| `GrpcEdition2024GateTest` · `GrpcEdition2026GuardTest` | 118 · 93 |
### Coverage ledger
| scope | count | disposition | reason |
|---|---:|---|---|
| `main/java/**` | 6 | `FULL_READ` | 전 본문 축자 확인 |
| `main/resources/proto/edition2024/*.proto` | 1 | `FULL_READ` | 28줄 전문 |
| `test/java/**` | 2 | `FULL_READ` | 211줄 전 본문 · 테스트 13개 |
| `build.gradle` | 1 | `FULL_READ` | 10줄 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
`UNCLASSIFIED` 0.
---
## 1. 모듈의 정체
```groovy
// build.gradle:3-5
// Protobuf Edition lanes. Edition 2024 is an opt-in Advanced lane that must produce cross-consumer
// compile evidence before anything public moves onto it; Edition 2026 is a watch lane that records
// release/toolchain status and is refused as a Stable contract source.
```
두 레인이 성격이 다르다. 하나는 증거를 만들어야 승격되는 레인이고, 하나는 사실만 기록하는 감시 레인이다.
## 2. Edition 2024 — 두 결정을 분리한다
`GrpcEdition2024Policy` 는 모듈 옵트인과 공개 서비스 이동을 따로 다룬다.
```java
public boolean serviceMayMove(String serviceName) {
return !publicServices.contains(serviceName) || promotionApproved;
}
```
> "the opt-in is a build decision and the promotion is a consumer-migration decision."
그 이유가 클래스 javadoc 에 있다.
> "an edition change is invisible to the schema's owner and consequential for its consumers: the
> wire bytes are usually identical, so nothing fails locally, and the breakage appears in whichever
> consumer's generator handles the edition's features differently."
## 3. 세 종류의 호환성
`GrpcEditionCompatibilityReport` 는 하나가 아니라 셋을 본다.
| 비교 | 답하는 질문 |
|---|---|
| wire | 저장된 메시지와 이동 중 메시지가 계속 디코딩되는가 |
| JSON | 전사 프록시와 브라우저 클라이언트가 계속 동작하는가 |
| source(툴체인별) | 생성된 코드가 여전히 컴파일되는가 |
> "An edition migration can preserve the first two and break the third for a language whose
> generator handles the edition's features differently — which is exactly the failure this lane
> exists to find before a public service moves."
그리고 툴체인 결과가 비어 있으면 생성자가 거부한다 — "Java alone is not cross-language evidence."
## 4. 레인 실패의 범위
```java
blocksStableRelease() 항상 false
blocksEditionPromotion() 항상 true
```
> "Without that split, an opt-in lane that nobody depends on can hold up every release, and the
> first response to that is to stop running the lane."
두 메서드 모두 상수를 돌려주고 javadoc 이 그 이유를 적는다 — "Stated as a method so the property is tested rather than described."
## 5. Edition 2026 — 감시 레인
네 게이트를 따로 추적한다 — 명세, `protoc`, Buf, 자바 런타임.
> "An edition can be released by the specification while `protoc` does not emit it, or emitted while
> Buf cannot lint it, or lintable while the Java runtime does not implement its features. A single
> 'supported yes/no' flag collapses four different waiting states into one."
보고서는 날짜를 필수로 요구한다 — 날짜 없는 감시 기록은 오래된 메모와 구분되지 않는다.
그리고 가드가 보고서와 **무관하게** 거부한다.
> "The guard is deliberately not conditional on the watch report… letting the same record also
> authorise use means the moment somebody marks four fields SUPPORTED, a schema can move onto an
> edition with no promotion decision, no consumer migration and no ADR. Turning the watch into a
> lane that can be used is a code change here, and that is the point."
## 10. 테스트 레인
두 테스트 211줄 · 13개.
`GrpcEdition2024GateTest` 7개 — 모듈 옵트인의 기본 꺼짐, 공개 서비스의 승격 요구, 자바 단독이 증거가 아님(빈 툴체인 맵 거부 포함), 세 호환성의 분리, 승격 차단 셋, 레인 실패의 격리, 그리고 픽스처 파일 자체를 리소스로 읽어 `edition = "2024";` 로 시작하는지와 `features.field_presence = EXPLICIT` 를 담는지 대조하는 것.
`GrpcEdition2026GuardTest` 6개 — 네 게이트의 개별 추적, 날짜 필수, `SUPPORTED` 만 usable, 전부 SUPPORTED 여도 가드가 거부, 거부 메시지의 미해결 항목, 감시 레인이 Stable 빌드를 막지 않음.
`theGuardIsNotConditionalOnTheReport` 가 이 레인에서 가장 중요한 한 줄을 붙든다 — 보고서가 `readyToEvaluate() == true` 인 상태를 만들어 놓고, 그래도 `requireNotUsedAsSource` 가 던지는지 확인한다. 기록이 사용을 허가하지 않는다는 설계가 테스트로 고정되어 있다.
## 12. negative-space probes
**12.1 도달성.** 리프 밖에서 이 리프를 참조하는 것이 하나도 없다 — 자바 코드도, build.gradle 도.
```
$ grep -rn "advanced.edition" --include=*.java src/ | grep -v grpc-advanced-edition/
grpc-core-api/…/GrpcStableModuleCatalog.java:38: "grpc-advanced-edition", ← 목록 안의 문자열
$ grep -rn "grpc-advanced-edition" --include=*.gradle src/
(매치 없음)
```
**12.2 선언된 의존 셋이 전부 쓰이지 않는다.**
```groovy
api project(':grpc:grpc-core-api')
api project(':grpc:grpc-proto-contract')
api project(':grpc-advanced:grpc-advanced-bootstrap')
```
이 리프의 자바 6파일에는 `dev.caskeleton` 으로 시작하는 import 가 **한 줄도 없다.**
```
$ grep -rn "^import dev.caskeleton" grpc-advanced/grpc-advanced-edition/src/main/java/
(매치 없음)
```
여섯 파일이 쓰는 것은 `java.time` · `java.util` 뿐이다. 세 의존 중 어느 것도 코드에 닿지 않는다.
셋 중 둘은 의도를 읽을 수 있다 — `grpc-proto-contract``compatibility.proto` 가 그쪽 스키마 규칙의 관할이라는 선언으로, `grpc-advanced-bootstrap``GrpcAdvancedCapability.EDITION_2024` 가 이 레인의 등급을 들고 있다는 선언으로. 다만 어느 쪽도 코드로 연결되어 있지 않고, 그 연결 없음이 §17.2 가 지적한 "두 게이트가 서로를 부르지 않는다" 와 같은 사실의 빌드 파일 쪽 표현이다.
`grpc-advanced-bootstrap` §12.1 이 반대편에서 같은 것을 기록했다 — 그 리프를 의존 선언한 다섯 모듈 중 실제로 부르는 것은 둘뿐이고, 이 리프는 부르지 않는 셋 중 하나다.
**12.3 대조군.** 무조건 상수를 돌려주고 그것을 테스트가 붙드는 형태가 같은 가족의 `GrpcAdvancedPromotionGate.capabilitiesDraggedAlong` 과 같다. 이 리프에는 그런 메서드가 넷 있다 — `blocksStableRelease` · `blocksEditionPromotion` · `allowedAsStableSource` · `blocksStableBuild`.
그중 셋(`blocksStableRelease` · `blocksEditionPromotion` · `blocksStableBuild`)은 `capabilitiesDraggedAlong` 과 같은 한계를 갖는다 — 리터럴을 리터럴과 비교하므로, 그 속성이 실제로 지켜지는지는 이 저장소에 릴리스 파이프라인이 생겨야 알 수 있다. `grpc-advanced-bootstrap` §17.5 에 그 판정을 적어 두었다.
넷째 `allowedAsStableSource` 는 다르다. 같은 클래스의 `requireNotUsedAsSource` 가 그 상수와 **독립적으로** 무조건 던지고, `theGuardIsNotConditionalOnTheReport` 가 "전부 SUPPORTED 인 보고서"라는 실제 상태를 만들어 그 독립성을 확인한다. 상수 하나를 읽는 것이 아니라 설계 속성을 실행으로 밟는다.
**12.2 대조군.** 무조건 상수를 돌려주고 그것을 테스트가 붙드는 형태가 같은 가족의 `GrpcAdvancedPromotionGate.capabilitiesDraggedAlong` 과 같다. 이 저장소가 "주석이 주장하는 대신 테스트가 붙든다" 를 반복해서 쓴다.
**12.5 저장소의 `.proto` 넷.** 이 리프의 `compatibility.proto``edition = "2024";` 로 시작하므로 `grpc-proto-contract``PROTO3_SYNTAX` 규칙에 걸린다. 그 검증기의 커밋 스키마 테스트가 파일 목록을 하드코딩해 이 파일을 판정하지 않으므로 지금은 충돌하지 않는다. 그 테스트를 전수 훑기로 바꾼다면(그쪽 §17.3) 이 파일에 대한 면제가 함께 필요하다.
**12.4 드리프트.** build.gradle 이 서술한 두 레인의 성격이 코드와 일치한다. 드리프트 없음.
## 16. 확인하지 못한 것
- `protoc` 을 돌려 이 편집 파일이 실제로 컴파일되는지 확인하지 않았다. 저장소에 protobuf 플러그인이 없다.
- 테스트를 실행하지 않았다. 13개 전부 본문으로만 확인했다.
- 세 의존이 쓰이지 않는다는 것(§12.2)은 `^import dev.caskeleton` grep 으로 판정했다. 같은 패키지 안의 타입이나 완전 한정명 사용이라면 잡히지 않는다 — 다만 이 리프의 패키지는 `dev.caskeleton.grpc.advanced.edition` 하나이고 세 의존의 패키지와 겹치지 않는다.
- 편집 기능(`features.field_presence = EXPLICIT`)이 proto3 의 `optional` 과 같은 유선 결과를 내는지 확인하지 않았다. 그것이 이 레인의 질문이고 §17.1 이 그 질문에 답할 수 없는 이유다.
## 17. 손볼 것
### 17.1 P2 — 비교 픽스처에 비교 대상이 없다
`compatibility.proto` 의 주석이 존재 이유를 적는다.
> "It exists to be compiled beside its **proto3 twin** and compared: same fields, same numbers, same
> JSON names, with presence expressed by the edition's features rather than by `optional`. The
> lane's question is whether the two produce the same wire bytes and the same JSON, and **answering
> it needs both files to exist.**"
그 쌍둥이가 저장소에 없다.
```
$ grep -rn "DocumentSummary" --include=*.proto --include=*.java .
./src/grpc-advanced/grpc-advanced-edition/src/main/resources/proto/edition2024/compatibility.proto:17
```
한 곳뿐이다. 같은 필드와 번호를 proto3 로 선언한 파일이 없으므로 비교가 성립하지 않는다.
그리고 두 번째 전제도 없다. 이 저장소에는 protobuf 플러그인이 어디에도 없다 — `grpc-proto-contract``adapter-inbound-grpc` 의 build.gradle 이 그 사실을 주석으로 명시한다. 그러므로 편집 파일도 proto3 파일도 컴파일되지 않고, 유선 바이트와 JSON 을 비교할 산출물 자체가 만들어지지 않는다.
결과적으로 `GrpcEditionCompatibilityReport` 는 사람이 손으로 채우는 기록이 된다. 승격 게이트가 그것을 읽어 판정하므로, 게이트의 입력이 측정이 아니라 선언이다.
**등급.** Advanced 가족이라 오늘의 배포에는 영향이 없다. 기록하는 이유는 이 리프의 목적이 "공개 서비스가 옮겨 가기 전에 그 실패를 찾는 것" 이고, 그 실패를 찾을 장치가 픽스처 하나만 있고 짝이 없다는 점이다.
**수정.** `compatibility_proto3.proto` 를 같은 디렉터리에 두어 필드·번호·JSON 이름을 맞추고, 두 파일을 컴파일해 산출물을 비교하는 레인을 만든다. 그 레인이 생기기 전까지는 `GrpcEditionCompatibilityReport` 가 측정이 아니라 선언이라는 것을 자바독에 적는 편이 낫다.
### 17.2 P3 — 승격 차단 목록에 담금 기간과 실환경 항목이 없다
`GrpcEdition2024Gate.promotionBlockers` 가 보는 것은 셋이다 — 호환성 보고서의 문제들, 소비자 이관 계획, 승격 ADR.
같은 가족의 `GrpcAdvancedPromotionGate``EDITION_2024` 능력에 대해 일곱 증거 항목과 7일 담금을 요구한다. 두 게이트가 같은 능력의 승격을 서로 다른 기준으로 판정한다.
두 게이트가 각각 다른 것을 묻는다고 볼 수도 있다 — 하나는 편집 자체의 호환성, 하나는 능력의 운영 준비도. 다만 어느 쪽도 상대를 부르지 않고, 문서에도 두 게이트의 관계가 적혀 있지 않다. 승격을 실제로 수행할 때 어느 쪽을 만족해야 하는지가 코드에서 답해지지 않는다.
수정은 `promotionBlockers``GrpcAdvancedPromotionGate.evaluate` 의 결과를 포함하게 하거나, 두 게이트의 역할 분담을 자바독에 적는 것이다.
### 17.3 P3 — 정책의 자바독이 하지 않는 거부를 한다고 적고, 승격 승인이 두 곳에 따로 있다
**첫째, 서술과 코드가 어긋난다.**
```java
/** Copies both sets and refuses an approval nobody recorded. */
public GrpcEdition2024Policy {
if (optedInModules == null || publicServices == null) {
throw new IllegalArgumentException("an edition policy states both sets");
}
optedInModules = Set.copyOf(optedInModules);
publicServices = Set.copyOf(publicServices);
}
```
"refuses an approval nobody recorded" 에 해당하는 검사가 없다. `promotionApproved` 는 읽히지도 검증되지도 않고 그대로 저장된다. `new GrpcEdition2024Policy(Set.of(), Set.of(), true)` — 옵트인한 모듈도 공개 서비스도 없는데 승인만 참인 정책 — 이 아무 저항 없이 만들어지고, `serviceMayMove` 는 모든 서비스에 참을 답한다.
**둘째, 같은 사실이 두 곳에 따로 있다.**
| 어디 | 무엇 |
|---|---|
| `GrpcEdition2024Policy.promotionApproved` | 승격이 승인되었는가 (record 성분) |
| `GrpcEdition2024Gate.promotionBlockers(..., boolean promotionAdr)` | 승격 ADR 이 있는가 (메서드 인자) |
게이트는 정책을 인자로 받지도, 참조하지도 않는다. 그래서 "ADR 이 없다"고 판정한 게이트와 "승인되었다"고 답하는 정책이 동시에 성립할 수 있고, 둘을 맞추는 코드가 없다. §17.2 가 지적한 "두 게이트가 서로를 부르지 않는다" 와 같은 구조가 정책과 게이트 사이에도 있다.
**왜 P3 인가.** 정책도 게이트도 production 호출자가 없고(§12.1), 승격은 사람이 수행하는 절차다. 다만 이 리프가 존재하는 이유가 "그 절차를 코드로 적어 두는 것" 이므로, 적힌 절차 안에서 같은 사실이 둘로 갈라져 있는 것은 그 목적에 어긋난다.
**수정.** `promotionBlockers``GrpcEdition2024Policy` 를 받아 `promotionApproved``promotionAdr` 자리에 쓰고, 정책 생성자가 자바독대로 "승인이 참이면 그 근거(공개 서비스 집합이 비어 있지 않을 것 등)"를 요구한다. 어느 쪽도 하지 않겠다면 자바독의 그 문장을 지운다.
### 확인된 설계(문제 아님)
- **모듈 옵트인과 공개 서비스 이동을 분리한 것** — 빌드 결정과 소비자 이관 결정은 다른 결정이다.
- **호환성을 셋으로 나눈 것** — 앞의 둘이 보존돼도 셋째가 깨지는 것이 이 레인이 찾는 실패다.
- **툴체인 결과가 비면 생성자가 거부하는 것** — 자바 하나는 교차 언어 증거가 아니다.
- **레인 실패가 Stable 릴리스를 막지 않게 한 것과 그 근거** — 막으면 사람들이 레인을 끄게 된다.
- **감시 레인의 네 게이트를 따로 추적한 것.**
- **감시 보고서에 날짜를 필수로 둔 것.**
- **가드를 보고서와 무관하게 만든 것** — 기록이 사용을 허가하지 않는다. 사용하려면 코드를 고쳐야 한다.
---
## Source anchors
```
src/grpc-advanced/grpc-advanced-edition/build.gradle:1-10
main/java/…/edition/GrpcEditionCompatibilityReport.java:1-72
main/java/…/edition/GrpcEdition2026WatchReport.java:1-70
main/java/…/edition/GrpcEdition2024Gate.java:1-61
main/java/…/edition/GrpcEdition2026Guard.java:1-48
main/java/…/edition/GrpcEdition2024Policy.java:1-47
main/java/…/edition/GrpcEdition2026Status.java:1-28
main/resources/proto/edition2024/compatibility.proto:1-28
test/java/…/edition/GrpcEdition2024GateTest.java:1-118
test/java/…/edition/GrpcEdition2026GuardTest.java:1-93
```
@@ -0,0 +1,237 @@
test/resources/xds/bootstrap.json
---
# grpc-advanced-resilience 완전 해부
> 상태: COMPLETE
> 재오픈 게이트: cycle 2 재통독(2026-09-01) — `src/main` production 16파일 940줄 + `src/test` 4파일 577줄 + `src/test/resources` 1파일 축자 통독 완료. `STRUCTURAL_ONLY` 는 `gradle.lockfile` 하나.
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/grpc-advanced/grpc-advanced-resilience`
> SSOT owner: `grpc-advanced-resilience`
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지
- `allowed_dependencies`: core-api · policy · client · discovery · advanced-bootstrap
- `runtime_memberships`: **`[]`** — build-only
| 패키지 | 파일 | LOC |
|---|---:|---:|
| `resilience` (헤징) | 4 | 242 |
| `xds` | 4 | 252 |
| `discovery` (사용자 정의 리졸버·LB) | 7 | 446 |
### Coverage ledger
| scope | count | disposition | reason |
|---|---:|---|---|
| `main/java/**` | 16 | `FULL_READ` | 940줄 전 본문 |
| `test/java/**` | 4 | `FULL_READ` | 577줄 |
| `test/resources/xds/bootstrap.json` | 1 | `FULL_READ` | 커밋된 부트스트랩 픽스처 — §12.5 |
| `build.gradle` | 1 | `FULL_READ` | 전문 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
`UNCLASSIFIED` 0.
---
## 1. 모듈의 정체
```groovy
// build.gradle:3-4
// Resilience and discovery capabilities that Stable refuses: read-only unary hedging, the custom
// name resolver SPI, the custom load balancer SPI, and the proxyless xDS experimental profile.
```
## 2. 헤징은 읽기 전용 단항만
`GrpcHedgingEligibility` 가 세 조건을 순서대로 본다 — 단항이 아님, 읽기 전용이 아님, 재시도 소유자가 in-process 헤징을 허락하지 않음.
> "A hedged mutation runs twice by design rather than by accident — both attempts are in flight, both
> may reach the server, and an idempotency key does not help because the second attempt is not a
> retry of a failure but a duplicate of a success in progress. A hedged stream is worse still: two
> streams deliver two prefixes."
멱등 키가 왜 도움이 되지 않는지를 한 문장으로 정리한 것이 이 리프의 핵심 판단이다.
## 3. 헤징 예산
토큰 버킷이다. 헤지 하나가 `round(1/ratio)` 토큰을 쓰고, 완료된 호출 하나가 토큰 하나를 돌려준다. 상한이 조용한 구간 뒤의 폭주를 제한한다.
비율 상한이 0.5 이고 그 근거가 적혀 있다.
> "a hedging ratio above 0.5 means more than half of all calls are duplicated, which is a load
> decision rather than a latency one"
그리고 왜 재시도 예산보다 더 급한지도 적는다.
> "A retry happens after a failure; a hedge happens on a call that might have succeeded, so a fleet
> that hedges without a budget doubles its backend load in the steady state and doubles it again the
> moment latency rises."
소비는 정확한 비교 후 교체 루프다 — 이 가족에서 원자성을 제대로 다룬 몇 안 되는 곳이다.
## 4. xDS 시작 가드
두 거절이 있고 javadoc 이 둘째를 더 중요하다고 적는다.
> "xDS working in a deployment is not the same claim as the platform supporting it: it brings a
> control plane, its outage modes, its own security boundary and its own version skew, and the
> Stable support statement covers DNS and static targets. A support matrix that quietly widens is a
> support matrix nobody can rely on."
시작 차단 사유는 둘 — 능력이 사용 가능하지 않음, 그리고 애플리케이션이 재시도 정책을 함께 정의함.
> "with xDS the control plane owns it, and defining it in both places makes the winner depend on
> resolution order"
부트스트랩 대조는 세 가지를 본다 — `xds_servers` 선언, 통제 평면 채널의 TLS, 프로파일의 자원 이름공간.
> "a client whose bootstrap names a namespace the deployment did not configure subscribes
> successfully and receives another team's routing. Nothing errors — the control plane answers, the
> resources parse, and traffic goes somewhere nobody chose."
## 5. 사용자 정의 리졸버·LB 안전 규칙
리졸버는 주소와 검증된 서비스 설정만 줄 수 있다.
> "A resolver runs inside the channel and speaks to something outside the deployment. Everything it
> can put into an update is therefore attacker-influenced in the worst case."
권한 문자열 형태 검사, 개정 번호의 전진 요구, 자격증명 형태 필드 거부 셋이다.
선택기는 두 규칙을 받는다 — 리졸버가 준 엔드포인트만 고를 수 있고, 던지면 결정적 대체로 떨어진다.
> "a picker that can invent an address can send a request anywhere … a picker bug should degrade the
> balancing rather than the availability"
## 12. negative-space probes
**12.1 도달성.** Advanced 가족이므로 배선 경로가 없다.
**12.2 대조군 — 원자성.** `GrpcHedgingBudget.tryConsume` 이 비교 후 교체 루프를 정확히 쓴다. 같은 가족의 `GrpcAdmissionController.tryAdmit`·`GrpcStreamAdmission.tryAdmit` 은 같은 문제를 비원자적으로 푼다. 정본이 이 리프에 있다.
**12.3 리프 밖 참조 0.** 세 패키지 각각을 확인했다 — `advanced.discovery`·`advanced.resilience`·`advanced.xds` 를 import 하는 파일이 이 리프 밖에 없다. `grpc-advanced-bootstrap` 도 포함해서다.
**12.4 드리프트.** build.gradle 이 서술한 네 능력이 전부 존재한다.
**12.5 대조군 — 커밋된 픽스처를 판정기에 넣는가.** 이 리프는 넣는다.
```java
// GrpcXdsStartupGuardTest: "the committed bootstrap fixture agrees with the profile it is meant to serve"
String bootstrap = resource("xds/bootstrap.json");
assertThat(GrpcXdsStartupGuard.bootstrapMismatches(profile, bootstrap)).isEmpty();
```
같은 자리에서 `grpc-advanced-compat` 은 넣지 않는다 — `envoy.yaml` 을 부분 문자열로만 확인하고 `GrpcWebProxyContract` 에 넣지 않는다(그 리프 §17.3). 두 리프가 같은 재료를 갖고 한 쪽만 고리를 닫았다.
## 16. 확인하지 못한 것
- 실제 xDS 통제 평면을 세워 부트스트랩 대조를 재현하지 않았다.
- 헤징 예산의 정상 상태 비율을 부하로 측정하지 않았다. 토큰 계산으로 판정했다.
## 17. 손볼 것
### 17.1 P3 — 부트스트랩 대조가 문서 어디든의 부분 문자열을 본다
```java
if (!bootstrapJson.contains("\"xds_servers\"")) { }
if (!bootstrapJson.contains("\"channel_creds\"") || !bootstrapJson.contains("\"tls\"")) { }
if (!bootstrapJson.contains(profile.resourceNamespace())) { }
```
세 검사가 모두 문서 전체에 대한 부분 문자열 포함이다. JSON 파서를 쓰지 않은 이유는 자바독이 밝힌다 — 세 필드를 보려고 파서를 xDS 를 켜는 모든 배포의 실행 클래스패스에 올리지 않겠다는 것이다. 그 판단 자체는 이 저장소의 다른 결정들과 일관된다.
다만 검사의 형태가 그 판단보다 느슨하다.
- `"tls"` 가 문서 어디에든 있으면 통과한다. 통제 평면 채널이 `insecure` 로 설정되어 있고 다른 곳(예: 서버 리스너 설정)에 `tls` 라는 낱말이 있으면 두 번째 검사가 지나간다.
- 자원 이름공간이 주석·다른 필드·다른 서버 항목에 있어도 통과한다. 세 번째 검사가 막으려는 것은 "이 클라이언트가 자기 이름공간 밖을 구독하는 것" 인데, 문자열이 어딘가에 있다는 것은 그것이 이 클라이언트의 구독 대상이라는 뜻이 아니다.
그리고 이 검사가 막으려는 실패는 자바독이 스스로 "조용하다" 고 적은 것이다 — 아무것도 오류가 되지 않는 종류다. 느슨한 검사와 조용한 실패의 조합이 이 항목을 기록하는 이유다.
수정은 파서를 들이지 않고도 가능하다 — `"channel_creds"` 를 포함하는 객체 범위 안에서 `"type"` 값을 찾는 정도의 구조 인식이면 두 번째 검사가 실제 조건에 가까워진다. 또는 파서를 테스트 범위에만 두고 이 가드는 형태를 좁힌 정규식으로 바꾼다.
### 17.2 P3 — 대체 선택기는 사용자 정의 선택기가 받는 보호를 받지 않는다
```java
try {
chosen = picker.pick(selectable);
} catch (RuntimeException pickerFailure) {
return GrpcLoadBalancerDecision.fallback(fallback.pick(selectable), "");
}
if (chosen == null || !selectable.contains(chosen)) {
return GrpcLoadBalancerDecision.fallback(fallback.pick(selectable), "");
}
```
`fallback.pick(selectable)` 은 감싸이지 않는다. 대체가 던지면 예외가 그대로 올라가고, 널이나 목록 밖 엔드포인트를 돌려주면 그대로 결정이 된다.
기본 생성자는 플랫폼의 라운드 로빈을 대체로 쓰므로 지금은 안전하다. 그러나 두 인자 생성자가 임의의 선택기를 대체로 받고, 그 인자에는 아무 제약이 없다.
이 클래스의 존재 이유가 "선택기 버그가 가용성이 아니라 균형을 저하시키게 하는 것" 인데, 대체 선택기의 버그는 가용성을 저하시킨다.
수정은 대체 호출도 같은 검사를 지나게 하거나(그 결과가 널이거나 목록 밖이면 플랫폼 라운드 로빈으로 한 번 더 떨어진다), 두 인자 생성자를 없애 대체를 플랫폼 것으로 고정하는 것이다.
### 17.3 P2 — 리졸버의 개정 가드가 비교 후 교체가 아니다
`GrpcCustomResolver` 의 javadoc 이 지키겠다고 하는 것은 명확하다.
> "Stale revisions and empty endpoint sets are dropped rather than propagated."
빈 집합은 `GrpcEndpointSnapshot` 의 생성자가 지키므로 성립한다. 개정 가드는 그렇지 않다.
```java
public List<String> offer(GrpcResolverUpdate update) {
if (closed.get()) { return List.of(); }
List<String> violations = GrpcResolverSafetyPolicy.violations(update, applied.get()); // ← 읽기
if (!violations.isEmpty()) { return violations; }
applied.set(update.snapshot()); // ← 조건 없는 쓰기
listener.accept(update);
return List.of();
}
```
`AtomicReference` 를 쓰면서 읽기와 쓰기 사이에 원자성이 없다. 개정 5 와 6 을 든 두 스레드가 같은 `applied`(개정 4)를 읽으면 둘 다 `supersedes` 를 통과하고, 나중에 `set` 하는 쪽이 이긴다. 6 이 먼저 쓰이고 5 가 덮으면 **채널이 옛 엔드포인트로 되돌아간다** — 개정 번호가 존재하는 이유가 정확히 그것을 막는 것이다.
`listener.accept(update)``set` 밖에 있으므로, `applied` 의 최종 값이 옳더라도 리스너(=채널)가 받는 순서는 뒤집힐 수 있다. 채널은 마지막으로 받은 것을 믿는다.
같은 형태가 이 가족에 셋이다.
| 자리 | 형태 |
|---|---|
| `GrpcHedgingBudget.tryConsume`(이 리프) | 비교 후 교체 루프 — 정확 |
| `GrpcCredentialRotationManager.rotate`·`completeDrain`(grpc-policy §17.2) | 읽고 조건 없이 쓴다 |
| `GrpcChannelRuntimeRegistry.rotate`(grpc-client) | 같은 형태 |
| `GrpcCustomResolver.offer`(여기) | 같은 형태 |
정본이 같은 리프 안에 있다는 점이 §12.2 의 대조와 같다 — 이 리프는 예산에서는 CAS 를 쓰고 리졸버에서는 쓰지 않는다.
**시험이 보지 못하는 이유.** `a stale revision is dropped rather than applied` 는 단일 스레드에서 개정 2 를 적용한 뒤 개정 1 을 제시한다. 순차적으로는 가드가 정확히 작동한다.
**등급.** 이 리프가 배선되지 않으므로 P2. 리졸버는 정의상 외부 발견 소스가 밀어 넣는 것이고, 그 소스가 한 스레드만 쓴다는 보장은 이 클래스가 하지 않는다.
**수정.** `applied.updateAndGet` 안에서 판정과 교체를 함께 하거나, `compareAndSet(observed, snapshot)` 이 실패하면 다시 읽어 판정한다. 리스너 통지는 성공한 CAS 뒤에 그 CAS 가 이긴 순서로 해야 한다 — 예산 쪽의 `tryConsume` 루프가 같은 리프 안의 본보기다.
### 확인된 설계(문제 아님)
- **헤징을 읽기 전용 단항으로 한정하고, 멱등 키가 왜 도움이 되지 않는지를 명시한 것.**
- **헤징 예산의 비율 상한 0.5 와 그 근거.**
- **예산 소비를 정확한 비교 후 교체로 구현한 것.**
- **xDS 를 Stable 지원으로 광고할 수 없게 상수로 못박은 것.**
- **애플리케이션과 통제 평면이 재시도를 함께 정의하는 것을 시작 차단 사유로 둔 것.**
- **부트스트랩과 프로파일의 불일치를 검사 대상으로 삼은 것** — 두 문서를 다른 사람이 다른 저장소에서 쓴다.
- **리졸버가 자격증명을 실을 수 없게 한 것과 권한 문자열 형태를 제한한 것.**
- **리졸버 업데이트의 개정 번호 전진을 요구한 것.**
- **선택기가 리졸버가 준 엔드포인트만 고르게 한 것.**
---
## Source anchors
```
src/grpc-advanced/grpc-advanced-resilience/build.gradle
main/java/…/resilience/(GrpcHedgingEligibility · GrpcHedgingBudget · GrpcHedgingPolicy · GrpcHedgingResult)
main/java/…/xds/(GrpcXdsStartupGuard · GrpcXdsFailurePolicy · GrpcXdsProfile · GrpcXdsResourceSnapshot)
main/java/…/discovery/(GrpcResolverSafetyPolicy · GrpcLoadBalancerSafetyPolicy · GrpcCustomResolver · GrpcLoadBalancerDecision · GrpcEndpointSnapshot · GrpcEndpointCandidate · GrpcResolverUpdate · GrpcLoadBalancerPicker)
```
@@ -0,0 +1,237 @@
# grpc-advanced-streaming 완전 해부
> 상태: COMPLETE
> 재오픈 게이트: cycle 2 재통독(2026-09-01) — `src/main` production 14파일 833줄 + `src/test` 4파일 429줄 축자 통독 완료. §17.1·§17.2 를 독립적으로 재도출했고 둘 다 성립한다. `STRUCTURAL_ONLY` 는 `gradle.lockfile` 하나.
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/grpc-advanced/grpc-advanced-streaming`
> SSOT owner: `grpc-advanced-streaming`
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지
- `allowed_dependencies`: `["grpc-core-api", "grpc-policy", "grpc-advanced-bootstrap"]`
- `runtime_memberships`: **`[]`** — build-only
| 파일 | LOC |
|---|---:|
| `GrpcClientMessageDeduplicator` | 123 |
| `GrpcDemandController` | 105 |
| `GrpcBidiSession` · `GrpcBidiSequenceTracker` | 77 · 62 |
| `GrpcBidiDirectionState` · `GrpcClientStreamResumeDecision` · `GrpcClientStreamCheckpoint` | 61 · 58 · 56 |
| `GrpcClientStreamPolicy` · `GrpcClientStreamSessionId` · `GrpcManualFlowControlPolicy` | 50 · 46 · 45 |
| `GrpcBidiResumeState` · `GrpcDemandDecision` · `GrpcClientStreamState` · `GrpcClientStreamMessage` | 42 · 40 · 36 · 32 |
| test 4파일 | 429 |
### Coverage ledger
| scope | count | disposition | reason |
|---|---:|---|---|
| `main/java/**` | 14 | `FULL_READ` | 833줄 전 본문 |
| `test/java/**` | 4 | `FULL_READ` | 429줄 |
| `build.gradle` | 1 | `FULL_READ` | 전문 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
`UNCLASSIFIED` 0.
---
## 1. 모듈의 정체
```groovy
// build.gradle:3-5
// The streaming shapes the Stable plan deliberately excludes: client streaming sessions with
// dedup/checkpoint/resume, bidirectional sessions with independent per-direction sequences, and the
// manual flow-control approval API.
```
## 2. 적용됨과 수신됨을 구분한다
`GrpcClientStreamCheckpoint` javadoc:
> "Applied, not received. The distinction is the whole contract: the transport acknowledging a
> message means it reached the server's buffer, and a checkpoint means the application committed its
> effect. A resume that continues from a transport acknowledgement skips everything that was
> received and not yet applied when the connection died."
그리고 체크포인트는 뒤로 갈 수 없다 — 뒤로 가려는 시도는 두 기록자가 한 세션을 체크포인트하고 있다는 뜻이다.
## 3. 집합이 아니라 체크포인트
`GrpcClientMessageDeduplicator` javadoc:
> "Checkpoint-based rather than a set of seen keys. **A set grows without bound for the life of a
> session** and answers 'have I seen this' — which is not quite the question. The question is 'has
> this been applied', and a monotonic applied-sequence answers it in constant space and survives the
> process restart that a set does not."
판정은 셋이다 — 이미 적용됨이면 재생, 다음 순번보다 앞서면 간극, 아니면 적용.
재개 판정은 두 겹이다. 제시한 호출자가 세션 소유자와 다르면 거절하고, 체크포인트가 없으면 새 세션으로 돌린다.
> "the server holds no checkpoint for this session; resuming would leave its prefix either lost or
> applied twice, with nothing to tell which"
그리고 적용 기록의 자바독이 저장소 쪽 요구를 적는다 — 적용 효과와 체크포인트는 한 트랜잭션에 있어야 하며, 따로 커밋하면 효과는 내구적이고 체크포인트는 아닌 창이 생긴다.
## 4. 방향마다 독립된 순번
> "the client's message 5 and the server's message 5 are unrelated events, and a shared counter makes
> a resume token from one side meaningless to the other — so a reconnect either skips or replays,
> depending on which side moved faster."
절반 닫기와 취소가 방향별로 따로 있다.
## 5. 수동 흐름 제어
승인이 record 의 필드이고 거짓이면 생성자가 거부한다.
> "Approval is a field because this capability is granted per method, not per service. A method that
> reads a large result set benefits; the one next to it does not, and enabling both because they
> share a service is how the second one acquires a bug nobody was looking for."
수요 상한과 교착 감시가 필수다 — 상한 없는 `request(n)` 은 단계만 늘린 무제한 버퍼링이다.
감시견은 잠들지 않고 두 시각을 비교한다 — 마지막으로 수요를 요청한 때와 마지막으로 메시지가 움직인 때. 둘 다 시간 제한만큼 멈춰 있으면 교착이다.
## 10. 테스트 레인
네 테스트 429줄. 중복 제거 판정과 재개, 수요 상한과 교착, 방향별 순번, 클라이언트 스트림 정책 거부를 확인한다.
## 12. negative-space probes
**12.1 도달성.** Advanced 가족이므로 배선 경로가 없다. 리프 밖 참조도 없다.
**12.2 대조군 — 동시성 규율.** 이 리프는 가족 안에서 동시성을 가장 잘 다룬다.
| 클래스 | 보호 |
|---|---|
| `GrpcDemandController` | 모든 공개 메서드 `synchronized` |
| `GrpcBidiSequenceTracker` | 모든 공개 메서드 `synchronized` |
| `GrpcClientMessageDeduplicator` | `ConcurrentHashMap` 둘 |
특히 `GrpcDemandController.messageReceived``if (outstandingDemand > 0) outstandingDemand--;``synchronized` 안이라 경합하지 않는다. 같은 형태가 `grpc-server``GrpcAdmissionController.release``grpc-client``GrpcChannelRuntime.finishUnaryCall` 에서는 보호 없이 쓰여 각각 결함이 된다.
**12.4 드리프트.** build.gradle 이 서술한 세 요소가 전부 존재한다.
## 16. 확인하지 못한 것
- 실제 스트림을 열어 재개를 재현하지 않았다. 배선 경로가 없다.
- `replayableOutcomes` 의 증가를 장시간 실행으로 측정하지 않았다(§17.1). 제거 경로 부재로 판정했다.
## 17. 손볼 것
### 17.1 P3 — 클래스가 비판한 무제한 증가를 형제 맵이 그대로 한다
클래스 javadoc 이 집합 방식을 거부한 이유가 무제한 증가다 — "A set grows without bound for the life of a session".
체크포인트는 그 비판을 지킨다. 세션당 항목 하나이고 순번만 앞으로 간다.
형제 맵은 지키지 않는다.
```java
private final ConcurrentMap<String, String> replayableOutcomes = new ConcurrentHashMap<>();
public void recordApplied(GrpcClientStreamMessage<?> message, String outcomeReference, Instant at) {
checkpoints.put(message.sessionId().value(), checkpoint.advancedTo(message.sequence(), at));
if (outcomeReference != null && !outcomeReference.isBlank()) {
replayableOutcomes.put(message.dedupKey(), outcomeReference); // ← 메시지마다 한 항목
}
}
```
제거는 `endSession` 뿐이고, 그때 그 세션의 접두를 가진 키를 전부 지운다.
그러므로 결과 참조를 기록하는 세션에서는 적용된 메시지 수만큼 항목이 쌓인다. 상한도 만료도 없다.
클래스 javadoc 은 다르게 말한다.
> "Replayed outcomes are kept for **the small window after the checkpoint**, so a duplicate that
> arrives before the checkpoint advances gets the original answer rather than being reapplied."
작은 창이 코드에 없다. 체크포인트가 앞으로 가도 그 이전 결과들은 남는다.
그리고 실제로 필요한 창은 좁다 — 판정이 `alreadyApplied(sequence)` 로 재생을 결정하고, 재생 응답에 쓰이는 것은 그 순번의 결과 하나다. 체크포인트보다 한참 뒤처진 순번의 결과가 필요할 상황은 재개 직후의 좁은 구간뿐이다.
수정은 창을 실제로 만드는 것이다 — 세션당 최근 N개만 유지하거나, 체크포인트가 앞으로 갈 때 그보다 오래된 항목을 지운다. 후자가 자바독의 서술과 정확히 같다.
### 17.2 P3 — 클라이언트 스트림 정책의 네 상한 중 둘은 읽는 코드가 없다
`GrpcClientStreamPolicy` javadoc 이 네 상한을 모두 든다.
> "all four bounds are about the client rather than the server: how long it may hold the stream, how
> long it may go quiet, how fast it may send, and how much it may have unacknowledged."
저장소 전체에서 접근자 호출을 세면 둘이 0 이다.
```
maxMessagesPerSecond production 호출 0
maxInFlightMessages production 호출 0
wholeStreamRetryAllowed production 호출 0
```
Advanced 가족이 미배선이라는 사실과는 별개다 — 이 리프 안에도 그 값을 쓰는 코드가 없다. 수요 상한을 강제하는 `GrpcDemandController``GrpcManualFlowControlPolicy` 를 쓰고, 이 정책을 보지 않는다.
`wholeStreamRetryAllowed()` 는 항상 거짓을 돌려주는 형태이므로 그 자체가 문서화 장치다. 나머지 둘은 강제 지점이 필요하다.
수정은 상한을 강제하는 지점을 만들거나(수신 경로에 속도·미확인 수 검사), 강제되지 않는 값이 강제되는 것처럼 읽히지 않도록 자바독을 낮추는 것이다.
### 17.3 P3 — 체크포인트 전진이 `ConcurrentMap` 위의 확인 후 쓰기다
`GrpcClientStreamCheckpoint.advancedTo` 가 뒤로 가는 것을 거부하고, 그 메시지가 원인을 정확히 짚는다 — "two writers are checkpointing one session". 그 가드가 보는 것은 **호출한 스레드가 읽은 값** 이다.
```java
public void recordApplied(GrpcClientStreamMessage<?> message, String outcomeReference, Instant at) {
GrpcClientStreamCheckpoint checkpoint = requireCheckpoint(message.sessionId()); // ← 읽기
checkpoints.put(message.sessionId().value(), checkpoint.advancedTo(message.sequence(), at)); // ← 조건 없는 쓰기
```
두 스레드가 순번 5 와 6 을 적용하며 같은 체크포인트(4)를 읽으면 둘 다 `advancedTo` 를 통과한다. 5 를 든 쪽이 나중에 `put` 하면 체크포인트는 6 에서 5 로 **뒤로 간다**`advancedTo` 가 막겠다고 한 바로 그 상태이고, 이번에는 예외 없이 조용히 일어난다.
그러면 순번 6 의 메시지가 다시 `APPLY` 로 판정되어 두 번 적용된다. 이 클래스가 존재하는 이유가 정확히 그것을 막는 것이다.
`ConcurrentHashMap` 에는 이 형태를 위한 연산이 있다.
```java
checkpoints.compute(key, (k, existing) -> existing.advancedTo(message.sequence(), at));
```
`compute` 안에서는 읽기와 쓰기가 원자적이므로, 뒤처진 쪽이 `advancedTo` 의 예외를 실제로 받는다 — 가드가 설계대로 발화한다.
**대조.** 같은 리프의 `GrpcDemandController` 는 모든 공개 메서드가 `synchronized` 이고, `GrpcBidiSequenceTracker` 도 그렇다(§12.2 가 그것을 이 가족의 모범으로 든다). 중복 제거기만 `ConcurrentMap` 의 원자 연산을 쓰지 않는다.
**시험이 보지 못하는 이유.** 중복 제거기 시험 아홉 개가 전부 단일 스레드다. 순차적으로는 `advancedTo` 가 정확히 작동하고, 전용 시험(`aCheckpointRecordsWhatWasApplied`)이 그것을 확인한다 — 확인하는 것은 record 의 메서드이지 맵에 쓰는 경로가 아니다.
**등급.** 미배선이므로 P3. 다만 이 클래스의 javadoc 이 "The application effect and this checkpoint belong in one transaction" 이라고 적어 둔 것과 함께 보면, 이 자리는 배선되는 날 트랜잭션 경계와 함께 다시 설계될 곳이다.
### 확인된 설계(문제 아님)
- **적용됨과 수신됨을 구분하고 그 차이를 계약으로 삼은 것.**
- **집합 대신 단조 증가 순번으로 상수 공간을 쓴 것.**
- **체크포인트가 뒤로 가려는 시도를 두 기록자의 신호로 읽는 것.**
- **재개에서 소유자 불일치를 거절하고, 체크포인트 부재를 새 세션으로 돌리는 것.**
- **적용 효과와 체크포인트를 한 트랜잭션에 두라는 요구를 자바독에 남긴 것.**
- **방향별 순번을 합치지 않은 것과 그 근거.**
- **수동 흐름 제어 승인을 메서드 단위 필드로 둔 것.**
- **감시견이 잠들지 않고 두 시각을 비교하는 것.**
- **전체 스트림 재시도를 설정이 아니라 상수 거절로 둔 것.**
- **동시성 보호를 실제로 적용한 것** — 가족의 다른 리프와 대조된다.
---
## Source anchors
```
src/grpc-advanced/grpc-advanced-streaming/build.gradle
main/java/…/streaming/GrpcClientMessageDeduplicator.java:1-123
main/java/…/streaming/GrpcDemandController.java:1-105
main/java/…/streaming/GrpcBidiSequenceTracker.java:1-62
main/java/…/streaming/GrpcClientStreamCheckpoint.java:1-56
main/java/…/streaming/GrpcClientStreamPolicy.java:1-50
main/java/…/streaming/GrpcManualFlowControlPolicy.java:1-45
main/java/…/streaming/GrpcClientStreamMessage.java:1-32
main/java/…/streaming/(GrpcBidiSession · GrpcBidiDirectionState · GrpcBidiResumeState · GrpcClientStreamResumeDecision · GrpcClientStreamSessionId · GrpcDemandDecision · GrpcClientStreamState)
test/java/…/streaming/(GrpcClientMessageDeduplicatorTest · GrpcDemandControllerTest · GrpcBidiSequenceTrackerTest · GrpcClientStreamPolicyTest)
```
@@ -0,0 +1,246 @@
# grpc-client 완전 해부
> 상태: COMPLETE
> 재오픈 게이트: cycle 2 재통독(2026-09-01) — `src/main` production 13파일 931줄 + `src/test` 4파일 581줄 축자 통독 완료. 재통독에서 §17.1–§17.4 를 독립적으로 재도출했고 넷 다 성립한다. `STRUCTURAL_ONLY` 는 `gradle.lockfile` 하나.
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/grpc/grpc-client`
> SSOT owner: `grpc-client`
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지
- `allowed_dependencies`: `["grpc-core-api", "grpc-policy"]` + vendor `grpc-api`·`grpc-stub`(BOM)
- `runtime_memberships`: **`[]`** — build-only
| 파일 | LOC |
|---|---:|
| `GrpcChannelRuntimeRegistry` | 137 |
| `GrpcTypedStubFactory` | 119 |
| `GrpcNamedChannelProfile` | 100 |
| `GrpcChannelRuntime` | 96 |
| `GrpcClientMetadataPolicy` | 87 |
| `GrpcChannelProfileValidator` | 81 |
| `GrpcStubPolicyApplier` · `GrpcClientCallContext` | 68 · 67 |
| `GrpcChannelGeneration` · `GrpcLoadBalancingPolicy` · `GrpcChannelDrainPolicy` · `GrpcStubDescriptor` · `GrpcCallCredentialProvider` | 42 · 35 · 34 · 33 · 32 |
| test 4파일 | 581 |
### Coverage ledger
| scope | count | disposition | reason |
|---|---:|---|---|
| `main/java/**` | 13 | `FULL_READ` | 931줄 전 본문 |
| `test/java/**` | 4 | `FULL_READ` | 581줄 |
| `build.gradle` | 1 | `FULL_READ` | 17줄 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
`UNCLASSIFIED` 0.
---
## 1. 모듈의 정체
```groovy
// build.gradle:3-4
// Client runtime: named channel profiles, channel runtime generations with drain, the typed stub
// factory that refuses to hand a raw Channel to application code, and client metadata/credentials.
```
## 2. 채널은 한 번 만들고 재사용한다
레지스트리 javadoc 이 두 성질을 든다.
> "a channel is created once and reused. Creating one per request is a mistake that works — every
> call succeeds — while spending a TCP handshake, a TLS handshake and an HTTP/2 setup on each one,
> and it is usually found by a connection count rather than by a failure."
> "prepare-then-swap. The new runtime exists before the pointer moves, so no call ever finds nothing
> there; the old one drains rather than being closed under its in-flight work."
`require` 가 빈 값을 돌려주지 않고 던지는 이유도 적혀 있다 — 빈 값은 "설정되지 않음" 과 "설정됐지만 도달 불가" 를 구분할 수 없게 만든다.
## 3. 세대와 배수
`GrpcChannelRuntime` 이 단항 호출과 열린 스트림을 따로 센다.
> "a drain treats them differently: unary calls are waited for, streams are signalled. A single
> counter would make the drain either cut a stream that could have finished or wait an hour for one
> that never will."
그리고 기저 채널을 노출하지 않는다 — 그것을 건네는 것이 정책 없는 스텁이 만들어지는 경로다.
## 4. 타입 있는 스텁 공장 — 두 거절
> "It will not build a stub type nobody registered, so a service cannot acquire a channel without a
> policy; and it never returns a `Channel` or a builder, so application code has no way to construct
> one itself. Both are what make the raw-API import rule enforceable rather than merely stated:
> there is nothing to reach for."
등록 함수가 원시 채널이 아니라 런타임을 받는 것도 같은 이유다 — 등록이 채널을 몰래 빼돌릴 수 없다.
빈 공장은 만들 수 없다 — "a stub factory with no registered types can build nothing and refuses everything."
## 5. 메타데이터 허용 목록이 둘인 이유
> "Tenant and actor metadata is meaningful to a service inside the same trust domain and is an
> unverified assertion to one outside it; sending it across the boundary invites the receiver to
> trust it."
그리고 교차 경계 목록은 같은 도메인 목록의 부분집합이어야 한다 — 생성자가 강제한다.
인가 헤더는 어느 목록에도 올 수 없다.
> "`authorization` is supplied per call by a credential provider, not set as metadata; a header set
> by the application is a header that survives a rotation."
나가는 방향은 허용 목록 밖을 거절이 아니라 폐기로 다룬다. 그 비대칭의 이유도 적혀 있다 — 알 수 없는 상관 헤더 때문에 나가는 호출이 실패하는 것이 더 나쁜 결과다. 예산은 그대로 강제된다.
## 10. 테스트 레인
네 테스트 581줄. 프로파일 검증, 레지스트리 설치·회전·배수, 메타데이터 정책, 스텁 공장의 두 거절을 확인한다.
## 12. negative-space probes
**12.1 도달성.** build-only. `grpc-spring-boot-starter` 는 이 리프의 타입을 빈으로 만들지 않는다(§20 가족 문서 §3.4).
**12.2 대조군 — 비원자적 해제.** 이 리프의 `finishUnaryCall`·`closeStream``grpc-server``GrpcAdmissionController.release`, `grpc-policy``GrpcStreamAdmission.release` 가 같은 형태다 — `get() > 0` 을 본 뒤 별도로 감소. §17.2 가 이 리프에서의 구체적 결과를 다룬다.
**12.3 이 리프를 import 하는 곳.** 재통독에서 다시 세었다. `grpc-discovery` main 셋(`GrpcResolverProfile`·`GrpcStableLoadBalancer`·`GrpcKubernetesRoutingMode`)과 `grpc-spring-boot-starter``GrpcPlatformStartupValidator` 가 이 리프의 타입을 이름으로 부른다 — 빈으로 만들지는 않고 검증·판정에 쓴다. `GrpcTypedStubFactory`·`GrpcChannelRuntimeRegistry` 를 실제로 조립하는 코드는 없다.
**12.4 드리프트.** build.gradle 이 서술한 네 요소가 전부 존재한다. 드리프트 없음.
## 16. 확인하지 못한 것
- 실제 채널을 만들어 회전시키지 않았다. `ManagedChannel` 을 만드는 코드가 이 리프에 없다.
- 동시 회전과 동시 해제를 실행으로 재현하지 않았다. 원자성 분석으로 판정했다.
## 17. 손볼 것
### 17.1 P2 — `rotate` 가 비교 후 교체가 아니라 덮어쓰기다
`install` 은 정확하다.
```java
if (!holder.compareAndSet(null, runtime)) {
throw new IllegalStateException("… already has a runtime; use rotate()");
}
```
`rotate` 는 그렇지 않다.
```java
GrpcChannelRuntime previous = holder.get();
if (!previous.generation().supersededBy(next)) { throw ; }
GrpcChannelRuntime replacement = new GrpcChannelRuntime(next);
holder.set(replacement); // ← 비교 없이 덮어쓴다
previous.beginDrain();
draining.computeIfAbsent().add(previous);
```
두 회전이 동시에 들어오면 둘 다 같은 `previous` 를 읽고, 둘 다 대체본을 만들고, 나중 `set` 이 앞의 대체본을 덮는다.
덮인 대체본은 어디에도 등록되지 않는다 — `draining` 목록에 들어가는 것은 `previous` 뿐이다. 그러므로 그 세대는 배수도 회수도 되지 않고, 그 위에서 시작된 호출은 아무도 세지 않는다.
클래스가 이 문제를 인지하고 있다는 증거가 같은 파일에 있다 — `install` 의 비교 후 교체와 `AtomicReference` 선택이다. 회전 쪽만 그 규율에서 벗어나 있다.
수정은 `holder.compareAndSet(previous, replacement)` 로 바꾸고 실패 시 다시 읽어 판정하거나 던지는 것이다.
### 17.2 P2 — 비원자적 감소가 세대를 영구히 회수 불가로 만든다
```java
public void finishUnaryCall() {
if (inFlightUnaryCalls.get() > 0) { inFlightUnaryCalls.decrementAndGet(); }
}
public void closeStream() {
if (openStreams.get() > 0) { openStreams.decrementAndGet(); }
}
```
카운터가 1 일 때 두 스레드가 동시에 끝나면 둘 다 조건을 통과해 둘 다 감소시켜 −1 이 된다.
그 결과가 이 리프에서는 구체적이다.
```java
public boolean quiescent() {
return inFlightUnaryCalls.get() == 0 && openStreams.get() == 0;
}
```
정확히 0 을 요구한다. 음수가 되면 조용해짐 판정이 영원히 거짓이고, `retireQuiescent` 가 그 세대를 결코 제거하지 않는다. 회전이 반복될수록 `draining` 목록이 자란다.
같은 형태가 이 가족의 다른 두 곳에도 있다(`GrpcAdmissionController.release`, `GrpcStreamAdmission.release`). 그쪽은 경계가 느슨해지는 결과였고, 이쪽은 자원이 회수되지 않는 결과다.
수정은 `updateAndGet(v -> Math.max(0, v - 1))` 이나 `decrementAndGet()` 후 하한 보정이다. 같은 가족의 `GrpcRetryBudget` 이 정확한 비교 후 교체 루프를 이미 쓴다.
### 17.3 P3 — 배수 목록의 순회가 동기화 밖에서 일어난다
```java
draining.computeIfAbsent(name, key -> java.util.Collections.synchronizedList(new ArrayList<>())).add(previous);
public List<GrpcChannelRuntime> draining(GrpcChannelProfileName profileName) {
return List.copyOf(draining.getOrDefault(profileName, List.of()));
}
public int retireQuiescent(GrpcChannelProfileName profileName) {
List<GrpcChannelRuntime> runtimes = draining.get(profileName);
List<GrpcChannelRuntime> quiescent = runtimes.stream().filter(GrpcChannelRuntime::quiescent).toList();
runtimes.removeAll(quiescent);
```
`Collections.synchronizedList` 는 개별 연산만 동기화한다. 순회는 호출자가 그 목록을 잠그고 해야 한다는 것이 그 API 의 계약이다.
`List.copyOf(...)``stream()` 둘 다 순회다. 회전이 동시에 `add` 하면 동시 변경 예외가 가능하다.
그리고 읽고 지우는 두 단계가 원자적이지 않으므로, 그 사이에 조용해진 세대가 추가되면 이번 회수에서 빠진다. 후자는 다음 호출에서 회수되므로 무해하다.
수정은 `CopyOnWriteArrayList` 로 바꾸는 것이다. 배수 목록은 쓰기가 드물고 읽기가 잦아 그 자료구조의 전형적 용례다.
### 17.4 P3 — 프로파일 검증기가 javadoc 이 든 두 실수 중 하나만 검사한다
javadoc:
> "Two in particular. Round-robin over a target that resolves to one address … and **two profiles
> pointing at the same target with the same settings are one channel with two names**, which is the
> shape that appears when somebody wanted a different SLO and copied the profile instead."
구현된 것은 첫째와 **다른 것**이다.
```java
String previous = seenNames.putIfAbsent(profileName, profile.target().toString());
if (previous != null) { violations.add("channel profile '…' is declared twice, for '…' and '…'"); }
```
이름이 같은 프로파일이 두 번 선언된 경우를 잡는다. javadoc 이 든 둘째는 **이름이 다르고 대상이 같은** 경우인데, 그 검사가 없다. 지도는 이름을 키로 쓰므로 같은 대상을 가리키는 두 이름은 서로를 만나지 않는다.
그리고 둘째가 실제로 더 찾기 어려운 형태다 — 이름이 같으면 설정 결속이 먼저 실패하거나 나중 것이 이기지만, 이름이 다르면 조용히 두 채널이 생긴다.
수정은 대상과 설정을 키로 하는 두 번째 지도를 두고 역방향 중복을 보고하는 것이다.
### 확인된 설계(문제 아님)
- **채널을 한 번 만들고 재사용하는 것과, 그 실수가 실패가 아니라 연결 수로 발견된다는 근거.**
- **준비 후 교체** — 새 런타임이 먼저 존재하고 포인터가 나중에 움직인다.
- **`require` 가 빈 값 대신 던지는 것.**
- **단항 호출과 스트림을 따로 세는 것.**
- **기저 채널을 노출하지 않는 것과 등록 함수가 런타임을 받는 것.**
- **등록되지 않은 스텁 타입을 거절하는 것.**
- **신뢰 도메인별 메타데이터 허용 목록 둘과 부분집합 불변식.**
- **인가 헤더를 자격증명 제공자에게만 맡기는 것.**
- **나가는 방향에서 허용 목록 밖을 폐기로 다루고 그 비대칭의 이유를 적은 것.**
---
## Source anchors
```
src/grpc/grpc-client/build.gradle:1-17
main/java/…/client/GrpcChannelRuntimeRegistry.java:1-137
main/java/…/client/GrpcTypedStubFactory.java:1-119
main/java/…/client/GrpcNamedChannelProfile.java:1-100
main/java/…/client/GrpcChannelRuntime.java:1-96
main/java/…/client/GrpcClientMetadataPolicy.java:1-87
main/java/…/client/GrpcChannelProfileValidator.java:1-81
main/java/…/client/(GrpcStubPolicyApplier · GrpcClientCallContext · GrpcChannelGeneration · GrpcLoadBalancingPolicy · GrpcChannelDrainPolicy · GrpcStubDescriptor · GrpcCallCredentialProvider)
test/java/…/client/(GrpcNamedChannelProfileTest · GrpcClientMetadataPolicyTest · GrpcTypedStubFactoryTest · GrpcChannelRuntimeRegistryTest)
```
@@ -0,0 +1,348 @@
# grpc-codegen 완전 해부
> 상태: COMPLETE
> 재오픈 게이트: cycle 2 — `src/main` production 9파일 692줄, test 3파일 414줄, 소비자 픽스처 리소스 2파일 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음.
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/grpc/grpc-codegen`
> SSOT owner: `grpc-codegen`
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지
- `allowed_dependencies`: `["grpc-core-api", "grpc-proto-contract"]`
- `runtime_memberships`: **`[]`** — build-only
| 파일 | LOC |
|---|---:|
| `GrpcConsumerFixture` | 158 |
| `GrpcSchemaArtifactPublisher` | 113 |
| `GrpcCodegenManifest` | 81 |
| `GrpcBufPolicy` | 75 |
| `GrpcGeneratedPackagePolicy` | 68 |
| `GrpcDescriptorArtifact` | 59 |
| `GrpcCodegenOutput` | 58 |
| `GrpcBreakingCategory` | 43 |
| `GrpcSchemaBaseline` | 37 |
| **main 합계** | **692** |
| test 3파일 | 248 + 88 + 78 |
| 소비자 픽스처 리소스 | 33 + 17 |
### Coverage ledger
| scope | count | disposition | reason |
|---|---:|---|---|
| `main/java/**` | 9 | `FULL_READ` | 692줄 전 본문 |
| `test/java/**` | 3 | `FULL_READ` | 414줄 · 테스트 21개 |
| `test/resources/consumer-fixtures/**` | 2 | `FULL_READ` | 50줄 |
| `build.gradle` | 1 | `FULL_READ` | 12줄 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
`UNCLASSIFIED` 0.
---
## 1. 모듈의 정체
```groovy
// build.gradle:3-7
// Contract governance: Buf format/lint/breaking policy, the single codegen owner declaration, and
// the descriptor/schema-hash release artifact with its consumer-compile gate.
//
// The Buf rules are implemented here rather than shelled out to the Buf CLI (adaptation D5): the
// CLI is not present in this toolchain, and a gate that silently no-ops when a binary is missing is
// worse than one that computes the same judgement from the committed schema.
```
마지막 문장이 이 저장소의 반복 원칙이다 — 도구가 없을 때 조용히 통과하는 게이트는 없는 것보다 나쁘다.
## 2. 파괴적 변경 범주 — 왜 FILE 인가
| 범주 | 소스 파괴 감지 | 파일 이동 감지 |
|---|---|---|
| `FILE` | 예 | 예 |
| `PACKAGE` | 예 | 아니오 |
| `WIRE_JSON` | 아니오 | 아니오 |
| `WIRE` | 아니오 | 아니오 |
> "A team that gates on WIRE ships a field rename, watches its own integration tests pass, and finds
> out at the consumer's next build."
`GrpcBufPolicy` 정규 생성자가 소스 파괴를 감지하지 못하는 범주를 거부하고, 형식·린트를 선택 사항으로 두지 않는다.
## 3. 기준선은 브랜치가 아니라 릴리스다
`GrpcSchemaBaseline``-SNAPSHOT` 버전을 거부하고 `sha256:` 접두 해시를 요구한다.
> "Comparing against the previous commit answers 'did this commit break anything', which is not the
> question: a breaking change introduced two commits ago and refined since then passes every
> commit-to-commit check while being broken against everything that has actually been deployed."
## 4. 생성물의 자리
`GrpcCodegenOutput` 은 모든 경로가 빌드 디렉터리 아래일 것을 요구하고, 절대 경로와 `..` 를 거부하며, 서술자 집합 확장자를 `.desc`/`.binpb` 로 제한한다.
> "A generator that writes into a source tree produces files that get committed, then edited, then
> silently reverted by the next regeneration — and the diff that reverts them looks like the
> generator working correctly."
## 5. 생성자는 하나여야 한다
`GrpcCodegenManifest` 는 소유자 하나와 관리 플랫폼에서 오는 두 버전 출처를 요구한다.
> "Two generators for one schema is the state in which a type exists twice with different options
> and the classpath decides which one a consumer gets."
> "A pinned protobuf version beside a BOM-managed gRPC version is how the runtime and the generator
> drift into a combination nobody tested, and the symptom is a `NoSuchMethodError` in generated code."
그리고 생성 패키지와 손으로 쓴 패키지가 겹치면 생성 시점에 던진다.
`caSkeleton()` 의 소유자는 Gradle protobuf 플러그인이고, javadoc 이 그것이 아직 이 빌드에서 돌지 않는다고 적는다 — "this manifest is what a future decision to turn it on has to satisfy rather than replace."
## 6. 소비자 컴파일 게이트
`GrpcConsumerFixture.fromJavaSource` 가 릴리스된 소비자의 자바 소스에서 요구 사항 셋을 기계적으로 유도한다.
> "a hand-written requirement list is a second copy of what the client already says and the copy is
> the one that stops being updated."
세 규칙이다.
```
생성 자바 패키지 = fixture 클래스가 import 하는 패키지 중 접미가 맞는 것
서비스 = <Name>Grpc import → <proto package>.<Name>
메서드 = stub.<name>( 호출 → <service>/<UpperCamelName>
```
그리고 그것이 컴파일의 근사라는 것과, 근사인 이유(ADR-GRPC-002)를 함께 적는다.
`breaksAgainst` 는 세 종류를 따로 보고한다 — 서비스 경로, 메서드 경로, 자바 패키지. 하나의 개수로 합치지 않는다.
## 10. 테스트 레인
세 테스트 414줄 · 21개.
`GrpcBufPolicyTest` 6개 — Stable 게이트가 `FILE` 이라는 것, `WIRE`/`WIRE_JSON` 거부, 형식·린트 비선택, 수명주기 태스크 이름(§17.1), 기준선의 불변 릴리스 요구, 해시 일치.
`GrpcCodegenManifestTest` 5개 — 소유자 유일성, 리터럴 버전 거부, 출력 경로가 `build/` 아래여야 한다는 것과 서술자 확장자, 패키지 겹침의 양방향 감지.
`GrpcDescriptorArtifactTest` 10개 — 산출물의 불변 버전과 세 digest, 파괴 종류별 보고, 넓어진 스키마가 아무것도 깨지 않는다는 것, 커밋된 픽스처의 유도 결과, 메서드 이름 변경이 발행을 막는다는 것, 요구가 빈 픽스처 거부, 픽스처 build 파일의 고정 버전, 소비자 실패의 발행 차단, 같은 버전 다른 바이트 거부, 같은 바이트 재발행 허용.
**픽스처를 리소스에서 읽는다.** `resource(path)` 가 클래스로더로 `consumer-fixtures/v1/...` 를 읽어 실제 커밋된 텍스트를 넣는다 — 유도 규칙을 리터럴 문자열이 아니라 저장소에 있는 파일에 대고 돌린다. `theFixturePinsItsSchemaVersion` 은 픽스처의 `build.gradle.kts` 본문까지 대조한다.
## 12. negative-space probes
**12.1 도달성.** build-only 이지만 타입 참조는 리프 밖에 있다. 실제 참조 지점은 다섯이다.
| 참조 | 형태 |
|---|---|
| `grpc-testkit/…/release/GrpcStableReleaseGate.java:3` | `import …codegen.GrpcSchemaArtifactPublisher` — production `src/main` 코드 |
| `grpc-testkit/build.gradle:55` | `api project(':grpc:grpc-codegen')` |
| `grpc-spring-boot-starter/build.gradle:19` | `implementation project(':grpc:grpc-codegen')` |
| `grpc-proto-contract/…/GrpcProtoContractValidator.java:22` | javadoc 언급만 |
| `grpc-core-api/…/GrpcStableModuleCatalog.java:24` | 목록 안의 `"grpc-codegen"` 문자열 |
`GrpcStableReleaseGate.evaluate``GrpcSchemaArtifactPublisher.PublishDecision`**인자로 받는다** — 발행자를 만들지 않는다. 그리고 그 게이트 자신도 리터럴을 먹이는 테스트 말고는 호출자가 없다(`grpc-testkit` §17). 즉 타입 수준 연결은 실재하지만 그 사슬 어디에도 실행 시점 생산자가 없다.
`grpc-spring-boot-starter` 의 의존 선언에는 대응하는 자바 참조가 없다 — 스타터 소스 전체에 `codegen` 문자열이 나오지 않는다. 쓰이지 않는 의존이다.
**12.2 저장소의 스키마에는 service 가 하나도 없다.**
```
$ grep -rn "^service" --include=*.proto src/ (매치 없음)
$ grep -rn "^package" --include=*.proto src/
grpc-proto-contract/…/v1/stream.proto:3: package hyeonworks.grpc.common.v1;
grpc-proto-contract/…/v1/error.proto:3: package hyeonworks.grpc.common.v1;
grpc-advanced-edition/…/edition2024/compatibility.proto:3: package hyeonworks.grpc.edition.v1;
messaging-schema-protobuf/src/test/proto/order_created_v1.proto:3: package dev.caskeleton.messaging.sample;
```
두 실물 proto 는 message 와 enum 만 담는다. 그런데 `GrpcDescriptorArtifact` 정규 생성자는 메서드가 비면 거부한다 — "a schema artifact with no methods describes nothing". **이 산출물 타입은 이 저장소의 실제 스키마를 표현할 수 없다.**
그래서 소비자 게이트 전체가 저장소에 없는 표면(`hyeonworks.document.v1.DocumentService`)을 상대로만 돌아간다. `GrpcCodegenManifest.caSkeleton()` 이 선언하는 생성 패키지는 `hyeonworks.grpc.common.v1.generated` 이고 픽스처가 유도하는 패키지는 `hyeonworks.document.v1.generated` 다 — 매니페스트와 픽스처가 서로 다른 스키마를 서술한다.
결함으로 세지 않는 이유는 build.gradle 과 매니페스트 javadoc 이 이 리프를 "protoc 을 켜기로 하는 미래의 결정이 만족시켜야 할 선언"으로 규정하기 때문이다(D6). 다만 §17.1·§17.4 의 검사들이 지금 무엇에 대해서도 돌지 않는다는 사실의 뿌리가 여기다.
**12.3 도달 불가 분기.** `GrpcSchemaArtifactPublisher.evaluate` 의 두 번째 차단 사유는 발화할 수 없다.
```java
if (!policy.breakingCategory().detectsSourceBreak()) {
blockers.add("the active breaking category does not detect source breaks");
}
```
`GrpcBufPolicy` 정규 생성자가 이미 그런 범주를 거부하므로, 구성된 정책은 언제나 소스 파괴를 감지한다. 이 저장소에서 반복해서 나타나는 형태다 — 선행 검증이 후행 검증을 가린다. 보안 효과는 그대로이므로 결함이 아니라 기록으로 남긴다.
**12.4 드리프트.** build.gradle 이 서술한 세 요소(Buf 정책·단일 생성자 선언·서술자 산출물과 소비자 게이트)가 전부 존재한다. 드리프트 없음.
## 16. 확인하지 못한 것
- 실제 `protoc` 이나 Buf CLI 를 돌리지 않았다. 저장소에 둘 다 없다.
- 서비스가 둘 이상인 픽스처를 만들어 §17.3 을 재현하지 않았다. 유도 코드로 판정했다.
- §17.4 의 어긋난 짝(`publish(다른 후보, 이 결정)`)을 실제로 실행해 보지 않았다. `publish` 본문에 대조 코드가 없다는 것으로 판정했다.
- 테스트를 실행하지 않았다. 21개 전부 본문으로만 확인했다.
- `grpc-spring-boot-starter` 가 이 모듈을 의존 선언만 하고 쓰지 않는 것은 문자열 grep 으로 판정했다 — 그쪽 SSOT 에서 다시 본다.
## 17. 손볼 것
### 17.1 P3 — Buf 수명주기 태스크 목록이 빌드와 대조되지 않는다. 테스트는 목록을 자기 자신과 비교한다
정책이 네 태스크 이름을 담고, javadoc 이 그 이유를 적는다.
> "Keeping the task names in the policy rather than only in a workflow file means **a missing stage
> is a test failure rather than a stage nobody noticed was gone.**"
그런데 그 네 이름은 저장소의 어떤 빌드 파일에도 없다.
```
$ grep -rn "bufFormatCheck\|bufLint\|bufBreaking" --include=*.gradle src/
(매치 없음)
```
그리고 테스트가 비교하는 대상이 실제 등록 태스크 집합이 아니다.
```java
assertThat(GrpcBufPolicy.requiredTasks())
.containsExactly("bufFormatCheck", "bufLint", "bufBuild", "bufBreaking");
assertThat(GrpcBufPolicy.missingTasks(Set.of("bufFormatCheck", "bufLint", "bufBuild")))
assertThat(GrpcBufPolicy.missingTasks(Set.copyOf(GrpcBufPolicy.requiredTasks()))).isEmpty();
```
첫 단언은 목록을 리터럴과, 셋째는 목록을 자기 자신과 비교한다. 어느 것도 빌드가 그 단계를 등록했는지 묻지 않는다.
Buf CLI 가 이 툴체인에 없다는 것은 build.gradle 이 이미 밝힌 사실이므로 태스크가 없는 것 자체는 놀랍지 않다. 어긋난 것은 javadoc 의 주장이다 — 지금 형태에서 단계가 사라져도 테스트는 초록이다.
수정은 `missingTasks` 에 Gradle 이 실제로 등록한 태스크 이름 집합을 넣는 검사를 만들거나(다른 가족의 레인 등록 검사와 같은 형태), CLI 가 없는 동안에는 그 문장을 "CI 환경이 채울 계약" 으로 낮추는 것이다.
### 17.2 P3 — 릴리스 버전 불변성이 프로세스 안에서만 성립한다
```java
private final Map<String, String> publishedHashesByVersion = new LinkedHashMap<>();
String alreadyPublished = publishedHashesByVersion.get(candidate.schemaVersion());
if (alreadyPublished != null && !alreadyPublished.equals(candidate.schemaHash())) {
blockers.add("version '…' is already published with a different schema hash; a released schema version is immutable");
}
```
발행 이력이 발행자 인스턴스의 필드다. 새 프로세스는 아무것도 기억하지 못하므로 같은 버전을 다른 해시로 다시 발행하려는 시도가 통과한다.
이 클래스가 존재하는 이유가 그 규칙이다 — "refuses to let a released version change underneath its consumers." 그 규칙이 지켜지는 범위가 한 발행자 인스턴스의 수명이다.
빌드마다 새 프로세스가 도는 것이 정상 형태이므로, 실제로 이 검사가 무언가를 막으려면 이력이 산출물 저장소나 파일에서 와야 한다. `GrpcSchemaBaseline` 이 이미 릴리스된 해시를 들고 있으므로 그 방향의 재료는 있다.
덧붙여 이 맵은 동기화되지 않는다. 발행자를 공유해 병렬로 평가하면 경합한다.
### 17.3 P3 — 픽스처의 메서드 경로가 서비스 × 메서드 교차곱이다
```java
while (calls.find()) {
String method = calls.group(1);
String upperCamel = Character.toUpperCase(method.charAt(0)) + method.substring(1);
servicePaths.forEach(service -> methodPaths.add(service + "/" + upperCamel));
}
```
`stub.<name>(` 호출 하나가 그 파일이 import 한 **모든** 서비스에 대해 메서드 경로를 만든다.
javadoc 의 규칙 서술은 단수형이다 — "a method is a `stub.<name>(` call, mapped to `<service>/<UpperCamelName>`". 서비스가 둘 이상일 때 어느 서비스인지는 소스 텍스트만으로 알 수 없고, 코드는 전부에 붙이는 쪽을 골랐다.
결과는 존재하지 않는 메서드 경로를 요구하는 픽스처다. 서비스 둘과 메서드 셋이면 요구 경로가 여섯 개가 되고, 그중 셋은 어떤 후보 스키마에도 없으므로 `breaksAgainst` 가 항상 `METHOD_PATH` 파괴를 보고한다. 그러면 `GrpcSchemaArtifactPublisher.evaluate` 가 모든 발행을 거부한다.
커밋된 픽스처는 서비스가 하나(`DocumentServiceGrpc`)라 지금은 정확하다. 두 번째 소비자 픽스처를 추가하는 순간 성립한다.
수정은 호출자 변수의 선언 타입을 함께 읽어 메서드를 서비스에 귀속시키거나, 서비스가 둘 이상인 픽스처를 거부하는 것이다. 후자는 지금 형태의 근사를 명시적으로 만든다.
### 17.4 P2 — `publish` 가 결정을 그 결정이 판정한 후보에 묶지 않는다
```java
public void publish(GrpcDescriptorArtifact candidate, PublishDecision decision) {
if (decision == null || !decision.allowed()) {
throw new IllegalStateException("refusing to publish '…'");
}
publishedHashesByVersion.put(candidate.schemaVersion(), candidate.schemaHash());
}
```
`decision``candidate` 를 판정한 결정인지 확인하는 코드가 없다. `PublishDecision``(boolean allowed, List<String> blockers)` 뿐이라 자기가 무엇을 판정했는지 들고 있지도 않다.
그래서 이렇게 쓸 수 있다.
```java
PublishDecision ok = publisher.evaluate(harmlessArtifact, List.of()); // 통과
publisher.publish(breakingArtifact, ok); // 그대로 기록된다
```
두 번째 줄에서 `breakingArtifact` 는 어떤 소비자 픽스처와도 대조되지 않고, 이미 발행된 버전인지도 확인되지 않은 채 이력에 들어간다. 이 클래스의 존재 이유인 두 규칙 — 소비자 컴파일 게이트와 릴리스 버전 불변성 — 을 둘 다 우회한다.
**왜 이 형태가 생겼나.** 판정과 기록이 두 호출로 나뉘어 있고 그 사이를 묶는 것이 호출자의 규율뿐이다. 이 저장소가 여러 가족에서 반복해 온 check-then-act 형태와 같다. 다만 여기서는 경합이 아니라 **인자 짝 맞추기**가 깨진 지점이다.
테스트는 안전한 형태만 쓴다 — `identicalRepublishIsAllowed``publisher.publish(artifact, publisher.evaluate(artifact, List.of()))` 로 한 줄에서 짝을 맞춘다. 그 규율을 코드가 강제하지 않는다.
**수정.** `PublishDecision` 이 판정 대상의 `schemaVersion`·`schemaHash` 를 들고, `publish` 가 후보와 대조한다. 또는 `evaluate` 가 발행 가능한 후보를 감싼 토큰을 돌려주고 `publish` 가 그 토큰만 받는다 — 짝이 어긋날 수 없는 형태가 된다.
### 17.5 P3 — `sha256:` 검사가 길이 15자 이상만 요구한다. 저장소 자신의 테스트가 32자 해시를 통과시킨다
같은 검사가 두 곳에 손으로 복사돼 있다.
```java
// GrpcDescriptorArtifact.requireDigest
if (digest == null || !digest.startsWith("sha256:") || digest.length() < 15) throw ;
// GrpcSchemaBaseline 정규 생성자
if (schemaHash == null || !schemaHash.startsWith("sha256:") || schemaHash.length() < 15) throw ;
```
`"sha256:"` 이 7자이므로 뒤에 8자만 있으면 통과한다. sha256 digest 는 hex 64자다.
그리고 이 헐거움이 테스트에 이미 드러나 있다.
```java
assertThat(policy.unchangedFromBaseline("sha256:ffffffffffffffffffffffffffffffff")).isFalse();
```
32자 — sha256 이 아니다. 여기서는 "다른 해시" 역할이라 결과가 바뀌지 않지만, 형식 검사가 이런 값을 유효한 해시로 받는다는 사실 자체가 이 값 객체의 주장("the hashes that prove which bytes it was built from")을 약하게 만든다.
**수정.** `sha256:` 뒤 64자 hex 를 정규식으로 요구하고, 검사를 한 곳에 둔다 — 두 record 가 같은 규칙을 각자 적고 있는 지금 형태에서는 한쪽만 조여도 다른 쪽이 남는다.
### 확인된 설계(문제 아님)
- **Buf CLI 를 부르지 않고 같은 판정을 계산한 것과 그 근거** — 바이너리가 없을 때 조용히 통과하는 게이트보다 낫다.
- **파괴적 범주를 소스 파괴 감지 여부로 나눈 것** — 유선 호환만 보면 이름 변경이 호환으로 통과한다.
- **기준선을 릴리스에 고정한 것** — 커밋 대 커밋 비교가 답하는 질문이 다르다.
- **생성물 경로를 빌드 디렉터리로 강제한 것.**
- **생성자를 하나로 못박고 버전 출처를 관리 플랫폼으로 제한한 것.**
- **소비자 요구 사항을 손으로 적지 않고 소스에서 유도한 것** — 손으로 적은 목록이 갱신을 멈춘다.
- **파괴 종류를 셋으로 나눠 보고하는 것** — 하나의 개수로 합치지 않는다.
- **근사임을 자바독에 명시하고 그 한계의 근거를 ADR 로 지목한 것.**
- **픽스처를 의존이 아니라 테스트 리소스로 커밋한 것** — 픽스처의 `build.gradle.kts` 가 스키마 산출물을 `1.4.0` 으로 고정하고, 그 이유("a fixture that floats to the latest version cannot detect a break, because it is always built against the schema it is meant to be testing")를 파일 안에 적어 두었다.
- **요구 사항이 빈 픽스처를 거부한 것** — 아무것도 요구하지 않는 픽스처는 모든 스키마를 통과시킨다.
- **`PublishDecision` 정규 생성자가 허용과 차단 사유의 모순을 거부한 것** — 허용인데 차단 사유가 있거나, 거부인데 사유가 없으면 던진다.
- **테스트가 픽스처를 클래스로더로 실제 파일에서 읽는 것** — 유도 규칙을 리터럴이 아니라 커밋된 텍스트에 대고 돌린다.
---
## Source anchors
```
src/grpc/grpc-codegen/build.gradle:1-12
main/java/…/codegen/GrpcConsumerFixture.java:1-158
main/java/…/codegen/GrpcSchemaArtifactPublisher.java:1-113
main/java/…/codegen/GrpcCodegenManifest.java:1-81
main/java/…/codegen/GrpcBufPolicy.java:1-75
main/java/…/codegen/GrpcGeneratedPackagePolicy.java:1-68
main/java/…/codegen/GrpcDescriptorArtifact.java:1-59
main/java/…/codegen/GrpcCodegenOutput.java:1-58
main/java/…/codegen/GrpcBreakingCategory.java:1-43
main/java/…/codegen/GrpcSchemaBaseline.java:1-37
test/java/…/codegen/GrpcDescriptorArtifactTest.java:1-248
test/java/…/codegen/GrpcBufPolicyTest.java:1-88
test/java/…/codegen/GrpcCodegenManifestTest.java:1-78
test/resources/consumer-fixtures/v1/src/main/java/fixture/DocumentClientFixture.java:1-33
test/resources/consumer-fixtures/v1/build.gradle.kts:1-17
grpc/grpc-testkit/…/release/GrpcStableReleaseGate.java:3,35-38 (PublishDecision 소비 지점)
grpc/grpc-testkit/build.gradle:55 · grpc/grpc-spring-boot-starter/build.gradle:19 (의존 선언)
```
@@ -0,0 +1,308 @@
# grpc-core-api 완전 해부
> 상태: COMPLETE
> 재오픈 게이트: cycle 2 재통독(2026-09-01) — `src/main` production 32파일 1,897줄 + `src/test` 7파일 926줄 축자 통독 완료. `STRUCTURAL_ONLY` 는 `gradle.lockfile` 하나.
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/grpc/grpc-core-api`
> SSOT owner: `grpc-core-api`
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지
- `allowed_dependencies`: **`[]`** — 이 저장소에서 의존성이 하나도 없는 두 리프 중 하나(다른 하나는 `messaging-core-api`)
- `runtime_memberships`: **`[]`** — build-only
```groovy
// build.gradle:3-9
// The platform's port layer: identifiers, method policy, execution evidence, failure model,
// deadline primitives and request context.
//
// No dependencies at all, and that is the contract rather than an accident. The Stable plan's
// Global Constraints make `grpc-core-api` framework-free so "evidence and policy do not know about
// a transport" is verifiable instead of aspirational — the same rule `messaging-core-api` holds.
// A type here may not name io.grpc, Spring, Netty, protobuf or a database.
```
| 패키지 | 파일 | 줄 | 성격 |
|---|---:|---:|---|
| `core` | 8 | 390 | 식별자·상태 코드·RPC 종류·Stable 모듈 목록과 불변식 |
| `error` | 4 | 276 | 실패 문맥·범주·완료 결과·플랫폼 예외 |
| `context` | 4 | 273 | 요청 문맥·메타데이터 키와 예산·클라이언트 신원 |
| `evidence` | 4 | 239 | 전송·업무·스트림 세 축 |
| `deadline` | 4 | 238 | 예산·프로파일·취소 토큰·마감 예외 |
| `policy` | 4 | 295 | 메서드 정책과 목록, 멱등 프로파일, wait-for-ready |
| `ledger` | 4 | 176 | 연산 원장 포트와 기록·신원·상태 |
가장 큰 파일 넷: `GrpcMethodPolicyCatalog` 124 · `GrpcMethodPolicy` 100 · `GrpcFailureContext` 99 · `GrpcRequestContext`·`GrpcExecutionEvidence` 87.
main 총 **32파일 / 1,897줄**.
### Coverage ledger
| scope | count | disposition | reason |
|---|---:|---|---|
| `main/java/**` | 32 | `FULL_READ` | 1,897줄. 위 표가 전부 |
| `test/java/**` | 7 | `FULL_READ` | 926줄 |
| `build.gradle` | 1 | `FULL_READ` | 10줄 전문 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 — 생성물 |
`UNCLASSIFIED` 0.
> 이 표는 2026-09-01 재통독에서 파일 단위로 다시 세었다. 이전 판은 `main/java/**` 를 "전 파일", `test/java/**` 를 6(실제 7)으로 적었다. 그 미세한 오차가 §17.4–§17.6 이 표에 없던 이유다.
`UNCLASSIFIED` 0.
---
## 1. 증거 세 축
`GrpcExecutionEvidence` 가 전송·업무·스트림을 함께 들고 절대 합치지 않는다.
> "The same type is used by the failure model and by the observation convention. That is deliberate:
> when the exception and the metric are built from different snapshots of what happened, the
> incident review has two accounts of one call and no way to choose between them."
관측될 수 없는 조합을 생성자가 거부한다 — 단항이 스트림 증거를 들 수 없고, 보내지 않은 요청이 업무 증거를 들 수 없다.
승격 메서드가 하나뿐인 것도 의도다.
> "This is the promotion the plan forbids, written as the one method that is allowed to observe
> headers — so the forbidden edit is visible as a change to this method rather than as a plausible
> line somewhere in an interceptor."
즉 응답 헤더를 봤다는 사실이 업무 축을 건드리지 못하게 하고, 그 규칙을 어기려면 이 메서드를 고쳐야 한다.
## 2. 완료 결과가 상태 코드와 분리된 이유
> "a mutation that times out is `DEADLINE_EXCEEDED` on the wire and `COMPLETION_UNKNOWN` in the
> business, and a caller that reads the first as the second's answer either loses a committed write
> or performs it twice."
`forMutation` 의 판정 순서가 다섯 단계다.
```
커밋 확인됨 → COMPLETED
부분 스트림 → PARTIAL_STREAM
상태 OK → COMPLETED
전송이 미시작을 증명 → REJECTED
그 밖 → 상태별 표
```
상태별 표에서 `DEADLINE_EXCEEDED`·`UNAVAILABLE`·`CANCELLED`·`UNKNOWN`·`INTERNAL`·`ALREADY_EXISTS`·`ABORTED`·`DATA_LOSS``COMPLETION_UNKNOWN` 이다. `ALREADY_EXISTS` 가 모호에 있는 것이 특히 정확하다 — 재시도가 그 답을 받으면 첫 시도가 성공했다는 뜻일 수 있다.
## 3. 메서드 정책 목록
가장 유용한 성질이 빌드를 깨는 쪽이다.
> "when a descriptor method set is declared, registering a policy for a method the schema does not
> have is an error. That catches the rename — the method becomes `CreateDocumentV2`, the policy still
> names `CreateDocument`, and every call to the new method silently runs with default deadline,
> default retry and no idempotency requirement."
그리고 정책 없는 메서드는 조회에서 던진다 — 정책 없는 호출은 마감도 멱등 프로파일도 없고, 그것을 서비스하려면 둘 다 지어내야 한다.
## 4. Stable 모듈 목록과 불변식
`GrpcStableModuleCatalog` 이 Stable 12 와 Advanced 6 을 상수로 든다.
`GrpcStableBuildInvariant.advancedDependencyAllowed()` 가 인자를 받지 않는 이유가 적혀 있다.
> "the answer does not vary by module, by capability or by environment. A method that could return
> true for some input would be the seam through which 'just this one Advanced type in the starter'
> arrives."
그리고 누출을 던지지 않고 집합으로 돌려주는 이유도 적혀 있다 — 첫 하나만 보고하는 게이트는 넷을 지우는 일을 네 번의 대화로 만든다.
## 10. 테스트 레인
여섯 테스트. 증거 조합 거부, 완료 결과 파생, 정책 목록의 서술자 대조와 중복 거부, 마감 예산, 메타데이터 예산, 식별자 경계, 모듈 목록을 확인한다.
## 12. negative-space probes
**12.1 도달성.** 이 리프는 가족 전체의 포트 계층이므로 참조가 가장 많다. 다만 §17.3 의 타입은 예외다.
**12.2 프레임워크 부재 확인.** `io.grpc`·Spring·Netty·protobuf·JDBC 를 이름으로 부르는 import 가 main 에 없다. build.gradle 의 의존 블록도 비어 있다.
**12.3 실제로 쓰이는 게이트.** 이 가족의 다른 게이트들과 달리 `GrpcStableBuildInvariant.requireNoAdvancedDependency` 는 production 호출자가 둘 있다 — `grpc-spring-boot-starter` 의 시작 검증기와 `grpc-advanced-bootstrap` 의 모듈 가드. 불변식의 양쪽을 각각 다른 리프가 부른다.
**12.4 드리프트.** build.gradle 이 서술한 일곱 패키지가 전부 존재하고, 파일 수는 `core` 8 · `policy` 4 · `evidence` 4 · `error` 4 · `deadline` 4 · `context` 4 · `ledger` 4 = 32 다.
**12.5 검증만 되고 강제되지 않는 성분.** `GrpcMetadataBudget.maxTotalBytes`(§17.5). 같은 형태를 `grpc-policy` 에서도 찾았다 — `GrpcContextPropagationPolicy.clearAfterTask`(그 리프 §17.8). 두 자리 모두 compact constructor 의 가드가 유일한 소비자다.
## 16. 확인하지 못한 것
- 서술자 대조 경로를 실제 스키마로 돌려 보지 않았다(§17.1). 저장소에 컴파일된 서술자가 없다.
- 상태 코드별 매핑을 실제 서버 응답으로 재현하지 않았다. 표와 근거 문장으로 판정했다.
## 17. 손볼 것
### 17.1 P3 — 정책 목록의 가장 강한 성질을 이 저장소에서는 쓸 수 없다
`withDescriptorMethods` 를 부르는 곳은 이 리프의 테스트 두 줄뿐이다.
```
grpc-core-api/src/test/.../GrpcMethodPolicyCatalogTest.java:145
grpc-core-api/src/test/.../GrpcMethodPolicyCatalogTest.java:175
```
`GrpcMethodPolicyCatalog.builder()` 를 부르는 곳은 저장소 전체에서 전부 테스트다. 그리고 그중 어느 것도 서술자 집합을 선언하지 않는다(위 두 줄 제외).
자바독이 그 상태를 미리 서술한다 — 서술자가 없으면 "the catalog is materially weaker … there is nothing to compare a policy's method name against."
그리고 서술자가 없는 이유는 옆 리프에 있다. `grpc-codegen` 이 서술자 산출물을 정의하지만 저장소에 protobuf 플러그인이 없어 `protoc` 이 돌지 않는다. 즉 이름 변경을 잡는 성질은 코드 생성 레인이 켜지기 전까지 성립할 수 없다.
기록하는 이유는 이것이 이 클래스가 존재하는 첫 번째 이유로 적혀 있기 때문이다. 수정은 코드 생성 레인이 생길 때 그 서술자를 목록 조립에 연결하는 것이고, 그때까지는 자바독이 그 조건을 명시하는 편이 낫다.
### 17.2 P3 — 모듈 목록 테스트가 레지스트리와 목록을 붙들지 않는다
클래스 javadoc 이 두 SSOT 의 관계를 적는다.
> "This repository's module registry (`src/config/architecture/modules.json`) is the SSOT for which
> Gradle projects exist; this catalog is the SSOT for which of them the Stable contract covers, and
> **`GrpcStableModuleCatalogTest` holds the two together.**"
그 테스트는 레지스트리를 읽지 않는다. 다섯 테스트가 하는 일은 목록을 리터럴과 대조하고, 두 집합의 서로소를 확인하고, 누출 판정을 확인하는 것이다.
```java
assertThat(catalog.modules()).containsExactlyInAnyOrder(리터럴);
assertThat(GrpcStableModuleCatalog.advancedModules()).isNotEmpty().noneMatch(catalog::isStable);
```
`modules.json` 을 읽는 줄도, 파일 경로도 없다.
두 목록은 오늘 일치한다 — 레지스트리의 grpc 계열 리프가 18 개이고 목록이 12 + 6 이다. 어긋난 것은 그 일치를 무엇이 지키는가다.
같은 저장소가 이 형태를 messaging 가족에서 이미 기록했다 — 정확한 목록은 레지스트리가 소유하므로 산문에서 세지 않는다, 세는 순간 다시 표류한다.
수정은 테스트가 `modules.json` 을 읽어 grpc 계열 리프 집합과 두 상수 집합의 합집합을 대조하는 것이다. 그 테스트가 있으면 새 리프가 어느 쪽에도 들어가지 않은 채 추가되는 것을 잡는다.
### 17.3 P3 — `RESOURCE_EXHAUSTED` 매핑이 그 상태의 두 출처 중 하나만 가정한다
```java
case INVALID_ARGUMENT, UNAUTHENTICATED, PERMISSION_DENIED, NOT_FOUND,
FAILED_PRECONDITION, OUT_OF_RANGE, UNIMPLEMENTED, RESOURCE_EXHAUSTED -> REJECTED;
```
이 분기는 전송이 미시작을 증명하지 못한 뒤에 도달한다. 즉 "보냈는지 모르지만 이 상태 코드는 거절을 뜻한다" 는 판정이다.
목록의 나머지 일곱은 서버가 일을 시작하기 전에 답하는 상태다. `RESOURCE_EXHAUSTED` 는 두 출처를 갖는다.
- 이 플랫폼 자신의 승인 제어기가 부하를 흘려보낼 때 — 일을 쓰기 전이므로 거절이 맞다.
- 원격 서버가 작업 중 자원(할당량·디스크)을 소진했을 때 — 부분 커밋이 있을 수 있다.
이 클래스의 원칙은 보수적이다. 자바독이 두 기본값(`DEADLINE_EXCEEDED`·`UNAVAILABLE` 를 모호로)을 계획의 전역 제약이라 부르고, 그 이유는 "보냈는지 모르면 모호" 다. `RESOURCE_EXHAUSTED` 는 그 원칙에서 벗어난 유일한 항목이다.
`ABORTED` 가 모호에 있는 것과 대비된다 — 트랜잭션 충돌은 서버가 일을 시작한 뒤의 상태이고, 그래서 모호다.
수정은 둘 중 하나다. `RESOURCE_EXHAUSTED` 를 모호로 옮기거나, 그 상태를 이 플랫폼이 발행한 것과 원격이 발행한 것으로 구분해 전자만 거절로 두는 것이다. 후자는 증거 축에 발신자 정보를 요구하므로 전자가 현실적이다.
### 17.4 P3 — 하나의 상태 코드가 같은 메서드 안에서 두 답을 갖는다
`forMutation` 은 스위치에 닿기 전에 `OK` 를 먼저 처리한다.
```java
if (statusCode == GrpcStatusCode.OK) { return COMPLETED; }
if (evidence.transport().provesNotStarted()) { return REJECTED; }
return switch (statusCode) {
case OK, ALREADY_EXISTS, ABORTED, DATA_LOSS -> COMPLETION_UNKNOWN; // ← OK 가 여기에도 있다
};
```
스위치의 `OK` 분기는 도달하지 않는다. 열거형 전수 처리를 컴파일러가 요구하므로 항목 자체는 필요하지만, 그 값이 위의 가드와 반대다.
결과는 잠재적 함정이다. 누군가 위의 `OK` 가드를 "중복이니까" 지우면 컴파일은 통과하고 `OK` 인 변경이 `COMPLETION_UNKNOWN` 이 된다 — 성공한 변경마다 대사(reconciliation)를 요구하게 된다. 이 리프의 다른 자리들은 그런 편집이 눈에 띄도록 설계되어 있다(예: 승격 메서드를 하나로 좁힌 것).
수정은 한 글자다. 스위치의 `OK``COMPLETED` 로 옮기면 두 자리의 답이 같아지고, 가드가 사라져도 결과가 바뀌지 않는다.
### 17.5 P3 — 메타데이터 예산의 두 성분 중 하나는 강제되지 않고, 나머지 하나는 바이트가 아니라 문자를 센다
`GrpcMetadataBudget` 은 세 성분을 갖는다 — `maxTotalBytes`·`maxUserDefinedBytes`·`maxEntries`.
`check(...)` 가 보는 것은 뒤의 둘뿐이다.
```java
if (metadata.size() > maxEntries) { throw ; }
int userDefinedBytes = 0;
for () { userDefinedBytes += entry.getKey().name().length() + value.length(); }
if (userDefinedBytes > maxUserDefinedBytes) { throw ; }
// maxTotalBytes 는 여기서 쓰이지 않는다
```
**첫째, `maxTotalBytes` 는 읽히지 않는다.** 저장소 전체에서 이 접근자를 부르는 곳은 compact constructor 의 순서 가드와 테스트 단언 하나뿐이다. 자바독은 그 이유를 설명한다 — 하드 총계를 넘기는 것은 프레임워크가 던지는 전송 거절이고, 여기서 함께 검사하면 "고칠 수 있는 쪽" 과 "고칠 수 없는 쪽" 이 한 자리에서 발견된다는 것. 판단은 옳다. 다만 그 결과로 이 record 는 자기가 쓰지 않는 수를 성분으로 들고 있고, 이름은 그것이 강제된다고 읽힌다.
**둘째, 단위가 어긋난다.** 성분 이름은 `...Bytes` 인데 세는 것은 `String.length()`, 즉 UTF-16 코드 단위다. 키는 `[a-z0-9._-]` 로 제한되어 ASCII 지만 값에는 문자 집합 제약이 없다. 다중 바이트 문자를 담은 값은 실제 프레임보다 적게 계산된다.
gRPC 의 ASCII 메타데이터 값은 프로토콜 상 인쇄 가능 ASCII 여야 하므로 실무에서는 대개 일치한다. 다만 그 제약을 이 클래스가 검사하지 않으므로, 일치는 보장이 아니라 관행이다.
수정은 둘 다 작다 — `value.getBytes(StandardCharsets.US_ASCII).length` 로 세거나 값의 문자 집합을 `GrpcMetadataKey.Kind.ASCII` 에 맞춰 검증하고, `maxTotalBytes` 는 성분에서 빼고 javadoc 의 서술로 남긴다.
### 17.6 P3 — 직렬화 가능하다고 선언한 예외가 자기 내용을 직렬화하지 않는다
```java
public class GrpcPlatformException extends RuntimeException {
private static final long serialVersionUID = 1L;
private final transient GrpcFailureContext context; // ← transient
public boolean requiresReconciliation() { return context.completionOutcome().requiresReconciliation(); }
}
```
`serialVersionUID` 는 이 타입이 직렬화된다는 선언이고, `transient` 는 유일한 필드가 그 직렬화에서 빠진다는 선언이다. 둘이 함께 있으면 역직렬화된 예외는 `context == null` 이고, 공개 메서드 둘 중 하나(`requiresReconciliation()`)가 NPE 를 던진다.
`transient` 자체는 강제된 선택이다 — `GrpcFailureContext``Serializable` 을 구현하지 않으므로 필드를 남기면 예외가 직렬화되지 않는다.
기록하는 이유는 이 리프의 서술 규율과 대비되기 때문이다. 다른 자리에서는 부재마다 이유가 붙어 있다("There is no factory that takes raw metadata, and that absence is the design"). 여기에는 `transient` 의 이유도, 역직렬화 뒤의 계약도 적혀 있지 않다.
도달성은 낮다. gRPC 예외가 자바 직렬화를 지나는 경로는 이 저장소에 없다. 수정은 셋 중 하나다 — `GrpcFailureContext` 와 그 구성 요소를 `Serializable` 로 만들거나, `serialVersionUID` 를 지워 직렬화를 지원하지 않음을 명시하거나, `context()``requiresReconciliation()` 이 null 문맥을 다루도록 하고 그 이유를 적는 것.
### 확인된 설계(문제 아님)
- **의존성 0 을 계약으로 삼고 그 이유를 적은 것** — "evidence and policy do not know about a transport" 가 검증 가능해진다.
- **증거 세 축을 한 타입에 두고 관측 불가 조합을 생성자가 거부한 것.**
- **승격 메서드를 하나로 좁혀 금지된 편집이 그 메서드의 변경으로 보이게 한 것.**
- **완료 결과를 상태 코드와 분리한 것과 그 예시.**
- **`ALREADY_EXISTS`·`ABORTED` 를 모호로 둔 것.**
- **정책 없는 메서드를 조회에서 던지는 것.**
- **서술자 대조를 선택 사항으로 두되 그 부재의 대가를 자바독에 적은 것.**
- **`advancedDependencyAllowed()` 가 인자를 받지 않는 것과 그 근거.**
- **누출을 집합으로 돌려주는 것.**
---
## Source anchors
```
src/grpc/grpc-core-api/build.gradle:1-10
main/java/…/policy/GrpcMethodPolicyCatalog.java:1-124 (§17.1 withDescriptorMethods:80-87)
main/java/…/policy/GrpcMethodPolicy.java:1-100
main/java/…/error/GrpcFailureContext.java:1-99
main/java/…/context/GrpcRequestContext.java:1-87
main/java/…/evidence/GrpcExecutionEvidence.java:1-87
main/java/…/deadline/GrpcDeadlineBudget.java:1-85
main/java/…/evidence/GrpcStreamEvidence.java:1-80
main/java/…/core/GrpcStableModuleCatalog.java:1-79 (§17.2)
main/java/…/error/GrpcCompletionOutcome.java:1-69 (§17.3 · §17.4 forMutation:360-390)
main/java/…/deadline/GrpcCancellationToken.java:1-68
main/java/…/context/GrpcMetadataBudget.java:1-66 (§17.5 check:130-154)
main/java/…/context/GrpcMetadataKey.java:1-65
main/java/…/error/GrpcFailureCategory.java:1-64
main/java/…/deadline/GrpcDeadlineProfile.java:1-59
main/java/…/ledger/GrpcOperationLedgerRecord.java:1-59
main/java/…/context/GrpcClientIdentity.java:1-55
main/java/…/core/GrpcMethodName.java:1-55
main/java/…/core/{GrpcStableBuildInvariant:1-53, RpcType:1-53, GrpcStatusCode:1-52,
GrpcIdentifiers:1-47, GrpcServiceName:1-37, GrpcChannelProfileName:1-24}
main/java/…/policy/{RpcIdempotencyProfile:1-49, WaitForReadyPolicy:1-22}
main/java/…/ledger/{GrpcOperationLedger:1-50, GrpcOperationIdentity:1-39, GrpcOperationLedgerState:1-28}
main/java/…/error/GrpcPlatformException.java:1-44 (§17.6)
main/java/…/evidence/{GrpcTransportEvidence:1-41, GrpcBusinessEvidence:1-31}
main/java/…/deadline/GrpcDeadlineExceededException.java:1-26
test/java/…/ 7파일 926줄 (GrpcMethodPolicyCatalogTest:183 · GrpcFailureContextTest:182 ·
GrpcMetadataBudgetTest:174 · GrpcDeadlineBudgetTest:124 · GrpcExecutionEvidenceTest:120 ·
GrpcCoreIdentifiersTest:83 · GrpcStableModuleCatalogTest:60)
src/config/architecture/modules.json (§17.2 — 테스트가 읽지 않는 SSOT)
grpc-spring-boot-starter/…/GrpcPlatformStartupValidator.java:176 (GrpcStableBuildInvariant 실사용)
grpc-advanced/grpc-advanced-bootstrap/…/GrpcAdvancedModuleGuard.java:76 (같은 불변식의 반대편)
```
@@ -0,0 +1,239 @@
# grpc-discovery 완전 해부
> 상태: COMPLETE
> 재오픈 게이트: cycle 2 — `src/main` production 7파일 409줄, test 2파일 220줄 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음.
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/grpc/grpc-discovery`
> SSOT owner: `grpc-discovery`
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지
- `allowed_dependencies`: `["grpc-core-api", "grpc-client"]`
- `runtime_memberships`: **`[]`** — build-only
| 파일 | LOC |
|---|---:|
| `GrpcKubernetesProfile` | 90 |
| `GrpcDiscoveryPolicyValidator` | 67 |
| `GrpcResolverProfile` | 63 |
| `GrpcKubernetesProfileValidator` | 61 |
| `GrpcResolverType` · `GrpcKubernetesRoutingMode` | 45 · 45 |
| `GrpcStableLoadBalancer` | 38 |
| **main 합계 (7파일)** | **409** |
| `GrpcKubernetesProfileTest` · `GrpcDiscoveryPolicyValidatorTest` | 127 · 93 |
### Coverage ledger
| scope | count | disposition | reason |
|---|---:|---|---|
| `main/java/**` | 7 | `FULL_READ` | 409줄 전 본문 |
| `test/java/**` | 2 | `FULL_READ` | 220줄 전 본문 · 테스트 15개 |
| `build.gradle` | 1 | `FULL_READ` | 9줄 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
`UNCLASSIFIED` 0.
---
## 1. 모듈의 정체
```groovy
// build.gradle:3-5
// Stable discovery: Static/DNS resolvers, pick_first/round_robin load balancing, and the
// Kubernetes VIP / headless / mesh routing profiles. Custom resolvers, custom load balancers and
// xDS are Advanced and are refused here by GrpcDiscoveryPolicyValidator.
```
## 2. 이 리프가 붙드는 한 가지 짝
세 타입이 같은 사실을 다른 각도에서 말한다.
- `GrpcResolverType` — 각 리졸버가 주소를 여럿 돌려줄 수 있는가. `STATIC`·`DNS` 는 예, `UNIX` 는 아니오.
- `GrpcStableLoadBalancer` — 주소 수에 맞는 정책. 1개면 `PICK_FIRST`, 여럿이면 `ROUND_ROBIN`.
- `GrpcKubernetesRoutingMode` — 누가 균형을 잡는가. VIP 는 kube-proxy, headless 는 클라이언트, MESH 는 사이드카.
세 javadoc 이 같은 실패를 다르게 서술한다.
> `GrpcResolverType` — "A resolver that returns one address makes `round_robin` a no-op, and the
> pairing is the most common way a deployment has load balancing on paper and none in practice."
> `GrpcStableLoadBalancer` — "`pick_first` over a headless record pins every request from this
> client to one pod, which shows up as one instance at capacity while the rest are idle."
> `GrpcKubernetesRoutingMode` — "A Service VIP balances per connection in kube-proxy, which for a
> long-lived HTTP/2 connection means it does not balance at all after the first request."
## 3. 두 검증기가 다른 질문에 답한다
`GrpcKubernetesProfileValidator` javadoc 이 분리 이유를 적는다.
> "The resolver validator asks whether a load-balancing policy does anything over the addresses it
> will see; this one asks whether the deployment shape, the retry owner and the stream obligations
> agree with each other. A deployment can have a perfectly coherent resolver profile and still have
> put retries in two places."
| 검사 | 어디 |
|---|---|
| 균형 정책이 주소 수에 대해 무의미한가 | `GrpcDiscoveryPolicyValidator` |
| 재시도 소유자가 라우팅 모드가 요구하는 것과 다른가 | `GrpcKubernetesProfileValidator` |
| VIP 인데 긴 스트림을 싣는가 | 〃 |
| 배수 유예가 재접속 예산보다 짧은가 | 〃 |
## 4. 생성자가 거부하는 것과 검증기가 보고하는 것
`GrpcResolverProfile` 정규 생성자가 네 조합을 아예 만들 수 없게 한다 — 주소 0 이하, 단일 엔드포인트 리졸버에 복수 주소, 음수 갱신 주기, DNS 인데 갱신 주기 0.
`GrpcKubernetesProfile` 정규 생성자는 셋을 막는다 — 메시 라우팅에 in-process 재시도 소유자, 긴 스트림인데 재접속 예산 0, 긴 스트림인데 배수 유예 0.
두 겹의 역할 분담이 이 저장소의 다른 곳에 적힌 규칙과 같다 — 위험한 조합은 정책이 아니라 생성자가 거부하게 만든다.
그리고 그 분담 때문에 검증기의 재시도 소유자 규칙은 일부 조합에서만 발화한다. `MESH` + `GRPC_PLATFORM` 은 생성자가 먼저 던지므로(둘 다 in-process 재시도) 검증기까지 오지 않고, `MESH` + `NONE` 이나 `K8S_VIP` + `SERVICE_MESH` 는 생성자를 통과해 검증기가 잡는다. 도달 불가 분기가 아니라 역할 분담이다.
## 10. 테스트 레인
두 테스트 220줄 · 15개.
`GrpcDiscoveryPolicyValidatorTest` 7개 — Stable 리졸버 셋의 열거, Advanced 스킴과 미지 스킴 거부, 주소 수에 따른 권고, 단일 주소 위의 round_robin 보고, 올바른 짝의 통과, 갱신 주기 0 인 DNS 거부, 단일 엔드포인트 리졸버의 복수 주소 거부.
`GrpcKubernetesProfileTest` 8개 — 라우팅 모드별 균형자·재시도 소유자, 메시 + in-process 재시도의 생성자 거부, 긴 스트림의 두 필수 값, 세 팩토리의 통과, headless 인데 주소 1개, VIP 인데 긴 스트림, 배수 유예 < 재접속 예산, 라우팅 모드가 함의하는 리졸버 프로파일.
**레인에 없는 것 하나.** §4 가 "생성자를 통과해 검증기가 잡는다" 고 설명한 분기 — `retryOwner != routingMode.requiredRetryOwner()` — 를 실제로 발화시키는 테스트가 없다.
```java
// GrpcKubernetesProfileValidator:204
if (profile.retryOwner() != profile.routingMode().requiredRetryOwner()) { violations.add(); }
```
`routingModesImplyTheirOwners` 는 열거형의 `requiredRetryOwner()` 값만 단언하고 검증기를 부르지 않는다. `aMeshProfileMayNotAlsoRetryInProcess` 는 생성자 쪽을 친다. `MESH` + `NONE` 이나 `K8S_VIP` + `SERVICE_MESH` — 두 검증기 분담을 실증하는 조합 — 은 어느 테스트에도 없다. 규칙은 있고 그것을 붙드는 단언이 없다.
## 12. negative-space probes
**12.1 도달성.** 이 리프 밖의 production 소비자는 하나뿐이다.
| 타입 | leaf 밖 main 참조 |
|---|---:|
| `GrpcDiscoveryPolicyValidator` | 1 — `GrpcPlatformStartupValidator.validateChannels` |
| 나머지 6종 | **0** |
그리고 그 하나의 소비자인 시작 검증기는 시작 시 실행되지 않는다(`grpc-spring-boot-starter` §17.1). 그러므로 Advanced 스킴 거부(`requireStableScheme`)에 도달하는 production 경로가 없다.
**12.2 거절 목록은 안전이 아니라 메시지를 위해 있다.**
```java
private static final List<String> ADVANCED_SCHEMES = List.of("xds", "consul", "etcd", "eureka");
if (ADVANCED_SCHEMES.contains(scheme)) { throw new IllegalArgumentException("… Advanced capability …"); }
return GrpcResolverType.forScheme(scheme).orElseThrow(() -> new IllegalArgumentException("unknown resolver scheme …"));
```
두 번째 줄이 이미 허용 목록이다 — `GrpcResolverType` 이 아는 것은 `static`·`dns`·`unix` 셋뿐이고, 그 밖은 전부 `orElseThrow` 로 떨어진다. 그러므로 `xds` 는 거절 목록이 없어도 거부된다.
거절 목록이 하는 일은 **거부 사유를 바꾸는 것**이다 — "unknown resolver scheme" 대신 "Advanced capability with its own control plane and promotion gate". 클래스 javadoc 이 그 구분을 명시한다.
> "`xds:///` in a Stable profile is not a configuration mistake to warn about — it is a capability
> with its own control plane, its own failure modes and its own promotion gate."
읽는 사람에게 중요한 함의: 다섯 번째 Advanced 스킴 이름을 이 목록에 넣지 않아도 **안전은 유지된다.** 빠지면 나빠지는 것은 메시지의 정확도뿐이고, 그것이 이 목록이 감당하는 유일한 부채다. 기본 거절이 바깥을 지킨다.
**12.4 드리프트.** build.gradle 이 서술한 범위(Static/DNS, pick_first/round_robin, VIP/headless/mesh, xDS 거부)가 전부 코드에 있다. 드리프트 없음.
## 16. 확인하지 못한 것
- 실제 DNS 리졸버로 헤드리스 레코드를 조회해 주소 수를 확인하지 않았다. 이 리프는 그 수를 입력으로 받는다.
- 시작 검증기를 통한 스킴 거부를 실행으로 확인하지 않았다. 그 검증기가 돌지 않는다.
- 테스트를 실행하지 않았다. 15개 전부 본문으로만 확인했다.
- §17.3 의 `violations(profile, 0)` 을 실행으로 재현하지 않았다. `resolverProfile``GrpcResolverProfile` 정규 생성자 경로로 판정했다.
## 17. 손볼 것
### 17.1 P3 — 프로파일이 스트림 재접속 예산을 선언하는데 그것이 함의하는 DNS 갱신 주기를 정하지 않는다
`GrpcKubernetesProfile` 은 세 시간 값을 다룬다.
```java
streamReconnectBudget // 프로파일이 선언
readinessDrainGrace // 프로파일이 선언
refreshInterval // resolverProfile(...) 이 30초로 하드코딩
```
```java
public GrpcResolverProfile resolverProfile(int expectedAddressCount) {
return new GrpcResolverProfile(
GrpcResolverType.DNS, routingMode.loadBalancingPolicy(), Duration.ofSeconds(30), expectedAddressCount);
}
```
검증기는 앞의 둘만 비교한다 — 배수 유예가 재접속 예산보다 짧으면 위반. 셋째는 비교 대상에 없다.
그래서 `headlessStreaming()`(재접속 예산 5초, 배수 유예 30초)에서 갱신 주기는 여전히 30초다. 롤아웃으로 스트림이 끊긴 클라이언트가 5초 예산 안에 재접속하려 할 때, 그 클라이언트의 DNS 캐시는 최대 30초 동안 사라진 파드 주소를 들고 있을 수 있다.
그 실패가 `GrpcResolverProfile` 자신의 javadoc 이 서술한 것이다 — "A channel that resolved once at startup keeps sending to addresses that stopped existing an hour ago; the calls fail with `UNAVAILABLE` and the deployment looks unhealthy long after it finished."
수정은 갱신 주기를 재접속 예산에서 파생시키거나(예: 예산 이하), 검증기에 세 값의 순서 규칙을 추가하는 것이다.
### 17.2 P3 — 리졸버 검증기의 규칙이 하나뿐인데 javadoc 은 복수형으로 서술한다
```java
public static List<String> violations(GrpcResolverProfile profile) { } // 규칙 1개
```
javadoc 은 "Checks a discovery configuration for **the things** that look right and are not" 라고 적는다. 실제로 담긴 규칙은 균형 정책의 무의미함 하나다.
나머지 위험 조합은 `GrpcResolverProfile` 정규 생성자가 이미 거부하므로 결과적으로 빈틈은 아니다. 다만 목록으로 보고하는 API 형태와 규칙 하나라는 내용이 어긋나 있어, 다음 사람이 여기에 규칙을 더할 자리로 읽거나 이미 여러 규칙이 있다고 읽는다. §17.1 이 실제로 그 자리다.
### 17.3 P3 — 목록으로 보고하는 검증기가 주소 수 0 에서 던진다
```java
public static List<String> violations(GrpcKubernetesProfile profile, int expectedAddressCount) {
List<String> violations = new ArrayList<>(
GrpcDiscoveryPolicyValidator.violations(profile.resolverProfile(expectedAddressCount)));
```
`profile.resolverProfile(n)``new GrpcResolverProfile(DNS, …, n)` 을 만들고, 그 정규 생성자가 거부한다.
```java
if (expectedAddressCount < 1) {
throw new IllegalArgumentException("a target resolves to at least one address");
}
```
그래서 `violations(profile, 0)` 은 빈 목록도 위반 목록도 아닌 `IllegalArgumentException` 이다. 같은 메서드가 `profile == null` 에는 명시적으로 던지고 나머지는 목록으로 답하므로, 호출자는 이 API 를 "던지지 않고 보고한다" 로 읽는다.
**왜 0 이 실제 값인가.** `expectedAddressCount` 는 이 리프가 계산하지 않고 입력으로 받는 값이고(§16), 그 출처는 헤드리스 레코드의 DNS 조회 결과다. 롤아웃 중 파드가 모두 교체되는 순간이나 셀렉터가 어긋난 서비스에서 그 답은 0 이다. 그것은 이 리프가 다루는 문제 영역 안의 상태이지 프로그래밍 오류가 아니다 — 그리고 운영자가 가장 보고받고 싶어 할 상태다.
`GrpcResolverProfile` 쪽 거부 자체는 옳다. 값 객체가 "주소 0 개인 목표"를 표현하지 않는 것은 §4 의 두 겹 분담과 일치한다. 어긋난 것은 그 위에 얹힌 검증기가 그 예외를 그대로 통과시킨다는 점이다.
**수정.** `violations``expectedAddressCount < 1` 을 먼저 보고 위반 문자열로 보고한 뒤 나머지 검사를 건너뛴다. 그러면 이 리프가 답할 수 있는 가장 중요한 배포 상태 하나가 예외가 아니라 목록의 한 줄이 된다.
### 확인된 설계(문제 아님)
- **리졸버의 다중 주소 가능성을 열거형 속성으로 둔 것** — 짝이 맞지 않는 조합을 타입 수준에서 판정할 수 있다.
- **위험한 조합을 생성자가 거부하고 애매한 조합만 검증기가 보고하는 두 겹.**
- **두 검증기를 분리하고 그 이유를 적은 것.**
- **Advanced 스킴을 이름으로 거부하고 그 근거를 적은 것** — 통제 평면과 승격 게이트가 따로 있는 능력이다.
- **알 수 없는 스킴을 기본 거절로 둔 것.**
- **긴 스트림을 싣는 프로파일에 재접속 예산과 배수 유예를 필수로 만든 것.**
- **세 팩토리(`virtualIp` · `headlessStreaming` · `mesh`)가 각자 일관된 조합을 들고 있고, 테스트가 셋 다 위반 0 임을 확인하는 것** — 기본으로 고르는 값이 스스로의 규칙을 만족한다.
- **`GrpcRetryOwner.NONE` 이 "아직 정하지 않았다" 와 구분되는 것** — 그 열거형 javadoc 이 "A method whose owner is NONE has been looked at" 라고 적고, 이 리프의 두 겹 분담이 그 값 덕분에 의미를 갖는다(§4).
---
## Source anchors
```
src/grpc/grpc-discovery/build.gradle:1-9
main/java/…/discovery/GrpcKubernetesProfile.java:1-90
main/java/…/discovery/GrpcDiscoveryPolicyValidator.java:1-67
main/java/…/discovery/GrpcResolverProfile.java:1-63
main/java/…/discovery/GrpcKubernetesProfileValidator.java:1-61
main/java/…/discovery/GrpcResolverType.java:1-45
main/java/…/discovery/GrpcKubernetesRoutingMode.java:1-45
main/java/…/discovery/GrpcStableLoadBalancer.java:1-38
test/java/…/discovery/GrpcKubernetesProfileTest.java:1-127
test/java/…/discovery/GrpcDiscoveryPolicyValidatorTest.java:1-93
src/grpc/grpc-policy/…/resilience/GrpcRetryOwner.java:14-33
```
@@ -0,0 +1,277 @@
# grpc-observability 완전 해부
> 상태: COMPLETE
> 재오픈 게이트: cycle 2 — `src/main` production 4파일 354줄, test 1파일 172줄 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음.
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/grpc/grpc-observability`
> SSOT owner: `grpc-observability`
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지와 숫자 지도
- `allowed_dependencies`: `["grpc-core-api"]`
- `runtime_memberships`: **`[]`** (`EVD-325`)
| 항목 | 수 |
|---|---:|
| production Java 파일 | **4** (354 LOC) |
| test Java 파일 | 1 (172 LOC) |
| build 파일 | `build.gradle` 12줄 |
| test 메서드(실행 확인) | **10** (`EVD-325`) |
| 선언된 의존 | project 1 + vendor 1 (`micrometer-core`) |
파일별 LOC:
| 파일 | LOC | 성격 |
|---|---:|---|
| `GrpcMetricCardinalityPolicy` | 123 | 태그 허용/거절 판정 (static 유틸) |
| `GrpcObservationConvention` | 99 | Micrometer 등록 (유일한 상태 보유 클래스) |
| `GrpcRpcObservation` | 78 | 논리 RPC 관측 record |
| `GrpcStreamObservation` | 54 | 스트림 수명 관측 record |
### Coverage ledger
| scope | count | disposition | reason |
|---|---:|---|---|
| `build.gradle` | 1 | `FULL_READ` | 12줄 전문 |
| `main/…/observability/*.java` | 4 | `FULL_READ` | 4파일 전 본문 축자 확인 (cycle 2) |
| `test/…/GrpcMetricCardinalityPolicyTest.java` | 1 | `FULL_READ` | 172줄, 10개 `@Test` 전부 단언 대상 확인 |
`STRUCTURAL_ONLY` 0 · `UNCLASSIFIED` 0.
---
## 1. 모듈의 정체와 경계
```groovy
// build.gradle:3-5
// Bounded observability: logical RPC vs physical attempt vs stream lifecycle, with a cardinality
// policy that refuses payload, raw metadata and any actor/tenant/object/stream/idempotency
// identifier as a tag.
```
세 층위를 구별한다 — 논리 RPC, 물리 시도, 스트림 수명주기.
Micrometer 를 `api` 로 노출하는 이유도 build.gradle 에 적혀 있다 — "the observation convention's public signatures name Micrometer types, so wiring it requires naming them." 실제로 `GrpcObservationConvention` 의 생성자와 `boundedTags` 반환형이 Micrometer 타입(`MeterRegistry`, `Tags`)이므로 그 서술은 코드와 일치한다.
## 2. 의존성과 런타임 배선
`grpc-core-api` 에서 쓰는 타입은 넷이다 — `GrpcMethodName`, `GrpcStatusCode`, `RpcType`, `GrpcCompletionOutcome`. 네 타입 모두 `GrpcRpcObservation` 의 record 성분이다. `GrpcStreamObservation``GrpcMethodName` 하나만 쓴다.
배선 없음(`EVD-325`). `runtime_memberships` 가 비어 있고, 저장소 어디에서도 `new GrpcObservationConvention(...)` 을 만드는 production 코드가 없다.
## 3. 컴포넌트 지도
```
GrpcMetricCardinalityPolicy 태그 키 allowlist 8 · 명시적 거절 11 · 값 패턴 1
GrpcObservationConvention meter 이름 7개 상수 · record 오버로드 2개
GrpcRpcObservation 9성분 record · tags() 7태그
GrpcStreamObservation 7성분 record · tags() 5태그
```
## 4. 계약·불변식
### 4.1 allowlist 가 기본 거절이고 거절 목록은 메시지를 위한 것이다
`violations(Map)` 의 판정 순서가 셋이다.
```java
if (FORBIDDEN_TAGS.contains(key)) "its value space grows with traffic…"
if (!ALLOWED_TAGS.contains(key)) "not on the bounded allowlist [...]"
if (UNBOUNDED_VALUE.matcher(value)) "looks like an identifier or a credential"
```
클래스 javadoc 이 두 목록이 겹치는 이유를 적는다 — "Everything unlisted is refused anyway; naming the dangerous ones gives the refusal a message that says why rather than just that." 즉 `FORBIDDEN_TAGS` 는 판정을 바꾸지 않고 진단만 바꾼다. 두 번째 분기가 이미 그것들을 거절한다.
허용 태그 8개: `grpc.service` · `grpc.method` · `grpc.rpc_type` · `grpc.status` · `grpc.channel_profile` · `grpc.completion_outcome` · `grpc.retry_bucket` · `grpc.stream_termination_reason`.
명시적 거절 11개: `actor_id` · `tenant_id` · `object_id` · `stream_id` · `idempotency_key` · `request` · `response` · `metadata` · `authorization` · `error_detail` · `trace_id`.
### 4.2 값 검사는 세 형태만 잡는다
```java
Pattern.compile("(?i).*([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}|sha256:|bearer ).*")
```
UUID · `sha256:` 접두 · `bearer ` 접두. 숫자 id, 이메일, 호스트명은 잡히지 않는다. 그리고 `.` 은 기본적으로 개행에 맞지 않으므로 값에 개행이 섞이면 `matches()` 가 거짓이 된다.
### 4.3 재시도는 값이 아니라 버킷이다
`retryBucket(int)` 이 1-based 시도 수를 받아 `0`/`1`/`2`/`3+` 로 접는다. 0 이하는 던진다. javadoc 이 이유를 적는다 — "an attempt count is unbounded in principle and the distinction anyone acts on is first attempt, one retry, several."
### 4.4 논리 호출과 물리 시도의 분리
`GrpcRpcObservation` javadoc:
> "A retried call is one observation with a retry bucket, and three attempt events beneath it;
> recording three separate calls instead makes the success rate read as 33% when the caller in fact
> got its answer."
그 분리가 `GrpcObservationConvention.record(GrpcRpcObservation)` 에서 실제로 그렇게 구현되어 있다 — `RPC_DURATION` 타이머는 1회, `RPC_ATTEMPTS` 카운터는 `attempts` 만큼 증가. 같은 태그 집합을 쓴다.
### 4.5 조건부 기록 둘
```java
if (observation.completionOutcome().requiresReconciliation()) COMPLETION_UNKNOWN 카운터
if (!observation.queueWaitTime().isZero()) QUEUE_WAIT 타이머
```
대기 시간이 0 이면 타이머를 등록조차 하지 않는다. 즉 큐 대기가 없던 배포에서는 그 meter 가 생기지 않는다.
### 4.6 생성자 검증의 비대칭 — 의도된 쪽
`GrpcRpcObservation` 의 검증에서 `duration``queueWaitTime` 은 음수를 거부하고 `deadlineRemaining` 은 존재만 요구한다. 그리고 `unusedDeadline()` 이 음수일 때 빈 값을 돌려준다. 마감을 넘긴 호출을 표현하기 위한 것으로 읽히고, 두 메서드가 그 해석과 일관된다.
### 4.7 스트림은 지속 시간이 아니라 무엇이 움직였는지로 잰다
`GrpcStreamObservation` javadoc:
> "Duration percentiles are meaningless here — a healthy subscription lasts an hour and an unhealthy
> one lasts an hour — so what is recorded instead is what actually distinguishes them: how many
> messages moved, how often the writer stalled waiting for the transport, and how it ended."
그리고 `tags()` 주석이 "The stream id is deliberately absent" 라고 적는다. 실제로 `grpc.stream_id``FORBIDDEN_TAGS` 에도 있어 두 겹으로 막힌다.
## 10. 테스트 레인
**10 tests, 0 failures, 0 skipped.** 전부 `GrpcMetricCardinalityPolicyTest`(172줄).
| 테스트 | 붙드는 것 |
|---|---|
| `onlyBoundedTagsAreAllowed` | allowlist 원소 |
| `unboundedIdentifierTagsAreRefused` | 식별자 5종 거절 + 메시지 문구 |
| `contentBearingTagsAreRefused` | 페이로드·메타데이터·오류 상세 3종 |
| `anIdentifierShapedValueIsRefused` | 허용 키 + UUID/`Bearer` 값 |
| `unlistedTagsAreRefused` | 목록 밖 키 + 메시지에 allowlist |
| `attemptsAreBucketed` | 1→`0`, 2→`1`, 4→`3+`, 99→`3+`, 0→예외 |
| `aRetriedCallIsOneObservation` | 타이머 1 · 시도 카운터 3 |
| `completionUnknownIsCountedSeparately` | 전용 카운터 |
| `streamsAreMeasuredByMessagesAndStalls` | `grpc.stream_id` 부재 · 메시지·스톨 카운터 |
| `anUnboundedTagThrowsRatherThanBeingDropped` | 등록 거부가 던지기 |
## 12. negative-space probes
**12.1 도달성.** 블록 전체가 배선되지 않았다(`EVD-325`). `unusedDeadline()`·`retried()`·`consumerFellBehind()`·`allowedTags()`·`forbiddenTags()`·`retryBuckets()` 의 production 호출자 0.
리프 밖 참조도 0 이다.
```
$ grep -rn "grpc.observability" --include=*.java src/ | grep -v /grpc-observability/
grpc-core-api/…/GrpcStableModuleCatalog.java:30: "grpc-observability", ← 목록 안의 문자열
```
그런데 **두 모듈이 이 리프를 `api` 로 노출한다.**
```groovy
grpc/grpc-testkit/build.gradle:53 api project(':grpc:grpc-observability')
grpc/grpc-spring-boot-starter/build.gradle:16 api project(':grpc:grpc-observability')
```
`api` 는 그 모듈을 쓰는 쪽까지 Micrometer 를 포함한 이 리프의 타입을 물려받는다는 선언인데, 두 모듈 어느 자바 파일도 `dev.caskeleton.grpc.observability` 를 import 하지 않는다. 스타터 쪽은 같은 형태의 미사용 의존을 셋 더 들고 있다(`grpc-spring-boot-starter` §12.3).
이 리프의 build.gradle 은 Micrometer 를 `api` 로 두는 이유를 적어 두었다 — 공개 서명이 Micrometer 타입을 이름으로 부르므로 배선하려면 그것을 명명해야 한다. 그 논거는 **이 리프를 실제로 쓰는 모듈**에 대해 성립한다. 지금은 쓰지 않는 두 모듈이 그 전파를 받고 있다.
**12.2 대조군 — 세 개의 카디널리티/노출 정책.**
| 위치 | 막는 것 | 배선 |
|---|---|---|
| `messaging` `CardinalityGuard` | 지표 태그 폭발 | 없음 (`EVD-316`) |
| `grpc-observability` `GrpcMetricCardinalityPolicy` | 태그 키 allowlist + 값 형태 | 없음 |
| `grpc-policy` `GrpcErrorExposurePolicy` | 클라이언트에 보낼 수 없는 문자열 | 블록 미배선 |
**12.3 중복 장치.** `GrpcStreamTerminationReason``grpc-policy` 에 열거형으로 존재한다. 이 리프의 `GrpcStreamObservation.terminationReason``String` 이다. §17.2 참조.
**12.4 문서 드리프트.** build.gradle 주석이 거절 대상으로 든 다섯(actor·tenant·object·stream·idempotency)이 `FORBIDDEN_TAGS` 에 전부 있다. 드리프트 없음.
## 16. 확인하지 못한 것
- 이 리프를 실제 `MeterRegistry` 에 배선해 돌린 적이 없다. 배선 자체가 없으므로 런타임 관측이 불가능하다.
- `UNBOUNDED_VALUE` 를 우회하는 값 형태(숫자 id·이메일 등)를 실행으로 확인하지 않았다. 정규식 형태로 판정했다.
- §17.1-b 의 "마감 잔량에 해당하는 meter 가 없다" 는 meter 이름 상수 일곱 개와 두 `record` 오버로드 본문으로 판정했다. 다른 이름의 상수가 그 역할을 겸하는지는 이름만 보고 배제했다.
- 두 모듈의 `api` 의존이 미사용이라는 것(§12.1)은 패키지 이름 grep 으로 판정했다.
## 17. 손볼 것
### 17.1 P3 — `queueHighWatermark` 는 요구되고 검증되지만 아무도 읽지 않는다
`GrpcStreamObservation` 의 7성분 중 `queueHighWatermark` 만 소비자가 없다.
```
GrpcStreamObservation.java:23 long queueHighWatermark, ← 선언
GrpcStreamObservation.java:31 … || queueHighWatermark < 0 ← 검증
그 외 저장소 전체 매치 0
```
`tags()` 에 없고, `GrpcObservationConvention.record(GrpcStreamObservation)` 이 등록하는 세 meter(`STREAM_LIFETIME`·`STREAM_MESSAGES`·`STREAM_FLOW_CONTROL_STALLS`) 어디에도 들어가지 않는다. 테스트도 `250L` 을 넘기고 그 값에 대해 아무것도 단언하지 않는다.
클래스 javadoc 이 "what is recorded instead is …" 로 세 가지를 열거하는데 그 목록에도 없다. 즉 서술과 구현은 일치하고, 어긋난 것은 **필수 생성자 인자**라는 점이다. 호출자는 측정해서 넘겨야 하고 그 값은 버려진다.
수정은 둘 중 하나다 — `STREAM_QUEUE_HIGH_WATERMARK` gauge/counter 를 추가하거나, 성분에서 뺀다. 큐 최고 수위는 소비자 지연의 직접 지표이므로 전자가 이 클래스의 목적에 맞는다.
### 17.1-b P3 — `deadlineRemaining` 도 meter 가 없다. javadoc 은 그것이 기록된다고 말한다
§17.1 과 같은 형태가 `GrpcRpcObservation` 에도 있고, 이쪽은 클래스 javadoc 이 명시적으로 어긋난다.
> "{@code deadlineRemaining} and {@code queueWaitTime} are **recorded** because they are the two
> numbers that explain a latency change without being latency. A p99 that doubles during a rollout
> is a different incident depending on whether callers were queueing."
두 값을 함께 들면서 "기록된다"고 단언하는데, `record(GrpcRpcObservation)` 이 등록하는 meter 는 넷이다.
```java
Timer.builder(RPC_DURATION)record(observation.duration());
registry.counter(RPC_ATTEMPTS, tags).increment(observation.attempts());
if (requiresReconciliation()) registry.counter(COMPLETION_UNKNOWN, tags).increment();
if (!observation.queueWaitTime().isZero()) Timer.builder(QUEUE_WAIT)record(observation.queueWaitTime());
```
`queueWaitTime``QUEUE_WAIT` 타이머로 나간다. `deadlineRemaining` 은 나가는 곳이 없다 — meter 이름 상수 일곱 개 중에도 마감 잔량에 해당하는 것이 없고, `tags()` 에도 들어가지 않는다(태그로 넣으면 카디널리티가 터지므로 그것이 옳다).
그래서 이 성분을 읽는 코드는 `unusedDeadline()` 하나이고, 그 메서드의 production 호출자는 0 이다(§12.1).
**§4.6 과의 관계.** §4.6 은 이 성분의 검증 비대칭(음수 허용)이 "마감을 넘긴 호출을 표현하기 위한 것" 이라고 읽었다. 그 해석은 그대로 유효하다 — 다만 그 표현이 도달하는 곳이 아직 없다. 관측값으로서는 §17.1 의 `queueHighWatermark` 와 같은 처지다.
**수정.** `queueWaitTime` 과 같은 형태로 타이머를 하나 더 둔다(마감을 넘긴 경우는 `unusedDeadline()` 이 이미 빈 값으로 구분해 주므로 기록 대상에서 빼면 된다). 아니면 javadoc 의 "recorded" 를 "carried" 로 낮춘다. 지금은 관측 대상 둘을 나란히 약속하고 하나만 내보낸다.
### 17.2 P3 — 허용 태그 8개 중 둘은 값이 자유 문자열이고, 그중 하나는 bounded 열거형이 이미 존재한다
값 검사는 키가 allowlist 를 통과한 뒤 `UNBOUNDED_VALUE` 세 형태만 본다. 그런데 태그 값의 출처는 균일하지 않다.
| 태그 | 값 출처 | 유계 |
|---|---|---|
| `grpc.service` · `grpc.method` | `GrpcMethodName` | 서비스/메서드 수만큼 |
| `grpc.rpc_type` · `grpc.status` · `grpc.completion_outcome` | 열거형 | 예 |
| `grpc.retry_bucket` | `retryBucket()` 4값 | 예 |
| `grpc.channel_profile` | `String` (null 이면 `"server"`) | **아니오** |
| `grpc.stream_termination_reason` | `String`, 비어 있지 않기만 하면 됨 | **아니오** |
`GrpcStreamObservation` 의 검증은 `terminationReason` 이 널이 아니고 공백이 아닌지만 본다. 호출자가 예외 메시지나 원격 상태 문자열을 그대로 넣으면 그 태그의 값 공간이 트래픽과 함께 자란다 — 이 클래스가 존재하는 이유로 든 바로 그 실패다.
그리고 그 개념의 bounded 열거형이 이미 저장소에 있다 — `grpc-policy``GrpcStreamTerminationReason`.
쓰지 않은 이유는 의존 방향으로 설명된다. 이 리프의 `allowed_dependencies``["grpc-core-api"]` 뿐이고 그 열거형은 `grpc-policy` 에 있다. 그래서 수정은 열거형을 `grpc-core-api` 로 옮기거나, `violations` 가 두 자유 문자열 태그에 대해 허용값 집합을 받도록 서명을 넓히는 것이다.
### 확인된 설계(문제 아님)
- **논리 RPC / 물리 시도 / 스트림 수명주기를 구별한 것.** 재시도가 있는 시스템에서 호출 한 번이 무엇인지가 층위마다 다르고, `record` 구현이 그 구별을 실제로 지킨다.
- **거절이 드롭이 아니라 던지기인 것.** `boundedTags` 의 javadoc 이 이유를 적는다 — 드롭하면 넣은 쪽이 계속 쓰고 첫 증상이 프로덕션 백엔드의 시계열 거부가 된다.
- **`FORBIDDEN_TAGS` 를 진단 전용으로 둔 것.** 판정은 allowlist 가 하고, 이 목록은 왜 거절인지만 바꾼다.
- **스트림 id 를 두 겹으로 막은 것.** `tags()` 에서 빼고 `FORBIDDEN_TAGS` 에도 둔다.
- **`long → double` 확대 변환을 명시하고 이유를 주석에 적은 것.**
---
## Source anchors
```
src/grpc/grpc-observability/build.gradle:1-12
main/…/observability/GrpcMetricCardinalityPolicy.java:1-123
main/…/observability/GrpcObservationConvention.java:1-99
main/…/observability/GrpcRpcObservation.java:1-78
main/…/observability/GrpcStreamObservation.java:1-54
test/…/observability/GrpcMetricCardinalityPolicyTest.java:1-172
src/grpc/grpc-policy/…/streaming/GrpcStreamTerminationReason.java (대비)
src/messaging/messaging-observability/…/CardinalityGuard.java (대비)
```
@@ -0,0 +1,226 @@
# grpc-operation-ledger-jpa 완전 해부
> 상태: COMPLETE
> 재오픈 게이트: cycle 2 — `src/main` production 3파일과 마이그레이션 1개 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음.
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/grpc/grpc-operation-ledger-jpa`
> SSOT owner: `grpc-operation-ledger-jpa`
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지와 숫자 지도
- `allowed_dependencies`: `["grpc-core-api"]`
- `runtime_memberships`: **`[]`** — build-only
| 파일 | LOC | 성격 |
|---|---:|---|
| `GrpcOperationLedgerEntity` | 155 | JPA 엔티티 + 상태 전이 |
| `JpaGrpcOperationLedger` | 93 | 포트 구현 (insert-first 주장) |
| `GrpcOperationLedgerRepository` | 28 | Spring Data 인터페이스 (메서드 4개) |
| `V001__create_grpc_operation_ledger.sql` | 39 | 테이블 + 제약 4 + 인덱스 1 |
| `GrpcOperationLedgerRepositoryTest` | 208 | 테스트 (인메모리 이중) |
| `build.gradle` | 17 | project 1 + vendor 2 |
### Coverage ledger
| scope | count | disposition | reason |
|---|---:|---|---|
| `main/java/**` | 3 | `FULL_READ` | 155+93+28 전 본문 |
| `main/resources/db/migration/grpc/*.sql` | 1 | `FULL_READ` | 39줄 전문 |
| `test/java/**` | 1 | `FULL_READ` | 208줄, 인메모리 이중 구현 포함 |
| `build.gradle` | 1 | `FULL_READ` | 17줄 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
`UNCLASSIFIED` 0.
---
## 1. 모듈의 정체
```groovy
// build.gradle:3-8
// Durable mutation idempotency: the operation ledger entity, its state machine, the vendor-neutral
// repository port and the Spring Data JPA binding, plus the migration that owns the unique
// constraint the whole contract rests on.
// … Tests here run against a hand-rolled in-memory port implementation — a real datastore is only
// justified when vendor semantics are the thing under test, and the constraint is asserted by the migration.
```
마지막 문장이 이 리프의 검증 전략을 규정한다. §17.1 이 그 전략의 경계를 다룬다.
## 2. 스키마가 계약이다
```sql
CONSTRAINT pk_grpc_operation_ledger PRIMARY KEY (storage_key),
CONSTRAINT uq_grpc_operation_ledger_identity
UNIQUE (caller_fingerprint, full_method_name, idempotency_key_hash),
CONSTRAINT ck_grpc_operation_ledger_state
CHECK (state IN ('IN_PROGRESS', 'COMMITTED', 'FAILED_TERMINAL')),
CONSTRAINT ck_grpc_operation_ledger_committed_has_outcome
CHECK (state <> 'COMMITTED' OR outcome_reference IS NOT NULL),
CONSTRAINT ck_grpc_operation_ledger_terminal_has_completion
CHECK (state = 'IN_PROGRESS' OR completed_at IS NOT NULL)
```
마이그레이션 헤더가 왜 애플리케이션 검사가 아니라 제약인지 적는다.
> "A uniqueness check in application code instead would be a read followed by a write, with a window
> between them precisely as wide as the race it is meant to close."
커밋 행이 결과를 반드시 갖는다는 검사를 자바 record 와 DB 양쪽에 둔 이유도 적혀 있다 — 마이그레이션·백필·지원 스크립트가 쓴 행은 record 를 지나지 않는다.
전용 Flyway 위치(`db/migration/grpc`)를 쓰는 이유도 적혀 있다. gRPC 플랫폼을 채택하지 않은 배포가 이 테이블을 만들도록 강요받지 않기 위해서다.
## 3. 저장 키와 유니크 제약이 같은 행을 가리킨다
`GrpcOperationIdentity`(grpc-core-api):
```java
public String storageKey() {
return callerFingerprint + "|" + method.canonical() + "|" + idempotencyKeyHash;
}
```
즉 기본 키는 유니크 제약의 세 컬럼을 이어 붙인 파생값이다. 엔티티 javadoc 이 그 이중 저장을 설명한다 — 복합 쪽이 원자성을 주고, 파생 키가 조회에 단일 컬럼 기본 키를 준다.
같은 신원의 두 번째 청구는 **같은 기본 키 행**을 겨냥한다. §17.1 이 그 사실에서 나온다.
## 4. 좁은 저장소 인터페이스
`Repository` 를 확장하고 네 메서드만 이름 짓는다.
> "`JpaRepository` publishes `deleteAll`, `findAll` and `saveAll` on the table that decides whether a
> payment runs twice."
## 5. 어댑터의 주장
`JpaGrpcOperationLedger` javadoc:
> "`claim` is insert-first, read-on-conflict — not read-then-insert. That ordering is the whole
> adapter… A read-first implementation has a window between the read and the insert that is exactly
> as wide as the race it is supposed to close, and it passes every test that does not run the two
> attempts concurrently."
트랜잭션 애너테이션이 없는 이유도 적혀 있다 — 커밋은 호출자의 업무 트랜잭션 안에서 일어나야 하고 `REQUIRES_NEW` 는 변경이 내구적인데 청구는 아닌 창을 다시 만든다.
## 6. 상태 전이
`IN_PROGRESS` 에서만 전이할 수 있다(`requireInProgress`). 커밋은 결과 참조가 비면 거부한다. `EnumType.STRING` 을 쓰는 이유가 javadoc 에 있다 — 서수 컬럼은 열거형에 값이 끼어들면 저장된 모든 행을 조용히 다른 값으로 만든다.
## 10. 테스트 레인
11개 테스트. 인메모리 저장소 이중이 `putIfAbsent` 로 기존 행이 있으면 `DataIntegrityViolationException` 을 던진다 — INSERT + 유니크 제약의 동작을 모사한다.
마지막 테스트가 마이그레이션 파일을 직접 읽어 유니크 제약 문장이 있는지 단언한다.
## 12. negative-space probes
**12.1 도달성.** build-only. `JpaGrpcOperationLedger` 를 만드는 production 코드가 없다.
**12.2 마이그레이션 적용 경로.** `db/migration/grpc` 를 가리키는 설정이 저장소에 없다. main 설정 어디에도 `spring.flyway.locations` 가 없고(app-bootstrap 의 네 프로파일 yml 전수 확인), 그 경로를 이름으로 부르는 것은 이 리프의 테스트 한 곳뿐이다. messaging 가족이 §7.2 에서 기록한 것과 같은 형태다.
**12.4 드리프트.** build.gradle 주석이 서술한 네 요소(엔티티·상태 기계·포트·Spring Data 바인딩)와 마이그레이션이 전부 존재한다. 드리프트 없음.
## 16. 확인하지 못한 것
- 실제 데이터베이스로 `claim` 을 두 번 돌려 §17.1 을 재현하지 않았다. Spring Data JPA 의 `save` 계약과 이 엔티티의 식별자 형태로 판정했다.
- 동시 청구를 실제 커넥션 둘로 재현하지 않았다.
## 17. 손볼 것
### 17.1 P2 — insert-first 주장이 Spring Data 의 `save` 계약과 어긋난다. 그리고 테스트 이중이 그 차이를 가린다
어댑터는 이렇게 쓴다.
```java
try {
repository.save(GrpcOperationLedgerEntity.claim(identity, requestFingerprint, now));
return Optional.empty(); // 내가 이겼다
} catch (DataIntegrityViolationException alreadyClaimed) {
return repository.findById(identity.storageKey()).map(entity -> entity.toRecord(identity));
}
```
전제는 `save` 가 INSERT 이고, 같은 신원의 두 번째 청구가 유니크 제약을 건드린다는 것이다.
그러나 이 엔티티의 식별자는 **호출자가 배정한다**. `claim(...)` 팩토리가 `storageKey``identity.storageKey()` 로 채우므로 `@Id` 가 널이 아니다. Spring Data JPA 의 `SimpleJpaRepository.save` 는 식별자가 널이 아닌 엔티티를 새 것으로 보지 않고 `EntityManager.merge` 로 보낸다.
그리고 §3 에서 확인했듯 기본 키는 유니크 제약의 세 컬럼에서 파생된다. 같은 신원의 두 번째 청구는 **같은 행**을 겨냥한다.
따라서 실제 JPA 에서 일어나는 일은 이렇다.
1. 두 번째 청구가 `merge` 로 들어간다. 그 행은 이미 존재한다.
2. 유니크 제약이 발화하지 않는다. 새 행을 넣는 것이 아니라 같은 행을 갱신하기 때문이다.
3. 분리 상태의 새 엔티티가 기존 행 위에 복사된다 — `state``IN_PROGRESS`, `outcome_reference` 는 널, `completed_at` 은 널, `claimed_at` 은 지금.
4. 예외가 없으므로 `claim``Optional.empty()` 를 돌려준다. 호출자는 자기가 청구를 소유했다고 읽는다.
즉 이미 커밋된 연산의 결과 참조가 지워지고, 재시도가 그 변경을 다시 실행한다. 이 모듈이 존재하는 이유로 든 바로 그 결과다.
세 CHECK 제약도 이것을 막지 못한다. 갱신 후 상태는 `IN_PROGRESS` + `completed_at` 널이라 전부 합법이다.
덧붙여 `merge` 는 즉시 flush 하지 않으므로, 서로 다른 트랜잭션의 진짜 경합에서 제약 위반이 나더라도 그것은 flush 나 커밋 시점에 도착한다 — `try` 블록 밖이다.
**테스트가 이것을 볼 수 없는 이유.** 인메모리 이중의 `save` 는 키가 이미 있으면 예외를 던진다.
```java
GrpcOperationLedgerEntity existing = rows.putIfAbsent(entity.getStorageKey(), entity);
if (existing != null && existing != entity) { throw new DataIntegrityViolationException(...); }
```
즉 이중은 INSERT 를, 실제 저장소는 UPSERT 를 한다. build.gradle 주석이 실제 데이터스토어를 쓰지 않는 근거로 "vendor semantics 가 시험 대상일 때만 정당하다" 고 적었는데, 여기서 어긋난 것이 정확히 vendor semantics 다.
**등급.** 이 리프는 build-only 이고 어떤 배포도 이 어댑터를 조립하지 않는다. 그래서 오늘의 사고는 아니다. 배선하는 순간 성립한다.
**수정.** 셋 중 하나다.
- 엔티티가 `Persistable<String>` 을 구현해 `isNew()` 를 명시한다. 신규 여부를 어댑터가 안다.
- 저장소에 `@Modifying @Query` 로 명시적 INSERT 를 두고 `save` 를 청구 경로에서 쓰지 않는다.
- 청구를 `INSERT … ON CONFLICT DO NOTHING` 의 영향 행 수로 판정한다.
어느 쪽이든 테스트 이중이 아니라 실제 데이터베이스에서 두 번 청구하는 계약 테스트가 함께 필요하다.
### 17.2 P3 — 낙관적 잠금 컬럼이 없어 전이 가드가 메모리 안에만 있다
`requireInProgress()` 가 두 번째 종결 전이를 막는다. 그 가드는 한 영속성 컨텍스트 안의 인스턴스 상태에만 적용된다. 엔티티에 `@Version` 이 없으므로 두 트랜잭션이 같은 행을 읽어 각각 전이하면 나중 쓰기가 앞의 것을 덮는다.
DB 의 세 CHECK 제약은 행의 모양을 지키지 지 전이 순서를 지키지 않는다. `COMMITTED` 행이 다른 결과 참조로 갱신되는 것을 막는 제약이 없다.
청구가 배타적이라는 설계 전제 아래서는 도달성이 낮다. 다만 §17.1 을 고치면 이 전제가 실제로 성립하는지가 함께 확인되어야 한다.
### 17.3 P3 — `markCommitted` 는 던지고 `markFailed` 는 조용히 넘어간다
```java
markCommitted findById(...).orElseThrow(IllegalStateException) // 청구 없으면 실패
markFailed findById(...).ifPresent(entity -> ) // 청구 없으면 무동작
```
커밋 쪽의 근거는 자바독에 있다 — 청구 없이 커밋하면 변경은 내구적이고 보호받지 못한다.
실패 쪽에는 근거가 없다. 청구가 사라진 뒤 도착한 종결 실패가 아무 흔적도 남기지 않는다. 회수가 청구를 지운 뒤 원래 소유자가 실패를 기록하려는 경우가 그 형태다. 의도라면 그 이유를 자바독에 적어야 하고, 아니라면 커밋 쪽과 같게 다뤄야 한다.
### 확인된 설계(문제 아님)
- **유니크 제약을 애플리케이션 검사 대신 쓰기로 한 판단과 그 근거.**
- **커밋 행이 결과를 갖는다는 규칙을 record 와 DB 양쪽에 둔 것** — 마이그레이션·백필·지원 스크립트는 record 를 지나지 않는다.
- **`Repository` 를 확장해 네 메서드만 노출한 것.**
- **`EnumType.STRING`** — 서수 컬럼의 조용한 재지정을 피한다.
- **전용 Flyway 위치** — 채택하지 않은 배포에 테이블을 강요하지 않는다.
- **트랜잭션 애너테이션을 두지 않은 것과 그 근거.**
- **상태·완료 시각 CHECK 제약** — 종결 상태는 완료 시각을 갖는다.
---
## Source anchors
```
src/grpc/grpc-operation-ledger-jpa/build.gradle:1-17
main/java/…/ledger/JpaGrpcOperationLedger.java:1-93
main/java/…/ledger/GrpcOperationLedgerEntity.java:1-155
main/java/…/ledger/GrpcOperationLedgerRepository.java:1-28
main/resources/db/migration/grpc/V001__create_grpc_operation_ledger.sql:1-39
test/java/…/ledger/GrpcOperationLedgerRepositoryTest.java:1-208
src/grpc/grpc-core-api/…/ledger/GrpcOperationIdentity.java:36-38
src/app-bootstrap/src/main/resources/application*.yml (flyway locations 부재 확인)
```
@@ -0,0 +1,389 @@
# grpc-policy 완전 해부
> 상태: COMPLETE
> 재오픈 게이트: cycle 2 재통독(2026-09-01) — `src/main` production 62파일 4,781줄 + `src/test` 18파일 2,800줄 축자 통독 완료. `STRUCTURAL_ONLY` 는 `gradle.lockfile` 하나.
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/grpc/grpc-policy`
> SSOT owner: `grpc-policy`
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지
- `runtime_memberships`: **`[]`** — build-only
- vendor: `io.grpc` BOM 을 **모듈 범위**로 가져온다
```groovy
// build.gradle:6-8
// io.grpc versions are NOT managed by the Spring Boot BOM and this repo has no version catalog, so
// the grpc-bom is imported at MODULE scope from the root `ext.grpcVersion` SSOT — the same shape
// `adapter:inbound:grpc` uses, keeping the strict-locking blast radius local.
```
| 패키지 | 파일 | 줄 | 주제 |
|---|---:|---:|---|
| `streaming` | 19 | 1,440 | 봉투·재개 토큰·직렬 기록기·간극 탐지·흐름 제어·수명·승인·심박 |
| `resilience` | 11 | 809 | 재시도 설정·예산·조정자·결정·자격·소유권·소유권 검증·서비스 설정·wait-for-ready 3종 |
| `idempotency` | 8 | 627 | 결정·인터셉터·지문·결과 재생·완료 조정·완료 판정·연산 상태·상태 질의 |
| `deadline` | 6 | 402 | 계산기·정책 검증기·취소 조정자·취소 가능 연산·취소 사유·의존 예산 |
| `error` | 4 | 407 | 매퍼·노출 정책·리치 상세·상태 매핑 |
| `policy` | 4 | 314 | 적재물 경계·메시지 크기·압축 프로파일·크기 위반 |
| `security` | 4 | 329 | TLS 프로파일·인증 프로파일·자격 세대·회전 관리자 |
| `context` | 3 | 229 | 문맥 결속기·전파 정책·스냅숏 |
| `validation` | 3 | 284 | 전송 검증기·위반·protovalidate 인터셉터 |
main 총 **62파일 / 4,781줄**.
### Coverage ledger
| scope | count | disposition | reason |
|---|---:|---|---|
| `main/java/**` | 62 | `FULL_READ` | 4,781줄. 패키지 9개의 전 파일 본문 |
| `test/java/**` | 18 | `FULL_READ` | 2,800줄. 패키지 9개 전부 |
| `build.gradle` | 1 | `FULL_READ` | 23줄 전문 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 — 생성물이고 의미 없는 좌표 반복 |
`UNCLASSIFIED` 0.
> 이 표는 2026-09-01 재통독에서 다시 세었다. 이전 판의 패키지 표는 합이 47이었다 — `streaming` 을 9, `resilience` 를 7 로 적고 `security`·`validation` 에 `3+` 라고 썼다. 전 파일을 열지 않은 채 적힌 수였고, 그 표가 곧 통독이 끝나지 않았다는 증거였다. 아래 §17.6–17.8 은 나머지 15파일을 읽고 나서야 나온 것이다.
---
## 1. 오류 매퍼 — 클라이언트는 메시지 문자열을 읽지 않는다
> "The rule it exists to hold is that a client never reads a message string. Everything a caller
> needs to branch on is a code, an `ErrorInfo.reason`, or a typed detail; the description is for a
> human reading a log and is replaced wholesale whenever it is not provably safe."
> "An unrecognised exception becomes `INTERNAL` with an opaque execution id and nothing else. The id
> is the entire bridge between what the client saw and what the operator can find, and it is
> generated rather than derived so that it cannot accidentally encode a key or a row id."
실행 식별자 공급자가 주입되는 이유도 적혀 있다 — 무작위 값을 단언하지 않고도 그 식별자가 트레일러에 닿는 것을 테스트가 확인할 수 있게 하기 위해서다.
## 2. 적재물 경계 — 자원이 아니라 구조의 문제
> "The binary rule is the one with an architectural reason behind it rather than a resource one.
> This repository already has a file server and an object store; a method that accepts a file as
> bytes duplicates their responsibility, loses their resumability and lifecycle, and puts the file in
> a request that has to be buffered whole to be parsed."
그리고 도달할 수 없는 설정을 생성자가 거부한다 — 인라인 이진 임계값이 메시지 상한보다 크면 결코 발화하지 않는다.
## 3. 재개 토큰 — 서명하고, 구분자를 봉인한다
`GrpcResumeToken` 의 아홉 성분 각각이 왜 필요한지가 javadoc 에 있다 — 스냅숏 판본 없이는 사라진 뷰의 위치에서 재개하고, 만료 없이는 이력이 사라진 커서에서 재개하고, 필터 지문 없이는 남의 필터를 자기 위치에서 재개해 요청하지 않은 행을 받는다.
그리고 문자열 성분이 구분자를 담지 못하게 생성자가 거부한다.
```java
if (value.indexOf('|') >= 0) {
throw new IllegalArgumentException(what + " must not contain '|', which separates the token's fields");
}
```
`GrpcResumeTokenCodec` 의 검증이 세 성질을 지킨다 — 상수 시간 비교(`MessageDigest.isEqual`), 알 수 없는 키 식별자 거부, 세 실패의 구분 불가.
> "A codec that retries verification with every key it holds turns key rotation into a window in
> which a token signed by a compromised key still verifies."
> "The three are deliberately indistinguishable to a caller: telling them apart is a probing oracle."
## 4. 재시도 예산 — 이 가족의 원자성 정본
```java
public boolean tryConsume() {
while (true) {
long observed = tokens.get();
if (observed < tokensPerRetry) { return false; }
if (tokens.compareAndSet(observed, observed - tokensPerRetry)) { return true; }
}
}
```
같은 문제를 이 리프의 `GrpcStreamAdmission``grpc-server``GrpcAdmissionController` 는 비원자적으로 푼다(§17.1).
## 5. 자격증명 회전 — 준비 후 교체 후 배수
> "Replacing the material in place is what produces the failure this exists to avoid: every call that
> was mid-flight when the swap happened fails with an authentication error that looks, from the
> client, exactly like a credential that was never valid."
같은 세대를 다시 적용하는 것은 무동작이다 — 재시도된 회전이 첫 회전이 받아들인 호출을 취소하면 안 되기 때문이다.
## 10. 테스트 레인
18파일 2,800줄. 패키지 9개 전부에 테스트가 있고, 배치는 균등하지 않다.
| 패키지 | 테스트 | 줄 |
|---|---:|---:|
| `streaming` | 5 | 675 |
| `resilience` | 3 | 630 |
| `idempotency` | 3 | 397 |
| `validation` | 1 | 240 |
| `error` | 1 | 182 |
| `security` | 1 | 163 |
| `context` | 1 | 156 |
| `deadline` | 2 | 227 |
| `policy` | 1 | 130 |
`idempotency` 의 세 번째 파일은 테스트가 아니라 손으로 쓴 원장 이중 `InMemoryOperationLedger` 이고, 그 javadoc 이 자기 존재 이유를 적어 둔다.
> "`putIfAbsent` on a concurrent map is the in-memory equivalent of the unique constraint the real
> adapter relies on, so the claim is atomic here for the same reason it is there. A fake that read
> and then wrote would let the policy tests pass while the property they exist to check does not
> hold."
즉 이 리프의 멱등 테스트는 원장의 원자성을 **가정**한다. 그 가정이 실제 어댑터에서 성립하는지는 `grpc-operation-ledger-jpa` §17.1 의 주제이고, 그 리프의 판정은 성립하지 않는다는 것이다. 이중이 production 보다 엄격하다.
이 리프는 동시성 테스트를 쓸 줄 안다. `GrpcSerializedStreamWriterTest.concurrentProducersDoNotTouchTheTransport` 는 생산자 8개로 400회를 밀어 넣고 순서·중복 없음을 단언한다. 그래서 §17.1·§17.2·§17.6 의 경합이 단일 스레드로만 시험되는 것은 능력의 한계가 아니라 선택이다.
## 12. negative-space probes
**12.1 도달성.** build-only. `grpc-spring-boot-starter` 가 이 리프의 타입 중 문맥 결속기와 오류 매퍼만 빈으로 만든다. 인터셉터 둘(`ProtovalidateGrpcInterceptor`·`GrpcIdempotencyInterceptor`)과 스트림 계열 19파일 전부는 조립되지 않는다. 이 리프의 모든 판정 등급이 그래서 한 칸 낮다 — 오늘의 사고가 아니라 배선하는 날의 사고다.
**12.2 대조군 — 원자성 셋.** 같은 저장소 안에 세 구현이 있다.
| 구현 | 형태 |
|---|---|
| `GrpcRetryBudget.tryConsume` | 비교 후 교체 루프 — 정확 |
| `GrpcStreamAdmission.tryAdmit` | 읽고 비교한 뒤 별도 증가 — §17.1 |
| `GrpcAdmissionController.tryAdmit`(grpc-server) | 같은 형태 |
**12.3 대조군 — 배수 플래그.** 저장소 전체에서 `volatile boolean` 은 정확히 둘이다.
```
grpc-client/…/GrpcChannelRuntime.java:20 private volatile boolean draining;
grpc-admin/…/GrpcServiceHealthRegistry.java:25 private volatile boolean draining;
```
같은 뜻의 세 번째 플래그가 `GrpcStreamLifecycleCoordinator.drainSignalled` 인데 여기에는 `volatile` 이 없다(§17.6). 같은 저장소가 같은 문제를 두 번은 표시하고 한 번은 표시하지 않았다.
**12.4 드리프트.** build.gradle 이 서술한 아홉 주제가 전부 패키지로 존재한다. 파일 수의 분포는 균등하지 않다 — `streaming` 하나가 main 의 30%(19/62)다.
**12.5 테스트가 볼 수 없는 것.** 세 곳에서 테스트의 형태가 결함을 구조적으로 가린다.
| 결함 | 가리는 형태 |
|---|---|
| §17.4 DROP_OLDEST 바이트 계산 | `writer(policy, messageSize)``() -> messageSize` 상수 크기 공급자를 넘긴다. 모든 메시지가 같은 크기면 잘못 뺀 값과 옳은 값이 같다 |
| §17.1 승인 경계 경합 | `streamAdmissionBoundsTotalAndPerCaller` 가 단일 스레드다 |
| §17.6 배수 신호 가시성 | `aDrainOutranksTheTimers``signalDrain()``terminationDue()` 를 같은 스레드에서 부른다 |
**12.6 설정처럼 보이지만 상수인 것.** `GrpcContextPropagationPolicy.clearAfterTask` 는 두 값을 받는 성분인데 생성자가 `false` 를 무조건 거부한다. 합법 값이 하나뿐이고, 그 값을 읽는 production 코드도 없다(§17.8).
## 16. 확인하지 못한 것
- 어떤 인터셉터도 실제 서버에 걸어 돌리지 않았다. 배선 경로가 없다.
- 동시 회전·동시 해제·동시 승인을 실행으로 재현하지 않았다. 원자성 분석과 JMM 으로 판정했다.
- §17.6 의 가시성 실패를 관측하지 않았다. `volatile` 부재와 두 호출자의 스레드 소속으로 판정했다. 관측하려면 배수 스레드와 스트림 틱 스레드를 분리한 반복 시험이 필요하고, 이런 실패는 재현되지 않는 것이 정상이다.
- §17.7 의 IPv6 누출을 실제 예외 메시지로 재현하지 않았다. 거부 목록 아홉 패턴을 전부 읽고 IPv4 점표기 외에 주소 형태를 보는 패턴이 없음을 확인해 판정했다.
- `gradle.lockfile` 은 읽지 않았다(`STRUCTURAL_ONLY`).
## 17. 손볼 것
### 17.1 P2 — 스트림 승인의 경계가 동시성 아래에서 새고, caller별 맵이 줄지 않는다
```java
AtomicInteger callerCount = perCaller.computeIfAbsent(callerFingerprint, key -> new AtomicInteger());
if (callerCount.get() >= maxStreamsPerCaller) { return false; }
if (openStreams.get() >= maxConcurrentStreams) { return false; }
callerCount.incrementAndGet();
openStreams.incrementAndGet();
```
읽고 비교한 뒤 별도로 증가한다. 경계에 있는 N 개 스레드가 모두 통과한다.
이 클래스의 javadoc 이 서술하는 실패 상황이 곧 고동시성이다 — "a client that reconnects on every error opens streams faster than the old ones close." 재접속 폭풍에서 경계가 가장 많이 샌다.
`release` 도 같은 형태라 음수로 갈 수 있다.
그리고 `perCaller` 에서 항목이 제거되지 않는다. `computeIfAbsent` 가 호출자 지문마다 계수기를 만들고 `release` 는 값만 줄인다. 서로 다른 호출자 수만큼 맵이 자란다 — `grpc-observability``GrpcMetricCardinalityPolicy` 가 지표 태그에 대해 명시적으로 막는 것과 같은 종류의 증가이고, 여기에는 그 가드가 없다.
정본이 같은 리프에 있다 — `GrpcRetryBudget.tryConsume` 의 비교 후 교체 루프.
### 17.2 P2 — 자격증명 회전이 비교 후 교체가 아니고, 배수 완료가 진행 중인 회전을 되돌릴 수 있다
```java
State observed = state.get();
state.set(new State(next, observed.current(), deadline)); // rotate
public void completeDrain() {
State observed = state.get();
state.set(new State(observed.current(), null, null)); // completeDrain
}
```
`AtomicReference` 를 쓰면서 두 메서드 모두 읽고 나서 조건 없이 쓴다.
두 결과가 다르다.
**회전 경합.** 두 회전이 같은 `observed` 를 읽으면 둘 다 승계 검사를 통과할 수 있고, 나중 `set` 이 앞의 것을 덮는다. 덮인 회전이 배수 대상으로 기록해 둔 세대가 상태에서 사라진다. 그 세대 위의 호출은 아무도 배수하지 않는다.
javadoc 이 이 상황을 이미 알고 있다 — 승계 검사의 존재 이유로 "the usual reason for one is two rotators racing" 를 든다. 검사는 있고 원자성이 없다.
**배수 완료의 되돌림이 더 무겁다.** `completeDrain()` 이 자기가 읽은 `observed.current()` 로 새 상태를 만든다. 읽기와 쓰기 사이에 회전이 일어나면, 그 회전이 활성화한 세대가 지워지고 **이전 세대가 다시 현재가 된다.** 즉 방금 교체된 자격증명이 되살아난다.
클래스의 존재 이유가 "in-flight 작업을 떨어뜨리지 않고 자격 자재를 교체하는 것" 인데, 이 경로는 교체 자체를 되돌린다.
수정은 두 메서드를 비교 후 교체로 바꾸는 것이다. `rotate``compareAndSet(observed, next)` 가 실패하면 다시 읽어 판정하고, `completeDrain``updateAndGet(s -> new State(s.current(), null, null))` 로 현재 값을 원자적으로 읽어 쓰면 된다. 후자는 한 줄이다.
같은 형태가 `grpc-client``GrpcChannelRuntimeRegistry.rotate` 에도 있다. 두 리프가 같은 자료구조를 같은 방식으로 잘못 쓴다.
### 17.3 P2 — 결과 재생 저장소에 제거 경로가 없다
```java
private final ConcurrentMap<String, byte[]> storedOutcomes = new ConcurrentHashMap<>();
```
`maxInlineBytes` 는 항목 하나의 크기를 제한하고, 개수를 제한하는 것은 없다. `remove`·`clear`·축출·만료가 전부 없다. `size()` 만 있고 그 값을 읽는 곳도 없다.
`store` 는 멱등 키가 필요한 메서드가 커밋될 때마다 불리므로 프로세스 수명 동안 커밋 수만큼 쌓인다.
javadoc 이 이 저장소를 "a small inline store" 라 부르는데 작게 유지하는 장치가 없다. 크기를 넘는 응답은 거부하면서 개수는 거부하지 않는다.
이웃 리프의 자매 클래스가 같은 문제를 명시적으로 다룬다 — `GrpcClientMessageDeduplicator`(advanced-streaming)의 javadoc 이 "A set grows without bound for the life of a session" 을 집합 방식을 거부한 이유로 들고, `endSession()` 으로 세션 단위 정리를 한다.
다만 그 자매 클래스도 절반만 지킨다. 재통독에서 확인했다 — 체크포인트 맵은 세션당 항목 하나로 유지되지만, 형제인 `replayableOutcomes` 는 적용된 메시지마다 항목을 쌓고 `endSession` 전까지 줄지 않는다. 그 리프의 §17.1 이 그것을 자기 판정으로 기록한다. 그러므로 이 자리의 대조는 "저쪽은 풀었고 이쪽은 안 풀었다" 가 아니라 **"두 리프가 같은 형태의 무제한 증가를 갖고 있고, 한쪽만 세션 경계라는 부분적 상한을 갖는다"** 이다.
### 17.4 P2 — 직렬 스트림 기록기의 가장 오래된 것 버리기가 잘못된 메시지의 바이트를 뺀다
```java
case DROP_OLDEST -> {
GrpcStreamEnvelope<T> dropped = queue.pollFirst();
if (dropped != null) {
queuedBytes = Math.max(0L, queuedBytes - nextBytes); // ← 들어오는 메시지의 크기
droppedMessages++;
}
enqueue(kind, payload, snapshotVersion, resumeToken, nextBytes);
yield GrpcStreamWriteResult.DROPPED;
}
```
버려지는 것은 꺼낸 봉투인데 빼는 값은 새 메시지의 크기다. 봉투는 크기를 성분으로 담지 않으므로 이 지점에서 버려지는 크기를 알 방법이 없다.
계산을 따라가면 이렇다. 한 번의 DROP_OLDEST 마다 `queuedBytes``nextBytes` 만큼 빠졌다가 `enqueue` 에서 같은 값만큼 다시 더해진다 — **순변화 0**. 그런데 큐의 실제 내용은 `nextBytes - droppedBytes` 만큼 바뀐다. 그 차이가 매 낙차마다 쌓인다.
방향은 둘 다 틀렸다. 들어오는 메시지가 버려지는 것보다 크면 추적값이 실제보다 **낮아져** 바이트 경계가 늦게 발화한다(메모리). 반대면 실제보다 **높아져** 경계가 이르게 발화한다(불필요한 종료·낙차). 누적 바이트는 흐름 제어 정책의 판정 입력이고, 바이트 경계의 존재 이유가 javadoc 에 있다 — 개수 경계만 있으면 메모리 한도를 가장 큰 메시지가 정한다.
**범위는 flush 창 하나다.** `flush()` 가 큐를 비우면서 `queuedBytes = 0L` 로 되돌리므로 오차가 flush 를 건너 누적되지는 않는다. 그래서 이것은 영구 드리프트가 아니라 한 flush 주기 안의 폭주 구간에서 바이트 경계를 잘못 판정하는 결함이다. 낙차가 일어나는 상황이 곧 소비자가 못 따라가는 상황이고, 그때 flush 간격이 가장 길어진다.
### 17.5 P2 — 완료 조정자가 요청 경로에서 동기화 없는 가변 리스트를 변경한다
```java
private final List<PendingCase> pending = new ArrayList<>();
pending.add(new PendingCase(...)); // reconcile(...) — 요청 경로
List.copyOf(pending); // pendingCases()
pending.remove(resolved); // clearPending(...)
```
`synchronized`·`Concurrent*`·`volatile`·`Lock` 전부 0 이고 단일 스레드 전용 표기도 없다. 같은 리프의 `GrpcSerializedStreamWriter` 는 아홉 마커로 제대로 닫혀 있어, 이 리프가 동시성을 인지하고 있음을 보여 준다.
`reconcile` 은 완료 결과가 불확실한 호출마다 불린다 — 장애 상황에서 동시에 몰리는 경로다. 그리고 `pending` 이 담는 것은 사람이 조정해야 하는 연산 목록이므로, 유실은 조정되지 않은 채 잊히는 연산이 된다.
### 17.6 P2 — 스트림 수명 조정자의 배수 신호가 스레드를 건너면서 `volatile` 이 아니다
```java
private boolean drainSignalled;
public void signalDrain() { drainSignalled = true; }
public Optional<GrpcStreamTerminationReason> terminationDue(Instant now, Instant credentialExpiry) {
if (drainSignalled) { return Optional.of(GrpcStreamTerminationReason.SERVER_DRAIN); }
```
두 메서드의 호출자가 다른 스레드다. `signalDrain()` 은 서버가 내려갈 때 종료 훅이 부르고, `terminationDue(...)` 는 스트림 자신의 틱에서 불린다 — 클래스 javadoc 이 검사 순서를 "then drain, because a server that has been told to stop should stop before its own timers fire" 로 규정한 그 틱이다.
평범한 `boolean` 이고 `volatile`·`synchronized`·`AtomicBoolean` 어느 것도 없다. 자바 메모리 모델 아래서 틱 스레드가 이 쓰기를 관측할 보장이 없다. 관측하지 못하면 스트림은 배수 명령을 받고도 계속 돌고, 최대 수명(기본 1시간)이 차야 끝난다.
같은 저장소가 같은 뜻의 플래그를 두 번은 `volatile` 로 적었다(§12.3). 세 번째만 빠졌다.
수정은 `volatile boolean` 한 단어다. `heartbeat``lastActivity` 는 같은 문제가 아니다 — 스트림 틱 스레드만 만진다.
### 17.7 P3 — 오류 노출 거부 목록의 "호스트와 포트" 규칙이 IPv4 점표기만 본다
```java
Pattern.compile("\\b\\d{1,3}(\\.\\d{1,3}){3}(:\\d{1,5})?\\b"),
```
아홉 패턴을 전부 읽으면 주소 형태를 보는 것은 이 하나다. 클래스 javadoc 은 거부 대상을 "a stack frame, a SQL fragment, a JDBC URL, a bearer token, **a host and port**, a file path" 로 서술하는데, 실제로 걸리는 host 는 IPv4 점표기뿐이다.
통과하는 것들:
- IPv6 리터럴 — `fe80::1`, `[2001:db8::1]:5432`
- DNS 이름과 포트 — `documents-db.internal:5432`, `kafka-0.kafka-headless:9092`
`jdbc:postgresql://db/app` 이 막히는 것은 host 규칙이 아니라 `jdbc:` 규칙 때문이다. 즉 이 구멍은 테스트에도 없다 — `exposurePolicyRefusesLeakyStrings` 의 아홉 사례 중 주소는 `upstream 10.0.3.14:5432 refused` 하나이고 IPv4 다.
닿는 경로는 `mapUnknown` 이다. 인식되지 않은 예외의 메시지를 `safeToExpose` 가 통과시키면 그대로 클라이언트로 간다. IPv6 클러스터나 쿠버네티스 서비스 이름을 쓰는 배포에서 상류 좌표가 밖으로 나간다.
등급이 P3 인 이유는 두 가지다. 이 리프가 build-only 라 오늘 닿지 않고, 노출되는 것이 자격증명이 아니라 내부 좌표다. 다만 이 정책이 존재하는 이유 자체가 "부분 마스킹이 아니라 통째 교체" 이므로, 목록에 빠진 형태는 통째로 통과한다.
### 17.8 P3 — `clearAfterTask` 는 합법 값이 하나뿐인 성분이고, 아무도 읽지 않는다
```java
public record GrpcContextPropagationPolicy(
boolean failClosedWithoutContext, boolean clearAfterTask) {
public GrpcContextPropagationPolicy {
if (!clearAfterTask) { throw new IllegalArgumentException("context must be cleared after every task; …"); }
}
}
```
`false` 를 무조건 거부하므로 이 성분이 가질 수 있는 값은 `true` 하나다. 그리고 저장소 전체에서 `clearAfterTask()` 를 읽는 production 코드가 없다 — 호출처는 이 생성자의 가드와 테스트의 단언 한 줄뿐이다.
읽지 않아도 되는 이유는 `GrpcContextBinder` 가 옳게 쓰였기 때문이다. `runWith`·`callWith`·`wrap` 이 전부 `finally` 에서 detach 한다. 불변식이 이미 구조로 지켜진다.
그래서 이 성분은 설정처럼 보이지만 설정이 아니다. 읽는 사람은 정책으로 끌 수 있는 것이라고 읽고, 테스트는 그 가드를 시험한다.
수정은 성분을 지우고 javadoc 에 "always cleared" 를 남기는 것이다. 그러면 `backgroundWork()`·`stable()` 이 인자 하나가 되고, 불변식은 검증이 아니라 구조가 된다.
### 확인된 설계(문제 아님)
- **클라이언트가 메시지 문자열을 읽지 않는다는 규칙과, 인식되지 않은 예외의 불투명 처리.**
- **실행 식별자를 파생이 아니라 생성으로 만든 것** — 키나 행 식별자를 우연히 담을 수 없다.
- **적재물 경계의 근거를 자원이 아니라 책임 중복으로 든 것.**
- **도달할 수 없는 임계값 설정을 생성자가 거부한 것.**
- **재개 토큰의 아홉 성분 각각에 이유를 붙인 것.**
- **토큰 문자열 성분이 구분자를 담지 못하게 한 것** — 같은 저장소의 web 지문이 이 프레이밍을 하지 않는 것과 대비된다.
- **토큰 검증의 상수 시간 비교·키 식별자 거부·실패 구분 불가.**
- **재시도 예산의 비교 후 교체 루프.**
- **자격증명 회전의 준비-교체-배수 순서와 같은 세대 재적용의 무동작 처리.**
- **BOM 을 모듈 범위로 가져와 잠금 파급을 지역화한 것.**
---
## Source anchors
```
src/grpc/grpc-policy/build.gradle:1-23
main/java/…/context/{GrpcContextBinder:1-122, GrpcContextPropagationPolicy:1-37, GrpcContextSnapshot:1-70}
main/java/…/deadline/{GrpcCancellableOperation:1-23, GrpcCancellationCoordinator:1-122, GrpcCancellationReason:1-48,
GrpcDeadlineCalculator:1-68, GrpcDeadlinePolicyValidator:1-93, GrpcDependencyBudget:1-48}
main/java/…/error/{GrpcErrorExposurePolicy:1-82, GrpcErrorMapper:1-179, GrpcRichErrorDetail:1-93, GrpcStatusMapping:1-53}
main/java/…/idempotency/{GrpcCompletionReconciler:1-93, GrpcCompletionResolution:1-52, GrpcIdempotencyDecision:1-95,
GrpcIdempotencyInterceptor:1-148, GrpcOperationStatus:1-32, GrpcOperationStatusQuery:1-90,
GrpcOutcomeReplay:1-62, GrpcRequestFingerprint:1-55}
main/java/…/policy/{GrpcCompressionProfile:1-50, GrpcMessageSizeProfile:1-57, GrpcPayloadBoundaryPolicy:1-161,
GrpcSizeViolation:1-46}
main/java/…/resilience/{GrpcMethodRetryConfig:1-99, GrpcRetryBudget:1-76, GrpcRetryCoordinator:1-126,
GrpcRetryDecision:1-64, GrpcRetryEligibility:1-103, GrpcRetryOwner:1-39,
GrpcRetryOwnershipValidator:1-79, GrpcServiceConfigPolicy:1-73,
GrpcWaitForReadyDecision:1-40, GrpcWaitForReadyProfile:1-54, GrpcWaitForReadyValidator:1-56}
main/java/…/security/{GrpcAuthenticationProfile:1-59, GrpcCredentialGeneration:1-47,
GrpcCredentialRotationManager:1-122, GrpcTlsProfile:1-101}
main/java/…/streaming/{GrpcFlowControlDecision:1-43, GrpcFlowControlPolicy:1-88, GrpcResumeDecision:1-57,
GrpcResumeToken:1-91, GrpcResumeTokenCodec:1-142, GrpcSerializedStreamWriter:1-176,
GrpcSlowConsumerPolicy:1-26, GrpcStreamAdmission:1-76, GrpcStreamEnvelope:1-128,
GrpcStreamGapDetector:1-104, GrpcStreamHeartbeat:1-62, GrpcStreamId:1-41,
GrpcStreamLifecycleCoordinator:1-82, GrpcStreamLifetimePolicy:1-71, GrpcStreamProfile:1-47,
GrpcStreamSequence:1-48, GrpcStreamTerminationReason:1-51, GrpcStreamWriteResult:1-25,
GrpcStreamWriterState:1-22}
main/java/…/validation/{GrpcTransportValidator:1-134, GrpcValidationViolation:1-35, ProtovalidateGrpcInterceptor:1-115}
test/java/…/{context:1, deadline:2, error:1, idempotency:3, policy:1, resilience:3, security:1, streaming:5, validation:1} — 18파일 2,800줄
grpc-client/…/GrpcChannelRuntime.java:20 (§12.3 대조)
grpc-admin/…/GrpcServiceHealthRegistry.java:25 (§12.3 대조)
```
@@ -0,0 +1,310 @@
# grpc-proto-contract 완전 해부
> 상태: COMPLETE
> 재오픈 게이트: cycle 2 — `src/main` production 3파일 605줄, 스키마/설정 리소스 5개, test 1파일 324줄 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음.
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/grpc/grpc-proto-contract`
> SSOT owner: `grpc-proto-contract`
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지와 숫자 지도
- `allowed_dependencies`: `["grpc-core-api"]`
- `runtime_memberships`: **`[]`**
| 파일 | LOC | 성격 |
|---|---:|---|
| `GrpcProtoContractValidator` | 447 | 라인 스캐너 + 9개 규칙 판정 |
| `GrpcProtoStyleManifest` | 120 | 규칙을 데이터로 둔 record |
| `GrpcProtoRuleViolation` | 38 | 위반 1건 record |
| **main java 합계** | **605** | |
| `error.proto` | 66 | 리치 오류 상세 5 메시지 + 열거형 1 |
| `stream.proto` | 57 | 스트림 공통 스키마 |
| `buf.yaml` · `buf.gen.yaml` · `buf.lock` | — | 생성 설정(이 리프는 protoc 을 돌리지 않는다) |
| `GrpcProtoContractValidatorTest` | 324 | 테스트 |
| `build.gradle` | 12 | 의존 project 1 |
### Coverage ledger
| scope | count | disposition | reason |
|---|---:|---|---|
| `main/java/**` | 3 | `FULL_READ` | 447+120+38 전 본문 축자 확인 |
| `main/resources/proto/**/*.proto` | 2 | `FULL_READ` | 66+57 전문 |
| `main/resources/proto/buf.*` | 3 | `FULL_READ` | 테스트가 단언하는 키 전수 확인 |
| `test/java/**` | 1 | `FULL_READ` | 324줄 · 테스트 13개 |
| `build.gradle` | 1 | `FULL_READ` | 12줄 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일; 선언 의존은 project 1 뿐임을 build.gradle 에서 확인 |
`UNCLASSIFIED` 0.
---
## 1. 모듈의 정체와 경계
```groovy
// build.gradle:3-9
// Schema source of truth: the `.proto` files plus the rule engine that judges them.
// No protobuf plugin and no protoc invocation here — … running protoc is a separate, gated decision.
```
이 리프는 스키마 원본과 그것을 판정하는 규칙 엔진을 함께 담는다. protoc 은 돌지 않는다.
검증기 javadoc 이 그 한계를 스스로 규정한다.
> "A line scanner, not a Protobuf parser, and that is a deliberate limit rather than a shortcut.
> Everything this checks is a property of the source text a reviewer reads… Semantics that need a
> compiled descriptor belong to `grpc-codegen`'s descriptor artifact."
## 2. 규칙 9개
| 상수 | 판정 |
|---|---|
| `PROTO3_SYNTAX` | `syntax = "proto3"` 필수 |
| `PACKAGE_VERSIONED` | `<org>.<domain>.v<major>` — 접두 일치 + `.*\.v[1-9]\d*$` |
| `JAVA_MULTIPLE_FILES` | `option java_multiple_files = true` 필수 |
| `JAVA_PACKAGE_SEPARATE` | `java_package` 가 손으로 쓴 패키지 안이면 위반 |
| `ENUM_ZERO_UNSPECIFIED` | 0 값 이름이 `_UNSPECIFIED` 로 끝나야 함 |
| `RESERVED_HISTORY` | 삭제 이력의 번호·이름이 `reserved` 에 있어야 함 |
| `WELL_KNOWN_TYPE_ALLOWLIST` | `google/protobuf/` import 는 allowlist 에만 |
| `MAP_ALLOWLIST` | `map` 필드는 `message.field` 단위 허용 |
| `EXPLICIT_PRESENCE` | 매니페스트가 지정한 필드는 `optional` 선언 필수 |
## 3. 세 가지 설계 판단
### 3.1 금지가 아니라 allowlist
`GrpcProtoStyleManifest` javadoc:
> "Three of the five fields are allowlists, and that shape is the decision: `Any`, `Struct` and
> `map` are not banned, they are things you have to ask for by name. A ban gets worked around; an
> allowlist entry gets read by the next person to open the manifest and carries the field it was
> granted for."
`caSkeleton()` 의 기본값은 조직 `hyeonworks`, 손으로 쓴 패키지 `dev.caskeleton`, WKT allowlist 5개(timestamp·duration·field_mask·empty·wrappers), map allowlist 빈 집합, presence 요구 빈 맵이다.
`allowingWellKnownTypes` · `allowingMapFields` · `requiringPresence` 세 메서드가 매니페스트를 넓힌 사본을 만든다. 정규 생성자가 모든 컬렉션을 복사하므로 리뷰를 통과한 매니페스트가 나중에 넓어지지 않는다.
### 3.2 던지지 않고 목록으로 돌려준다
`GrpcProtoRuleViolation` javadoc:
> "Returned rather than thrown, and carrying a line number, because a schema review is a list. A
> validator that throws on the first violation turns 'this file breaks four rules' into four
> separate runs, and the author fixes them one at a time without ever seeing the shape of the
> problem."
### 3.3 삭제 이력은 추론하지 않고 입력으로 받는다
> "a field that is simply gone from the current source is indistinguishable from one that never
> existed. Recording removals and checking them against `reserved` is the only way the 'do not reuse
> a field number' rule survives the commit that deletes the field."
`SchemaHistory(removedFieldNumbers, removedFieldNames)` 가 그 입력이고, 키는 파일에 적힌 메시지 이름이며 중첩은 점으로 한정한다.
## 4. 스캔 절차
한 줄씩 읽으면서 `//` 이후를 지우고, 빈 줄을 건너뛰고, 순서대로 시도한다 — syntax → package → import → option(파일 수준만) → 스코프 열기 → 닫기 → 스코프 안 멤버.
스코프는 `message`·`enum`·`service`·`oneof` 넷을 열고 이름을 점으로 한정해 스택에 쌓는다. 열거형 안에서는 0 값 이름을, 메시지와 `oneof` 안에서는 `reserved`·`map`·필드를 본다.
## 10. 테스트 레인
`GrpcProtoContractValidatorTest` 324줄. 첫 두 테스트가 이 리프의 게이트다.
- `committedSchemaIsCompliant` — 커밋된 두 스키마를 실제로 검증기에 넣어 위반 0 을 단언한다.
- `theBufConfigurationAgreesWithTheValidator``buf.yaml``FILE`·`STANDARD` 범주, 생성 경로가 `build/generated/...` 이고 `out: src/` 가 아님, 버전 리터럴 부재, `deps: []` 를 단언한다.
나머지는 규칙별 거부 사례다 — proto2 거부, 패키지 형식, 자바 패키지 충돌, `java_multiple_files` 부재, 열거형 0 값, 삭제 이력, WKT allowlist, map allowlist, explicit presence, 중첩 한정, `describe()` 렌더링. 전부 13개.
거부 사례 셋은 **넓힌 매니페스트로 같은 소스를 다시 돌려** 통과까지 확인한다 — `allowingWellKnownTypes` · `allowingMapFields` · `requiringPresence`. allowlist 라는 설계가 실제로 넓혀지는지까지 붙드는 형태다.
`enumZeroValueNeedsTheUnspecifiedSuffix` 는 줄 번호 7까지 단언한다 — 위반이 줄을 정확히 가리키는지가 이 리프의 산출물 형태(`file:line rule — detail`)에 직결되기 때문이다.
## 12. negative-space probes
**12.1 도달성.** 이 리프의 production 소비자는 0 이다. `GrpcProtoContractValidator`·`GrpcProtoStyleManifest`·`GrpcProtoRuleViolation` 을 부르는 코드는 자기 테스트뿐이다.
리프 밖 참조는 두 종류다.
| 참조 | 형태 |
|---|---|
| `grpc-codegen/…/GrpcBufPolicy.java:10` | javadoc 언급 |
| `grpc-testkit` · `grpc-codegen` · `grpc-spring-boot-starter` · `grpc-advanced-edition` 의 build.gradle | project 의존 선언 |
네 모듈이 의존을 선언하지만 그중 어느 자바 파일도 이 리프의 타입을 import 하지 않는다. §17.4 가 그 결과를 다룬다.
**12.3 아홉 규칙 중 둘은 이 저장소의 매니페스트에서 사실상 비활성이다.**
```java
public static GrpcProtoStyleManifest caSkeleton() {
return new GrpcProtoStyleManifest("hyeonworks", Set.of("dev.caskeleton"),
ALWAYS_ALLOWED_WELL_KNOWN_TYPES, Set.of(), Map.of());
// ^^^^^^^^ ^^^^^^^
// mapFieldAllowlist presenceRequiredFields
}
```
- `MAP_ALLOWLIST` — 허용 목록이 비었으므로 실제 판정은 "map 전면 금지"다. §3.1 이 설명하는 "금지가 아니라 이름으로 요청" 이라는 형태는 매니페스트를 넓히는 호출자가 있어야 성립하는데, `allowingMapFields` 를 부르는 곳은 테스트뿐이다.
- `EXPLICIT_PRESENCE` — 요구 맵이 비었으므로 어떤 필드도 `optional` 을 강제받지 않는다. 커밋된 두 스키마가 `optional` 을 다섯 곳에 쓰지만(예: `FieldViolation.description`, `StreamEnvelope.resume_token`) 그것을 요구하는 규칙은 없다. 규율이 코드가 아니라 저자의 손에 있다.
나머지 일곱은 기본 매니페스트에서도 실제로 판정한다.
**12.2 대조군.** 저장소에 `.proto` 파일이 넷 있다.
| 파일 | 이 검증기가 판정하는가 |
|---|---|
| `grpc-proto-contract/.../common/v1/error.proto` | 예 (테스트 목록) |
| `grpc-proto-contract/.../common/v1/stream.proto` | 예 (테스트 목록) |
| `messaging-schema-protobuf/src/test/proto/order_created_v1.proto` | 아니오 |
| `grpc-advanced-edition/.../edition2024/compatibility.proto` | 아니오 |
**12.4 드리프트.** build.gradle 주석이 규칙으로 든 다섯(proto3 + explicit optional, 패키지 버전, reserved 이력, 열거형 0 접미, WKT allowlist)이 전부 상수로 존재한다. 드리프트 없음.
## 16. 확인하지 못한 것
- `reserved` 범위 문법의 오탐(§17.1)을 실행으로 재현하지 않았다. 정규식과 수집 코드로 판정했다.
- 블록 주석(`/* */`) 안의 선언이 스캔되는지 실행으로 확인하지 않았다. `LINE_COMMENT``//` 만 지우므로 그 형태가 남는다.
- 테스트를 실행하지 않았다. 13개 전부 본문으로만 확인했다.
- §17.4 의 "부르는 빌드가 없다"는 `*.gradle` · `*.kts` · `*.yml` 세 확장자와 자바 타입 이름 grep 으로 판정했다. 리플렉션이나 서비스 로더로 부르는 형태라면 잡히지 않는다.
- 열거형 `reserved` 오탐(§17.5)을 실행으로 재현하지 않았다. 스코프 분기 코드로 판정했다.
## 17. 손볼 것
### 17.1 P3 — `reserved 2 to 5;` 범위가 개별 숫자로만 수집되어 `RESERVED_HISTORY` 오탐이 된다
```java
RESERVED_NUMBERS = Pattern.compile("^\\s*reserved\\s+([^\";]*\\d[^\";]*);");
NUMBER = Pattern.compile("\\d+");
Matcher number = NUMBER.matcher(reservedNumbers.group(1));
while (number.find()) { scan.reservedNumbersadd(Integer.valueOf(number.group())); }
```
`reserved 2 to 5;` 는 그룹이 `"2 to 5"` 이고 수집되는 것은 `{2, 5}` 다. `3`·`4` 는 들어가지 않는다. `reserved 9 to max;``{9}` 만 남는다.
그러면 삭제 이력이 `3` 을 담고 스키마가 `reserved 2 to 5;` 로 정확히 예약했는데도 `RESERVED_HISTORY` 위반이 보고된다. 범위 예약은 표준 문법이고 여러 필드를 한 번에 지울 때 쓰는 형태이므로 도달 가능하다.
수정은 `to` 를 인식해 범위를 펼치는 것이다. `max` 는 상한 상수로 다루거나 그 메시지에 대해 검사를 통과시킨다.
### 17.2 P3 — 반환 목록이 자바독이 약속한 source order 가 아니다
```java
Scan scan = scan(fileName, source, violations); // import·enum·map·presence 위반이 여기서 append
checkFileHeader(fileName, scan, violations); // syntax·package·java_* 위반이 그 뒤에 append
checkRemovalHistory(fileName, scan, history, violations);
```
`validate` 의 javadoc 은 "@return every violation found, **in source order**" 라고 적는다. 실제로는 파일 앞머리의 `syntax`·`package` 위반이 40번째 줄의 `map` 위반보다 뒤에 온다.
`describe()``file:line rule — detail` 형태를 만들고 그 형태의 목적이 빌드 로그를 읽는 것이므로, 정렬이 어긋나면 리뷰 목록으로서의 값이 줄어든다. 수정은 반환 직전에 `line` 으로 안정 정렬하는 것이다.
### 17.3 P3 — 커밋 스키마 게이트가 파일 목록을 하드코딩한다
```java
List<String> files = List.of(
"proto/hyeonworks/grpc/common/v1/error.proto",
"proto/hyeonworks/grpc/common/v1/stream.proto");
```
리소스 디렉터리를 훑지 않는다. 이 리프에 세 번째 `.proto` 를 추가하면 이 테스트를 함께 고치기 전까지 판정되지 않고, 빌드는 초록으로 남는다.
같은 저장소가 다른 곳에서 이 형태를 이미 경계했다 — 빠뜨림이 통과가 되는 게이트다. 수정은 `proto/**` 아래 `.proto` 를 전부 열거해 돌리는 것이다.
### 기록 — `oneof` 도 스코프 이름을 밀어 넣는다 (현재 무해)
`SCOPE_OPEN``oneof` 를 스코프로 열고 이름을 점으로 한정한다. 그러면 `message Foo { oneof kind { … } }` 안의 필드는 `Foo.kind.<field>` 로 한정되고, `SchemaHistory` javadoc 이 말하는 키 규약(메시지 이름)과 어긋난다.
지금은 도달하지 않는다. protobuf 가 `oneof` 안에서 `map``optional` 을 모두 금지하므로 `MAP_ALLOWLIST`·`EXPLICIT_PRESENCE` 판정이 그 자리에서 발생하지 않고, `reserved``oneof` 안에 올 수 없다. 규칙을 넓힐 때 다시 볼 자리로 남긴다.
### 17.4 P2 — 두 파일이 이 검증기를 "빌드를 실패시키는 것" 이라고 단언하는데, 어떤 빌드도 그것을 부르지 않는다
같은 주장이 두 곳에 있다.
```java
// grpc-codegen/…/GrpcBufPolicy.java:8-10
* Buf's CLI is not part of this toolchain (adaptation D5), so the four lifecycle task names
* below are the contract a CI environment fulfils and {@code GrpcProtoContractValidator} is what
* actually fails a build here.
```
```yaml
# grpc-proto-contract/…/proto/buf.yaml:3-5
# The rules named here are also implemented in GrpcProtoContractValidator, which is what actually
# fails this repository's build: the Buf CLI is not part of this toolchain, and a gate that silently
# passes when a binary is missing is worse than one that computes the same judgement from the
# committed schema.
```
두 문장이 같은 논증을 편다 — CLI 가 없으므로 이 자바 검증기가 그 자리를 대신한다는 것. 그런데 그 검증기를 부르는 빌드 코드가 없다.
```
$ grep -rn "GrpcProtoContractValidator" --include=*.gradle --include=*.kts --include=*.yml .
(매치 없음)
$ grep -rn "GrpcProtoContractValidator" --include=*.java src/ | grep -v grpc-proto-contract/
grpc-codegen/…/GrpcBufPolicy.java:10: * … {@code GrpcProtoContractValidator} is what
```
Gradle 태스크도, 검증 훅도, 다른 모듈의 호출도 없다. 실제로 이 규칙 아홉 개를 실행하는 것은 `GrpcProtoContractValidatorTest` 하나이고, 그 테스트가 판정하는 대상은 §17.3 이 지적한 대로 **하드코딩된 두 파일**이다.
**그래서 지금 성립하는 것과 성립하지 않는 것.**
- 성립: 이 리프에 커밋된 `error.proto` · `stream.proto` 는 매 빌드마다 아홉 규칙에 걸린다(테스트가 그것을 돌린다).
- 성립하지 않음: "이 저장소의 빌드를 실패시킨다"는 범위. 리프 밖의 `.proto` 는 판정되지 않고(§12.2), 이 리프에 새로 추가되는 `.proto` 도 테스트 목록에 손으로 넣기 전까지 판정되지 않는다.
**왜 P2 인가.** 오작동이 아니라 **주장과 배선의 불일치**다. 그리고 그 주장이 CLI 부재를 정당화하는 논거로 쓰이고 있다 — "바이너리가 없을 때 조용히 통과하는 게이트보다 낫다"고 말하면서, 실제로 만든 것도 조용히 통과하는 게이트다. `grpc-codegen` §17.1 이 같은 형태를 반대편에서 기록했다(Buf 태스크 이름 넷이 어떤 빌드 파일에도 없다). 두 리프가 서로를 가리키며 상대가 게이트라고 말하는 모양이다.
**수정.** 두 가지 중 하나다.
1. 배선한다 — `check` 에 물리는 Gradle 태스크가 `proto/**` 를 훑어 `validate` 를 돌리고 위반이 있으면 실패한다. §17.3 의 하드코딩도 함께 해소된다.
2. 문장을 사실에 맞춘다 — "빌드를 실패시킨다"를 "이 리프의 테스트가 커밋된 스키마에 대해 실행한다"로 낮춘다. buf.yaml 과 GrpcBufPolicy 두 곳을 함께 고쳐야 한다.
낮추는 쪽을 고르더라도 §12.3 이 남는다 — 규칙 아홉 중 둘은 기본 매니페스트에서 판정할 것이 없다.
### 17.5 P3 — 열거형 안의 `reserved` 는 수집되지 않는다
`scan` 은 스코프 종류로 갈라진다.
```java
if ("enum".equals(scopeKind)) {
scanEnumValue(fileName, line, lineNumber, scopeName, violations);
} else if ("message".equals(scopeKind) || "oneof".equals(scopeKind)) {
scanMessageMember(fileName, line, lineNumber, scopeName, scan, violations);
}
```
`reserved` 수집은 `scanMessageMember` 안에만 있다. proto3 는 열거형에도 `reserved 2, 15;``reserved "OLD_VALUE";` 를 허용하고, 열거형 값을 지울 때 번호를 예약하는 것은 필드와 같은 이유로 필요하다 — 예약하지 않고 재사용하면 옛 클라이언트가 보낸 정수가 다른 뜻으로 해석된다.
지금 `SchemaHistory` 에 열거형 이름으로 삭제 이력을 넣으면, 스키마가 정확히 예약했더라도 `scan.reservedNumbers` 에 그 이름이 없으므로 `RESERVED_HISTORY` 오탐이 난다. §17.1 의 범위 문법 문제와 같은 방향(fail-closed)이고 같은 자리에서 고칠 수 있다.
**수정.** `reserved` 수집을 스코프 종류와 무관하게 먼저 시도한 뒤 나머지 판정을 갈래로 보낸다.
### 확인된 설계(문제 아님)
- **라인 스캐너라는 한계를 스스로 규정하고 그 경계 밖을 다른 리프로 넘긴 것.**
- **금지 대신 allowlist 를 고르고 그 이유를 적은 것** — 금지는 우회되고 allowlist 항목은 다음 사람이 읽는다.
- **위반을 던지지 않고 목록으로 돌려주는 것** — 스키마 리뷰는 목록이다.
- **삭제 이력을 입력으로 받는 것** — 사라진 필드는 없던 필드와 구분되지 않으므로 추론할 수 없다.
- **커밋된 스키마 자체를 테스트가 검증기에 넣는 것** — 규칙이 자기 스키마에 실제로 적용된다.
- **buf 설정과 검증기가 같은 것을 말하는지 테스트가 붙드는 것.**
- **넓힌 매니페스트로 같은 소스를 다시 돌려 통과까지 확인하는 테스트 형태** — allowlist 라는 설계가 거부만이 아니라 허용도 실제로 하는지 붙든다.
- **`buf.lock` 을 빈 채로 커밋한 것과 그 근거** — "adding a first dependency is a visible diff in a file that already exists, instead of a new file nobody reviews."
- **`buf.gen.yaml` 에 판본 리터럴을 두지 않은 것** — 관리 플랫폼이 플러그인 판본을 소유한다는 `GrpcCodegenManifest` 의 규칙과 같은 결정이고, 테스트가 `version: v1` 부재로 그것을 붙든다.
- **리치 오류 상세를 `google.rpc.*` 대신 자기 메시지로 소유한 것과 그 근거** — "the Stable contract is that a client branches on a code, a reason and a typed detail — never on a message string", 그리고 모양을 `google.rpc` 에 맞춰 두어 나중의 이전이 재설계가 아니라 이름 바꾸기가 되게 한 것.
---
## Source anchors
```
src/grpc/grpc-proto-contract/build.gradle:1-12
main/java/…/contract/GrpcProtoContractValidator.java:1-447
main/java/…/contract/GrpcProtoStyleManifest.java:1-120
main/java/…/contract/GrpcProtoRuleViolation.java:1-38
main/resources/proto/hyeonworks/grpc/common/v1/error.proto:1-66
main/resources/proto/hyeonworks/grpc/common/v1/stream.proto:1-57
main/resources/proto/buf.yaml:1-17 · buf.gen.yaml:1-24 · buf.lock:1-12
grpc/grpc-codegen/…/GrpcBufPolicy.java:8-10 (게이트라고 주장하는 두 자리 중 하나)
test/java/…/contract/GrpcProtoContractValidatorTest.java:1-324
```
@@ -0,0 +1,277 @@
# grpc-server 완전 해부
> 상태: COMPLETE
> 재오픈 게이트: cycle 2 — `src/main` production 17파일 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음.
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/grpc/grpc-server`
> SSOT owner: `grpc-server`
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지
- `runtime_memberships`: **`[]`** — build-only
- vendor: `grpc-api`(BOM). Netty 의존 없음 — 프로파일은 설정 모델이지 배선이 아니다
| 파일 | LOC |
|---|---:|
| `GrpcServerInterceptorChain` | 110 |
| `GrpcRawApiImportRule` | 108 |
| `GrpcAdmissionController` | 103 |
| `GrpcServiceAdapter` · `GrpcApplicationBoundaryRules` | 92 · 92 |
| `GrpcServerInterceptorOrder` | 90 |
| `GrpcServerProfile` · `GrpcNettyParityContract` · `GrpcExecutorProfile` | 84 · 69 · 66 |
| `GrpcNettyVariantSelector` · `GrpcServerInterceptorStage` · `GrpcServiceAdapterDescriptor` | 52 · 51 · 48 |
| `GrpcServerTransport` · `GrpcNettyVariant` · `GrpcApplicationInvocation` · `GrpcServiceAdapterMarker` · `GrpcResponseMapper` | 38 · 32 · 28 · 25 · 18 |
| test 5파일 | 730 |
### Coverage ledger
| scope | count | disposition | reason |
|---|---:|---|---|
| `main/java/**` | 17 | `FULL_READ` | 1,106줄 전 본문 |
| `test/java/**` | 5 | `FULL_READ` | 730줄 |
| `build.gradle` | 1 | `FULL_READ` | 전문 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
`UNCLASSIFIED` 0.
---
## 1. 모듈의 정체
```groovy
// build.gradle:3-6
// Server boundary: the ArchUnit-shaped application boundary rules, the typed service adapter SPI,
// the interceptor order contract, and the Netty server/executor/admission profiles.
//
// The Netty profiles are configuration models, not Netty wiring — no netty dependency here. Real
// Netty lives in `grpc-testkit`'s certification lane, which is where transport evidence is produced.
```
## 2. 인터셉터 순서 계약
열 단계이고 선언 순서가 계약이다. 각 위치의 이유가 열거형 javadoc 에 있다.
```
EXCEPTION_BOUNDARY → TRACE → AUTHENTICATION → ACTOR_TENANT → AUTHORIZATION
→ ADMISSION → DEADLINE_CANCELLATION → IDEMPOTENCY → VALIDATION → SERVICE_ADAPTER
```
- 예외 경계가 가장 바깥 — 이후 단계의 실패가 매핑되지 않은 상태로 새지 않는다
- 인증 → 행위자·소속 → 인가 — 각 단계가 앞 단계의 답을 필요로 한다
- 승인이 마감보다 먼저 — 부하 중 서버가 일을 쓰기 전에 흘려보낸다
- 멱등이 검증보다 먼저 — 재생된 요청이 이미 받아들인 본문을 다시 검증하지 않고 저장된 결과를 돌려준다
- 검증이 어댑터 직전 — 사용 사례는 믿을 수 있는 메시지를 받는다
필수가 아닌 단계는 멱등 하나다 — 상태 변경 키 메서드가 없는 서버에는 할 일이 없기 때문이다.
## 3. 뒤집기가 이 클래스의 존재 이유다
> "`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."
`inStableOrder()``inGrpcRegistrationOrder()` 를 나누고, 후자가 전자의 역순임을 테스트가 붙든다.
## 4. 순서 검증의 근거
> "A chain with validation before authentication lets an anonymous caller probe the schema through
> error messages; one with the exception boundary in the middle lets a throwable from an earlier
> stage escape as `UNKNOWN`. Neither shows up in a test of the happy path."
네 규칙이다 — 중복 단계, 필수 단계 누락, 역순, 예외 경계가 최외곽이 아님.
## 5. 원시 API 차단 규칙
금지 타입 열네 개가 채널·서버·호출을 손으로 만드는 구성 API 다. 금지가 아니라 허용 패키지 목록을 받는다.
> "They are legitimate inside the platform and inside generated code, which is why this rule takes
> an allowlist of packages rather than banning them outright."
그리고 허용 목록이 비면 생성자가 거부한다 — 플랫폼 자신은 어딘가에서 채널을 만들어야 한다.
## 6. 응용 경계 규칙
금지 접두 열세 개와 금지 타입 셋. 접두만으로 너무 넓은 경우를 위해 정확한 타입 목록을 따로 둔다.
> "a transport adapter that calls a repository has moved the use case into the transport, and the
> next caller of that use case — a scheduled job, a message consumer — either duplicates it or
> reaches through the controller."
## 10. 테스트 레인
다섯 테스트. 순서 계약(등록 역순 포함), 프로파일 거부(무제한 큐·in-process production·킵얼라이브·연결 수명), 승인 경계, 어댑터의 매핑, 경계 규칙과 원시 API 규칙을 확인한다.
## 12. negative-space probes
**12.1 도달성.** 이 리프의 다섯 타입은 저장소 어디에서도(자기 리프 밖) 참조되지 않는다.
```
GrpcApplicationBoundaryRules · GrpcRawApiImportRule · GrpcServiceAdapterMarker
GrpcServerInterceptorChain · GrpcNettyParityContract → leaf 밖 참조 0
```
`GrpcAdmissionController`·`GrpcExecutorProfile`·`GrpcServerProfile``grpc-spring-boot-starter` 가 빈으로 만들고, `GrpcAdmissionController``grpc-admin` 의 배수 조정자가 협력자로 받는다.
다만 **만들어지는 것과 불리는 것은 다르다.** 재통독에서 다시 세었다.
```
tryAdmit() production 호출 0 (테스트 3곳)
release() production 호출 0
promoteFromQueue() production 호출 0
```
즉 승인 제어기는 빈으로 존재하고 아무 호출도 승인받지 않는다. 그것을 부를 자리인 `ADMISSION` 인터셉터 단계의 구현이 이 가족에 없기 때문이다(§12.1 의 `GrpcServerInterceptorChain` 미참조와 같은 원인). §17.4 가 그 첫 호출자가 만나게 될 것을 다룬다.
**12.4 드리프트.** build.gradle 이 서술한 네 요소가 전부 존재하고, Netty 의존이 없다는 서술도 맞다.
## 16. 확인하지 못한 것
- 실제 서버를 세워 인터셉터 사슬을 돌리지 않았다. 이 리프에 서버를 만드는 코드가 없다.
- `GrpcNettyParityContract` 가 서술하는 두 변형의 동등성을 실행으로 확인하지 않았다. 그 클래스의 `unproven(...)` 을 부르는 코드도 저장소에 없다 — grpc-testkit §17.5 와 같은 형태의 평가기다.
- §17.4 의 경합을 실행으로 재현하지 않았다. 읽기와 증가가 분리되어 있다는 것과 하한 가드가 없다는 것으로 판정했다.
- `gradle.lockfile` 은 읽지 않았다(`STRUCTURAL_ONLY`).
## 17. 손볼 것
### 17.1 P2 — 두 아키텍처 규칙이 저장소 소스에 적용되지 않는다
`GrpcApplicationBoundaryRules` javadoc:
> "The list is package prefixes rather than a prose rule, **so it can be applied by an architecture
> test, by a source scan and by a review checklist** without three people deciding what 'must not use
> a repository' covers."
세 적용처 중 저장소에 존재하는 것이 없다.
```
GrpcApplicationBoundaryRules leaf 밖 참조 0
GrpcRawApiImportRule leaf 밖 참조 0
GrpcServiceAdapterMarker leaf 밖 참조 0 (규칙이 어댑터를 열거하려고 만든 마커)
```
그리고 이 리프의 테스트는 저장소 파일을 훑지 않는다. 인라인 소스 문자열을 넣는다.
```java
assertThat(rule.violations("DocumentClient.java", applicationSource)).isNotEmpty();
assertThat(rule.violations("ChannelFactory.java", platformSource)).isEmpty();
```
즉 규칙의 판정 로직은 검증되지만, 저장소의 어떤 파일도 그 판정을 받지 않는다. `GrpcServiceAdapterMarker` 는 규칙이 어댑터를 런타임에 열거할 수 있도록 만든 애너테이션인데, 그것을 붙인 타입도 그것을 읽는 코드도 없다.
**수정.** 이 리프의 테스트에 저장소 소스를 훑는 검사를 추가한다 — `src/**/*.java` 를 읽어 `GrpcRawApiImportRule.violations` 를 돌리고 비어 있음을 단언하는 형태다. 규칙이 이미 파일 이름과 소스 텍스트를 받는 서명이므로 재료는 갖춰져 있다.
### 17.2 P3 — 원시 API 규칙이 import 문만 보므로 완전 수식 사용과 와일드카드를 놓친다
```java
private static final Pattern IMPORT = Pattern.compile("^\\s*import\\s+(?:static\\s+)?([\\w.]+)\\s*;", MULTILINE);
if (RAW_API_TYPES.contains(imported)) { violations.add(); }
```
두 형태가 빠진다.
```java
io.grpc.ManagedChannelBuilder.forAddress("h", 1).build(); // import 없이 완전 수식
import io.grpc.*; // 정확 일치 실패
```
이것이 가정에 그치지 않는 이유는 이 저장소 자신의 문체다. 같은 가족의 여러 파일이 완전 수식 참조를 본문에 그대로 쓴다.
```
GrpcConsumerFixture java.util.regex.Pattern.compile(...)
GrpcAdvancedSupportMatrix java.util.stream.Collectors.toUnmodifiableMap(...)
GrpcProtoStyleManifest java.util.Set / java.util.LinkedHashSet 인라인
```
즉 이 코드베이스에서 완전 수식 사용은 예외가 아니라 흔한 형태다.
규칙 클래스의 자바독은 "there is nothing to reach for" 를 목표로 든다. 지금 형태는 손이 닿는 경로 하나만 본다.
수정은 정규식을 타입 이름의 등장 자체로 넓히거나(오탐이 생기므로 주석·문자열 제거가 필요), 바이트코드 기반 검사로 옮기는 것이다. 후자가 이 저장소의 다른 아키텍처 게이트와 형태가 같다.
### 17.3 P3 — 빌더 경로에서 순서 규칙 넷 중 셋이 발화할 수 없다
```java
public GrpcServerInterceptorChain build() {
GrpcServerInterceptorOrder.requireStableOrder(List.copyOf(byStage.keySet()));
}
```
`byStage``EnumMap` 이므로 `keySet()` 은 언제나 열거형 선언 순서다. 그리고 `stage(...)` 가 같은 단계의 두 번째 등록을 이미 거부한다.
따라서 빌더가 만드는 목록에서는 중복도, 역순도, 예외 경계가 최외곽이 아닌 경우도 발생할 수 없다. 발화 가능한 규칙은 필수 단계 누락 하나다.
결함은 아니다 — 나머지 셋은 `violations(List)` 를 직접 부르는 외부 호출자를 위한 것이고, 테스트가 그 경로로 셋을 모두 확인한다. 기록하는 이유는 빌더를 쓰는 조립 코드가 그 셋의 보호를 받는다고 읽기 쉽기 때문이다. 실제 보호는 자료구조가 준다.
### 17.4 P2 — 승인 제어기의 세 메서드가 원자적이지 않고, 큐 계수기를 되돌리는 경로가 없다
이 리프가 SSOT 이므로 여기에 적는다. `grpc-policy` §17.1 이 이 클래스를 대조군으로 지목하는데, 지목된 쪽 문서에 판정이 없었다.
**첫째, 읽고 나서 따로 증가시킨다.**
```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(); // ← 같은 형태
```
경계에 있는 N 개 스레드가 모두 통과한다. `AtomicInteger` 를 쓰면서 비교와 증가를 나눈 형태이고, 같은 가족의 정본이 `GrpcRetryBudget.tryConsume` 의 비교 후 교체 루프다.
`release()`·`promoteFromQueue()` 도 같다 — `get() > 0` 을 확인한 뒤 별도로 감소시키므로, 두 스레드가 같은 마지막 하나를 보고 둘 다 감소시켜 음수가 될 수 있다. 클래스가 `Math.max(0, …)` 같은 하한도 두지 않는다.
**둘째, 큐 계수기를 되돌리는 경로가 없다.**
큐에 들어간 호출도 `admitted=true` 를 받는다. 그런데 그 경로는 `queued` 만 올리고 `inFlight` 는 올리지 않는다. 그리고 끝난 호출을 반납하는 메서드는 하나뿐이다.
```java
public void release() {
if (inFlight.get() > 0) { inFlight.decrementAndGet(); } // ← queued 는 건드리지 않는다
}
```
따라서 호출자가 `promoteFromQueue()` 를 정확히 한 번 끼워 넣지 않으면 계수기가 어긋난다 — 큐에서 실행된 호출이 끝나면 `queued` 는 그대로이고 `inFlight` 만 줄어든다. `releaseQueued()` 같은 메서드도, 그 짝짓기를 요구하는 서술도 없다.
**시험이 이것을 볼 수 없는 이유.** 두 시험 모두 단일 스레드이고, `releaseAndPromotionTrackCapacity``release()``promoteFromQueue()`**짝지어** 부른다. 짝짓지 않는 경로는 시험되지 않는다.
**등급.** 오늘 호출자가 없으므로(§12.1) P2. 승인 단계를 배선하는 순간 P1 이다 — 부하 아래에서 경계가 새는 것과, 큐 계수기가 단조 증가해 `at capacity` 가 영구히 참이 되는 것이 함께 온다.
**수정.** 세 메서드를 비교 후 교체 루프로 바꾸고, 큐 경로에 대응하는 반납 메서드를 두거나 `promoteFromQueue``release` 안으로 접는다.
### 확인된 설계(문제 아님)
- **열 단계의 순서와 각 위치의 이유를 열거형 javadoc 에 적은 것.**
- **등록 순서 뒤집기를 클래스로 분리하고 그 이유를 적은 것.**
- **멱등만 선택 단계로 둔 것.**
- **순서 위반의 증상이 정상 경로 테스트에 나타나지 않는다는 근거.**
- **원시 API 를 금지가 아니라 허용 패키지 목록으로 다룬 것.**
- **허용 목록이 빈 규칙을 거부한 것.**
- **접두 목록으로 너무 넓은 경우를 위해 정확한 타입 목록을 따로 둔 것.**
- **Netty 의존 없이 프로파일만 두고, 실제 전송 증거를 테스트킷 인증 레인으로 넘긴 것.**
---
## Source anchors
```
src/grpc/grpc-server/build.gradle:1-14
main/java/…/server/GrpcServerInterceptorChain.java:1-110
main/java/…/server/GrpcServerInterceptorOrder.java:1-90
main/java/…/server/GrpcServerInterceptorStage.java:1-51
main/java/…/architecture/GrpcRawApiImportRule.java:1-108
main/java/…/architecture/GrpcApplicationBoundaryRules.java:1-92
main/java/…/architecture/GrpcServiceAdapterMarker.java:1-25
main/java/…/server/(GrpcAdmissionController · GrpcServiceAdapter · GrpcServerProfile · GrpcExecutorProfile · GrpcNettyParityContract · GrpcNettyVariantSelector · GrpcServiceAdapterDescriptor · GrpcServerTransport · GrpcNettyVariant · GrpcApplicationInvocation · GrpcResponseMapper)
test/java/…/(architecture/GrpcApplicationBoundaryRulesTest · server/GrpcServerInterceptorOrderTest · server/GrpcServerProfileTest · server/GrpcServiceAdapterTest · server/GrpcNettyVariantSelectorTest)
```
@@ -0,0 +1,295 @@
# grpc-spring-boot-starter 완전 해부
> 상태: COMPLETE
> 재오픈 게이트: cycle 2 — `src/main` production 4파일 468줄, 등록 파일 1개, test 1파일 267줄 축자 통독 완료. `STRUCTURAL_ONLY` 잔여 없음.
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/grpc/grpc-spring-boot-starter`
> SSOT owner: `grpc-spring-boot-starter`
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지와 숫자 지도
- `runtime_memberships`: **`[]`** — build-only
- 선언 의존: `api` project 7 · `implementation` project 3 + vendor 2
| 파일 | LOC | 성격 |
|---|---:|---|
| `GrpcPlatformStartupValidator` | 188 | 5개 검증 묶음, static 유틸 |
| `GrpcPlatformProperties` | 139 | `ca-skeleton.grpc.platform.*` 결속 |
| `GrpcPlatformAutoConfiguration` | 106 | 빈 9개 |
| `GrpcPlatformConfigurationException` | 35 | 위반 목록 예외 |
| **main java 합계** | **468** | |
| `AutoConfiguration.imports` | 1 | 자동 설정 1개 등록 |
| `GrpcPlatformStartupValidatorTest` | 267 | 테스트 |
| `build.gradle` | 24 | 의존 선언 |
### Coverage ledger
| scope | count | disposition | reason |
|---|---:|---|---|
| `main/java/**` | 4 | `FULL_READ` | 188+139+106+35 전 본문 |
| `main/resources/META-INF/spring/*.imports` | 1 | `FULL_READ` | 1줄 |
| `test/java/**` | 1 | `FULL_READ` | 267줄 · 테스트 12개 |
| `build.gradle` | 1 | `FULL_READ` | 24줄 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 |
`UNCLASSIFIED` 0.
---
## 1. 모듈의 정체와 격리 규칙
```groovy
// build.gradle:3-7
// The platform's composition boundary: typed properties, auto-configuration and the startup
// validator that refuses a deployment whose configuration contradicts a Stable invariant.
//
// It must never reach `:grpc-advanced:*`. That is not a comment — the registry's
// allowed_dependencies for this leaf omits every advanced id, `verifyCleanArchitectureDependencies`
// enforces it, and GrpcPlatformStartupValidatorTest asserts the same rule from the Java side.
```
격리 규칙은 세 겹이다 — 레지스트리, 빌드 검증 태스크, 그리고 자바 쪽 단언. 세 번째는 `validateAdvancedIsolation``GrpcStableBuildInvariant.requireNoAdvancedDependency` 를 부르는 형태다.
## 2. 자동 설정이 만드는 것
`@ConditionalOnProperty(prefix = "ca-skeleton.grpc.platform", name = "enabled", havingValue = "true", matchIfMissing = false)` — 기본 꺼짐.
| 빈 | 만들어지는 값 |
|---|---|
| `GrpcExecutorProfile` | `boundedPool(maxPoolSize, queueCapacity)` |
| `GrpcServerProfile` | `stableNetty(executorProfile)` |
| `GrpcAdmissionController` | `forExecutor(executorProfile)` |
| `GrpcServiceHealthRegistry` | `new …(GrpcHealthPolicy.standalone())` |
| `GrpcReflectionPolicy` | 설정 값이 없으면 `defaultFor(environment)` |
| `GrpcAdminExposurePolicy` | `standard()` |
| `GrpcDrainPolicy` | `stable()` |
| `GrpcContextBinder` | `new …(GrpcContextPropagationPolicy.stable())` |
| `GrpcErrorMapper` | `new …("grpc-platform", UUID::randomUUID)` |
전부 정책·프로파일·레지스트리다. 서버도, 인터셉터 사슬도, 서비스 어댑터 등록도 없다.
## 3. 설정 표면
`@ConfigurationProperties(prefix = "ca-skeleton.grpc.platform", ignoreUnknownFields = false)`.
두 판단이 javadoc 에 적혀 있다.
> "Off by default, like every other optional capability in this repository. A platform that starts
> because its jar is on the classpath is a platform that opens a port on a deployment nobody decided
> to give one to."
> "`ignoreUnknownFields = false` so a misspelled key fails startup rather than silently leaving a
> setting at its default."
## 4. 검증기가 담은 규칙
javadoc 이 선정 기준을 적는다.
> "Every rule here is a mistake whose runtime symptom is either silence or a misattributed failure…
> None of them fails a smoke test."
다섯 묶음이다.
- 전송·보안 — production 전송이 아니면 거부, 배포 환경에서 TLS 미사용·trust-all·반사 전체 공개 거부
- 실행기 — 큐 용량 1 미만(무제한) 거부, 풀 크기 양수 요구
- 메서드 — 단항인데 사용 가능한 마감이 0, 명시적 재시도가 멱등 프로파일과 모순, 멱등 키 필수인데 원장 비활성, Stable 범위 밖 RPC 종류
- 채널 — Stable 스킴 요구, 두 재시도 소유자가 동시에 in-process 재시도
- 고급 격리 — Stable 스타터가 advanced 의존을 끌면 위반
그리고 한 번에 전부 모아 실패한다 — "so a deployment learns the whole list in one restart."
## 10. 테스트 레인
`GrpcPlatformStartupValidatorTest` 267줄 · 12개. 검증기의 규칙별 거부와 통과를 직접 호출로 확인한다. **자동 설정 컨텍스트를 세우는 테스트는 없다**`ApplicationContextRunner` 도, 슬라이스 테스트도 없다. 빈 아홉 개가 실제로 조립되는지는 이 레인이 답하지 않는다.
`aCoherentConfigurationStarts` 가 통과 쪽을, 나머지 아홉이 규칙별 거부 쪽을 잡는다 — in-process 전송, TLS 둘, 반사, 무제한 실행기, 원장 없는 멱등 키, client-streaming, 재시도 소유자 둘, advanced 누출. `aRefusalNamesEveryViolation` 이 세 개를 동시에 깨뜨려 목록이 한 번에 나오는지 본다.
마지막 하나가 형태로 특이하다.
```java
void theAutoConfigurationIsRegisteredAndStable() {
assertThat(read(Path.of("src/main/resources/META-INF/spring/…imports")).strip())
.isEqualTo("dev.caskeleton.grpc.boot.GrpcPlatformAutoConfiguration");
// The dependency declaration, not the word: the build file's own comment says it must never
// reach an advanced module, and matching on the prose would fail on the sentence stating the rule.
assertThat(read(Path.of("build.gradle"))).doesNotContain("project(':grpc-advanced");
}
```
단위 테스트가 자기 모듈의 `build.gradle` 을 파일로 읽어 의존 선언을 단언한다. 주석이 왜 낱말이 아니라 선언 문법에 맞추는지까지 적어 두었다 — 규칙을 서술한 문장 자체가 낱말 검색에 걸리기 때문이다. `verifyCleanArchitectureDependencies` 가 도는 것과 별개로 이 레인 안에서도 격리가 붙들린다.
## 12. negative-space probes
**12.1 도달성.** 리프 밖에서 이 리프의 타입을 부르는 코드가 0 이고, **이 리프를 의존하는 모듈도 0 이다.**
```
$ grep -rn "grpc-spring-boot-starter" --include=*.gradle src/
(매치 없음)
$ grep -rn "ca-skeleton.grpc.platform" --include=*.yml --include=*.yaml --include=*.properties .
(매치 없음 — 이 리프 자신을 빼고)
$ grep -rn "GrpcPlatformAutoConfiguration\|GrpcPlatformProperties\|GrpcPlatformStartupValidator" --include=*.java src/ | grep -v grpc-spring-boot-starter
(매치 없음)
```
| 타입 | production 호출자 |
|---|---|
| `GrpcPlatformAutoConfiguration` | 등록 파일 1줄 — 그러나 이 스타터를 클래스패스에 올리는 모듈이 없다 |
| `GrpcPlatformStartupValidator` | **0** |
| `GrpcPlatformConfigurationException` | 검증기 안에서만 |
`enabled=true` 를 쓰는 설정 파일도 저장소에 없다. 즉 `@ConditionalOnProperty` 가 참이 되는 배포가 지금 하나도 없고, 아홉 빈은 아직 한 번도 만들어진 적이 없다. §17.1 의 등급을 P2 로 둔 근거가 이것이다 — 오늘의 사고가 아니라, 이 스타터를 처음 채택하는 배포가 맞을 상태다.
**12.3 선언만 있고 쓰이지 않는 의존 셋.**
```groovy
implementation project(':grpc:grpc-proto-contract')
implementation project(':grpc:grpc-codegen')
implementation project(':grpc:grpc-operation-ledger-jpa')
```
이 리프의 자바 4파일 어디에도 `dev.caskeleton.grpc.contract` · `…grpc.codegen` · 운영 원장 타입의 import 가 없다. `operation-ledger-enabled``boolean` 프로퍼티일 뿐 원장 타입을 참조하지 않는다.
세 의존 모두 build-only 판정 도구다 — 스키마 규칙 엔진, 코드 생성 거버넌스, JPA 원장. 스타터가 그것들을 **런타임 조립에 쓰지 않으면서 클래스패스에 끌고 온다.** 이 리프의 존재 이유가 "구성 경계"이므로, 경계가 끌어오는 것이 실제로 필요한 것인지가 다른 리프보다 더 중요하다.
같은 사실을 반대편에서도 기록해 두었다 — `grpc-codegen` §12.1, `grpc-proto-contract` §12.1.
**12.2 설정 키별 소비자.**
| 키 | 읽는 곳 |
|---|---|
| `enabled` | `@ConditionalOnProperty` |
| `executor-queue-capacity` · `executor-max-pool-size` | 자동 설정 + 검증기 |
| `environment` | 자동 설정(반사 정책) + 검증기 |
| `reflection-mode` | 자동 설정 + 검증기 |
| `transport` | **검증기뿐** |
| `tls-enabled` · `trust-all-certificates` | **검증기뿐** |
| `operation-ledger-enabled` | **검증기뿐** |
| `default-unary-deadline` | **없음** |
**12.4 드리프트.** build.gradle 이 서술한 세 요소(타입 있는 설정·자동 설정·시작 검증기)가 전부 존재한다. 어긋난 것은 세 번째가 시작 시 돌지 않는다는 점이고 §17.1 이다.
## 16. 확인하지 못한 것
- 스타터를 실제 애플리케이션에 올려 컨텍스트를 세우지 않았다. build-only 이고, 이 스타터를 의존하는 모듈이 저장소에 없다(§12.1).
- `verifyCleanArchitectureDependencies` 태스크를 이 리비전에서 실행하지 않았다.
- 테스트를 실행하지 않았다. 12개 전부 본문으로만 확인했다.
-`implementation` 의존이 쓰이지 않는다는 것(§12.3)은 패키지 이름 grep 으로 판정했다. 상수나 문자열을 통한 간접 사용이라면 잡히지 않는다.
## 17. 손볼 것
### 17.1 P2 — 시작 검증기가 시작 시 실행되지 않는다
`GrpcPlatformStartupValidator` 를 이름으로 부르는 파일은 둘뿐이다 — 자기 자신과 자기 테스트.
```
src/grpc/grpc-spring-boot-starter/src/main/java/…/GrpcPlatformStartupValidator.java
src/grpc/grpc-spring-boot-starter/src/test/java/…/GrpcPlatformStartupValidatorTest.java
```
`GrpcPlatformAutoConfiguration` 은 빈 9개를 만들고 `requireValid` 를 부르지 않는다. 초기화 콜백도, `@PostConstruct` 도, `ApplicationRunner` 도 없다.
그래서 클래스 javadoc 이 약속한 성질이 성립하지 않는다 — "Refuses to start on a configuration that would be wrong in a way nobody would notice." 지금은 그 설정으로 그냥 시작한다.
**함께 사라지는 것.** 검증기가 유일한 소비자인 설정 키가 넷이다.
- `transport` — production 이 아닌 전송을 거부할 곳이 없다. 게다가 자동 설정은 이 값을 보지 않고 `GrpcServerProfile.stableNetty(...)` 를 하드코딩한다(§17.2).
- `tls-enabled` · `trust-all-certificates` — 배포 환경의 TLS 바닥을 강제할 곳이 없다.
- `operation-ledger-enabled` — 멱등 키 필수 메서드가 원장 없이 열리는 것을 막을 곳이 없다.
같은 저장소가 이 형태를 두 번 기록했다 — `WebPlatformStartupValidator` 가 시작 시 실행되지 않고, `BrokerAclManifest` 의 시작 자기점검이 없다. 반대로 messaging 의 `StartupProfileValidation``InitializingBean.afterPropertiesSet` 으로 돌려 그 문제를 이미 한 번 해결했고, fileserver 는 `attestMapping()` 을 app-bootstrap 의 `@Bean` 으로 연결했다. 정본이 저장소 안에 둘 있다.
**왜 배선되지 않았는지가 서명에 보인다.** `violations` 는 넷을 받는다.
```java
public static List<String> violations(
GrpcPlatformProperties properties, // 자동 설정이 @EnableConfigurationProperties 로 가진다
GrpcMethodPolicyCatalog catalog, // 이 자동 설정에 빈 정의 없음
List<GrpcNamedChannelProfile> channelProfiles, // 빈 정의 없음
Set<String> stableModuleDependencies) // 이것을 런타임에 계산하는 코드가 저장소에 없음
```
넷 중 셋에 생산자가 없다. 특히 마지막은 "스타터가 해석한 모듈 id 집합" 인데, 그것을 실행 중에 산출하는 코드가 저장소 어디에도 없다 — 테스트는 `Set.of("grpc-core-api", "grpc-policy", "grpc-server", "grpc-client")` 리터럴을 넣는다. 검증기가 요구하는 입력을 구성 경계가 만들지 않으므로, 지금 형태로는 부를 수가 없다.
**수정.** 자동 설정에 검증기를 부르는 `InitializingBean`(또는 `SmartInitializingSingleton`) 빈을 하나 추가하되, 세 입력의 생산자를 함께 정한다.
- `GrpcMethodPolicyCatalog` · `List<GrpcNamedChannelProfile>``ObjectProvider` 로 받고 비어 있을 때의 동작(건너뛸지, 그 자체를 위반으로 볼지)을 정한다.
- `stableModuleDependencies` — 런타임에 계산할 방법이 없다면 `GrpcStableModuleCatalog` 가 아는 정적 목록으로 대체하거나, 이 규칙을 빌드 태스크 쪽에만 남기고 검증기 서명에서 뺀다. 지금은 같은 불변식을 세 겹으로 둔다고 §1 이 말하지만, 세 번째 겹이 실행되려면 아무도 만들지 않는 입력이 필요하다.
### 17.2 P3 — 자동 설정이 `transport` 를 읽지 않고 전송을 하드코딩한다
```java
@Bean @ConditionalOnMissingBean
public GrpcServerProfile grpcServerProfile(GrpcExecutorProfile executorProfile) {
return GrpcServerProfile.stableNetty(executorProfile);
}
```
`GrpcPlatformProperties.transport``GrpcServerTransport` 열거형이고 기본값이 `NETTY_SHADED` 다. 그 값을 자동 설정이 보지 않으므로 다른 값을 설정해도 만들어지는 프로파일은 같다.
지금은 무해에 가깝다 — 기본값이 하드코딩된 것과 같고, 다른 값은 §17.1 때문에 거부되지도 않지만 반영되지도 않는다. 그러나 설정 키가 존재하고 문서화되어 있으므로 운영자는 그것이 전송을 고른다고 읽는다.
수정은 프로파일 팩토리를 `transport` 로 분기시키거나, 그 키를 검증 전용임을 자바독에 명시하는 것이다.
### 17.3 P3 — `default-unary-deadline` 은 읽는 코드가 저장소에 없다
```java
/** The default deadline applied to a Stable unary method that declares none. */
private Duration defaultUnaryDeadline = Duration.ofSeconds(2);
```
`getDefaultUnaryDeadline()` 의 호출자가 0 이다. 검증기도 이 값을 쓰지 않는다 — 검증기가 보는 것은 정책 목록의 `policy.deadline().usable()` 이고 그 값이 0 이면 위반을 낸다. 즉 자바독이 말하는 "선언하지 않은 메서드에 적용되는 기본 마감" 을 적용하는 코드가 없다.
`ignoreUnknownFields = false` 라서 이 키를 설정하는 것은 성공하고 아무 효과가 없다.
수정은 그 기본값을 실제로 적용하는 지점을 만들거나(정책 목록 조립 시), 필드를 제거하는 것이다.
### 17.4 P3 — 반사 모드를 명시하면 서비스·역할 허용 목록이 조용히 하드코딩으로 바뀐다
```java
@Bean @ConditionalOnMissingBean
public GrpcReflectionPolicy grpcReflectionPolicy(GrpcPlatformProperties properties) {
return properties.getReflectionMode() == null
? GrpcReflectionPolicy.defaultFor(properties.getEnvironment())
: new GrpcReflectionPolicy(
properties.getReflectionMode(),
java.util.Set.of("admin"), // ← 리터럴
java.util.Set.of("ROLE_PLATFORM_ADMIN")); // ← 리터럴
}
```
두 갈래가 만드는 것이 같은 종류의 값이 아니다.
- 설정하지 않으면 `defaultFor(environment)` — 환경이 서비스 목록과 역할 목록을 함께 결정한다.
- 설정하면 모드만 운영자 것이고, **허용 서비스와 허용 역할은 이 자동 설정에 박힌 리터럴이 된다.**
운영자가 조정한다고 생각하는 것은 노출 수위 하나인데, 실제로는 노출 대상 집합까지 바뀐다. 그리고 그 두 리터럴은 설정 표면에 노출되어 있지 않으므로 되돌릴 방법이 `reflection-mode` 를 다시 비우는 것뿐이다.
`ca-skeleton.grpc.platform``ignoreUnknownFields = false` 를 걸어 "오타가 조용히 기본값으로 남지 않게" 한 설정 표면이다. 같은 규율로 보면, 값을 하나 설정했을 때 설정하지 않은 두 값이 함께 바뀌는 것도 같은 종류의 침묵이다.
**수정.** 허용 서비스·역할을 `GrpcPlatformProperties` 에 올리거나, 명시 모드에서도 `defaultFor(environment)` 가 만든 정책의 모드만 바꾼 사본을 쓴다. 후자가 이 저장소의 다른 곳에서 쓰는 형태다(`GrpcProtoStyleManifest.allowingWellKnownTypes` 처럼 넓힌 사본).
### 확인된 설계(문제 아님)
- **기본 꺼짐과 그 근거** — 클래스패스에 있다는 이유로 포트를 여는 플랫폼이 되지 않는다.
- **`ignoreUnknownFields = false`** — 오타가 조용히 기본값으로 남지 않는다.
- **고급 격리를 세 겹으로 둔 것** — 레지스트리·빌드 태스크·자바 단언.
- **검증기가 한 번에 전부 보고하는 것** — 재시작 한 번으로 목록 전체를 배운다.
- **검증 규칙 선정 기준** — 증상이 침묵이거나 오귀인인 실수만 담는다.
- **`@ConditionalOnMissingBean` 을 아홉 빈 전부에 둔 것** — 채택자가 개별 정책을 갈아끼울 수 있다.
---
## Source anchors
```
src/grpc/grpc-spring-boot-starter/build.gradle:1-24
main/java/…/boot/GrpcPlatformAutoConfiguration.java:1-106
main/java/…/boot/GrpcPlatformStartupValidator.java:1-188
main/java/…/boot/GrpcPlatformProperties.java:1-139
main/java/…/boot/GrpcPlatformConfigurationException.java:1-35
main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports:1
test/java/…/boot/GrpcPlatformStartupValidatorTest.java:1-267
```
@@ -0,0 +1,320 @@
# grpc-testkit 완전 해부
> 상태: COMPLETE
> 재오픈 게이트: cycle 2 재통독(2026-09-01) — `src/main` production 26파일 2,313줄 + `src/test` 8파일 1,339줄 축자 통독 완료. `STRUCTURAL_ONLY` 는 `gradle.lockfile` 하나.
> 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916`
> 분석 범위: `src/grpc/grpc-testkit`
> SSOT owner: `grpc-testkit`
> integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY)
---
## 0. SSOT identity / 커버리지
- `runtime_memberships`: **`[]`** — build-only
- 이 리프만 실제 전송을 싣는다 — `grpc-inprocess`, `grpc-netty-shaded`
| 파일 | LOC | 구획 |
|---|---:|---|
| `testkit/netty/GrpcTlsTestMaterial` | 239 | netty |
| `testkit/inprocess/GrpcInProcessContractFixture` | 145 | inprocess |
| `testkit/netty/GrpcNettyTestServer` | 137 | netty |
| `testkit/netty/GrpcNettyTestClient` | 116 | netty |
| `testkit/fault/GrpcTransportEvidenceClassifier` | 107 | fault |
| `release/GrpcStableReleaseGate` | 96 | release |
| `performance/GrpcPerformanceGate` | 95 | performance |
| `testkit/inprocess/GrpcInProcessTestServer` | 94 | inprocess |
| `release/GrpcCompatibilityMatrix` | 93 | release |
| `testkit/netty/GrpcNettyContractProfile` | 87 | netty |
| `testkit/inprocess/GrpcInProcessTestClient` | 83 | inprocess |
| `testkit/GrpcUnaryScenario` | 82 | testkit |
| `testkit/GrpcStreamingContractResult` · `testkit/GrpcUnaryReliabilityContract` | 81 · 81 | testkit |
| `testkit/GrpcEvidenceGrade` | 80 | testkit |
| `testkit/GrpcStreamingScenario` | 79 | testkit |
| `performance/GrpcPerformanceBudget` | 78 | performance |
| `testkit/GrpcUnaryContractResult` | 69 | testkit |
| `performance/GrpcPerformanceResult` · `testkit/fault/GrpcFaultScenario` | 66 · 66 | performance / fault |
| `release/GrpcReleaseEvidence` | 63 | release |
| `testkit/GrpcTextCodec` | 62 | testkit |
| `testkit/GrpcServerStreamingContract` | 61 | testkit |
| `testkit/fault/GrpcFaultResult` | 58 | fault |
| `testkit/fault/GrpcFaultPoint` | 53 | fault |
| `release/GrpcReleaseDecision` | 42 | release |
구획별: `testkit` 8파일 595줄 · `netty` 4파일 579줄 · `inprocess` 3파일 322줄 · `release` 4파일 294줄 · `fault` 4파일 284줄 · `performance` 3파일 239줄.
main 총 **26파일 / 2,313줄**.
### Coverage ledger
| scope | count | disposition | reason |
|---|---:|---|---|
| `main/java/**` | 26 | `FULL_READ` | 2,313줄. 위 표가 전부 |
| `test/java/**` | 8 | `FULL_READ` | 1,339줄 |
| `build.gradle` | 1 | `FULL_READ` | 66줄. 레인 선언 포함 전문 |
| `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 — 생성물 |
`UNCLASSIFIED` 0.
> 이 표는 2026-09-01 재통독에서 파일 단위로 다시 세었다. 이전 판은 구획 다섯의 근사치(`189+`·`3+`)로 적었고, 그 근사 안에 §17.3–§17.6 이 있었다.
`UNCLASSIFIED` 0.
---
## 1. 네 레인이 모듈 넷을 대신한다
```groovy
// build.gradle:3-7
// Certification. The Stable plan splits this across four modules (core / in-process / netty /
// fault); this repository already expresses "these two runs are not the same kind of evidence" with
// strict test lanes rather than with module boundaries, so the four become four lanes over one
// leaf. A lane that discovers nothing fails, and none of them can serve an up-to-date result —
// which is the property the split was protecting.
```
| 레인 | 태그 | 증거 |
|---|---|---|
| `grpcInProcessContractTest` | `grpc-inprocess` | 어댑터·인터셉터 순서·상태·멱등 재생. HTTP/2·TLS·전송 한도는 절대 아님 |
| `grpcNettyContractTest` | `grpc-netty` | 실제 소켓의 HTTP/2, TLS·상호 TLS, 메타데이터·메시지 하드 한도, GOAWAY, 킵얼라이브, 배수 |
| `grpcFaultTest` | `grpc-fault` | 증거 경계마다의 연결 손실과, 관측되지 않은 상태에서 미전송을 추론하기를 거부하는 분류기 |
| `grpcPerformanceTest` | `grpc-performance` | 지연 백분위·스트림 포화·실행기 포화·배수 예산 |
## 2. 증거 등급이 코드 안에서 구분을 유지한다
`GrpcEvidenceGrade` javadoc:
> "a claim about TLS backed by `CONTRACT` evidence is refused, because in-process transport never
> negotiated one."
`certifies()` 를 필드가 아니라 계산으로 둔 이유도 적혀 있다 — 컬렉션 필드를 가진 열거형은 어떤 정적 분석에도 가변 열거형으로 보인다.
## 3. 성능 레인이 기본 test 에서 빠진 이유
```groovy
// The performance lane is excluded from the default `test` task. It measures a running server
// under load, and a measurement in the release gate is a flaky test on a shared CI runner; it runs
// when somebody asks for it, by name.
tasks.named('test') { useJUnitPlatform { excludeTags 'grpc-performance' } }
```
측정을 릴리스 게이트에 넣지 않는다는 판단이 명시적이고, 그 대신 `GrpcPerformanceGate` 가 기록된 기준선과 대조하는 형태로 남는다.
## 4. 릴리스 게이트 — 문서가 후속이 아니라 차단 사유다
> "A platform whose failure modes are `COMPLETION_UNKNOWN` and a stream that needs a full resync is
> a platform whose on-call has to be told what to do about them; 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."
차단 사유가 다섯 갈래다 — 호환성 표의 누락 결과, 생산되지 않은 증거 등급, 스키마 발행 거부, 런북 부재, 결정 기록 부재, 지원 표 부재.
`requireCertified` 는 능력이 이번 릴리스가 낸 증거로 인증되지 않으면 던지고, 메시지에 실제로 돈 등급을 나열한다.
## 10. 테스트 레인
여덟 테스트 1,339줄. 전송 증거 분류기(272줄)가 가장 크고, 그다음이 Netty 계약 프로파일과 단항 신뢰성 계약이다.
## 12. negative-space probes
**12.1 도달성.** 릴리스 게이트·성능 게이트·증거 타입의 소비자는 이 리프의 테스트뿐이다. 그리고 저장소 전체에서 `dev.caskeleton.grpc.testkit` 를 import 하는 파일이 이 리프 밖에 **하나도 없다** — build.gradle 이 grpc 리프 열을 `api` 로 노출하는데, 그 픽스처를 쓰는 리프가 없다. 각 리프가 자기 픽스처를 따로 만든다.
**12.2 대조군 — 이 저장소의 다른 인증 지형.** messaging 가족은 인증 워크플로를 갖고 있고(`messaging-certification`), 이 가족은 갖고 있지 않다. §17.1.
**12.3 "레인" 이 두 뜻으로 쓰인다.**
| 출처 | 이름 | 개수 |
|---|---|---:|
| `build.gradle``strictTestLanes` | `grpcInProcessContractTest`·`grpcNettyContractTest`·`grpcFaultTest`·`grpcPerformanceTest` | 4 |
| `GrpcCompatibilityMatrix.caSkeleton()` | `boot-managed-platform`·`proto3-explicit-optional`·`netty-shaded`·`netty-unshaded`·`upstream-grpc-java-override`·`protobuf-edition-2024`·`protobuf-edition-2026` | 7 |
교집합이 없다. `missingResults(laneResults)` 가 요구하는 키는 둘째 목록의 것이고, 그것을 만드는 코드는 자기 테스트뿐이다(§17.4).
**12.4 드리프트.** build.gradle 이 서술한 네 레인이 전부 등록되어 있고, 각각 정확히 하나의 `@Tag` 붙은 테스트 클래스를 갖는다 — `grpc-inprocess``GrpcInProcessContractFixtureTest`, `grpc-netty``GrpcNettyContractProfileTest`, `grpc-fault``GrpcTransportEvidenceClassifierTest`, `grpc-performance``GrpcPerformanceLaneTest`.
**12.5 실제로 소켓을 여는 것과 리터럴로 만드는 것.**
| 등급 | 실제 실행 | 결과 객체의 출처 |
|---|---|---|
| CONTRACT | in-process 서버·클라이언트 왕복 ✓ | `GrpcUnaryContractResult`·`GrpcStreamingContractResult`**전부 리터럴**(§17.5) |
| TRANSPORT | Netty 소켓·TLS·mTLS·한도·GOAWAY ✓ | 결과 타입 없음. `profile.grade()` 만 단언한다 |
| FAULT | 소켓 하나를 작업 중에 죽인다 ✓ | `GrpcExecutionEvidence`**리터럴**(§17.3) |
| PERFORMANCE | 200회 측정 ✓ | 측정값은 실제, 예산은 임시값·기준선 없음 |
## 16. 확인하지 못한 것
- 성능 레인을 돌리지 않았다. 기본 `test` 에서 제외되어 있고 부하 측정이 필요하다.
- 세 레인은 이전 분석에서 직접 실행해 통과를 확인했다(계약 7 · Netty 9 · 고장 9, 실패 0). 이번 재통독에서는 다시 돌리지 않았다.
- §17.3 의 `observedFailure` 경로를 실행으로 확인하지 않았다. 대입과 단언이 같은 메서드 안에 있고 그 사이에 재대입이 없다는 것으로 판정했다.
- `keytool` 명령줄 노출(§17.6)을 실제로 `ps` 로 관측하지 않았다. `ProcessBuilder` 인자 목록에 비밀번호가 들어간다는 것으로 판정했다.
- `gradle.lockfile` 은 읽지 않았다(`STRUCTURAL_ONLY`).
## 17. 손볼 것
### 17.1 P2 — 네 레인이 `check` 에 붙지 않고, 이 가족을 이름으로 부르는 워크플로가 없다
`ca.strict-test-lane.gradle` 은 레인을 `verification` 그룹의 `Test` 태스크로 **등록만** 한다. `check` 에 연결하는 줄이 없다.
```
tasks.register(lane.name, Test) { group = 'verification'; … }
check dependsOn 관련 라인 → 0건
```
그리고 CI 워크플로에서 이 가족을 이름으로 부르는 것이 없다. `ci-quality-gates.yml``./gradlew check` 를 돌리므로 각 리프의 기본 `test` 는 돈다(이번에 확인: classes=71 tests=579 failures=0). 네 증거 레인은 그 밖에 있다.
결과적으로 이 플랫폼의 CONTRACT·TRANSPORT·FAULT 등급을 뒷받침하는 것은 25개 테스트이고, 그 25개는 누군가 명령을 직접 입력할 때만 돈다.
build.gradle 자신이 그 위험을 적는다 — "A lane that discovers nothing fails, and **none of them can serve an up-to-date result**". 첫 성질은 레인 규약이 지킨다. 둘째 성질은 아무도 돌리지 않으면 무의미하다.
같은 저장소가 이 형태를 두 번 기록했다 — 모듈 18 의 "붉은 게이트는 마지막으로 돌린 사람이 본 것을 보고한다" 와 mongo 가족의 릴리스 게이트 지형. 차이는 이쪽 레인이 오늘 초록이라는 것이고, 그것을 확인한 방법이 이번 분석에서 직접 돌린 것이라는 점이다.
수정은 세 레인(성능 제외)을 `check` 에 붙이거나, messaging 가족처럼 전용 워크플로를 두는 것이다. 성능 레인을 빼는 판단은 이미 근거와 함께 코드에 있으므로 그대로 두면 된다.
### 17.2 P3 — 릴리스 게이트의 입력이 전부 호출자가 손으로 만드는 값이다
```java
public GrpcReleaseDecision evaluate(
GrpcReleaseEvidence evidence,
Map<String, Boolean> laneResults,
GrpcSchemaArtifactPublisher.PublishDecision schemaDecision)
```
세 입력 중 어느 것도 실제 레인 결과나 실제 산출물에서 오지 않는다. 게이트를 부르는 곳은 자기 테스트 하나뿐이고, 그 테스트가 세 값을 리터럴로 만든다.
이 형태 자체는 이 저장소의 다른 게이트와 다르다. mongo 가족의 증거 검증기는 테스트 결과 XML 을 읽고 파일의 수정 시각까지 본다. 이쪽 게이트는 그런 산출물 판독기를 갖지 않는다.
지금은 무해하다 — 릴리스 절차가 이 게이트를 부르지 않기 때문이다. 기록하는 이유는 §17.1 을 고쳐 레인을 자동으로 돌리게 되면, 그 결과를 이 게이트에 넣어 주는 코드가 함께 필요하다는 점이다.
### 17.3 P2 — 고장 레인의 유일한 실소켓 시험이 자기가 관측한 것을 버리고 리터럴로 증거를 만든다
`GrpcTransportEvidenceClassifierTest.aRealConnectionLossAfterAppStartIsCompletionUnknown` 은 이 리프에서 유일하게 실제 연결을 작업 중에 끊는다. 서버 핸들러가 래치로 멈춰 있는 동안 `server.close()` 를 부른다. 거기까지는 진짜 고장이다.
그런데 그 고장이 만들어 낸 관측이 어디에도 남지 않는다.
```java
StatusRuntimeException observedFailure;
try (GrpcNettyTestClient client = ) {
Thread caller = new Thread(() -> {
try { client.callUnary(CREATE_DESCRIPTOR, "create"); }
catch (StatusRuntimeException expected) {
// The connection dies underneath this call; the exception is the observation.
} // ← 그 "observation" 을 버린다
});
observedFailure = null; // ← 무조건 null 을 대입한다
}
assertThat(observedFailure).isNull(); // ← 방금 대입한 null 을 단언한다
```
주석이 "the exception is the observation" 이라고 말하는데 그 예외는 `catch` 안에서 사라지고, 변수는 `null` 로 고정되고, 단언은 자기 대입을 확인한다.
그리고 `GrpcFaultResult` 에 들어가는 증거는 방금 일어난 호출에서 오지 않는다.
```java
GrpcExecutionEvidence evidence = GrpcTransportEvidenceClassifier.classify(
CREATE, RpcType.UNARY,
GrpcTransportEvidenceClassifier.ClientObservation.sentAndSilent(), // ← 리터럴 팩토리
false, GrpcBusinessEvidence.ATTEMPTED);
```
즉 소켓은 실제로 죽었고, 그 죽음에서 읽어 낸 값은 하나도 쓰이지 않는다. 이 시험이 실제로 증명하는 것은 `applicationStarted == true` 하나다. 나머지는 분류기의 산술이고, 그것은 같은 파일의 다른 일곱 시험이 이미 소켓 없이 증명한다.
이 형태를 이 리프 자신이 이름 붙여 두었다.
> "a release cannot cite an in-process run as transport evidence. That substitution is the easiest
> one to make under time pressure and the hardest to spot afterwards: the suite name says
> 'contract', the report says the platform is certified, and nothing in between records that no
> socket was opened." — `GrpcReleaseEvidence`
여기서는 소켓이 열렸다. 그런데 등급을 뒷받침해야 할 증거가 여전히 손으로 쓴 값이다. 한 단계 아래의 같은 치환이다.
**수정.** `callUnary` 를 부른 스레드가 잡은 예외와 그 시점의 진행 상태를 밖으로 넘겨(`AtomicReference`), 그것으로 `ClientObservation` 을 구성한다. 그러면 `sendCompleted`·`responseHeadersReceived` 가 관측값이 되고, 이 시험이 FAULT 등급을 실제로 뒷받침한다.
### 17.4 P3 — 호환성 표의 레인 이름과 빌드의 레인 이름이 서로 다른 집합이다
`GrpcStableReleaseGate.evaluate` 의 둘째 인자는 `Map<String, Boolean> laneResults` 이고, `GrpcCompatibilityMatrix.missingResults` 가 그 키를 자기 목록과 대조한다.
그 목록은 배포 조합의 이름이다 — `boot-managed-platform`, `netty-shaded`, `upstream-grpc-java-override`, `protobuf-edition-2024`
빌드가 등록하는 레인의 이름은 증거 종류다 — `grpcInProcessContractTest`, `grpcNettyContractTest`, `grpcFaultTest`, `grpcPerformanceTest`.
두 집합의 교집합이 비어 있다. 그래서 §17.1 을 고쳐 네 Gradle 레인을 `check` 에 붙이더라도, 그 결과가 이 게이트의 `laneResults` 를 채우지는 못한다 — 이름이 다른 축을 가리키기 때문이다. 게이트가 요구하는 것은 "Boot 관리 플랫폼 조합에서 돌았는가" 이고, 레인이 답할 수 있는 것은 "전송 증거를 냈는가" 다.
두 축이 다 필요하다는 것 자체는 옳다. 기록하는 이유는 §17.1·§17.2 의 수정이 이것까지 함께 다루지 않으면 게이트가 여전히 손으로 만든 값을 먹는다는 점이다.
### 17.5 P3 — 계약 스위트 둘이 결과를 만드는 코드를 갖지 않는다
`GrpcUnaryReliabilityContract``GrpcServerStreamingContract` 는 순수 평가기다 — `List<Result>` 를 받아 위반을 돌려준다. 시나리오 정의(단항 3 · 스트리밍 5)와 그 정합성 검사는 훌륭하다. 스위트가 자기 커버리지를 열거하고, 돌지 않은 시나리오를 침묵이 아니라 위반으로 만든다.
빠진 것은 그 시나리오를 **돌리는** 쪽이다. `GrpcUnaryContractResult`·`GrpcStreamingContractResult` 를 만드는 코드는 저장소 전체에서 두 테스트뿐이고, 둘 다 리터럴로 만든다.
```java
private static GrpcUnaryContractResult result(
GrpcUnaryScenario scenario, int attempts, int invocations, GrpcCompletionOutcome outcome) {
return new GrpcUnaryContractResult(scenario, GrpcEvidenceGrade.CONTRACT, attempts, invocations, outcome);
}
```
그래서 "이 플랫폼은 비멱등 변경을 재시도하지 않는다" 를 뒷받침하는 것은, 그 문장을 리터럴로 적은 뒤 평가기가 그것을 읽고 위반이 없다고 답하는 절차다. 평가기의 산술은 옳고, 대상이 관측이 아니다.
in-process 픽스처(§1)는 이 시나리오들을 돌릴 재료를 이미 갖고 있다 — 인터셉터를 끼운 서버, 상태 매핑, 스트리밍 핸들러. 수정은 픽스처 위에서 세 시나리오를 실행해 `attempts`·`businessInvocations` 를 세는 러너를 두는 것이다.
### 17.6 P3 — 던져 버릴 비밀번호를 만들어 놓고 외부 프로세스의 명령줄에 싣는다
`GrpcTlsTestMaterial` 이 상수 비밀번호를 피하는 이유를 세 줄로 적는다.
> "A literal password in source is a literal password in source, and a scanner that flags it is
> right to — the cost of being correct here is three lines."
그리고 같은 클래스가 그 값을 `keytool` 인자로 넘긴다.
```java
runKeytool(List.of("-genkeypair", , "-storepass", new String(password), "-keypass", new String(password)));
Process process = new ProcessBuilder(command).redirectErrorStream(true).start();
```
프로세스 명령줄은 같은 호스트의 다른 사용자가 `ps``/proc/<pid>/cmdline` 로 읽을 수 있다. 소스 리터럴보다 관측 가능성이 오히려 높다.
영향은 작다 — 값이 매번 새로 만들어지고, 키스토어는 임시 디렉터리에 있으며 `close()` 가 지운다. 기록하는 이유는 이 클래스가 정확히 그 위험 계층을 스스로 논증했다는 점이다. 완화와 노출이 같은 메서드 안에 있다.
`keytool``-storepass:file``-keypass:file` 을 받는다. 임시 파일 하나면 명령줄에서 값이 사라진다.
### 확인된 설계(문제 아님)
- **모듈 넷 대신 레인 넷으로 증거 종류를 분리하고, 그 대체의 근거를 적은 것.**
- **증거 등급을 열거형으로 두고 각 등급이 무엇을 인증할 수 있는지 계산으로 답한 것.**
- **`certifies()` 를 필드가 아니라 계산으로 둔 것과 그 근거.**
- **성능 레인을 기본 `test` 에서 제외하고 그 이유를 적은 것** — 공유 러너의 측정은 흔들리는 테스트다.
- **문서(런북·결정 기록·지원 표)를 후속이 아니라 차단 사유로 둔 것.**
- **`requireCertified` 가 실패 메시지에 실제로 돈 등급을 나열하는 것.**
- **실제 전송 의존을 이 리프에만 둔 것** — 다른 리프는 전송 설정 모델만 갖는다.
---
## Source anchors
```
src/grpc/grpc-testkit/build.gradle:1-66 (레인 넷 · 성능 제외 · api 리프 열)
main/java/…/testkit/netty/GrpcTlsTestMaterial.java:1-239 (§17.6 runKeytool:212-239)
main/java/…/testkit/inprocess/GrpcInProcessContractFixture.java:1-145
main/java/…/testkit/netty/GrpcNettyTestServer.java:1-137
main/java/…/testkit/netty/GrpcNettyTestClient.java:1-116
main/java/…/testkit/fault/GrpcTransportEvidenceClassifier.java:1-107
main/java/…/release/GrpcStableReleaseGate.java:1-96 (§17.2 evaluate:36-72 · §17.4)
main/java/…/performance/GrpcPerformanceGate.java:1-95
main/java/…/testkit/inprocess/GrpcInProcessTestServer.java:1-94
main/java/…/release/GrpcCompatibilityMatrix.java:1-93 (§17.4 caSkeleton:148-158)
main/java/…/testkit/netty/GrpcNettyContractProfile.java:1-87
main/java/…/testkit/inprocess/GrpcInProcessTestClient.java:1-83
main/java/…/testkit/{GrpcUnaryScenario:1-82, GrpcStreamingContractResult:1-81,
GrpcUnaryReliabilityContract:1-81, GrpcEvidenceGrade:1-80,
GrpcStreamingScenario:1-79, GrpcUnaryContractResult:1-69,
GrpcTextCodec:1-62, GrpcServerStreamingContract:1-61} (§17.5)
main/java/…/performance/{GrpcPerformanceBudget:1-78, GrpcPerformanceResult:1-66}
main/java/…/testkit/fault/{GrpcFaultScenario:1-66, GrpcFaultResult:1-58, GrpcFaultPoint:1-53}
main/java/…/release/{GrpcReleaseEvidence:1-63, GrpcReleaseDecision:1-42}
test/java/…/testkit/GrpcTransportEvidenceClassifierTest.java:186-273 (§17.3)
test/java/…/ 8파일 1,339줄
src/config/gradle/ca.strict-test-lane.gradle (레인 등록 · check 미연결)
```