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>
20 KiB
grpc-testkit 완전 해부
상태: COMPLETE 재오픈 게이트: cycle 2 재통독(2026-09-01) —
src/mainproduction 26파일 2,313줄 +src/test8파일 1,339줄 축자 통독 완료.STRUCTURAL_ONLY는gradle.lockfile하나. 기준 revision:21234e38cdb9a926cbc92bb97a2aee2e4a7d2916분석 범위:src/grpc/grpc-testkitSSOT owner:grpc-testkitintegration/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 이 있었다.UNCLASSIFIED0.
1. 네 레인이 모듈 넷을 대신한다
// 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
CONTRACTevidence is refused, because in-process transport never negotiated one."
certifies() 를 필드가 아니라 계산으로 둔 이유도 적혀 있다 — 컬렉션 필드를 가진 열거형은 어떤 정적 분석에도 가변 열거형으로 보인다.
3. 성능 레인이 기본 test 에서 빠진 이유
// 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_UNKNOWNand 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 — 릴리스 게이트의 입력이 전부 호출자가 손으로 만드는 값이다
public GrpcReleaseDecision evaluate(
GrpcReleaseEvidence evidence,
Map<String, Boolean> laneResults,
GrpcSchemaArtifactPublisher.PublishDecision schemaDecision)
세 입력 중 어느 것도 실제 레인 결과나 실제 산출물에서 오지 않는다. 게이트를 부르는 곳은 자기 테스트 하나뿐이고, 그 테스트가 세 값을 리터럴로 만든다.
이 형태 자체는 이 저장소의 다른 게이트와 다르다. mongo 가족의 증거 검증기는 테스트 결과 XML 을 읽고 파일의 수정 시각까지 본다. 이쪽 게이트는 그런 산출물 판독기를 갖지 않는다.
지금은 무해하다 — 릴리스 절차가 이 게이트를 부르지 않기 때문이다. 기록하는 이유는 §17.1 을 고쳐 레인을 자동으로 돌리게 되면, 그 결과를 이 게이트에 넣어 주는 코드가 함께 필요하다는 점이다.
17.3 P2 — 고장 레인의 유일한 실소켓 시험이 자기가 관측한 것을 버리고 리터럴로 증거를 만든다
GrpcTransportEvidenceClassifierTest.aRealConnectionLossAfterAppStartIsCompletionUnknown 은 이 리프에서 유일하게 실제 연결을 작업 중에 끊는다. 서버 핸들러가 래치로 멈춰 있는 동안 server.close() 를 부른다. 거기까지는 진짜 고장이다.
그런데 그 고장이 만들어 낸 관측이 어디에도 남지 않는다.
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 에 들어가는 증거는 방금 일어난 호출에서 오지 않는다.
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 를 만드는 코드는 저장소 전체에서 두 테스트뿐이고, 둘 다 리터럴로 만든다.
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 인자로 넘긴다.
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 미연결)