Files
document-haness/docs/clean-architecture-backend-template/analysis/grpc/grpc-proto-contract.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

19 KiB

grpc-proto-contract 완전 해부

상태: COMPLETE 재오픈 게이트: cycle 2 — src/main production 3파일 605줄, 스키마/설정 리소스 5개, test 1파일 324줄 축자 통독 완료. STRUCTURAL_ONLY 잔여 없음. 기준 revision: 21234e38cdb9a926cbc92bb97a2aee2e4a7d2916 분석 범위: src/grpc/grpc-proto-contract SSOT owner: grpc-proto-contract integration/family document: analysis/20-grpc-platform.md (secondary, INTEGRATION_ONLY)


0. SSOT identity / 커버리지와 숫자 지도

  • allowed_dependencies: ["grpc-core-api"]
  • runtime_memberships: []
파일 LOC 성격
GrpcProtoContractValidator 447 라인 스캐너 + 9개 규칙 판정
GrpcProtoStyleManifest 120 규칙을 데이터로 둔 record
GrpcProtoRuleViolation 38 위반 1건 record
main java 합계 605
error.proto 66 리치 오류 상세 5 메시지 + 열거형 1
stream.proto 57 스트림 공통 스키마
buf.yaml · buf.gen.yaml · buf.lock 생성 설정(이 리프는 protoc 을 돌리지 않는다)
GrpcProtoContractValidatorTest 324 테스트
build.gradle 12 의존 project 1

Coverage ledger

