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

311 lines
19 KiB
Markdown

# 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. 모듈의 정체와 경계
```groovy
// 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 을 단언한다.
- `theBufConfigurationAgreesWithTheValidator``buf.yaml``FILE`·`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 아홉 규칙 중 둘은 이 저장소의 매니페스트에서 사실상 비활성이다.**
```java
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` 오탐이 된다
```java
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 가 아니다
```java
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 — 커밋 스키마 게이트가 파일 목록을 하드코딩한다
```java
List<String> files = List.of(
"proto/hyeonworks/grpc/common/v1/error.proto",
"proto/hyeonworks/grpc/common/v1/stream.proto");
```
리소스 디렉터리를 훑지 않는다. 이 리프에 세 번째 `.proto` 를 추가하면 이 테스트를 함께 고치기 전까지 판정되지 않고, 빌드는 초록으로 남는다.
같은 저장소가 다른 곳에서 이 형태를 이미 경계했다 — 빠뜨림이 통과가 되는 게이트다. 수정은 `proto/**` 아래 `.proto` 를 전부 열거해 돌리는 것이다.
### 기록 — `oneof` 도 스코프 이름을 밀어 넣는다 (현재 무해)
`SCOPE_OPEN``oneof` 를 스코프로 열고 이름을 점으로 한정한다. 그러면 `message Foo { oneof kind { … } }` 안의 필드는 `Foo.kind.<field>` 로 한정되고, `SchemaHistory` javadoc 이 말하는 키 규약(메시지 이름)과 어긋난다.
지금은 도달하지 않는다. protobuf 가 `oneof` 안에서 `map``optional` 을 모두 금지하므로 `MAP_ALLOWLIST`·`EXPLICIT_PRESENCE` 판정이 그 자리에서 발생하지 않고, `reserved``oneof` 안에 올 수 없다. 규칙을 넓힐 때 다시 볼 자리로 남긴다.
### 17.4 P2 — 두 파일이 이 검증기를 "빌드를 실패시키는 것" 이라고 단언하는데, 어떤 빌드도 그것을 부르지 않는다
같은 주장이 두 곳에 있다.
```java
// 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.
```
```yaml
# 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` 은 스코프 종류로 갈라진다.
```java
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
```