Files
document-haness/docs/clean-architecture-backend-template/analysis/grpc/grpc-core-api.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-core-api 완전 해부

상태: COMPLETE 재오픈 게이트: cycle 2 재통독(2026-09-01) — src/main production 32파일 1,897줄 + src/test 7파일 926줄 축자 통독 완료. STRUCTURAL_ONLYgradle.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
// 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_LOSSCOMPLETION_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."

그 테스트는 레지스트리를 읽지 않는다. 다섯 테스트가 하는 일은 목록을 리터럴과 대조하고, 두 집합의 서로소를 확인하고, 누출 판정을 확인하는 것이다.

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 매핑이 그 상태의 두 출처 중 하나만 가정한다

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 를 먼저 처리한다.

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)를 요구하게 된다. 이 리프의 다른 자리들은 그런 편집이 눈에 띄도록 설계되어 있다(예: 승격 메서드를 하나로 좁힌 것).

수정은 한 글자다. 스위치의 OKCOMPLETED 로 옮기면 두 자리의 답이 같아지고, 가드가 사라져도 결과가 바뀌지 않는다.

17.5 P3 — 메타데이터 예산의 두 성분 중 하나는 강제되지 않고, 나머지 하나는 바이트가 아니라 문자를 센다

GrpcMetadataBudget 은 세 성분을 갖는다 — maxTotalBytes·maxUserDefinedBytes·maxEntries.

check(...) 가 보는 것은 뒤의 둘뿐이다.

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 — 직렬화 가능하다고 선언한 예외가 자기 내용을 직렬화하지 않는다

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 자체는 강제된 선택이다 — GrpcFailureContextSerializable 을 구현하지 않으므로 필드를 남기면 예외가 직렬화되지 않는다.

기록하는 이유는 이 리프의 서술 규율과 대비되기 때문이다. 다른 자리에서는 부재마다 이유가 붙어 있다("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 (같은 불변식의 반대편)