scope count disposition reason
main/java/** 3 FULL_READ 447+120+38 전 본문 축자 확인
main/resources/proto/**/*.proto 2 FULL_READ 66+57 전문
main/resources/proto/buf.* 3 FULL_READ 테스트가 단언하는 키 전수 확인
test/java/** 1 FULL_READ 324줄 · 테스트 13개
build.gradle 1 FULL_READ 12줄
gradle.lockfile 1 STRUCTURAL_ONLY 잠금 파일; 선언 의존은 project 1 뿐임을 build.gradle 에서 확인

UNCLASSIFIED 0.


1. 모듈의 정체와 경계

// build.gradle:3-9
// Schema source of truth: the `.proto` files plus the rule engine that judges them.
// No protobuf plugin and no protoc invocation here — … running protoc is a separate, gated decision.

이 리프는 스키마 원본과 그것을 판정하는 규칙 엔진을 함께 담는다. protoc 은 돌지 않는다.

검증기 javadoc 이 그 한계를 스스로 규정한다.

"A line scanner, not a Protobuf parser, and that is a deliberate limit rather than a shortcut. Everything this checks is a property of the source text a reviewer reads… Semantics that need a compiled descriptor belong to grpc-codegen's descriptor artifact."

2. 규칙 9개

상수 판정
PROTO3_SYNTAX syntax = "proto3" 필수
PACKAGE_VERSIONED <org>.<domain>.v<major> — 접두 일치 + .*\.v[1-9]\d*$
JAVA_MULTIPLE_FILES option java_multiple_files = true 필수
JAVA_PACKAGE_SEPARATE java_package 가 손으로 쓴 패키지 안이면 위반
ENUM_ZERO_UNSPECIFIED 0 값 이름이 _UNSPECIFIED 로 끝나야 함
RESERVED_HISTORY 삭제 이력의 번호·이름이 reserved 에 있어야 함
WELL_KNOWN_TYPE_ALLOWLIST google/protobuf/ import 는 allowlist 에만
MAP_ALLOWLIST map 필드는 message.field 단위 허용
EXPLICIT_PRESENCE 매니페스트가 지정한 필드는 optional 선언 필수

3. 세 가지 설계 판단

3.1 금지가 아니라 allowlist

GrpcProtoStyleManifest javadoc:

"Three of the five fields are allowlists, and that shape is the decision: Any, Struct and map are not banned, they are things you have to ask for by name. A ban gets worked around; an allowlist entry gets read by the next person to open the manifest and carries the field it was granted for."

caSkeleton() 의 기본값은 조직 hyeonworks, 손으로 쓴 패키지 dev.caskeleton, WKT allowlist 5개(timestamp·duration·field_mask·empty·wrappers), map allowlist 빈 집합, presence 요구 빈 맵이다.

allowingWellKnownTypes · allowingMapFields · requiringPresence 세 메서드가 매니페스트를 넓힌 사본을 만든다. 정규 생성자가 모든 컬렉션을 복사하므로 리뷰를 통과한 매니페스트가 나중에 넓어지지 않는다.

3.2 던지지 않고 목록으로 돌려준다

GrpcProtoRuleViolation javadoc:

"Returned rather than thrown, and carrying a line number, because a schema review is a list. A validator that throws on the first violation turns 'this file breaks four rules' into four separate runs, and the author fixes them one at a time without ever seeing the shape of the problem."

3.3 삭제 이력은 추론하지 않고 입력으로 받는다

"a field that is simply gone from the current source is indistinguishable from one that never existed. Recording removals and checking them against reserved is the only way the 'do not reuse a field number' rule survives the commit that deletes the field."

SchemaHistory(removedFieldNumbers, removedFieldNames) 가 그 입력이고, 키는 파일에 적힌 메시지 이름이며 중첩은 점으로 한정한다.

4. 스캔 절차

한 줄씩 읽으면서 // 이후를 지우고, 빈 줄을 건너뛰고, 순서대로 시도한다 — syntax → package → import → option(파일 수준만) → 스코프 열기 → 닫기 → 스코프 안 멤버.

스코프는 message·enum·service·oneof 넷을 열고 이름을 점으로 한정해 스택에 쌓는다. 열거형 안에서는 0 값 이름을, 메시지와 oneof 안에서는 reserved·map·필드를 본다.

10. 테스트 레인

GrpcProtoContractValidatorTest 324줄. 첫 두 테스트가 이 리프의 게이트다.

  • committedSchemaIsCompliant — 커밋된 두 스키마를 실제로 검증기에 넣어 위반 0 을 단언한다.
  • theBufConfigurationAgreesWithTheValidatorbuf.yamlFILE·STANDARD 범주, 생성 경로가 build/generated/... 이고 out: src/ 가 아님, 버전 리터럴 부재, deps: [] 를 단언한다.

나머지는 규칙별 거부 사례다 — proto2 거부, 패키지 형식, 자바 패키지 충돌, java_multiple_files 부재, 열거형 0 값, 삭제 이력, WKT allowlist, map allowlist, explicit presence, 중첩 한정, describe() 렌더링. 전부 13개.

거부 사례 셋은 넓힌 매니페스트로 같은 소스를 다시 돌려 통과까지 확인한다 — allowingWellKnownTypes · allowingMapFields · requiringPresence. allowlist 라는 설계가 실제로 넓혀지는지까지 붙드는 형태다.

enumZeroValueNeedsTheUnspecifiedSuffix 는 줄 번호 7까지 단언한다 — 위반이 줄을 정확히 가리키는지가 이 리프의 산출물 형태(file:line rule — detail)에 직결되기 때문이다.

12. negative-space probes

12.1 도달성. 이 리프의 production 소비자는 0 이다. GrpcProtoContractValidator·GrpcProtoStyleManifest·GrpcProtoRuleViolation 을 부르는 코드는 자기 테스트뿐이다.

리프 밖 참조는 두 종류다.

참조 형태
grpc-codegen/…/GrpcBufPolicy.java:10 javadoc 언급
grpc-testkit · grpc-codegen · grpc-spring-boot-starter · grpc-advanced-edition 의 build.gradle project 의존 선언

네 모듈이 의존을 선언하지만 그중 어느 자바 파일도 이 리프의 타입을 import 하지 않는다. §17.4 가 그 결과를 다룬다.

12.3 아홉 규칙 중 둘은 이 저장소의 매니페스트에서 사실상 비활성이다.

public static GrpcProtoStyleManifest caSkeleton() {
  return new GrpcProtoStyleManifest("hyeonworks", Set.of("dev.caskeleton"),
      ALWAYS_ALLOWED_WELL_KNOWN_TYPES, Set.of(), Map.of());
  //                                   ^^^^^^^^  ^^^^^^^
  //                              mapFieldAllowlist  presenceRequiredFields
}
  • MAP_ALLOWLIST — 허용 목록이 비었으므로 실제 판정은 "map 전면 금지"다. §3.1 이 설명하는 "금지가 아니라 이름으로 요청" 이라는 형태는 매니페스트를 넓히는 호출자가 있어야 성립하는데, allowingMapFields 를 부르는 곳은 테스트뿐이다.
  • EXPLICIT_PRESENCE — 요구 맵이 비었으므로 어떤 필드도 optional 을 강제받지 않는다. 커밋된 두 스키마가 optional 을 다섯 곳에 쓰지만(예: FieldViolation.description, StreamEnvelope.resume_token) 그것을 요구하는 규칙은 없다. 규율이 코드가 아니라 저자의 손에 있다.

나머지 일곱은 기본 매니페스트에서도 실제로 판정한다.

12.2 대조군. 저장소에 .proto 파일이 넷 있다.

파일 이 검증기가 판정하는가
grpc-proto-contract/.../common/v1/error.proto 예 (테스트 목록)
grpc-proto-contract/.../common/v1/stream.proto 예 (테스트 목록)
messaging-schema-protobuf/src/test/proto/order_created_v1.proto 아니오
grpc-advanced-edition/.../edition2024/compatibility.proto 아니오

12.4 드리프트. build.gradle 주석이 규칙으로 든 다섯(proto3 + explicit optional, 패키지 버전, reserved 이력, 열거형 0 접미, WKT allowlist)이 전부 상수로 존재한다. 드리프트 없음.

16. 확인하지 못한 것

  • reserved 범위 문법의 오탐(§17.1)을 실행으로 재현하지 않았다. 정규식과 수집 코드로 판정했다.
  • 블록 주석(/* */) 안의 선언이 스캔되는지 실행으로 확인하지 않았다. LINE_COMMENT// 만 지우므로 그 형태가 남는다.
  • 테스트를 실행하지 않았다. 13개 전부 본문으로만 확인했다.
  • §17.4 의 "부르는 빌드가 없다"는 *.gradle · *.kts · *.yml 세 확장자와 자바 타입 이름 grep 으로 판정했다. 리플렉션이나 서비스 로더로 부르는 형태라면 잡히지 않는다.
  • 열거형 reserved 오탐(§17.5)을 실행으로 재현하지 않았다. 스코프 분기 코드로 판정했다.

