# 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` | `..v` — 접두 일치 + `.*\.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.reservedNumbers…add(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 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.` 로 한정되고, `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 ```