# 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 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 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` 를 받아 위반을 돌려준다. 시나리오 정의(단항 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//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 미연결) ```