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

209 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 타입 안전 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 플랫폼도 같은 자리에 둔다.
```text
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.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_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 부재)를 반복하지 않는다. 계약상의 경로는
```text
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 출력에서 확인).
```text
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`.