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>
12 KiB
grpc-client 완전 해부
상태: COMPLETE 재오픈 게이트: cycle 2 재통독(2026-09-01) —
src/mainproduction 13파일 931줄 +src/test4파일 581줄 축자 통독 완료. 재통독에서 §17.1–§17.4 를 독립적으로 재도출했고 넷 다 성립한다.STRUCTURAL_ONLY는gradle.lockfile하나. 기준 revision:21234e38cdb9a926cbc92bb97a2aee2e4a7d2916분석 범위:src/grpc/grpc-clientSSOT owner:grpc-clientintegration/family document:analysis/20-grpc-platform.md(secondary, INTEGRATION_ONLY)
0. SSOT identity / 커버리지
allowed_dependencies:["grpc-core-api", "grpc-policy"]+ vendorgrpc-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. 모듈의 정체
// 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
Channelor 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."
그리고 교차 경계 목록은 같은 도메인 목록의 부분집합이어야 한다 — 생성자가 강제한다.
인가 헤더는 어느 목록에도 올 수 없다.
"
authorizationis 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 은 정확하다.
if (!holder.compareAndSet(null, runtime)) {
throw new IllegalStateException("… already has a runtime; use rotate()");
}
rotate 는 그렇지 않다.
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 — 비원자적 감소가 세대를 영구히 회수 불가로 만든다
public void finishUnaryCall() {
if (inFlightUnaryCalls.get() > 0) { inFlightUnaryCalls.decrementAndGet(); }
}
public void closeStream() {
if (openStreams.get() > 0) { openStreams.decrementAndGet(); }
}
카운터가 1 일 때 두 스레드가 동시에 끝나면 둘 다 조건을 통과해 둘 다 감소시켜 −1 이 된다.
그 결과가 이 리프에서는 구체적이다.
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 — 배수 목록의 순회가 동기화 밖에서 일어난다
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."
구현된 것은 첫째와 다른 것이다.
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)