17. 손볼 것

17.1 P3 — reserved 2 to 5; 범위가 개별 숫자로만 수집되어 RESERVED_HISTORY 오탐이 된다

RESERVED_NUMBERS = Pattern.compile("^\\s*reserved\\s+([^\";]*\\d[^\";]*);");
NUMBER           = Pattern.compile("\\d+");

Matcher number = NUMBER.matcher(reservedNumbers.group(1));
while (number.find()) { scan.reservedNumbersadd(Integer.valueOf(number.group())); }

reserved 2 to 5; 는 그룹이 "2 to 5" 이고 수집되는 것은 {2, 5} 다. 3·4 는 들어가지 않는다. reserved 9 to max;{9} 만 남는다.

그러면 삭제 이력이 3 을 담고 스키마가 reserved 2 to 5; 로 정확히 예약했는데도 RESERVED_HISTORY 위반이 보고된다. 범위 예약은 표준 문법이고 여러 필드를 한 번에 지울 때 쓰는 형태이므로 도달 가능하다.

수정은 to 를 인식해 범위를 펼치는 것이다. max 는 상한 상수로 다루거나 그 메시지에 대해 검사를 통과시킨다.

17.2 P3 — 반환 목록이 자바독이 약속한 source order 가 아니다

Scan scan = scan(fileName, source, violations);   // import·enum·map·presence 위반이 여기서 append
checkFileHeader(fileName, scan, violations);      // syntax·package·java_* 위반이 그 뒤에 append
checkRemovalHistory(fileName, scan, history, violations);

validate 의 javadoc 은 "@return every violation found, in source order" 라고 적는다. 실제로는 파일 앞머리의 syntax·package 위반이 40번째 줄의 map 위반보다 뒤에 온다.

describe()file:line rule — detail 형태를 만들고 그 형태의 목적이 빌드 로그를 읽는 것이므로, 정렬이 어긋나면 리뷰 목록으로서의 값이 줄어든다. 수정은 반환 직전에 line 으로 안정 정렬하는 것이다.

17.3 P3 — 커밋 스키마 게이트가 파일 목록을 하드코딩한다

List<String> files = List.of(
    "proto/hyeonworks/grpc/common/v1/error.proto",
    "proto/hyeonworks/grpc/common/v1/stream.proto");

