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>
247 lines
12 KiB
Markdown
247 lines
12 KiB
Markdown
# 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)
|
|
```
|