14 KiB
타입 안전 gRPC 실행 플랫폼 — 이 저장소 규약으로의 어댑팅 설계
두 계획 문서를 이 저장소(ca-skeleton)의 실제 구조·정책·빌드 게이트에 맞춰 실행 가능한 형태로
옮기는 설계다.
- 원본 A:
docs/2026-08-13-grpc-type-safe-rpc-platform-implementation-plan.md(Stable, Task 1–53) - 원본 B:
docs/2026-08-13-grpc-advanced-capabilities-expansion-plan.md(Advanced, Task 1–18)
원본은 modules/grpc/** · Gradle Kotlin DSL · 패키지 io.backend.skeleton.grpc · Spring Boot 4.1을
전제한다. 이 저장소는 src/** · Groovy DSL · dev.caskeleton · Spring Boot 4.0.8 · fail-closed
module registry를 쓴다. 원본 Global Constraint의 마지막 항목이 이 어댑팅을 명시적으로 허용한다:
"실제 저장소 구조가 예상 경로와 다르면 파일 경로만 매핑하고 공개 계약·불변 조건·테스트 의미는
변경하지 않는다."
1. 패밀리 위치 — 왜 adapter:inbound:grpc 확장이 아닌가
원본이 기술하는 것은 인바운드 어댑터 하나가 아니라 자기 API·SPI·adapter·조립 경계를 가진 벤더드
RPC 플랫폼이다. 이 저장소에는 그 형태의 선례가 이미 있다 — messaging:*. root CLAUDE.md가
직접 그렇게 규정한다: "vendored messaging platform: a product with its own API, SPI, adapters and
composition boundary, not a layer of this application".
따라서 gRPC 플랫폼도 같은 자리에 둔다.
src/grpc/ → :grpc:* Stable 플랫폼 (12 leaf)
src/grpc-advanced/ → :grpc-advanced:* Advanced/Experimental (6 leaf)
src/adapter/inbound/grpc 기존 인바운드 전송 어댑터 (그대로 유지)
grpc-advanced를 별도 디렉터리·별도 Gradle prefix로 分離하는 이유는 원본 Stable Task 1의 불변
조건 "Stable starter가 modules/grpc-advanced를 참조하면 build를 실패시킨다"를 registry의
allowed_dependencies만으로 기계 검증할 수 있게 만들기 위해서다. verifyCleanArchitectureDependencies가
그 게이트다.
패키지 루트는 dev.caskeleton.grpc / dev.caskeleton.grpc.advanced다.
2. Leaf 매핑 — 원본 31개 모듈 → 이 저장소 18개 leaf
원본 모듈 경계 중 이 저장소가 이미 다른 메커니즘으로 표현하는 것만 합친다. 능력(capability)은 하나도 버리지 않는다.
Stable — src/grpc/
| leaf | 원본 모듈 | 담는 Task | 의존 |
|---|---|---|---|
grpc-core-api |
grpc-core-api | 2–7 | (없음, Java stdlib) |
grpc-proto-contract |
grpc-proto-contract | 8 | core-api |
grpc-codegen |
grpc-codegen | 9–11 | core-api, proto-contract |
grpc-policy |
grpc-policy | 12, 16, 17, 20, 28–31, 33, 34, 37–43 | core-api |
grpc-server |
grpc-server | 13–15, 18, 19 | core-api, policy |
grpc-client |
grpc-client | 24–27 | core-api, policy |
grpc-discovery |
grpc-discovery | 35, 36 | core-api, client |
grpc-admin |
grpc-admin | 21–23, 45 | core-api, server |
grpc-observability |
grpc-observability | 44 | core-api |
grpc-operation-ledger-jpa |
grpc-operation-ledger-jpa | 32 | core-api |
grpc-spring-boot-starter |
grpc-spring-boot-starter | 52 | 위 전부 |
grpc-testkit |
testkit-core + -inprocess + -netty + -fault | 46–51, 53 | 위 전부 |
testkit 4개를 1개로 합친 근거. 원본이 testkit을 넷으로 쪼갠 목적은 "in-process 증거를 실제
네트워크 증거로 오인하지 않게 한다"이다. 이 저장소는 그 목적을 모듈 경계가 아니라 strict test
lane 컨벤션(ca.strict-test-lane)으로 이미 표현한다 — lane은 태그/소스셋/명시 테스트 중 하나로만
선택하고, 아무것도 실행하지 않으면 실패하며, up-to-date 결과를 제공하지 않는다. 그래서 네 모듈은
grpc-testkit 한 leaf 안의 네 lane이 된다:
grpcInProcessContractTest, grpcNettyContractTest, grpcFaultTest, grpcPerformanceTest.
원본의 "in-process는 HTTP/2·TLS 증거가 아니다"는 lane 분리와 GrpcEvidenceGrade로 강제한다.
Advanced — src/grpc-advanced/
| leaf | 원본 모듈 | 담는 Task |
|---|---|---|
grpc-advanced-bootstrap |
grpc-advanced-bootstrap | A1, A18 |
grpc-advanced-edition |
grpc-edition-2024 + grpc-edition-2026-experimental | A2, A3 |
grpc-advanced-streaming |
grpc-client-streaming + grpc-bidi-streaming + grpc-manual-flow-control | A4–A7 |
grpc-advanced-resilience |
grpc-hedging + grpc-custom-resolver + grpc-custom-load-balancer + grpc-xds | A8–A11 |
grpc-advanced-compat |
grpc-web + grpc-servlet-compat + grpc-integration-bridge + grpc-reactor + grpc-kotlin | A12–A16 |
grpc-advanced-diagnostics |
grpc-channel-diagnostics | A17 |
Advanced 모듈은 capability마다 leaf를 나누는 대신 capability grade와 feature flag
(GrpcAdvancedCapability / GrpcAdvancedFeatureFlags)로 분리한다. A18의 요구사항
"Edition, streaming, xDS, gRPC-Web, Servlet, language adapter가 서로의 승격을 묶지 않는다"는
leaf 경계가 아니라 capability별 독립 promotion evidence로 표현되므로, 합쳐도 그 불변 조건은 유지된다.
3. 원본과 달라지는 지점 (deviation)과 근거
| # | 원본 | 이 저장소 | 근거 |
|---|---|---|---|
| D1 | Spring Boot 4.1 BOM | Spring Boot 4.0.8 BOM | 저장소 실제 baseline(src/build.gradle:13). BOM이 SSOT라는 계약 자체는 유지 |
| D2 | Boot-managed Spring gRPC starter | self-managed io.grpc (ext.grpcVersion) |
기존 기록된 결정(adapter/inbound/grpc/README.md "왜 self-managed Netty 인가"). starter 커플링 회피 |
| D3 | Gradle Kotlin DSL, settings.gradle.kts |
Groovy DSL + config/architecture/modules.json |
registry가 leaf 목록의 SSOT이고 settings는 그것을 읽기만 한다 |
| D4 | io.backend.skeleton.grpc |
dev.caskeleton.grpc |
저장소 기본 패키지 |
| D5 | Buf CLI (bufLint/bufBreaking 등) |
저장소 소유 규칙 엔진 + Gradle verify task | Buf CLI 바이너리가 이 환경에 없다. lint/format/breaking 규칙을 Java로 구현해 동일 판정을 내리고, CLI는 동일 규칙을 재확인하는 선택 경로로 남긴다 |
| D6 | protoc/grpc-java codegen을 빌드에서 실행 | .proto 소스 + codegen 정책·descriptor 계약만 실행, protoc 실행은 명시적 확장점 |
아래 별도 절 |
| D7 | grpc-kotlin의 .kt 소스 |
Java 쪽 coroutine/Flow 경계 계약만 | 저장소에 Kotlin 플러그인·소스셋이 없다. A16의 5개 요구사항 중 4개(스키마 단일 소스, cancellation 전파 계약, backpressure 우회 금지, evidence 타입 보존)는 Java 계약으로 표현 가능하고, Kotlin 툴체인 lane은 GrpcKotlinCompatibilityGate가 미충족으로 fail-closed 판정한다 |
| D8 | 새 leaf가 곧 런타임 | 신규 leaf 전부 runtime_memberships: [] (build-only) |
messaging:*가 처음 착지한 방식과 동일. 런타임 투입은 registry membership 변경 + verifyRuntimeModuleMembership 통과가 선행 조건이며, 그것은 별도 결정이다 |
D6 — protoc 실행을 지금 켜지 않는 이유
이 저장소의 모든 leaf는 예외 없이 spotless(google-java-format) · checkstyle · spotbugs(HIGH) ·
errorprone · -Werror를 통과해야 한다(src/build.gradle의 configure(subprojects) 블록). protoc가
만든 소스는 그 어느 것도 통과하지 못하므로, 실제 codegen을 켜려면 해당 소스셋에서 다섯 게이트를
모두 끄는 carve-out이 필요하다. 저장소에 선례는 있다(jmh 소스셋). 하지만 그 carve-out은 Task
10 하나를 위해 품질 게이트를 여는 결정이고, 그 결정은 이 작업 범위 밖의 승인 사항이다.
그래서 Task 8–11의 불변 조건은 전부 실행 가능한 형태로 구현한다:
proto style 규칙 검증, 삭제 필드 reserved 이력 대조, Buf breaking category(FILE) 판정,
generated package와 hand-written package 겹침 금지, descriptor+schema hash 릴리스 아티팩트,
consumer fixture 실패 시 릴리스 차단. 빠지는 것은 protoc 프로세스 호출 하나이고,
GrpcCodegenManifest가 그 지점을 단일 owner로 고정한 채 비워둔다.
4. 유지되는 원본 불변 조건 (변경 없음)
- 실행 증거 3축(Transport/Business/Stream) 분리,
RESPONSE_HEADERS_SEEN→COMMIT_CONFIRMED자동 승격 금지 DEADLINE_EXCEEDEDmutation =COMPLETION_UNKNOWN후보,UNAVAILABLE만으로 상태 변경 RPC 재호출 금지NON_IDEMPOTENT에 explicit retry·hedging 금지, explicit retry owner는 하나- Stable Unary는 positive deadline 필수
- Server Streaming: bounded queue + single serialized writer, partial delivery 후 whole-call retry 금지
- Stable RPC 유형은 Unary·Server Streaming, Client/Bidi는 Advanced
- production reflection 기본 비활성, dev·stage·prod TLS 필수, trust-all 금지
- Stable resolver = Static·DNS, Stable LB = pick_first·round_robin
- metric tag에 raw metadata/payload/actor/tenant/object/stream/idempotency ID 금지
- Netty가 Stable certification transport, in-process는 network/TLS 증거가 아님
5. 애플리케이션이 이 패밀리에 도달하는 경로
messaging:*의 MSG-015(bridge 부재)를 반복하지 않는다. 계약상의 경로는
application-owned port → adapter:inbound:grpc (typed service adapter) → :grpc:grpc-server SPI
이고, Stable Task 13/14가 그 경계를 소유한다(GrpcApplicationBoundaryRules,
GrpcRawApiImportRule, GrpcServiceAdapter). 플랫폼이 green이 된 뒤 마지막 단계에서
adapter-inbound-grpc의 allowed_dependencies에 grpc-core-api·grpc-server를 추가하고
브리지를 배선한다. 그 전까지 플랫폼은 self-contained build-only다.
6. 원본 파일 목록 대비 대조 (2026-08-31 감사)
두 계획이 Files: 절에 명시한 산출물 전체를 기계적으로 대조했다. 감사 스크립트는 각 Task의 파일
basename이 저장소에 존재하는지 확인한다(build 산출물 제외, 단 annotation processor가 생성하는
spring-configuration-metadata.json은 build 출력에서 확인).
STABLE (Task 1–53) : present=250 missing=11
ADVANCED (Task 1–18) : present= 91 missing= 2
TOTAL : present=341 missing=13
잔여 13건은 전부 아래 편차로 설명된다. 행동이 빠진 것은 없다.
6.1 ADR 파일명 (6건) — 저장소 명명 규약
계획은 ADR-060 ~ ADR-065 연번을 쓴다. 이 저장소의 docs/adr/는 접두사 규약을 쓴다
(ADR-WS-001, ADR-MONGO-004, ADR-WEB-ADV-003). 내용은 1:1이다.
| 계획 | 이 저장소 |
|---|---|
| ADR-060 platform-boundary | ADR-GRPC-001-platform-family-and-registry-shape.md |
| ADR-061 execution-evidence | ADR-GRPC-003-three-axis-execution-evidence.md |
| ADR-062 retry-idempotency | ADR-GRPC-004-retry-ownership-and-durable-idempotency.md |
| ADR-063 streaming-resume | ADR-GRPC-005-server-streaming-single-writer-and-resume.md |
| ADR-064 discovery-kubernetes | ADR-GRPC-006-stable-discovery-and-kubernetes-routing.md |
| ADR-065 advanced-promotion | ADR-GRPC-ADV-001-capability-promotion-is-per-capability.md |
계획에 없던 ADR-GRPC-002-schema-governance-without-protoc.md가 추가로 있다 — D6 결정을 기록한다.
6.2 codegen convention plugin 3건 — D6
io.backend.grpc-buf-conventions.gradle.kts, io.backend.grpc-codegen-conventions.gradle.kts,
그 .properties. protoc를 실행하지 않으므로 실행할 convention plugin이 없다. 이들이 고정했을 결정은
GrpcCodegenManifest·GrpcCodegenOutput·GrpcGeneratedPackagePolicy가 Java로 강제하고,
buf.yaml·buf.gen.yaml·buf.lock이 CLI가 있는 환경에서 같은 판정을 내리도록 커밋되어 있다.
6.3 TLS 인증서 3건 — 런타임 생성으로 대체
ca.crt, server.crt, client.crt. 커밋된 인증서는 만료되고, 커밋된 개인키는 개인키다.
GrpcTlsTestMaterial이 JDK keytool로 fixture마다 PKCS12를 생성하고 close()가 지운다. Netty의
SelfSignedCertificate는 JDK 21에서 sun.security.x509 미export로 실패하므로 쓰지 않았다.
6.4 Kotlin 소스 1건 — D7
GrpcCoroutineAdapter.kt. 저장소에 Kotlin 툴체인이 없다. 계약 요구 4건은 Java로 검증하고
(GrpcKotlinProfile, GrpcCoroutineContextBridge), compile lane은
GrpcKotlinCompatibilityGate.supportableHere()가 false로 fail-closed다.
6.5 2026-08-31 감사에서 실제로 메꾼 것 (6건)
감사가 아니었으면 남았을 것들이다.
| 항목 | 조치 |
|---|---|
GrpcKubernetesProfileValidator |
이름 있는 타입으로 분리. VIP+장기스트림, drain grace < reconnect budget 두 규칙 추가 |
| ADR-064 상당 (discovery/Kubernetes) | ADR-GRPC-006 작성 |
xDS bootstrap.json |
테스트 리소스로 추가 + GrpcXdsStartupGuard.bootstrapMismatches가 실제로 대조 (namespace 불일치·비TLS control plane 검출) |
control-plane-snapshot.json |
테스트 리소스로 추가 + redactor가 실제 모양의 데이터로 검증됨 |
DocumentClientFixture.java + fixture build.gradle.kts |
추가. GrpcConsumerFixture.fromJavaSource가 fixture 소스에서 service·method·package 요구사항을 역산하므로 손으로 적은 목록이 아니다 |
buf.gen.yaml, buf.lock |
추가 + proto contract 테스트가 내용을 검증 |
6.6 테스트 클래스 17건 — 감사 후 계획대로 분리
감사 시점에 leaf별로 통합돼 있던 테스트를 계획이 명시한 이름으로 분리했다. 분리 전에도 모든 타입이 실제로 검증되고 있었으나(통합 클래스가 해당 타입을 참조), 계획과의 추적성을 위해 나눴다:
GrpcNettyVariantSelectorTest, GrpcReflectionPolicyTest, GrpcClientMetadataPolicyTest,
GrpcKubernetesProfileTest, GrpcEdition2026GuardTest, GrpcClientStreamPolicyTest,
GrpcClientMessageDeduplicatorTest, GrpcBidiSequenceTrackerTest, GrpcDemandControllerTest,
GrpcHedgingEligibilityTest, GrpcResolverSafetyPolicyTest, GrpcLoadBalancerSafetyPolicyTest,
GrpcXdsStartupGuardTest, GrpcWebCompatibilityGateTest, GrpcServletStartupValidatorTest,
GrpcIntegrationBridgePolicyTest, GrpcReactorContextBridgeTest, GrpcKotlinCompatibilityGateTest.