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:
co-authored by
Claude Opus 5
parent
43bccd08a8
commit
b2963105a8
@@ -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.reservedNumbers…add(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 미연결)
|
||||
```
|
||||
Reference in New Issue
Block a user