Files
document-haness/docs/clean-architecture-backend-template/analysis/grpc/grpc-codegen.md
T
DongHyeonkaandClaude Opus 5 b2963105a8 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>
2026-09-04 22:51:59 +09:00

21 KiB

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. 모듈의 정체

// 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.evaluateGrpcSchemaArtifactPublisher.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 의 두 번째 차단 사유는 발화할 수 없다.

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/
(매치 없음)

그리고 테스트가 비교하는 대상이 실제 등록 태스크 집합이 아니다.

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 — 릴리스 버전 불변성이 프로세스 안에서만 성립한다

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 — 픽스처의 메서드 경로가 서비스 × 메서드 교차곱이다

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 가 결정을 그 결정이 판정한 후보에 묶지 않는다

public void publish(GrpcDescriptorArtifact candidate, PublishDecision decision) {
  if (decision == null || !decision.allowed()) {
    throw new IllegalStateException("refusing to publish '…'");
  }
  publishedHashesByVersion.put(candidate.schemaVersion(), candidate.schemaHash());
}

decisioncandidate 를 판정한 결정인지 확인하는 코드가 없다. PublishDecision(boolean allowed, List<String> blockers) 뿐이라 자기가 무엇을 판정했는지 들고 있지도 않다.

그래서 이렇게 쓸 수 있다.

PublishDecision ok = publisher.evaluate(harmlessArtifact, List.of());  // 통과
publisher.publish(breakingArtifact, ok);                               // 그대로 기록된다

두 번째 줄에서 breakingArtifact 는 어떤 소비자 픽스처와도 대조되지 않고, 이미 발행된 버전인지도 확인되지 않은 채 이력에 들어간다. 이 클래스의 존재 이유인 두 규칙 — 소비자 컴파일 게이트와 릴리스 버전 불변성 — 을 둘 다 우회한다.

왜 이 형태가 생겼나. 판정과 기록이 두 호출로 나뉘어 있고 그 사이를 묶는 것이 호출자의 규율뿐이다. 이 저장소가 여러 가족에서 반복해 온 check-then-act 형태와 같다. 다만 여기서는 경합이 아니라 인자 짝 맞추기가 깨진 지점이다.

테스트는 안전한 형태만 쓴다 — identicalRepublishIsAllowedpublisher.publish(artifact, publisher.evaluate(artifact, List.of())) 로 한 줄에서 짝을 맞춘다. 그 규율을 코드가 강제하지 않는다.

수정. PublishDecision 이 판정 대상의 schemaVersion·schemaHash 를 들고, publish 가 후보와 대조한다. 또는 evaluate 가 발행 가능한 후보를 감싼 토큰을 돌려주고 publish 가 그 토큰만 받는다 — 짝이 어긋날 수 없는 형태가 된다.

17.5 P3 — sha256: 검사가 길이 15자 이상만 요구한다. 저장소 자신의 테스트가 32자 해시를 통과시킨다

같은 검사가 두 곳에 손으로 복사돼 있다.

// 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자다.

그리고 이 헐거움이 테스트에 이미 드러나 있다.

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 (의존 선언)