# grpc-core-api 완전 해부 > 상태: COMPLETE > 재오픈 게이트: cycle 2 재통독(2026-09-01) — `src/main` production 32파일 1,897줄 + `src/test` 7파일 926줄 축자 통독 완료. `STRUCTURAL_ONLY` 는 `gradle.lockfile` 하나. > 기준 revision: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916` > 분석 범위: `src/grpc/grpc-core-api` > SSOT owner: `grpc-core-api` > integration/family document: `analysis/20-grpc-platform.md` (secondary, INTEGRATION_ONLY) --- ## 0. SSOT identity / 커버리지 - `allowed_dependencies`: **`[]`** — 이 저장소에서 의존성이 하나도 없는 두 리프 중 하나(다른 하나는 `messaging-core-api`) - `runtime_memberships`: **`[]`** — build-only ```groovy // build.gradle:3-9 // The platform's port layer: identifiers, method policy, execution evidence, failure model, // deadline primitives and request context. // // No dependencies at all, and that is the contract rather than an accident. The Stable plan's // Global Constraints make `grpc-core-api` framework-free so "evidence and policy do not know about // a transport" is verifiable instead of aspirational — the same rule `messaging-core-api` holds. // A type here may not name io.grpc, Spring, Netty, protobuf or a database. ``` | 패키지 | 파일 | 줄 | 성격 | |---|---:|---:|---| | `core` | 8 | 390 | 식별자·상태 코드·RPC 종류·Stable 모듈 목록과 불변식 | | `error` | 4 | 276 | 실패 문맥·범주·완료 결과·플랫폼 예외 | | `context` | 4 | 273 | 요청 문맥·메타데이터 키와 예산·클라이언트 신원 | | `evidence` | 4 | 239 | 전송·업무·스트림 세 축 | | `deadline` | 4 | 238 | 예산·프로파일·취소 토큰·마감 예외 | | `policy` | 4 | 295 | 메서드 정책과 목록, 멱등 프로파일, wait-for-ready | | `ledger` | 4 | 176 | 연산 원장 포트와 기록·신원·상태 | 가장 큰 파일 넷: `GrpcMethodPolicyCatalog` 124 · `GrpcMethodPolicy` 100 · `GrpcFailureContext` 99 · `GrpcRequestContext`·`GrpcExecutionEvidence` 87. main 총 **32파일 / 1,897줄**. ### Coverage ledger | scope | count | disposition | reason | |---|---:|---|---| | `main/java/**` | 32 | `FULL_READ` | 1,897줄. 위 표가 전부 | | `test/java/**` | 7 | `FULL_READ` | 926줄 | | `build.gradle` | 1 | `FULL_READ` | 10줄 전문 | | `gradle.lockfile` | 1 | `STRUCTURAL_ONLY` | 잠금 파일 — 생성물 | `UNCLASSIFIED` 0. > 이 표는 2026-09-01 재통독에서 파일 단위로 다시 세었다. 이전 판은 `main/java/**` 를 "전 파일", `test/java/**` 를 6(실제 7)으로 적었다. 그 미세한 오차가 §17.4–§17.6 이 표에 없던 이유다. `UNCLASSIFIED` 0. --- ## 1. 증거 세 축 `GrpcExecutionEvidence` 가 전송·업무·스트림을 함께 들고 절대 합치지 않는다. > "The same type is used by the failure model and by the observation convention. That is deliberate: > when the exception and the metric are built from different snapshots of what happened, the > incident review has two accounts of one call and no way to choose between them." 관측될 수 없는 조합을 생성자가 거부한다 — 단항이 스트림 증거를 들 수 없고, 보내지 않은 요청이 업무 증거를 들 수 없다. 승격 메서드가 하나뿐인 것도 의도다. > "This is the promotion the plan forbids, written as the one method that is allowed to observe > headers — so the forbidden edit is visible as a change to this method rather than as a plausible > line somewhere in an interceptor." 즉 응답 헤더를 봤다는 사실이 업무 축을 건드리지 못하게 하고, 그 규칙을 어기려면 이 메서드를 고쳐야 한다. ## 2. 완료 결과가 상태 코드와 분리된 이유 > "a mutation that times out is `DEADLINE_EXCEEDED` on the wire and `COMPLETION_UNKNOWN` in the > business, and a caller that reads the first as the second's answer either loses a committed write > or performs it twice." `forMutation` 의 판정 순서가 다섯 단계다. ``` 커밋 확인됨 → COMPLETED 부분 스트림 → PARTIAL_STREAM 상태 OK → COMPLETED 전송이 미시작을 증명 → REJECTED 그 밖 → 상태별 표 ``` 상태별 표에서 `DEADLINE_EXCEEDED`·`UNAVAILABLE`·`CANCELLED`·`UNKNOWN`·`INTERNAL`·`ALREADY_EXISTS`·`ABORTED`·`DATA_LOSS` 가 `COMPLETION_UNKNOWN` 이다. `ALREADY_EXISTS` 가 모호에 있는 것이 특히 정확하다 — 재시도가 그 답을 받으면 첫 시도가 성공했다는 뜻일 수 있다. ## 3. 메서드 정책 목록 가장 유용한 성질이 빌드를 깨는 쪽이다. > "when a descriptor method set is declared, registering a policy for a method the schema does not > have is an error. That catches the rename — the method becomes `CreateDocumentV2`, the policy still > names `CreateDocument`, and every call to the new method silently runs with default deadline, > default retry and no idempotency requirement." 그리고 정책 없는 메서드는 조회에서 던진다 — 정책 없는 호출은 마감도 멱등 프로파일도 없고, 그것을 서비스하려면 둘 다 지어내야 한다. ## 4. Stable 모듈 목록과 불변식 `GrpcStableModuleCatalog` 이 Stable 12 와 Advanced 6 을 상수로 든다. `GrpcStableBuildInvariant.advancedDependencyAllowed()` 가 인자를 받지 않는 이유가 적혀 있다. > "the answer does not vary by module, by capability or by environment. A method that could return > true for some input would be the seam through which 'just this one Advanced type in the starter' > arrives." 그리고 누출을 던지지 않고 집합으로 돌려주는 이유도 적혀 있다 — 첫 하나만 보고하는 게이트는 넷을 지우는 일을 네 번의 대화로 만든다. ## 10. 테스트 레인 여섯 테스트. 증거 조합 거부, 완료 결과 파생, 정책 목록의 서술자 대조와 중복 거부, 마감 예산, 메타데이터 예산, 식별자 경계, 모듈 목록을 확인한다. ## 12. negative-space probes **12.1 도달성.** 이 리프는 가족 전체의 포트 계층이므로 참조가 가장 많다. 다만 §17.3 의 타입은 예외다. **12.2 프레임워크 부재 확인.** `io.grpc`·Spring·Netty·protobuf·JDBC 를 이름으로 부르는 import 가 main 에 없다. build.gradle 의 의존 블록도 비어 있다. **12.3 실제로 쓰이는 게이트.** 이 가족의 다른 게이트들과 달리 `GrpcStableBuildInvariant.requireNoAdvancedDependency` 는 production 호출자가 둘 있다 — `grpc-spring-boot-starter` 의 시작 검증기와 `grpc-advanced-bootstrap` 의 모듈 가드. 불변식의 양쪽을 각각 다른 리프가 부른다. **12.4 드리프트.** build.gradle 이 서술한 일곱 패키지가 전부 존재하고, 파일 수는 `core` 8 · `policy` 4 · `evidence` 4 · `error` 4 · `deadline` 4 · `context` 4 · `ledger` 4 = 32 다. **12.5 검증만 되고 강제되지 않는 성분.** `GrpcMetadataBudget.maxTotalBytes`(§17.5). 같은 형태를 `grpc-policy` 에서도 찾았다 — `GrpcContextPropagationPolicy.clearAfterTask`(그 리프 §17.8). 두 자리 모두 compact constructor 의 가드가 유일한 소비자다. ## 16. 확인하지 못한 것 - 서술자 대조 경로를 실제 스키마로 돌려 보지 않았다(§17.1). 저장소에 컴파일된 서술자가 없다. - 상태 코드별 매핑을 실제 서버 응답으로 재현하지 않았다. 표와 근거 문장으로 판정했다. ## 17. 손볼 것 ### 17.1 P3 — 정책 목록의 가장 강한 성질을 이 저장소에서는 쓸 수 없다 `withDescriptorMethods` 를 부르는 곳은 이 리프의 테스트 두 줄뿐이다. ``` grpc-core-api/src/test/.../GrpcMethodPolicyCatalogTest.java:145 grpc-core-api/src/test/.../GrpcMethodPolicyCatalogTest.java:175 ``` `GrpcMethodPolicyCatalog.builder()` 를 부르는 곳은 저장소 전체에서 전부 테스트다. 그리고 그중 어느 것도 서술자 집합을 선언하지 않는다(위 두 줄 제외). 자바독이 그 상태를 미리 서술한다 — 서술자가 없으면 "the catalog is materially weaker … there is nothing to compare a policy's method name against." 그리고 서술자가 없는 이유는 옆 리프에 있다. `grpc-codegen` 이 서술자 산출물을 정의하지만 저장소에 protobuf 플러그인이 없어 `protoc` 이 돌지 않는다. 즉 이름 변경을 잡는 성질은 코드 생성 레인이 켜지기 전까지 성립할 수 없다. 기록하는 이유는 이것이 이 클래스가 존재하는 첫 번째 이유로 적혀 있기 때문이다. 수정은 코드 생성 레인이 생길 때 그 서술자를 목록 조립에 연결하는 것이고, 그때까지는 자바독이 그 조건을 명시하는 편이 낫다. ### 17.2 P3 — 모듈 목록 테스트가 레지스트리와 목록을 붙들지 않는다 클래스 javadoc 이 두 SSOT 의 관계를 적는다. > "This repository's module registry (`src/config/architecture/modules.json`) is the SSOT for which > Gradle projects exist; this catalog is the SSOT for which of them the Stable contract covers, and > **`GrpcStableModuleCatalogTest` holds the two together.**" 그 테스트는 레지스트리를 읽지 않는다. 다섯 테스트가 하는 일은 목록을 리터럴과 대조하고, 두 집합의 서로소를 확인하고, 누출 판정을 확인하는 것이다. ```java assertThat(catalog.modules()).containsExactlyInAnyOrder(…리터럴…); assertThat(GrpcStableModuleCatalog.advancedModules()).isNotEmpty().noneMatch(catalog::isStable); ``` `modules.json` 을 읽는 줄도, 파일 경로도 없다. 두 목록은 오늘 일치한다 — 레지스트리의 grpc 계열 리프가 18 개이고 목록이 12 + 6 이다. 어긋난 것은 그 일치를 무엇이 지키는가다. 같은 저장소가 이 형태를 messaging 가족에서 이미 기록했다 — 정확한 목록은 레지스트리가 소유하므로 산문에서 세지 않는다, 세는 순간 다시 표류한다. 수정은 테스트가 `modules.json` 을 읽어 grpc 계열 리프 집합과 두 상수 집합의 합집합을 대조하는 것이다. 그 테스트가 있으면 새 리프가 어느 쪽에도 들어가지 않은 채 추가되는 것을 잡는다. ### 17.3 P3 — `RESOURCE_EXHAUSTED` 매핑이 그 상태의 두 출처 중 하나만 가정한다 ```java case INVALID_ARGUMENT, UNAUTHENTICATED, PERMISSION_DENIED, NOT_FOUND, FAILED_PRECONDITION, OUT_OF_RANGE, UNIMPLEMENTED, RESOURCE_EXHAUSTED -> REJECTED; ``` 이 분기는 전송이 미시작을 증명하지 못한 뒤에 도달한다. 즉 "보냈는지 모르지만 이 상태 코드는 거절을 뜻한다" 는 판정이다. 목록의 나머지 일곱은 서버가 일을 시작하기 전에 답하는 상태다. `RESOURCE_EXHAUSTED` 는 두 출처를 갖는다. - 이 플랫폼 자신의 승인 제어기가 부하를 흘려보낼 때 — 일을 쓰기 전이므로 거절이 맞다. - 원격 서버가 작업 중 자원(할당량·디스크)을 소진했을 때 — 부분 커밋이 있을 수 있다. 이 클래스의 원칙은 보수적이다. 자바독이 두 기본값(`DEADLINE_EXCEEDED`·`UNAVAILABLE` 를 모호로)을 계획의 전역 제약이라 부르고, 그 이유는 "보냈는지 모르면 모호" 다. `RESOURCE_EXHAUSTED` 는 그 원칙에서 벗어난 유일한 항목이다. `ABORTED` 가 모호에 있는 것과 대비된다 — 트랜잭션 충돌은 서버가 일을 시작한 뒤의 상태이고, 그래서 모호다. 수정은 둘 중 하나다. `RESOURCE_EXHAUSTED` 를 모호로 옮기거나, 그 상태를 이 플랫폼이 발행한 것과 원격이 발행한 것으로 구분해 전자만 거절로 두는 것이다. 후자는 증거 축에 발신자 정보를 요구하므로 전자가 현실적이다. ### 17.4 P3 — 하나의 상태 코드가 같은 메서드 안에서 두 답을 갖는다 `forMutation` 은 스위치에 닿기 전에 `OK` 를 먼저 처리한다. ```java if (statusCode == GrpcStatusCode.OK) { return COMPLETED; } if (evidence.transport().provesNotStarted()) { return REJECTED; } return switch (statusCode) { … case OK, ALREADY_EXISTS, ABORTED, DATA_LOSS -> COMPLETION_UNKNOWN; // ← OK 가 여기에도 있다 }; ``` 스위치의 `OK` 분기는 도달하지 않는다. 열거형 전수 처리를 컴파일러가 요구하므로 항목 자체는 필요하지만, 그 값이 위의 가드와 반대다. 결과는 잠재적 함정이다. 누군가 위의 `OK` 가드를 "중복이니까" 지우면 컴파일은 통과하고 `OK` 인 변경이 `COMPLETION_UNKNOWN` 이 된다 — 성공한 변경마다 대사(reconciliation)를 요구하게 된다. 이 리프의 다른 자리들은 그런 편집이 눈에 띄도록 설계되어 있다(예: 승격 메서드를 하나로 좁힌 것). 수정은 한 글자다. 스위치의 `OK` 를 `COMPLETED` 로 옮기면 두 자리의 답이 같아지고, 가드가 사라져도 결과가 바뀌지 않는다. ### 17.5 P3 — 메타데이터 예산의 두 성분 중 하나는 강제되지 않고, 나머지 하나는 바이트가 아니라 문자를 센다 `GrpcMetadataBudget` 은 세 성분을 갖는다 — `maxTotalBytes`·`maxUserDefinedBytes`·`maxEntries`. `check(...)` 가 보는 것은 뒤의 둘뿐이다. ```java if (metadata.size() > maxEntries) { throw …; } int userDefinedBytes = 0; for (…) { userDefinedBytes += entry.getKey().name().length() + value.length(); } if (userDefinedBytes > maxUserDefinedBytes) { throw …; } // maxTotalBytes 는 여기서 쓰이지 않는다 ``` **첫째, `maxTotalBytes` 는 읽히지 않는다.** 저장소 전체에서 이 접근자를 부르는 곳은 compact constructor 의 순서 가드와 테스트 단언 하나뿐이다. 자바독은 그 이유를 설명한다 — 하드 총계를 넘기는 것은 프레임워크가 던지는 전송 거절이고, 여기서 함께 검사하면 "고칠 수 있는 쪽" 과 "고칠 수 없는 쪽" 이 한 자리에서 발견된다는 것. 판단은 옳다. 다만 그 결과로 이 record 는 자기가 쓰지 않는 수를 성분으로 들고 있고, 이름은 그것이 강제된다고 읽힌다. **둘째, 단위가 어긋난다.** 성분 이름은 `...Bytes` 인데 세는 것은 `String.length()`, 즉 UTF-16 코드 단위다. 키는 `[a-z0-9._-]` 로 제한되어 ASCII 지만 값에는 문자 집합 제약이 없다. 다중 바이트 문자를 담은 값은 실제 프레임보다 적게 계산된다. gRPC 의 ASCII 메타데이터 값은 프로토콜 상 인쇄 가능 ASCII 여야 하므로 실무에서는 대개 일치한다. 다만 그 제약을 이 클래스가 검사하지 않으므로, 일치는 보장이 아니라 관행이다. 수정은 둘 다 작다 — `value.getBytes(StandardCharsets.US_ASCII).length` 로 세거나 값의 문자 집합을 `GrpcMetadataKey.Kind.ASCII` 에 맞춰 검증하고, `maxTotalBytes` 는 성분에서 빼고 javadoc 의 서술로 남긴다. ### 17.6 P3 — 직렬화 가능하다고 선언한 예외가 자기 내용을 직렬화하지 않는다 ```java public class GrpcPlatformException extends RuntimeException { private static final long serialVersionUID = 1L; private final transient GrpcFailureContext context; // ← transient … public boolean requiresReconciliation() { return context.completionOutcome().requiresReconciliation(); } } ``` `serialVersionUID` 는 이 타입이 직렬화된다는 선언이고, `transient` 는 유일한 필드가 그 직렬화에서 빠진다는 선언이다. 둘이 함께 있으면 역직렬화된 예외는 `context == null` 이고, 공개 메서드 둘 중 하나(`requiresReconciliation()`)가 NPE 를 던진다. `transient` 자체는 강제된 선택이다 — `GrpcFailureContext` 가 `Serializable` 을 구현하지 않으므로 필드를 남기면 예외가 직렬화되지 않는다. 기록하는 이유는 이 리프의 서술 규율과 대비되기 때문이다. 다른 자리에서는 부재마다 이유가 붙어 있다("There is no factory that takes raw metadata, and that absence is the design"). 여기에는 `transient` 의 이유도, 역직렬화 뒤의 계약도 적혀 있지 않다. 도달성은 낮다. gRPC 예외가 자바 직렬화를 지나는 경로는 이 저장소에 없다. 수정은 셋 중 하나다 — `GrpcFailureContext` 와 그 구성 요소를 `Serializable` 로 만들거나, `serialVersionUID` 를 지워 직렬화를 지원하지 않음을 명시하거나, `context()` 와 `requiresReconciliation()` 이 null 문맥을 다루도록 하고 그 이유를 적는 것. ### 확인된 설계(문제 아님) - **의존성 0 을 계약으로 삼고 그 이유를 적은 것** — "evidence and policy do not know about a transport" 가 검증 가능해진다. - **증거 세 축을 한 타입에 두고 관측 불가 조합을 생성자가 거부한 것.** - **승격 메서드를 하나로 좁혀 금지된 편집이 그 메서드의 변경으로 보이게 한 것.** - **완료 결과를 상태 코드와 분리한 것과 그 예시.** - **`ALREADY_EXISTS`·`ABORTED` 를 모호로 둔 것.** - **정책 없는 메서드를 조회에서 던지는 것.** - **서술자 대조를 선택 사항으로 두되 그 부재의 대가를 자바독에 적은 것.** - **`advancedDependencyAllowed()` 가 인자를 받지 않는 것과 그 근거.** - **누출을 집합으로 돌려주는 것.** --- ## Source anchors ``` src/grpc/grpc-core-api/build.gradle:1-10 main/java/…/policy/GrpcMethodPolicyCatalog.java:1-124 (§17.1 withDescriptorMethods:80-87) main/java/…/policy/GrpcMethodPolicy.java:1-100 main/java/…/error/GrpcFailureContext.java:1-99 main/java/…/context/GrpcRequestContext.java:1-87 main/java/…/evidence/GrpcExecutionEvidence.java:1-87 main/java/…/deadline/GrpcDeadlineBudget.java:1-85 main/java/…/evidence/GrpcStreamEvidence.java:1-80 main/java/…/core/GrpcStableModuleCatalog.java:1-79 (§17.2) main/java/…/error/GrpcCompletionOutcome.java:1-69 (§17.3 · §17.4 forMutation:360-390) main/java/…/deadline/GrpcCancellationToken.java:1-68 main/java/…/context/GrpcMetadataBudget.java:1-66 (§17.5 check:130-154) main/java/…/context/GrpcMetadataKey.java:1-65 main/java/…/error/GrpcFailureCategory.java:1-64 main/java/…/deadline/GrpcDeadlineProfile.java:1-59 main/java/…/ledger/GrpcOperationLedgerRecord.java:1-59 main/java/…/context/GrpcClientIdentity.java:1-55 main/java/…/core/GrpcMethodName.java:1-55 main/java/…/core/{GrpcStableBuildInvariant:1-53, RpcType:1-53, GrpcStatusCode:1-52, GrpcIdentifiers:1-47, GrpcServiceName:1-37, GrpcChannelProfileName:1-24} main/java/…/policy/{RpcIdempotencyProfile:1-49, WaitForReadyPolicy:1-22} main/java/…/ledger/{GrpcOperationLedger:1-50, GrpcOperationIdentity:1-39, GrpcOperationLedgerState:1-28} main/java/…/error/GrpcPlatformException.java:1-44 (§17.6) main/java/…/evidence/{GrpcTransportEvidence:1-41, GrpcBusinessEvidence:1-31} main/java/…/deadline/GrpcDeadlineExceededException.java:1-26 test/java/…/ 7파일 926줄 (GrpcMethodPolicyCatalogTest:183 · GrpcFailureContextTest:182 · GrpcMetadataBudgetTest:174 · GrpcDeadlineBudgetTest:124 · GrpcExecutionEvidenceTest:120 · GrpcCoreIdentifiersTest:83 · GrpcStableModuleCatalogTest:60) src/config/architecture/modules.json (§17.2 — 테스트가 읽지 않는 SSOT) grpc-spring-boot-starter/…/GrpcPlatformStartupValidator.java:176 (GrpcStableBuildInvariant 실사용) grpc-advanced/grpc-advanced-bootstrap/…/GrpcAdvancedModuleGuard.java:76 (같은 불변식의 반대편) ```