Files
clean-architecture-backend-…/docs/superpowers/specs/2026-08-30-grpc-platform-adaptation-design.md
T

14 KiB
Raw Blame History

타입 안전 gRPC 실행 플랫폼 — 이 저장소 규약으로의 어댑팅 설계

두 계획 문서를 이 저장소(ca-skeleton)의 실제 구조·정책·빌드 게이트에 맞춰 실행 가능한 형태로 옮기는 설계다.

  • 원본 A: docs/2026-08-13-grpc-type-safe-rpc-platform-implementation-plan.md (Stable, Task 153)
  • 원본 B: docs/2026-08-13-grpc-advanced-capabilities-expansion-plan.md (Advanced, Task 118)

원본은 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 27 (없음, Java stdlib)
grpc-proto-contract grpc-proto-contract 8 core-api
grpc-codegen grpc-codegen 911 core-api, proto-contract
grpc-policy grpc-policy 12, 16, 17, 20, 2831, 33, 34, 3743 core-api
grpc-server grpc-server 1315, 18, 19 core-api, policy
grpc-client grpc-client 2427 core-api, policy
grpc-discovery grpc-discovery 35, 36 core-api, client
grpc-admin grpc-admin 2123, 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 4651, 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 A4A7
grpc-advanced-resilience grpc-hedging + grpc-custom-resolver + grpc-custom-load-balancer + grpc-xds A8A11
grpc-advanced-compat grpc-web + grpc-servlet-compat + grpc-integration-bridge + grpc-reactor + grpc-kotlin A12A16
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.gradleconfigure(subprojects) 블록). protoc가 만든 소스는 그 어느 것도 통과하지 못하므로, 실제 codegen을 켜려면 해당 소스셋에서 다섯 게이트를 모두 끄는 carve-out이 필요하다. 저장소에 선례는 있다(jmh 소스셋). 하지만 그 carve-out은 Task 10 하나를 위해 품질 게이트를 여는 결정이고, 그 결정은 이 작업 범위 밖의 승인 사항이다.

그래서 Task 811의 불변 조건은 전부 실행 가능한 형태로 구현한다: 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_SEENCOMMIT_CONFIRMED 자동 승격 금지
  • DEADLINE_EXCEEDED mutation = 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-grpcallowed_dependenciesgrpc-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 153) : present=250  missing=11
ADVANCED (Task 118) : 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.