리소스 디렉터리를 훑지 않는다. 이 리프에 세 번째 .proto 를 추가하면 이 테스트를 함께 고치기 전까지 판정되지 않고, 빌드는 초록으로 남는다.

같은 저장소가 다른 곳에서 이 형태를 이미 경계했다 — 빠뜨림이 통과가 되는 게이트다. 수정은 proto/** 아래 .proto 를 전부 열거해 돌리는 것이다.

기록 — oneof 도 스코프 이름을 밀어 넣는다 (현재 무해)

SCOPE_OPENoneof 를 스코프로 열고 이름을 점으로 한정한다. 그러면 message Foo { oneof kind { … } } 안의 필드는 Foo.kind.<field> 로 한정되고, SchemaHistory javadoc 이 말하는 키 규약(메시지 이름)과 어긋난다.

지금은 도달하지 않는다. protobuf 가 oneof 안에서 mapoptional 을 모두 금지하므로 MAP_ALLOWLIST·EXPLICIT_PRESENCE 판정이 그 자리에서 발생하지 않고, reservedoneof 안에 올 수 없다. 규칙을 넓힐 때 다시 볼 자리로 남긴다.

17.4 P2 — 두 파일이 이 검증기를 "빌드를 실패시키는 것" 이라고 단언하는데, 어떤 빌드도 그것을 부르지 않는다

같은 주장이 두 곳에 있다.

// grpc-codegen/…/GrpcBufPolicy.java:8-10
 * Buf's CLI is not part of this toolchain (adaptation D5), so the four lifecycle task names
 * below are the contract a CI environment fulfils and {@code GrpcProtoContractValidator} is what
 * actually fails a build here.
# grpc-proto-contract/…/proto/buf.yaml:3-5
# The rules named here are also implemented in GrpcProtoContractValidator, which is what actually
# fails this repository's build: the Buf CLI is not part of this toolchain, and a gate that silently
# passes when a binary is missing is worse than one that computes the same judgement from the
# committed schema.

두 문장이 같은 논증을 편다 — CLI 가 없으므로 이 자바 검증기가 그 자리를 대신한다는 것. 그런데 그 검증기를 부르는 빌드 코드가 없다.

$ grep -rn "GrpcProtoContractValidator" --include=*.gradle --include=*.kts --include=*.yml .
(매치 없음)
$ grep -rn "GrpcProtoContractValidator" --include=*.java src/ | grep -v grpc-proto-contract/
grpc-codegen/…/GrpcBufPolicy.java:10:   * … {@code GrpcProtoContractValidator} is what

Gradle 태스크도, 검증 훅도, 다른 모듈의 호출도 없다. 실제로 이 규칙 아홉 개를 실행하는 것은 GrpcProtoContractValidatorTest 하나이고, 그 테스트가 판정하는 대상은 §17.3 이 지적한 대로 하드코딩된 두 파일이다.

그래서 지금 성립하는 것과 성립하지 않는 것.

  • 성립: 이 리프에 커밋된 error.proto · stream.proto 는 매 빌드마다 아홉 규칙에 걸린다(테스트가 그것을 돌린다).
  • 성립하지 않음: "이 저장소의 빌드를 실패시킨다"는 범위. 리프 밖의 .proto 는 판정되지 않고(§12.2), 이 리프에 새로 추가되는 .proto 도 테스트 목록에 손으로 넣기 전까지 판정되지 않는다.

왜 P2 인가. 오작동이 아니라 주장과 배선의 불일치다. 그리고 그 주장이 CLI 부재를 정당화하는 논거로 쓰이고 있다 — "바이너리가 없을 때 조용히 통과하는 게이트보다 낫다"고 말하면서, 실제로 만든 것도 조용히 통과하는 게이트다. grpc-codegen §17.1 이 같은 형태를 반대편에서 기록했다(Buf 태스크 이름 넷이 어떤 빌드 파일에도 없다). 두 리프가 서로를 가리키며 상대가 게이트라고 말하는 모양이다.

수정. 두 가지 중 하나다.

  1. 배선한다 — check 에 물리는 Gradle 태스크가 proto/** 를 훑어 validate 를 돌리고 위반이 있으면 실패한다. §17.3 의 하드코딩도 함께 해소된다.
  2. 문장을 사실에 맞춘다 — "빌드를 실패시킨다"를 "이 리프의 테스트가 커밋된 스키마에 대해 실행한다"로 낮춘다. buf.yaml 과 GrpcBufPolicy 두 곳을 함께 고쳐야 한다.

낮추는 쪽을 고르더라도 §12.3 이 남는다 — 규칙 아홉 중 둘은 기본 매니페스트에서 판정할 것이 없다.

17.5 P3 — 열거형 안의 reserved 는 수집되지 않는다

scan 은 스코프 종류로 갈라진다.

if ("enum".equals(scopeKind)) {
  scanEnumValue(fileName, line, lineNumber, scopeName, violations);
} else if ("message".equals(scopeKind) || "oneof".equals(scopeKind)) {
  scanMessageMember(fileName, line, lineNumber, scopeName, scan, violations);
}

reserved 수집은 scanMessageMember 안에만 있다. proto3 는 열거형에도 reserved 2, 15;reserved "OLD_VALUE"; 를 허용하고, 열거형 값을 지울 때 번호를 예약하는 것은 필드와 같은 이유로 필요하다 — 예약하지 않고 재사용하면 옛 클라이언트가 보낸 정수가 다른 뜻으로 해석된다.

지금 SchemaHistory 에 열거형 이름으로 삭제 이력을 넣으면, 스키마가 정확히 예약했더라도 scan.reservedNumbers 에 그 이름이 없으므로 RESERVED_HISTORY 오탐이 난다. §17.1 의 범위 문법 문제와 같은 방향(fail-closed)이고 같은 자리에서 고칠 수 있다.

수정. reserved 수집을 스코프 종류와 무관하게 먼저 시도한 뒤 나머지 판정을 갈래로 보낸다.

확인된 설계(문제 아님)

  • 라인 스캐너라는 한계를 스스로 규정하고 그 경계 밖을 다른 리프로 넘긴 것.
  • 금지 대신 allowlist 를 고르고 그 이유를 적은 것 — 금지는 우회되고 allowlist 항목은 다음 사람이 읽는다.
  • 위반을 던지지 않고 목록으로 돌려주는 것 — 스키마 리뷰는 목록이다.
  • 삭제 이력을 입력으로 받는 것 — 사라진 필드는 없던 필드와 구분되지 않으므로 추론할 수 없다.
  • 커밋된 스키마 자체를 테스트가 검증기에 넣는 것 — 규칙이 자기 스키마에 실제로 적용된다.
  • buf 설정과 검증기가 같은 것을 말하는지 테스트가 붙드는 것.
  • 넓힌 매니페스트로 같은 소스를 다시 돌려 통과까지 확인하는 테스트 형태 — allowlist 라는 설계가 거부만이 아니라 허용도 실제로 하는지 붙든다.
  • buf.lock 을 빈 채로 커밋한 것과 그 근거 — "adding a first dependency is a visible diff in a file that already exists, instead of a new file nobody reviews."
  • buf.gen.yaml 에 판본 리터럴을 두지 않은 것 — 관리 플랫폼이 플러그인 판본을 소유한다는 GrpcCodegenManifest 의 규칙과 같은 결정이고, 테스트가 version: v1 부재로 그것을 붙든다.
  • 리치 오류 상세를 google.rpc.* 대신 자기 메시지로 소유한 것과 그 근거 — "the Stable contract is that a client branches on a code, a reason and a typed detail — never on a message string", 그리고 모양을 google.rpc 에 맞춰 두어 나중의 이전이 재설계가 아니라 이름 바꾸기가 되게 한 것.

Source anchors

src/grpc/grpc-proto-contract/build.gradle:1-12
main/java/…/contract/GrpcProtoContractValidator.java:1-447
main/java/…/contract/GrpcProtoStyleManifest.java:1-120
main/java/…/contract/GrpcProtoRuleViolation.java:1-38
main/resources/proto/hyeonworks/grpc/common/v1/error.proto:1-66
main/resources/proto/hyeonworks/grpc/common/v1/stream.proto:1-57
main/resources/proto/buf.yaml:1-17 · buf.gen.yaml:1-24 · buf.lock:1-12
grpc/grpc-codegen/…/GrpcBufPolicy.java:8-10 (게이트라고 주장하는 두 자리 중 하나)
test/java/…/contract/GrpcProtoContractValidatorTest.java:1-324