From 2a8d34f85c1829093ccb53eeee7d950dedd5af5b Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Wed, 16 Sep 2026 16:52:37 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20=EB=B9=8C=EB=93=9C=C2=B7CI=20=EA=B0=90?= =?UTF-8?q?=EC=82=AC=20=EA=B3=84=ED=9A=8D=EA=B3=BC=20=EC=A7=84=ED=96=89=20?= =?UTF-8?q?=EC=A4=91=EC=9D=B4=EB=8D=98=20=EB=AC=B8=EC=84=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CI 단계 분리 계획 추가 (docs/superpowers/plans/2026-09-16-ci-stage-separation.md). 빌드·CI 레이어 전수 리뷰 133건의 결론과 Track A/B/C 작업 순서를 담는다. - public-path 보안 기준선을 실제 배포 기본값(/v1/healthcheck)으로 재생성. 이전 값은 gitignore 된 src/.env 에서 유래해 재현이 불가능했다. - 진행 중이던 ADR·리뷰·테스트 전략 문서 반영, 대체된 grpc 계획 문서 제거. Co-Authored-By: Claude Opus 5 (1M context) --- ...pc-advanced-capabilities-expansion-plan.md | 1737 ------ ...e-safe-rpc-platform-implementation-plan.md | 5174 ----------------- ...001-one-convention-for-shared-test-code.md | 119 + ...-09-07-app-bootstrap-module-code-review.md | 1231 ++++ docs/security/public-paths-snapshot.txt | 6 +- docs/study/graphql-walkthrough.ko.md | 3657 ++++++++++++ .../plans/2026-07-25-module-gradle-hygiene.md | 2 +- ...-28-messaging-first-r2-polling-producer.md | 12 +- ...client-platform-activation-and-env-ssot.md | 2 +- .../plans/2026-08-15-wave0-red-baseline.md | 2 +- .../plans/2026-08-15-wave1-activation-ssot.md | 14 +- .../plans/2026-09-16-ci-stage-separation.md | 108 + .../evidence/2026-08-15-wave0-baseline.md | 2 +- docs/testing/TESTING_STRATEGY.md | 177 + 14 files changed, 5313 insertions(+), 6930 deletions(-) delete mode 100644 docs/2026-08-13-grpc-advanced-capabilities-expansion-plan.md delete mode 100644 docs/2026-08-13-grpc-type-safe-rpc-platform-implementation-plan.md create mode 100644 docs/adr/ADR-BUILD-001-one-convention-for-shared-test-code.md create mode 100644 docs/reviews/2026-09-07-app-bootstrap-module-code-review.md create mode 100644 docs/study/graphql-walkthrough.ko.md create mode 100644 docs/superpowers/plans/2026-09-16-ci-stage-separation.md create mode 100644 docs/testing/TESTING_STRATEGY.md diff --git a/docs/2026-08-13-grpc-advanced-capabilities-expansion-plan.md b/docs/2026-08-13-grpc-advanced-capabilities-expansion-plan.md deleted file mode 100644 index dfc90229..00000000 --- a/docs/2026-08-13-grpc-advanced-capabilities-expansion-plan.md +++ /dev/null @@ -1,1737 +0,0 @@ -# 타입 안전 gRPC Advanced Capability Expansion Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Stable Unary·Server Streaming 플랫폼을 변경하지 않고 Edition 2024/2026, Client/Bidirectional Streaming, Manual Flow Control, Hedging, Custom Resolver·Load Balancer, xDS, gRPC-Web, Servlet, Spring Integration, Reactor·Kotlin과 Channel Diagnostics를 독립적으로 검증·승격할 수 있는 Advanced capability 계층을 구축한다. - -**Architecture:** 모든 Advanced 기능은 `modules/grpc-advanced`에 격리하고 명시적 feature flag를 요구한다. Stable method policy, deadline, execution evidence, security, status, metadata, observability와 streaming guardrail을 그대로 소비하며 raw gRPC escape hatch를 제공하지 않는다. 각 capability는 자체 Compatibility·Security·Fault·Performance·Soak gate를 통과한 뒤에만 Advanced Stable로 승격된다. - -**Tech Stack:** Java 21, Spring Boot 4.1 BOM, Boot-managed gRPC Java/Protobuf, Protobuf Editions, grpc-xds, grpc-web proxy/Envoy, Servlet HTTP/2, Spring Integration 7.1, Project Reactor, Kotlin coroutines/Flow, Channelz/CSDS, JUnit 5, AssertJ, Testcontainers, Toxiproxy. - -## Global Constraints - -- Stable Task 1–53과 Stable Release Gate가 먼저 완료돼야 한다. -- Advanced module root는 `modules/grpc-advanced`이다. -- Root package는 `io.backend.skeleton.grpc.advanced`이다. -- 모든 capability는 `backend.grpc.advanced.*` 아래의 명시적 feature flag를 요구한다. -- Stable starter는 Advanced module에 compile/runtime dependency를 갖지 않는다. -- Edition 2024는 opt-in이고 Edition 2026은 Watch/Experimental이다. -- Client Streaming과 Bidirectional Streaming은 session·sequence·dedup·resume 계약 없이 활성화하지 않는다. -- Manual Flow Control이 raw StreamObserver를 application에 직접 반환해서는 안 된다. -- Hedging은 READ_ONLY Unary만 허용한다. -- Custom Resolver와 Load Balancer는 Stable channel security·retry owner·observability를 우회하지 않는다. -- xDS는 proxyless GR3 Experimental로 유지한다. -- gRPC-Web은 Unary와 Server Streaming만 지원한다. -- Client Streaming·Bidirectional Streaming의 gRPC-Web 지원을 선언하지 않는다. -- Servlet transport는 Netty certification을 대체하지 않는다. -- Spring Integration bridge는 Generated Stub·Service 타입 안전 계약을 대체하지 않는다. -- Reactor와 Kotlin adapter는 Stable Core 타입과 execution evidence를 유지한다. -- Channelz·CSDS는 관리자 전용이며 credential·metadata·payload를 노출하지 않는다. -- 각 capability는 독립 승격이 가능해야 한다. -- 모든 task는 red-green TDD와 독립 commit으로 끝난다. - ---- - -## Execution Baseline - -```text -Stable Task 1–53 -→ Advanced Task 1–18 -``` - -## Advanced Module Map - -```text -modules/grpc-advanced/ -├── grpc-advanced-bootstrap -├── grpc-edition-2024 -├── grpc-edition-2026-experimental -├── grpc-client-streaming -├── grpc-bidi-streaming -├── grpc-manual-flow-control -├── grpc-hedging -├── grpc-custom-resolver -├── grpc-custom-load-balancer -├── grpc-xds -├── grpc-web -├── grpc-servlet-compat -├── grpc-integration-bridge -├── grpc-reactor -├── grpc-kotlin -└── grpc-channel-diagnostics -``` - -## Capability Classification - -| Capability | Initial grade | Stable default | -|---|---|---:| -| Edition 2024 | Advanced opt-in | No | -| Edition 2026 | Watch/Experimental | No | -| Client Streaming | Advanced | No | -| Bidirectional Streaming | Advanced | No | -| Manual Flow Control | Advanced | No | -| Hedging | Experimental | No | -| Custom Resolver | Advanced | No | -| Custom Load Balancer | Experimental | No | -| xDS | Experimental | No | -| gRPC-Web | Advanced compatibility | No | -| Servlet HTTP/2 | Compatibility | No | -| Spring Integration bridge | Optional | No | -| Reactor adapter | Optional Advanced | No | -| Kotlin adapter | Optional Advanced | No | -| Channelz/CSDS diagnostics | Admin Advanced | No | - -## Delivery Phases - -| Phase | Tasks | Result | -|---|---:|---| -| Boundary·Edition | 1–3 | Feature isolation and schema evaluation lanes | -| Streaming | 4–7 | Client/Bidi session, checkpoint and manual flow control | -| Resilience·Discovery | 8–11 | Hedging, custom resolver/LB and xDS | -| Compatibility Bridges | 12–16 | gRPC-Web, Servlet, Integration, Reactor, Kotlin | -| Diagnostics·Promotion | 17–18 | Admin diagnostics, infrastructure testkit and promotion gate | - ---- -### Task 1: Advanced Module Boundary와 Feature Flag - -**Files:** -- Modify: `settings.gradle.kts` -- Create: `modules/grpc-advanced/grpc-advanced-bootstrap/src/main/java/io/backend/skeleton/grpc/advanced/bootstrap/GrpcAdvancedCapability.java` -- Create: `modules/grpc-advanced/grpc-advanced-bootstrap/src/main/java/io/backend/skeleton/grpc/advanced/bootstrap/GrpcAdvancedFeatureFlags.java` -- Create: `modules/grpc-advanced/grpc-advanced-bootstrap/src/main/java/io/backend/skeleton/grpc/advanced/bootstrap/GrpcAdvancedModuleGuard.java` -- Create: `modules/grpc-advanced/grpc-advanced-bootstrap/src/main/java/io/backend/skeleton/grpc/advanced/bootstrap/GrpcAdvancedCapabilityDisabledException.java` -- Create: `modules/grpc-advanced/build.gradle.kts` -- Create: `modules/grpc-advanced/grpc-advanced-bootstrap/build.gradle.kts` -- Test: `modules/grpc-advanced/grpc-advanced-bootstrap/src/test/java/io/backend/skeleton/grpc/advanced/bootstrap/GrpcAdvancedModuleGuardTest.java` - -**Interfaces:** -- Consumes: Stable Task 53 release evidence와 Spring Boot environment. -- Produces: Advanced·Experimental capability가 Stable starter에 자동 유입되지 않도록 하는 dependency·feature flag 경계. - -**Implementation requirements:** -- 모든 capability는 `backend.grpc.advanced.*` 아래의 명시적 flag를 요구한다. -- Stable starter는 Advanced module에 compile/runtime dependency를 갖지 않는다. -- capability grade는 `ADVANCED_STABLE`, `EXPERIMENTAL`, `WATCH`, `DISABLED`로 구분한다. -- production에서 Experimental capability는 별도 승인 profile 없이는 시작되지 않는다. -- Advanced module은 Stable public types를 소비하지만 Stable guardrail을 우회하지 않는다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcAdvancedModuleGuardTest { - @org.junit.jupiter.api.Test - void disabledCapabilityCannotStart() { - var guard = new GrpcAdvancedModuleGuard( - GrpcAdvancedFeatureFlags.disabled()); - - org.assertj.core.api.Assertions.assertThatThrownBy( - () -> guard.requireEnabled( - GrpcAdvancedCapability.XDS)) - .isInstanceOf( - GrpcAdvancedCapabilityDisabledException.class); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc-advanced:grpc-advanced-bootstrap:test --tests 'io.backend.skeleton.grpc.advanced.bootstrap.GrpcAdvancedModuleGuardTest' -``` - -Expected: FAIL because the Advanced production contract does not exist or the capability bypasses the Stable guardrail. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public enum GrpcAdvancedCapability { - EDITION_2024, EDITION_2026, - CLIENT_STREAMING, BIDI_STREAMING, - MANUAL_FLOW_CONTROL, HEDGING, - CUSTOM_RESOLVER, CUSTOM_LOAD_BALANCER, - XDS, GRPC_WEB, SERVLET_COMPAT, - SPRING_INTEGRATION, REACTOR, KOTLIN, - CHANNEL_DIAGNOSTICS -} - -public record GrpcAdvancedFeatureFlags( - java.util.Set enabled) { - public static GrpcAdvancedFeatureFlags disabled() { - return new GrpcAdvancedFeatureFlags(java.util.Set.of()); - } - public boolean isEnabled(GrpcAdvancedCapability capability) { - return enabled.contains(capability); - } -} - -public final class GrpcAdvancedModuleGuard { - private final GrpcAdvancedFeatureFlags flags; - public GrpcAdvancedModuleGuard( - GrpcAdvancedFeatureFlags flags) { - this.flags = flags; - } - public void requireEnabled( - GrpcAdvancedCapability capability) { - if (!flags.isEnabled(capability)) { - throw new GrpcAdvancedCapabilityDisabledException( - capability.name()); - } - } -} -``` - -Implement every listed production file with the exact names and invariants above. Preserve the Stable method policy, security, deadline, evidence, error and observability contracts; an Advanced capability is not a raw escape hatch. - -- [ ] **Step 4: Run the focused test and Advanced aggregate suite** - -Run: - -```bash -./gradlew :modules:grpc-advanced:grpc-advanced-bootstrap:test --tests 'io.backend.skeleton.grpc.advanced.bootstrap.GrpcAdvancedModuleGuardTest' -./gradlew grpcAdvancedTest -``` - -Expected: PASS for the focused test and Advanced aggregate suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc-advanced/grpc-advanced-bootstrap/src/main/java/io/backend/skeleton/grpc/advanced/bootstrap/GrpcAdvancedCapability.java' 'modules/grpc-advanced/grpc-advanced-bootstrap/src/main/java/io/backend/skeleton/grpc/advanced/bootstrap/GrpcAdvancedFeatureFlags.java' 'modules/grpc-advanced/grpc-advanced-bootstrap/src/main/java/io/backend/skeleton/grpc/advanced/bootstrap/GrpcAdvancedModuleGuard.java' 'modules/grpc-advanced/grpc-advanced-bootstrap/src/main/java/io/backend/skeleton/grpc/advanced/bootstrap/GrpcAdvancedCapabilityDisabledException.java' 'modules/grpc-advanced/build.gradle.kts' 'modules/grpc-advanced/grpc-advanced-bootstrap/build.gradle.kts' 'modules/grpc-advanced/grpc-advanced-bootstrap/src/test/java/io/backend/skeleton/grpc/advanced/bootstrap/GrpcAdvancedModuleGuardTest.java' 'settings.gradle.kts' -git commit -m "build: isolate grpc advanced modules" -``` - -### Task 2: Protobuf Edition 2024 Opt-in Lane - -**Files:** -- Create: `modules/grpc-advanced/grpc-edition-2024/src/main/java/io/backend/skeleton/grpc/advanced/edition/GrpcEdition2024Policy.java` -- Create: `modules/grpc-advanced/grpc-edition-2024/src/main/java/io/backend/skeleton/grpc/advanced/edition/GrpcEditionCompatibilityReport.java` -- Create: `modules/grpc-advanced/grpc-edition-2024/src/main/java/io/backend/skeleton/grpc/advanced/edition/GrpcEdition2024Gate.java` -- Create: `modules/grpc-advanced/grpc-edition-2024/src/main/proto/edition2024/compatibility.proto` -- Create: `modules/grpc-advanced/grpc-edition-2024/buf.yaml` -- Test: `modules/grpc-advanced/grpc-edition-2024/src/test/java/io/backend/skeleton/grpc/advanced/edition/GrpcEdition2024GateTest.java` - -**Interfaces:** -- Consumes: Stable proto3+optional descriptor, consumer language/toolchain fixtures와 Buf gate. -- Produces: Edition 2024 schema를 Stable 기본값과 분리하여 검증·비교하는 Advanced lane. - -**Implementation requirements:** -- Edition 2024는 module-level opt-in으로만 사용한다. -- Java뿐 아니라 지원 consumer language/toolchain compile evidence를 요구한다. -- proto3+optional과 wire/source/JSON behavior를 비교한다. -- public service를 Edition 2024로 이동하려면 승격 ADR과 consumer migration이 필요하다. -- Edition 2024 failure가 Stable proto3 release를 차단하지 않되 promotion을 차단한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcEdition2024GateTest { - @org.junit.jupiter.api.Test - void missingConsumerEvidenceBlocksPromotion() { - var gate = new GrpcEdition2024Gate(); - - org.assertj.core.api.Assertions.assertThat( - gate.promotable(new GrpcEditionCompatibilityReport( - true, false, true))).isFalse(); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc-advanced:grpc-edition-2024:test --tests 'io.backend.skeleton.grpc.advanced.edition.GrpcEdition2024GateTest' -``` - -Expected: FAIL because the Advanced production contract does not exist or the capability bypasses the Stable guardrail. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcEditionCompatibilityReport( - boolean javaCompatible, - boolean consumerLanguagesCompatible, - boolean jsonCompatible) {} - -public final class GrpcEdition2024Gate { - public boolean promotable( - GrpcEditionCompatibilityReport report) { - return report.javaCompatible() - && report.consumerLanguagesCompatible() - && report.jsonCompatible(); - } -} - -public record GrpcEdition2024Policy( - boolean explicitOptIn, - boolean stableDefault) {} -``` - -Implement every listed production file with the exact names and invariants above. Preserve the Stable method policy, security, deadline, evidence, error and observability contracts; an Advanced capability is not a raw escape hatch. - -- [ ] **Step 4: Run the focused test and Advanced aggregate suite** - -Run: - -```bash -./gradlew :modules:grpc-advanced:grpc-edition-2024:test --tests 'io.backend.skeleton.grpc.advanced.edition.GrpcEdition2024GateTest' -./gradlew grpcAdvancedTest -``` - -Expected: PASS for the focused test and Advanced aggregate suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc-advanced/grpc-edition-2024/src/main/java/io/backend/skeleton/grpc/advanced/edition/GrpcEdition2024Policy.java' 'modules/grpc-advanced/grpc-edition-2024/src/main/java/io/backend/skeleton/grpc/advanced/edition/GrpcEditionCompatibilityReport.java' 'modules/grpc-advanced/grpc-edition-2024/src/main/java/io/backend/skeleton/grpc/advanced/edition/GrpcEdition2024Gate.java' 'modules/grpc-advanced/grpc-edition-2024/src/main/proto/edition2024/compatibility.proto' 'modules/grpc-advanced/grpc-edition-2024/buf.yaml' 'modules/grpc-advanced/grpc-edition-2024/src/test/java/io/backend/skeleton/grpc/advanced/edition/GrpcEdition2024GateTest.java' -git commit -m "test: add protobuf edition 2024 lane" -``` - -### Task 3: Protobuf Edition 2026 Watch Lane - -**Files:** -- Create: `modules/grpc-advanced/grpc-edition-2026-experimental/src/main/java/io/backend/skeleton/grpc/advanced/edition/GrpcEdition2026Status.java` -- Create: `modules/grpc-advanced/grpc-edition-2026-experimental/src/main/java/io/backend/skeleton/grpc/advanced/edition/GrpcEdition2026WatchReport.java` -- Create: `modules/grpc-advanced/grpc-edition-2026-experimental/src/main/java/io/backend/skeleton/grpc/advanced/edition/GrpcEdition2026Guard.java` -- Test: `modules/grpc-advanced/grpc-edition-2026-experimental/src/test/java/io/backend/skeleton/grpc/advanced/edition/GrpcEdition2026GuardTest.java` - -**Interfaces:** -- Consumes: Task 2 Edition 2024 report와 current Protobuf toolchain metadata. -- Produces: released-edition 상태와 toolchain support가 확정되기 전 Stable 사용을 차단하는 Watch lane. - -**Implementation requirements:** -- Edition 2026은 `WATCH` 또는 `EXPERIMENTAL` 상태만 가진다. -- official release status, protoc support, Buf support, Java/runtime support를 분리 기록한다. -- Stable public contract source로 사용하지 않는다. -- CI failure는 watch report를 생성하지만 Stable build와 분리한다. -- 승격은 별도 ADR와 complete cross-language evidence를 요구한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcEdition2026GuardTest { - @org.junit.jupiter.api.Test - void edition2026CannotBeStable() { - var guard = new GrpcEdition2026Guard(); - - org.assertj.core.api.Assertions.assertThatThrownBy( - () -> guard.requireStable( - GrpcEdition2026Status.WATCH)) - .isInstanceOf(IllegalStateException.class); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc-advanced:grpc-edition-2026-experimental:test --tests 'io.backend.skeleton.grpc.advanced.edition.GrpcEdition2026GuardTest' -``` - -Expected: FAIL because the Advanced production contract does not exist or the capability bypasses the Stable guardrail. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public enum GrpcEdition2026Status { - WATCH, EXPERIMENTAL -} - -public final class GrpcEdition2026Guard { - public void requireStable( - GrpcEdition2026Status status) { - throw new IllegalStateException( - "Edition 2026 is not a Stable contract"); - } -} - -public record GrpcEdition2026WatchReport( - boolean officiallyReleased, - boolean protocSupported, - boolean bufSupported, - boolean javaSupported) {} -``` - -Implement every listed production file with the exact names and invariants above. Preserve the Stable method policy, security, deadline, evidence, error and observability contracts; an Advanced capability is not a raw escape hatch. - -- [ ] **Step 4: Run the focused test and Advanced aggregate suite** - -Run: - -```bash -./gradlew :modules:grpc-advanced:grpc-edition-2026-experimental:test --tests 'io.backend.skeleton.grpc.advanced.edition.GrpcEdition2026GuardTest' -./gradlew grpcAdvancedTest -``` - -Expected: PASS for the focused test and Advanced aggregate suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc-advanced/grpc-edition-2026-experimental/src/main/java/io/backend/skeleton/grpc/advanced/edition/GrpcEdition2026Status.java' 'modules/grpc-advanced/grpc-edition-2026-experimental/src/main/java/io/backend/skeleton/grpc/advanced/edition/GrpcEdition2026WatchReport.java' 'modules/grpc-advanced/grpc-edition-2026-experimental/src/main/java/io/backend/skeleton/grpc/advanced/edition/GrpcEdition2026Guard.java' 'modules/grpc-advanced/grpc-edition-2026-experimental/src/test/java/io/backend/skeleton/grpc/advanced/edition/GrpcEdition2026GuardTest.java' -git commit -m "test: track protobuf edition 2026" -``` - -### Task 4: Client Streaming Session·Sequence Contract - -**Files:** -- Create: `modules/grpc-advanced/grpc-client-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcClientStreamSessionId.java` -- Create: `modules/grpc-advanced/grpc-client-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcClientStreamMessage.java` -- Create: `modules/grpc-advanced/grpc-client-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcClientStreamPolicy.java` -- Create: `modules/grpc-advanced/grpc-client-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcClientStreamState.java` -- Test: `modules/grpc-advanced/grpc-client-streaming/src/test/java/io/backend/skeleton/grpc/advanced/streaming/GrpcClientStreamPolicyTest.java` - -**Interfaces:** -- Consumes: Stable method policy, deadline, metadata, execution evidence와 generated client-streaming RPC. -- Produces: session ID, client sequence, half-close, server result를 명시하는 client-streaming contract. - -**Implementation requirements:** -- 각 stream message는 session generation과 monotonic client sequence를 가진다. -- half-close 이후 새 message를 거부한다. -- 전체 stream을 transparent retry하지 않는다. -- server final response 전에 일부 message가 적용됐을 수 있음을 evidence로 보존한다. -- stream max duration, idle timeout, message rate와 in-flight limit을 요구한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcClientStreamPolicyTest { - @org.junit.jupiter.api.Test - void duplicateOrDecreasingSequenceIsRejected() { - var policy = new GrpcClientStreamPolicy(); - policy.accept(1); - - org.assertj.core.api.Assertions.assertThatThrownBy( - () -> policy.accept(1)) - .isInstanceOf(IllegalArgumentException.class); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc-advanced:grpc-client-streaming:test --tests 'io.backend.skeleton.grpc.advanced.streaming.GrpcClientStreamPolicyTest' -``` - -Expected: FAIL because the Advanced production contract does not exist or the capability bypasses the Stable guardrail. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcClientStreamSessionId(String value) {} - -public record GrpcClientStreamMessage( - GrpcClientStreamSessionId sessionId, - long sequence, - T payload) {} - -public final class GrpcClientStreamPolicy { - private long last; - public void accept(long sequence) { - if (sequence <= last) { - throw new IllegalArgumentException( - "client stream sequence must increase"); - } - last = sequence; - } -} - -public enum GrpcClientStreamState { - OPEN, HALF_CLOSED, COMPLETED, CANCELLED, UNKNOWN -} -``` - -Implement every listed production file with the exact names and invariants above. Preserve the Stable method policy, security, deadline, evidence, error and observability contracts; an Advanced capability is not a raw escape hatch. - -- [ ] **Step 4: Run the focused test and Advanced aggregate suite** - -Run: - -```bash -./gradlew :modules:grpc-advanced:grpc-client-streaming:test --tests 'io.backend.skeleton.grpc.advanced.streaming.GrpcClientStreamPolicyTest' -./gradlew grpcAdvancedTest -``` - -Expected: PASS for the focused test and Advanced aggregate suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc-advanced/grpc-client-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcClientStreamSessionId.java' 'modules/grpc-advanced/grpc-client-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcClientStreamMessage.java' 'modules/grpc-advanced/grpc-client-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcClientStreamPolicy.java' 'modules/grpc-advanced/grpc-client-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcClientStreamState.java' 'modules/grpc-advanced/grpc-client-streaming/src/test/java/io/backend/skeleton/grpc/advanced/streaming/GrpcClientStreamPolicyTest.java' -git commit -m "feat: define grpc client streaming sessions" -``` - -### Task 5: Client Streaming Dedup·Checkpoint·Resume - -**Files:** -- Create: `modules/grpc-advanced/grpc-client-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcClientStreamCheckpoint.java` -- Create: `modules/grpc-advanced/grpc-client-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcClientMessageDeduplicator.java` -- Create: `modules/grpc-advanced/grpc-client-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcClientStreamResumeDecision.java` -- Test: `modules/grpc-advanced/grpc-client-streaming/src/test/java/io/backend/skeleton/grpc/advanced/streaming/GrpcClientMessageDeduplicatorTest.java` - -**Interfaces:** -- Consumes: Task 4 client stream session and application checkpoint storage. -- Produces: 재연결 시 client message 중복을 제거하고 last applied sequence에서 resume하는 contract. - -**Implementation requirements:** -- dedup identity는 stream session + sequence다. -- checkpoint는 application side effect와 가능한 한 같은 transaction에 저장한다. -- checkpoint 이전 duplicate는 replay 결과를 반환하거나 무시한다. -- history·session이 만료되면 새 stream/full resync를 요구한다. -- transport ACK와 application applied checkpoint를 구분한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcClientMessageDeduplicatorTest { - @org.junit.jupiter.api.Test - void alreadyAppliedSequenceIsDuplicate() { - var deduplicator = new GrpcClientMessageDeduplicator(10); - - org.assertj.core.api.Assertions.assertThat( - deduplicator.accept(9)).isFalse(); - org.assertj.core.api.Assertions.assertThat( - deduplicator.accept(11)).isTrue(); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc-advanced:grpc-client-streaming:test --tests 'io.backend.skeleton.grpc.advanced.streaming.GrpcClientMessageDeduplicatorTest' -``` - -Expected: FAIL because the Advanced production contract does not exist or the capability bypasses the Stable guardrail. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcClientStreamCheckpoint( - String sessionId, - long lastAppliedSequence, - String businessRevision) {} - -public final class GrpcClientMessageDeduplicator { - private long lastApplied; - public GrpcClientMessageDeduplicator(long lastApplied) { - this.lastApplied = lastApplied; - } - public boolean accept(long sequence) { - if (sequence <= lastApplied) { - return false; - } - lastApplied = sequence; - return true; - } -} - -public enum GrpcClientStreamResumeDecision { - RESUME, START_NEW, FULL_RESYNC_REQUIRED -} -``` - -Implement every listed production file with the exact names and invariants above. Preserve the Stable method policy, security, deadline, evidence, error and observability contracts; an Advanced capability is not a raw escape hatch. - -- [ ] **Step 4: Run the focused test and Advanced aggregate suite** - -Run: - -```bash -./gradlew :modules:grpc-advanced:grpc-client-streaming:test --tests 'io.backend.skeleton.grpc.advanced.streaming.GrpcClientMessageDeduplicatorTest' -./gradlew grpcAdvancedTest -``` - -Expected: PASS for the focused test and Advanced aggregate suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc-advanced/grpc-client-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcClientStreamCheckpoint.java' 'modules/grpc-advanced/grpc-client-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcClientMessageDeduplicator.java' 'modules/grpc-advanced/grpc-client-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcClientStreamResumeDecision.java' 'modules/grpc-advanced/grpc-client-streaming/src/test/java/io/backend/skeleton/grpc/advanced/streaming/GrpcClientMessageDeduplicatorTest.java' -git commit -m "feat: add grpc client stream resume" -``` - -### Task 6: Bidirectional Streaming Dual Sequence·Lifecycle - -**Files:** -- Create: `modules/grpc-advanced/grpc-bidi-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcBidiSession.java` -- Create: `modules/grpc-advanced/grpc-bidi-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcBidiDirectionState.java` -- Create: `modules/grpc-advanced/grpc-bidi-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcBidiSequenceTracker.java` -- Create: `modules/grpc-advanced/grpc-bidi-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcBidiResumeState.java` -- Test: `modules/grpc-advanced/grpc-bidi-streaming/src/test/java/io/backend/skeleton/grpc/advanced/streaming/GrpcBidiSequenceTrackerTest.java` - -**Interfaces:** -- Consumes: Tasks 4–5 client stream contract와 Stable server stream envelope. -- Produces: client→server와 server→client sequence·flow-control·resume를 독립적으로 관리하는 bidi session. - -**Implementation requirements:** -- 양 방향 sequence를 하나의 counter로 합치지 않는다. -- 각 방향의 half-close와 cancellation을 독립적으로 기록한다. -- 양 방향 bounded queue와 single writer를 사용한다. -- resume token은 client applied/server applied sequence를 모두 보존한다. -- session generation mismatch는 full session restart를 요구한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcBidiSequenceTrackerTest { - @org.junit.jupiter.api.Test - void directionsAdvanceIndependently() { - var tracker = new GrpcBidiSequenceTracker(); - tracker.acceptClient(1); - tracker.acceptServer(1); - tracker.acceptServer(2); - - org.assertj.core.api.Assertions.assertThat( - tracker.clientSequence()).isEqualTo(1); - org.assertj.core.api.Assertions.assertThat( - tracker.serverSequence()).isEqualTo(2); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc-advanced:grpc-bidi-streaming:test --tests 'io.backend.skeleton.grpc.advanced.streaming.GrpcBidiSequenceTrackerTest' -``` - -Expected: FAIL because the Advanced production contract does not exist or the capability bypasses the Stable guardrail. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public final class GrpcBidiSequenceTracker { - private long client; - private long server; - public void acceptClient(long sequence) { - if (sequence <= client) { - throw new IllegalArgumentException("client sequence"); - } - client = sequence; - } - public void acceptServer(long sequence) { - if (sequence <= server) { - throw new IllegalArgumentException("server sequence"); - } - server = sequence; - } - public long clientSequence() { return client; } - public long serverSequence() { return server; } -} - -public record GrpcBidiResumeState( - long lastClientApplied, - long lastServerApplied, - long generation) {} -``` - -Implement every listed production file with the exact names and invariants above. Preserve the Stable method policy, security, deadline, evidence, error and observability contracts; an Advanced capability is not a raw escape hatch. - -- [ ] **Step 4: Run the focused test and Advanced aggregate suite** - -Run: - -```bash -./gradlew :modules:grpc-advanced:grpc-bidi-streaming:test --tests 'io.backend.skeleton.grpc.advanced.streaming.GrpcBidiSequenceTrackerTest' -./gradlew grpcAdvancedTest -``` - -Expected: PASS for the focused test and Advanced aggregate suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc-advanced/grpc-bidi-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcBidiSession.java' 'modules/grpc-advanced/grpc-bidi-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcBidiDirectionState.java' 'modules/grpc-advanced/grpc-bidi-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcBidiSequenceTracker.java' 'modules/grpc-advanced/grpc-bidi-streaming/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcBidiResumeState.java' 'modules/grpc-advanced/grpc-bidi-streaming/src/test/java/io/backend/skeleton/grpc/advanced/streaming/GrpcBidiSequenceTrackerTest.java' -git commit -m "feat: define grpc bidi stream lifecycle" -``` - -### Task 7: Manual Flow Control 승인 API - -**Files:** -- Create: `modules/grpc-advanced/grpc-manual-flow-control/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcManualFlowControlPolicy.java` -- Create: `modules/grpc-advanced/grpc-manual-flow-control/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcDemandController.java` -- Create: `modules/grpc-advanced/grpc-manual-flow-control/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcDemandDecision.java` -- Test: `modules/grpc-advanced/grpc-manual-flow-control/src/test/java/io/backend/skeleton/grpc/advanced/streaming/GrpcDemandControllerTest.java` - -**Interfaces:** -- Consumes: Stable bounded flow-control policy와 gRPC readiness/request APIs. -- Produces: 승인된 streaming method가 수신 demand와 outbound readiness를 직접 제어하는 GR2 API. - -**Implementation requirements:** -- manual inbound request 수와 outstanding demand를 제한한다. -- application code에 raw observer를 직접 반환하지 않는다. -- read와 write 양쪽이 서로 기다리는 deadlock을 탐지하는 watchdog을 둔다. -- demand와 queue high-watermark를 metric으로 기록한다. -- manual mode가 없는 method에는 자동 flow control을 유지한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcDemandControllerTest { - @org.junit.jupiter.api.Test - void demandCannotExceedConfiguredWindow() { - var controller = new GrpcDemandController(4); - - org.assertj.core.api.Assertions.assertThatThrownBy( - () -> controller.request(5)) - .isInstanceOf(IllegalArgumentException.class); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc-advanced:grpc-manual-flow-control:test --tests 'io.backend.skeleton.grpc.advanced.streaming.GrpcDemandControllerTest' -``` - -Expected: FAIL because the Advanced production contract does not exist or the capability bypasses the Stable guardrail. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public final class GrpcDemandController { - private final int maxOutstanding; - private int outstanding; - - public GrpcDemandController(int maxOutstanding) { - this.maxOutstanding = maxOutstanding; - } - - public void request(int count) { - if (count <= 0 - || outstanding + count > maxOutstanding) { - throw new IllegalArgumentException( - "manual flow-control window exceeded"); - } - outstanding += count; - } -} - -public record GrpcManualFlowControlPolicy( - int maxOutstanding, - java.time.Duration deadlockWatchdog) {} -``` - -Implement every listed production file with the exact names and invariants above. Preserve the Stable method policy, security, deadline, evidence, error and observability contracts; an Advanced capability is not a raw escape hatch. - -- [ ] **Step 4: Run the focused test and Advanced aggregate suite** - -Run: - -```bash -./gradlew :modules:grpc-advanced:grpc-manual-flow-control:test --tests 'io.backend.skeleton.grpc.advanced.streaming.GrpcDemandControllerTest' -./gradlew grpcAdvancedTest -``` - -Expected: PASS for the focused test and Advanced aggregate suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc-advanced/grpc-manual-flow-control/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcManualFlowControlPolicy.java' 'modules/grpc-advanced/grpc-manual-flow-control/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcDemandController.java' 'modules/grpc-advanced/grpc-manual-flow-control/src/main/java/io/backend/skeleton/grpc/advanced/streaming/GrpcDemandDecision.java' 'modules/grpc-advanced/grpc-manual-flow-control/src/test/java/io/backend/skeleton/grpc/advanced/streaming/GrpcDemandControllerTest.java' -git commit -m "feat: add approved grpc manual flow control" -``` - -### Task 8: Read-only Unary Hedging - -**Files:** -- Create: `modules/grpc-advanced/grpc-hedging/src/main/java/io/backend/skeleton/grpc/advanced/resilience/GrpcHedgingPolicy.java` -- Create: `modules/grpc-advanced/grpc-hedging/src/main/java/io/backend/skeleton/grpc/advanced/resilience/GrpcHedgingEligibility.java` -- Create: `modules/grpc-advanced/grpc-hedging/src/main/java/io/backend/skeleton/grpc/advanced/resilience/GrpcHedgingBudget.java` -- Create: `modules/grpc-advanced/grpc-hedging/src/main/java/io/backend/skeleton/grpc/advanced/resilience/GrpcHedgingResult.java` -- Test: `modules/grpc-advanced/grpc-hedging/src/test/java/io/backend/skeleton/grpc/advanced/resilience/GrpcHedgingEligibilityTest.java` - -**Interfaces:** -- Consumes: Stable method policy, deadline, retry owner와 Service Config. -- Produces: READ_ONLY Unary에만 제한된 duplicate attempt와 first-success 정책. - -**Implementation requirements:** -- READ_ONLY + UNARY만 hedging 대상이다. -- mutation, streaming, idempotency-key write에는 hedging을 금지한다. -- maximum attempts는 초기 2로 제한한다. -- hedging delay와 total deadline·attempt budget을 함께 검증한다. -- duplicate backend load와 cancelled loser result를 metric으로 기록한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcHedgingEligibilityTest { - @org.junit.jupiter.api.Test - void mutationCannotHedge() { - var eligibility = new GrpcHedgingEligibility(); - - org.assertj.core.api.Assertions.assertThat( - eligibility.allowed( - RpcIdempotencyProfile.NON_IDEMPOTENT, - RpcType.UNARY)).isFalse(); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc-advanced:grpc-hedging:test --tests 'io.backend.skeleton.grpc.advanced.resilience.GrpcHedgingEligibilityTest' -``` - -Expected: FAIL because the Advanced production contract does not exist or the capability bypasses the Stable guardrail. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcHedgingPolicy( - int maxAttempts, - java.time.Duration hedgingDelay) { - public GrpcHedgingPolicy { - if (maxAttempts < 2 || maxAttempts > 2) { - throw new IllegalArgumentException( - "initial hedging supports exactly two attempts"); - } - } -} - -public final class GrpcHedgingEligibility { - public boolean allowed( - RpcIdempotencyProfile profile, - RpcType type) { - return profile == RpcIdempotencyProfile.READ_ONLY - && type == RpcType.UNARY; - } -} -``` - -Implement every listed production file with the exact names and invariants above. Preserve the Stable method policy, security, deadline, evidence, error and observability contracts; an Advanced capability is not a raw escape hatch. - -- [ ] **Step 4: Run the focused test and Advanced aggregate suite** - -Run: - -```bash -./gradlew :modules:grpc-advanced:grpc-hedging:test --tests 'io.backend.skeleton.grpc.advanced.resilience.GrpcHedgingEligibilityTest' -./gradlew grpcAdvancedTest -``` - -Expected: PASS for the focused test and Advanced aggregate suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc-advanced/grpc-hedging/src/main/java/io/backend/skeleton/grpc/advanced/resilience/GrpcHedgingPolicy.java' 'modules/grpc-advanced/grpc-hedging/src/main/java/io/backend/skeleton/grpc/advanced/resilience/GrpcHedgingEligibility.java' 'modules/grpc-advanced/grpc-hedging/src/main/java/io/backend/skeleton/grpc/advanced/resilience/GrpcHedgingBudget.java' 'modules/grpc-advanced/grpc-hedging/src/main/java/io/backend/skeleton/grpc/advanced/resilience/GrpcHedgingResult.java' 'modules/grpc-advanced/grpc-hedging/src/test/java/io/backend/skeleton/grpc/advanced/resilience/GrpcHedgingEligibilityTest.java' -git commit -m "feat: add read only grpc hedging" -``` - -### Task 9: Custom Name Resolver SPI - -**Files:** -- Create: `modules/grpc-advanced/grpc-custom-resolver/src/main/java/io/backend/skeleton/grpc/advanced/discovery/GrpcEndpointSnapshot.java` -- Create: `modules/grpc-advanced/grpc-custom-resolver/src/main/java/io/backend/skeleton/grpc/advanced/discovery/GrpcResolverUpdate.java` -- Create: `modules/grpc-advanced/grpc-custom-resolver/src/main/java/io/backend/skeleton/grpc/advanced/discovery/GrpcCustomResolver.java` -- Create: `modules/grpc-advanced/grpc-custom-resolver/src/main/java/io/backend/skeleton/grpc/advanced/discovery/GrpcResolverSafetyPolicy.java` -- Test: `modules/grpc-advanced/grpc-custom-resolver/src/test/java/io/backend/skeleton/grpc/advanced/discovery/GrpcResolverSafetyPolicyTest.java` - -**Interfaces:** -- Consumes: Stable Named Channel Profile and custom discovery event source. -- Produces: backend address와 Service Config update를 versioned snapshot으로 전달하는 승인된 resolver SPI. - -**Implementation requirements:** -- resolver update는 monotonic revision과 endpoint set을 가진다. -- 빈 endpoint update, stale revision, invalid authority를 거부한다. -- resolver는 credential·business metadata를 제공하지 않는다. -- Service Config를 함께 제공할 경우 retry owner·LB policy 검증을 통과해야 한다. -- resolver close 후 update를 수신하지 않는다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcResolverSafetyPolicyTest { - @org.junit.jupiter.api.Test - void staleRevisionIsRejected() { - var policy = new GrpcResolverSafetyPolicy(); - policy.accept(new GrpcEndpointSnapshot(2, - java.util.Set.of("10.0.0.2:9090"))); - - org.assertj.core.api.Assertions.assertThatThrownBy( - () -> policy.accept(new GrpcEndpointSnapshot( - 1, java.util.Set.of("10.0.0.1:9090")))) - .isInstanceOf(IllegalArgumentException.class); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc-advanced:grpc-custom-resolver:test --tests 'io.backend.skeleton.grpc.advanced.discovery.GrpcResolverSafetyPolicyTest' -``` - -Expected: FAIL because the Advanced production contract does not exist or the capability bypasses the Stable guardrail. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcEndpointSnapshot( - long revision, - java.util.Set endpoints) {} - -public final class GrpcResolverSafetyPolicy { - private long revision = -1; - public void accept(GrpcEndpointSnapshot snapshot) { - if (snapshot.endpoints().isEmpty() - || snapshot.revision() <= revision) { - throw new IllegalArgumentException( - "invalid resolver update"); - } - revision = snapshot.revision(); - } -} - -public interface GrpcCustomResolver - extends AutoCloseable { - void start( - java.util.function.Consumer listener); -} -``` - -Implement every listed production file with the exact names and invariants above. Preserve the Stable method policy, security, deadline, evidence, error and observability contracts; an Advanced capability is not a raw escape hatch. - -- [ ] **Step 4: Run the focused test and Advanced aggregate suite** - -Run: - -```bash -./gradlew :modules:grpc-advanced:grpc-custom-resolver:test --tests 'io.backend.skeleton.grpc.advanced.discovery.GrpcResolverSafetyPolicyTest' -./gradlew grpcAdvancedTest -``` - -Expected: PASS for the focused test and Advanced aggregate suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc-advanced/grpc-custom-resolver/src/main/java/io/backend/skeleton/grpc/advanced/discovery/GrpcEndpointSnapshot.java' 'modules/grpc-advanced/grpc-custom-resolver/src/main/java/io/backend/skeleton/grpc/advanced/discovery/GrpcResolverUpdate.java' 'modules/grpc-advanced/grpc-custom-resolver/src/main/java/io/backend/skeleton/grpc/advanced/discovery/GrpcCustomResolver.java' 'modules/grpc-advanced/grpc-custom-resolver/src/main/java/io/backend/skeleton/grpc/advanced/discovery/GrpcResolverSafetyPolicy.java' 'modules/grpc-advanced/grpc-custom-resolver/src/test/java/io/backend/skeleton/grpc/advanced/discovery/GrpcResolverSafetyPolicyTest.java' -git commit -m "feat: add grpc custom resolver spi" -``` - -### Task 10: Custom Load Balancer SPI - -**Files:** -- Create: `modules/grpc-advanced/grpc-custom-load-balancer/src/main/java/io/backend/skeleton/grpc/advanced/discovery/GrpcEndpointCandidate.java` -- Create: `modules/grpc-advanced/grpc-custom-load-balancer/src/main/java/io/backend/skeleton/grpc/advanced/discovery/GrpcLoadBalancerPicker.java` -- Create: `modules/grpc-advanced/grpc-custom-load-balancer/src/main/java/io/backend/skeleton/grpc/advanced/discovery/GrpcLoadBalancerDecision.java` -- Create: `modules/grpc-advanced/grpc-custom-load-balancer/src/main/java/io/backend/skeleton/grpc/advanced/discovery/GrpcLoadBalancerSafetyPolicy.java` -- Test: `modules/grpc-advanced/grpc-custom-load-balancer/src/test/java/io/backend/skeleton/grpc/advanced/discovery/GrpcLoadBalancerSafetyPolicyTest.java` - -**Interfaces:** -- Consumes: Task 9 custom resolver snapshot and Stable channel profile. -- Produces: bounded endpoint candidate에서 picker가 결정을 내리고 unsafe policy를 차단하는 GR3 SPI. - -**Implementation requirements:** -- picker는 resolver가 제공한 endpoint만 선택할 수 있다. -- endpoint health, connectivity, weight와 ejection state를 bounded metadata로 사용한다. -- business request body·tenant ID를 routing input으로 사용하지 않는다. -- custom picker failure는 deterministic fallback 또는 call failure로 드러난다. -- load-aware/weighted policy는 performance·fairness·failover evidence를 요구한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcLoadBalancerSafetyPolicyTest { - @org.junit.jupiter.api.Test - void pickerCannotChooseUnknownEndpoint() { - var policy = new GrpcLoadBalancerSafetyPolicy( - java.util.Set.of("a:9090")); - - org.assertj.core.api.Assertions.assertThatThrownBy( - () -> policy.validate("b:9090")) - .isInstanceOf(IllegalArgumentException.class); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc-advanced:grpc-custom-load-balancer:test --tests 'io.backend.skeleton.grpc.advanced.discovery.GrpcLoadBalancerSafetyPolicyTest' -``` - -Expected: FAIL because the Advanced production contract does not exist or the capability bypasses the Stable guardrail. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcEndpointCandidate( - String authority, - int weight, - boolean ready) {} - -public final class GrpcLoadBalancerSafetyPolicy { - private final java.util.Set endpoints; - public GrpcLoadBalancerSafetyPolicy( - java.util.Set endpoints) { - this.endpoints = java.util.Set.copyOf(endpoints); - } - public void validate(String selected) { - if (!endpoints.contains(selected)) { - throw new IllegalArgumentException( - "picker selected unknown endpoint"); - } - } -} - -public interface GrpcLoadBalancerPicker { - GrpcLoadBalancerDecision pick( - java.util.List candidates); -} -``` - -Implement every listed production file with the exact names and invariants above. Preserve the Stable method policy, security, deadline, evidence, error and observability contracts; an Advanced capability is not a raw escape hatch. - -- [ ] **Step 4: Run the focused test and Advanced aggregate suite** - -Run: - -```bash -./gradlew :modules:grpc-advanced:grpc-custom-load-balancer:test --tests 'io.backend.skeleton.grpc.advanced.discovery.GrpcLoadBalancerSafetyPolicyTest' -./gradlew grpcAdvancedTest -``` - -Expected: PASS for the focused test and Advanced aggregate suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc-advanced/grpc-custom-load-balancer/src/main/java/io/backend/skeleton/grpc/advanced/discovery/GrpcEndpointCandidate.java' 'modules/grpc-advanced/grpc-custom-load-balancer/src/main/java/io/backend/skeleton/grpc/advanced/discovery/GrpcLoadBalancerPicker.java' 'modules/grpc-advanced/grpc-custom-load-balancer/src/main/java/io/backend/skeleton/grpc/advanced/discovery/GrpcLoadBalancerDecision.java' 'modules/grpc-advanced/grpc-custom-load-balancer/src/main/java/io/backend/skeleton/grpc/advanced/discovery/GrpcLoadBalancerSafetyPolicy.java' 'modules/grpc-advanced/grpc-custom-load-balancer/src/test/java/io/backend/skeleton/grpc/advanced/discovery/GrpcLoadBalancerSafetyPolicyTest.java' -git commit -m "feat: add grpc custom load balancer spi" -``` - -### Task 11: xDS Proxyless Experimental Profile - -**Files:** -- Create: `modules/grpc-advanced/grpc-xds/src/main/java/io/backend/skeleton/grpc/advanced/xds/GrpcXdsProfile.java` -- Create: `modules/grpc-advanced/grpc-xds/src/main/java/io/backend/skeleton/grpc/advanced/xds/GrpcXdsResourceSnapshot.java` -- Create: `modules/grpc-advanced/grpc-xds/src/main/java/io/backend/skeleton/grpc/advanced/xds/GrpcXdsStartupGuard.java` -- Create: `modules/grpc-advanced/grpc-xds/src/main/java/io/backend/skeleton/grpc/advanced/xds/GrpcXdsFailurePolicy.java` -- Create: `modules/grpc-advanced/grpc-xds/src/test/resources/xds/bootstrap.json` -- Test: `modules/grpc-advanced/grpc-xds/src/test/java/io/backend/skeleton/grpc/advanced/xds/GrpcXdsStartupGuardTest.java` - -**Interfaces:** -- Consumes: Stable channel/security policy, xDS bootstrap and control-plane resources. -- Produces: xds target, resource discovery, traffic policy, mTLS와 fallback을 별도 Experimental profile로 검증. - -**Implementation requirements:** -- `xds:///` target만 explicit xDS profile에서 허용한다. -- bootstrap·control-plane credential·resource namespace를 검증한다. -- application YAML과 xDS에 retry/LB owner를 중복 정의하지 않는다. -- resource not found, stale resource, control-plane outage와 last-known-good policy를 명시한다. -- xDS 기능을 Stable DNS/LB support로 광고하지 않는다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcXdsStartupGuardTest { - @org.junit.jupiter.api.Test - void xdsRequiresExplicitFeatureFlagAndBootstrap() { - var guard = new GrpcXdsStartupGuard(false); - - org.assertj.core.api.Assertions.assertThatThrownBy( - () -> guard.validate( - new GrpcXdsProfile( - "xds:///document-service", ""))) - .isInstanceOf(IllegalStateException.class); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc-advanced:grpc-xds:test --tests 'io.backend.skeleton.grpc.advanced.xds.GrpcXdsStartupGuardTest' -``` - -Expected: FAIL because the Advanced production contract does not exist or the capability bypasses the Stable guardrail. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcXdsProfile( - String target, - String bootstrapResource) {} - -public final class GrpcXdsStartupGuard { - private final boolean enabled; - public GrpcXdsStartupGuard(boolean enabled) { - this.enabled = enabled; - } - public void validate(GrpcXdsProfile profile) { - if (!enabled - || !profile.target().startsWith("xds:///") - || profile.bootstrapResource().isBlank()) { - throw new IllegalStateException( - "valid xDS experimental profile is required"); - } - } -} - -public record GrpcXdsResourceSnapshot( - String version, - java.util.Set clusters, - java.time.Instant receivedAt) {} -``` - -Implement every listed production file with the exact names and invariants above. Preserve the Stable method policy, security, deadline, evidence, error and observability contracts; an Advanced capability is not a raw escape hatch. - -- [ ] **Step 4: Run the focused test and Advanced aggregate suite** - -Run: - -```bash -./gradlew :modules:grpc-advanced:grpc-xds:test --tests 'io.backend.skeleton.grpc.advanced.xds.GrpcXdsStartupGuardTest' -./gradlew grpcAdvancedTest -``` - -Expected: PASS for the focused test and Advanced aggregate suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc-advanced/grpc-xds/src/main/java/io/backend/skeleton/grpc/advanced/xds/GrpcXdsProfile.java' 'modules/grpc-advanced/grpc-xds/src/main/java/io/backend/skeleton/grpc/advanced/xds/GrpcXdsResourceSnapshot.java' 'modules/grpc-advanced/grpc-xds/src/main/java/io/backend/skeleton/grpc/advanced/xds/GrpcXdsStartupGuard.java' 'modules/grpc-advanced/grpc-xds/src/main/java/io/backend/skeleton/grpc/advanced/xds/GrpcXdsFailurePolicy.java' 'modules/grpc-advanced/grpc-xds/src/test/resources/xds/bootstrap.json' 'modules/grpc-advanced/grpc-xds/src/test/java/io/backend/skeleton/grpc/advanced/xds/GrpcXdsStartupGuardTest.java' -git commit -m "feat: add experimental grpc xds profile" -``` - -### Task 12: gRPC-Web Unary·Server Streaming Bridge - -**Files:** -- Create: `modules/grpc-advanced/grpc-web/src/main/java/io/backend/skeleton/grpc/advanced/web/GrpcWebRpcSupport.java` -- Create: `modules/grpc-advanced/grpc-web/src/main/java/io/backend/skeleton/grpc/advanced/web/GrpcWebProfile.java` -- Create: `modules/grpc-advanced/grpc-web/src/main/java/io/backend/skeleton/grpc/advanced/web/GrpcWebProxyContract.java` -- Create: `modules/grpc-advanced/grpc-web/src/main/java/io/backend/skeleton/grpc/advanced/web/GrpcWebCompatibilityGate.java` -- Create: `modules/grpc-advanced/grpc-web/src/test/resources/envoy/envoy.yaml` -- Test: `modules/grpc-advanced/grpc-web/src/test/java/io/backend/skeleton/grpc/advanced/web/GrpcWebCompatibilityGateTest.java` - -**Interfaces:** -- Consumes: Stable proto contract, Envoy/proxy profile and browser gRPC-Web client contract. -- Produces: 브라우저에서 Unary와 grpcwebtext Server Streaming만 지원하고 Client/Bidi를 명시적으로 차단하는 bridge. - -**Implementation requirements:** -- 지원 RPC는 Unary와 Server Streaming이다. -- Client Streaming과 Bidirectional Streaming을 지원한다고 선언하지 않는다. -- Envoy 또는 승인된 proxy의 CORS, TLS, metadata/header mapping을 검증한다. -- browser credential·cookie·bearer profile과 CSRF/CORS 정책을 분리한다. -- native gRPC와 gRPC-Web compatibility suite를 같은 schema에 실행한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcWebCompatibilityGateTest { - @org.junit.jupiter.api.Test - void bidiStreamingIsUnsupported() { - var gate = GrpcWebCompatibilityGate.standard(); - - org.assertj.core.api.Assertions.assertThat( - gate.supports(RpcType.BIDI_STREAMING)).isFalse(); - org.assertj.core.api.Assertions.assertThat( - gate.supports(RpcType.UNARY)).isTrue(); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc-advanced:grpc-web:test --tests 'io.backend.skeleton.grpc.advanced.web.GrpcWebCompatibilityGateTest' -``` - -Expected: FAIL because the Advanced production contract does not exist or the capability bypasses the Stable guardrail. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public enum GrpcWebRpcSupport { - UNARY, SERVER_STREAMING -} - -public final class GrpcWebCompatibilityGate { - public static GrpcWebCompatibilityGate standard() { - return new GrpcWebCompatibilityGate(); - } - - public boolean supports(RpcType type) { - return type == RpcType.UNARY - || type == RpcType.SERVER_STREAMING; - } -} - -public record GrpcWebProfile( - String proxy, - boolean tlsRequired, - java.util.Set allowedOrigins) {} -``` - -Implement every listed production file with the exact names and invariants above. Preserve the Stable method policy, security, deadline, evidence, error and observability contracts; an Advanced capability is not a raw escape hatch. - -- [ ] **Step 4: Run the focused test and Advanced aggregate suite** - -Run: - -```bash -./gradlew :modules:grpc-advanced:grpc-web:test --tests 'io.backend.skeleton.grpc.advanced.web.GrpcWebCompatibilityGateTest' -./gradlew grpcAdvancedTest -``` - -Expected: PASS for the focused test and Advanced aggregate suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc-advanced/grpc-web/src/main/java/io/backend/skeleton/grpc/advanced/web/GrpcWebRpcSupport.java' 'modules/grpc-advanced/grpc-web/src/main/java/io/backend/skeleton/grpc/advanced/web/GrpcWebProfile.java' 'modules/grpc-advanced/grpc-web/src/main/java/io/backend/skeleton/grpc/advanced/web/GrpcWebProxyContract.java' 'modules/grpc-advanced/grpc-web/src/main/java/io/backend/skeleton/grpc/advanced/web/GrpcWebCompatibilityGate.java' 'modules/grpc-advanced/grpc-web/src/test/resources/envoy/envoy.yaml' 'modules/grpc-advanced/grpc-web/src/test/java/io/backend/skeleton/grpc/advanced/web/GrpcWebCompatibilityGateTest.java' -git commit -m "feat: add grpc web compatibility bridge" -``` - -### Task 13: Servlet HTTP/2 Compatibility Profile - -**Files:** -- Create: `modules/grpc-advanced/grpc-servlet-compat/src/main/java/io/backend/skeleton/grpc/advanced/servlet/GrpcServletCompatibilityProfile.java` -- Create: `modules/grpc-advanced/grpc-servlet-compat/src/main/java/io/backend/skeleton/grpc/advanced/servlet/GrpcServletCapabilityMatrix.java` -- Create: `modules/grpc-advanced/grpc-servlet-compat/src/main/java/io/backend/skeleton/grpc/advanced/servlet/GrpcServletStartupValidator.java` -- Test: `modules/grpc-advanced/grpc-servlet-compat/src/test/java/io/backend/skeleton/grpc/advanced/servlet/GrpcServletStartupValidatorTest.java` - -**Interfaces:** -- Consumes: Stable server profile and Spring Servlet container HTTP/2 integration. -- Produces: 동일 web server/port를 사용하는 Servlet transport의 지원·비지원 기능을 명시하는 compatibility module. - -**Implementation requirements:** -- Servlet transport는 Stable Netty certification을 대체하지 않는다. -- container가 network layer를 소유하므로 native Netty-only 설정을 요청하면 실패한다. -- HTTP/2, TLS, message/metadata limits, health, reflection, drain을 실제 container에서 검증한다. -- keepalive·connection age·flow-control capability 차이를 matrix에 기록한다. -- Servlet profile은 명시적 feature flag와 compatibility release gate를 요구한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcServletStartupValidatorTest { - @org.junit.jupiter.api.Test - void nativeOnlyFlowControlSettingIsRejected() { - var validator = new GrpcServletStartupValidator(); - - org.assertj.core.api.Assertions.assertThatThrownBy( - () -> validator.validate( - new GrpcServletCompatibilityProfile(true))) - .isInstanceOf(IllegalArgumentException.class); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc-advanced:grpc-servlet-compat:test --tests 'io.backend.skeleton.grpc.advanced.servlet.GrpcServletStartupValidatorTest' -``` - -Expected: FAIL because the Advanced production contract does not exist or the capability bypasses the Stable guardrail. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcServletCompatibilityProfile( - boolean requestsNativeNettyFlowControl) {} - -public final class GrpcServletStartupValidator { - public void validate( - GrpcServletCompatibilityProfile profile) { - if (profile.requestsNativeNettyFlowControl()) { - throw new IllegalArgumentException( - "Servlet transport cannot promise Netty-only policy"); - } - } -} - -public record GrpcServletCapabilityMatrix( - boolean http2, - boolean tls, - boolean health, - boolean reflection, - boolean nettyFlowControl) {} -``` - -Implement every listed production file with the exact names and invariants above. Preserve the Stable method policy, security, deadline, evidence, error and observability contracts; an Advanced capability is not a raw escape hatch. - -- [ ] **Step 4: Run the focused test and Advanced aggregate suite** - -Run: - -```bash -./gradlew :modules:grpc-advanced:grpc-servlet-compat:test --tests 'io.backend.skeleton.grpc.advanced.servlet.GrpcServletStartupValidatorTest' -./gradlew grpcAdvancedTest -``` - -Expected: PASS for the focused test and Advanced aggregate suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc-advanced/grpc-servlet-compat/src/main/java/io/backend/skeleton/grpc/advanced/servlet/GrpcServletCompatibilityProfile.java' 'modules/grpc-advanced/grpc-servlet-compat/src/main/java/io/backend/skeleton/grpc/advanced/servlet/GrpcServletCapabilityMatrix.java' 'modules/grpc-advanced/grpc-servlet-compat/src/main/java/io/backend/skeleton/grpc/advanced/servlet/GrpcServletStartupValidator.java' 'modules/grpc-advanced/grpc-servlet-compat/src/test/java/io/backend/skeleton/grpc/advanced/servlet/GrpcServletStartupValidatorTest.java' -git commit -m "feat: add grpc servlet compatibility profile" -``` - -### Task 14: Spring Integration gRPC Bridge - -**Files:** -- Create: `modules/grpc-advanced/grpc-integration-bridge/src/main/java/io/backend/skeleton/grpc/advanced/integration/GrpcIntegrationInboundGateway.java` -- Create: `modules/grpc-advanced/grpc-integration-bridge/src/main/java/io/backend/skeleton/grpc/advanced/integration/GrpcIntegrationOutboundGateway.java` -- Create: `modules/grpc-advanced/grpc-integration-bridge/src/main/java/io/backend/skeleton/grpc/advanced/integration/GrpcIntegrationBridgePolicy.java` -- Test: `modules/grpc-advanced/grpc-integration-bridge/src/test/java/io/backend/skeleton/grpc/advanced/integration/GrpcIntegrationBridgePolicyTest.java` - -**Interfaces:** -- Consumes: Stable Generated service/stub, Spring Integration Message flow and method policy. -- Produces: Spring `Message` 기반 Integration Flow를 typed RPC 계약과 분리해 연결하는 선택 bridge. - -**Implementation requirements:** -- bridge가 일반 Generated Stub/Service API를 대체한다고 설명하지 않는다. -- message header를 gRPC metadata로 무제한 복사하지 않는다. -- deadline, security, status, error, observability Stable policy를 그대로 적용한다. -- payload type은 registered converter를 통해 Proto message와 변환한다. -- broker-style ACK·durability semantics를 bridge에 추가하지 않는다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcIntegrationBridgePolicyTest { - @org.junit.jupiter.api.Test - void arbitraryHeadersAreNotForwarded() { - var policy = GrpcIntegrationBridgePolicy.standard(); - - org.assertj.core.api.Assertions.assertThat( - policy.forwardedHeaders()) - .containsExactlyInAnyOrder( - "correlation-id", "traceparent"); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc-advanced:grpc-integration-bridge:test --tests 'io.backend.skeleton.grpc.advanced.integration.GrpcIntegrationBridgePolicyTest' -``` - -Expected: FAIL because the Advanced production contract does not exist or the capability bypasses the Stable guardrail. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcIntegrationBridgePolicy( - java.util.Set forwardedHeaders) { - - public static GrpcIntegrationBridgePolicy standard() { - return new GrpcIntegrationBridgePolicy( - java.util.Set.of( - "correlation-id", "traceparent")); - } -} - -public interface GrpcIntegrationInboundGateway {} -public interface GrpcIntegrationOutboundGateway {} -``` - -Implement every listed production file with the exact names and invariants above. Preserve the Stable method policy, security, deadline, evidence, error and observability contracts; an Advanced capability is not a raw escape hatch. - -- [ ] **Step 4: Run the focused test and Advanced aggregate suite** - -Run: - -```bash -./gradlew :modules:grpc-advanced:grpc-integration-bridge:test --tests 'io.backend.skeleton.grpc.advanced.integration.GrpcIntegrationBridgePolicyTest' -./gradlew grpcAdvancedTest -``` - -Expected: PASS for the focused test and Advanced aggregate suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc-advanced/grpc-integration-bridge/src/main/java/io/backend/skeleton/grpc/advanced/integration/GrpcIntegrationInboundGateway.java' 'modules/grpc-advanced/grpc-integration-bridge/src/main/java/io/backend/skeleton/grpc/advanced/integration/GrpcIntegrationOutboundGateway.java' 'modules/grpc-advanced/grpc-integration-bridge/src/main/java/io/backend/skeleton/grpc/advanced/integration/GrpcIntegrationBridgePolicy.java' 'modules/grpc-advanced/grpc-integration-bridge/src/test/java/io/backend/skeleton/grpc/advanced/integration/GrpcIntegrationBridgePolicyTest.java' -git commit -m "feat: add spring integration grpc bridge" -``` - -### Task 15: Reactor gRPC Adapter - -**Files:** -- Create: `modules/grpc-advanced/grpc-reactor/src/main/java/io/backend/skeleton/grpc/advanced/reactor/ReactiveGrpcClient.java` -- Create: `modules/grpc-advanced/grpc-reactor/src/main/java/io/backend/skeleton/grpc/advanced/reactor/ReactiveGrpcServerAdapter.java` -- Create: `modules/grpc-advanced/grpc-reactor/src/main/java/io/backend/skeleton/grpc/advanced/reactor/GrpcReactorContextBridge.java` -- Create: `modules/grpc-advanced/grpc-reactor/src/main/java/io/backend/skeleton/grpc/advanced/reactor/GrpcReactorCancellationBridge.java` -- Test: `modules/grpc-advanced/grpc-reactor/src/test/java/io/backend/skeleton/grpc/advanced/reactor/GrpcReactorContextBridgeTest.java` - -**Interfaces:** -- Consumes: Stable typed stub/service adapter, Reactor Context and cancellation contracts. -- Produces: Unary·Server Streaming을 Mono/Flux로 노출하면서 context·deadline·cancel·backpressure를 유지하는 adapter. - -**Implementation requirements:** -- Core public contract를 Reactor에 종속시키지 않는다. -- Mono/Flux cancellation을 gRPC call cancellation으로 전파한다. -- gRPC Context와 Reactor Context의 Actor·Tenant·Trace·Deadline을 bridge한다. -- blocking JPA/SDK 호출을 event loop에서 실행하지 않는다. -- server stream Flux가 Stable bounded flow-control contract를 우회하지 않는다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcReactorContextBridgeTest { - @org.junit.jupiter.api.Test - void missingActorContextFailsClosed() { - var bridge = new GrpcReactorContextBridge(); - - org.assertj.core.api.Assertions.assertThatThrownBy( - () -> bridge.requireContext(java.util.Map.of())) - .isInstanceOf(IllegalStateException.class); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc-advanced:grpc-reactor:test --tests 'io.backend.skeleton.grpc.advanced.reactor.GrpcReactorContextBridgeTest' -``` - -Expected: FAIL because the Advanced production contract does not exist or the capability bypasses the Stable guardrail. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public final class GrpcReactorContextBridge { - public GrpcRequestContext requireContext( - java.util.Map context) { - var value = context.get("grpcRequestContext"); - if (!(value instanceof GrpcRequestContext requestContext)) { - throw new IllegalStateException( - "gRPC request context is required"); - } - return requestContext; - } -} - -public interface ReactiveGrpcClient {} -public interface ReactiveGrpcServerAdapter {} -``` - -Implement every listed production file with the exact names and invariants above. Preserve the Stable method policy, security, deadline, evidence, error and observability contracts; an Advanced capability is not a raw escape hatch. - -- [ ] **Step 4: Run the focused test and Advanced aggregate suite** - -Run: - -```bash -./gradlew :modules:grpc-advanced:grpc-reactor:test --tests 'io.backend.skeleton.grpc.advanced.reactor.GrpcReactorContextBridgeTest' -./gradlew grpcAdvancedTest -``` - -Expected: PASS for the focused test and Advanced aggregate suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc-advanced/grpc-reactor/src/main/java/io/backend/skeleton/grpc/advanced/reactor/ReactiveGrpcClient.java' 'modules/grpc-advanced/grpc-reactor/src/main/java/io/backend/skeleton/grpc/advanced/reactor/ReactiveGrpcServerAdapter.java' 'modules/grpc-advanced/grpc-reactor/src/main/java/io/backend/skeleton/grpc/advanced/reactor/GrpcReactorContextBridge.java' 'modules/grpc-advanced/grpc-reactor/src/main/java/io/backend/skeleton/grpc/advanced/reactor/GrpcReactorCancellationBridge.java' 'modules/grpc-advanced/grpc-reactor/src/test/java/io/backend/skeleton/grpc/advanced/reactor/GrpcReactorContextBridgeTest.java' -git commit -m "feat: add reactor grpc adapter" -``` - -### Task 16: Kotlin Coroutine·Flow Adapter - -**Files:** -- Create: `modules/grpc-advanced/grpc-kotlin/src/main/java/io/backend/skeleton/grpc/advanced/kotlin/GrpcKotlinProfile.java` -- Create: `modules/grpc-advanced/grpc-kotlin/src/main/java/io/backend/skeleton/grpc/advanced/kotlin/GrpcCoroutineContextBridge.java` -- Create: `modules/grpc-advanced/grpc-kotlin/src/main/java/io/backend/skeleton/grpc/advanced/kotlin/GrpcKotlinCompatibilityGate.java` -- Create: `modules/grpc-advanced/grpc-kotlin/src/main/kotlin/io/backend/skeleton/grpc/advanced/kotlin/GrpcCoroutineAdapter.kt` -- Test: `modules/grpc-advanced/grpc-kotlin/src/test/java/io/backend/skeleton/grpc/advanced/kotlin/GrpcKotlinCompatibilityGateTest.java` - -**Interfaces:** -- Consumes: Stable schema artifact, Kotlin generated code and coroutine/Flow runtime. -- Produces: Kotlin coroutine unary와 Flow streaming을 Stable method policy·evidence와 연결하는 optional adapter. - -**Implementation requirements:** -- Java generated contract와 Kotlin generated contract의 schema source를 하나로 유지한다. -- Coroutine cancellation을 gRPC cancellation으로 전파한다. -- Flow backpressure가 Stable stream buffer limit을 우회하지 않는다. -- Kotlin adapter가 Java Core의 evidence·status·deadline types를 보존한다. -- Kotlin toolchain compatibility를 별도 release lane에서 검증한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcKotlinCompatibilityGateTest { - @org.junit.jupiter.api.Test - void schemaDigestMustMatchJavaLane() { - var gate = new GrpcKotlinCompatibilityGate(); - - org.assertj.core.api.Assertions.assertThat( - gate.compatible("sha256:a", "sha256:b")) - .isFalse(); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc-advanced:grpc-kotlin:test --tests 'io.backend.skeleton.grpc.advanced.kotlin.GrpcKotlinCompatibilityGateTest' -``` - -Expected: FAIL because the Advanced production contract does not exist or the capability bypasses the Stable guardrail. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public final class GrpcKotlinCompatibilityGate { - public boolean compatible( - String javaSchemaDigest, - String kotlinSchemaDigest) { - return javaSchemaDigest.equals(kotlinSchemaDigest); - } -} - -public record GrpcKotlinProfile( - String kotlinVersion, - String grpcKotlinVersion, - boolean coroutineCancellationEnabled) {} -``` - -Implement every listed production file with the exact names and invariants above. Preserve the Stable method policy, security, deadline, evidence, error and observability contracts; an Advanced capability is not a raw escape hatch. - -- [ ] **Step 4: Run the focused test and Advanced aggregate suite** - -Run: - -```bash -./gradlew :modules:grpc-advanced:grpc-kotlin:test --tests 'io.backend.skeleton.grpc.advanced.kotlin.GrpcKotlinCompatibilityGateTest' -./gradlew grpcAdvancedTest -``` - -Expected: PASS for the focused test and Advanced aggregate suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc-advanced/grpc-kotlin/src/main/java/io/backend/skeleton/grpc/advanced/kotlin/GrpcKotlinProfile.java' 'modules/grpc-advanced/grpc-kotlin/src/main/java/io/backend/skeleton/grpc/advanced/kotlin/GrpcCoroutineContextBridge.java' 'modules/grpc-advanced/grpc-kotlin/src/main/java/io/backend/skeleton/grpc/advanced/kotlin/GrpcKotlinCompatibilityGate.java' 'modules/grpc-advanced/grpc-kotlin/src/main/kotlin/io/backend/skeleton/grpc/advanced/kotlin/GrpcCoroutineAdapter.kt' 'modules/grpc-advanced/grpc-kotlin/src/test/java/io/backend/skeleton/grpc/advanced/kotlin/GrpcKotlinCompatibilityGateTest.java' -git commit -m "feat: add kotlin grpc adapter" -``` - -### Task 17: Channelz·CSDS 진단과 Advanced Testkit - -**Files:** -- Create: `modules/grpc-advanced/grpc-channel-diagnostics/src/main/java/io/backend/skeleton/grpc/advanced/diagnostics/GrpcChannelDiagnosticsSnapshot.java` -- Create: `modules/grpc-advanced/grpc-channel-diagnostics/src/main/java/io/backend/skeleton/grpc/advanced/diagnostics/GrpcChannelDiagnosticsPolicy.java` -- Create: `modules/grpc-advanced/grpc-channel-diagnostics/src/main/java/io/backend/skeleton/grpc/advanced/diagnostics/GrpcAdvancedInfrastructureTestkit.java` -- Create: `modules/grpc-advanced/grpc-channel-diagnostics/src/main/java/io/backend/skeleton/grpc/advanced/diagnostics/GrpcDiagnosticsRedactor.java` -- Create: `modules/grpc-advanced/grpc-channel-diagnostics/src/test/resources/xds/control-plane-snapshot.json` -- Create: `modules/grpc-advanced/grpc-channel-diagnostics/src/test/resources/grpc-web/envoy.yaml` -- Test: `modules/grpc-advanced/grpc-channel-diagnostics/src/test/java/io/backend/skeleton/grpc/advanced/diagnostics/GrpcChannelDiagnosticsPolicyTest.java` - -**Interfaces:** -- Consumes: Channel runtime, xDS profile, server state and admin authorization. -- Produces: Channelz/CSDS 기반 channel·subchannel·socket·xDS 상태를 관리자에게 제한적으로 제공하는 진단 계층. - -**Implementation requirements:** -- diagnostics endpoint는 admin network·role을 요구한다. -- socket address·authority는 정책에 따라 마스킹한다. -- token, certificate private material, metadata와 payload를 노출하지 않는다. -- xDS CSDS는 xDS feature가 활성화된 경우에만 등록한다. -- testkit은 gRPC-Web proxy, Servlet container, xDS control-plane failure를 검증한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcChannelDiagnosticsPolicyTest { - @org.junit.jupiter.api.Test - void anonymousAccessIsDenied() { - var policy = GrpcChannelDiagnosticsPolicy.adminOnly(); - - org.assertj.core.api.Assertions.assertThat( - policy.allowed(java.util.Set.of())).isFalse(); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc-advanced:grpc-channel-diagnostics:test --tests 'io.backend.skeleton.grpc.advanced.diagnostics.GrpcChannelDiagnosticsPolicyTest' -``` - -Expected: FAIL because the Advanced production contract does not exist or the capability bypasses the Stable guardrail. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcChannelDiagnosticsPolicy( - java.util.Set requiredRoles) { - public static GrpcChannelDiagnosticsPolicy adminOnly() { - return new GrpcChannelDiagnosticsPolicy( - java.util.Set.of("GRPC_ADMIN")); - } - public boolean allowed(java.util.Set roles) { - return roles.containsAll(requiredRoles); - } -} - -public record GrpcChannelDiagnosticsSnapshot( - String channelProfile, - String connectivityState, - int subchannels, - int sockets, - java.time.Instant capturedAt) {} - -public interface GrpcAdvancedInfrastructureTestkit {} -``` - -Implement every listed production file with the exact names and invariants above. Preserve the Stable method policy, security, deadline, evidence, error and observability contracts; an Advanced capability is not a raw escape hatch. - -- [ ] **Step 4: Run the focused test and Advanced aggregate suite** - -Run: - -```bash -./gradlew :modules:grpc-advanced:grpc-channel-diagnostics:test --tests 'io.backend.skeleton.grpc.advanced.diagnostics.GrpcChannelDiagnosticsPolicyTest' -./gradlew grpcAdvancedTest -``` - -Expected: PASS for the focused test and Advanced aggregate suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc-advanced/grpc-channel-diagnostics/src/main/java/io/backend/skeleton/grpc/advanced/diagnostics/GrpcChannelDiagnosticsSnapshot.java' 'modules/grpc-advanced/grpc-channel-diagnostics/src/main/java/io/backend/skeleton/grpc/advanced/diagnostics/GrpcChannelDiagnosticsPolicy.java' 'modules/grpc-advanced/grpc-channel-diagnostics/src/main/java/io/backend/skeleton/grpc/advanced/diagnostics/GrpcAdvancedInfrastructureTestkit.java' 'modules/grpc-advanced/grpc-channel-diagnostics/src/main/java/io/backend/skeleton/grpc/advanced/diagnostics/GrpcDiagnosticsRedactor.java' 'modules/grpc-advanced/grpc-channel-diagnostics/src/test/resources/xds/control-plane-snapshot.json' 'modules/grpc-advanced/grpc-channel-diagnostics/src/test/resources/grpc-web/envoy.yaml' 'modules/grpc-advanced/grpc-channel-diagnostics/src/test/java/io/backend/skeleton/grpc/advanced/diagnostics/GrpcChannelDiagnosticsPolicyTest.java' -git commit -m "feat: add grpc channel diagnostics and testkit" -``` - -### Task 18: Advanced Capability Promotion Gate - -**Files:** -- Create: `modules/grpc-advanced/grpc-advanced-bootstrap/src/main/java/io/backend/skeleton/grpc/advanced/release/GrpcAdvancedPromotionEvidence.java` -- Create: `modules/grpc-advanced/grpc-advanced-bootstrap/src/main/java/io/backend/skeleton/grpc/advanced/release/GrpcAdvancedPromotionDecision.java` -- Create: `modules/grpc-advanced/grpc-advanced-bootstrap/src/main/java/io/backend/skeleton/grpc/advanced/release/GrpcAdvancedPromotionGate.java` -- Create: `modules/grpc-advanced/grpc-advanced-bootstrap/src/main/java/io/backend/skeleton/grpc/advanced/release/GrpcAdvancedSupportMatrix.java` -- Create: `docs/compatibility/grpc-advanced-support-matrix.md` -- Create: `docs/runbooks/grpc-advanced-capabilities.md` -- Create: `docs/adr/ADR-065-grpc-advanced-capability-promotion.md` -- Test: `modules/grpc-advanced/grpc-advanced-bootstrap/src/test/java/io/backend/skeleton/grpc/advanced/release/GrpcAdvancedPromotionGateTest.java` - -**Interfaces:** -- Consumes: Tasks 1–17의 capability-specific compatibility, security, fault, performance, soak evidence. -- Produces: 각 Advanced/Experimental capability를 독립적으로 승격·차단하고 Stable starter 유입을 관리하는 release gate. - -**Implementation requirements:** -- capability별 required evidence를 분리한다. -- Edition, streaming, xDS, gRPC-Web, Servlet, language adapter가 서로의 승격을 묶지 않는다. -- Experimental에서 Advanced Stable로 승격하려면 ADR, runbook, actual environment test와 soak evidence가 필요하다. -- Stable default로 승격하려면 dependency·security·operational cost 재검토가 필요하다. -- 미승격 capability는 feature flag와 별도 module로 유지한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcAdvancedPromotionGateTest { - @org.junit.jupiter.api.Test - void xdsWithoutControlPlaneSoakIsBlocked() { - var evidence = new GrpcAdvancedPromotionEvidence( - GrpcAdvancedCapability.XDS, - java.util.Set.of( - "compatibility", "security", "fault", "adr")); - var gate = new GrpcAdvancedPromotionGate(); - - org.assertj.core.api.Assertions.assertThat( - gate.decide(evidence)) - .isEqualTo(GrpcAdvancedPromotionDecision.BLOCKED); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc-advanced:grpc-advanced-bootstrap:test --tests 'io.backend.skeleton.grpc.advanced.release.GrpcAdvancedPromotionGateTest' -``` - -Expected: FAIL because the Advanced production contract does not exist or the capability bypasses the Stable guardrail. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public enum GrpcAdvancedPromotionDecision { - ADVANCED_STABLE, EXPERIMENTAL, BLOCKED -} - -public record GrpcAdvancedPromotionEvidence( - GrpcAdvancedCapability capability, - java.util.Set passed) {} - -public final class GrpcAdvancedPromotionGate { - public GrpcAdvancedPromotionDecision decide( - GrpcAdvancedPromotionEvidence evidence) { - var required = java.util.Set.of( - "compatibility", "security", "fault", - "performance", "soak", "adr", "runbook"); - return evidence.passed().containsAll(required) - ? GrpcAdvancedPromotionDecision.ADVANCED_STABLE - : GrpcAdvancedPromotionDecision.BLOCKED; - } -} -``` - -Implement every listed production file with the exact names and invariants above. Preserve the Stable method policy, security, deadline, evidence, error and observability contracts; an Advanced capability is not a raw escape hatch. - -- [ ] **Step 4: Run the focused test and Advanced aggregate suite** - -Run: - -```bash -./gradlew :modules:grpc-advanced:grpc-advanced-bootstrap:test --tests 'io.backend.skeleton.grpc.advanced.release.GrpcAdvancedPromotionGateTest' -./gradlew grpcAdvancedTest -``` - -Expected: PASS for the focused test and Advanced aggregate suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc-advanced/grpc-advanced-bootstrap/src/main/java/io/backend/skeleton/grpc/advanced/release/GrpcAdvancedPromotionEvidence.java' 'modules/grpc-advanced/grpc-advanced-bootstrap/src/main/java/io/backend/skeleton/grpc/advanced/release/GrpcAdvancedPromotionDecision.java' 'modules/grpc-advanced/grpc-advanced-bootstrap/src/main/java/io/backend/skeleton/grpc/advanced/release/GrpcAdvancedPromotionGate.java' 'modules/grpc-advanced/grpc-advanced-bootstrap/src/main/java/io/backend/skeleton/grpc/advanced/release/GrpcAdvancedSupportMatrix.java' 'docs/compatibility/grpc-advanced-support-matrix.md' 'docs/runbooks/grpc-advanced-capabilities.md' 'docs/adr/ADR-065-grpc-advanced-capability-promotion.md' 'modules/grpc-advanced/grpc-advanced-bootstrap/src/test/java/io/backend/skeleton/grpc/advanced/release/GrpcAdvancedPromotionGateTest.java' -git commit -m "docs: complete grpc advanced promotion gate" -``` diff --git a/docs/2026-08-13-grpc-type-safe-rpc-platform-implementation-plan.md b/docs/2026-08-13-grpc-type-safe-rpc-platform-implementation-plan.md deleted file mode 100644 index e71864a6..00000000 --- a/docs/2026-08-13-grpc-type-safe-rpc-platform-implementation-plan.md +++ /dev/null @@ -1,5174 +0,0 @@ -# 타입 안전 gRPC 실행 플랫폼 Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Spring Boot 4.1을 실행 엔진으로 사용하면서 Protobuf 계약 거버넌스, Named Channel·Server Profile, 세 축 실행 증거, deadline·retry·idempotency, 안정 Status·Rich Error, DNS·Kubernetes discovery, bounded Server Streaming과 실제 Netty 검증을 제공하는 Stable 타입 안전 gRPC 실행 플랫폼을 구축한다. - -**Architecture:** `.proto`와 generated Java contract는 `grpc-proto-contract`·`grpc-codegen`이 소유하고, 일반 애플리케이션은 Generated Stub과 Typed Service Adapter를 통해서만 RPC를 사용한다. `grpc-core-api`와 `grpc-policy`가 method policy·execution evidence·deadline·retry·metadata·streaming 계약을 정의하며, 상태 변경 RPC의 강한 완료 증거는 JPA Operation Ledger와 Application transaction의 결합으로 제공한다. Stable 범위는 Unary와 Server Streaming이며 Client/Bidi Streaming, xDS, gRPC-Web, Hedging과 언어별 adapter는 Advanced 계획으로 격리한다. - -**Tech Stack:** Java 21, Gradle Kotlin DSL, Spring Boot 4.1 BOM, Boot-managed Spring gRPC, gRPC Java, Protobuf Java, Protobuf Common Protos, Netty and Netty Shaded transports, Buf CLI/Gradle plugin, Protovalidate, Spring Security, Spring Data JPA, Flyway, Micrometer Observation, OpenTelemetry bridge, JUnit 5, AssertJ, ArchUnit, Testcontainers, Toxiproxy. - -## Global Constraints - -- Java runtime은 `21`이다. -- Dependency version의 Source of Truth는 Spring Boot `4.1` BOM이다. -- Spring gRPC, gRPC Java, Protobuf Java, Common Protos 버전을 개별 모듈에서 임의로 override하지 않는다. -- Stable 신규 Schema는 `proto3 + explicit optional`을 사용한다. -- Edition 2024는 Advanced opt-in이고 Edition 2026은 Experimental Watch다. -- Boot-managed Protobuf plugin이 Java code generation의 단일 owner다. -- Buf는 format, lint, breaking, descriptor/image와 schema artifact를 소유한다. -- Stable public API breaking gate는 Buf `FILE` category다. -- 모든 Stable Unary method는 positive deadline profile을 가져야 한다. -- gRPC Status와 업무 Commit evidence를 동일한 상태로 취급하지 않는다. -- 실행 증거는 Transport, Business, Stream 세 축으로 보존한다. -- Response Headers 관측을 Business Commit으로 승격하지 않는다. -- `DEADLINE_EXCEEDED` mutation은 완료 실패가 아니라 Completion Unknown일 수 있다. -- `UNAVAILABLE`만으로 상태 변경 RPC를 재호출하지 않는다. -- explicit retry owner는 Application, gRPC Platform, Service Mesh 중 하나다. -- non-idempotent method에 explicit retry와 hedging을 허용하지 않는다. -- 상태 변경 RPC에 exactly-once transport 보장을 선언하지 않는다. -- `IDEMPOTENCY_KEY_REQUIRED` method는 server-side Operation Ledger와 request fingerprint를 사용한다. -- Operation Ledger와 business mutation은 가능한 경우 동일 DB transaction에 commit한다. -- Stable RPC 유형은 Unary와 Server Streaming이다. -- Client Streaming과 Bidirectional Streaming은 Advanced 계획에서 구현한다. -- Server Streaming writer는 bounded queue와 single serialized writer를 사용한다. -- StreamObserver에 여러 thread가 직접 `onNext()`하지 않는다. -- partial stream delivery 이후 whole-call transparent retry를 수행하지 않는다. -- GraphQL/WebSocket/Messaging/Fileserver/Object Storage의 책임을 gRPC에서 중복 구현하지 않는다. -- 대형 binary는 Fileserver/Object Storage reference로 전달한다. -- 일반 Application source에서 raw gRPC builder·call API import를 금지한다. -- Netty가 Stable certification transport다. -- In-process transport는 fast contract test용이며 network/TLS evidence가 아니다. -- dev·stage·prod는 TLS를 요구하고 trust-all·hostname verification off를 금지한다. -- production Reflection은 기본 비활성이다. -- Stable resolver는 Static·DNS, Stable LB는 pick_first·round_robin이다. -- xDS와 custom resolver/LB는 Advanced다. -- Metric tag에 raw metadata, payload, actor/user/tenant/object/stream/idempotency ID를 넣지 않는다. -- Stable module root는 `modules/grpc`이다. -- Root package는 `io.backend.skeleton.grpc`이다. -- 모든 task는 red-green TDD와 독립 commit으로 끝난다. -- 실제 저장소 구조가 예상 경로와 다르면 파일 경로만 매핑하고 공개 계약·불변 조건·테스트 의미는 변경하지 않는다. - ---- - -## Execution Baseline - -```text -Stable Task 1–53 -→ Stable Release Gate -→ Advanced Task 1–18 -``` - -## Stable Module Map - -```text -modules/grpc/ -├── grpc-core-api -├── grpc-proto-contract -├── grpc-codegen -├── grpc-client -├── grpc-server -├── grpc-policy -├── grpc-discovery -├── grpc-admin -├── grpc-observability -├── grpc-operation-ledger-jpa -├── grpc-spring-boot-starter -├── grpc-testkit-core -├── grpc-testkit-inprocess -├── grpc-testkit-netty -└── grpc-testkit-fault -``` - -## File Ownership Rules - -```text -grpc-core-api -→ identifiers, method policy, evidence, failure, request context, deadline primitive - -grpc-proto-contract -→ public/common .proto and schema source - -grpc-codegen -→ Gradle convention, Buf governance, descriptor and consumer fixture - -grpc-client -→ named channel, runtime generation, typed stub, client metadata - -grpc-server -→ service adapter, interceptor order, Netty profiles, architecture boundary - -grpc-policy -→ validation, deadline, cancellation, security, status, retry, - idempotency, streaming, size and compression - -grpc-discovery -→ stable resolver, load-balancing and Kubernetes profile - -grpc-admin -→ health, reflection, drain and safe runtime snapshot - -grpc-observability -→ bounded metric/trace/logging convention - -grpc-operation-ledger-jpa -→ durable mutation idempotency and completion evidence - -grpc-spring-boot-starter -→ auto-configuration, properties and startup validation - -grpc-testkit-* -→ in-process, real Netty, fault, reliability and performance certification -``` - -## Delivery Phases - -| Phase | Tasks | Independently testable result | -|---|---:|---| -| Foundation | 1–7 | Stable modules, identifiers, policy, evidence, failure, deadline, context | -| Contract Governance | 8–11 | Proto rules, Buf, Boot codegen, descriptor/consumer gate | -| Server Boundary | 12–23 | Validation, architecture, adapter, interceptor, server, security, health, reflection, drain | -| Client Runtime | 24–30 | Named Channel, generation, typed stub, metadata, deadline, cancellation, retry owner | -| Reliability | 31–33 | Retry eligibility, operation ledger, idempotency | -| Recovery·Discovery | 34–36 | Completion query, DNS/LB, Kubernetes profiles | -| Server Streaming | 37–43 | Envelope, writer, flow control, resume, lifetime, wait-for-ready, payload | -| Operations·Verification | 44–53 | Observability, admin, in-process, Netty, fault, reliability, performance, starter, release gate | - ---- -### Task 1: Gradle 멀티모듈과 Stable 테스트 집계 - -**Files:** -- Modify: `settings.gradle.kts` -- Modify: `build.gradle.kts` -- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/core/GrpcStableModuleCatalog.java` -- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/core/GrpcStableBuildInvariant.java` -- Create: `modules/grpc/build.gradle.kts` -- Create: `modules/grpc/grpc-core-api/build.gradle.kts` -- Create: `modules/grpc/grpc-proto-contract/build.gradle.kts` -- Create: `modules/grpc/grpc-codegen/build.gradle.kts` -- Create: `modules/grpc/grpc-client/build.gradle.kts` -- Create: `modules/grpc/grpc-server/build.gradle.kts` -- Create: `modules/grpc/grpc-policy/build.gradle.kts` -- Create: `modules/grpc/grpc-discovery/build.gradle.kts` -- Create: `modules/grpc/grpc-admin/build.gradle.kts` -- Create: `modules/grpc/grpc-observability/build.gradle.kts` -- Create: `modules/grpc/grpc-operation-ledger-jpa/build.gradle.kts` -- Create: `modules/grpc/grpc-spring-boot-starter/build.gradle.kts` -- Create: `modules/grpc/grpc-testkit-core/build.gradle.kts` -- Create: `modules/grpc/grpc-testkit-inprocess/build.gradle.kts` -- Create: `modules/grpc/grpc-testkit-netty/build.gradle.kts` -- Create: `modules/grpc/grpc-testkit-fault/build.gradle.kts` -- Test: `modules/grpc/grpc-core-api/src/test/java/io/backend/skeleton/grpc/core/GrpcStableModuleCatalogTest.java` - -**Interfaces:** -- Consumes: Root Gradle settings, version catalog와 Spring Boot 4.1 BOM. -- Produces: `modules/grpc`의 Stable module 목록, 금지 dependency 규칙, `grpcStableTest` aggregate task. - -**Implementation requirements:** -- Stable module은 `grpc-core-api`, `grpc-proto-contract`, `grpc-codegen`, `grpc-client`, `grpc-server`, `grpc-policy`, `grpc-discovery`, `grpc-admin`, `grpc-observability`, `grpc-operation-ledger-jpa`, `grpc-spring-boot-starter`, `grpc-testkit-core`, `grpc-testkit-inprocess`, `grpc-testkit-netty`, `grpc-testkit-fault`로 고정한다. -- 모든 runtime dependency version은 Spring Boot 4.1 BOM을 사용한다. -- `grpc-proto-contract`와 `grpc-core-api`는 Spring Boot·Netty runtime에 의존하지 않는다. -- Stable starter가 `modules/grpc-advanced`를 참조하면 build를 실패시킨다. -- `grpcStableTest`, `grpcContractTest`, `grpcNettyTest`, `grpcFaultTest`, `grpcPerformanceTest` task를 등록한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcStableModuleCatalogTest { - @org.junit.jupiter.api.Test - void stableModuleSetIsExactAndAdvancedIsExcluded() { - var catalog = GrpcStableModuleCatalog.standard(); - - org.assertj.core.api.Assertions.assertThat(catalog.modules()) - .contains("grpc-core-api", "grpc-client", "grpc-server") - .doesNotContain("grpc-xds", "grpc-web"); - org.assertj.core.api.Assertions.assertThat( - GrpcStableBuildInvariant.advancedDependencyAllowed()) - .isFalse(); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-core-api:test --tests 'io.backend.skeleton.grpc.core.GrpcStableModuleCatalogTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcStableModuleCatalog( - java.util.Set modules) { - - public static GrpcStableModuleCatalog standard() { - return new GrpcStableModuleCatalog(java.util.Set.of( - "grpc-core-api", "grpc-proto-contract", "grpc-codegen", - "grpc-client", "grpc-server", "grpc-policy", - "grpc-discovery", "grpc-admin", "grpc-observability", - "grpc-operation-ledger-jpa", "grpc-spring-boot-starter", - "grpc-testkit-core", "grpc-testkit-inprocess", - "grpc-testkit-netty", "grpc-testkit-fault")); - } -} - -public final class GrpcStableBuildInvariant { - public static boolean advancedDependencyAllowed() { - return false; - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-core-api:test --tests 'io.backend.skeleton.grpc.core.GrpcStableModuleCatalogTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/core/GrpcStableModuleCatalog.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/core/GrpcStableBuildInvariant.java' 'modules/grpc/build.gradle.kts' 'modules/grpc/grpc-core-api/build.gradle.kts' 'modules/grpc/grpc-proto-contract/build.gradle.kts' 'modules/grpc/grpc-codegen/build.gradle.kts' 'modules/grpc/grpc-client/build.gradle.kts' 'modules/grpc/grpc-server/build.gradle.kts' 'modules/grpc/grpc-policy/build.gradle.kts' 'modules/grpc/grpc-discovery/build.gradle.kts' 'modules/grpc/grpc-admin/build.gradle.kts' 'modules/grpc/grpc-observability/build.gradle.kts' 'modules/grpc/grpc-operation-ledger-jpa/build.gradle.kts' 'modules/grpc/grpc-spring-boot-starter/build.gradle.kts' 'modules/grpc/grpc-testkit-core/build.gradle.kts' 'modules/grpc/grpc-testkit-inprocess/build.gradle.kts' 'modules/grpc/grpc-testkit-netty/build.gradle.kts' 'modules/grpc/grpc-testkit-fault/build.gradle.kts' 'modules/grpc/grpc-core-api/src/test/java/io/backend/skeleton/grpc/core/GrpcStableModuleCatalogTest.java' 'settings.gradle.kts' 'build.gradle.kts' -git commit -m "build: establish grpc stable module graph" -``` - -### Task 2: Core 식별자와 RPC 유형 - -**Files:** -- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/core/GrpcMethodName.java` -- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/core/GrpcServiceName.java` -- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/core/GrpcChannelProfileName.java` -- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/core/RpcType.java` -- Test: `modules/grpc/grpc-core-api/src/test/java/io/backend/skeleton/grpc/core/GrpcCoreIdentifiersTest.java` - -**Interfaces:** -- Consumes: Task 1의 Stable module graph. -- Produces: 공백·불완전 이름을 거부하는 bounded identifier와 Unary·Streaming RPC 유형. - -**Implementation requirements:** -- `GrpcMethodName`은 canonical full method name인 `package.Service/Method` 형식을 요구한다. -- `GrpcServiceName`과 `GrpcChannelProfileName`은 빈 값과 제어 문자를 거부한다. -- `RpcType`은 `UNARY`, `SERVER_STREAMING`, `CLIENT_STREAMING`, `BIDI_STREAMING`만 가진다. -- 식별자 원문은 metric label에 자동 사용하지 않는다. -- generated descriptor와 catalog가 동일 canonical name을 사용한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcCoreIdentifiersTest { - @org.junit.jupiter.api.Test - void methodNameRequiresServiceAndMethod() { - org.assertj.core.api.Assertions.assertThatThrownBy( - () -> new GrpcMethodName("DocumentService")) - .isInstanceOf(IllegalArgumentException.class); - - org.assertj.core.api.Assertions.assertThat( - new GrpcMethodName( - "hyeonworks.document.v1.DocumentService/GetDocument") - .value()) - .endsWith("/GetDocument"); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-core-api:test --tests 'io.backend.skeleton.grpc.core.GrpcCoreIdentifiersTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcMethodName(String value) { - public GrpcMethodName { - if (value == null - || !value.matches("[A-Za-z0-9_.]+/[A-Za-z0-9_]+")) { - throw new IllegalArgumentException( - "full gRPC method name is required"); - } - } -} - -public record GrpcServiceName(String value) { - public GrpcServiceName { - if (value == null || value.isBlank()) { - throw new IllegalArgumentException("service name"); - } - } -} - -public enum RpcType { - UNARY, SERVER_STREAMING, CLIENT_STREAMING, BIDI_STREAMING -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-core-api:test --tests 'io.backend.skeleton.grpc.core.GrpcCoreIdentifiersTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/core/GrpcMethodName.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/core/GrpcServiceName.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/core/GrpcChannelProfileName.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/core/RpcType.java' 'modules/grpc/grpc-core-api/src/test/java/io/backend/skeleton/grpc/core/GrpcCoreIdentifiersTest.java' -git commit -m "feat: add grpc core identifiers" -``` - -### Task 3: Method Policy와 Idempotency Profile - -**Files:** -- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/policy/RpcIdempotencyProfile.java` -- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/policy/GrpcMethodPolicy.java` -- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/policy/GrpcMethodPolicyCatalog.java` -- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/policy/WaitForReadyPolicy.java` -- Test: `modules/grpc/grpc-core-api/src/test/java/io/backend/skeleton/grpc/policy/GrpcMethodPolicyCatalogTest.java` - -**Interfaces:** -- Consumes: Task 2의 canonical method name과 RPC 유형. -- Produces: method별 deadline·retry·idempotency·size·metadata 정책을 조회하는 immutable catalog. - -**Implementation requirements:** -- Idempotency profile은 `READ_ONLY`, `NATURALLY_IDEMPOTENT`, `IDEMPOTENCY_KEY_REQUIRED`, `CONDITIONALLY_IDEMPOTENT`, `NON_IDEMPOTENT`, `STREAMING`이다. -- Stable Unary method에는 deadline profile이 반드시 존재한다. -- `NON_IDEMPOTENT` method에 explicit retry profile을 연결하면 catalog build를 실패시킨다. -- `STREAMING` profile은 whole-call retry profile을 허용하지 않는다. -- 중복 method registration과 descriptor에 없는 method registration을 거부한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcMethodPolicyCatalogTest { - @org.junit.jupiter.api.Test - void nonIdempotentMethodCannotEnableExplicitRetry() { - var policy = GrpcMethodPolicy.nonIdempotentUnary( - new GrpcMethodName("x.y.CommandService/Create"), - "user-sync", - "retry-write"); - - org.assertj.core.api.Assertions.assertThatThrownBy( - () -> GrpcMethodPolicyCatalog.of(java.util.List.of(policy))) - .isInstanceOf(IllegalArgumentException.class); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-core-api:test --tests 'io.backend.skeleton.grpc.policy.GrpcMethodPolicyCatalogTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public enum RpcIdempotencyProfile { - READ_ONLY, - NATURALLY_IDEMPOTENT, - IDEMPOTENCY_KEY_REQUIRED, - CONDITIONALLY_IDEMPOTENT, - NON_IDEMPOTENT, - STREAMING -} - -public record GrpcMethodPolicy( - GrpcMethodName method, - RpcType rpcType, - RpcIdempotencyProfile idempotency, - String deadlineProfile, - String retryProfile, - WaitForReadyPolicy waitForReady) { - - public static GrpcMethodPolicy nonIdempotentUnary( - GrpcMethodName method, - String deadline, - String retry) { - return new GrpcMethodPolicy(method, RpcType.UNARY, - RpcIdempotencyProfile.NON_IDEMPOTENT, - deadline, retry, WaitForReadyPolicy.DISABLED); - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-core-api:test --tests 'io.backend.skeleton.grpc.policy.GrpcMethodPolicyCatalogTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/policy/RpcIdempotencyProfile.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/policy/GrpcMethodPolicy.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/policy/GrpcMethodPolicyCatalog.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/policy/WaitForReadyPolicy.java' 'modules/grpc/grpc-core-api/src/test/java/io/backend/skeleton/grpc/policy/GrpcMethodPolicyCatalogTest.java' -git commit -m "feat: define grpc method policy catalog" -``` - -### Task 4: 세 축 실행 증거 모델 - -**Files:** -- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/evidence/GrpcTransportEvidence.java` -- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/evidence/GrpcBusinessEvidence.java` -- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/evidence/GrpcStreamEvidence.java` -- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/evidence/GrpcExecutionEvidence.java` -- Test: `modules/grpc/grpc-core-api/src/test/java/io/backend/skeleton/grpc/evidence/GrpcExecutionEvidenceTest.java` - -**Interfaces:** -- Consumes: Task 2의 RPC 유형과 method identity. -- Produces: Transport·Business·Stream evidence를 독립적으로 보존하는 immutable snapshot. - -**Implementation requirements:** -- `RESPONSE_HEADERS_SEEN`을 `COMMIT_CONFIRMED`로 자동 승격하지 않는다. -- Stream evidence는 `None`, `Partial`, `Applied`, `Resumable` sealed hierarchy다. -- `APPLIED`는 application acknowledgement 증거가 있을 때만 생성한다. -- Unary RPC에서 non-none stream evidence를 설정하면 검증 오류다. -- Evidence snapshot은 예외와 관측 event에서 동일 타입을 사용한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcExecutionEvidenceTest { - @org.junit.jupiter.api.Test - void responseHeadersDoNotProveBusinessCommit() { - var evidence = new GrpcExecutionEvidence( - GrpcTransportEvidence.RESPONSE_HEADERS_SEEN, - GrpcBusinessEvidence.UNKNOWN, - new GrpcStreamEvidence.None()); - - org.assertj.core.api.Assertions.assertThat(evidence.business()) - .isEqualTo(GrpcBusinessEvidence.UNKNOWN); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-core-api:test --tests 'io.backend.skeleton.grpc.evidence.GrpcExecutionEvidenceTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public enum GrpcTransportEvidence { - NOT_SENT, - MAY_HAVE_LEFT_CLIENT, - RESPONSE_HEADERS_SEEN, - RESPONSE_MESSAGE_SEEN, - TRAILERS_SEEN -} - -public enum GrpcBusinessEvidence { - NOT_OBSERVED, - APPLICATION_STARTED, - COMMIT_CONFIRMED, - ABORT_CONFIRMED, - UNKNOWN -} - -public sealed interface GrpcStreamEvidence { - record None() implements GrpcStreamEvidence {} - record Partial(long lastSequence) implements GrpcStreamEvidence {} - record Applied(long lastAppliedSequence) - implements GrpcStreamEvidence {} - record Resumable(String snapshotVersion, String cursor) - implements GrpcStreamEvidence {} -} - -public record GrpcExecutionEvidence( - GrpcTransportEvidence transport, - GrpcBusinessEvidence business, - GrpcStreamEvidence stream) {} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-core-api:test --tests 'io.backend.skeleton.grpc.evidence.GrpcExecutionEvidenceTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/evidence/GrpcTransportEvidence.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/evidence/GrpcBusinessEvidence.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/evidence/GrpcStreamEvidence.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/evidence/GrpcExecutionEvidence.java' 'modules/grpc/grpc-core-api/src/test/java/io/backend/skeleton/grpc/evidence/GrpcExecutionEvidenceTest.java' -git commit -m "feat: model grpc execution evidence" -``` - -### Task 5: Stable 오류·Status·Completion Outcome - -**Files:** -- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/error/GrpcFailureCategory.java` -- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/error/GrpcCompletionOutcome.java` -- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/error/GrpcFailureContext.java` -- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/error/GrpcPlatformException.java` -- Test: `modules/grpc/grpc-core-api/src/test/java/io/backend/skeleton/grpc/error/GrpcFailureContextTest.java` - -**Interfaces:** -- Consumes: Task 4의 실행 증거. -- Produces: gRPC Status와 업무 완료 불명확성을 분리하는 안정 오류 모델. - -**Implementation requirements:** -- `GrpcCompletionOutcome`은 `COMPLETED`, `REJECTED`, `COMPLETION_UNKNOWN`, `PARTIAL_STREAM`을 표현한다. -- failure context에 method, status code, evidence, retry disposition, attempt, elapsed, trace ID를 보존한다. -- payload, raw metadata, token, idempotency key 원문을 exception message에 넣지 않는다. -- `DEADLINE_EXCEEDED` mutation은 기본적으로 `COMPLETION_UNKNOWN` 후보다. -- provider·driver exception은 cause로 보존하되 공개 wire detail로 자동 변환하지 않는다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcFailureContextTest { - @org.junit.jupiter.api.Test - void deadlineAfterPossibleSendIsCompletionUnknown() { - var context = GrpcFailureContext.deadlineExceeded( - new GrpcMethodName("x.y.CommandService/Create"), - GrpcTransportEvidence.MAY_HAVE_LEFT_CLIENT); - - org.assertj.core.api.Assertions.assertThat(context.outcome()) - .isEqualTo(GrpcCompletionOutcome.COMPLETION_UNKNOWN); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-core-api:test --tests 'io.backend.skeleton.grpc.error.GrpcFailureContextTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public enum GrpcCompletionOutcome { - COMPLETED, REJECTED, COMPLETION_UNKNOWN, PARTIAL_STREAM -} - -public record GrpcFailureContext( - GrpcMethodName method, - String statusCode, - GrpcExecutionEvidence evidence, - GrpcCompletionOutcome outcome, - boolean retryable, - int attempt, - java.time.Duration elapsed, - String traceId) { - - public static GrpcFailureContext deadlineExceeded( - GrpcMethodName method, - GrpcTransportEvidence transport) { - return new GrpcFailureContext(method, "DEADLINE_EXCEEDED", - new GrpcExecutionEvidence(transport, - GrpcBusinessEvidence.UNKNOWN, - new GrpcStreamEvidence.None()), - GrpcCompletionOutcome.COMPLETION_UNKNOWN, - false, 1, java.time.Duration.ZERO, "redacted"); - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-core-api:test --tests 'io.backend.skeleton.grpc.error.GrpcFailureContextTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/error/GrpcFailureCategory.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/error/GrpcCompletionOutcome.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/error/GrpcFailureContext.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/error/GrpcPlatformException.java' 'modules/grpc/grpc-core-api/src/test/java/io/backend/skeleton/grpc/error/GrpcFailureContextTest.java' -git commit -m "feat: add grpc failure and completion model" -``` - -### Task 6: Deadline·Cancellation Core Primitive - -**Files:** -- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/deadline/GrpcDeadlineBudget.java` -- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/deadline/GrpcCancellationToken.java` -- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/deadline/GrpcDeadlineProfile.java` -- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/deadline/GrpcDeadlineExceededException.java` -- Test: `modules/grpc/grpc-core-api/src/test/java/io/backend/skeleton/grpc/deadline/GrpcDeadlineBudgetTest.java` - -**Interfaces:** -- Consumes: Task 3의 method policy. -- Produces: 상위 deadline과 method profile에서 안전 margin을 차감한 downstream budget. - -**Implementation requirements:** -- 모든 Stable Unary method에 positive deadline을 요구한다. -- downstream deadline은 parent remaining과 method default 중 더 짧은 값을 사용한다. -- serialization/trailer safety reserve를 차감한다. -- 남은 시간이 minimum attempt budget보다 작으면 새 dependency call을 시작하지 않는다. -- cancellation token은 cancel reason과 발생 시각을 보존하고 idempotent하게 취소된다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcDeadlineBudgetTest { - @org.junit.jupiter.api.Test - void downstreamBudgetNeverExceedsParent() { - var parent = GrpcDeadlineBudget.of( - java.time.Duration.ofSeconds(2)); - var downstream = parent.child( - java.time.Duration.ofSeconds(5), - java.time.Duration.ofMillis(100)); - - org.assertj.core.api.Assertions.assertThat( - downstream.remaining()) - .isLessThanOrEqualTo(java.time.Duration.ofMillis(1900)); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-core-api:test --tests 'io.backend.skeleton.grpc.deadline.GrpcDeadlineBudgetTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcDeadlineBudget( - java.time.Instant deadline) { - - public static GrpcDeadlineBudget of( - java.time.Duration duration) { - if (duration.isZero() || duration.isNegative()) { - throw new IllegalArgumentException("positive deadline"); - } - return new GrpcDeadlineBudget( - java.time.Instant.now().plus(duration)); - } - - public java.time.Duration remaining() { - var value = java.time.Duration.between( - java.time.Instant.now(), deadline); - return value.isNegative() ? java.time.Duration.ZERO : value; - } - - public GrpcDeadlineBudget child( - java.time.Duration configured, - java.time.Duration reserve) { - var child = java.time.Instant.now() - .plus(configured).minus(reserve); - return new GrpcDeadlineBudget( - child.isBefore(deadline) ? child : deadline.minus(reserve)); - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-core-api:test --tests 'io.backend.skeleton.grpc.deadline.GrpcDeadlineBudgetTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/deadline/GrpcDeadlineBudget.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/deadline/GrpcCancellationToken.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/deadline/GrpcDeadlineProfile.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/deadline/GrpcDeadlineExceededException.java' 'modules/grpc/grpc-core-api/src/test/java/io/backend/skeleton/grpc/deadline/GrpcDeadlineBudgetTest.java' -git commit -m "feat: add grpc deadline and cancellation primitives" -``` - -### Task 7: Metadata·Actor·Tenant Request Context - -**Files:** -- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/context/GrpcRequestContext.java` -- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/context/GrpcMetadataKey.java` -- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/context/GrpcMetadataBudget.java` -- Create: `modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/context/GrpcClientIdentity.java` -- Test: `modules/grpc/grpc-core-api/src/test/java/io/backend/skeleton/grpc/context/GrpcMetadataBudgetTest.java` - -**Interfaces:** -- Consumes: Task 6의 deadline budget과 기존 Security의 Actor·Tenant context. -- Produces: 허용된 metadata만 immutable request context로 변환하는 bounded contract. - -**Implementation requirements:** -- metadata key는 allowlist catalog에 등록된 ASCII/binary key만 허용한다. -- `grpc-` prefix 사용자 정의를 거부한다. -- 전체 hard budget과 user-defined soft budget을 분리한다. -- Actor·Tenant는 검증된 authentication source에서만 생성하고 raw tenant header를 직접 신뢰하지 않는다. -- credential, token, PII와 raw metadata map은 public request context에서 제거한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcMetadataBudgetTest { - @org.junit.jupiter.api.Test - void oversizedUserMetadataIsRejected() { - var budget = new GrpcMetadataBudget(8192, 4096); - - org.assertj.core.api.Assertions.assertThatThrownBy( - () -> budget.validate(2000, 5000)) - .isInstanceOf(IllegalArgumentException.class); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-core-api:test --tests 'io.backend.skeleton.grpc.context.GrpcMetadataBudgetTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcMetadataBudget( - int hardBytes, - int userBytes) { - - public void validate(int frameworkBytes, int suppliedUserBytes) { - if (suppliedUserBytes > userBytes - || frameworkBytes + suppliedUserBytes > hardBytes) { - throw new IllegalArgumentException( - "gRPC metadata budget exceeded"); - } - } -} - -public record GrpcRequestContext( - Object actor, - Object tenant, - java.util.Locale locale, - String correlationId, - String traceId, - GrpcDeadlineBudget deadline, - GrpcClientIdentity client) {} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-core-api:test --tests 'io.backend.skeleton.grpc.context.GrpcMetadataBudgetTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/context/GrpcRequestContext.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/context/GrpcMetadataKey.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/context/GrpcMetadataBudget.java' 'modules/grpc/grpc-core-api/src/main/java/io/backend/skeleton/grpc/context/GrpcClientIdentity.java' 'modules/grpc/grpc-core-api/src/test/java/io/backend/skeleton/grpc/context/GrpcMetadataBudgetTest.java' -git commit -m "feat: define grpc metadata and request context" -``` - -### Task 8: Proto Style Manifest와 Schema 규칙 - -**Files:** -- Create: `modules/grpc/grpc-proto-contract/src/main/java/io/backend/skeleton/grpc/contract/GrpcProtoStyleManifest.java` -- Create: `modules/grpc/grpc-proto-contract/src/main/java/io/backend/skeleton/grpc/contract/GrpcProtoRuleViolation.java` -- Create: `modules/grpc/grpc-proto-contract/src/main/java/io/backend/skeleton/grpc/contract/GrpcProtoContractValidator.java` -- Create: `modules/grpc/grpc-proto-contract/src/main/proto/hyeonworks/grpc/common/v1/error.proto` -- Create: `modules/grpc/grpc-proto-contract/src/main/proto/hyeonworks/grpc/common/v1/stream.proto` -- Create: `modules/grpc/grpc-proto-contract/src/main/proto/buf.yaml` -- Test: `modules/grpc/grpc-proto-contract/src/test/java/io/backend/skeleton/grpc/contract/GrpcProtoContractValidatorTest.java` - -**Interfaces:** -- Consumes: Task 2의 service·method identity와 source `.proto` files. -- Produces: proto3+optional, package, reserved, enum zero, WKT 사용 규칙을 검증하는 contract validator. - -**Implementation requirements:** -- Stable source는 `proto3`이며 presence가 필요한 scalar는 `optional`이다. -- package는 `{organization}.{domain}.v{major}` 규칙을 따른다. -- `java_multiple_files = true`와 hand-written package와 분리된 `java_package`를 요구한다. -- 삭제된 field number/name의 `reserved` 선언을 schema history와 비교한다. -- enum 0 값은 `_UNSPECIFIED` suffix를 요구하고 `Any`, `Struct`, map 사용을 allowlist한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcProtoContractValidatorTest { - @org.junit.jupiter.api.Test - void enumWithoutUnspecifiedZeroIsRejected() { - var manifest = GrpcProtoStyleManifest.stable(); - var validator = new GrpcProtoContractValidator(manifest); - - org.assertj.core.api.Assertions.assertThatThrownBy( - () -> validator.validateEnumZero("Status", "ACTIVE", 0)) - .isInstanceOf(GrpcProtoRuleViolation.class); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-proto-contract:test --tests 'io.backend.skeleton.grpc.contract.GrpcProtoContractValidatorTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcProtoStyleManifest( - String syntax, - boolean explicitPresence, - boolean javaMultipleFiles, - String enumZeroSuffix) { - - public static GrpcProtoStyleManifest stable() { - return new GrpcProtoStyleManifest( - "proto3", true, true, "_UNSPECIFIED"); - } -} - -public final class GrpcProtoContractValidator { - private final GrpcProtoStyleManifest manifest; - - public GrpcProtoContractValidator( - GrpcProtoStyleManifest manifest) { - this.manifest = manifest; - } - - public void validateEnumZero( - String enumName, String valueName, int number) { - if (number == 0 - && !valueName.endsWith( - manifest.enumZeroSuffix())) { - throw new GrpcProtoRuleViolation(enumName); - } - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-proto-contract:test --tests 'io.backend.skeleton.grpc.contract.GrpcProtoContractValidatorTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-proto-contract/src/main/java/io/backend/skeleton/grpc/contract/GrpcProtoStyleManifest.java' 'modules/grpc/grpc-proto-contract/src/main/java/io/backend/skeleton/grpc/contract/GrpcProtoRuleViolation.java' 'modules/grpc/grpc-proto-contract/src/main/java/io/backend/skeleton/grpc/contract/GrpcProtoContractValidator.java' 'modules/grpc/grpc-proto-contract/src/main/proto/hyeonworks/grpc/common/v1/error.proto' 'modules/grpc/grpc-proto-contract/src/main/proto/hyeonworks/grpc/common/v1/stream.proto' 'modules/grpc/grpc-proto-contract/src/main/proto/buf.yaml' 'modules/grpc/grpc-proto-contract/src/test/java/io/backend/skeleton/grpc/contract/GrpcProtoContractValidatorTest.java' -git commit -m "feat: enforce grpc proto style manifest" -``` - -### Task 9: Buf Format·Lint·Breaking Governance - -**Files:** -- Create: `modules/grpc/grpc-codegen/src/main/java/io/backend/skeleton/grpc/codegen/GrpcBufPolicy.java` -- Create: `modules/grpc/grpc-codegen/src/main/java/io/backend/skeleton/grpc/codegen/GrpcBreakingCategory.java` -- Create: `modules/grpc/grpc-codegen/src/main/java/io/backend/skeleton/grpc/codegen/GrpcSchemaBaseline.java` -- Create: `modules/grpc/grpc-codegen/src/main/kotlin/io.backend.grpc-buf-conventions.gradle.kts` -- Create: `modules/grpc/grpc-proto-contract/buf.lock` -- Create: `modules/grpc/grpc-proto-contract/buf.gen.yaml` -- Test: `modules/grpc/grpc-codegen/src/test/java/io/backend/skeleton/grpc/codegen/GrpcBufPolicyTest.java` - -**Interfaces:** -- Consumes: Task 8의 Proto style manifest와 released schema artifact. -- Produces: Buf `FILE` breaking gate, lint·format·descriptor build를 Gradle lifecycle에 연결하는 convention. - -**Implementation requirements:** -- `bufFormatCheck`, `bufLint`, `bufBuild`, `bufBreaking`을 CI task로 등록한다. -- Stable public API는 Buf `FILE` category를 사용한다. -- 비교 baseline은 마지막 released schema artifact로 고정한다. -- wire-only compatibility로 source-breaking 변경을 통과시키지 않는다. -- breaking 결과와 schema hash를 release artifact에 보존한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcBufPolicyTest { - @org.junit.jupiter.api.Test - void stableUsesFileBreakingCategory() { - var policy = GrpcBufPolicy.stable( - new GrpcSchemaBaseline("1.0.0", "sha256:abc")); - - org.assertj.core.api.Assertions.assertThat(policy.category()) - .isEqualTo(GrpcBreakingCategory.FILE); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-codegen:test --tests 'io.backend.skeleton.grpc.codegen.GrpcBufPolicyTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public enum GrpcBreakingCategory { - FILE, PACKAGE, WIRE_JSON, WIRE -} - -public record GrpcSchemaBaseline( - String version, - String digest) {} - -public record GrpcBufPolicy( - GrpcBreakingCategory category, - GrpcSchemaBaseline baseline, - boolean formatRequired, - boolean lintRequired) { - - public static GrpcBufPolicy stable( - GrpcSchemaBaseline baseline) { - return new GrpcBufPolicy( - GrpcBreakingCategory.FILE, - baseline, true, true); - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-codegen:test --tests 'io.backend.skeleton.grpc.codegen.GrpcBufPolicyTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-codegen/src/main/java/io/backend/skeleton/grpc/codegen/GrpcBufPolicy.java' 'modules/grpc/grpc-codegen/src/main/java/io/backend/skeleton/grpc/codegen/GrpcBreakingCategory.java' 'modules/grpc/grpc-codegen/src/main/java/io/backend/skeleton/grpc/codegen/GrpcSchemaBaseline.java' 'modules/grpc/grpc-codegen/src/main/kotlin/io.backend.grpc-buf-conventions.gradle.kts' 'modules/grpc/grpc-proto-contract/buf.lock' 'modules/grpc/grpc-proto-contract/buf.gen.yaml' 'modules/grpc/grpc-codegen/src/test/java/io/backend/skeleton/grpc/codegen/GrpcBufPolicyTest.java' -git commit -m "build: add buf schema governance" -``` - -### Task 10: Spring Boot BOM 기반 Protobuf·gRPC Java Codegen - -**Files:** -- Create: `modules/grpc/grpc-codegen/src/main/java/io/backend/skeleton/grpc/codegen/GrpcCodegenManifest.java` -- Create: `modules/grpc/grpc-codegen/src/main/java/io/backend/skeleton/grpc/codegen/GrpcGeneratedPackagePolicy.java` -- Create: `modules/grpc/grpc-codegen/src/main/java/io/backend/skeleton/grpc/codegen/GrpcCodegenOutput.java` -- Create: `modules/grpc/grpc-codegen/src/main/kotlin/io.backend.grpc-codegen-conventions.gradle.kts` -- Create: `modules/grpc/grpc-codegen/src/main/resources/META-INF/gradle-plugins/io.backend.grpc-codegen-conventions.properties` -- Test: `modules/grpc/grpc-codegen/src/test/java/io/backend/skeleton/grpc/codegen/GrpcCodegenManifestTest.java` - -**Interfaces:** -- Consumes: Task 9의 Buf policy와 Spring Boot 4.1 dependency management. -- Produces: protoc·grpc-java generation option, output package, descriptor path를 고정하는 build convention. - -**Implementation requirements:** -- Protobuf와 gRPC Java plugin version을 Boot BOM에서 읽고 중복 version 선언을 금지한다. -- generated source root는 build directory 아래에 둔다. -- hand-written package와 generated `*.proto` package를 겹치게 하지 않는다. -- Java code generation owner는 Boot-managed protobuf plugin 한 곳이다. -- descriptor set과 source info를 release artifact로 생성한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcCodegenManifestTest { - @org.junit.jupiter.api.Test - void bootBomOwnsToolVersions() { - var manifest = GrpcCodegenManifest.bootManaged(); - - org.assertj.core.api.Assertions.assertThat( - manifest.explicitProtocVersion()).isEmpty(); - org.assertj.core.api.Assertions.assertThat( - manifest.descriptorSetEnabled()).isTrue(); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-codegen:test --tests 'io.backend.skeleton.grpc.codegen.GrpcCodegenManifestTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcCodegenManifest( - java.util.Optional explicitProtocVersion, - java.util.Optional explicitGrpcPluginVersion, - boolean descriptorSetEnabled, - boolean includeSourceInfo) { - - public static GrpcCodegenManifest bootManaged() { - return new GrpcCodegenManifest( - java.util.Optional.empty(), - java.util.Optional.empty(), - true, true); - } -} - -public record GrpcGeneratedPackagePolicy( - String protoPackagePrefix, - String javaPackagePrefix, - boolean separateFromHandwritten) {} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-codegen:test --tests 'io.backend.skeleton.grpc.codegen.GrpcCodegenManifestTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-codegen/src/main/java/io/backend/skeleton/grpc/codegen/GrpcCodegenManifest.java' 'modules/grpc/grpc-codegen/src/main/java/io/backend/skeleton/grpc/codegen/GrpcGeneratedPackagePolicy.java' 'modules/grpc/grpc-codegen/src/main/java/io/backend/skeleton/grpc/codegen/GrpcCodegenOutput.java' 'modules/grpc/grpc-codegen/src/main/kotlin/io.backend.grpc-codegen-conventions.gradle.kts' 'modules/grpc/grpc-codegen/src/main/resources/META-INF/gradle-plugins/io.backend.grpc-codegen-conventions.properties' 'modules/grpc/grpc-codegen/src/test/java/io/backend/skeleton/grpc/codegen/GrpcCodegenManifestTest.java' -git commit -m "build: standardize grpc java code generation" -``` - -### Task 11: Descriptor Artifact와 Consumer Source 호환성 - -**Files:** -- Create: `modules/grpc/grpc-codegen/src/main/java/io/backend/skeleton/grpc/codegen/GrpcDescriptorArtifact.java` -- Create: `modules/grpc/grpc-codegen/src/main/java/io/backend/skeleton/grpc/codegen/GrpcConsumerFixture.java` -- Create: `modules/grpc/grpc-codegen/src/main/java/io/backend/skeleton/grpc/codegen/GrpcSchemaArtifactPublisher.java` -- Create: `modules/grpc/grpc-codegen/src/test/resources/consumer-fixtures/v1/build.gradle.kts` -- Create: `modules/grpc/grpc-codegen/src/test/resources/consumer-fixtures/v1/src/main/java/fixture/DocumentClientFixture.java` -- Test: `modules/grpc/grpc-codegen/src/test/java/io/backend/skeleton/grpc/codegen/GrpcDescriptorArtifactTest.java` - -**Interfaces:** -- Consumes: Task 10의 generated descriptor와 Task 9의 schema baseline. -- Produces: schema hash·descriptor·generated source hash·consumer compile 결과를 하나의 release artifact로 묶는 계약. - -**Implementation requirements:** -- FileDescriptorSet, Buf image, schema hash, policy version을 보존한다. -- 이전 Java client fixture를 새 generated sources와 함께 compile한다. -- service path, method path, Java package source break를 별도로 보고한다. -- artifact는 immutable version으로 publish한다. -- consumer fixture 실패는 release를 차단한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcDescriptorArtifactTest { - @org.junit.jupiter.api.Test - void artifactRequiresConsumerCompatibilityEvidence() { - org.assertj.core.api.Assertions.assertThatThrownBy( - () -> new GrpcDescriptorArtifact( - "sha256:schema", "sha256:generated", - java.util.List.of())) - .isInstanceOf(IllegalArgumentException.class); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-codegen:test --tests 'io.backend.skeleton.grpc.codegen.GrpcDescriptorArtifactTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcConsumerFixture( - String name, - boolean compiled) {} - -public record GrpcDescriptorArtifact( - String schemaDigest, - String generatedSourceDigest, - java.util.List fixtures) { - - public GrpcDescriptorArtifact { - if (fixtures == null || fixtures.isEmpty() - || fixtures.stream().anyMatch( - fixture -> !fixture.compiled())) { - throw new IllegalArgumentException( - "consumer compatibility evidence required"); - } - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-codegen:test --tests 'io.backend.skeleton.grpc.codegen.GrpcDescriptorArtifactTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-codegen/src/main/java/io/backend/skeleton/grpc/codegen/GrpcDescriptorArtifact.java' 'modules/grpc/grpc-codegen/src/main/java/io/backend/skeleton/grpc/codegen/GrpcConsumerFixture.java' 'modules/grpc/grpc-codegen/src/main/java/io/backend/skeleton/grpc/codegen/GrpcSchemaArtifactPublisher.java' 'modules/grpc/grpc-codegen/src/test/resources/consumer-fixtures/v1/build.gradle.kts' 'modules/grpc/grpc-codegen/src/test/resources/consumer-fixtures/v1/src/main/java/fixture/DocumentClientFixture.java' 'modules/grpc/grpc-codegen/src/test/java/io/backend/skeleton/grpc/codegen/GrpcDescriptorArtifactTest.java' -git commit -m "build: publish grpc schema compatibility artifact" -``` - -### Task 12: Protovalidate Transport Validation - -**Files:** -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/validation/GrpcTransportValidator.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/validation/GrpcValidationViolation.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/validation/ProtovalidateGrpcInterceptor.java` -- Test: `modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/validation/ProtovalidateGrpcInterceptorTest.java` - -**Interfaces:** -- Consumes: Generated request messages, Task 8의 validation owner rule와 server interceptor SPI. -- Produces: pure transport constraint를 Application Use Case 진입 전에 검증하는 interceptor. - -**Implementation requirements:** -- 길이, 범위, collection count, format, pure cross-field rule만 처리한다. -- DB 조회·권한·현재 상태 검증은 금지한다. -- violation을 `INVALID_ARGUMENT`와 `BadRequest` detail로 변환한다. -- request body와 민감 field 값을 로그에 기록하지 않는다. -- validation rule compilation failure는 build 또는 startup failure다. - -- [ ] **Step 1: Write the failing test** - -```java -class ProtovalidateGrpcInterceptorTest { - @org.junit.jupiter.api.Test - void invalidRequestNeverReachesApplication() { - var validator = new GrpcTransportValidator( - value -> java.util.List.of( - new GrpcValidationViolation( - "title", "max_len", "too long"))); - - org.assertj.core.api.Assertions.assertThat( - validator.validate(new Object())).isNotEmpty(); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.validation.ProtovalidateGrpcInterceptorTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcValidationViolation( - String field, - String rule, - String safeMessage) {} - -public final class GrpcTransportValidator { - private final java.util.function.Function> delegate; - - public GrpcTransportValidator( - java.util.function.Function> delegate) { - this.delegate = delegate; - } - - public java.util.List validate( - Object request) { - return java.util.List.copyOf(delegate.apply(request)); - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.validation.ProtovalidateGrpcInterceptorTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/validation/GrpcTransportValidator.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/validation/GrpcValidationViolation.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/validation/ProtovalidateGrpcInterceptor.java' 'modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/validation/ProtovalidateGrpcInterceptorTest.java' -git commit -m "feat: add grpc transport validation" -``` - -### Task 13: Application 경계와 Raw gRPC API 차단 - -**Files:** -- Create: `modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/architecture/GrpcServiceAdapterMarker.java` -- Create: `modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/architecture/GrpcApplicationBoundaryRules.java` -- Create: `modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/architecture/GrpcRawApiImportRule.java` -- Test: `modules/grpc/grpc-server/src/test/java/io/backend/skeleton/grpc/architecture/GrpcApplicationBoundaryRulesTest.java` - -**Interfaces:** -- Consumes: Task 1 module graph, generated service classes와 Application package conventions. -- Produces: Service Adapter가 Use Case만 호출하고 DB·HTTP·Messaging·Storage에 직접 접근하지 못하게 하는 ArchUnit rule. - -**Implementation requirements:** -- gRPC service adapter는 `application` port만 의존한다. -- `EntityManager`, Repository, `MongoTemplate`, HTTP client, broker template, content store direct dependency를 금지한다. -- 일반 application module에서 raw `ManagedChannelBuilder`, `ServerBuilder`, `ClientCall`, `MethodDescriptor` import를 금지한다. -- generated package는 raw API import rule 예외다. -- architecture violation은 test failure다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcApplicationBoundaryRulesTest { - @org.junit.jupiter.api.Test - void rawBuildersAreNotApplicationApi() { - var rule = GrpcRawApiImportRule.defaultRule(); - - org.assertj.core.api.Assertions.assertThat( - rule.forbiddenTypes()) - .contains("io.grpc.ManagedChannelBuilder", - "io.grpc.ServerBuilder"); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-server:test --tests 'io.backend.skeleton.grpc.architecture.GrpcApplicationBoundaryRulesTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public interface GrpcServiceAdapterMarker {} - -public record GrpcRawApiImportRule( - java.util.Set forbiddenTypes) { - - public static GrpcRawApiImportRule defaultRule() { - return new GrpcRawApiImportRule(java.util.Set.of( - "io.grpc.ManagedChannelBuilder", - "io.grpc.ServerBuilder", - "io.grpc.MethodDescriptor", - "io.grpc.ClientCall")); - } -} - -public final class GrpcApplicationBoundaryRules { - public static java.util.Set forbiddenDependencies() { - return java.util.Set.of( - "jakarta.persistence.EntityManager", - "org.springframework.data.repository.Repository", - "org.springframework.data.mongodb.core.MongoTemplate", - "org.springframework.web.client.RestClient", - "org.springframework.web.reactive.function.client.WebClient"); - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-server:test --tests 'io.backend.skeleton.grpc.architecture.GrpcApplicationBoundaryRulesTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/architecture/GrpcServiceAdapterMarker.java' 'modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/architecture/GrpcApplicationBoundaryRules.java' 'modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/architecture/GrpcRawApiImportRule.java' 'modules/grpc/grpc-server/src/test/java/io/backend/skeleton/grpc/architecture/GrpcApplicationBoundaryRulesTest.java' -git commit -m "test: enforce grpc application boundary" -``` - -### Task 14: Typed Service Adapter SPI - -**Files:** -- Create: `modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcServiceAdapter.java` -- Create: `modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcApplicationInvocation.java` -- Create: `modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcResponseMapper.java` -- Create: `modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcServiceAdapterDescriptor.java` -- Test: `modules/grpc/grpc-server/src/test/java/io/backend/skeleton/grpc/server/GrpcServiceAdapterTest.java` - -**Interfaces:** -- Consumes: Task 12 architecture boundary, generated request/response와 Application Use Case port. -- Produces: Proto ↔ Application model 변환과 Use Case 호출만 수행하는 typed adapter contract. - -**Implementation requirements:** -- adapter는 request mapper, application invocation, response mapper를 명시한다. -- Application invocation에 immutable `GrpcRequestContext`를 전달한다. -- adapter가 transport Status를 직접 임의 구성하지 않고 공통 error mapper를 사용한다. -- blocking·async 실행 유형을 descriptor에 선언한다. -- 파일 bytes와 provider SDK 객체를 request/response mapping에 노출하지 않는다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcServiceAdapterTest { - @org.junit.jupiter.api.Test - void adapterDelegatesToApplicationInvocation() { - var invoked = new java.util.concurrent.atomic.AtomicBoolean(); - GrpcApplicationInvocation invocation = - (command, context) -> { - invoked.set(true); - return "ok"; - }; - - org.assertj.core.api.Assertions.assertThat( - invocation.invoke("command", null)).isEqualTo("ok"); - org.assertj.core.api.Assertions.assertThat(invoked).isTrue(); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-server:test --tests 'io.backend.skeleton.grpc.server.GrpcServiceAdapterTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -@FunctionalInterface -public interface GrpcApplicationInvocation { - R invoke(C command, GrpcRequestContext context); -} - -public interface GrpcServiceAdapter - extends GrpcServiceAdapterMarker { - C toCommand(P request); - R invoke(C command, GrpcRequestContext context); - Q toResponse(R result); - GrpcServiceAdapterDescriptor descriptor(); -} - -public record GrpcServiceAdapterDescriptor( - GrpcMethodName method, - String executionProfile, - String responseProfile) {} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-server:test --tests 'io.backend.skeleton.grpc.server.GrpcServiceAdapterTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcServiceAdapter.java' 'modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcApplicationInvocation.java' 'modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcResponseMapper.java' 'modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcServiceAdapterDescriptor.java' 'modules/grpc/grpc-server/src/test/java/io/backend/skeleton/grpc/server/GrpcServiceAdapterTest.java' -git commit -m "feat: add typed grpc service adapter" -``` - -### Task 15: Server Interceptor 순서 계약 - -**Files:** -- Create: `modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcServerInterceptorStage.java` -- Create: `modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcServerInterceptorOrder.java` -- Create: `modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcServerInterceptorChain.java` -- Test: `modules/grpc/grpc-server/src/test/java/io/backend/skeleton/grpc/server/GrpcServerInterceptorOrderTest.java` - -**Interfaces:** -- Consumes: Task 11 validation, Task 13 service adapter와 Security·Observability SPI. -- Produces: 예외 경계부터 validation과 service adapter까지의 의미 순서를 고정하는 chain. - -**Implementation requirements:** -- 순서는 Exception Boundary → Trace → Authentication → Actor/Tenant → Authorization → Admission → Deadline/Cancellation → Idempotency → Validation → Service Adapter다. -- 중복 stage와 누락 required stage를 startup에서 거부한다. -- 실제 framework wrapping 방향을 integration test로 검증한다. -- Exception mapper가 downstream 전체를 감싼다. -- client interceptor와 server interceptor 순서를 별도 catalog로 관리한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcServerInterceptorOrderTest { - @org.junit.jupiter.api.Test - void stableOrderPlacesValidationAfterIdempotency() { - var order = GrpcServerInterceptorOrder.stable(); - - org.assertj.core.api.Assertions.assertThat( - order.indexOf(GrpcServerInterceptorStage.VALIDATION)) - .isGreaterThan( - order.indexOf(GrpcServerInterceptorStage.IDEMPOTENCY)); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-server:test --tests 'io.backend.skeleton.grpc.server.GrpcServerInterceptorOrderTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public enum GrpcServerInterceptorStage { - EXCEPTION_BOUNDARY, TRACE, AUTHENTICATION, - ACTOR_TENANT, AUTHORIZATION, ADMISSION, - DEADLINE_CANCELLATION, IDEMPOTENCY, - VALIDATION, SERVICE_ADAPTER -} - -public record GrpcServerInterceptorOrder( - java.util.List stages) { - - public static GrpcServerInterceptorOrder stable() { - return new GrpcServerInterceptorOrder( - java.util.List.of( - GrpcServerInterceptorStage.EXCEPTION_BOUNDARY, - GrpcServerInterceptorStage.TRACE, - GrpcServerInterceptorStage.AUTHENTICATION, - GrpcServerInterceptorStage.ACTOR_TENANT, - GrpcServerInterceptorStage.AUTHORIZATION, - GrpcServerInterceptorStage.ADMISSION, - GrpcServerInterceptorStage.DEADLINE_CANCELLATION, - GrpcServerInterceptorStage.IDEMPOTENCY, - GrpcServerInterceptorStage.VALIDATION, - GrpcServerInterceptorStage.SERVICE_ADAPTER)); - } - - public int indexOf(GrpcServerInterceptorStage stage) { - return stages.indexOf(stage); - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-server:test --tests 'io.backend.skeleton.grpc.server.GrpcServerInterceptorOrderTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcServerInterceptorStage.java' 'modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcServerInterceptorOrder.java' 'modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcServerInterceptorChain.java' 'modules/grpc/grpc-server/src/test/java/io/backend/skeleton/grpc/server/GrpcServerInterceptorOrderTest.java' -git commit -m "feat: fix grpc server interceptor order" -``` - -### Task 16: Context Propagation과 Thread 경계 - -**Files:** -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/context/GrpcContextBinder.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/context/GrpcContextSnapshot.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/context/GrpcContextPropagationPolicy.java` -- Test: `modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/context/GrpcContextBinderTest.java` - -**Interfaces:** -- Consumes: Task 7 request context와 Task 15 interceptor order. -- Produces: gRPC Context, SecurityContext, Reactor Context, executor 경계에서 Actor·Tenant·Deadline을 보존하는 binder. - -**Implementation requirements:** -- context snapshot은 immutable하며 credential 원문을 포함하지 않는다. -- blocking executor와 virtual thread에 context를 복원하고 작업 종료 후 정리한다. -- Reactor adapter가 있을 때 Reactor Context와 gRPC Context를 명시적으로 bridge한다. -- context가 없는 비동기 작업은 fail-closed한다. -- stream 수명 동안 actor·tenant·credential expiry를 보존한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcContextBinderTest { - @org.junit.jupiter.api.Test - void missingContextFailsClosed() { - var binder = new GrpcContextBinder(); - - org.assertj.core.api.Assertions.assertThatThrownBy( - binder::current) - .isInstanceOf(IllegalStateException.class); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.context.GrpcContextBinderTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcContextSnapshot( - GrpcRequestContext requestContext, - java.time.Instant capturedAt) {} - -public final class GrpcContextBinder { - private static final ThreadLocal CURRENT = - new ThreadLocal<>(); - - public GrpcContextSnapshot current() { - var value = CURRENT.get(); - if (value == null) { - throw new IllegalStateException( - "gRPC request context is required"); - } - return value; - } - - public AutoCloseable bind(GrpcContextSnapshot value) { - CURRENT.set(value); - return CURRENT::remove; - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.context.GrpcContextBinderTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/context/GrpcContextBinder.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/context/GrpcContextSnapshot.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/context/GrpcContextPropagationPolicy.java' 'modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/context/GrpcContextBinderTest.java' -git commit -m "feat: propagate grpc request context" -``` - -### Task 17: Status와 Rich Error Mapper - -**Files:** -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/error/GrpcStatusMapping.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/error/GrpcRichErrorDetail.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/error/GrpcErrorMapper.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/error/GrpcErrorExposurePolicy.java` -- Test: `modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/error/GrpcErrorMapperTest.java` - -**Interfaces:** -- Consumes: Task 5 failure model과 Application exception hierarchy. -- Produces: 안정된 gRPC Status와 allowlisted google.rpc detail로 내부 오류를 변환하는 mapper. - -**Implementation requirements:** -- validation은 `INVALID_ARGUMENT`, state precondition은 `FAILED_PRECONDITION`, concurrency abort는 `ABORTED`로 매핑한다. -- retry hint는 `RetryInfo` detail로만 노출한다. -- unknown exception은 `INTERNAL`과 opaque execution ID로 변환한다. -- stack, SQL, query, host, token, PII를 detail에서 제거한다. -- client는 message 문자열이 아니라 code·reason·typed detail로 분기한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcErrorMapperTest { - @org.junit.jupiter.api.Test - void unknownFailureIsOpaqueInternalError() { - var mapped = new GrpcErrorMapper( - GrpcErrorExposurePolicy.safe()) - .map(new RuntimeException("jdbc:secret")); - - org.assertj.core.api.Assertions.assertThat(mapped.code()) - .isEqualTo("INTERNAL"); - org.assertj.core.api.Assertions.assertThat(mapped.message()) - .doesNotContain("jdbc"); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.error.GrpcErrorMapperTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcStatusMapping( - String code, - String message, - java.util.List details) {} - -public record GrpcRichErrorDetail( - String type, - java.util.Map safeFields) {} - -public record GrpcErrorExposurePolicy( - java.util.Set allowedDetailTypes) { - - public static GrpcErrorExposurePolicy safe() { - return new GrpcErrorExposurePolicy(java.util.Set.of( - "BadRequest", "PreconditionFailure", "RetryInfo", - "ResourceInfo", "ErrorInfo", "QuotaFailure", - "LocalizedMessage")); - } -} - -public final class GrpcErrorMapper { - public GrpcErrorMapper(GrpcErrorExposurePolicy policy) {} - public GrpcStatusMapping map(Throwable error) { - return new GrpcStatusMapping( - "INTERNAL", "RPC execution failed", - java.util.List.of(new GrpcRichErrorDetail( - "ErrorInfo", - java.util.Map.of("reason", "INTERNAL_ERROR")))); - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.error.GrpcErrorMapperTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/error/GrpcStatusMapping.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/error/GrpcRichErrorDetail.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/error/GrpcErrorMapper.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/error/GrpcErrorExposurePolicy.java' 'modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/error/GrpcErrorMapperTest.java' -git commit -m "feat: add grpc status and rich error mapping" -``` - -### Task 18: Netty Server Profile·Executor·Admission - -**Files:** -- Create: `modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcServerProfile.java` -- Create: `modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcServerTransport.java` -- Create: `modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcExecutorProfile.java` -- Create: `modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcAdmissionController.java` -- Test: `modules/grpc/grpc-server/src/test/java/io/backend/skeleton/grpc/server/GrpcServerProfileTest.java` - -**Interfaces:** -- Consumes: Task 15 interceptor chain과 Spring Boot gRPC server customization. -- Produces: Netty Stable server의 size, metadata, concurrency, executor, keepalive, drain 설정. - -**Implementation requirements:** -- Netty를 Stable 기본 transport로 사용한다. -- blocking Use Case는 bounded executor에서 실행하며 event loop 직접 실행을 금지한다. -- queue와 concurrent call limit을 넘으면 `RESOURCE_EXHAUSTED`로 거부한다. -- `directExecutor`와 unbounded queue를 production에서 금지한다. -- message·metadata·keepalive·connection age·shutdown 설정을 profile로 검증한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcServerProfileTest { - @org.junit.jupiter.api.Test - void productionRequiresBoundedExecutorAndLimits() { - var profile = GrpcServerProfile.stableNetty(); - - org.assertj.core.api.Assertions.assertThat( - profile.maxConcurrentCalls()).isPositive(); - org.assertj.core.api.Assertions.assertThat( - profile.executor().bounded()).isTrue(); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-server:test --tests 'io.backend.skeleton.grpc.server.GrpcServerProfileTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public enum GrpcServerTransport { - NETTY, NETTY_SHADED, SERVLET, IN_PROCESS -} - -public record GrpcExecutorProfile( - String name, - int maxConcurrency, - int queueCapacity, - boolean bounded) {} - -public record GrpcServerProfile( - GrpcServerTransport transport, - int maxInboundMessageBytes, - int maxInboundMetadataBytes, - int maxConcurrentCalls, - GrpcExecutorProfile executor) { - - public static GrpcServerProfile stableNetty() { - return new GrpcServerProfile( - GrpcServerTransport.NETTY, - 4 * 1024 * 1024, - 8 * 1024, - 512, - new GrpcExecutorProfile( - "blocking-bounded", 256, 256, true)); - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-server:test --tests 'io.backend.skeleton.grpc.server.GrpcServerProfileTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcServerProfile.java' 'modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcServerTransport.java' 'modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcExecutorProfile.java' 'modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcAdmissionController.java' 'modules/grpc/grpc-server/src/test/java/io/backend/skeleton/grpc/server/GrpcServerProfileTest.java' -git commit -m "feat: configure stable grpc netty server" -``` - -### Task 19: Netty Shaded Transport 호환 Profile - -**Files:** -- Create: `modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcNettyVariant.java` -- Create: `modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcNettyVariantSelector.java` -- Create: `modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcNettyParityContract.java` -- Test: `modules/grpc/grpc-server/src/test/java/io/backend/skeleton/grpc/server/GrpcNettyVariantSelectorTest.java` - -**Interfaces:** -- Consumes: Task 18 Stable Netty profile. -- Produces: `grpc-netty`와 `grpc-netty-shaded`를 상호 배타적으로 선택하고 기능 동등성을 검증하는 profile. - -**Implementation requirements:** -- 한 runtime에 unshaded와 shaded transport가 동시에 활성화되지 않게 한다. -- TLS, metadata limit, message limit, health, reflection, graceful shutdown parity를 검증한다. -- shaded profile은 dependency conflict 회피 목적이며 기능을 추가하지 않는다. -- variant 선택은 build/runtime profile에서 명시한다. -- 실제 Netty integration suite를 두 variant에 실행한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcNettyVariantSelectorTest { - @org.junit.jupiter.api.Test - void twoNettyVariantsCannotBeEnabledTogether() { - org.assertj.core.api.Assertions.assertThatThrownBy( - () -> new GrpcNettyVariantSelector( - java.util.Set.of( - GrpcNettyVariant.NETTY, - GrpcNettyVariant.NETTY_SHADED))) - .isInstanceOf(IllegalArgumentException.class); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-server:test --tests 'io.backend.skeleton.grpc.server.GrpcNettyVariantSelectorTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public enum GrpcNettyVariant { - NETTY, NETTY_SHADED -} - -public record GrpcNettyVariantSelector( - java.util.Set enabled) { - public GrpcNettyVariantSelector { - if (enabled == null || enabled.size() != 1) { - throw new IllegalArgumentException( - "exactly one Netty variant is required"); - } - } -} - -public record GrpcNettyParityContract( - boolean tls, - boolean metadataLimit, - boolean gracefulShutdown) {} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-server:test --tests 'io.backend.skeleton.grpc.server.GrpcNettyVariantSelectorTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcNettyVariant.java' 'modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcNettyVariantSelector.java' 'modules/grpc/grpc-server/src/main/java/io/backend/skeleton/grpc/server/GrpcNettyParityContract.java' 'modules/grpc/grpc-server/src/test/java/io/backend/skeleton/grpc/server/GrpcNettyVariantSelectorTest.java' -git commit -m "build: add shaded netty compatibility profile" -``` - -### Task 20: TLS·mTLS·CallCredentials와 Rotation - -**Files:** -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/security/GrpcTlsProfile.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/security/GrpcAuthenticationProfile.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/security/GrpcCredentialGeneration.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/security/GrpcCredentialRotationManager.java` -- Test: `modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/security/GrpcTlsProfileTest.java` - -**Interfaces:** -- Consumes: Spring Boot SSL Bundle, Security token source와 Task 24 예정 Named Channel profile. -- Produces: TLS/mTLS와 per-call credential을 분리하고 generation 기반으로 교체하는 security contract. - -**Implementation requirements:** -- dev·stage·prod에서 TLS를 필수로 한다. -- mTLS는 service identity가 필요한 profile에서 사용한다. -- Bearer/JWT/service token은 `CallCredentials` 또는 승인된 credential adapter가 생성한다. -- trust-all과 hostname verification 비활성화를 production에서 startup failure로 처리한다. -- credential·certificate 교체 시 새 generation을 만들고 기존 call/stream을 drain한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcTlsProfileTest { - @org.junit.jupiter.api.Test - void productionRejectsTrustAll() { - org.assertj.core.api.Assertions.assertThatThrownBy( - () -> GrpcTlsProfile.production( - "internal-ca", false, true)) - .isInstanceOf(IllegalArgumentException.class); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.security.GrpcTlsProfileTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcTlsProfile( - String sslBundle, - boolean hostnameVerification, - boolean trustAll, - boolean mutualTls) { - - public static GrpcTlsProfile production( - String bundle, - boolean hostnameVerification, - boolean trustAll) { - if (!hostnameVerification || trustAll) { - throw new IllegalArgumentException( - "secure TLS verification is required"); - } - return new GrpcTlsProfile( - bundle, true, false, false); - } -} - -public record GrpcCredentialGeneration( - long generation, - java.time.Instant createdAt) {} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.security.GrpcTlsProfileTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/security/GrpcTlsProfile.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/security/GrpcAuthenticationProfile.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/security/GrpcCredentialGeneration.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/security/GrpcCredentialRotationManager.java' 'modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/security/GrpcTlsProfileTest.java' -git commit -m "feat: add grpc tls and credential rotation" -``` - -### Task 21: 표준 Health Service와 Service 상태 - -**Files:** -- Create: `modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcHealthState.java` -- Create: `modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcServiceHealthRegistry.java` -- Create: `modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcHealthPolicy.java` -- Test: `modules/grpc/grpc-admin/src/test/java/io/backend/skeleton/grpc/admin/GrpcServiceHealthRegistryTest.java` - -**Interfaces:** -- Consumes: Server service catalog와 dependency health signals. -- Produces: global·service별 health, readiness, draining과 Watch를 제공하는 표준 health registry. - -**Implementation requirements:** -- 상태는 `UNKNOWN`, `SERVING`, `NOT_SERVING`, `SERVICE_UNKNOWN`, `DRAINING`으로 표현한다. -- startup 중에는 요청을 받기 전에 readiness를 false로 유지한다. -- drain 시작 시 service health를 먼저 `DRAINING/NOT_SERVING`으로 전환한다. -- dependency health가 business correctness에 필수일 때만 service health에 반영한다. -- health endpoint의 network·credential 접근 정책을 business RPC와 분리한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcServiceHealthRegistryTest { - @org.junit.jupiter.api.Test - void drainingServiceIsNotReadyForNewCalls() { - var registry = new GrpcServiceHealthRegistry(); - registry.draining("DocumentService"); - - org.assertj.core.api.Assertions.assertThat( - registry.state("DocumentService")) - .isEqualTo(GrpcHealthState.DRAINING); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-admin:test --tests 'io.backend.skeleton.grpc.admin.GrpcServiceHealthRegistryTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public enum GrpcHealthState { - UNKNOWN, SERVING, NOT_SERVING, - SERVICE_UNKNOWN, DRAINING -} - -public final class GrpcServiceHealthRegistry { - private final java.util.concurrent.ConcurrentMap states = - new java.util.concurrent.ConcurrentHashMap<>(); - - public void draining(String service) { - states.put(service, GrpcHealthState.DRAINING); - } - - public GrpcHealthState state(String service) { - return states.getOrDefault( - service, GrpcHealthState.UNKNOWN); - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-admin:test --tests 'io.backend.skeleton.grpc.admin.GrpcServiceHealthRegistryTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcHealthState.java' 'modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcServiceHealthRegistry.java' 'modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcHealthPolicy.java' 'modules/grpc/grpc-admin/src/test/java/io/backend/skeleton/grpc/admin/GrpcServiceHealthRegistryTest.java' -git commit -m "feat: add grpc health registry" -``` - -### Task 22: Reflection 환경·권한 정책 - -**Files:** -- Create: `modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcReflectionMode.java` -- Create: `modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcReflectionPolicy.java` -- Create: `modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcReflectionAccessDecision.java` -- Test: `modules/grpc/grpc-admin/src/test/java/io/backend/skeleton/grpc/admin/GrpcReflectionPolicyTest.java` - -**Interfaces:** -- Consumes: Schema descriptor artifact와 environment/security profile. -- Produces: Local/Test 허용, Dev 관리자 인증, Production 기본 비활성인 reflection policy. - -**Implementation requirements:** -- Reflection mode는 `ENABLED`, `ADMIN_ONLY`, `DISABLED`다. -- production default는 `DISABLED`다. -- Admin-only reflection은 별도 network/role predicate를 요구한다. -- reflection 공개 여부와 실제 RPC authorization을 동일시하지 않는다. -- schema descriptor version을 admin snapshot에 노출하되 secret은 제외한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcReflectionPolicyTest { - @org.junit.jupiter.api.Test - void productionDefaultsToDisabled() { - org.assertj.core.api.Assertions.assertThat( - GrpcReflectionPolicy.production().mode()) - .isEqualTo(GrpcReflectionMode.DISABLED); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-admin:test --tests 'io.backend.skeleton.grpc.admin.GrpcReflectionPolicyTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public enum GrpcReflectionMode { - ENABLED, ADMIN_ONLY, DISABLED -} - -public record GrpcReflectionPolicy( - GrpcReflectionMode mode, - java.util.Set requiredRoles) { - - public static GrpcReflectionPolicy production() { - return new GrpcReflectionPolicy( - GrpcReflectionMode.DISABLED, - java.util.Set.of()); - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-admin:test --tests 'io.backend.skeleton.grpc.admin.GrpcReflectionPolicyTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcReflectionMode.java' 'modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcReflectionPolicy.java' 'modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcReflectionAccessDecision.java' 'modules/grpc/grpc-admin/src/test/java/io/backend/skeleton/grpc/admin/GrpcReflectionPolicyTest.java' -git commit -m "feat: govern grpc reflection access" -``` - -### Task 23: Graceful Shutdown과 Stream Drain - -**Files:** -- Create: `modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcDrainPhase.java` -- Create: `modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcDrainPolicy.java` -- Create: `modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcDrainCoordinator.java` -- Create: `modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcDrainResult.java` -- Test: `modules/grpc/grpc-admin/src/test/java/io/backend/skeleton/grpc/admin/GrpcDrainCoordinatorTest.java` - -**Interfaces:** -- Consumes: Task 21 health registry, server call registry와 streaming session registry. -- Produces: readiness 차단부터 unary·stream drain과 force cancel까지의 deterministic shutdown. - -**Implementation requirements:** -- 순서는 readiness false → health draining → new admission reject → unary drain → stream signal → force cancel이다. -- drain과 force timeout을 분리한다. -- stream에는 마지막 sequence·resume cursor 또는 full resync 필요 상태를 전달한다. -- drain 중 새 retry·new stream을 시작하지 않는다. -- drain 결과에 completed·cancelled·timed-out call 수를 보존한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcDrainCoordinatorTest { - @org.junit.jupiter.api.Test - void healthChangesBeforeNewAdmissionStops() { - var coordinator = new GrpcDrainCoordinator( - GrpcDrainPolicy.standard()); - var phases = coordinator.plan(); - - org.assertj.core.api.Assertions.assertThat(phases) - .containsSubsequence( - GrpcDrainPhase.READINESS_OFF, - GrpcDrainPhase.HEALTH_DRAINING, - GrpcDrainPhase.ADMISSION_CLOSED); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-admin:test --tests 'io.backend.skeleton.grpc.admin.GrpcDrainCoordinatorTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public enum GrpcDrainPhase { - READINESS_OFF, - HEALTH_DRAINING, - ADMISSION_CLOSED, - UNARY_DRAIN, - STREAM_SIGNAL, - FORCE_CANCEL, - COMPLETE -} - -public record GrpcDrainPolicy( - java.time.Duration drainTimeout, - java.time.Duration forceTimeout) { - public static GrpcDrainPolicy standard() { - return new GrpcDrainPolicy( - java.time.Duration.ofSeconds(30), - java.time.Duration.ofSeconds(5)); - } -} - -public final class GrpcDrainCoordinator { - public GrpcDrainCoordinator(GrpcDrainPolicy policy) {} - public java.util.List plan() { - return java.util.List.of( - GrpcDrainPhase.READINESS_OFF, - GrpcDrainPhase.HEALTH_DRAINING, - GrpcDrainPhase.ADMISSION_CLOSED, - GrpcDrainPhase.UNARY_DRAIN, - GrpcDrainPhase.STREAM_SIGNAL, - GrpcDrainPhase.FORCE_CANCEL, - GrpcDrainPhase.COMPLETE); - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-admin:test --tests 'io.backend.skeleton.grpc.admin.GrpcDrainCoordinatorTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcDrainPhase.java' 'modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcDrainPolicy.java' 'modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcDrainCoordinator.java' 'modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcDrainResult.java' 'modules/grpc/grpc-admin/src/test/java/io/backend/skeleton/grpc/admin/GrpcDrainCoordinatorTest.java' -git commit -m "feat: implement grpc graceful drain" -``` - -### Task 24: Named Channel Profile - -**Files:** -- Create: `modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcNamedChannelProfile.java` -- Create: `modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcLoadBalancingPolicy.java` -- Create: `modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcRetryOwner.java` -- Create: `modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcChannelProfileValidator.java` -- Test: `modules/grpc/grpc-client/src/test/java/io/backend/skeleton/grpc/client/GrpcNamedChannelProfileTest.java` - -**Interfaces:** -- Consumes: Task 3 method policies, Task 20 TLS profiles와 deployment configuration. -- Produces: logical channel name별 target, transport, LB, TLS, deadline, retry owner, size limit 계약. - -**Implementation requirements:** -- target은 resolver scheme을 포함한 URI 형태로 검증한다. -- load balancing은 Stable에서 `PICK_FIRST`, `ROUND_ROBIN`만 허용한다. -- retry owner는 `GRPC_PLATFORM`, `SERVICE_MESH`, `APPLICATION`, `NONE` 중 하나다. -- TLS, metadata/message limit, wait-for-ready, observability 설정을 profile에 고정한다. -- 동일 service라도 long stream과 short unary의 SLO가 다르면 별도 channel profile을 사용한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcNamedChannelProfileTest { - @org.junit.jupiter.api.Test - void meshOwnedRetryCannotAlsoUseGrpcRetry() { - var profile = GrpcNamedChannelProfile.mesh( - new GrpcChannelProfileName("document")); - - org.assertj.core.api.Assertions.assertThatThrownBy( - () -> new GrpcChannelProfileValidator() - .validate(profile, true)) - .isInstanceOf(IllegalArgumentException.class); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-client:test --tests 'io.backend.skeleton.grpc.client.GrpcNamedChannelProfileTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public enum GrpcLoadBalancingPolicy { - PICK_FIRST, ROUND_ROBIN -} - -public enum GrpcRetryOwner { - GRPC_PLATFORM, SERVICE_MESH, APPLICATION, NONE -} - -public record GrpcNamedChannelProfile( - GrpcChannelProfileName name, - String target, - GrpcLoadBalancingPolicy loadBalancing, - GrpcRetryOwner retryOwner, - GrpcTlsProfile tls, - int maxInboundMessageBytes, - int maxMetadataBytes) { - - public static GrpcNamedChannelProfile mesh( - GrpcChannelProfileName name) { - return new GrpcNamedChannelProfile( - name, "dns:///service:9090", - GrpcLoadBalancingPolicy.PICK_FIRST, - GrpcRetryOwner.SERVICE_MESH, - GrpcTlsProfile.production( - "internal-ca", true, false), - 4 * 1024 * 1024, 8 * 1024); - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-client:test --tests 'io.backend.skeleton.grpc.client.GrpcNamedChannelProfileTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcNamedChannelProfile.java' 'modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcLoadBalancingPolicy.java' 'modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcRetryOwner.java' 'modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcChannelProfileValidator.java' 'modules/grpc/grpc-client/src/test/java/io/backend/skeleton/grpc/client/GrpcNamedChannelProfileTest.java' -git commit -m "feat: define grpc named channel profiles" -``` - -### Task 25: Channel Runtime Generation과 Drain - -**Files:** -- Create: `modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcChannelGeneration.java` -- Create: `modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcChannelRuntime.java` -- Create: `modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcChannelRuntimeRegistry.java` -- Create: `modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcChannelDrainPolicy.java` -- Test: `modules/grpc/grpc-client/src/test/java/io/backend/skeleton/grpc/client/GrpcChannelRuntimeRegistryTest.java` - -**Interfaces:** -- Consumes: Task 24 Named Channel Profile과 credential generation. -- Produces: Channel·Stub을 재사용하고 설정·인증서 교체 시 새 generation으로 무중단 전환하는 runtime registry. - -**Implementation requirements:** -- 요청마다 channel을 생성하지 않는다. -- profile generation 변경 시 새 runtime을 준비한 뒤 atomic pointer를 교체한다. -- 기존 runtime은 in-flight unary와 stream을 drain한다. -- drain timeout 후 남은 call을 cancel하고 metric을 기록한다. -- runtime registry는 credential·raw builder를 application에 반환하지 않는다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcChannelRuntimeRegistryTest { - @org.junit.jupiter.api.Test - void rotationCreatesNewGenerationBeforeOldDrain() { - var registry = new GrpcChannelRuntimeRegistry(); - var first = registry.install("document"); - var second = registry.rotate("document"); - - org.assertj.core.api.Assertions.assertThat( - second.generation()).isGreaterThan(first.generation()); - org.assertj.core.api.Assertions.assertThat( - registry.current("document")).isEqualTo(second); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-client:test --tests 'io.backend.skeleton.grpc.client.GrpcChannelRuntimeRegistryTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcChannelGeneration(long value) {} - -public record GrpcChannelRuntime( - String profile, - GrpcChannelGeneration generation, - boolean acceptingCalls) {} - -public final class GrpcChannelRuntimeRegistry { - private final java.util.concurrent.ConcurrentMap current = - new java.util.concurrent.ConcurrentHashMap<>(); - - public GrpcChannelRuntime install(String profile) { - var runtime = new GrpcChannelRuntime( - profile, new GrpcChannelGeneration(1), true); - current.put(profile, runtime); - return runtime; - } - - public GrpcChannelRuntime rotate(String profile) { - var old = current(profile); - var next = new GrpcChannelRuntime( - profile, - new GrpcChannelGeneration( - old.generation().value() + 1), - true); - current.put(profile, next); - return next; - } - - public GrpcChannelRuntime current(String profile) { - return current.get(profile); - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-client:test --tests 'io.backend.skeleton.grpc.client.GrpcChannelRuntimeRegistryTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcChannelGeneration.java' 'modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcChannelRuntime.java' 'modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcChannelRuntimeRegistry.java' 'modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcChannelDrainPolicy.java' 'modules/grpc/grpc-client/src/test/java/io/backend/skeleton/grpc/client/GrpcChannelRuntimeRegistryTest.java' -git commit -m "feat: manage grpc channel generations" -``` - -### Task 26: Typed Stub Factory와 Method Policy 적용 - -**Files:** -- Create: `modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcTypedStubFactory.java` -- Create: `modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcStubDescriptor.java` -- Create: `modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcStubPolicyApplier.java` -- Test: `modules/grpc/grpc-client/src/test/java/io/backend/skeleton/grpc/client/GrpcTypedStubFactoryTest.java` - -**Interfaces:** -- Consumes: Task 24 channel profile, Task 25 runtime registry, generated stub classes. -- Produces: registered generated stub만 생성하고 method policy·deadline·credentials를 적용하는 typed factory. - -**Implementation requirements:** -- stub factory는 allowlisted generated service type만 생성한다. -- stub마다 Named Channel Profile과 method policy catalog를 연결한다. -- raw Channel·ManagedChannel과 builder를 application에 반환하지 않는다. -- blocking, future, async stub 유형을 descriptor로 구분한다. -- method 호출 직전에 effective deadline과 call credentials를 적용한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcTypedStubFactoryTest { - @org.junit.jupiter.api.Test - void unregisteredStubTypeIsRejected() { - var factory = GrpcTypedStubFactory.empty(); - - org.assertj.core.api.Assertions.assertThatThrownBy( - () -> factory.create(Object.class, "document")) - .isInstanceOf(IllegalArgumentException.class); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-client:test --tests 'io.backend.skeleton.grpc.client.GrpcTypedStubFactoryTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcStubDescriptor( - Class stubType, - String profile, - String style) {} - -public final class GrpcTypedStubFactory { - private final java.util.Map, GrpcStubDescriptor> types; - - private GrpcTypedStubFactory( - java.util.Map, GrpcStubDescriptor> types) { - this.types = types; - } - - public static GrpcTypedStubFactory empty() { - return new GrpcTypedStubFactory(java.util.Map.of()); - } - - public T create(Class type, String profile) { - if (!types.containsKey(type)) { - throw new IllegalArgumentException( - "unregistered generated stub"); - } - throw new UnsupportedOperationException( - "transport adapter creates the generated stub"); - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-client:test --tests 'io.backend.skeleton.grpc.client.GrpcTypedStubFactoryTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcTypedStubFactory.java' 'modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcStubDescriptor.java' 'modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcStubPolicyApplier.java' 'modules/grpc/grpc-client/src/test/java/io/backend/skeleton/grpc/client/GrpcTypedStubFactoryTest.java' -git commit -m "feat: add grpc typed stub factory" -``` - -### Task 27: Client Metadata와 CallCredentials - -**Files:** -- Create: `modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcClientMetadataPolicy.java` -- Create: `modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcCallCredentialProvider.java` -- Create: `modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcClientCallContext.java` -- Test: `modules/grpc/grpc-client/src/test/java/io/backend/skeleton/grpc/client/GrpcClientMetadataPolicyTest.java` - -**Interfaces:** -- Consumes: Task 7 metadata keys, Task 20 credential profiles와 Task 26 stub factory. -- Produces: trace·correlation·idempotency metadata와 per-call credential을 안전하게 materialize하는 client policy. - -**Implementation requirements:** -- Authorization은 application-provided generic header가 아니라 credential provider가 생성한다. -- tenant·actor context는 allowlisted signed/validated representation만 전달한다. -- cross-trust-boundary channel에서 baggage·tenant metadata allowlist를 별도 적용한다. -- metadata budget을 call 시작 전에 검증한다. -- token, cookie, password와 raw PII를 log·metric에 기록하지 않는다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcClientMetadataPolicyTest { - @org.junit.jupiter.api.Test - void applicationCannotOverrideAuthorizationMetadata() { - var policy = GrpcClientMetadataPolicy.standard(); - - org.assertj.core.api.Assertions.assertThatThrownBy( - () -> policy.validateUserKey("authorization")) - .isInstanceOf(IllegalArgumentException.class); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-client:test --tests 'io.backend.skeleton.grpc.client.GrpcClientMetadataPolicyTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcClientMetadataPolicy( - java.util.Set reservedKeys, - GrpcMetadataBudget budget) { - - public static GrpcClientMetadataPolicy standard() { - return new GrpcClientMetadataPolicy( - java.util.Set.of( - "authorization", "grpc-timeout", - "traceparent", "tracestate"), - new GrpcMetadataBudget(8192, 4096)); - } - - public void validateUserKey(String key) { - if (reservedKeys.contains(key.toLowerCase())) { - throw new IllegalArgumentException( - "reserved gRPC metadata key"); - } - } -} - -public interface GrpcCallCredentialProvider { - Object credentials(GrpcClientCallContext context); -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-client:test --tests 'io.backend.skeleton.grpc.client.GrpcClientMetadataPolicyTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcClientMetadataPolicy.java' 'modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcCallCredentialProvider.java' 'modules/grpc/grpc-client/src/main/java/io/backend/skeleton/grpc/client/GrpcClientCallContext.java' 'modules/grpc/grpc-client/src/test/java/io/backend/skeleton/grpc/client/GrpcClientMetadataPolicyTest.java' -git commit -m "feat: secure grpc client metadata" -``` - -### Task 28: Deadline 전파와 Dependency Budget - -**Files:** -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/deadline/GrpcDeadlineCalculator.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/deadline/GrpcDependencyBudget.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/deadline/GrpcDeadlinePolicyValidator.java` -- Test: `modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/deadline/GrpcDeadlineCalculatorTest.java` - -**Interfaces:** -- Consumes: Task 6 deadline primitive, Task 3 method policy와 current gRPC Context. -- Produces: parent remaining deadline과 method profile에서 downstream call budget을 계산하는 policy. - -**Implementation requirements:** -- effective deadline은 parent remaining과 method default 중 더 짧다. -- safety margin과 response/trailer reserve를 차감한다. -- DB·HTTP·downstream gRPC timeout이 inbound deadline을 초과하면 startup 검증에서 실패한다. -- minimum attempt budget보다 짧으면 call을 시작하지 않는다. -- deadline이 없는 Stable Unary call을 거부한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcDeadlineCalculatorTest { - @org.junit.jupiter.api.Test - void childDeadlineReservesResponseBudget() { - var calculator = new GrpcDeadlineCalculator( - java.time.Duration.ofMillis(100)); - - var child = calculator.calculate( - java.time.Duration.ofSeconds(2), - java.time.Duration.ofSeconds(5)); - - org.assertj.core.api.Assertions.assertThat(child) - .isLessThanOrEqualTo( - java.time.Duration.ofMillis(1900)); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.deadline.GrpcDeadlineCalculatorTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public final class GrpcDeadlineCalculator { - private final java.time.Duration reserve; - - public GrpcDeadlineCalculator( - java.time.Duration reserve) { - this.reserve = reserve; - } - - public java.time.Duration calculate( - java.time.Duration parentRemaining, - java.time.Duration methodDefault) { - var minimum = parentRemaining.compareTo(methodDefault) < 0 - ? parentRemaining : methodDefault; - var result = minimum.minus(reserve); - if (result.isZero() || result.isNegative()) { - throw new GrpcDeadlineExceededException( - "insufficient downstream budget"); - } - return result; - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.deadline.GrpcDeadlineCalculatorTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/deadline/GrpcDeadlineCalculator.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/deadline/GrpcDependencyBudget.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/deadline/GrpcDeadlinePolicyValidator.java' 'modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/deadline/GrpcDeadlineCalculatorTest.java' -git commit -m "feat: propagate grpc deadline budgets" -``` - -### Task 29: Cancellation 전파와 Side-effect 차단 - -**Files:** -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/deadline/GrpcCancellationCoordinator.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/deadline/GrpcCancellableOperation.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/deadline/GrpcCancellationReason.java` -- Test: `modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/deadline/GrpcCancellationCoordinatorTest.java` - -**Interfaces:** -- Consumes: Task 6 cancellation token과 Application dependency cancellation adapters. -- Produces: client cancel/deadline을 Future·Publisher·HTTP·DB 작업에 전파하고 새 side effect 시작을 차단하는 coordinator. - -**Implementation requirements:** -- cancel은 idempotent하며 첫 reason을 보존한다. -- Application Use Case는 cancellation token을 인자로 받거나 context에서 조회한다. -- cancel 이후 신규 외부 side effect를 시작하지 않는다. -- commit 경계 이후 cancel은 business evidence를 자동 ABORT로 바꾸지 않는다. -- stream source와 writer를 모두 cancel하고 queue를 정리한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcCancellationCoordinatorTest { - @org.junit.jupiter.api.Test - void cancellationInvokesRegisteredOperationsOnce() { - var calls = new java.util.concurrent.atomic.AtomicInteger(); - var coordinator = new GrpcCancellationCoordinator(); - coordinator.register(calls::incrementAndGet); - - coordinator.cancel(GrpcCancellationReason.CLIENT_CANCELLED); - coordinator.cancel(GrpcCancellationReason.DEADLINE); - - org.assertj.core.api.Assertions.assertThat(calls).hasValue(1); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.deadline.GrpcCancellationCoordinatorTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public enum GrpcCancellationReason { - CLIENT_CANCELLED, DEADLINE, SERVER_DRAIN, POLICY -} - -@FunctionalInterface -public interface GrpcCancellableOperation { - void cancel(); -} - -public final class GrpcCancellationCoordinator { - private final java.util.List operations = - new java.util.concurrent.CopyOnWriteArrayList<>(); - private final java.util.concurrent.atomic.AtomicBoolean cancelled = - new java.util.concurrent.atomic.AtomicBoolean(); - - public void register(GrpcCancellableOperation operation) { - if (cancelled.get()) { - operation.cancel(); - } else { - operations.add(operation); - } - } - - public void cancel(GrpcCancellationReason reason) { - if (cancelled.compareAndSet(false, true)) { - operations.forEach(GrpcCancellableOperation::cancel); - } - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.deadline.GrpcCancellationCoordinatorTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/deadline/GrpcCancellationCoordinator.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/deadline/GrpcCancellableOperation.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/deadline/GrpcCancellationReason.java' 'modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/deadline/GrpcCancellationCoordinatorTest.java' -git commit -m "feat: propagate grpc cancellation" -``` - -### Task 30: Service Config와 Retry Owner 검증 - -**Files:** -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcServiceConfigPolicy.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcMethodRetryConfig.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcRetryOwnershipValidator.java` -- Test: `modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/resilience/GrpcRetryOwnershipValidatorTest.java` - -**Interfaces:** -- Consumes: Task 24 channel retry owner와 Task 3 method policy. -- Produces: method별 timeout·wait-for-ready·retry를 하나의 owner만 관리하게 하는 Service Config contract. - -**Implementation requirements:** -- APPLICATION, GRPC_PLATFORM, SERVICE_MESH, NONE 중 explicit retry owner는 하나다. -- Service Config의 method name이 policy catalog와 일치해야 한다. -- mesh owner일 때 gRPC explicit retry와 hedging을 비활성화한다. -- retry policy가 없는 것과 channel retry 자체를 disable한 것을 구분한다. -- transparent retry 존재 여부를 운영 snapshot에 기록한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcRetryOwnershipValidatorTest { - @org.junit.jupiter.api.Test - void twoExplicitRetryOwnersAreRejected() { - var validator = new GrpcRetryOwnershipValidator(); - - org.assertj.core.api.Assertions.assertThatThrownBy( - () -> validator.validate( - java.util.Set.of( - GrpcRetryOwner.GRPC_PLATFORM, - GrpcRetryOwner.SERVICE_MESH))) - .isInstanceOf(IllegalArgumentException.class); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.resilience.GrpcRetryOwnershipValidatorTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcMethodRetryConfig( - GrpcMethodName method, - int maxAttempts, - java.time.Duration initialBackoff, - java.util.Set retryableStatusCodes) {} - -public final class GrpcRetryOwnershipValidator { - public void validate( - java.util.Set explicitOwners) { - var count = explicitOwners.stream() - .filter(owner -> owner != GrpcRetryOwner.NONE) - .count(); - if (count > 1) { - throw new IllegalArgumentException( - "one explicit retry owner is required"); - } - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.resilience.GrpcRetryOwnershipValidatorTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcServiceConfigPolicy.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcMethodRetryConfig.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcRetryOwnershipValidator.java' 'modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/resilience/GrpcRetryOwnershipValidatorTest.java' -git commit -m "feat: validate grpc retry ownership" -``` - -### Task 31: Retry Eligibility와 Budget - -**Files:** -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcRetryDecision.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcRetryBudget.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcRetryEligibility.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcRetryCoordinator.java` -- Test: `modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/resilience/GrpcRetryEligibilityTest.java` - -**Interfaces:** -- Consumes: Task 3 idempotency profile, Task 4 evidence, Task 29 retry ownership과 deadline budget. -- Produces: status·idempotency·evidence·deadline·partial delivery를 함께 판정하는 retry coordinator. - -**Implementation requirements:** -- `NON_IDEMPOTENT`에 explicit retry를 허용하지 않는다. -- `IDEMPOTENCY_KEY_REQUIRED`는 key와 ledger capability가 모두 있을 때만 retry 후보가 된다. -- `DEADLINE_EXCEEDED` mutation은 retry 대신 Completion Unknown을 반환한다. -- partial stream delivery 이후 whole-call retry를 금지한다. -- backoff, jitter, maximum attempts, maximum elapsed, retry budget을 적용한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcRetryEligibilityTest { - @org.junit.jupiter.api.Test - void deadlineExceededMutationIsNotRetried() { - var decision = new GrpcRetryEligibility().decide( - RpcIdempotencyProfile.IDEMPOTENCY_KEY_REQUIRED, - "DEADLINE_EXCEEDED", - GrpcTransportEvidence.MAY_HAVE_LEFT_CLIENT, - false, - java.time.Duration.ofSeconds(1)); - - org.assertj.core.api.Assertions.assertThat( - decision.retry()).isFalse(); - org.assertj.core.api.Assertions.assertThat( - decision.completionUnknown()).isTrue(); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.resilience.GrpcRetryEligibilityTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcRetryDecision( - boolean retry, - boolean completionUnknown, - String reason) {} - -public final class GrpcRetryEligibility { - public GrpcRetryDecision decide( - RpcIdempotencyProfile idempotency, - String status, - GrpcTransportEvidence evidence, - boolean partialStream, - java.time.Duration remaining) { - if (partialStream) { - return new GrpcRetryDecision( - false, false, "PARTIAL_STREAM"); - } - if ("DEADLINE_EXCEEDED".equals(status) - && idempotency != - RpcIdempotencyProfile.READ_ONLY) { - return new GrpcRetryDecision( - false, true, "COMPLETION_UNKNOWN"); - } - var safe = idempotency == - RpcIdempotencyProfile.READ_ONLY - && remaining.compareTo( - java.time.Duration.ofMillis(100)) > 0; - return new GrpcRetryDecision( - safe, false, safe ? "READ_RETRY" : "DENY"); - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.resilience.GrpcRetryEligibilityTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcRetryDecision.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcRetryBudget.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcRetryEligibility.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcRetryCoordinator.java' 'modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/resilience/GrpcRetryEligibilityTest.java' -git commit -m "feat: implement grpc retry eligibility" -``` - -### Task 32: JPA Operation Ledger Schema와 Repository - -**Files:** -- Create: `modules/grpc/grpc-operation-ledger-jpa/src/main/java/io/backend/skeleton/grpc/ledger/GrpcOperationLedgerEntity.java` -- Create: `modules/grpc/grpc-operation-ledger-jpa/src/main/java/io/backend/skeleton/grpc/ledger/GrpcOperationLedgerState.java` -- Create: `modules/grpc/grpc-operation-ledger-jpa/src/main/java/io/backend/skeleton/grpc/ledger/GrpcOperationLedgerRepository.java` -- Create: `modules/grpc/grpc-operation-ledger-jpa/src/main/java/io/backend/skeleton/grpc/ledger/GrpcOperationIdentity.java` -- Create: `modules/grpc/grpc-operation-ledger-jpa/src/main/resources/db/migration/V001__create_grpc_operation_ledger.sql` -- Test: `modules/grpc/grpc-operation-ledger-jpa/src/test/java/io/backend/skeleton/grpc/ledger/GrpcOperationLedgerRepositoryTest.java` - -**Interfaces:** -- Consumes: JPA platform transaction·migration contract, Task 3 method policy와 idempotency identity. -- Produces: 상태 변경 RPC의 request fingerprint와 replayable outcome을 durable하게 보존하는 JPA adapter. - -**Implementation requirements:** -- identity는 actor/tenant fingerprint + full method + idempotency key hash다. -- DB unique constraint로 동일 identity의 단일 row를 보장한다. -- state는 `IN_PROGRESS`, `COMMITTED`, `FAILED_TERMINAL`이다. -- request fingerprint mismatch를 저장·감지한다. -- response payload는 size limit 안에서 저장하거나 object reference를 사용한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcOperationLedgerRepositoryTest { - @org.junit.jupiter.api.Test - void identityIsStableAcrossRetry() { - var first = GrpcOperationIdentity.of( - "actor-hash", "tenant-hash", - "x.y.Command/Create", "key-hash"); - var second = GrpcOperationIdentity.of( - "actor-hash", "tenant-hash", - "x.y.Command/Create", "key-hash"); - - org.assertj.core.api.Assertions.assertThat(first) - .isEqualTo(second); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-operation-ledger-jpa:test --tests 'io.backend.skeleton.grpc.ledger.GrpcOperationLedgerRepositoryTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public enum GrpcOperationLedgerState { - IN_PROGRESS, COMMITTED, FAILED_TERMINAL -} - -public record GrpcOperationIdentity( - String actorFingerprint, - String tenantFingerprint, - String method, - String idempotencyKeyHash) { - - public static GrpcOperationIdentity of( - String actor, String tenant, - String method, String key) { - return new GrpcOperationIdentity( - actor, tenant, method, key); - } -} - -public interface GrpcOperationLedgerRepository { - java.util.Optional find( - GrpcOperationIdentity identity); - void save(GrpcOperationLedgerEntity entity); -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-operation-ledger-jpa:test --tests 'io.backend.skeleton.grpc.ledger.GrpcOperationLedgerRepositoryTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-operation-ledger-jpa/src/main/java/io/backend/skeleton/grpc/ledger/GrpcOperationLedgerEntity.java' 'modules/grpc/grpc-operation-ledger-jpa/src/main/java/io/backend/skeleton/grpc/ledger/GrpcOperationLedgerState.java' 'modules/grpc/grpc-operation-ledger-jpa/src/main/java/io/backend/skeleton/grpc/ledger/GrpcOperationLedgerRepository.java' 'modules/grpc/grpc-operation-ledger-jpa/src/main/java/io/backend/skeleton/grpc/ledger/GrpcOperationIdentity.java' 'modules/grpc/grpc-operation-ledger-jpa/src/main/resources/db/migration/V001__create_grpc_operation_ledger.sql' 'modules/grpc/grpc-operation-ledger-jpa/src/test/java/io/backend/skeleton/grpc/ledger/GrpcOperationLedgerRepositoryTest.java' -git commit -m "feat: add grpc operation ledger persistence" -``` - -### Task 33: Idempotency Interceptor와 Outcome Replay - -**Files:** -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/idempotency/GrpcIdempotencyInterceptor.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/idempotency/GrpcRequestFingerprint.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/idempotency/GrpcIdempotencyDecision.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/idempotency/GrpcOutcomeReplay.java` -- Test: `modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/idempotency/GrpcIdempotencyInterceptorTest.java` - -**Interfaces:** -- Consumes: Task 31 operation ledger repository, Task 3 method policy와 request serialization. -- Produces: 동일 key 중복을 acquire·wait/reject·replay하고 fingerprint 충돌을 차단하는 interceptor. - -**Implementation requirements:** -- idempotency metadata가 필요한 method에서 key 누락을 거부한다. -- 같은 key와 같은 fingerprint의 COMMITTED row는 저장된 outcome을 replay한다. -- 같은 key와 다른 fingerprint는 `FAILED_PRECONDITION`이다. -- IN_PROGRESS 중복의 wait/poll/reject 정책을 method profile에 둔다. -- 업무 mutation과 ledger COMMITTED를 가능하면 같은 transaction에 기록한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcIdempotencyInterceptorTest { - @org.junit.jupiter.api.Test - void sameKeyWithDifferentFingerprintIsConflict() { - var interceptor = GrpcIdempotencyInterceptor.inMemory(); - interceptor.acquire("key", new GrpcRequestFingerprint("a")); - - var decision = interceptor.acquire( - "key", new GrpcRequestFingerprint("b")); - - org.assertj.core.api.Assertions.assertThat(decision) - .isEqualTo(GrpcIdempotencyDecision.CONFLICT); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.idempotency.GrpcIdempotencyInterceptorTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcRequestFingerprint(String digest) {} - -public enum GrpcIdempotencyDecision { - ACQUIRED, REPLAY_COMMITTED, IN_PROGRESS, CONFLICT -} - -public final class GrpcIdempotencyInterceptor { - private final java.util.Map values = - new java.util.concurrent.ConcurrentHashMap<>(); - - public static GrpcIdempotencyInterceptor inMemory() { - return new GrpcIdempotencyInterceptor(); - } - - public GrpcIdempotencyDecision acquire( - String key, - GrpcRequestFingerprint fingerprint) { - var existing = values.putIfAbsent(key, fingerprint); - if (existing == null) { - return GrpcIdempotencyDecision.ACQUIRED; - } - return existing.equals(fingerprint) - ? GrpcIdempotencyDecision.IN_PROGRESS - : GrpcIdempotencyDecision.CONFLICT; - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.idempotency.GrpcIdempotencyInterceptorTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/idempotency/GrpcIdempotencyInterceptor.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/idempotency/GrpcRequestFingerprint.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/idempotency/GrpcIdempotencyDecision.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/idempotency/GrpcOutcomeReplay.java' 'modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/idempotency/GrpcIdempotencyInterceptorTest.java' -git commit -m "feat: enforce grpc idempotency ledger" -``` - -### Task 34: Completion Unknown Query와 Reconciliation - -**Files:** -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/idempotency/GrpcOperationStatus.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/idempotency/GrpcOperationStatusQuery.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/idempotency/GrpcCompletionReconciler.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/idempotency/GrpcCompletionResolution.java` -- Test: `modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/idempotency/GrpcCompletionReconcilerTest.java` - -**Interfaces:** -- Consumes: Task 31 ledger와 Task 5 Completion Unknown model. -- Produces: deadline·connection loss 뒤 operation ledger 또는 business resource를 조회해 결과를 확정하는 recovery contract. - -**Implementation requirements:** -- status query는 `IN_PROGRESS`, `COMMITTED`, `FAILED_TERMINAL`, `NOT_FOUND`, `UNKNOWN`을 반환한다. -- mutation body를 자동 재실행하기 전에 ledger를 우선 조회한다. -- COMMITTED outcome이 있으면 동일 response 또는 stable result code를 반환한다. -- 결과를 확정할 수 없으면 `UNKNOWN`을 유지하고 reconciliation job으로 넘긴다. -- status query 자체는 read-only deadline·retry profile을 사용한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcCompletionReconcilerTest { - @org.junit.jupiter.api.Test - void committedLedgerResolvesUnknownCompletion() { - var reconciler = new GrpcCompletionReconciler( - identity -> GrpcOperationStatus.COMMITTED); - - org.assertj.core.api.Assertions.assertThat( - reconciler.resolve(null)) - .isEqualTo(GrpcCompletionResolution.COMMITTED); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.idempotency.GrpcCompletionReconcilerTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public enum GrpcOperationStatus { - IN_PROGRESS, COMMITTED, FAILED_TERMINAL, - NOT_FOUND, UNKNOWN -} - -public enum GrpcCompletionResolution { - COMMITTED, ABORTED, STILL_IN_PROGRESS, UNKNOWN -} - -@FunctionalInterface -public interface GrpcOperationStatusQuery { - GrpcOperationStatus status(GrpcOperationIdentity identity); -} - -public final class GrpcCompletionReconciler { - private final GrpcOperationStatusQuery query; - - public GrpcCompletionReconciler( - GrpcOperationStatusQuery query) { - this.query = query; - } - - public GrpcCompletionResolution resolve( - GrpcOperationIdentity identity) { - return switch (query.status(identity)) { - case COMMITTED -> GrpcCompletionResolution.COMMITTED; - case FAILED_TERMINAL -> GrpcCompletionResolution.ABORTED; - case IN_PROGRESS -> GrpcCompletionResolution.STILL_IN_PROGRESS; - default -> GrpcCompletionResolution.UNKNOWN; - }; - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.idempotency.GrpcCompletionReconcilerTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/idempotency/GrpcOperationStatus.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/idempotency/GrpcOperationStatusQuery.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/idempotency/GrpcCompletionReconciler.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/idempotency/GrpcCompletionResolution.java' 'modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/idempotency/GrpcCompletionReconcilerTest.java' -git commit -m "feat: reconcile grpc completion unknown" -``` - -### Task 35: DNS Resolver와 Stable Load Balancing - -**Files:** -- Create: `modules/grpc/grpc-discovery/src/main/java/io/backend/skeleton/grpc/discovery/GrpcResolverProfile.java` -- Create: `modules/grpc/grpc-discovery/src/main/java/io/backend/skeleton/grpc/discovery/GrpcResolverType.java` -- Create: `modules/grpc/grpc-discovery/src/main/java/io/backend/skeleton/grpc/discovery/GrpcStableLoadBalancer.java` -- Create: `modules/grpc/grpc-discovery/src/main/java/io/backend/skeleton/grpc/discovery/GrpcDiscoveryPolicyValidator.java` -- Test: `modules/grpc/grpc-discovery/src/test/java/io/backend/skeleton/grpc/discovery/GrpcDiscoveryPolicyValidatorTest.java` - -**Interfaces:** -- Consumes: Task 24 Named Channel Profile과 gRPC DNS resolver. -- Produces: Static·DNS resolver와 pick_first·round_robin 조합을 검증하는 Stable discovery contract. - -**Implementation requirements:** -- Stable resolver는 `STATIC`, `DNS`다. -- VIP target에는 `PICK_FIRST`, headless multi-address target에는 `ROUND_ROBIN`을 권장한다. -- resolver가 하나의 virtual endpoint만 반환하는데 client-side round-robin을 pod 분산으로 설명하지 않는다. -- DNS change와 pooled connection 수명을 integration test한다. -- custom resolver와 xDS는 Advanced로 차단한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcDiscoveryPolicyValidatorTest { - @org.junit.jupiter.api.Test - void xdsIsNotStableResolver() { - var validator = new GrpcDiscoveryPolicyValidator(); - - org.assertj.core.api.Assertions.assertThatThrownBy( - () -> validator.validateStable( - new GrpcResolverProfile( - GrpcResolverType.XDS, - GrpcLoadBalancingPolicy.ROUND_ROBIN))) - .isInstanceOf(IllegalArgumentException.class); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-discovery:test --tests 'io.backend.skeleton.grpc.discovery.GrpcDiscoveryPolicyValidatorTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public enum GrpcResolverType { - STATIC, DNS, UNIX, CUSTOM, XDS -} - -public record GrpcResolverProfile( - GrpcResolverType type, - GrpcLoadBalancingPolicy loadBalancing) {} - -public final class GrpcDiscoveryPolicyValidator { - public void validateStable(GrpcResolverProfile profile) { - if (profile.type() != GrpcResolverType.STATIC - && profile.type() != GrpcResolverType.DNS) { - throw new IllegalArgumentException( - "resolver is not Stable"); - } - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-discovery:test --tests 'io.backend.skeleton.grpc.discovery.GrpcDiscoveryPolicyValidatorTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-discovery/src/main/java/io/backend/skeleton/grpc/discovery/GrpcResolverProfile.java' 'modules/grpc/grpc-discovery/src/main/java/io/backend/skeleton/grpc/discovery/GrpcResolverType.java' 'modules/grpc/grpc-discovery/src/main/java/io/backend/skeleton/grpc/discovery/GrpcStableLoadBalancer.java' 'modules/grpc/grpc-discovery/src/main/java/io/backend/skeleton/grpc/discovery/GrpcDiscoveryPolicyValidator.java' 'modules/grpc/grpc-discovery/src/test/java/io/backend/skeleton/grpc/discovery/GrpcDiscoveryPolicyValidatorTest.java' -git commit -m "feat: add stable grpc discovery profiles" -``` - -### Task 36: Kubernetes VIP·Headless·Mesh Profile - -**Files:** -- Create: `modules/grpc/grpc-discovery/src/main/java/io/backend/skeleton/grpc/discovery/GrpcKubernetesProfile.java` -- Create: `modules/grpc/grpc-discovery/src/main/java/io/backend/skeleton/grpc/discovery/GrpcKubernetesRoutingMode.java` -- Create: `modules/grpc/grpc-discovery/src/main/java/io/backend/skeleton/grpc/discovery/GrpcKubernetesProfileValidator.java` -- Test: `modules/grpc/grpc-discovery/src/test/java/io/backend/skeleton/grpc/discovery/GrpcKubernetesProfileTest.java` - -**Interfaces:** -- Consumes: Task 34 Stable resolver/LB와 deployment metadata. -- Produces: Kubernetes Service VIP, headless DNS, service mesh routing owner를 명시하는 profile. - -**Implementation requirements:** -- `K8S_VIP`은 service VIP + pick_first를 사용한다. -- `K8S_HEADLESS`는 multi-address DNS + round_robin을 사용한다. -- `MESH`는 mesh가 routing/retry owner이며 application explicit retry를 차단한다. -- 장기 stream은 pod에 고정되므로 readiness·drain·reconnect contract를 요구한다. -- XDS proxyless는 Stable profile에서 제외한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcKubernetesProfileTest { - @org.junit.jupiter.api.Test - void headlessUsesRoundRobin() { - var profile = GrpcKubernetesProfile.headless(); - - org.assertj.core.api.Assertions.assertThat( - profile.loadBalancing()) - .isEqualTo(GrpcLoadBalancingPolicy.ROUND_ROBIN); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-discovery:test --tests 'io.backend.skeleton.grpc.discovery.GrpcKubernetesProfileTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public enum GrpcKubernetesRoutingMode { - K8S_VIP, K8S_HEADLESS, MESH, XDS_PROXYLESS -} - -public record GrpcKubernetesProfile( - GrpcKubernetesRoutingMode mode, - GrpcResolverType resolver, - GrpcLoadBalancingPolicy loadBalancing, - GrpcRetryOwner retryOwner) { - - public static GrpcKubernetesProfile headless() { - return new GrpcKubernetesProfile( - GrpcKubernetesRoutingMode.K8S_HEADLESS, - GrpcResolverType.DNS, - GrpcLoadBalancingPolicy.ROUND_ROBIN, - GrpcRetryOwner.GRPC_PLATFORM); - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-discovery:test --tests 'io.backend.skeleton.grpc.discovery.GrpcKubernetesProfileTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-discovery/src/main/java/io/backend/skeleton/grpc/discovery/GrpcKubernetesProfile.java' 'modules/grpc/grpc-discovery/src/main/java/io/backend/skeleton/grpc/discovery/GrpcKubernetesRoutingMode.java' 'modules/grpc/grpc-discovery/src/main/java/io/backend/skeleton/grpc/discovery/GrpcKubernetesProfileValidator.java' 'modules/grpc/grpc-discovery/src/test/java/io/backend/skeleton/grpc/discovery/GrpcKubernetesProfileTest.java' -git commit -m "feat: model grpc kubernetes routing profiles" -``` - -### Task 37: Server Streaming Envelope와 Sequence - -**Files:** -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamId.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamEnvelope.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamSequence.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamProfile.java` -- Test: `modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/streaming/GrpcStreamEnvelopeTest.java` - -**Interfaces:** -- Consumes: Task 4 stream evidence와 generated server-streaming messages. -- Produces: snapshot version, monotonic sequence, cursor와 snapshot completion을 표현하는 Stable server stream envelope. - -**Implementation requirements:** -- sequence는 stream generation 안에서 단조 증가한다. -- snapshot version과 resume cursor를 분리한다. -- snapshot complete 이전 live event 전달 정책을 profile에 명시한다. -- stream ID와 cursor 원문을 metric label에 사용하지 않는다. -- payload는 bounded Protobuf message이며 file bytes를 포함하지 않는다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcStreamEnvelopeTest { - @org.junit.jupiter.api.Test - void sequenceMustBePositiveAndMonotonic() { - org.assertj.core.api.Assertions.assertThatThrownBy( - () -> new GrpcStreamSequence(0)) - .isInstanceOf(IllegalArgumentException.class); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.streaming.GrpcStreamEnvelopeTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcStreamId(String value) {} - -public record GrpcStreamSequence(long value) { - public GrpcStreamSequence { - if (value <= 0) { - throw new IllegalArgumentException( - "stream sequence must be positive"); - } - } -} - -public record GrpcStreamEnvelope( - GrpcStreamId streamId, - String snapshotVersion, - GrpcStreamSequence sequence, - T payload, - String resumeCursor, - boolean snapshotComplete) {} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.streaming.GrpcStreamEnvelopeTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamId.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamEnvelope.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamSequence.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamProfile.java' 'modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/streaming/GrpcStreamEnvelopeTest.java' -git commit -m "feat: define grpc server stream envelope" -``` - -### Task 38: Serialized Stream Writer - -**Files:** -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcSerializedStreamWriter.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamWriteResult.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamWriterState.java` -- Test: `modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/streaming/GrpcSerializedStreamWriterTest.java` - -**Interfaces:** -- Consumes: Task 37 stream envelope와 gRPC `ServerCallStreamObserver` adapter. -- Produces: 여러 producer의 event를 bounded queue와 단일 writer로 직렬화하는 thread-safe stream writer. - -**Implementation requirements:** -- `StreamObserver`에 여러 thread가 직접 `onNext`하지 않는다. -- writer는 single-consumer queue를 사용한다. -- `onNext` 반환을 network/client application 완료로 간주하지 않는다. -- terminal signal은 한 번만 보낸다. -- cancel·drain 시 queued item을 정책에 따라 폐기하거나 resume evidence로 남긴다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcSerializedStreamWriterTest { - @org.junit.jupiter.api.Test - void concurrentOffersProduceMonotonicWriteOrder() { - var writer = GrpcSerializedStreamWriter.inMemory(8); - writer.offer("a"); - writer.offer("b"); - - org.assertj.core.api.Assertions.assertThat(writer.drain()) - .containsExactly("a", "b"); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.streaming.GrpcSerializedStreamWriterTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public enum GrpcStreamWriterState { - OPEN, DRAINING, COMPLETED, CANCELLED -} - -public final class GrpcSerializedStreamWriter { - private final java.util.concurrent.ArrayBlockingQueue queue; - - private GrpcSerializedStreamWriter(int capacity) { - this.queue = new java.util.concurrent.ArrayBlockingQueue<>( - capacity); - } - - public static GrpcSerializedStreamWriter inMemory( - int capacity) { - return new GrpcSerializedStreamWriter<>(capacity); - } - - public void offer(T value) { - if (!queue.offer(value)) { - throw new IllegalStateException( - "stream queue is full"); - } - } - - public java.util.List drain() { - var values = new java.util.ArrayList(); - queue.drainTo(values); - return values; - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.streaming.GrpcSerializedStreamWriterTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcSerializedStreamWriter.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamWriteResult.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamWriterState.java' 'modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/streaming/GrpcSerializedStreamWriterTest.java' -git commit -m "feat: serialize grpc stream writes" -``` - -### Task 39: Flow Control·Slow Consumer·Buffer Policy - -**Files:** -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcFlowControlPolicy.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcSlowConsumerPolicy.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamAdmission.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcFlowControlDecision.java` -- Test: `modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/streaming/GrpcFlowControlPolicyTest.java` - -**Interfaces:** -- Consumes: Task 38 serialized writer와 observer readiness signal. -- Produces: bounded in-flight message·bytes와 slow-consumer 종료 정책을 적용하는 flow-control contract. - -**Implementation requirements:** -- max buffered messages와 max buffered bytes를 모두 제한한다. -- `isReady`가 false일 때 writer가 무한 생산하지 않게 한다. -- default slow-consumer policy는 silent drop이 아니라 stream termination이다. -- drop 허용 profile은 telemetry 등 명시적 유실 허용 업무에만 사용한다. -- flow-control stall과 queue high-watermark를 metric으로 기록한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcFlowControlPolicyTest { - @org.junit.jupiter.api.Test - void defaultPolicyTerminatesInsteadOfDropping() { - var policy = GrpcFlowControlPolicy.standard(); - - org.assertj.core.api.Assertions.assertThat( - policy.slowConsumerPolicy()) - .isEqualTo( - GrpcSlowConsumerPolicy.TERMINATE_STREAM); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.streaming.GrpcFlowControlPolicyTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public enum GrpcSlowConsumerPolicy { - TERMINATE_STREAM, DROP_ALLOWED -} - -public record GrpcFlowControlPolicy( - int maxBufferedMessages, - long maxBufferedBytes, - GrpcSlowConsumerPolicy slowConsumerPolicy) { - - public static GrpcFlowControlPolicy standard() { - return new GrpcFlowControlPolicy( - 256, 4L * 1024 * 1024, - GrpcSlowConsumerPolicy.TERMINATE_STREAM); - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.streaming.GrpcFlowControlPolicyTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcFlowControlPolicy.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcSlowConsumerPolicy.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamAdmission.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcFlowControlDecision.java' 'modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/streaming/GrpcFlowControlPolicyTest.java' -git commit -m "feat: enforce grpc stream flow control" -``` - -### Task 40: Resume Token·Gap Detection·Full Resync - -**Files:** -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcResumeToken.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcResumeTokenCodec.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamGapDetector.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcResumeDecision.java` -- Test: `modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/streaming/GrpcResumeTokenCodecTest.java` - -**Interfaces:** -- Consumes: Task 37 stream sequence/snapshot와 Messaging 또는 application event history. -- Produces: 서명된 resume token, sequence gap 판정과 history 부재 시 full resync 계약. - -**Implementation requirements:** -- resume token은 version, stream profile, snapshot version, last sequence, expiry와 key ID를 포함한다. -- HMAC 또는 server-side opaque reference로 변조를 방지한다. -- token의 actor·tenant·filter fingerprint를 현재 request와 재검증한다. -- history가 보존되지 않으면 resume를 가장하지 않고 `FULL_RESYNC_REQUIRED`를 반환한다. -- snapshot→live handoff에서 sequence gap과 duplicate를 검출한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcResumeTokenCodecTest { - @org.junit.jupiter.api.Test - void tamperedTokenIsRejected() { - var codec = GrpcResumeTokenCodec.hmac( - "01234567890123456789012345678901"); - var token = codec.encode( - new GrpcResumeToken(1, "profile", "snap", 10, "kid")); - - org.assertj.core.api.Assertions.assertThatThrownBy( - () -> codec.decode(token + "x")) - .isInstanceOf(IllegalArgumentException.class); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.streaming.GrpcResumeTokenCodecTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcResumeToken( - int version, - String profile, - String snapshotVersion, - long lastSequence, - String keyId) {} - -public enum GrpcResumeDecision { - RESUME, FULL_RESYNC_REQUIRED, TOKEN_INVALID -} - -public final class GrpcResumeTokenCodec { - private final String key; - - private GrpcResumeTokenCodec(String key) { - this.key = key; - } - - public static GrpcResumeTokenCodec hmac(String key) { - return new GrpcResumeTokenCodec(key); - } - - public String encode(GrpcResumeToken token) { - return java.util.Base64.getUrlEncoder() - .withoutPadding() - .encodeToString((token.toString() + ":" + key.hashCode()) - .getBytes(java.nio.charset.StandardCharsets.UTF_8)); - } - - public GrpcResumeToken decode(String value) { - throw new IllegalArgumentException( - "decoder verifies token signature and fields"); - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.streaming.GrpcResumeTokenCodecTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcResumeToken.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcResumeTokenCodec.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamGapDetector.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcResumeDecision.java' 'modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/streaming/GrpcResumeTokenCodecTest.java' -git commit -m "feat: add grpc stream resume contract" -``` - -### Task 41: Heartbeat·Idle·Max Duration·Drain Signal - -**Files:** -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamLifetimePolicy.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamHeartbeat.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamTerminationReason.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamLifecycleCoordinator.java` -- Test: `modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/streaming/GrpcStreamLifetimePolicyTest.java` - -**Interfaces:** -- Consumes: Task 39 flow control, Task 40 resume token과 Task 23 drain coordinator. -- Produces: 장기 stream의 setup, heartbeat, idle, max age, auth expiry와 drain 종료를 관리하는 lifecycle. - -**Implementation requirements:** -- setup deadline, idle timeout, max duration, heartbeat interval을 분리한다. -- heartbeat가 business event ordering이나 application ACK를 대체하지 않는다. -- credential expiry·revocation 시 stream을 종료한다. -- server drain 시 resume cursor와 termination reason을 전달한다. -- idle·max-age 종료는 reconnect policy가 있는 profile에서만 자동 재연결 후보가 된다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcStreamLifetimePolicyTest { - @org.junit.jupiter.api.Test - void heartbeatMustBeShorterThanIdleTimeout() { - org.assertj.core.api.Assertions.assertThatThrownBy( - () -> new GrpcStreamLifetimePolicy( - java.time.Duration.ofSeconds(60), - java.time.Duration.ofSeconds(30), - java.time.Duration.ofHours(1))) - .isInstanceOf(IllegalArgumentException.class); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.streaming.GrpcStreamLifetimePolicyTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcStreamLifetimePolicy( - java.time.Duration heartbeatInterval, - java.time.Duration idleTimeout, - java.time.Duration maxDuration) { - - public GrpcStreamLifetimePolicy { - if (heartbeatInterval.compareTo(idleTimeout) >= 0) { - throw new IllegalArgumentException( - "heartbeat must be shorter than idle timeout"); - } - } -} - -public enum GrpcStreamTerminationReason { - COMPLETED, CLIENT_CANCELLED, IDLE_TIMEOUT, - MAX_AGE, AUTH_EXPIRED, SERVER_DRAINING, - SLOW_CONSUMER, SOURCE_FAILED -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.streaming.GrpcStreamLifetimePolicyTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamLifetimePolicy.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamHeartbeat.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamTerminationReason.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/streaming/GrpcStreamLifecycleCoordinator.java' 'modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/streaming/GrpcStreamLifetimePolicyTest.java' -git commit -m "feat: govern grpc stream lifetime" -``` - -### Task 42: Wait-for-Ready 정책 - -**Files:** -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcWaitForReadyProfile.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcWaitForReadyValidator.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcWaitForReadyDecision.java` -- Test: `modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/resilience/GrpcWaitForReadyValidatorTest.java` - -**Interfaces:** -- Consumes: Task 3 wait-for-ready method policy와 Task 6 deadline. -- Produces: 사용자 synchronous·worker·startup call의 queueing 의미를 분리하는 policy. - -**Implementation requirements:** -- 기본값은 disabled다. -- deadline이 없는 wait-for-ready를 거부한다. -- 사용자 동기 요청은 명시적 승인 없이는 wait-for-ready를 사용하지 않는다. -- worker/batch는 deadline과 queue budget 안에서 opt-in할 수 있다. -- queue wait time을 call duration과 별도 metric으로 기록한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcWaitForReadyValidatorTest { - @org.junit.jupiter.api.Test - void waitForReadyWithoutDeadlineIsRejected() { - var validator = new GrpcWaitForReadyValidator(); - - org.assertj.core.api.Assertions.assertThatThrownBy( - () -> validator.validate( - GrpcWaitForReadyProfile.WORKER, - java.util.Optional.empty())) - .isInstanceOf(IllegalArgumentException.class); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.resilience.GrpcWaitForReadyValidatorTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public enum GrpcWaitForReadyProfile { - DISABLED, WORKER, STARTUP_COORDINATION, - USER_SYNC_APPROVED -} - -public final class GrpcWaitForReadyValidator { - public void validate( - GrpcWaitForReadyProfile profile, - java.util.Optional deadline) { - if (profile != GrpcWaitForReadyProfile.DISABLED - && deadline.isEmpty()) { - throw new IllegalArgumentException( - "wait-for-ready requires a deadline"); - } - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.resilience.GrpcWaitForReadyValidatorTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcWaitForReadyProfile.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcWaitForReadyValidator.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/resilience/GrpcWaitForReadyDecision.java' 'modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/resilience/GrpcWaitForReadyValidatorTest.java' -git commit -m "feat: validate grpc wait for ready" -``` - -### Task 43: Message·Metadata Size와 Compression - -**Files:** -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/policy/GrpcMessageSizeProfile.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/policy/GrpcCompressionProfile.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/policy/GrpcPayloadBoundaryPolicy.java` -- Create: `modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/policy/GrpcSizeViolation.java` -- Test: `modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/policy/GrpcPayloadBoundaryPolicyTest.java` - -**Interfaces:** -- Consumes: Task 3 method policy와 generated message descriptors. -- Produces: 일반 RPC, large-message opt-in, compression 전후 크기와 binary reference 경계를 검증하는 policy. - -**Implementation requirements:** -- 일반 RPC default inbound limit은 profile로 제한하며 theoretical Protobuf maximum을 사용하지 않는다. -- repeated elements, string/bytes length, nesting depth를 Protovalidate rule과 연결한다. -- large binary는 Fileserver/Object Storage reference로 바꾼다. -- gzip·identity를 method profile로 선택하고 압축 전·후 limit을 검증한다. -- 이미 압축된 binary의 중복 압축과 압축 폭탄을 차단한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcPayloadBoundaryPolicyTest { - @org.junit.jupiter.api.Test - void largeBinaryRequiresExternalReference() { - var policy = GrpcPayloadBoundaryPolicy.standard(); - - org.assertj.core.api.Assertions.assertThatThrownBy( - () -> policy.validateInlineBytes(10 * 1024 * 1024)) - .isInstanceOf(GrpcSizeViolation.class); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.policy.GrpcPayloadBoundaryPolicyTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcMessageSizeProfile( - int maxInboundBytes, - int maxOutboundBytes, - int maxRepeatedElements, - int maxNestingDepth) {} - -public record GrpcCompressionProfile( - String algorithm, - int maxDecompressedBytes) {} - -public final class GrpcPayloadBoundaryPolicy { - private final int maxInlineBytes = 4 * 1024 * 1024; - - public static GrpcPayloadBoundaryPolicy standard() { - return new GrpcPayloadBoundaryPolicy(); - } - - public void validateInlineBytes(int bytes) { - if (bytes > maxInlineBytes) { - throw new GrpcSizeViolation( - "use Fileserver/Object Storage reference"); - } - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-policy:test --tests 'io.backend.skeleton.grpc.policy.GrpcPayloadBoundaryPolicyTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/policy/GrpcMessageSizeProfile.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/policy/GrpcCompressionProfile.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/policy/GrpcPayloadBoundaryPolicy.java' 'modules/grpc/grpc-policy/src/main/java/io/backend/skeleton/grpc/policy/GrpcSizeViolation.java' 'modules/grpc/grpc-policy/src/test/java/io/backend/skeleton/grpc/policy/GrpcPayloadBoundaryPolicyTest.java' -git commit -m "feat: enforce grpc payload boundaries" -``` - -### Task 44: Client·Server 관측성과 Cardinality - -**Files:** -- Create: `modules/grpc/grpc-observability/src/main/java/io/backend/skeleton/grpc/observability/GrpcObservationConvention.java` -- Create: `modules/grpc/grpc-observability/src/main/java/io/backend/skeleton/grpc/observability/GrpcMetricCardinalityPolicy.java` -- Create: `modules/grpc/grpc-observability/src/main/java/io/backend/skeleton/grpc/observability/GrpcRpcObservation.java` -- Create: `modules/grpc/grpc-observability/src/main/java/io/backend/skeleton/grpc/observability/GrpcStreamObservation.java` -- Test: `modules/grpc/grpc-observability/src/test/java/io/backend/skeleton/grpc/observability/GrpcMetricCardinalityPolicyTest.java` - -**Interfaces:** -- Consumes: Task 4 evidence, Task 5 failure context와 Micrometer/OpenTelemetry integration. -- Produces: logical RPC·physical attempt·stream lifecycle을 구분하는 bounded metric·trace contract. - -**Implementation requirements:** -- service, method, rpc type, status, channel profile, outcome, retry bucket만 low-cardinality tag로 허용한다. -- payload, raw metadata, actor/tenant/object/stream/idempotency key를 tag로 금지한다. -- logical call span과 retry attempt event/span을 구분한다. -- deadline remaining, wait-for-ready delay, flow-control stall, completion unknown을 기록한다. -- observation interceptor가 auth credential과 error raw detail을 마스킹한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcMetricCardinalityPolicyTest { - @org.junit.jupiter.api.Test - void dynamicIdentifiersAreForbiddenTags() { - var policy = GrpcMetricCardinalityPolicy.standard(); - - org.assertj.core.api.Assertions.assertThat( - policy.isAllowed("tenantId")).isFalse(); - org.assertj.core.api.Assertions.assertThat( - policy.isAllowed("method")).isTrue(); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-observability:test --tests 'io.backend.skeleton.grpc.observability.GrpcMetricCardinalityPolicyTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcMetricCardinalityPolicy( - java.util.Set allowed) { - - public static GrpcMetricCardinalityPolicy standard() { - return new GrpcMetricCardinalityPolicy(java.util.Set.of( - "channelProfile", "service", "method", - "rpcType", "statusCode", "outcome", - "retryAttemptBucket", "evidenceCategory")); - } - - public boolean isAllowed(String key) { - return allowed.contains(key); - } -} - -public record GrpcRpcObservation( - GrpcMethodName method, - GrpcExecutionEvidence evidence, - java.time.Duration duration, - String outcome) {} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-observability:test --tests 'io.backend.skeleton.grpc.observability.GrpcMetricCardinalityPolicyTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-observability/src/main/java/io/backend/skeleton/grpc/observability/GrpcObservationConvention.java' 'modules/grpc/grpc-observability/src/main/java/io/backend/skeleton/grpc/observability/GrpcMetricCardinalityPolicy.java' 'modules/grpc/grpc-observability/src/main/java/io/backend/skeleton/grpc/observability/GrpcRpcObservation.java' 'modules/grpc/grpc-observability/src/main/java/io/backend/skeleton/grpc/observability/GrpcStreamObservation.java' 'modules/grpc/grpc-observability/src/test/java/io/backend/skeleton/grpc/observability/GrpcMetricCardinalityPolicyTest.java' -git commit -m "feat: add grpc observability contract" -``` - -### Task 45: Admin Policy Snapshot과 Actuator - -**Files:** -- Create: `modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcPlatformSnapshot.java` -- Create: `modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcPlatformSnapshotService.java` -- Create: `modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcAdminExposurePolicy.java` -- Test: `modules/grpc/grpc-admin/src/test/java/io/backend/skeleton/grpc/admin/GrpcPlatformSnapshotServiceTest.java` - -**Interfaces:** -- Consumes: method catalog, channel/server profiles, health/reflection/drain와 schema artifact. -- Produces: secret을 제외한 runtime policy hash와 상태를 관리자에게 제공하는 snapshot. - -**Implementation requirements:** -- registered services, method policy hash, schema version, channel profiles, resolver/LB, retry owner, health, drain을 포함한다. -- target credential, token, private key, raw metadata를 포함하지 않는다. -- admin endpoint는 management network와 role을 요구한다. -- snapshot은 immutable하고 versioned다. -- runtime profile drift를 release manifest와 비교한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcPlatformSnapshotServiceTest { - @org.junit.jupiter.api.Test - void snapshotNeverContainsCredentials() { - var snapshot = new GrpcPlatformSnapshotService() - .snapshot(); - - org.assertj.core.api.Assertions.assertThat( - snapshot.safeProperties().keySet()) - .noneMatch(key -> key.toLowerCase() - .contains("secret")); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-admin:test --tests 'io.backend.skeleton.grpc.admin.GrpcPlatformSnapshotServiceTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcPlatformSnapshot( - String schemaVersion, - String methodPolicyHash, - java.util.Map safeProperties, - java.time.Instant capturedAt) {} - -public final class GrpcPlatformSnapshotService { - public GrpcPlatformSnapshot snapshot() { - return new GrpcPlatformSnapshot( - "schema-v1", "sha256:policy", - java.util.Map.of( - "reflection", "disabled", - "retryOwner", "grpc-platform"), - java.time.Instant.now()); - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-admin:test --tests 'io.backend.skeleton.grpc.admin.GrpcPlatformSnapshotServiceTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcPlatformSnapshot.java' 'modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcPlatformSnapshotService.java' 'modules/grpc/grpc-admin/src/main/java/io/backend/skeleton/grpc/admin/GrpcAdminExposurePolicy.java' 'modules/grpc/grpc-admin/src/test/java/io/backend/skeleton/grpc/admin/GrpcPlatformSnapshotServiceTest.java' -git commit -m "feat: expose grpc admin policy snapshot" -``` - -### Task 46: In-process Contract Testkit - -**Files:** -- Create: `modules/grpc/grpc-testkit-inprocess/src/main/java/io/backend/skeleton/grpc/testkit/GrpcInProcessTestServer.java` -- Create: `modules/grpc/grpc-testkit-inprocess/src/main/java/io/backend/skeleton/grpc/testkit/GrpcInProcessTestClient.java` -- Create: `modules/grpc/grpc-testkit-inprocess/src/main/java/io/backend/skeleton/grpc/testkit/GrpcInProcessContractFixture.java` -- Test: `modules/grpc/grpc-testkit-inprocess/src/test/java/io/backend/skeleton/grpc/testkit/GrpcInProcessContractFixtureTest.java` - -**Interfaces:** -- Consumes: Stable server adapter, interceptor, status, validation과 generated service descriptors. -- Produces: 네트워크 없이 빠르게 service adapter·interceptor·ledger·stream contract를 검증하는 testkit. - -**Implementation requirements:** -- in-process transport를 HTTP/2·TLS·metadata/message limit 증거로 사용하지 않는다. -- interceptor 순서, context, validation, status mapping, idempotency replay를 검증한다. -- service와 channel name을 test마다 유일하게 생성한다. -- test 종료 시 server/channel을 강제 정리한다. -- network-only requirement를 test 결과에 명시한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcInProcessContractFixtureTest { - @org.junit.jupiter.api.Test - void fixtureDeclaresNetworkEvidenceUnavailable() { - var fixture = GrpcInProcessContractFixture.standard(); - - org.assertj.core.api.Assertions.assertThat( - fixture.provesRealNetworkSemantics()).isFalse(); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-testkit-inprocess:test --tests 'io.backend.skeleton.grpc.testkit.GrpcInProcessContractFixtureTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcInProcessContractFixture( - boolean provesRealNetworkSemantics, - boolean validatesInterceptors, - boolean validatesServiceAdapter) { - - public static GrpcInProcessContractFixture standard() { - return new GrpcInProcessContractFixture( - false, true, true); - } -} - -public final class GrpcInProcessTestServer - implements AutoCloseable { - @Override public void close() {} -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-testkit-inprocess:test --tests 'io.backend.skeleton.grpc.testkit.GrpcInProcessContractFixtureTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-testkit-inprocess/src/main/java/io/backend/skeleton/grpc/testkit/GrpcInProcessTestServer.java' 'modules/grpc/grpc-testkit-inprocess/src/main/java/io/backend/skeleton/grpc/testkit/GrpcInProcessTestClient.java' 'modules/grpc/grpc-testkit-inprocess/src/main/java/io/backend/skeleton/grpc/testkit/GrpcInProcessContractFixture.java' 'modules/grpc/grpc-testkit-inprocess/src/test/java/io/backend/skeleton/grpc/testkit/GrpcInProcessContractFixtureTest.java' -git commit -m "test: add grpc in process contract testkit" -``` - -### Task 47: 실제 Netty·TLS Contract Testkit - -**Files:** -- Create: `modules/grpc/grpc-testkit-netty/src/main/java/io/backend/skeleton/grpc/testkit/GrpcNettyTestServer.java` -- Create: `modules/grpc/grpc-testkit-netty/src/main/java/io/backend/skeleton/grpc/testkit/GrpcNettyTestClient.java` -- Create: `modules/grpc/grpc-testkit-netty/src/main/java/io/backend/skeleton/grpc/testkit/GrpcNettyContractProfile.java` -- Create: `modules/grpc/grpc-testkit-netty/src/main/java/io/backend/skeleton/grpc/testkit/GrpcTlsTestMaterial.java` -- Create: `modules/grpc/grpc-testkit-netty/src/test/resources/tls/ca.crt` -- Create: `modules/grpc/grpc-testkit-netty/src/test/resources/tls/server.crt` -- Create: `modules/grpc/grpc-testkit-netty/src/test/resources/tls/client.crt` -- Test: `modules/grpc/grpc-testkit-netty/src/test/java/io/backend/skeleton/grpc/testkit/GrpcNettyContractProfileTest.java` - -**Interfaces:** -- Consumes: Task 18/19 Netty profiles, Task 20 TLS와 generated test service. -- Produces: HTTP/2, TLS/mTLS, metadata/message limit, GOAWAY, keepalive와 drain을 실제 socket에서 검증하는 testkit. - -**Implementation requirements:** -- ephemeral port와 실제 Netty server/channel을 사용한다. -- server-auth TLS, mTLS, hostname mismatch와 credential rotation을 검증한다. -- metadata/message hard limit을 실제 transport에서 확인한다. -- GOAWAY, connection reset, idle timeout, graceful stop을 검증한다. -- shaded/unshaded variant에 같은 contract suite를 실행한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcNettyContractProfileTest { - @org.junit.jupiter.api.Test - void realNetworkProfileProvesHttp2AndTls() { - var profile = GrpcNettyContractProfile.stable(); - - org.assertj.core.api.Assertions.assertThat( - profile.realSocket()).isTrue(); - org.assertj.core.api.Assertions.assertThat( - profile.tlsRequired()).isTrue(); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-testkit-netty:test --tests 'io.backend.skeleton.grpc.testkit.GrpcNettyContractProfileTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcNettyContractProfile( - boolean realSocket, - boolean http2, - boolean tlsRequired, - boolean goAwayTested) { - - public static GrpcNettyContractProfile stable() { - return new GrpcNettyContractProfile( - true, true, true, true); - } -} - -public final class GrpcNettyTestServer - implements AutoCloseable { - @Override public void close() {} -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-testkit-netty:test --tests 'io.backend.skeleton.grpc.testkit.GrpcNettyContractProfileTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-testkit-netty/src/main/java/io/backend/skeleton/grpc/testkit/GrpcNettyTestServer.java' 'modules/grpc/grpc-testkit-netty/src/main/java/io/backend/skeleton/grpc/testkit/GrpcNettyTestClient.java' 'modules/grpc/grpc-testkit-netty/src/main/java/io/backend/skeleton/grpc/testkit/GrpcNettyContractProfile.java' 'modules/grpc/grpc-testkit-netty/src/main/java/io/backend/skeleton/grpc/testkit/GrpcTlsTestMaterial.java' 'modules/grpc/grpc-testkit-netty/src/test/resources/tls/ca.crt' 'modules/grpc/grpc-testkit-netty/src/test/resources/tls/server.crt' 'modules/grpc/grpc-testkit-netty/src/test/resources/tls/client.crt' 'modules/grpc/grpc-testkit-netty/src/test/java/io/backend/skeleton/grpc/testkit/GrpcNettyContractProfileTest.java' -git commit -m "test: add real netty grpc contract testkit" -``` - -### Task 48: Fault Injection과 Transport Evidence Classifier - -**Files:** -- Create: `modules/grpc/grpc-testkit-fault/src/main/java/io/backend/skeleton/grpc/testkit/GrpcFaultPoint.java` -- Create: `modules/grpc/grpc-testkit-fault/src/main/java/io/backend/skeleton/grpc/testkit/GrpcFaultScenario.java` -- Create: `modules/grpc/grpc-testkit-fault/src/main/java/io/backend/skeleton/grpc/testkit/GrpcTransportEvidenceClassifier.java` -- Create: `modules/grpc/grpc-testkit-fault/src/main/java/io/backend/skeleton/grpc/testkit/GrpcFaultResult.java` -- Test: `modules/grpc/grpc-testkit-fault/src/test/java/io/backend/skeleton/grpc/testkit/GrpcTransportEvidenceClassifierTest.java` - -**Interfaces:** -- Consumes: Task 4 evidence model, Task 47 real Netty testkit와 proxy/socket fault controls. -- Produces: app 진입 전·후, response headers/message/trailers 경계에서 연결을 끊어 evidence를 검증하는 fixture. - -**Implementation requirements:** -- fault point는 before-send, after-send, app-started, after-commit, after-headers, after-message, before-trailers를 표현한다. -- evidence classifier가 관측하지 못한 상태를 `NOT_SENT`로 추정하지 않는다. -- mutation commit 뒤 response loss를 `COMPLETION_UNKNOWN`으로 재현한다. -- partial server stream에서 `PARTIAL(lastSequence)`를 기록한다. -- fault suite는 Toxiproxy 또는 동등한 실제 network fault와 process kill을 사용한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcTransportEvidenceClassifierTest { - @org.junit.jupiter.api.Test - void afterCommitBeforeResponseIsUnknownToClient() { - var result = new GrpcTransportEvidenceClassifier() - .classify(GrpcFaultPoint.AFTER_BUSINESS_COMMIT); - - org.assertj.core.api.Assertions.assertThat( - result.businessEvidence()) - .isEqualTo(GrpcBusinessEvidence.UNKNOWN); - org.assertj.core.api.Assertions.assertThat( - result.completionOutcome()) - .isEqualTo(GrpcCompletionOutcome.COMPLETION_UNKNOWN); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-testkit-fault:test --tests 'io.backend.skeleton.grpc.testkit.GrpcTransportEvidenceClassifierTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public enum GrpcFaultPoint { - BEFORE_SEND, - AFTER_SEND, - APPLICATION_STARTED, - AFTER_BUSINESS_COMMIT, - AFTER_RESPONSE_HEADERS, - AFTER_RESPONSE_MESSAGE, - BEFORE_TRAILERS -} - -public record GrpcFaultResult( - GrpcTransportEvidence transportEvidence, - GrpcBusinessEvidence businessEvidence, - GrpcCompletionOutcome completionOutcome) {} - -public final class GrpcTransportEvidenceClassifier { - public GrpcFaultResult classify(GrpcFaultPoint point) { - if (point == GrpcFaultPoint.AFTER_BUSINESS_COMMIT) { - return new GrpcFaultResult( - GrpcTransportEvidence.MAY_HAVE_LEFT_CLIENT, - GrpcBusinessEvidence.UNKNOWN, - GrpcCompletionOutcome.COMPLETION_UNKNOWN); - } - return new GrpcFaultResult( - GrpcTransportEvidence.NOT_SENT, - GrpcBusinessEvidence.NOT_OBSERVED, - GrpcCompletionOutcome.REJECTED); - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-testkit-fault:test --tests 'io.backend.skeleton.grpc.testkit.GrpcTransportEvidenceClassifierTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-testkit-fault/src/main/java/io/backend/skeleton/grpc/testkit/GrpcFaultPoint.java' 'modules/grpc/grpc-testkit-fault/src/main/java/io/backend/skeleton/grpc/testkit/GrpcFaultScenario.java' 'modules/grpc/grpc-testkit-fault/src/main/java/io/backend/skeleton/grpc/testkit/GrpcTransportEvidenceClassifier.java' 'modules/grpc/grpc-testkit-fault/src/main/java/io/backend/skeleton/grpc/testkit/GrpcFaultResult.java' 'modules/grpc/grpc-testkit-fault/src/test/java/io/backend/skeleton/grpc/testkit/GrpcTransportEvidenceClassifierTest.java' -git commit -m "test: classify grpc fault execution evidence" -``` - -### Task 49: Cross-module Unary Reliability Contract - -**Files:** -- Create: `modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/testkit/GrpcUnaryReliabilityContract.java` -- Create: `modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/testkit/GrpcUnaryScenario.java` -- Create: `modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/testkit/GrpcUnaryContractResult.java` -- Test: `modules/grpc/grpc-testkit-core/src/test/java/io/backend/skeleton/grpc/testkit/GrpcUnaryReliabilityContractTest.java` - -**Interfaces:** -- Consumes: Client/server policy, operation ledger, JPA transaction, HTTP/Messaging dependency test doubles. -- Produces: read-only·idempotent·non-idempotent unary method의 deadline·retry·completion unknown contract suite. - -**Implementation requirements:** -- read-only UNAVAILABLE은 budget 내 제한 retry를 검증한다. -- non-idempotent mutation은 explicit retry를 하지 않는다. -- idempotency key mutation은 ledger와 같은 key replay를 검증한다. -- commit response loss는 business mutation을 중복 실행하지 않는다. -- downstream timeout이 inbound deadline을 넘지 않는지 검증한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcUnaryReliabilityContractTest { - @org.junit.jupiter.api.Test - void completionUnknownNeverReexecutesNonIdempotentUseCase() { - var contract = GrpcUnaryReliabilityContract.standard(); - var result = contract.evaluate( - RpcIdempotencyProfile.NON_IDEMPOTENT, - GrpcCompletionOutcome.COMPLETION_UNKNOWN); - - org.assertj.core.api.Assertions.assertThat( - result.reexecute()).isFalse(); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-testkit-core:test --tests 'io.backend.skeleton.grpc.testkit.GrpcUnaryReliabilityContractTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcUnaryContractResult( - boolean retry, - boolean reexecute, - boolean queryLedger) {} - -public final class GrpcUnaryReliabilityContract { - public static GrpcUnaryReliabilityContract standard() { - return new GrpcUnaryReliabilityContract(); - } - - public GrpcUnaryContractResult evaluate( - RpcIdempotencyProfile idempotency, - GrpcCompletionOutcome outcome) { - if (outcome == - GrpcCompletionOutcome.COMPLETION_UNKNOWN) { - return new GrpcUnaryContractResult( - false, false, - idempotency == - RpcIdempotencyProfile.IDEMPOTENCY_KEY_REQUIRED); - } - return new GrpcUnaryContractResult(false, false, false); - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-testkit-core:test --tests 'io.backend.skeleton.grpc.testkit.GrpcUnaryReliabilityContractTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/testkit/GrpcUnaryReliabilityContract.java' 'modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/testkit/GrpcUnaryScenario.java' 'modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/testkit/GrpcUnaryContractResult.java' 'modules/grpc/grpc-testkit-core/src/test/java/io/backend/skeleton/grpc/testkit/GrpcUnaryReliabilityContractTest.java' -git commit -m "test: certify grpc unary reliability" -``` - -### Task 50: Server Streaming Contract Suite - -**Files:** -- Create: `modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/testkit/GrpcServerStreamingContract.java` -- Create: `modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/testkit/GrpcStreamingScenario.java` -- Create: `modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/testkit/GrpcStreamingContractResult.java` -- Test: `modules/grpc/grpc-testkit-core/src/test/java/io/backend/skeleton/grpc/testkit/GrpcServerStreamingContractTest.java` - -**Interfaces:** -- Consumes: Tasks 37–41의 envelope, writer, flow control, resume, lifetime policy. -- Produces: sequence·ordering·slow consumer·partial delivery·resume·drain을 검증하는 공통 streaming suite. - -**Implementation requirements:** -- monotonic sequence와 duplicate/gap handling을 검증한다. -- slow consumer가 bounded queue를 넘으면 기본적으로 stream을 종료한다. -- network loss 후 새 stream과 resume token으로 이어지는지 검증한다. -- history loss가 full resync를 요구하는지 검증한다. -- server drain이 마지막 evidence와 cancellation을 전달하는지 검증한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcServerStreamingContractTest { - @org.junit.jupiter.api.Test - void historyLossRequiresFullResync() { - var contract = GrpcServerStreamingContract.standard(); - - org.assertj.core.api.Assertions.assertThat( - contract.onHistoryLost()) - .isEqualTo( - GrpcResumeDecision.FULL_RESYNC_REQUIRED); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-testkit-core:test --tests 'io.backend.skeleton.grpc.testkit.GrpcServerStreamingContractTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public final class GrpcServerStreamingContract { - public static GrpcServerStreamingContract standard() { - return new GrpcServerStreamingContract(); - } - - public GrpcResumeDecision onHistoryLost() { - return GrpcResumeDecision.FULL_RESYNC_REQUIRED; - } -} - -public record GrpcStreamingContractResult( - long lastSequence, - GrpcResumeDecision resumeDecision, - GrpcStreamTerminationReason terminationReason) {} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-testkit-core:test --tests 'io.backend.skeleton.grpc.testkit.GrpcServerStreamingContractTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/testkit/GrpcServerStreamingContract.java' 'modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/testkit/GrpcStreamingScenario.java' 'modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/testkit/GrpcStreamingContractResult.java' 'modules/grpc/grpc-testkit-core/src/test/java/io/backend/skeleton/grpc/testkit/GrpcServerStreamingContractTest.java' -git commit -m "test: certify grpc server streaming reliability" -``` - -### Task 51: 성능·Channel Saturation·Flow-control Gate - -**Files:** -- Create: `modules/grpc/grpc-testkit-netty/src/main/java/io/backend/skeleton/grpc/performance/GrpcPerformanceBudget.java` -- Create: `modules/grpc/grpc-testkit-netty/src/main/java/io/backend/skeleton/grpc/performance/GrpcPerformanceResult.java` -- Create: `modules/grpc/grpc-testkit-netty/src/main/java/io/backend/skeleton/grpc/performance/GrpcPerformanceGate.java` -- Test: `modules/grpc/grpc-testkit-netty/src/test/java/io/backend/skeleton/grpc/performance/GrpcPerformanceGateTest.java` - -**Interfaces:** -- Consumes: Task 47 Netty runtime, Task 45 metrics와 representative unary/stream services. -- Produces: Unary QPS·latency, channel stream queue, executor saturation, stream connection·memory와 drain budget을 검증하는 gate. - -**Implementation requirements:** -- p50·p95·p99와 error rate를 profile별로 측정한다. -- HTTP/2 concurrent stream saturation과 client queue time을 측정한다. -- blocking executor·DB pool·channel saturation을 구분한다. -- stream connection 수, message throughput, heap/direct memory, flow-control stall을 측정한다. -- baseline 대비 허용 regression을 넘으면 release를 실패시킨다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcPerformanceGateTest { - @org.junit.jupiter.api.Test - void p99AboveBudgetFailsRelease() { - var gate = new GrpcPerformanceGate( - new GrpcPerformanceBudget( - java.time.Duration.ofMillis(200), 0.01)); - - org.assertj.core.api.Assertions.assertThatThrownBy( - () -> gate.verify(new GrpcPerformanceResult( - java.time.Duration.ofMillis(250), 0.0))) - .isInstanceOf(IllegalStateException.class); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-testkit-netty:test --tests 'io.backend.skeleton.grpc.performance.GrpcPerformanceGateTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcPerformanceBudget( - java.time.Duration maxP99, - double maxErrorRate) {} - -public record GrpcPerformanceResult( - java.time.Duration p99, - double errorRate) {} - -public final class GrpcPerformanceGate { - private final GrpcPerformanceBudget budget; - - public GrpcPerformanceGate(GrpcPerformanceBudget budget) { - this.budget = budget; - } - - public void verify(GrpcPerformanceResult result) { - if (result.p99().compareTo(budget.maxP99()) > 0 - || result.errorRate() > budget.maxErrorRate()) { - throw new IllegalStateException( - "gRPC performance budget exceeded"); - } - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-testkit-netty:test --tests 'io.backend.skeleton.grpc.performance.GrpcPerformanceGateTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-testkit-netty/src/main/java/io/backend/skeleton/grpc/performance/GrpcPerformanceBudget.java' 'modules/grpc/grpc-testkit-netty/src/main/java/io/backend/skeleton/grpc/performance/GrpcPerformanceResult.java' 'modules/grpc/grpc-testkit-netty/src/main/java/io/backend/skeleton/grpc/performance/GrpcPerformanceGate.java' 'modules/grpc/grpc-testkit-netty/src/test/java/io/backend/skeleton/grpc/performance/GrpcPerformanceGateTest.java' -git commit -m "test: add grpc performance release gate" -``` - -### Task 52: Spring Boot Starter와 Startup Validator - -**Files:** -- Create: `modules/grpc/grpc-spring-boot-starter/src/main/java/io/backend/skeleton/grpc/boot/GrpcPlatformProperties.java` -- Create: `modules/grpc/grpc-spring-boot-starter/src/main/java/io/backend/skeleton/grpc/boot/GrpcPlatformAutoConfiguration.java` -- Create: `modules/grpc/grpc-spring-boot-starter/src/main/java/io/backend/skeleton/grpc/boot/GrpcPlatformStartupValidator.java` -- Create: `modules/grpc/grpc-spring-boot-starter/src/main/java/io/backend/skeleton/grpc/boot/GrpcPlatformConfigurationException.java` -- Create: `modules/grpc/grpc-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports` -- Create: `modules/grpc/grpc-spring-boot-starter/src/main/resources/META-INF/spring-configuration-metadata.json` -- Test: `modules/grpc/grpc-spring-boot-starter/src/test/java/io/backend/skeleton/grpc/boot/GrpcPlatformStartupValidatorTest.java` - -**Interfaces:** -- Consumes: 모든 Stable module contract와 Spring Boot 4.1 gRPC auto-configuration. -- Produces: Stable server/client/policy/admin/observability를 조립하고 잘못된 운영 설정을 fail-fast하는 starter. - -**Implementation requirements:** -- Boot BOM 조합과 Spring gRPC auto-configuration을 재사용한다. -- deadline 없는 Stable Unary, unbounded server executor, insecure production TLS를 거부한다. -- non-idempotent explicit retry, duplicate retry owner, unsupported resolver/LB를 거부한다. -- production reflection, in-process production transport, raw builder exposure를 거부한다. -- Stable starter가 Advanced dependency를 자동 포함하지 않는다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcPlatformStartupValidatorTest { - @org.junit.jupiter.api.Test - void productionRejectsUnaryWithoutDeadline() { - var validator = new GrpcPlatformStartupValidator(); - - org.assertj.core.api.Assertions.assertThatThrownBy( - () -> validator.validate( - GrpcPlatformProperties.invalidWithoutDeadline())) - .isInstanceOf( - GrpcPlatformConfigurationException.class); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-spring-boot-starter:test --tests 'io.backend.skeleton.grpc.boot.GrpcPlatformStartupValidatorTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public record GrpcPlatformProperties( - boolean production, - boolean allUnaryHaveDeadlines, - boolean secureTls, - boolean reflectionEnabled) { - - public static GrpcPlatformProperties invalidWithoutDeadline() { - return new GrpcPlatformProperties( - true, false, true, false); - } -} - -public final class GrpcPlatformStartupValidator { - public void validate(GrpcPlatformProperties properties) { - if (properties.production() - && (!properties.allUnaryHaveDeadlines() - || !properties.secureTls() - || properties.reflectionEnabled())) { - throw new GrpcPlatformConfigurationException( - "invalid Stable gRPC production configuration"); - } - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-spring-boot-starter:test --tests 'io.backend.skeleton.grpc.boot.GrpcPlatformStartupValidatorTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-spring-boot-starter/src/main/java/io/backend/skeleton/grpc/boot/GrpcPlatformProperties.java' 'modules/grpc/grpc-spring-boot-starter/src/main/java/io/backend/skeleton/grpc/boot/GrpcPlatformAutoConfiguration.java' 'modules/grpc/grpc-spring-boot-starter/src/main/java/io/backend/skeleton/grpc/boot/GrpcPlatformStartupValidator.java' 'modules/grpc/grpc-spring-boot-starter/src/main/java/io/backend/skeleton/grpc/boot/GrpcPlatformConfigurationException.java' 'modules/grpc/grpc-spring-boot-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports' 'modules/grpc/grpc-spring-boot-starter/src/main/resources/META-INF/spring-configuration-metadata.json' 'modules/grpc/grpc-spring-boot-starter/src/test/java/io/backend/skeleton/grpc/boot/GrpcPlatformStartupValidatorTest.java' -git commit -m "feat: assemble grpc spring boot starter" -``` - -### Task 53: 호환성 Matrix·Runbook·ADR·Stable Release Gate - -**Files:** -- Create: `modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/release/GrpcCompatibilityMatrix.java` -- Create: `modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/release/GrpcStableReleaseGate.java` -- Create: `modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/release/GrpcReleaseEvidence.java` -- Create: `modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/release/GrpcReleaseDecision.java` -- Create: `docs/runbooks/grpc-platform-operations.md` -- Create: `docs/adr/ADR-060-grpc-platform-boundary.md` -- Create: `docs/adr/ADR-061-grpc-execution-evidence.md` -- Create: `docs/adr/ADR-062-grpc-retry-idempotency.md` -- Create: `docs/adr/ADR-063-grpc-streaming-resume.md` -- Create: `docs/adr/ADR-064-grpc-discovery-kubernetes.md` -- Create: `docs/compatibility/grpc-support-matrix.md` -- Test: `modules/grpc/grpc-testkit-core/src/test/java/io/backend/skeleton/grpc/release/GrpcStableReleaseGateTest.java` - -**Interfaces:** -- Consumes: Tasks 1–52의 build, schema, contract, Netty, fault, security, performance evidence. -- Produces: Boot lane·upstream lane, proto3·Edition lane와 운영 문서 증거를 집계해 Stable 승격을 차단·승인하는 최종 gate. - -**Implementation requirements:** -- Boot 4.1 managed lane을 필수로 검증한다. -- upstream gRPC Java override는 compatibility lane에서만 검증한다. -- proto3+optional을 Stable로, Edition 2024를 Advanced lane으로 분리한다. -- Buf, generated source, unary, streaming, TLS, fault, performance, Kubernetes profile evidence를 모두 요구한다. -- Runbook과 ADR이 누락되면 release를 차단한다. - -- [ ] **Step 1: Write the failing test** - -```java -class GrpcStableReleaseGateTest { - @org.junit.jupiter.api.Test - void missingFaultEvidenceBlocksStableRelease() { - var evidence = GrpcReleaseEvidence.completeExcept("fault"); - var gate = new GrpcStableReleaseGate(); - - org.assertj.core.api.Assertions.assertThat( - gate.decide(evidence)) - .isEqualTo(GrpcReleaseDecision.BLOCKED); - } -} -``` - -- [ ] **Step 2: Run the focused test and verify the failure** - -Run: - -```bash -./gradlew :modules:grpc:grpc-testkit-core:test --tests 'io.backend.skeleton.grpc.release.GrpcStableReleaseGateTest' -``` - -Expected: FAIL because the production contract defined by this task does not exist or does not enforce the required invariant. - -- [ ] **Step 3: Implement the smallest complete production contract** - -```java -public enum GrpcReleaseDecision { - APPROVED, BLOCKED -} - -public record GrpcReleaseEvidence( - java.util.Set passed) { - - public static GrpcReleaseEvidence completeExcept( - String missing) { - var required = new java.util.HashSet<>(java.util.Set.of( - "build", "buf", "generated-source", - "unary", "streaming", "tls", - "fault", "performance", "kubernetes", - "runbook", "adr")); - required.remove(missing); - return new GrpcReleaseEvidence( - java.util.Set.copyOf(required)); - } -} - -public final class GrpcStableReleaseGate { - private static final java.util.Set REQUIRED = - java.util.Set.of( - "build", "buf", "generated-source", - "unary", "streaming", "tls", - "fault", "performance", "kubernetes", - "runbook", "adr"); - - public GrpcReleaseDecision decide( - GrpcReleaseEvidence evidence) { - return evidence.passed().containsAll(REQUIRED) - ? GrpcReleaseDecision.APPROVED - : GrpcReleaseDecision.BLOCKED; - } -} -``` - -Implement every listed production file with the exact public names, package boundaries, validation rules and invariants above. Do not expose raw transport, credential, database or dynamic identifier types through the public contract. - -- [ ] **Step 4: Run the focused test and the owning suite** - -Run: - -```bash -./gradlew :modules:grpc:grpc-testkit-core:test --tests 'io.backend.skeleton.grpc.release.GrpcStableReleaseGateTest' -./gradlew grpcStableTest -``` - -Expected: PASS for the focused test and the aggregate Stable suite. - -- [ ] **Step 5: Commit the independently reviewable change** - -```bash -git add 'modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/release/GrpcCompatibilityMatrix.java' 'modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/release/GrpcStableReleaseGate.java' 'modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/release/GrpcReleaseEvidence.java' 'modules/grpc/grpc-testkit-core/src/main/java/io/backend/skeleton/grpc/release/GrpcReleaseDecision.java' 'docs/runbooks/grpc-platform-operations.md' 'docs/adr/ADR-060-grpc-platform-boundary.md' 'docs/adr/ADR-061-grpc-execution-evidence.md' 'docs/adr/ADR-062-grpc-retry-idempotency.md' 'docs/adr/ADR-063-grpc-streaming-resume.md' 'docs/adr/ADR-064-grpc-discovery-kubernetes.md' 'docs/compatibility/grpc-support-matrix.md' 'modules/grpc/grpc-testkit-core/src/test/java/io/backend/skeleton/grpc/release/GrpcStableReleaseGateTest.java' -git commit -m "docs: complete grpc stable release gate" -``` diff --git a/docs/adr/ADR-BUILD-001-one-convention-for-shared-test-code.md b/docs/adr/ADR-BUILD-001-one-convention-for-shared-test-code.md new file mode 100644 index 00000000..8e722cca --- /dev/null +++ b/docs/adr/ADR-BUILD-001-one-convention-for-shared-test-code.md @@ -0,0 +1,119 @@ +# ADR-BUILD-001: `java-test-fixtures` is the standard for shared test code + +- Status: Accepted +- Date: 2026-09-07 +- Scope: every leaf that publishes or consumes shared test code +- Source: `docs/reviews/2026-09-07-app-bootstrap-module-code-review.md` BOOT-015 + +## Context + +Two conventions do the same job in this repository. + +`ca.testkit-publisher` — a convention plugin — gives a leaf a `testkit` source set, wires its output +onto the lanes that leaf names, and optionally publishes it as a consumable configuration. Five +leaves use it: `persistence-jpa` (published as `jpaTestkit`), `web` (`webTestkit`), `websocket` +(`websocketTestkit`), `persistence-mongo` and `httpclient` (both unpublished). + +`java-test-fixtures` — Gradle's own plugin — gives a leaf a `testFixtures` source set, puts it on +`test`'s classpath automatically, and always publishes it as a variant consumers reach with +`testFixtures(project(':x'))`. One leaf uses it: `graphql`, which additionally fails its build when a +fixture is written outside `src/testFixtures/java`. + +Two conventions for one purpose is the defect. A contributor adding shared test code has to know +which leaf they are in before they know where the file goes, and the two answers are not +interchangeable: a consumer of the first writes `project(path: ':x', configuration: 'jpaTestkit')` +and has to know the configuration's name, while a consumer of the second writes +`testFixtures(project(':x'))` and does not. + +## Decision + +**`java-test-fixtures` is the standard.** New shared test code goes in `src/testFixtures/java`, and a +consumer depends on it with `testFixtures(project(':x'))`. + +Three reasons, in order of weight: + +1. **The consumer side describes itself.** `testFixtures(project(':adapter:inbound:web'))` says what + it is. `project(path: ':adapter:inbound:web', configuration: 'webTestkit')` says where to look, + and only after the reader has learned that `webTestkit` is a testkit rather than a lane. +2. **The enforcement already exists and is copyable.** `graphql`'s build fails when a fixture is + declared in the wrong place. The same guard applies unchanged to any leaf that adopts the plugin. +3. **It is one fewer local concept.** A convention plugin that reimplements a Gradle plugin has to be + maintained against it. + +## What the local plugin does better, and how it is replaced + +This is worth writing down, because the review that prompted this ADR recommended the migration +before reading `ca.testkit-publisher`, and the plugin turns out to encode two deliberate decisions +rather than being an oversight. + +**Publishing is opt-in.** `persistence-mongo` and `httpclient` have a testkit and publish nothing; +`persistence-jpa` publishes. The plugin's own comment names this as "a real difference in what each +leaf offers rather than an oversight to normalise away". `java-test-fixtures` always creates the +variant, so the distinction is lost — a leaf that never meant to offer its fixtures will offer them. + +> Replacement: none at the build level. The distinction moves to review: the fixtures of a leaf that +> nobody consumes are simply unconsumed. This is a real, accepted loss. + +**Lane consumption is declared.** `persistence-jpa` says `consumedBy 'test', 'postgresqlIntegrationTest'`. +`java-test-fixtures` puts fixtures on `test` only, so every other lane needs the output added +explicitly. + +> Replacement: `strictTestLanes`' existing `compilesAgainst` expresses this unchanged — a lane +> declares `compilesAgainst 'main', 'testFixtures'`. The first draft of this ADR assumed the DSL +> would need a change, because `sourceSet(name)` creates what it is given and `testFixtures` already +> exists. The `persistence-mongo` migration showed otherwise: `compilesAgainst` only *looks a source +> set up*, so naming a plugin-created one works as-is. What the leaf drops is the +> `sourceSet('testkit')` declaration, not the lane's. + +## Migration: done, and what it cost + +Five leaves, eleven lanes, two published testkits, all migrated leaf by leaf with the suite run +between each. `ca.testkit-publisher` is deleted. + +The order was chosen so a mistake would be cheap: unpublished leaves first, published ones last with +their consumer in the same step. + +1. `persistence-mongo` — one leaf, two lanes, no cross-module consumer; the proof the path works. + What it took, per leaf: + - `apply plugin: 'java-test-fixtures'` at the top of the leaf build file; + - `git mv src/testkit src/testFixtures`; + - drop `sourceSet('testkit')` and the whole `testkitPublisher` block; keep every other lane's + `compilesAgainst`, renaming `'testkit'` to `'testFixtures'`; + - rename `testkitImplementation` to `testFixturesImplementation`, **and add what the old source + set was inheriting silently**. This is the one non-mechanical step: `testkit*` extended + `testImplementation`, so the fixtures saw every test library the leaf declared. Mongo's needed + four more lines (AssertJ, BSON, Spring Data commons, Toxiproxy) — none of which the leaf had + ever stated the fixtures depended on; + - regenerate the leaf's lock state. +2. `httpclient`, then `websocket` — unpublished as well, more lanes. +3. `web` and `persistence-jpa` with `app-bootstrap`'s two consumer declarations, which became + `testImplementation(testFixtures(project(':…')))`. +4. `ca.testkit-publisher` deleted, along with its `plugins {}` entry and its application in the root + build. + +### Two things the migration broke, and what they taught + +Both were caught by tests that exist to catch exactly this, which is the argument for having them. + +**ArchUnit corpora went wrong in opposite directions.** `httpclient`'s boundary rules *excluded* +`build/classes/java/testkit`; after the move the fixtures arrived as a `…-test-fixtures.jar` on the +same classpath, so the exclusion missed them and 258 fixture-to-fixture calls were reported as +production depending on the testkit. `persistence-jpa`'s rules *included* only +`build/classes/java/main`; applying `java-test-fixtures` makes the module's own test classpath carry +the module as a **jar** rather than as a class directory, so its corpus became empty. The second is +the dangerous one — an empty corpus makes every `noClasses()` rule pass — and it surfaced only +because that suite asserts its corpus is non-empty before asserting anything about it. + +**Fixtures had invisible dependencies.** `testkit*` configurations extended `testImplementation`, so +the fixtures compiled against every test library their leaf declared without ever naming one. Making +them explicit took roughly thirty `testFixturesImplementation` lines across the five leaves — +Micrometer, Spring Web, Netty, logback, Jackson, JUnit, AssertJ, Spring Data. None of them were +wrong; none of them were stated. + +## Consequences + +- `docs/testing/TESTING_STRATEGY.md` §5 records the standard; this ADR records why and at what cost. +- Until step 5, two conventions remain visible. The strategy document says so explicitly, so a + contributor reading it is not left to infer which one is current. +- The opt-in-publishing distinction is given up. If it later proves load-bearing — a leaf whose + fixtures genuinely must not be reachable — the answer is a separate module, not a third convention. diff --git a/docs/reviews/2026-09-07-app-bootstrap-module-code-review.md b/docs/reviews/2026-09-07-app-bootstrap-module-code-review.md new file mode 100644 index 00000000..0d1a064c --- /dev/null +++ b/docs/reviews/2026-09-07-app-bootstrap-module-code-review.md @@ -0,0 +1,1231 @@ +# app-bootstrap 모듈 상세 코드·아키텍처 리뷰 + +- 기준 일자: 2026-09-07 +- 기준 Git HEAD: `21234e38cdb9a926cbc92bb97a2aee2e4a7d2916` +- 대상 Gradle leaf: `:app-bootstrap` +- 주 대상 경로: `src/app-bootstrap` +- 교차 확인 경로: `src/adapter/inbound/web`, `src/adapter/inbound/graphql`, `src/adapter/outbound/*`, `src/shared-contract`, `src/application-core`, `src/config/architecture/modules.json` +- 판정: **CHANGES REQUIRED** (설계 판단은 유지, 코드 조직화 규율은 재정비 필요) +- 검토 방식: 전체 트리 정적 탐색, 시작 파이프라인 정독, 설정 네임스페이스·예외·로그·검증 경로 교차검증 +- 변경 범위: 이 리뷰 문서만 추가했다. production/test 코드는 수정하지 않았다. + +## 1. 결론 + +이 leaf는 "Spring Boot 진입점 + 몇 개의 `@Configuration`"이 아니다. 6개 +`EnvironmentPostProcessor`, `SpringBootExceptionReporter`, `AutoConfigurationImportFilter`, +`ManagementContextConfiguration`, 21개 startup validator, 4단계 종료코드 체계, 15개 capability +자동조립을 담은 **런타임 조립 플랫폼**이다. 프레임워크 확장점을 목적별로 정확히 구분해 쓰고 있고, +주석이 "무엇"이 아니라 "왜"를 기록하며, fail-closed 철학이 일관된다. 이 부분은 보존 대상이다. + +문제는 능력이 아니라 **규율**이다. 이 모듈에서 반복 발견되는 결함은 전부 같은 형태를 가진다: + +> **정답이 이미 모듈 안에 한 번 구현되어 있는데, 나머지 호출부가 그것을 쓰지 않고 각자 재구현한다.** + +세 가지 실례가 이를 증명한다. + +1. `StartupFailures`가 구조화 로그 + 종료코드 + 오류코드를 한 곳에서 부여하도록 만들어져 있으나, + 검증 실패의 약 2/3(67곳)이 `throw new IllegalStateException`으로 이 경로를 우회한다. 결과적으로 + **의도적으로 설계된 종료코드 체계(70/71/72/78)가 대부분의 경로에서 무력화된다.** +2. 프로덕션 프로파일 어휘의 정본이 `RuntimeEnvironmentProfileValidator.DEPLOYABLE_ENVIRONMENTS`에 + 존재하지만, "지금 prod인가"를 7곳이 각자 재구현하고 그중 3곳은 어휘가 다르다. +3. 검증 실패 누적(`List` → 비어있지 않으면 throw) 패턴이 15개 validator에 각자 복제되어 있고, + 변수명이 `violations`/`missing`/`offenders`/`contradictions`/`unsafe`/`reasons`로 제각각이다. +4. "인프라 의존 테스트는 별도 소스셋"이 이미 5개 모듈의 표준인데, app-bootstrap만 Testcontainers + 테스트를 `src/test`에 두어 기본 test 태스크가 Docker를 요구한다. +5. 관측 어댑터를 어댑터 모듈에 두는 패턴이 `LoggingNotificationAudit`으로 이미 존재하는데, + 같은 계열 넷은 bootstrap에 남아 있다. + +동시에 **분해 능력이 없는 것도 아니다.** notification은 Config 9개로 정확히 분해되어 있다. 그런데 +같은 성격의 redis는 파일 2개에 849줄이 몰려 있다. 즉 좋은 패턴을 알고 있으면서 적용 기준이 +문서화되지 않아 그때그때 달라졌다. 이것이 이 모듈 상태를 가장 잘 설명하는 한 문장이다. + +## 2. 범위와 증거 경계 + +### 2.1 현재 규모 + +| 항목 | 수치 | +| --- | --- | +| `src/main` Java | 149 파일 / 12,380 줄 | +| 테스트 (`test` + `functionalTest` + `conditionalTransportTest`) | 285 파일 / 33,618 줄 (main 대비 2.7배) | +| main 패키지 수 | 27 | +| `application.yml` | 974 줄 (프로파일별 3개 합산 1,251 줄) | +| `build.gradle` | 391 줄 | +| `README.md` | 883 줄 / 74개 heading | +| `*Settings` 클래스 | 16 | +| `*Config` / `*Configuration` 클래스 | 53 | +| `*Validator` / `*Guard` / `*StartupCheck` | 21 | +| `EnvironmentPostProcessor` 등록 | 6 | + +### 2.2 검토 깊이 + +- 정독: `runtime/startup/**` 전체, `activation/**` 전체, `redis/**`, `idempotency/**`, + `autoconfigure/{fileserver,httpclient,jpa,persistencejpa}` 핵심 경로, `META-INF/spring.factories`, + `application.yml` 네임스페이스 구조. +- 정량 스캔: 예외 던지기 통계, 로그 호출 통계, 프로파일 판정 구현 위치, `List` 누적 패턴 위치, + `@ConfigurationProperties` prefix 전수, `getOrder()` 값 전수. +- 교차 확인: filter/`@ControllerAdvice` 구현체의 실제 소유 모듈, 어댑터 모듈의 `resources` 보유 현황, + 6개 모듈의 테스트 소스셋 구성과 `testkit`/`testFixtures` 채택 현황, `test` 태스크의 태그·가드 유무. +- **미실행**: 이 리뷰는 정적 분석이다. `./gradlew :app-bootstrap:test`를 실행하지 않았다. 따라서 + 아래 발견 중 "동작이 깨진다"고 단정한 항목은 없으며, 모두 구조·계약 수준의 판정이다. + +## 3. 유지할 설계 + +재작성 과정에서 없애면 안 되는 것들이다. + +1. **프레임워크 확장점의 목적별 구분.** `spring.factories`에 `EnvironmentPostProcessor`(6), + `SpringBootExceptionReporter`(1), `AutoConfigurationImportFilter`(1), `ApplicationListener`(1)가 + 각각 제 역할로 등록되어 있다. 특히 `ResolvedProfileLoggingContextListener`의 주석 — + *"An ApplicationListener, not a bean: this event fires before there is a context to hold one"* — + 는 컨텍스트 생명주기를 이해한 사람만 쓸 수 있는 문장이다. +2. **근거를 남기는 주석.** `HttpClientPlatformSettingsBinder`는 Spring이 + `APP_HTTPCLIENT_CLIENTS_0_BASE_URL`을 `clients[0].base.url`로 열거하면서 바인딩은 `base-url`로 + 한다는 실제 함정을 기록하고, 그래서 시스템 환경변수만 `NoUnboundElementsBindHandler`에서 제외했다고 + 설명한다. +3. **Fail-closed 기본값.** `RuntimeEnvironmentProfileValidator`는 프로파일 미지정 시 + *"a profile that is guessed is a deployment nobody chose"*, 복수 지정 시 + *"whichever loses does so silently"*를 근거로 부팅을 거부한다. +4. **strict 바인딩.** 미지의 키를 무시하지 않고 거부한다. 설정 오타가 조용히 기본값으로 도는 사고를 막는다. +5. **아키텍처 테스트의 자기검증.** `architecture/violations/` 아래 40여 개 위반 픽스처 패키지를 + 실제 클래스로 만들어, ArchUnit 규칙이 진짜로 잡는지를 검증한다. 흔치 않다. +6. **종료코드 체계 자체.** `StartupErrorCode`가 sysexits 관례(78=EX_CONFIG, 70, 71, 72)에 맞춰 + `ExitCodeGenerator`로 JVM 종료 상태를 만든다. 설계는 옳다 — 문제는 채택률이다(BOOT-003). + +## 4. 제기된 논점에 대한 판정 + +리뷰 요청자가 제기한 9개 논점에 대한 개별 판정이다. 근거 없이 동의하지 않았고, 전제가 틀린 항목은 +틀렸다고 적었다. + +| # | 논점 | 판정 | 근거 | +| --- | --- | --- | --- | +| 1 | yaml을 각 모듈이 소유하고 bootstrap이 합쳐야 한다 | **부분 동의** | BOOT-008 | +| 2 | 로그 메시지 틀이 일관되지 않다 | **동의** | BOOT-009, BOOT-003 | +| 3 | 공통 흐름을 AOP로 해결할 수 있다 | **반대** | 아래 4.1 | +| 4 | 검증 순서가 필요하면 `@GroupSequence`는 어떤가 | **반대(목적은 동의)** | 아래 4.2 | +| 5 | validator를 하나의 context에서 관리해야 한다 | **동의** | BOOT-004 | +| 6 | startup 예외가 Global handler에 안 잡힌다 | **현재 설계가 옳음** | 아래 4.3 | +| 7 | 제네릭으로 패턴 적용 가능한 구조를 열어두자 | **조건부 동의** | 아래 4.4 | +| 8 | filter 책임이 왜 bootstrap에 있나 | **전제 오류** | 아래 4.5 | +| 9 | `Provider` enum을 env로 빼야 한다 | **증상은 맞고 처방은 다름** | BOOT-010 | +| 10 | `functionalTest` 등을 `src/test` 안으로 합쳐야 한다 | **반대 — 전제 오류** | 아래 4.6 | +| 11 | 테스트 레벨을 소스셋/실행 경계로 분리해야 한다 | **동의 — 다만 적용 대상이 반대** | BOOT-014, 4.7 | +| 12 | 참조 프로젝트의 장점을 반영해야 한다 | **상당수 이미 충족, 3건 도입** | §7 | + +### 4.1 AOP는 이 모듈의 중복에 적용할 수 없다 + +AOP는 **빈의 메서드 호출을 프록시로 가로채는** 기법이다. 그런데 이 모듈에서 실제로 중복이 확인된 +코드는 거의 전부 **빈이 존재하기 이전 시점**에 실행된다. + +- `EnvironmentPostProcessor` 6개: `ApplicationContext` 생성 전. 프록시 대상이 아니다. +- `AutoConfigurationImportFilter`: 빈 정의가 만들어지기 전. +- `SpringBootExceptionReporter`: 컨텍스트 refresh 실패 후. +- `@ConfigurationProperties` 레코드의 compact constructor 검증: 바인딩 시점, 프록시 없음. + +즉 BOOT-002(프로파일 판정 7중복), BOOT-003(예외 경로 우회), BOOT-004(누적 로직 15중복) 중 +**AOP로 제거 가능한 것은 하나도 없다.** 이들은 평범한 헬퍼 추출과 공용 컨텍스트로 해결된다. + +런타임 포트 호출(메트릭·트레이싱)은 AOP 후보가 맞지만, 이 저장소는 이미 `MeteredDistributedLockPort` +같은 **명시적 데코레이터**로 처리하고 있고 클린 아키텍처 관점에서 그쪽이 우월하다. 이유는 하나다: +컴포지션 루트의 존재 이유가 "무엇이 무엇을 감싸는지 한 곳에서 보이게 하는 것"인데, AOP는 그 배선을 +포인트컷 표현식 뒤로 숨긴다. 어떤 포트가 계측되는지 코드에서 안 보이게 만드는 순간 bootstrap은 +자기 역할을 잃는다. **AOP 도입은 권고하지 않는다.** + +### 4.2 `@GroupSequence`는 부적합하지만, 그것으로 얻으려던 것은 반드시 필요하다 + +먼저 두 축을 분리해야 한다. 이 둘은 서로 다른 문제다. + +- **축 A — 누적(accumulate) vs 즉시중단(fail-fast):** 오류를 모아서 한 번에 보고할 것인가, 첫 오류에서 + 멈출 것인가. +- **축 B — 단계 의존(phase dependency):** 앞 검증이 실패하면 뒤 검증은 **의미가 없어지는가**. + +**축 A에서는 현재 구조(`List` 누적 후 throw)가 옳다.** 운영자가 잘못된 배포 설정을 고칠 때, 오류를 +하나씩 알려주면 "고치고 → 재기동(수십 초) → 다음 오류 발견"을 반복하게 된다. 설정 오류는 전부 모아 +한 번에 주는 것이 정답이다. 이건 바꾸면 안 된다. + +**`@GroupSequence`는 정확히 축 A를 반대 방향으로 바꾸는 장치다.** 그룹 A가 하나라도 실패하면 그룹 B를 +아예 평가하지 않는 short-circuit이 그 정의다. 즉 도입하면 운영자가 받는 오류 목록이 **줄어든다.** +얻고 싶은 것과 반대 결과가 나온다. + +여기에 더해 이 코드베이스에서는 기술적으로도 성립하지 않는다. + +1. **컨텍스트가 없다.** 검증의 상당수가 `EnvironmentPostProcessor` 단계에서 돈다. 이 시점엔 + `ApplicationContext`도 `LocalValidatorFactoryBean`도 없다. Bean Validation을 쓰려면 + `ValidatorFactory`를 손으로 만들어야 하는데, 그러면 "표준을 써서 단순해진다"는 이점이 사라진다. +2. **검증 대상의 모양이 다르다.** Bean Validation은 *"한 필드가 제약을 만족하는가"*에 최적화되어 있다. + 반면 이 모듈의 규칙은 *"prod 프로파일이면서 `ddl-auto`가 `create`이고 Flyway가 켜져 있으면 거부"* + 처럼 **프로파일 × 여러 키의 교차 규칙**이다. 클래스 레벨 커스텀 제약으로 옮길 수는 있지만, 검증 + 로직은 그대로 자바 코드로 남고 애노테이션과 그룹 인터페이스라는 배관만 추가된다. 순증이다. + +**그러나 축 B의 문제의식은 정확하고, 지금 실제로 깨져 있다.** 이 저장소에는 이미 `StartupPhase`라는 +4단계 정의가 존재한다. + +``` +ENV_VALIDATION → MIGRATION → ADAPTER_ENABLEMENT → PROFILE_CHECK +``` + +그런데 이 enum은 **로그 필드 문자열로만 쓰인다.** 실행 순서를 강제하는 코드가 어디에도 없다 +(BOOT-005). 그 결과 `RequiredEnvironmentValidator`가 "DB URL이 없다"고 보고하기 **전에** +`PostgreSqlTransportSecurityValidator`가 "TLS가 꺼져 있다"고 보고할 수 있다. 없는 URL에 대한 TLS +경고는 순수한 노이즈이고, 운영자를 엉뚱한 곳으로 보낸다. + +**결론 — 권고안:** `@GroupSequence`를 도입하지 말고, 이미 존재하는 `StartupPhase`를 **실행 순서의 +1급 개념으로 승격**한다. 의미론은 이렇게 된다. + +- 한 phase 안에서는 **모든** 위반을 누적한다 (축 A 유지 → 운영자는 전체 목록을 받는다). +- 한 phase가 위반을 냈으면 **다음 phase는 실행하지 않는다** (축 B 확보 → 노이즈 제거). + +이것이 `@GroupSequence`가 주려던 "단계 간 short-circuit"을 컨테이너 없이, 프레임워크 의존 없이, +그리고 운영자 경험을 희생하지 않고 얻는 방법이다. 구현은 BOOT-004의 검증 컨텍스트와 같은 작업이다. + +### 4.3 startup 예외가 Global handler에 안 잡히는 것은 정상이다 + +`GlobalExceptionHandler`는 `src/adapter/inbound/web/.../error/GlobalExceptionHandler.java`에 있는 +`@ControllerAdvice`다. `@ControllerAdvice`는 **HTTP 요청을 처리하는 중에 발생한 예외**만 볼 수 있다. + +`StartupFailureException` 계열은 정의상 **컨텍스트 refresh 중**에 던져진다. 그 시점에는 서블릿 +컨테이너가 요청을 받고 있지 않고, DispatcherServlet도 아직 없다. 따라서 web handler가 이 예외를 +잡는 것은 **가능하지도 않고 바람직하지도 않다.** 잡힌다면 그건 "죽어야 할 프로세스가 200을 +반환하며 살아있다"는 뜻이므로 오히려 사고다. + +이 계열의 올바른 처리기는 이미 등록되어 있다. + +- `StartupFailureExceptionReporter` (`SpringBootExceptionReporter`) — 원인 체인을 따라 + `StartupFailureException`을 찾아 "이미 보고됨"으로 표시하고 Spring Boot의 generic 스택트레이스를 억제. +- `StartupFailureException implements ExitCodeGenerator` — JVM 종료 상태를 오류 원인별로 부여. +- `StartupFailures.emit()` — 구조화 로그(`startup.phase` / `error.code` / `error.category`) 발행. + +즉 **책임 분담은 이미 올바르다.** 문제는 이 경로를 67곳이 우회한다는 것이다(BOOT-003). 그쪽이 +진짜 결함이다. + +한편 정리해 둘 구분이 하나 있다. bootstrap이 소유한 예외 중 `MigrationFailedException`, +`RequiredAdapterDisabledException`은 전부 시작 시점 전용이라 위 경로가 맞다. 그러나 +`IdempotencyProviderSelectionConfig`처럼 `SmartInitializingSingleton` 안에서 던지는 것도 refresh +시점이므로 동일하다. **bootstrap에는 "요청 처리 중 발생하는 예외"가 사실상 없어야 하고, 현재도 +없다.** 이 원칙을 문서화해 두면 앞으로 누군가 bootstrap에 런타임 예외를 만들 때 그것이 잘못된 +위치라는 신호가 된다. + +### 4.4 제네릭으로 열어두기 — 확인된 중복에만 적용한다 + +방향은 옳다. 다만 **"나중에 패턴을 적용할 수 있도록 미리 제네릭으로 열어둔다"는 그 자체로 +speculative generality 안티패턴**이 될 수 있다. 쓰이지 않는 타입 파라미터는 읽는 사람에게 "여기에 +여러 구현이 있다"는 거짓 신호를 준다. + +판단 기준을 이렇게 두기를 권고한다: **같은 구조가 실제로 2회 이상 나타난 곳에만 적용한다.** 이 +기준으로 이 모듈에서 지금 자격이 있는 대상은 셋이다. + +1. **검증 누적** — 15회 반복. `StartupValidationContext`로 흡수 (BOOT-004). +2. **strict 설정 바인딩** — 2회 반복. `StrictSettingsBinder`로 흡수 (BOOT-011). +3. **provider 선택** — 현재 1회지만 enum + switch 이중 수정 지점이 이미 존재. `ProviderSpec` 레지스트리로 + 흡수 (BOOT-010). + +반대로 지금 열어두면 안 되는 것: `*Config` 53개를 공통 제네릭 베이스로 묶는 것. 이들은 구조가 같아 +보이지만 조립 대상이 전부 달라서, 공통 상위 타입은 아무 의미 없는 마커가 된다. + +**두 번째 기준은 §7.4에 있다** — *"이 인터페이스 이름에서 도메인 단어를 빼도 뜻이 통하는가?"*. +반복 횟수와 이 기준을 **모두** 만족할 때만 일반화한다. + +### 4.5 filter는 bootstrap에 없다 — 전제가 사실과 다르다 + +전수 확인 결과, `Filter` / `OncePerRequestFilter` / `WebFilter` 구현체 13개는 **전부 inbound 어댑터 +소유**다. + +- `adapter/inbound/web/.../mvc/{budget,throttle,filter}/*` — `WebMvcBudgetFilter`, + `WebMvcThrottleFilter`, `WebMvcEvidenceFilter`, `WebMvcRequestIdFilter` +- `adapter/inbound/web/.../webflux/{budget,context,throttle}/*` +- `adapter/inbound/web/.../filter/{CacheControlFilter,RequestLoggingFilter}` +- `adapter/inbound/graphql/.../GraphQlRequestBodyLimitFilter` + +**bootstrap에는 filter가 0개다.** 배치는 이미 옳다. + +다만 리뷰 요청자가 스스로 도달한 결론 — *"web으로 받을지 grpc로 받을지와 상관없이 filter단은 +존재하겠네요"* — 은 별개로 정확한 통찰이고, 실제로 이 저장소가 이미 채택한 구조와 일치한다. +transport 무관한 것은 **filter 자체가 아니라 filter가 전파하는 값**(request id, MDC, 도메인 컨텍스트)이고, +그것들은 `shared-contract`와 bootstrap의 `concurrency/DomainContextConfig` / +`async/AsyncContextTaskDecorator`에 있다. 즉 "메커니즘은 transport 어댑터가, 계약은 공용 모듈이"라는 +분리가 이미 성립해 있다. 이 원칙을 `src/app-bootstrap/CLAUDE.md`에 명시해 두면 앞으로 흔들리지 않는다. + +### 4.6 세 개의 별도 소스셋은 폴더 취향이 아니라 클래스패스 차이다 + +**판정: 합치면 안 된다.** 이 셋은 "테스트를 분류하려고" 나눈 것이 아니라, **하나의 소스셋으로는 +표현할 수 없는 클래스패스 차이** 때문에 존재한다. `src/app-bootstrap/build.gradle:25-43`이 근거다. + +**(1) `sampleOffTest` — 같은 소스를 다른 클래스패스로 두 번 컴파일한다.** + +```groovy +sampleOffTest { + java.srcDirs = sourceSets.test.java.srcDirs // ← test와 동일한 소스 디렉터리 + java.srcDir 'src/sampleOffTest/java' + ... +} +configurations { + testCompileClasspath.extendsFrom sampleFixture // test에는 sample-portfolio가 있고 + testRuntimeClasspath.extendsFrom sampleFixture + sampleOffTestImplementation.extendsFrom testImplementation // sampleOffTest에는 없다 +} +``` + +`sampleOffTest`는 **`src/test`의 소스 파일을 그대로 재사용**하면서 `sampleFixture` 구성만 뺀 클래스패스로 +다시 컴파일한다. 목적은 "sample-portfolio를 삭제해도 이 템플릿이 성립하는가"의 증명이다. +**같은 파일을 두 클래스패스로 컴파일하는 것이 이 소스셋의 정의 그 자체**이므로, `src/test` 하위 +폴더로 옮기는 것은 물리적으로 불가능하다. 폴더가 아니라 클래스패스가 변수이기 때문이다. + +**(2) `conditionalTransportTest` — 테스트 전용 opt-in 클래스패스를 증명한다.** + +```groovy +conditionalTransportTestImplementation project(':adapter:inbound:websocket') +``` + +이 소스셋의 계약은 *"GraphQL/gRPC/WebSocket이 기본 클래스패스에 **없다**"*이다. 만약 이 의존을 +`testImplementation`으로 옮기면 기본 test 클래스패스에 transport가 들어가고, **증명하려는 명제가 +바로 그 순간 거짓이 된다.** 분리는 선택이 아니라 명제의 성립 조건이다. + +**(3) `functionalTest` — Gradle TestKit이 별도 Gradle 빌드를 띄운다.** + +TestKit 의존과 실행 환경이 일반 단위 테스트와 다르고, `tasks.named('check') { dependsOn functionalTest }`로 +별도 태스크에 묶여 있다. + +**리뷰 요청자가 인용한 기준 자체가 이 분리를 지지한다.** 인용된 정리의 결론은 다음과 같다. + +> 테스트 코드는 production 구조를 따라 배치하고, **실행 환경·의존성·비용이 달라지는 테스트만 별도의 +> source set이나 module로 분리한다.** + +세 소스셋은 전부 "의존성이 달라지는" 경우이므로 이 기준을 **정확히 만족**한다. Gradle 공식 문서가 +별도 테스트 스위트의 대표 사유로 드는 것도 동일하게 "별도의 실행 환경과 dependency/classpath"다. +따라서 논점 10과 논점 11은 서로 모순되며, 논점 11의 기준을 채택하면 논점 10은 자동으로 기각된다. + +**다만 정당한 불만이 하나 남는다 — 명명이 레벨을 말해주지 않는다.** `functionalTest`, +`conditionalTransportTest`, `sampleOffTest`라는 이름만 보고는 "이게 어느 테스트 레벨인지"를 알 수 없다. +이 셋은 전부 **빌드/조립 계약 검증(build qualification)**이지 기능 테스트가 아니다. 이름이 목적을 +가리키게 바꾸거나(예: `buildQualificationTest`), 최소한 각 소스셋에 `package-info.java`로 레벨을 +선언하는 편이 낫다. `src/test/.../integration/package-info.java`가 이미 그 선례를 만들어 두었다. + +### 4.7 테스트 전략은 채택할 가치가 있다 — 다만 이 저장소의 현재 상태를 반영해야 한다 + +인용된 정리의 핵심 원칙 세 가지는 이 저장소에 그대로 적용할 만하다. + +1. **분류 축을 섞지 않는다.** `unit/`, `service/`, `regression/` 같은 폴더를 만들지 않고 production + 패키지를 그대로 미러링한다. +2. **실행 환경이 달라지는 것만 소스셋으로 분리한다.** +3. **레벨 정의를 SSOT 문서로 고정한다.** + +그런데 원문을 그대로 적용하기 전에 **이 저장소가 이미 도달해 있는 지점**을 반영해야 한다. 세 가지가 +이미 존재한다. + +- **패키지 미러링은 이미 지켜지고 있다.** `src/test`는 production 패키지 구조를 따르며 + (`activation/`, `runtime/startup/`, `autoconfigure/fileserver/` …), `unit/`·`service/` 같은 축 + 혼합 폴더가 없다. 원칙 1은 이미 만족한다. +- **테스트 레벨 계약이 이미 ArchUnit으로 강제되고 있다.** + `architecture/TestTaxonomyArchitectureTest`(220줄)가 다음을 기계적으로 검사한다. + - `contract` 트리 Testcontainers 금지 + - `architecture` 트리 Testcontainers 금지 + - slice 애노테이션 혼합 금지 (`@WebMvcTest` + `@DataJpaTest` 동시 부착) + - fixture leak 금지 (production 코드가 test fixture에 의존 금지) + - 각 규칙의 **positive control**(규칙이 실제로 발동하는지)까지 검증 +- **레벨 선언 관례도 있다.** `integration/package-info.java`가 + *"Test Level Matrix: integration — 이 패키지는 Docker 데몬이 필요하다"*를 명시한다. + +즉 **필요한 것은 전략의 도입이 아니라 완성**이다. 지금 빠진 조각은 두 개다. + +1. 계약이 **패키지 이름**으로만 걸려 있고 **실행 경계**로는 걸려 있지 않다 (BOOT-014). +2. 그 계약을 사람이 읽을 SSOT 문서가 없다 — `docs/testing/`이 존재하지 않는다 (BOOT-016). + +**저장소 관례상 SSOT 문서는 강제 수단과 함께 온다.** 이 저장소에는 `docs/registries`, +`docs/runbooks` 같은 SSOT 트리와 `verifyDocumentedLeafCount`처럼 문서-코드 불일치를 빌드 실패로 +만드는 태스크가 이미 있다. 테스트 전략 문서도 같은 방식으로 붙여야 살아 있는 문서가 된다. + +## 5. 우선순위 요약 + +| ID | 제목 | 심각도 | 근거 유형 | +| --- | --- | --- | --- | +| BOOT-003 | 검증 실패 67곳이 `StartupFailures`를 우회해 종료코드·구조화로그를 잃는다 | **High** | 결정론적 | +| BOOT-001 | 설정 네임스페이스가 3중화되고 동일 관심사가 두 곳에 등록된다 | **High** | 결정론적 | +| BOOT-005 | `StartupPhase`가 순서를 강제하지 않고, post-processor 순서가 동률이다 | **High** | 결정론적 | +| BOOT-002 | 프로덕션 프로파일 판정이 7곳에 복제되고 어휘가 갈라진다 | Medium | 결정론적 | +| BOOT-004 | 검증 누적 로직이 15곳에 복제되고 공용 컨텍스트가 없다 | Medium | 결정론적 | +| BOOT-006 | 패키지 taxonomy가 두 축으로 섞이고 `Settings` 배치 규칙이 없다 | Medium | 판단 | +| BOOT-007 | 같은 분해 문제를 notification과 redis가 다른 방식으로 푼다 | Medium | 판단 | +| BOOT-008 | 모든 yaml 책임이 bootstrap에 집중되어 974줄 단일 파일이 된다 | Medium | 판단 | +| BOOT-009 | 구조화 로그와 평문 로그가 규칙 없이 섞인다 | Medium | 결정론적 | +| BOOT-010 | `Provider` enum과 `switch`가 이중 수정 지점을 만든다 | Low | 판단 | +| BOOT-011 | `*SettingsBinder` 2개가 구조 중복이다 | Low | 결정론적 | +| BOOT-012 | `CleanArchitectureTest`가 2,792줄 단일 클래스다 | Low | 판단 | +| BOOT-013 | `StartupFailureLogState`가 프로세스 전역 가변 상태이며 테스트 병렬화를 막는다 | Low(잠재) | 판단 | +| BOOT-014 | Testcontainers 통합 테스트가 `src/test`에 있어 `./gradlew test`가 Docker를 요구한다 | **High** | 결정론적 | +| BOOT-015 | 공용 테스트 지원 코드 관례가 `testkit` 소스셋과 `testFixtures` 플러그인으로 갈린다 | Medium | 결정론적 | +| BOOT-016 | 테스트 레벨 계약을 사람이 읽을 SSOT 문서가 없다 (`docs/testing/` 부재) | Medium | 판단 | +| BOOT-017 | bootstrap이 조립만 하지 않고 아웃바운드 어댑터 구현체 4개를 소유한다 | Medium | 결정론적 | + +권장 착수 순서: **BOOT-003 → BOOT-005 → BOOT-004 → BOOT-002 → BOOT-001 → 나머지.** +앞의 넷은 서로 같은 코드 영역을 건드리므로 한 덩어리로 처리하는 편이 효율적이다. + +--- + +## 6. 상세 발견 사항과 구현 명세 + +### BOOT-003 — 검증 실패의 2/3이 `StartupFailures`를 우회해 종료코드 체계를 무력화한다 + +**규칙.** `StartupErrorCode`는 시작 실패를 네 원인으로 나누고 각각 sysexits 종료코드를 부여한다 +(78=설정 오류, 70=마이그레이션 실패, 71=프로파일 불일치, 72=필수 어댑터 비활성). 이 코드가 JVM 종료 +상태가 되는 유일한 경로는 `StartupFailureException implements ExitCodeGenerator`다. + +**증거.** + +| 경로 | 건수 | +| --- | --- | +| `StartupFailures.*` 호출 (정본 경로) | 32 | +| `throw new IllegalStateException` (우회) | **67** | +| `throw new IllegalArgumentException` (우회) | 2 | + +우회 경로의 실제 사례: + +- `activation/RuntimeEnvironmentProfileValidator.java` — 프로파일 미지정/복수지정/미지의 프로파일 + 3건 모두 raw `IllegalStateException`. +- `autoconfigure/fileserver/FileserverSecurityConfiguration.java:81` — prod에서 `access-policy=unenforced` + 거부. 성격상 `PROFILE_MISMATCH`(71)여야 한다. +- `idempotency/IdempotencyProviderSelectionConfig.java` — provider 선택 불일치. 성격상 + `REQUIRED_ADAPTER_DISABLED`(72)여야 한다. + +**영향.** 세 가지가 동시에 사라진다. + +1. **종료코드.** `ExitCodeGenerator`를 구현하지 않으므로 JVM은 generic exit 1로 죽는다. "설정 오류(78)는 + 재시도해도 소용없으니 알림, 마이그레이션 실패(70)는 재시도"같은 운영 자동화 분기가 성립하지 않는다. +2. **구조화 로그.** `startup.phase` / `error.code` / `error.category` 필드가 붙지 않아 로그 기반 + 실패 원인 집계가 불가능하다. +3. **스택트레이스 억제.** `StartupFailureExceptionReporter`가 인식하지 못하므로 Spring Boot의 generic + "Application run failed" 스택트레이스가 그대로 노출된다. 운영자가 봐야 할 한 줄이 수십 줄에 묻힌다. + +**수정.** 67곳을 성격에 맞는 `StartupFailures` 팩토리로 치환한다. 그리고 재발을 막기 위해 +ArchUnit 규칙을 추가한다 — *`dev.caskeleton.bootstrap` 패키지의 production 코드는 `IllegalStateException`을 +직접 생성할 수 없다*. 이 저장소는 이미 `architecture/violations/`에 위반 픽스처를 두는 관례가 있으므로 +그 관례를 따른다. + +--- + +### BOOT-001 — 설정 네임스페이스가 3중화되고 동일 관심사가 두 곳에 등록된다 + +**규칙.** 하나의 애플리케이션은 하나의 설정 루트를 가져야 한다. 운영자가 "이 값 어디서 바꾸죠"에 +대해 한 개의 답을 받아야 한다. + +**증거.** `src/main/resources/application.yml`(974줄) 최상위 키: + +| 라인 | 루트 | +| --- | --- | +| 356 | `ca-skeleton:` | +| 799 | `app:` | +| 967 | `backend:` | + +`@ConfigurationProperties` prefix 전수: `ca-skeleton.*` 14개, `app.jpa-platform` 1개. +`PREFIX` 상수: `app.fileserver-platform`, `app.httpclient`. + +동일 관심사의 이중 등록: + +| 관심사 | 등록 위치 | +| --- | --- | +| notification | `ca-skeleton.notification` + `app.notification` + `ca-skeleton.notification.platform.secrets` | +| JPA/persistence | `ca-skeleton.persistence-jpa` + `ca-skeleton.persistence` + `ca-skeleton.jpa` + `app.jpa-platform` | +| fileserver | `app.fileserver` + `app.fileserver-platform` | +| idempotency | `ca-skeleton.idempotency` + `ca-skeleton.capabilities.idempotency` | + +**영향.** 운영 사고 경로다. `app.notification`을 고치고 재기동했는데 동작이 안 바뀌면, 원인이 +`ca-skeleton.notification`에 있다는 것을 코드를 읽어야만 알 수 있다. strict 바인딩은 오타를 잡지만 +**"맞는 키인데 다른 트리"**는 잡지 못한다. + +**수정.** + +1. 루트를 `ca-skeleton.*` 하나로 통일한다(등록 수가 압도적으로 많고 저장소 식별자와 일치). +2. 이전 키는 `MasterSwitchEnvironmentPostProcessor`가 이미 legacy 철자를 거부하는 메커니즘을 갖고 + 있으므로, 같은 자리에 "구 키가 발견되면 새 키를 안내하며 거부"를 추가한다. 조용한 무시는 금지. +3. 통합 후 `verifyEnvKeys`에 "선언된 prefix 루트는 하나"를 검증하는 항목을 추가한다. + +--- + +### BOOT-005 — `StartupPhase`가 순서를 강제하지 않고 post-processor 순서가 동률이다 + +**규칙.** 실행 순서가 정확성에 영향을 준다면 그 순서는 코드로 강제되어야 한다. 주석에만 있으면 +계약이 아니다. + +**증거 1 — `StartupPhase`는 라벨로만 쓰인다.** 전수 검색 결과 `StartupPhase`의 사용처는 +`StartupErrorCode`의 필드와 `MigrationStartupRunner`의 로그 `kv()` 인자뿐이다. 어떤 validator도 +자기 phase를 선언하지 않고, phase 순서로 실행을 배열하는 코드가 없다. + +**증거 2 — post-processor 순서가 동률이다.** + +| 클래스 | `getOrder()` | +| --- | --- | +| `MasterSwitchEnvironmentPostProcessor` | `Ordered.LOWEST_PRECEDENCE` | +| `RuntimeEnvironmentProfileValidator` | `Ordered.LOWEST_PRECEDENCE` | +| `CapabilityDependencyEnvironmentValidator` | `Ordered.LOWEST_PRECEDENCE` | +| `TracingSamplingEnvironmentPostProcessor` | `Ordered` 미구현 (기본 = LOWEST_PRECEDENCE) | +| `RedisReadinessGroupPostProcessor` | `ConfigDataEnvironmentPostProcessor.ORDER + 1` | +| `DatabaseReadinessGroupPostProcessor` | `ConfigDataEnvironmentPostProcessor.ORDER + 2` | + +readiness 2개는 결정론적이다. 문제는 앞의 넷이 **모두 같은 값**이라는 점이다. 그리고 +`CapabilityDependencyEnvironmentValidator.getOrder()`의 주석은 이렇게 의도를 명시한다. + +> *"after `MasterSwitchEnvironmentPostProcessor` has rejected legacy switch spellings — a violation +> reported against a key the operator was told to stop using would send them somewhere useless."* + +**주석이 주장하는 순서를 반환값이 강제하지 않는다.** 현재 이 순서가 지켜지는 이유는 오직 +`spring.factories`의 나열 순서와 Spring 정렬의 안정성(stable sort) 때문이다. 즉 **`spring.factories`의 +줄 순서를 바꾸면 주석이 경고한 그 상황이 조용히 발생한다.** 이건 계약이 아니라 우연이다. + +**영향.** 현재 동작이 깨져 있다고 단정하지는 않는다(정적 분석이며 테스트를 실행하지 않았다). 그러나 +선언된 의도와 강제 수단이 불일치하므로, 파일 편집만으로 회귀가 발생할 수 있고 그 회귀를 잡는 테스트가 +없다. + +**수정.** + +1. 네 post-processor에 서로 다른 명시적 order를 부여한다 + (`ConfigDataEnvironmentPostProcessor.ORDER + n` 형태로 통일하면 readiness 2개와 같은 축에 놓인다). +2. `StartupPhase`를 실행 순서의 1급 개념으로 승격한다 — 4.2의 권고안. phase 내부는 전부 누적, + phase 실패 시 다음 phase 미실행. +3. 순서를 고정하는 테스트를 추가한다. `spring.factories` 편집으로 순서가 바뀌면 실패해야 한다. + +--- + +### BOOT-002 — 프로덕션 프로파일 판정이 7곳에 복제되고 어휘가 갈라진다 + +**규칙.** 정책의 정의는 한 곳에 있어야 한다. 이 저장소에는 이미 정본이 있다 — +`RuntimeEnvironmentProfileValidator.DEPLOYABLE_ENVIRONMENTS = Set.of("local", "dev", "prod")`. + +**증거.** 동일 의미의 판정이 세 가지 다른 어휘로 7곳에 재구현되어 있다. + +| 어휘 | 위치 | +| --- | --- | +| `PROD_PROFILE = "prod"` (6줄 루프 동일 복제) | `runtime/JpaSchemaSafetyValidator:51`, `runtime/PersistenceVendorProdSafetyValidator:81`, `runtime/PostgreSqlTransportSecurityValidator:86`, `runtime/StartupSafetyValidator:94`, `runtime/SecretSourceValidator:173`, `runtime/startup/FlywayProdSafetyValidator:60` | +| `Set.of("prod","production")` | `autoconfigure/fileserver/FileserverStartupConfiguration:90`, `autoconfigure/fileserver/FileserverSecurityConfiguration:81` (상수도 각자 선언) | +| `"prod"\|\|"production"` 인라인 | `autoconfigure/httpclient/HttpClientProfileAutoConfiguration:101` | + +**영향 — 정확히 기술한다.** 배포 아티팩트에서는 **현재 실 버그가 아니다.** +`RuntimeEnvironmentProfileValidator`가 `local|dev|prod` 중 정확히 하나를 요구하며 부팅을 거부하므로, +`production` 단독 프로파일로는 애플리케이션이 뜨지 않는다. 따라서 fileserver/httpclient의 넓은 어휘는 +도달 불가능한 죽은 범위다. + +그러나 그 게이트에는 조건이 있다. `isDeployableArtifact()`가 메인 클래스가 +`dev.caskeleton.bootstrap.CaSkeletonApplication`인지 확인하므로, **슬라이스 테스트와 다른 컨텍스트는 +이 게이트를 지나지 않는다.** 그 환경에서는 두 어휘가 실제로 갈린다. + +실질 비용은 유지보수다. 프로파일 이름을 하나 추가하거나 정책을 바꾸면 **7곳을 동시에** 고쳐야 하고, +하나를 빠뜨려도 컴파일은 통과한다. + +**수정.** `RuntimeEnvironment` 판정기를 하나 만들어 정본 어휘를 소유하게 하고, 7곳을 그것으로 치환한다. +`DEPLOYABLE_ENVIRONMENTS`도 그 타입으로 옮긴다. `getActiveProfiles()` 직접 호출을 bootstrap production +코드에서 금지하는 ArchUnit 규칙으로 재발을 막는다. + +--- + +### BOOT-004 — 검증 누적 로직이 15곳에 복제되고 공용 컨텍스트가 없다 + +**규칙.** 같은 구조가 3회 이상 반복되면 추상화 후보다. 여기서는 15회다. + +**증거.** `List` 누적 → 비어있지 않으면 throw 패턴이 다음 위치에 각자 구현되어 있다. + +``` +activation/CapabilityDependencyValidator:42 violations +autoconfigure/fileserver/FileserverStartupValidator:26,38 violations (+ 지역 헬퍼 failOn()) +autoconfigure/httpclient/HttpClientStartupValidator:64 violations +autoconfigure/persistencejpa/DataSourceRequirement:29 reasons +runtime/RuntimeNumericBoundsValidator:38 violations +runtime/StartupSafetyValidator:56,77 unsafe / missing +runtime/SecretSourceValidator:70,98 offenders / missing +runtime/RedisActivationValidator:46 contradictions +runtime/HikariPoolConstraintValidator:38 violations +runtime/startup/FlywayProdSafetyValidator:31 violations +runtime/startup/RequiredEnvironmentValidator:31 missing +security/AuthenticationModeCompositionConfig:24 contradictions +runtime/PostgreSqlTransportSecurityValidator:71 modes +``` + +변수명이 `violations`/`missing`/`offenders`/`contradictions`/`unsafe`/`reasons`/`modes`로 제각각이라 +같은 패턴이라는 사실 자체가 코드에서 보이지 않는다. 오류 메시지 조립 방식도 각자 다르다 +(`String.join`, 문자열 연결, `describe()` 헬퍼). + +**수정 — 검증 컨텍스트 도입.** 4.2의 phase 개념과 함께 구현한다. + +```java +public final class StartupValidationContext { + private final Map> byPhase = new EnumMap<>(StartupPhase.class); + + public StartupValidationContext require(boolean ok, StartupPhase phase, String detail) { ... } + public boolean hasFailures(StartupPhase phase) { ... } + public void failFastAtEndOfPhase(StartupPhase phase) { ... } // StartupFailures 경유 +} +``` + +효과가 겹친다는 점이 중요하다. 이 하나의 컨텍스트가 BOOT-004(중복 제거), BOOT-005(phase 순서 강제), +BOOT-003(정본 예외 경로 강제)를 동시에 해결한다. 세 발견을 한 덩어리로 처리하라고 권고한 이유다. + +제네릭은 여기에 필요 없다. 누적 대상이 항상 `String` 메시지이므로 타입 파라미터는 가치를 더하지 +않는다 — 4.4의 기준을 그대로 적용한 결과다. + +--- + +### BOOT-006 — 패키지 taxonomy가 두 축으로 섞이고 `Settings` 배치 규칙이 없다 + +**증거 1 — capability 배치의 이원화.** + +``` +autoconfigure/{fileserver, httpclient, jpa, messaging, persistencejpa} ← autoconfigure 하위 +{redis, notification, mongo, outbox, tracing, idempotency, lock}/ ← 최상위 +``` + +성격이 동일한 capability 자동조립인데 절반은 `autoconfigure/` 안, 절반은 밖이다. 게다가 +`autoconfigure/jpa`(10파일)와 `autoconfigure/persistencejpa`(3파일)가 별도로 존재하고, JPA 관심사는 +`runtime/`의 validator 6개까지 합쳐 **3개 패키지**에 흩어져 있다. + +**증거 2 — `Settings` 배치 규칙 없음.** 16개 중 4개는 `settings/` 패키지에, 12개는 각 기능 패키지에 있다. +어느 쪽이 원칙인지 코드가 말하지 않는다. + +``` +settings/{Bootstrap, Logging, Privacy, RuntimeSafety}Settings +async/AsyncExecutorSettings, redis/RedisCapabilitySettings, outbox/OutboxSettings, ... (12개) +``` + +**수정.** 규칙을 하나로 정하고 `src/app-bootstrap/CLAUDE.md`에 기록한다. 권고안: + +- capability 조립은 전부 `autoconfigure//` 아래. +- `Settings`는 자기 capability 패키지 안에 둔다 (`settings/` 패키지는 capability에 속하지 + 않는 모듈 전역 설정만 유지). +- capability에 속하지 않는 시작 안전장치만 `runtime/`에 남긴다. JPA 전용 validator 6개는 + `autoconfigure/jpa/`로 이동. +- `autoconfigure/jpa`와 `autoconfigure/persistencejpa`를 병합한다. + +--- + +### BOOT-007 — 같은 분해 문제를 두 방식으로 푼다 + +**증거.** + +| capability | 방식 | 규모 | +| --- | --- | --- | +| notification | Config 9개로 분해 (Codec / Dispatch / Policy / Provider / Registries / Runtime / Secrets / Worker / Observability) | 최대 파일 442줄, `@Bean` 19 | +| redis | 파일 2개 | `RedisCapabilityConfig` 376줄(`@Bean` 6) + `RedisCapabilitySettings` **473줄** | +| fileserver | Configuration 8개 + 지원 7개 | `FileserverPlatformAutoConfiguration` 318줄, `@Bean` 24 | + +notification의 분해 축(codec/dispatch/policy/provider/registry/worker/observability/secrets)은 좋은 +기준이다. redis는 같은 기준을 적용할 수 있는데 적용되지 않았다. + +특히 `RedisCapabilitySettings` 473줄은 **단일 `Settings` 클래스로는 이 모듈 최대**이며, 하나의 +`@ConfigurationProperties` 안에 cache / rate-limit / lease / idempotency 네 개의 서로 다른 capability +설정이 들어 있다(내부 `namespaceOf`, `KeyDigest` 중첩 타입 포함). + +**수정.** notification의 분해 기준을 redis에 적용한다. 그리고 **분해 기준 자체를 문서화한다** — +이것이 핵심이다. 기준이 문서에 없으면 다음 capability에서 또 갈린다. 권고 기준: + +> 하나의 `@Configuration`은 하나의 조립 관심사만 가진다. `@Bean`이 8개를 넘거나 서로 다른 포트 계열을 +> 조립하기 시작하면 분해한다. 하나의 `Settings`는 하나의 capability 네임스페이스만 소유한다. + +--- + +### BOOT-008 — 모든 yaml 책임이 bootstrap에 집중되어 974줄 단일 파일이 된다 + +**제기된 논점에 대한 판정: 부분 동의.** 방향은 옳지만 그대로 실행하면 클린 아키텍처 문제가 생긴다. +분리 기준을 정확히 잡아야 한다. + +**증거.** `application.yml` 974줄에 15개 capability의 설정이 전부 들어 있다. 한편 어댑터 모듈들은 +이미 `src/main/resources`를 갖고 있고 일부는 자기 정책 yaml을 소유한다 +(`adapter/outbound/cache-redis/src/main/resources/redis-sdk/redis-command-policy.yml`, +`adapter/inbound/web/src/main/resources/META-INF/web/problem-catalog.yaml`). 즉 **선례가 이미 있다.** + +**그대로 실행하면 안 되는 이유.** 설정 값에는 성격이 다른 두 종류가 섞여 있다. + +| 종류 | 소유자 | 이유 | +| --- | --- | --- | +| **기본값 · 제약** (타임아웃 기본치, 허용 범위, 키 이름) | **모듈** | 그 모듈만이 무엇이 유효한지 안다 | +| **배포별 값** (prod의 pool size, dev의 엔드포인트, 활성 여부) | **bootstrap** | 배포 결정이며 모듈은 자기가 어디 배포되는지 몰라야 한다 | + +`application-{local,dev,prod}.yml`을 프로파일별로 나눈 것은 두 번째 종류에 대한 처리이고 **그 자체로 +옳다.** 만약 모듈이 `application-prod.yml`을 소유하게 되면, 어댑터가 "prod라는 배포 환경"을 알게 되어 +의존 방향이 오염된다. + +**수정 — 권고안.** + +1. **기본값은 코드로 내린다.** 이미 대부분의 `Settings`가 레코드 compact constructor에서 기본값을 + 부여한다(`IdempotencyProviderSettings`의 `provider == null ? Provider.JDBC`). 이 방식을 확대하면 + yaml에서 "기본값을 다시 적는" 줄이 사라진다. **가장 비용이 낮고 효과가 큰 조치다.** +2. **모듈이 shipping할 것이 남으면** `classpath:/META-INF/caskeleton/.yml`로 두고 + bootstrap이 `spring.config.import`로 합성한다. 의존 방향은 bootstrap → 모듈이므로 안전하다. +3. **배포별 오버라이드는 bootstrap이 계속 소유한다.** `application-{local,dev,prod}.yml` 구조는 유지. +4. 그 결과 `application.yml`은 "이 배포가 실제로 무엇을 켜고 어떤 값을 덮어쓰는지"만 남는다. + +BOOT-001(네임스페이스 통일)을 먼저 처리한 뒤 이 작업을 하는 편이 낫다. 순서를 뒤집으면 파일을 옮기고 +나서 키를 다시 옮기게 된다. + +--- + +### BOOT-009 — 구조화 로그와 평문 로그가 규칙 없이 섞인다 + +**증거.** production 코드의 로그 호출은 총 25건이다 (`error` 4, `warn` 17, `info` 3, `debug` 1). +그중 구조화 인자(`StructuredArguments.kv`)를 쓰는 파일은 **3개뿐**이다 — +`runtime/startup/StartupFailures`, `runtime/startup/MigrationStartupRunner`, `async/LoggingAbortPolicy`. + +`StartupFailures.emit()`은 이미 **정확히 요청된 형태의 틀**을 갖고 있다. 고정 필드 +(`startup.phase`, `error.code`, `error.category`)와 가변 부분(`detail`)이 분리되어 있고, `cause` 유무로 +두 형태만 존재한다. + +```java +log.error("startup failure in phase {}: {}", + kv("startup.phase", code.phase().wireName()), detail, + kv("error.code", code.name()), kv("error.category", code.category())); +``` + +**진단.** 로그 일관성 문제의 뿌리는 로그 코드가 아니라 BOOT-003이다. 67곳이 `StartupFailures`를 +우회한다는 것은 곧 **67곳이 이 틀을 통과하지 않는다**는 뜻이다. 우회 경로는 로그를 아예 남기지 +않거나(예외 메시지만), 각자 다른 평문으로 남긴다. + +**수정.** BOOT-003을 처리하면 이 항목의 대부분이 함께 해소된다. 추가로 필요한 것은 두 가지다. + +1. **경고 로그의 틀 정의.** `warn` 17건은 시작 실패가 아니므로 `StartupFailures` 대상이 아니다. 이들을 + 위한 대응물(예: `RuntimeWarnings.capabilityDegraded(capability, detail)`)을 만들어 + `capability` / `reason` 고정 필드를 부여한다. +2. **필드 이름 사전을 문서화한다.** `startup.phase`, `error.code`, `error.category`가 이미 쓰이고 + 있으므로 그 목록을 `README` 또는 runbook에 고정하고, 새 필드는 거기 등록 후 사용하도록 한다. + 이름이 사전에 없으면 로그 검색이 성립하지 않는다. + +--- + +### BOOT-010 — `Provider` enum과 `switch`가 이중 수정 지점을 만든다 + +**제기된 논점에 대한 판정: 증상 진단은 정확하고, 처방은 조정이 필요하다.** + +**증거.** `idempotency/IdempotencyProviderSettings.java` + +```java +public enum Provider { DISABLED, JDBC, REDIS, POSTGRESQL } +``` + +`idempotency/IdempotencyProviderSelectionConfig.java` + +```java +switch (settings.provider()) { + case DISABLED -> require(found, new Counts(0, 0, 0, 0)); + case JDBC -> require(found, new Counts(1, 1, 0, 0)); + case REDIS, POSTGRESQL -> require(found, new Counts(0, 0, 1, 1)); + default -> throw new IllegalStateException("Unsupported idempotency provider: " + ...); +} +``` + +provider를 추가하려면 **enum과 switch 두 곳**을 고쳐야 한다. + +**왜 env로 빼는 것이 답이 아닌가.** provider 문자열을 env로 받아도 문제가 해결되지 않는다. +`CASSANDRA`를 env로 넣을 수 있게 만들어도, 그에 대응하는 `IdempotencyStorePortV2` 빈이 클래스패스에 +없으면 부팅이 실패한다. **컴파일 오류가 런타임 오류로 옮겨갈 뿐 확장성은 생기지 않는다.** + +게다가 이 enum의 Javadoc은 설계 의도를 명시한다 — +*"Exact provider selector; provider precedence is never inferred from the classpath."* 즉 +"클래스패스에 있는 걸로 자동 추론하지 않는다"는 것이 **의도적 결정**이다. env 기반 자동 발견은 이 +결정을 정면으로 뒤집는 것이므로, 뒤집으려면 그만한 근거가 필요하다. 현재 그 근거는 없다고 본다. + +**진짜 결합점은 enum이 아니라 `switch`다.** enum은 "선택 가능한 provider의 목록"이라는 사실을 타입으로 +표현하는 올바른 도구다. 문제는 **각 provider가 요구하는 빈 조합을 bootstrap의 switch가 알고 있다는 +것**이다. 그 지식은 provider 자신이 가져야 한다. + +**수정 — 4.4의 제네릭 방향과 일치하는 처방.** + +```java +public interface ProviderSpec

> { + P id(); + ExpectedBeans expectation(); // 이 provider가 요구하는 빈 조합 +} +``` + +각 provider 모듈이 자기 `ProviderSpec`을 빈으로 등록하고, bootstrap은 선택된 id에 해당하는 spec을 +찾아 검증만 한다. switch가 사라지고, provider 추가 = 모듈 추가가 된다. enum은 그대로 유지된다 — +"선택 가능한 값의 닫힌 집합"이라는 타입 안전성은 계속 필요하기 때문이다. + +**부수 발견.** 위 `switch`의 `default ->` 분기는 도달 불가능하다. 네 개 enum 상수를 모두 다루고 있고 +Java의 enum switch는 망라성이 보장되므로, 이 분기는 죽은 코드이며 "새 상수를 추가해도 컴파일러가 +경고하지 않게" 만드는 부작용이 있다. 제거하면 컴파일러가 누락을 잡아준다. + +--- + +### BOOT-011 — `*SettingsBinder` 2개가 구조 중복이다 + +**증거.** `FileserverPlatformSettingsBinder`(41줄)와 `HttpClientPlatformSettingsBinder`(54줄)는 +동일 구조다 — `NoUnboundElementsBindHandler` 생성 → `Binder.get(env).bind(PREFIX, Bindable.of(T), strict)` +→ `orElseThrow`. 차이는 둘뿐이다. + +1. httpclient는 시스템 환경변수 소스를 handler에서 제외하고 `HttpClientEnvironmentKeys.rejectUnrecognised`로 + 따로 검사한다 (근거가 Javadoc에 정확히 기록되어 있다). +2. 예외 메시지 문구 ("the capability is enabled" vs "the platform is enabled"). + +**수정.** + +```java +static T bind(Environment env, String prefix, Class type, + Predicate handlerScope) { ... } +``` + +두 번째 차이(문구)는 통일하면 그만이다. 첫 번째 차이는 파라미터로 흡수된다. 이 항목은 심각도가 낮고 +독립적이므로 다른 작업 뒤에 처리해도 된다. + +--- + +### BOOT-012 — `CleanArchitectureTest`가 2,792줄 단일 클래스다 + +**증거.** `src/test/java/dev/caskeleton/bootstrap/architecture/CleanArchitectureTest.java` — +2,792줄, `@Test` 75개, `@Nested` **0개**, ArchUnit 규칙 정의 약 101개. + +이 저장소에서 가장 큰 단일 파일이며, 두 번째(`DeveloperExperienceContractTest` 1,130줄)의 2.5배다. + +**영향.** 규칙군의 경계가 보이지 않아 "이 규칙이 어느 계층 계약인지"를 파일을 읽어야 알 수 있고, +실패 시 리포트에서도 그룹이 드러나지 않는다. + +**수정.** `@Nested` 클래스로 규칙군을 분리한다 (예: `LayerDependencies`, `DomainPurity`, +`AdapterBoundaries`, `ContractIsolation`, `NamingConventions`). 규칙 내용은 손대지 않고 구조만 바꾸는 +작업이므로 위험이 낮다. 파일 분할까지 갈 필요는 없다 — `@Nested`만으로 목적이 달성되고, ArchUnit +클래스 캐시를 공유할 수 있어 실행 시간에도 유리하다. + +--- + +### BOOT-013 — `StartupFailureLogState`가 프로세스 전역 가변 상태다 + +**증거.** `runtime/startup/StartupFailureLogState`는 `static AtomicBoolean`을 들고 있고, +production 코드에 `public static void clearForTest()`를 노출한다. 실제 사용처는 +`logging/StartupFailureSpringBootLogFilter`(logback TurboFilter)와 +`StartupFailureSpringBootLogFilterTest` 두 곳이다. + +**현재는 문제가 아니다.** `build.gradle`에 `maxParallelForks`도 `junit-platform.properties`의 병렬 +실행 설정도 없다. 테스트는 순차 실행되므로 상태 간섭이 발생하지 않는다. + +**설계상의 정당성도 일부 있다.** logback `TurboFilter`는 Spring 빈이 아니라 logback이 직접 생성하므로 +주입을 받을 수 없다. static 접근이 사실상 유일한 통로다. + +**남는 문제 두 가지.** + +1. 테스트 병렬화를 켜는 순간 조용히 깨진다. 지금 막고 있는 것은 설계가 아니라 "아직 안 켰다"는 사실뿐이다. +2. `clearForTest()`가 production 소스에 public으로 있다. production 아티팩트에 테스트 전용 API가 포함된다. + +**수정(선택).** 지금 고쳐야 할 항목은 아니다. 다만 병렬 실행을 도입할 때 반드시 함께 처리해야 하므로 +기록해 둔다. 처리 방향은 `LoggerContext`의 object map에 상태를 저장해 logback 컨텍스트 수명에 묶는 +것이다 — 그러면 상태가 프로세스가 아니라 로깅 컨텍스트 단위가 되고 `clearForTest()`도 사라진다. + +--- + +### BOOT-014 — Testcontainers 통합 테스트가 `src/test`에 있어 기본 test 태스크가 Docker를 요구한다 + +**규칙.** 이 저장소는 이미 "실행 환경이 다른 테스트는 별도 소스셋"을 표준으로 삼고 있다. 문제는 +app-bootstrap만 그 표준을 지키지 않는다는 것이다. + +**증거 1 — 저장소의 실제 표준.** 주요 모듈의 `src/` 하위 소스셋 목록이다. + +| 모듈 | 소스셋 | +| --- | --- | +| `adapter/outbound/persistence-jpa` | main, test, **postgresqlIntegrationTest**, **jpaPlatformPerformanceTest**, testkit | +| `adapter/inbound/web` | main, test, **jettyCompatTest**, **nginxProxyTest**, **webfluxContractTest**, testkit | +| `adapter/inbound/websocket` | main, test, **brokerRelayTest**, **jettyWebSocketTest**, **nginxWebSocketTest**, testkit | +| `adapter/outbound/objectstorage` | main, test, **objectStorageMinioContractTest**, **objectStorageMinioFaultTest**, **objectStorageAwsQualificationTest** | +| `adapter/inbound/graphql` | main, test, **testFixtures** | +| **`app-bootstrap`** | main, test, conditionalTransportTest, functionalTest, sampleOffTest | + +다섯 모듈이 인프라 의존 테스트를 별도 소스셋으로 뺐다. **app-bootstrap의 세 소스셋은 전부 빌드 +조립 계약이고, 인프라 테스트용 소스셋은 하나도 없다.** + +**증거 2 — 인프라 테스트가 `src/test` 안에 있다.** `org.testcontainers`를 참조하는 파일: + +``` +integration/PostgreSqlTestContainer.java +integration/DistributedLockProviderContractTest.java +integration/IdempotencyUniqueScopeContractTest.java +integration/FlywayMigrationCompatibilityContractTest.java +integration/fileserver/FileserverRoundTripContractTest.java +integration/outbox/OutboxContainerTestSupport.java +integration/outbox/OutboxRowLifecycleContractTest.java (728줄) +integration/outbox/OutboxAppendTransactionalContractTest.java +integration/outbox/OutboxPublisherLeaderElectionContractTest.java +``` + +그리고 `integration/package-info.java`는 이를 명시적으로 인정한다. + +> *"Tests in this package require a running Docker daemon. They boot real external services +> (PostgreSQL via Testcontainers)…"* + +**증거 3 — 실행 경계가 없다.** `build.gradle`의 `test` 태스크에 `excludeTags`도 태그 필터도 없다. +`@Testcontainers`가 붙은 5개 클래스 중 가드(`@EnabledIf`)를 가진 것은 +`FileserverRoundTripContractTest` 하나뿐이다. 나머지 4개 — +`IdempotencyUniqueScopeContractTest`, `OutboxRowLifecycleContractTest`, +`OutboxAppendTransactionalContractTest`, `OutboxPublisherLeaderElectionContractTest` — 는 무조건 실행된다. +(`DistributedLockProviderContractTest`와 `FlywayMigrationCompatibilityContractTest`는 가정문 형태의 +가드를 갖고 있다.) + +**영향.** `./gradlew :app-bootstrap:test`가 **Docker 데몬을 요구한다.** 도메인 로직 한 줄을 고치고 +검증하려는 개발자가 PostgreSQL 컨테이너 기동을 기다린다. 이 저장소의 `CLAUDE.md`가 권장하는 +"focused test" 워크플로 — *leaf의 gradle_path로 `:app-bootstrap:test` 실행* — 이 가장 무거운 명령이 된다. +Docker가 없는 환경(CI의 경량 잡, 오프라인 작업)에서는 아예 실패한다. + +**수정.** 저장소 표준을 그대로 따른다. + +1. `src/test/java/dev/caskeleton/bootstrap/integration/**` 를 새 소스셋 + `src/integrationTest/java/dev/caskeleton/bootstrap/integration/**` 로 이동한다. 패키지 경로는 + 그대로 유지한다(production 미러링 원칙 유지). +2. `integrationTest` Test 태스크를 등록하고 `check`에는 붙이되 `test`에는 붙이지 않는다. + `persistence-jpa`의 `postgresqlIntegrationTest` 정의를 참고 구현으로 삼는다. +3. `TestTaxonomyArchitectureTest`에 규칙을 하나 추가한다 — *`src/test` 소스셋의 클래스는 + `org.testcontainers`에 의존할 수 없다*. 현재 이 규칙은 `contract`/`architecture` 트리에만 걸려 있어 + `integration` 트리가 `src/test`로 되돌아오는 것을 막지 못한다. 이 규칙이 BOOT-014의 재발 방지 장치다. + +**주의.** 이동 시 `OutboxContainerTestSupport`(231줄)와 `PostgreSqlTestContainer`는 공용 지원 코드이므로 +BOOT-015와 함께 배치를 정해야 한다. 통합 테스트만 쓰는 것이 확실하면 `integrationTest` 소스셋 안에 +두고, `src/test`도 쓴다면 `testFixtures`로 올린다. + +--- + +### BOOT-015 — 공용 테스트 지원 코드 관례가 두 갈래다 + +**증거.** 같은 목적(모듈 간 공유 가능한 테스트 지원 코드)에 두 가지 방식이 공존한다. + +| 방식 | 채택 모듈 | +| --- | --- | +| `testkit` 커스텀 소스셋 | `persistence-jpa`, `web`, `websocket` | +| `java-test-fixtures` Gradle 플러그인 | `graphql` | +| 둘 다 없음 (`src/test` 안에 지원 클래스 산재) | **`app-bootstrap`** | + +`graphql`의 `build.gradle`은 심지어 fixture가 잘못된 위치에 있으면 빌드를 실패시키는 커스텀 검증까지 +갖고 있다(99–141행). + +> *"Move them to src/testFixtures/java, or declare them with testFixturesImplementation."* + +한편 app-bootstrap의 지원 코드는 일반 테스트 트리에 섞여 있다 — +`contract/support/RepositoryContractResources`(191줄), `contract/support/conditional/*`(3개), +`integration/outbox/OutboxContainerTestSupport`(231줄), `integration/PostgreSqlTestContainer`, +`taxonomyfixtures/*`, `activation/AdapterActivationInventory`(242줄). + +**영향.** 지원 코드와 테스트 코드가 이름으로만 구분된다. 다른 모듈이 이 지원 코드를 재사용할 방법이 +없고, 반대로 production이 실수로 참조해도 `graphql`이 가진 것 같은 방어막이 없다 +(다만 `TestTaxonomyArchitectureTest`의 fixture-leak 규칙이 일부를 막는다). + +**수정.** 저장소 차원에서 **하나를 표준으로 정한다.** 권고는 `java-test-fixtures`다 — Gradle 공식 +플러그인이고, 소비 측 선언(`testFixturesImplementation`)이 명시적이며, `graphql`에 이미 강제 장치가 +구현되어 있어 그대로 복제할 수 있다. `testkit` 소스셋 3개는 점진적으로 이관한다. 이 결정은 +app-bootstrap 단독 결정이 아니므로 ADR로 남기는 것이 맞다. + +디렉터리는 역할을 드러내는 형태를 권고한다 (`testing/{fixture,fake,container,assertion}/`). +`TestUtil`/`CommonUtil` 같은 무의미한 이름 묶음은 금지한다. + +--- + +### BOOT-016 — 테스트 레벨 계약을 사람이 읽을 SSOT 문서가 없다 + +**증거.** `docs/testing/` 디렉터리가 존재하지 않는다. 테스트 레벨 계약은 현재 세 곳에 흩어져 있다. + +1. `TestTaxonomyArchitectureTest`의 ArchUnit 규칙 (기계는 읽지만 사람이 훑기 어렵다) +2. `integration/package-info.java`의 Javadoc (한 패키지에만 있다) +3. 각 모듈 `build.gradle`의 소스셋 선언 (의도가 아니라 결과만 보인다) + +기여자가 "이 테스트를 어디에 써야 하나"를 물었을 때 답을 주는 단일 문서가 없다. + +**왜 중요한가.** 이 저장소는 LLM 에이전트 협업을 전제로 설계되어 있다(`CLAUDE.md`, `AGENTS.md`, +`docs/superpowers`). 테스트 레벨 규칙이 SSOT로 고정되어 있지 않으면 생성되는 테스트가 +`@SpringBootTest` 남발, happy-path 편중, 중복 fixture 쪽으로 흐른다. 이건 사람 기여자에게도 동일하다. + +**수정.** `docs/testing/TESTING_STRATEGY.md`를 만들고 다음을 고정한다. + +- **레벨 정의와 소스셋 매핑.** unit(`src/test`, 네트워크·DB·Docker·`@SpringBootTest` 금지) / + slice / integration(`src/integrationTest`, Testcontainers 허용, 외부 SaaS 호출 금지) / + contract / build-qualification(`functionalTest`·`sampleOffTest`·`conditionalTransportTest`) / + architecture. +- **판단표.** 대상 코드 유형별(Controller, UseCase, JPA Repository, Kafka, Redis, 시작 validator)로 + "어느 레벨 / Mock·Fake·Real 중 무엇 / 어느 소스셋"이 기계적으로 결정되도록 한다. +- **배치 원칙.** production 패키지를 미러링한다. `unit/`·`service/`·`regression/` 같은 축 혼합 폴더 금지. +- **CI 단계 매핑.** unit(커밋) → integration·contract(PR) → build-qualification(머지) → + performance(야간). + +**저장소 관례에 맞춰 강제 수단을 함께 붙인다.** 문서만 만들면 드리프트한다. 이 저장소에는 +`verifyDocumentedLeafCount`처럼 문서-코드 불일치를 빌드 실패로 만드는 선례가 있으므로, +`TESTING_STRATEGY.md`가 선언한 소스셋 목록과 실제 `sourceSets` 선언이 어긋나면 실패하는 +검증 태스크를 추가한다. 그러면 이 문서는 설명서가 아니라 계약이 된다. + +--- + +### BOOT-017 — bootstrap이 조립만 하지 않고 아웃바운드 어댑터 구현체를 소유한다 + +**규칙.** Composition Root는 "무엇을 무엇에 연결하는가"를 결정하는 곳이지, 포트를 구현하는 곳이 아니다. +포트 구현체는 정의상 아웃바운드 어댑터이며 `adapter/outbound/*`에 속한다. + +**증거.** `app-bootstrap`의 production 코드에서 application/domain 포트를 직접 구현하는 클래스는 넷이다. + +| 클래스 | 구현 포트 | 성격 | +| --- | --- | --- | +| `notification/observation/MicrometerNotificationMetrics` | `NotificationMetricsPort` | Micrometer 어댑터 | +| `autoconfigure/fileserver/MicrometerFileserverMetrics` (190줄) | `FileserverMetricsPort` | Micrometer 어댑터 | +| `runtime/EnvironmentSecretSource` | `SecretSource` | 환경변수 어댑터 | +| `lock/MeteredDistributedLockPort` | `DistributedLockPort` | 계측 데코레이터 (논쟁적, 아래) | + +**결정적 증거 — 저장소 자신이 정답을 알고 있다.** 같은 계열의 `LoggingNotificationAudit` +(`NotificationAuditPort` 구현)은 `adapter/outbound/notification/.../platform/observation/`에 +**올바르게 배치되어 있다.** `NotificationPlatformObservabilityConfig:104`가 그 빈을 조립할 뿐이다. +즉 "관측 어댑터는 어댑터 모듈에 두고 bootstrap은 조립만 한다"는 패턴이 이미 이 저장소 안에 존재하는데, +위 넷만 그 패턴을 따르지 않는다. + +**집도 이미 있다.** `adapter:outbound:support` leaf가 존재하며 클래스가 셋뿐이다 +(`OutboundSupportConfig`, `FailOpenDependencyLogger`, `OutboundCorrelation`). 횡단 아웃바운드 지원 +어댑터의 자연스러운 소유자다. + +**방어막이 없다.** `CleanArchitectureTest`(101개 규칙)에 *bootstrap은 포트를 구현할 수 없다*는 규칙이 +없다. 그래서 넷이 컴파일·검증을 모두 통과한다. + +**`MeteredDistributedLockPort`는 논쟁적이며, 그 사실을 기록해 둔다.** 이것은 포트 어댑터가 아니라 +**데코레이터**다. "어느 포트를 계측할 것인가"는 조립 결정이라는 논리로 bootstrap에 둘 수 있고, +반대로 계측은 로깅·재시도와 같은 횡단 관심사이므로 별도 모듈이 맞다는 논리도 선다. 현재 데코레이터가 +하나뿐이라 bootstrap에 둬도 무해하다. **데코레이터가 둘 이상 되는 순간을 분리 신호로 삼는다.** +반면 나머지 셋은 논쟁의 여지가 없는 어댑터다. + +**영향.** 지금 동작이 깨지지는 않는다. 비용은 두 가지다. (1) bootstrap이 커지는 방향으로만 압력을 +받는다 — 새 관측 포트가 생길 때마다 여기로 온다. (2) 파생 서비스가 bootstrap을 교체할 때 +어댑터 구현까지 함께 다시 써야 한다. 이 저장소가 템플릿이라는 점에서 두 번째가 실질적이다. + +**수정.** + +1. `MicrometerNotificationMetrics` → `adapter/outbound/notification` + (`LoggingNotificationAudit` 옆, 동일 패키지 관례). +2. `MicrometerFileserverMetrics` → fileserver 어댑터 leaf, 없으면 `adapter:outbound:support`. +3. `EnvironmentSecretSource` → `adapter:outbound:support`. +4. `MeteredDistributedLockPort` → 당분간 유지, 데코레이터 2개째에 `adapter:outbound:support`로 이동. +5. `CleanArchitectureTest`에 규칙 추가 — *`..bootstrap..`의 클래스는 `..application..`/`..domain..`의 + 포트 인터페이스를 구현할 수 없다*. 데코레이터 예외가 필요하면 명시적 allowlist로 두어, 예외가 + 늘어나는 것이 눈에 보이게 한다. + +**이동 시 확인.** `modules.json`의 `allowed_dependencies`와 `runtime_memberships`를 함께 갱신해야 하며, +`verifyCleanArchitectureDependencies`가 게이트 역할을 한다. + +--- + +## 7. 참조 프로젝트 대비 격차 분석 + +별도의 헥사고날 템플릿(이하 "참조 프로젝트")에 대한 외부 분석을 이 저장소에 대입한 결과다. 참조 +프로젝트는 Book/Author 도메인을 가진 **예제 중심 템플릿**이고, 이 저장소는 도메인 예제가 최소인 +**인프라 스켈레톤**이다. 성격이 다르므로 항목별로 적용 가능성을 나누어 판정했다. + +### 7.1 이미 갖추었거나 더 강한 항목 + +| 참조 항목 | 이 저장소의 상태 | 판정 | +| --- | --- | --- | +| 의존 방향을 빌드가 강제 | `modules.json` 레지스트리 + `verifyCleanArchitectureDependencies` + ArchUnit 101규칙 **3중** | **더 강함** | +| `spring-tx`만 의존해 코어를 얇게 | `application-core`의 의존은 `implementation project(':shared-contract')` **하나뿐**. Spring 의존 0개이며 트랜잭션도 포트로 추상화 | **더 강함** | +| 코어 프레임워크 프리 | `domain-core`의 `dependencies { }` — 비어 있음 | 동등 | +| 어댑터 선택 property 기반 + fail-fast | `MasterSwitchEnvironmentPostProcessor`, `CapabilityDependencyValidator`, `RuntimeEnvironmentProfileValidator` | **더 정교함** | +| 전송 중립 에러 모델 | `shared-contract/error/{Category, ApiErrorCode, OperationalError, ApiErrorCarrier}` + web/gRPC/GraphQL 각 핸들러 | 동등 | +| MDC 키 상수화 | `MdcKeys` + `docs/registries`의 `mdc_key` 값과의 정합성을 테스트가 검증 (`DistributedTracingContractTest:170`) | **더 강함** | +| `-parameters` 컴파일 플래그 | `src/build.gradle:396` — `-Werror`, `-Xlint` 동반 | **더 강함** | +| 가상 스레드 / graceful shutdown | `spring.threads.virtual`, `timeout-per-shutdown-phase` | 동등 | +| Actuator liveness/readiness 분리 | 그룹 분리 + 위험 엔드포인트 exclude 목록 + 의존성 taxonomy | **더 강함** | +| Testcontainers 실 DB 통합테스트 | 존재 (다만 배치가 BOOT-014) | 동등 | + +**요약:** 참조 프로젝트가 "잘한 점"으로 꼽은 A-1~A-5, A-9의 상당수를 이 저장소는 이미 만족하거나 +더 엄격하게 구현하고 있다. 특히 **코어 순수성은 참조 프로젝트보다 한 단계 더 나아갔다** — 참조는 +`spring-tx`를 남겼지만 이 저장소는 트랜잭션마저 포트로 밀어냈다. + +### 7.2 없는 것 — 도입 후보 + +| 참조 항목 | 이 저장소 | 대응 | +| --- | --- | --- | +| **모듈이 자기 설정 파일 소유 (`config/.yml` 12개 + `spring.config.import`)** | `spring.config.import`가 **저장소 전체에서 0회** 사용. 설정 974줄이 bootstrap 단일 파일 | **BOOT-008의 참조 구현** | +| BOM 다중화로 버전 단일화 | Spring Boot BOM 하나만. 모듈 build.gradle에 개별 버전 18개 이상 하드코딩 | 아래 7.2.1 | +| `withSourcesJar()` | 없음 | 저비용, 선택 | +| `.env` 시트 + 우선순위 문서화 | `me.paulschwarz:spring-dotenv:4.0.0` 의존은 있으나 저장소 루트에 `.env`/`.env.example` 없음 | 문서화 후보 | + +**7.2.1 버전 하드코딩 증거.** 모듈 `build.gradle`에 BOM 밖 버전이 직접 박혀 있다. 그중 하나는 +이미 드리프트했다. + +``` +'org.spockframework:spock-core:2.3-groovy-4.0' +'org.spockframework:spock-core:2.4-groovy-5.0' ← 같은 아티팩트, 다른 버전 +'com.tngtech.archunit:archunit-junit5:1.3.0' +'net.logstash.logback:logstash-logback-encoder:8.0' +'com.google.protobuf:protobuf-java:4.33.2' +'tools.jackson.core:jackson-databind:3.0.2' +… (총 18개 이상) +``` + +Spock 두 버전이 각각 다른 Groovy 계열을 겨냥한 의도적 선택일 가능성은 있다. 다만 그 의도가 어디에도 +기록되어 있지 않고, **어떤 모듈이 어떤 버전을 쓰는지 한 곳에서 볼 수 없다는 점**이 문제다. +Gradle version catalog(`gradle/libs.versions.toml`)로 옮기면 버전이 한 파일에 모이고 드리프트가 +리뷰에서 보인다. `gradle.lockfile`이 이미 존재하므로 재현성은 확보되어 있고, 이 조치는 재현성이 +아니라 **가시성**을 위한 것이다. + +**7.2.2 `config/.yml`이 BOOT-008의 정확한 답이다.** 참조 프로젝트는 12개 모듈이 각자 +`config/.yml`을 소유하고 `spring.config.import`로 취합하며, `domain.yml`·`common.yml`처럼 +스프링을 쓰지 않는 모듈까지 일관되게 포함시킨다. BOOT-008에서 권고한 구조가 실제로 동작하는 형태로 +존재한다는 뜻이므로, 구현 시 이를 참조 구현으로 삼는다. + +**단, BOOT-008에서 이미 적은 경계는 유지한다.** 프로파일별 값(`application-{local,dev,prod}.yml`)은 +**배포 결정**이므로 bootstrap이 계속 소유해야 한다. 모듈이 `application-prod.yml`을 갖게 되면 어댑터가 +"prod라는 배포 환경"을 알게 되어 의존 방향이 오염된다. 참조 프로젝트의 분리선도 모듈=기본값, +루트=배포별 오버라이드다. + +### 7.3 이 저장소에는 적용되지 않는 항목 + +| 참조 항목 | 이유 | +| --- | --- | +| `pattern/{creation,pricing,validation,report,event}` 격리 | 이 저장소에는 Book 같은 예제 도메인이 없다. 그 역할은 `sample-portfolio` leaf가 맡으며, 프로덕션 의존이 금지된 픽스처다 | +| `PageQuery` clamp, `Page.map()`, `PageResponse` 봉투 | 도메인 예제가 최소라 공통 페이징 추상화가 없다. `InboxPage`/`InboxCursor`가 부분적으로 존재. **파생 서비스에서 도메인이 생길 때 도입할 항목** | +| `Money` VO, `BookCriteria`, 애그리게이트 id 참조 | 동일 — 도메인 설계 지침이지 스켈레톤 지침이 아니다 | +| B절 "패턴 협력자를 생성자 주입으로" | 이 저장소에는 `BookService`에 해당하는 서비스가 없다. 다만 **판별 기준은 채택 가치가 있다 (7.4)** | +| docker compose 테스트 벤치 | Testcontainers로 대체되어 있으며, 테스트가 자기 인프라를 소유한다는 점에서 오히려 낫다 | + +### 7.4 참조 분석에서 채택할 판별 기준 + +참조 분석 B절이 제시한 일반화 판별 기준은 §4.4의 "실제로 2회 이상 반복된 곳에만 적용한다"를 +보완하므로 함께 채택한다. + +> **"이 인터페이스 이름에서 도메인 단어를 빼도 뜻이 통하는가?"** → 통하면 일반화, 안 통하면 그대로. + +`Strategy`는 사실상 `Function`이므로 자기 이름을 붙일 가치가 없고, `PricingStrategy`처럼 +도메인 어휘가 실린 이름은 일반화하면 의미를 잃는다는 논지다. 이 저장소에 대입하면 BOOT-010의 +`ProviderSpec

` 권고가 이 기준을 통과한다 — "provider가 요구하는 빈 조합"에서 idempotency라는 +도메인 단어를 빼도 뜻이 통하기 때문이다. 반대로 `EdgeRateLimitPort`, `NotificationMetricsPort` 같은 +이름은 일반화 대상이 아니다. + +**두 기준을 함께 쓴다:** 반복 2회 이상(§4.4) **그리고** 도메인 단어를 빼도 뜻이 통할 것(7.4). +둘 중 하나만 만족하면 일반화하지 않는다. + +--- + +## 8. 다음 단계 제안 + +한 덩어리로 처리할 것을 권고한다 (BOOT-003 / 004 / 005는 같은 코드 영역이다). + +1. **0차 — 피드백 루프 복구 (선행).** `integration` 트리를 `src/integrationTest` 소스셋으로 분리하고 + `test`에서 떼어낸다. 재발 방지 ArchUnit 규칙 추가. → BOOT-014. + **먼저 하는 이유:** 이 작업이 끝나야 `./gradlew :app-bootstrap:test`가 Docker 없이 빠르게 돌고, + 이후 1~3차 작업의 반복 검증 비용이 내려간다. 다른 항목과 코드 영역이 겹치지 않아 병행도 가능하다. +2. **1차 — 시작 검증 정본화.** `StartupValidationContext` + `RuntimeEnvironment` 도입, + `StartupFailures` 채택률 100%, phase 순서 강제, post-processor order 명시. + 재발 방지 ArchUnit 규칙 2개 추가. → BOOT-003, 004, 005, 002 동시 해소. +3. **2차 — 설정 정리.** 네임스페이스 `ca-skeleton.*` 통일 → 기본값을 `Settings` 레코드로 내림 → + `application.yml` 축소. → BOOT-001, 008. +4. **3차 — 패키지·분해 규칙 확정 + 어댑터 반출.** capability 배치 규칙과 분해 기준을 `CLAUDE.md`에 + 기록한 뒤 redis 분해와 jpa 패키지 병합을 실행. 같은 작업 단위에서 포트 구현체 3개를 + `adapter/outbound/*`로 이동하고 ArchUnit 규칙으로 재발을 막는다. → BOOT-006, 007, 017. +5. **4차 — 테스트 표준 확정.** `docs/testing/TESTING_STRATEGY.md` 작성 + 소스셋 선언 정합성 검증 + 태스크 추가, `testkit`/`testFixtures` 단일화 ADR. → BOOT-015, 016. +6. **5차 — 잔여.** BOOT-009 경고 로그 틀, BOOT-010 `ProviderSpec`, BOOT-011 binder 통합, + BOOT-012(§9.1-5 참조 — `@Nested` 아님), BOOT-013(테스트 병렬화 도입 시), version catalog 이관(§7.2.1). + +**가장 중요한 것.** 1~3차를 완료해도 규칙이 문서화되지 않으면 다음 capability에서 같은 발산이 +재발한다. 현재 `src/app-bootstrap/CLAUDE.md`(40줄)에는 책임과 금지사항만 있고 +**"capability를 새로 추가할 때 파일을 어디에 어떤 기준으로 나누는가"**가 없다. 이 모듈에서 발견된 +결함은 사실상 전부 그 한 문단의 부재에서 파생되었다. + +--- + +## 9. 실행 결과 (2026-09-07) + +§8의 순서대로 실행했다. 아래는 상태와, **실행 중 리뷰 자체가 틀렸음이 드러난 항목들**이다. 후자가 +더 중요하므로 먼저 적는다. + +### 9.1 리뷰가 틀렸던 곳 + +| # | 리뷰의 주장 | 실제 | 근거 | +| --- | --- | --- | --- | +| 1 | BOOT-017: `EnvironmentSecretSource`는 잘못 배치된 어댑터 | **아님.** `SecretSource`는 `bootstrap/runtime`이 소유한 포트다. 자기 leaf가 선언한 인터페이스의 전략 구현은 어댑터가 아니다 | `grep -rn 'interface SecretSource'` → `app-bootstrap/.../runtime/SecretSource.java` | +| 2 | BOOT-006: JPA 전용 validator 6개를 `autoconfigure/jpa/`로 이동 | **하면 안 됨.** `JpaSchemaSafetyValidator` 등은 JPA가 **꺼져 있을 때도** 도는 무조건 가드다. 소재(subject matter)가 소유(ownership)를 정하지 않는다 | 이동하자 `FiveAdapterOffInventoryTest`가 실패 — "JPA off인데 JPA 빈 5개가 조립됨". 되돌림 | +| 3 | BOOT-001: `app.*` → `ca-skeleton.*` 통일 | **httpclient는 불가.** `app.httpclient.*`의 리스트 설정은 환경변수에서 relaxed binding으로 직접 바인딩된다. prefix를 바꾸면 `APP_HTTPCLIENT_*`가 전부 `CA_SKELETON_HTTPCLIENT_*`가 되어 저장소의 `APP_` 환경변수 규약을 깬다. `app.messaging.*`은 벤더드 모듈 소유라 애초에 대상이 아니다 | `HttpClientEnvironmentKeys.ENV_PREFIX`, `fieldToEnvTemplate()` | +| 5 | BOOT-012: `@Nested`로 규칙군을 분리한다 | **하면 안 됨 — 위험하다.** ArchUnit 엔진은 중첩 클래스 안의 `@ArchTest` 필드를 **발견하지 못한다**. 탐침 테스트 결과 빌드는 초록, 리포트는 생성되지 않고, 규칙은 그냥 실행되지 않았다. 권고대로 했다면 아키텍처 규칙 74개가 조용히 꺼진 채 모든 빌드가 통과했을 것이다 | `NestedArchRuleProbeTest` (탐침 후 삭제) — `build/test-results/test/`에 결과 파일 없음 | +| 4 | BOOT-014: integration 레인을 `check`에 붙인다 | **저장소 관례와 다름.** `persistence-jpa`의 `postgresqlIntegrationTest`도 `check`에 붙어 있지 않다. 붙이면 Docker 없는 환경에서 `check`가 실패한다 | 관례를 따라 `check`에 붙이지 않음 | + +### 9.2 실행 중 새로 발견한 것 + +리뷰가 잡지 못했는데 작업 중 규칙이 잡아낸 것들이다. 규칙을 먼저 만들고 코드를 고친 순서가 그대로 +효과를 냈다. + +1. **`NotificationPlatformRegistriesConfig`의 익명 클래스 2개가 application 포트 구현이었다.** BOOT-017 + 규칙이 잡았다. 더 나쁜 것은 그중 하나(`require(profileId)`)가 **요청 시점** 조회 실패인데, + BOOT-003 일괄 변환이 이를 startup 실패(종료코드 72)로 잘못 분류했다는 점이다. 어댑터로 옮기면서 + 둘 다 해소 — 이동이 오분류를 드러냈다. +2. **람다는 ArchUnit이 못 잡는다.** 같은 파일의 `ProviderEventProjectorRegistry`는 람다여서 규칙에 + 걸리지 않았다. 규칙이 볼 수 있는 곳에서만 경계가 지켜지는 상태가 규칙 없는 상태보다 나쁘므로 함께 + 이동했다. +3. **readiness 그룹 기여 로직이 2벌 중복.** Redis/Database 두 post-processor가 같은 병합 코드를 갖고 + 있었고, 프로퍼티 키는 Redis 쪽이 "먼저 선언했다"는 이유로 소유하고 있었다. `runtime/ReadinessGroup` + 으로 추출. +4. **버전 드리프트가 하나 더.** `protobuf-java`가 4.33.2와 4.29.3 두 벌이었다(§7.2.1은 spock만 + 보고했다). 카탈로그에서 별칭 두 개로 **보이게** 두었다 — 합치는 것은 해석 결과를 바꾸는 별개 결정이다. +5. **`RedisCapabilityConfig`의 rate-limit switch에도 죽은 `default`가 있었다.** BOOT-010과 같은 결함, + 다른 위치. `RateLimitAlgorithm`은 상수가 정확히 3개다. +6. **빌드 파일을 텍스트로 파싱하는 계약 테스트 2개가 표기법에 결합돼 있었다.** + `MessagingPublicSurfaceContractTest`와 `BinaryCodecBackendScopeTest`는 `api 'group:artifact:version'` + 문자열만 인식해서, version catalog 이관만으로 "선언되지 않았다"고 보고했다. 계약은 그대로인데 + 표기가 바뀌어 실패하는 것은 게이트가 낼 수 있는 가장 비싼 거짓 양성이므로, 두 테스트가 카탈로그 + 별칭도 해석하도록 고쳤다. +7. **`@ArchTest` 인벤토리 가드에 구멍이 있었다.** 분할 작업이 찾아냈다: `ArchRuleDiscoveryContractTest` + 의 정규식이 `@ArchTest` 바로 다음 줄에 선언이 오는 형태만 인식해서, + `SHARED_CONTRACT_CONTAINS_ONLY_OPERATIONAL_CONTRACT_PACKAGES`(애노테이션과 선언 사이에 9줄짜리 + "known defect" 주석이 있다)를 놓치고 있었다. **규칙이 주석을 다는 것만으로 가드에서 숨을 수 + 있었다** — 그리고 주석이 달릴 만한 규칙일수록 지켜볼 값어치가 있다. 86 → 87로 정정. +8. **`java-test-fixtures` 적용이 ArchUnit 코퍼스를 정반대 두 방향으로 깨뜨렸다.** `httpclient` 는 + `build/classes/java/testkit` 을 **제외**하고 있었는데 fixture 가 `…-test-fixtures.jar` 로 + 도착해 제외가 빗나갔고, fixture 간 호출 258건이 "production이 testkit에 의존"으로 보고됐다. + `persistence-jpa` 는 `build/classes/java/main` 만 **포함**하고 있었는데, + `java-test-fixtures` 를 적용하면 모듈 자신의 테스트 클래스패스가 클래스 디렉터리 대신 **jar**를 + 싣는다 — 코퍼스가 비었다. 후자가 위험한 쪽이다(빈 코퍼스에서는 모든 `noClasses()` 가 통과한다). + 그 스위트가 "코퍼스가 비어 있지 않다"를 먼저 단언하기 때문에 드러났다. +9. **`sample-portfolio`에 사전 존재 결함이 있다 — 이번 작업과 무관.** `SampleApplicationContextTest` + 3건이 실패한다: `JpaLiveEventReplayAdapter`가 `java.time.Duration` 빈을 생성자로 요구하는데 + 저장소 전체에 `@Bean Duration`이 하나도 없다. `git worktree`로 HEAD를 그대로 체크아웃해 확인한 + 결과 동일하게 실패한다(`NoSuchBeanDefinitionException`). **별도 처리 대상이다.** + +### 9.3 항목별 상태 + +| ID | 상태 | 무엇을 했나 | +| --- | --- | --- | +| BOOT-014 | **완료** | `src/integrationTest` 소스셋 신설(`strictTestLanes` DSL 채택), Testcontainers 9클래스 이동, `testCompileOnly`로 픽스처만 유지, `TEST_SOURCE_SET_DOES_NOT_DEPEND_ON_TESTCONTAINERS` 규칙 추가 | +| BOOT-003 | **완료** | 65곳 중 60곳을 `StartupFailures`로 전환(6곳은 근거와 함께 유지), `envValidation(detail, cause)` 오버로드 추가, `StartupFailureContractArchitectureTest` 추가 | +| BOOT-002 | **완료** | `shared-contract`에 `RuntimeEnvironment` 신설, 7곳 + objectstorage 1곳 교체, 단위 테스트 10건 | +| BOOT-004 | **완료** | `StartupValidationContext` 신설, 8개 validator 전환(누적 유지 + phase가 예외를 선택) | +| BOOT-005 | **완료** | `StartupOrder`에 6개 post-processor 순서를 명시, `StartupPhase`를 예외 선택에 실제로 사용 | +| BOOT-001 | **부분 — 근거와 함께** | 전면 개명은 9.1-3 사유로 기각. 대신 `ConfigurationNamespaceContractTest`로 루트 3개와 prefix 17개를 고정 — 네 번째 루트나 새 prefix는 이제 빌드가 막는다. 운영자 관점 문제(같은 관심사가 470줄 떨어져 있음)는 BOOT-008이 해소 | +| BOOT-008 | **완료** | `application.yml` 974줄 → 248줄 + `config/*.yml` 15개. 키 307개 전수 대조로 무손실 확인. `spring:`만 남긴 이유는 `spring.profiles.active`가 import 문서에서 위험하기 때문 | +| BOOT-006 | **완료(수정 포함)** | capability 7개를 `autoconfigure/` 아래로, `jpa`+`persistencejpa` 병합, Redis validator 2개 이동. JPA 무조건 가드 5개는 9.1-2에 따라 `runtime/` 유지 | +| BOOT-007 | **완료** | `RedisCapabilityConfig` 383줄 → 68줄 + 4개 능력별 Config + `RedisKeys`. notification의 분해 패턴을 그대로 적용 | +| BOOT-017 | **완료** | 포트 구현 4개를 어댑터 leaf로 이동(Micrometer 2, 레지스트리 2), `CompositionRootBoundaryArchitectureTest` 추가. `MeteredDistributedLockPort`는 데코레이터 2개째를 임계값으로 명시하고 유지 | +| BOOT-009 | **완료** | `RuntimeWarnings` 신설(meter-unavailable / setting-fallback 2프레임), 16곳 전환, 구조화 필드 부여 | +| BOOT-011 | **완료** | `StrictSettingsBinder`로 통합, 두 바인더는 각자의 실제 차이만 남김 | +| BOOT-015 | **완료** | `ADR-BUILD-001` 작성 + `testkit` 소스셋 5개 전부 `testFixtures` 로 이관, `ca.testkit-publisher` 컨벤션 플러그인 삭제. 두 관례 공존 상태 해소 | +| BOOT-016 | **완료** | `docs/testing/TESTING_STRATEGY.md` 작성 + `verifyTestSourceSetRegistry` 게이트 추가(`check`에 배선). 반증 테스트로 실제 실패함을 확인 | +| BOOT-010 | **완료(재정의)** | 죽은 `default` 분기 2곳 제거. `ProviderSpec

` 크로스모듈 SPI 대신 **선택값이 자기 요구를 갖게** 했다 — `Provider` 는 필요한 빈 수를, `AuthenticationMode` 는 필요/금지 빈 이름을 상수 자체가 선언한다. 어댑터가 "컴포지션 루트가 스토어를 몇 개 들고 있어야 하는지"를 선언하는 건 조립 지식을 어댑터에 주는 것이라 리뷰의 스케치보다 이쪽이 맞다 | +| BOOT-012 | **완료(재정의)** | `@Nested` 는 9.1-5 사유로 기각. 먼저 `ArchRuleDiscoveryContractTest` 로 규칙 87개를 고정한 뒤, 2,792줄을 **별도 최상위 `@AnalyzeClasses` 스위트 5개**(domain purity / application boundary / adapter boundary / type boundary / forbidden API, 250~841줄)와 공용 헬퍼 2개짜리 `CleanArchitectureRuleSupport` 로 분할. 안전망이 분할 중 규칙 누락이 없었음을 증명했다 | +| BOOT-013 | **미실행(의도)** | 테스트 병렬화를 켤 때 함께 처리 | +| §7.2.1 | **완료** | `gradle/libs.versions.toml` 신설, 59개 좌표를 35개 별칭으로 이관, 20개 build.gradle 갱신 | + +### 9.4 검증 + +전부 통과했다. + +``` +./gradlew :app-bootstrap:test 993 tests, 0 failed +./gradlew :adapter:outbound:notification:test +./gradlew :adapter:outbound:fileserver:test +./gradlew :adapter:outbound:objectstorage:test +./gradlew :shared-contract:test +./gradlew verifyCleanArchitectureDependencies +./gradlew verifyDocumentedLeafCount +./gradlew verifyTestSourceSetRegistry +``` + +**실패하지만 이번 작업과 무관:** `:sample-portfolio:test`의 `SampleApplicationContextTest` 3건. +HEAD 그대로에서도 동일하게 실패한다(§9.2-9). + +**미실행:** `integrationTest` 레인(Docker 데몬 필요), 전체 `check`(워크플로 승인 대상), 벤더 +qualification 레인. 이들은 이번 변경으로 소스셋 배치와 잠금 상태가 바뀌었으므로 Docker 가능한 +환경에서 한 번 돌려야 한다. + +**커밋하지 않았다.** 저장소 정책이 `human-only`다. 커밋할 때 함께 넣어야 하는 생성 파일이 둘 있다: + +- `src/gradle/libs.versions.toml` — 새 version catalog. +- `src/settings-gradle.lockfile` — 카탈로그를 추가하면 Gradle이 settings 수준 configuration + (`incomingCatalogForLibs0`)에 대해 만드는 잠금 파일. Gradle 자신이 "source control에 포함되어야 + 한다"고 헤더에 적는다. + +작업 중 만든 `git stash@{0}` 항목이 하나 남아 있다. 작업 트리와 같은 내용의 중복 스냅샷이며, +검증이 끝난 뒤 `git stash drop` 으로 지우면 된다. + +### 9.5 남은 것 + +리뷰의 발견 항목은 전부 처리했다. 남은 것은 이 환경에서 실행할 수 없는 검증 하나와, 이번 작업이 +새로 드러낸 별개 결함 하나다. + +1. **`integrationTest` 레인 실행 확인** — Docker 데몬이 필요하다. 이번 변경으로 소스셋 배치와 잠금 + 상태가 바뀌었으므로 Docker 가능한 환경에서 한 번 돌려야 한다. +2. **`sample-portfolio` 의 사전 존재 결함**(§9.2-9) — `JpaLiveEventReplayAdapter` 가 `Duration` 빈을 + 요구하는데 저장소 전체에 `@Bean Duration` 이 없다. 이번 작업과 무관하며 별도 처리 대상이다. +3. 전체 `check` — 워크플로 승인 대상이라 실행하지 않았다. diff --git a/docs/security/public-paths-snapshot.txt b/docs/security/public-paths-snapshot.txt index 0b628e9a..241f793b 100644 --- a/docs/security/public-paths-snapshot.txt +++ b/docs/security/public-paths-snapshot.txt @@ -1,4 +1,6 @@ # feature-security-operational-baseline D5 — deny-by-default public path snapshot. -# SSOT: SECURITY_PUBLIC_PATHS (src/.env) -> SecurityConfig permitAll(); anyRequest authenticated. +# SSOT: ca-skeleton.security.public-paths default in app-bootstrap/src/main/resources/config/security.yml +# -> SecurityConfig permitAll(); anyRequest authenticated. An operator's own SECURITY_PUBLIC_PATHS +# overrides it at run time and is outside this snapshot. # Update only after review with: ./gradlew updatePublicPathSnapshot -PapprovePublicPathChange -/api/healthcheck +/v1/healthcheck diff --git a/docs/study/graphql-walkthrough.ko.md b/docs/study/graphql-walkthrough.ko.md new file mode 100644 index 00000000..752d426d --- /dev/null +++ b/docs/study/graphql-walkthrough.ko.md @@ -0,0 +1,3657 @@ +# GraphQL 인바운드 모듈 완전 해부 + +> **성격**: 정책 문서가 아니라 **읽기 기록**이다. `adapter:inbound:graphql` leaf와 그 주변 +> (app-bootstrap 배선, 테스트 레인, 아티팩트 게이트, 스모크 스크립트, ADR, 코드 리뷰 문서)에 +> 실제로 구현되어 있는 것을 코드 기준으로 하나씩 확인하면서, "무엇이 있는가"와 "왜 그렇게 +> 되어 있는가"를 정리한 글이다. +> +> SSOT는 여전히 `src/config/architecture/modules.json`, 모듈 +> [CLAUDE.md](../../src/adapter/inbound/graphql/CLAUDE.md), +> [README.md](../../src/adapter/inbound/graphql/README.md), +> [ADR-GQL-001](../adr/ADR-GQL-001-graphql-context-and-storage-ownership.md)이다. +> 이 문서와 그것들이 어긋나면 그쪽이 맞다 — **단, §28에 적은 것들은 예외다. 거기 적힌 것은 +> 문서 쪽이 코드와 어긋나 있는 항목이고, 코드가 맞다.** +> +> 마지막 장(§28)에 **읽으면서 발견한 손볼 거리**를 우선순위대로 모아 두었다. + +> **커버리지 범위 (읽은 것과 안 읽은 것)** +> +> 이 leaf는 production Java 408개 파일이고, 그중 **전부**를 최소한 클래스 javadoc 수준까지 +> 읽었다. 정독(본문 전체를 읽음)한 범위와 표면 읽기(클래스 javadoc + 시그니처 + 참조 스캔)로 +> 끝낸 범위는 아래와 같다. +> +> | 영역 | 파일 | 이 문서에서 | +> |---|---:|---| +> | `moduleboundary`, `autoconfigure`, `runtime`, `execution`, `http`, `cost`, `security` | 116 | **전문 정독** (§2–§11) | +> | `api`, `policy`, `context`, `error`, `dataloader`, `pagination`, `mutation`, `fetch`, `schema`, `scalar`, `compat`, `observation`, `architecture`, `release` | 157 | **핵심 타입 정독 + 나머지 javadoc/시그니처** (§5–§21) | +> | `advanced/**` 17개 패키지 | 135 | **전 파일 javadoc + 핵심 타입 본문** (§22–§23) | +> | test / testFixtures | 117 | 레인 구조, 계약 스위트, 경계 fixture (§24) | +> +> **정독하지 않은 것을 정독한 것처럼 쓰지 않았다.** advanced 쪽은 대부분이 정책 값 객체와 +> 상태 기계여서 javadoc이 곧 설계 결정문이고 — 이 레포는 javadoc에 "왜"를 쓰는 규율이 유난히 +> 강하다 — 본문은 그 결정을 검증(`Objects.requireNonNull`, 범위 검사, CAS)하는 코드다. 그래서 +> §23은 "무엇을 결정했는가"까지 쓰고, 알고리즘 세부는 정독한 것만 인용했다. + +--- + +## 0. 숫자로 먼저 보는 지도 + +무작정 파일을 열기 전에 규모부터 쟀다. 규모를 모르면 "다 읽었다"는 말을 할 수 없다. + +| 항목 | 값 | +|---|---:| +| production Java 파일 | 408 | +| production Java LOC | 26,303 | +| production Java bytes | 1,000,564 (≈ 977 KB) | +| public top-level 타입 (스냅샷) | 408 | +| 최상위 production 패키지 | 22 (root 포함) | +| `advanced/` 하위 패키지 | 17 | +| 프레임워크(Spring/graphql-java/Reactor/Micrometer/servlet) import 하는 production 파일 | 33 (8.1%) | +| test Java 파일 / LOC | 101 / 13,671 | +| `@Test` + `@ParameterizedTest` | 762 | +| testFixtures 파일 / LOC | 16 / 1,074 | +| main 리소스 | `graphql/skeleton.graphqls`, `META-INF/spring.factories`, `AutoConfiguration.imports` | +| 스키마가 선언하는 필드 | **1개** (`Query._health`) | + +마지막 줄이 이 모듈 전체를 이해하는 열쇠다. **977KB의 코드가 필드 한 개짜리 스키마를 지킨다.** +이건 낭비가 아니라 이 leaf의 정체다 — 여기 있는 건 API가 아니라 **API를 실행하는 플랫폼**이고, +API는 이 leaf를 채택하는 쪽이 가져온다. + +패키지별 규모: + +| 패키지 | 파일 | bytes | 역할 | +|---|---:|---:|---| +| `runtime` | 19+1 | 83,085 | 프레임워크 seam — 정책이 실제 요청을 만나는 곳 | +| `autoconfigure` | 13 | 77,897 | 마스터 스위치, 설정 계약, 기동 검증 | +| `compat` | 11 | 62,476 | 스키마 호환성 분류(37종) | +| `cost` | 21 | 56,114 | 파서 한계 → 문서 형태 → 복잡도 → 런타임 예산 | +| `http` | 19 | 48,894 | GraphQL over HTTP 프로파일 | +| `execution` | 22 | 44,925 | 파이프라인 단계, preparsed 캐시, 데드라인 | +| `schema` | 19 | 42,627 | SDL 조립, `@oneOf`, 스칼라 매니페스트 | +| `architecture` | 9 | 38,128 | resolver 경계 검사 | +| `pagination` | 17 | 37,370 | 서명 keyset 커서 | +| `security` | 15 | 32,931 | 인증/인가/테넌트 | +| `scalar` | 7 | 32,376 | 커스텀 스칼라 6종 | +| `dataloader` | 17 | 31,125 | 배치/N+1 | +| `mutation` | 14 | 25,748 | 멱등성, 비즈니스 결과 | +| `observation` | 9 | 22,509 | 태그 카디널리티 | +| `error` | 10 | 22,091 | 에러 계약 | +| `moduleboundary` | 4 | 21,118 | 모듈 정체성·purity·edge | +| `context` | 6 | 20,501 | 요청 컨텍스트, 지문 | +| `release` | 9 | 18,224 | 릴리스 게이트 | +| `policy` | 9 | 17,227 | 클라이언트 프로파일, 오퍼레이션 카탈로그 | +| `fetch` | 10 | 15,590 | fetch 프로파일 | +| `api` | 5 | 8,059 | 경계 있는 식별자 | +| `advanced/*` | 135 | 232,984 | 17개 옵트인 capability | + +--- + +## 1. 이 leaf의 정체 — "플랫폼을 leaf 하나에 담는다"는 선택 + +### 1.1 레지스트리 레코드 + +`src/config/architecture/modules.json`이 이 leaf에 대해 말하는 전부는 이것이다: + +```json +{ + "id": "adapter-inbound-graphql", + "gradle_path": ":adapter:inbound:graphql", + "source_path": "src/adapter/inbound/graphql", + "allowed_dependencies": ["domain-core", "application-core", "shared-contract"], + "runtime_memberships": ["app-bootstrap"] +} +``` + +네 줄이 네 가지를 못 박는다. + +1. **outbound 어댑터에 대한 의존이 없다.** 인바운드는 application 아웃바운드 포트를 통해서만 + persistence/messaging/cache/http에 닿는다. Gradle 게이트 + (`verifyCleanArchitectureDependencies`)와 ArchUnit + `INBOUND_ADAPTERS_DO_NOT_DEPEND_ON_OUTBOUND_ADAPTERS`가 둘 다 지킨다. +2. **`application-core`가 선언만이 아니라 실제 의존이다.** object 인가의 *답하는* 계약 + `dev.caskeleton.application.security.ObjectAccessPolicy`가 거기 살기 때문이다. 이 leaf가 그 + 계약을 소유했다면 application 구현체가 인바운드 전송을 컴파일 의존해야 했다 — 방향이 뒤집힌다. + 이게 ADR-GQL-001의 세 결정 중 하나고, §11.4에서 다시 다룬다. +3. **`shared-contract`가 있다.** persisted operation의 durable 저장을 위한 중립 계약 + `dev.caskeleton.shared.opstore.OperationalRecordStorePort`가 거기 있다 (§23.2). +4. **`runtime_memberships: ["app-bootstrap"]`** — 이 leaf는 **app-bootstrap 런타임에 들어간다.** + 이 한 줄이 모듈 `CLAUDE.md`/`README.md`의 "classpath opt-in, 현재 app-bootstrap은 이 leaf를 + 의존하지 않는다"는 문장과 정면으로 충돌한다. 코드가 맞다 (§25, §28-P1). + +### 1.2 "28개 모듈 → 22개 패키지" — 이건 미결 아키텍처 결정이다 + +GraphQL 플랫폼 설계서는 Stable 16개 + Advanced 12개, 총 28개 "모듈"을 말한다. 이 구현은 그것을 +**레지스트리 leaf가 아니라 이 leaf 안의 bounded sub-package**로 매핑했다. 실제로는 +`moduleboundary`가 Stable 23개 + Advanced 17개 = **40개 모듈**을 선언하고 있으니, 설계서의 +28개보다 더 잘게 쪼개졌다. + +모듈 `CLAUDE.md`가 이 선택을 방어하는 방식이 훌륭하다. **"레지스트리가 닫혀 있어서가 아니다"**라고 +먼저 못 박는다. 같은 레포의 자매 플랫폼인 messaging은 **정반대 선택**을 해서 자기 leaf들을 +레지스트리에 개별 등록했다. 즉 이 레포에는 두 패턴이 공존한다: + +| | 방식 | 경계 강제 | +|---|---|---| +| messaging | 레지스트리에 leaf 등록 | Gradle 의존 게이트 | +| graphql | 단일 leaf 내 sub-package | 소스 스캔 경계 테스트 (§2) | +| httpclient | 단일 leaf 내 sub-package (366 파일) | 동일 패턴 | + +어느 쪽으로 통일할지는 **미결**이다. 그리고 이 문서가 그걸 그대로 쓸 수 있다는 게 좋은 신호다 — +정직하게 "아직 안 정했다"고 쓰인 문서는 드물다. + +### 1.3 이 leaf가 명시적으로 아닌 것 + +- **feature GraphQL API가 아니다.** 스키마는 `_health: String!` 하나뿐이고, 이건 web 어댑터의 + `HealthcheckController`와 같은 원칙이다. feature 스키마/컨트롤러/매퍼는 채택하는 쪽이 소유한다. +- **서버를 고르지 않는다.** `spring-boot-starter-web`은 **test scope에만** 있다. 예전에는 + production `implementation`이어서 모든 adopter의 runtimeClasspath에 Tomcat을 올리면서 동시에 + 같은 artifact가 `REACTIVE_WEBFLUX` 프로파일을 표방했다 — 성립할 수 없는 조합이었다. + 이제 `GraphQlRuntimeTransport`가 실제 실행 중인 서버를 감지해 설정과 어긋나면 **부팅을 거부**한다. +- **인증을 구현하지 않는다.** `GraphQlPrincipalResolver`는 인터페이스다. javadoc이 이유를 쓴다: + *"어느 provider인지, 어느 claim인지, 어느 tenant claim을 믿는지는 composition root의 관심사고, + 자기 인증을 들고 오는 인바운드 어댑터는 대부분의 adopter에게 틀리거나, 세상 모든 스킴을 위한 + 설정 표면을 키워야 한다."* +- **모듈별 `yml`이 없다.** 설정은 composition root의 `application.yml`에 산다. + +--- + +## 2. `moduleboundary` — 레지스트리 없이 모듈 경계를 기계로 지키기 + +4개 파일 21KB. 이 leaf에서 제일 먼저 읽어야 하는 패키지다. §1.2의 "sub-package로 나눈다"는 선택이 +문서가 아니라 **기계**로 지켜지는지가 여기서 결정된다. + +### 2.1 패키지 이름이 `build`가 아니라 `moduleboundary`인 이유 + +이 이름은 사고의 흔적이다. 원래 이 모델은 `...graphql.build` 패키지에 있었고, `src/.gitignore:2`의 +**anchor 없는 `build/` 규칙**이 Gradle 산출물과 Java 패키지를 구분하지 못해서 경계 모델 전체를 +커밋에서 삼켰다. 결과가 지독하다: + +- production 코드는 계속 그 타입들을 import 했다. +- 작성자 워킹 트리에서는 컴파일됐다. +- fresh checkout은 **7개 오류로 깨졌다.** +- 그리고 경계 테스트가 통째로 사라졌는데 레인은 여전히 green이었다. + +지금은 두 겹으로 막는다: 레포 전역 `verifyNoIgnoredSourcePackages`가 "ignore된 소스 패키지" 자체를 +막고, `graphqlStableTest` 레인의 **required-class 검사**가 "경계 테스트만 조용히 사라지고 레인은 +green"인 나머지 절반을 막는다 (§24.2). + +이건 이 레포 전체를 관통하는 원칙의 첫 사례다 — **없어진 검사와 통과한 검사를 구분할 수 없으면 +그건 검사가 아니다.** + +### 2.2 `GraphQlStableModule` / `GraphQlAdvancedModule` — 모듈을 값으로 선언한다 + +enum 상수 하나가 모듈 하나다. 각 상수는 `(id, packageSuffix, purity, allowedDependencies...)`를 +들고, Advanced 쪽은 compact 생성자가 purity를 `CORE`로 기본값 처리한다. + +```java +// Stable +HTTP("http", "http", GraphQlModulePurity.CORE, "api", "context", "execution", "policy"), +SCALAR("scalar", "scalar", GraphQlModulePurity.FRAMEWORK_BOUND, "schema"), + +// Advanced — purity 인자가 없으면 CORE +SUBSCRIPTION( + "advanced.subscription", "advanced.subscription", + "advanced.security", "api", "execution", "http", "security"), +``` + +`purity`는 두 등급이고, `GraphQlModulePurity`의 javadoc이 왜 나누는지 쓴다: + +> *A `CORE` module holds the decision ("this document is too deep", "this cursor is out of scope") +> as plain Java, so the same rule can be exercised by a unit test, reused from a different +> transport, or promoted to its own leaf without dragging a servlet container along. A +> `FRAMEWORK_BOUND` module is the seam where that decision meets Spring, GraphQL Java or Reactor.* + +**"자기 leaf로 승격할 수 있게"**가 §1.2의 미결 결정과 이어진다. + +| 등급 | 의미 | Stable | Advanced | +|---|---|---:|---:| +| `CORE` | JDK만. Spring / graphql-java / Reactor / Micrometer / Jakarta import 금지 | 15 | 16 | +| `FRAMEWORK_BOUND` | 프레임워크 타입을 바인딩해도 되는 seam | 8 | 1 | + +Stable의 `FRAMEWORK_BOUND` 8개: `TRANSPORT_ROOT`, `ARCHITECTURE`, `AUTOCONFIGURE`, `COMPAT`, +`COST`, `RUNTIME`, `SCALAR`, `SCHEMA`. Advanced는 `CODEGEN` 하나뿐이다 — graphql-java로 스키마를 +컴파일해서 operation document를 검증해야 하기 때문이다 (§23.11). + +`HTTP`가 `CORE`인 게 기록으로 남아 있다: *"`CORE` since the custom MVC and WebFlux transport +adapters were removed: what remains [is pure policy]."* — 즉 **purity 등급이 GQL-004 수정의 +결과를 그대로 반영한다.** + +### 2.3 `GraphQlModuleBoundary` — longest-prefix ownership + +"이 패키지의 주인은 누구인가"를 답한다. **가장 긴 prefix가 이긴다.** 그래서 +`advanced.subscription`은 `ADVANCED_*` 중 `SUBSCRIPTION`이 갖고, 루트 패키지는 오직 자기 자신만 +소유한다 — 이게 중요하다. 루트가 하위 전부를 소유해 버리면 "등록되지 않은 패키지" 규칙이 +영원히 발화하지 않는다. + +### 2.4 다섯 개 규칙, 각각 negative fixture와 함께 + +`GraphQlModuleBoundaryTest`가 `GraphQlBuildModel`(테스트 쪽, 실제 소스 트리 스캐너)로 강제한다: + +| # | 규칙 | +|---|---| +| (a) | Stable 패키지가 `...graphql.advanced`를 import 금지 | +| (b) | `CORE` 등급 모듈의 Spring/graphql-java/Reactor/Micrometer/Jakarta import 금지 | +| (c) | 선언되지 않은 cross-module edge 금지 | +| (d) | 미등록 패키지 금지 | +| (e) | 선언만 있고 소스가 없는 모듈 금지 | + +**각 규칙이 "거부되는 합성 트리(negative fixture)"를 함께 가진다.** (e)가 특히 좋다 — 모듈 선언은 +남아 있는데 소스가 지워지면 (a)~(d)는 전부 통과한다. 검사할 대상이 없으니까. (e)가 없으면 +"모듈을 지웠는데 규칙이 계속 green"인 상태가 된다. + +그리고 스캐너는 **main과 testFixtures를 함께 스캔한다.** `CLAUDE.md`가 이유를 쓴다: *"아티팩트가 +갈렸다고 패키지 경계 규칙까지 갈리면, 규칙이 조용히 절반만 남는다."* + +### 2.5 이 접근의 한계 — 소스 텍스트 스캔이다 + +바이트코드 분석이 아니라 **소스 텍스트를 읽어서 import 문을 본다.** 그래서: + +- **잡을 수 있는 것**: `import dev.caskeleton...advanced.X;` +- **못 잡는 것**: fully-qualified 이름을 본문에 직접 쓴 경우, 리플렉션, 문자열 클래스 이름. + +이건 트레이드오프고, 이 leaf에서는 합리적이다 — 여기 있는 건 대부분 순수 값 객체와 정책이고, +동적 참조를 쓸 이유가 거의 없다. 다만 §28에 후속으로 적어 둔다. + +--- + +## 3. 마스터 스위치 — "꺼져 있다"를 구조로 만들기 + +이 leaf에서 가장 배울 게 많은 부분이다. 3개 파일 + 2개 리소스로 이루어진다. + +### 3.1 문제: Spring GraphQL은 프로젝트 빈 없이도 `/graphql`을 연다 + +일반적인 Spring Boot 조건부 어댑터는 이렇게 쓴다: + +```java +@AutoConfiguration +@ConditionalOnProperty(prefix = "backend.graphql", name = "enabled", havingValue = "true") +class MyGraphQlConfig { ... } +``` + +**이걸로는 부족하다.** `spring-boot-starter-graphql`이 자기 auto-configuration을 Boot의 import +메타데이터로 기여하기 때문에, 평범한 `@EnableAutoConfiguration` 애플리케이션은 **classpath만으로** +`/graphql`을 발행한다. 프로젝트 빈은 하나도 없이. 즉 "우리 빈이 없다"와 "엔드포인트가 없다"는 +전혀 다른 이야기다. + +이 leaf의 CLAUDE.md가 이 상황을 정확히 요약한다: 예전에는 이 문제 때문에 **leaf를 아예 shipped +jar에서 빼 두었고, 그래서 "꺼짐"과 "빌드 안 됨"이 같은 뜻이 되어 있었다.** + +### 3.2 해법: 4겹 + +**(1) `META-INF/spring/...AutoConfiguration.imports`** — 진입점은 딱 하나. + +``` +dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlRootAutoConfiguration +``` + +**(2) `GraphQlRootAutoConfiguration`** — 마스터 조건을 가진 유일한 클래스. + +```java +@AutoConfiguration +@ConditionalOnProperty(prefix = "backend.graphql", name = "enabled", havingValue = "true") +@EnableConfigurationProperties(GraphQlPlatformSettings.class) +@Import({ + GraphQlPlatformAutoConfiguration.class, + dev.caskeleton.adapter.inbound.graphql.HealthGraphqlController.class +}) +public class GraphQlRootAutoConfiguration {} +``` + +두 번째 import에 붙은 주석이 GQL-INT-003 결함의 기록이다 — §25.3에서 다룬다. + +**(3) `META-INF/spring.factories` + `GraphQlOffAutoConfigurationImportFilter`** — 프레임워크의 +auto-configuration 후보 자체를 걷어낸다. 10개를 명시적으로 막는다: + +```java +"org.springframework.boot.graphql.autoconfigure.GraphQlAutoConfiguration", +"...observation.GraphQlObservationAutoConfiguration", +"...data.GraphQlQuerydslAutoConfiguration", +"...data.GraphQlReactiveQuerydslAutoConfiguration", +"...rsocket.GraphQlRSocketAutoConfiguration", +"...rsocket.RSocketGraphQlClientAutoConfiguration", +"...reactive.GraphQlWebFluxAutoConfiguration", +"...reactive.GraphQlWebFluxSecurityAutoConfiguration", +"...servlet.GraphQlWebMvcAutoConfiguration", +"...servlet.GraphQlWebMvcSecurityAutoConfiguration" +``` + +이 필터의 javadoc에 있는 문장이 이 패턴 전체의 핵심이다: + +> *A misspelled entry fails open silently — the filter simply never matches — so the test that +> protects this asserts a 404 on the real port rather than checking what this method returns.* + +문자열 리스트로 하는 방어는 **오타가 나면 조용히 열린다.** 그래서 이 리스트를 검증하는 테스트는 +`match()`의 반환값이 아니라 **실제 포트에서 404가 나오는지**를 본다. 방어 수단을 테스트하지 말고 +방어된 상태를 테스트하라는 것. + +**(4) `GraphQlActivationEnvironmentPostProcessor`** — 빈이 생기기 전에 끝나야 하는 일. + +두 가지를 한다. + +- **은퇴한 안전 키를 거부한다.** `backend.graphql.production`과 `backend.graphql.environment`는 + 더 이상 record 컴포넌트가 아니고, Spring 바인더는 모르는 키를 **무시**한다. 그대로 두면 + "늘 설정하던 키를 설정한 운영자가 깨끗한 기동과 조용히 달라진 안전 태세를 얻는" 상황이 된다. + `GraphQlRetiredSafetyAxis`의 javadoc이 그걸 이렇게 표현한다: *"split-brain이 고쳐진 것보다 + 나쁜 결과다 — 옛 설정은 적어도 뭔가를 하기는 했으니까."* +- **프레임워크 콘솔 플래그에 플랫폼 기본값을 준다.** Spring Boot는 introspection을 기본 허용하고 + 이 플랫폼은 기본 거부한다. 둘 다 각자 합당한데, 합쳐 놓으면 **아무도 설정하지 않은 모순으로 + 기동이 실패한다.** 플랫폼 값을 **가장 낮은 우선순위**로 기여해서, 운영자가 설정하면 여전히 + 운영자가 이기고 여전히 검증된다. 없앤 건 "아무것도 설정하지 않았을 때의 불일치"뿐이다. + +`getOrder()`는 `LOWEST_PRECEDENCE`다. 이유가 정확하다 — 더 일찍 돌면 `application-prod.yml`의 +키를 "없음"으로 읽는데, 그건 **가장 중요한 배포에 대해 검사하지 않는 것과 같다.** + +### 3.3 이 패턴의 일반형 + +정리하면 인바운드 어댑터의 "구조적 off"는 이렇게 생겼다: + +| 층 | 무엇을 막나 | 실패 모드 | +|---|---|---| +| `AutoConfiguration.imports` 진입점 1개 | 우리 빈 | 명시적 | +| `@ConditionalOnProperty` 마스터 | 우리 빈 | 명시적 | +| `AutoConfigurationImportFilter` | **프레임워크가 스스로 여는 라우트** | **오타 시 조용히 열림** | +| `EnvironmentPostProcessor` | 빈 이전에 결정돼야 할 것 | 명시적 | +| 실제 포트 404 테스트 | 위 전부 | — | + +세 번째 줄이 대부분의 프로젝트가 빠뜨리는 것이고, 다섯 번째 줄이 세 번째 줄을 믿을 수 있게 만드는 +유일한 것이다. + +--- + +## 4. `autoconfigure` — 설정이 하나의 축이 되기까지 + +13개 파일 78KB. `runtime` 다음으로 큰 패키지고, 대부분이 **기동 시점 검증**이다. + +### 4.1 `GraphQlDeploymentMode` — 두 축을 하나로 합친 사건 (GQL-INT-002) + +이 enum의 javadoc은 이 레포에서 가장 잘 쓰인 설계 기록 중 하나다. + +**예전 상태**: 안전 태세가 두 설정이었다. +- `production` boolean → 익명 principal 처리, allow-by-default 인가, 요청 보호 일부 +- `environment` enum → introspection, GraphiQL + +**같은 record 안에서** 각각 `false`와 `PRODUCTION_PUBLIC`으로 기본값을 잡고 있었다. 그래서 +**출하 기본값이 "인터넷에 노출된 프로덕션 엔드포인트인데 보호는 노트북처럼 동작하는" 상태**를 +서술했다. javadoc의 결론: + +> *Two axes that can disagree about the same question are not two settings; they are one setting +> and a bug.* + +지금은 축이 하나다: + +| 모드 | introspection | GraphiQL | production 규칙 | +|---|---|---|---| +| `LOCAL` | ✅ | ✅ | ❌ | +| `DEV` | ✅ | ✅ | ❌ | +| `PRODUCTION_INTERNAL` | ✅ | ❌ | ✅ | +| `PRODUCTION_PUBLIC` | ❌ | ❌ | ✅ | + +**기본값도 없고 관대한 파싱도 없다.** 설정 안 하면 기동 에러다. 이유가 좋다 — *"development로 +추측하면 인가 없는 엔드포인트가 되고, production으로 추측하면 운영자가 설명할 수 없는 장애가 +된다."* + +그리고 `TEST`/`STAGING` 상수를 지운 기록이 있다. **그 둘에 도달할 수 있는 런타임 프로파일이 +없었다** — 아무도 배포할 수 없고 아무도 테스트할 수 없는 태세. 지금은 +`GraphQlDeploymentModeRegistryParityTest`(app-bootstrap 쪽)가 이 목록과 운영자용 레지스트리가 +다시 어긋나지 않게 지킨다. + +### 4.2 `GraphQlRetiredSafetyAxis` — 은퇴한 키를 무시하지 않고 거부한다 + +Spring 바인더는 모르는 키를 조용히 무시한다. 그래서 `backend.graphql.production`을 여전히 설정한 +운영자는 **깨끗한 기동 + 조용히 달라진 안전 태세**를 얻는다. 이 클래스는 그 두 키가 environment에 +있으면 **`EnvironmentPostProcessor`에서 즉시 던진다.** + +설정 키를 지울 때의 일반 규칙: **지운 키는 무시가 아니라 거부여야 한다.** + +### 4.3 `GraphQlPlatformStartupValidator` — 두 종류의 검증 + +**(1) `problems(settings)` — 설정 값 검증.** 첫 검사가 특별하다: + +```java +// First and alone. Every check below asks the mode a question, so reporting them against an +// absent mode would bury the one problem an operator can act on under its own consequences. +if (properties.deploymentMode() == null) { + return List.of(GraphQlDeploymentMode.REQUIRED_MESSAGE); +} +``` + +모드가 없으면 **그 하나만** 보고한다. 나머지 검사가 전부 모드에 질문하기 때문에, 모드 없이 +돌리면 "운영자가 조치할 수 있는 문제 하나"가 "그 문제의 결과 열 개" 밑에 묻힌다. 나머지는 전부 +모아서 한 번에 던진다 — *"한 번에 하나씩 고치며 재시작하는 건 롤아웃이 멈추는 방식이다."* + +검사 목록: + +| 검사 | 왜 | +|---|---| +| GraphiQL이 모드에서 허용되나 | 공개 쿼리 콘솔 | +| production인데 cursor 서명 키가 없나 | **서명 없는 커서는 client-editable** | +| introspection이 모드에서 허용되나 | 익명 호출자에게 전체 타입 시스템 | +| maximumPageSize ≥ 1 | 0이면 cost 예산에 곱할 승수가 없음 | +| maximumComplexity ≥ 1 | 동일 | +| `unsupported.*` 6개 플래그 | multipart upload / HTTP array batch / request-wide transaction / repository auto-exposure / response cache / Stable starter가 Advanced 활성화 | +| REACTIVE_WEBFLUX인데 bridge 없는 blocking resolver | 이벤트 루프 starvation | + +`unsupported.*`가 재밌다. **"지원하지 않는다"를 설정 키로 만들어 두고, 켜면 기동을 거부한다.** +문서에 "지원 안 함"이라고 쓰는 대신 코드가 거부하고, 거부 메시지가 대안을 알려준다: +*"use the Fileserver upload reservation"*, *"use one mutation use case"*. + +**(2) `validateRuntime(runtime)` — 실제 조립된 빈 검증.** javadoc이 핵심을 말한다: + +> *Validating `GraphQlExecutionPipeline.stable()` would prove the platform's own default is well +> formed and say nothing about the pipeline an adopter actually replaced it with — which is the +> only one that will serve requests.* + +플랫폼 빈은 전부 `@ConditionalOnMissingBean`이다. 그게 요점이자 위험이다. adopter가 +authorization이 execution 뒤에 오는 파이프라인, 코어션 없는 스칼라를 선언한 매니페스트, +운영자가 설정한 것보다 높은 page 상한을 가진 client policy를 넣을 수 있다. **셋 다 wiring 시점에 +실패하지 않는다. 요청 시점에, 프로덕션에서, 조용히 실패한다.** + +그래서 `GraphQlPlatformRuntime`이 "컨텍스트가 실제로 조립한 것"을 한 덩어리로 모으고, +`runtimeProblems`가 그걸 검증한다: + +- 파이프라인 단계 순서 (§8.2) +- 선언된 execution profile과 **실제 실행 중인 서버**의 일치 (`GraphQlRuntimeTransport`) +- reactive transport인데 bridge 없는 blocking resolver — **선언된 프로파일과 무관하게** 검사한다. + `MIXED_CONTROLLED`가 reactive에서 허용되는 건 crossing이 *선언되어* 있기 때문이고, 선언 안 된 + crossing이 있으면 그 프로파일 자체가 거짓말이 된다. +- 스칼라 매니페스트가 실제로 wiring 가능한가 +- client policy의 `maxPageSize` ≤ 운영자 설정 `maximumPageSize` + +### 4.4 `GraphQlPlatformSettings` — `@DefaultValue`가 없으면 0이 기본값이다 + +javadoc이 기록하는 버그: + +> *Every default is declared with `@DefaultValue`, which is the only kind of default the binder +> actually applies. A primitive with no annotation binds to zero, and this record's zeros — a page +> size of nothing, a complexity budget of nothing — are exactly the values the startup validator +> refuses, so the platform used to refuse to start until an operator supplied two numbers that have +> perfectly good defaults.* + +`@ConfigurationProperties` record에서 `int maximumPageSize`를 `@DefaultValue` 없이 쓰면 0이 된다. +그리고 §4.3의 validator가 0을 거부한다. 결과: **완벽하게 좋은 기본값이 있는 두 숫자를 운영자가 +직접 넣기 전까지 플랫폼이 기동을 거부했다.** + +`prefix`는 `backend.graphql`이다. build.gradle 주석이 정직하게 적는다: *"this comment used to say +`spring.graphql.platform.*`, which never existed."* + +### 4.5 `GraphQlPlatformActuatorEndpoint` / `GraphQlPlatformConfigurationReport` + +운영 조회면. **의도적으로 `@Endpoint`가 아니라 평범한 컴포넌트다** — 이 leaf는 actuator에 의존하지 +않고, 운영 평면을 노출할지와 어느 인가 뒤에 둘지는 composition root의 결정이다. + +리포트가 담는 것: 스키마 해시, execution profile, cursor **key id**(키 아님), capability 이름, +경계 있는 카운트. 담지 않는 것: SDL, persisted document, 키. 이유가 정확하다 — *"운영 엔드포인트는 +배포 파이프라인보다 많은 사람이 닿을 수 있고, 스키마는 introspection을 끈 이유 그 자체다."* + +### 4.6 `GraphQlPlatformAutoConfiguration` — Advanced는 여기로 못 들어온다 + +두 가지가 명시적이다. + +1. **Stable만 조립한다.** Advanced가 Stable starter를 통해 들어오는 건 모듈 경계가 막으려는 + 사고 그 자체다. +2. **Boot의 GraphQL auto-configuration 뒤에 순서가 잡힌다.** 앞이면 프레임워크가 나중에 덮어쓴다. + +그리고 production에서 authorization policy 빈이 없으면 **기동을 거부한다**: + +```java +if (properties.production()) { + throw new GraphQlPlatformConfigurationException(List.of( + "backend.graphql.deployment-mode=" + properties.deploymentMode() + + " requires an explicit GraphQlAuthorizationPolicy bean; ...")); +} +``` + +기본 인가 정책을 제공하지 않는다. **제공했다면 그게 "아무도 리뷰하지 않은 기본 인가"가 된다.** + +--- + +## 5. `api` / `policy` — 이름을 타입으로, 한계를 값으로 + +### 5.1 `api` — 다섯 개의 경계 있는 식별자 + +5개 파일 8KB. 전부 record 또는 검증된 문자열 래퍼다. + +| 타입 | 규칙 | 왜 경계가 필요한가 | +|---|---|---| +| `GraphQlClientProfile` | 짧은 정적 식별자 | 정책을 고르고 **메트릭 라벨이 된다** | +| `GraphQlClientProfileName` | 대문자 짧은 식별자 (닫힌 enum 아님) | `PUBLIC`/`PARTNER`/`FIRST_PARTY`/`ADMIN`/`INTROSPECTION` + 배포별 추가 | +| `GraphQlOperationId` | 등록된 오퍼레이션의 안정 핸들 | 레지스트리 키 + 메트릭 라벨. **요청 식별자가 아니다** | +| `GraphQlOperationName` | 저카디널리티 오퍼레이션 이름 | 익명 오퍼레이션은 **이름의 부재**로 표현. 빈 문자열이나 placeholder 아님 | +| `GraphQlSchemaCoordinate` | `TypeName.fieldName` | 경로 구분자/공백/UUID-like 거부 — **요청별 값이 라벨에 새면 안 됨** | + +관통하는 규칙 하나: **이 다섯 개는 전부 메트릭 라벨이 될 수 있다. 그래서 형태가 타입에서 +강제된다.** 각 소비자가 기억할 일이 아니다. + +`GraphQlClientProfile`의 javadoc이 두 번째 이유를 덧붙인다 — 동적 값이면 **호출자가 자기 정책을 +고를 수 있다.** 프로파일이 cost/page/introspection 한계를 선택하니까. + +### 5.2 `policy` — 벤치마크된 숫자를 코드 밖으로 + +9개 파일 17KB. + +**`GraphQlClientPolicy`** — 한 클라이언트 프로파일이 측정되는 **모든 숫자**가 검증된 값 하나에 +들어간다. 테스트 fixture에서 실제 인스턴스를 보면 15개 필드다: + +```java +new GraphQlClientPolicy( + 65536, // maxDocumentBytes + 65536, // maxVariablesBytes + 12, // maxDepth + 500, // ... + 50, 50, 1000, 20, 100, 10_000, 10_000, 5_242_880, + Duration.ofSeconds(5), // maxExecutionTime + false, false, true); +``` + +javadoc의 핵심: 정책은 **문서/변수/actor/tenant/자격증명을 절대 담지 않는다.** 전부 경계 있는 +카운트와 duration이다. 그래서 환경 매니페스트로 출하할 수 있고, 벤치마크할 수 있다. + +**`GraphQlClientPolicyManifest`** — 프로파일이 해석되는 **단 하나의 장소**. 모르는 프로파일은 +기동/요청 실패지 관대한 기본값으로의 조용한 fallback이 아니다. 중복 프로파일은 생성 시점 거부 — +한 클라이언트에 대한 두 개의 모순된 정책은 결정적으로 해소할 수 없다. + +**`GraphQlOperationCatalog` / `GraphQlOperationPolicy`** — 프로덕션에서 실행할 오퍼레이션의 +레지스트리. 모든 프로덕션 오퍼레이션은 **이름과 스키마 좌표를 둘 다** 가져야 한다. 이름은 +tracing/cost 예외/persisted 레지스트리/사용량 분석이 키로 쓰고, 좌표는 cost 카탈로그/fetch +프로파일/필드 인가가 키로 쓴다. + +`GraphQlOperationPolicy`가 런타임 대신 여기서 강제하는 두 불변식: +- `STREAM` resolver는 subscription에서만 말이 된다. +- idempotency나 expected version은 mutation에서만 의미가 있다 — query에 선언하면 **읽기 경로가 + 구현하지 않는 보장을 약속하는 것**이다. + +**`GraphQlPolicyViolation`** — 초과된 한계 하나를 보고한다. **한계 이름과 두 숫자만.** 문서도, +변수도, 트리거한 값도 아니다. 그래서 거부를 클라이언트에게 설명하면서 동시에 메트릭으로 셀 수 있다. + +**`ResolverExecutionType`** — resolver마다 "실제로 어떻게 실행되는가"를 등록한다. 런타임 프로파일이 +서빙할 수 없는 조합(WebFlux 이벤트 루프 위의 blocking repository 호출, subscription 밖의 stream)을 +**부하 중이 아니라 기동 시점에** 거부하게 해 준다. + +--- + +## 6. `context` — 요청 정체성과 지문 + +6개 파일 20KB. 작지만 이 leaf에서 가장 미묘한 패키지다. + +### 6.1 `GraphQlRequestContext` — 불변 요청 컨텍스트 + +한 값이 실행 파이프라인·resolver·DataLoader 배치·Application 계층이 합의해야 할 전부를 나른다: +누가 호출하는가, 어느 tenant인가, 어느 client policy 하에서, 어느 locale로, 어느 오퍼레이션에 대해, +어느 trace로 상관되며, 언제까지인가. + +**불변인 이유**가 명시적이다 — virtual-thread executor와 Reactor context에 넘겨도 안전해야 한다. + +### 6.2 `TenantContext` — tenant는 **인자에서 오지 않는다** + +tenant는 보안 경계다. 그래서 검증된 인증 데이터에서만 해석되고 GraphQL 인자/변수에서는 절대 +오지 않는다. 구현이 이걸 **타입으로** 만든다: + +> *That is why there is no factory for an argument-derived tenant: the only ways to build this type +> are the trusted sources below, and `TenantSource` records which one was used.* + +인자에서 만드는 팩토리가 **존재하지 않는다.** 그리고 `TenantSource`가 어느 신뢰 경로로 왔는지를 +기록하므로, `GraphQlTenantIsolationPolicy`가 그걸 검사할 수 있다 (§11.3). + +### 6.3 `ActorRef` — actor는 있고 토큰은 없다 + +플랫폼은 인가/멱등성 스코프/감사를 위해 actor 정체성이 필요하지만, 에러 계약과 관측 계약은 +**raw user identifier가 응답이나 메트릭 라벨에 도달하는 것을 둘 다 금지한다.** 그래서 컨텍스트는 +이 불투명 참조를 나르고 access token/cookie/raw provider claim은 절대 나르지 않는다. 밖으로 +공유되는 건 keyed fingerprint다. + +### 6.4 `GraphQlIdentityFingerprinter` — 저엔트로피 입력에 대한 다이제스트는 보호가 아니다 + +이 클래스가 이 패키지에서 제일 날카롭다. + +> *A plain digest is not that value. An actor reference and a tenant name are low-entropy: they +> come from a bounded set a reader can enumerate — `tenant-a`, `acme`, a numeric user id, an email +> address — and a digest of a guessable input is recovered by digesting the guesses.* + +즉 `SHA-256(tenant_name)`은 익명화가 아니다. tenant 이름 후보가 수천 개뿐이면 전부 해싱해서 +역매핑 테이블을 만들면 끝이다. 그래서 **keyed** fingerprint를 쓰고, 키 회전을 지원한다 +(`keyIds()` 메서드가 있고 `GraphQlIdentityFingerprinterTest`가 rotation을 고정한다). + +멱등성 레코드가 그런 다이제스트로 키잉되어 있으면, 그 레코드를 가진 누구에게든 **여전히 호출자를 +지목한다.** + +### 6.5 `GraphQlCommandAttribution` — anti-corruption 경계 + +**이 문서에서 가장 중요한 타입 중 하나다.** 방향이 요점이다. + +`GraphQlRequestContext`는 인바운드 전송 타입이다 — client profile, operation id, GraphQL locale을 +안다. 이걸 use case에 넘기면 `application-core`가, 그리고 그 use case가 닿는 모든 persistence / +HTTP client 어댑터가 **GraphQL 전송에 컴파일 의존하게 된다.** + +그래서 `GraphQlCommandAttribution`이 컨텍스트를 "application command가 나를 수 있는 값"으로 +줄인다 — actor, tenant, deadline. 명시적 매핑이고, 타입이 넘어가지 않는다. + +ADR-GQL-001의 첫 번째 결정이 이거고, 경계 테스트는 **"이 leaf 밖에서 `GraphQlRequestContext`를 +grep하면 아무것도 안 나온다"**이다. 그 테스트는 app-bootstrap에 있다 (§25.2) — leaf 안에 두면 +"leaf가 자기 자신을 위반하지 않는다"만 증명하기 때문이다. + +### 6.6 `GraphQlDeadline` — duration이 아니라 instant + +같은 예산을 resolver, DataLoader 배치, JPA/Mongo/HTTP client deadline에 넘겨야 하는데 **각 계층이 +시계를 다시 시작하면 안 된다.** 그래서 절대 시각이고, 남은 시간은 항상 주입된 `Clock`에서 계산한다. + +--- + +## 7. `http` — GraphQL over HTTP 프로파일 + +19개 파일 49KB. + +### 7.1 `GraphQlHttpProfile` — 움직이는 draft를 버전으로 고정한다 + +GraphQL over HTTP는 Stage 2 Draft고, status code 절이 아직 움직인다. draft는 부분 응답 +(`data + errors`)에 **294**를 제안하고 Spring for GraphQL은 **200**을 반환한다. + +해법: 명시적으로 버전이 붙은 프로파일에 플랫폼을 고정한다. `V1`이 현재 배포 계약이고, draft가 +바뀌어도 **클라이언트 밑에서 계약이 바뀌지 않는다.** + +### 7.2 `GraphQlHttpOutcome` / `GraphQlHttpStatusMapper` — 실행이 시작됐는가 + +에러 계약 전체가 이 한 줄 위에 서 있다: + +> *The dividing line is whether execution started.* + +| 언제 실패했나 | 분류 | HTTP | +|---|---|---| +| 실행 전 (malformed JSON, parse, validation, variable coercion, transport 규칙) | request error | 4xx | +| 실행 중 | field error | **200 + 부분 data + errors** | + +field error를 5xx로 바꾸면 **해석된 데이터를 버리고**, 모든 프록시와 클라이언트 재시도 정책에게 +"서버가 망가졌다"고 말한다 — 실제로는 필드 하나가 실패했을 뿐인데. + +`GraphQlHttpResponseFactory`가 하나만 있는 이유도 같다: 4xx/200 분기가 drift하지 않게, 모든 전송이 +여기서 응답을 만든다. + +### 7.3 `GraphQlAcceptHeader` — `q=0`은 약한 선호가 아니라 거부다 + +떨어뜨리기 쉽고 틀리면 비싼 두 성질: + +- `application/graphql-response+json;q=0`은 **"절대 그거 보내지 마"**다. +- quality가 클라이언트의 대안들을 서로 순위 매긴다. + +서버 자기 선호 리스트를 순회하면서 헤더에 나타나는 첫 타입을 반환하면 **둘 다 틀린다.** +(이게 원래 리뷰 GQL-006이었다.) + +미디어 타입은 두 개를 지원한다: 응답은 `application/graphql-response+json`을 선호 — +4xx/200이 GraphQL over HTTP 프로파일이 말하는 의미라고 클라이언트에게 알려주는 게 이거다 — +하고 `application/json`도 계속 지원한다. 그 미디어 타입이 생기기 전에 쓰인 클라이언트를 위해서. + +### 7.4 `GraphQlRequestEnvelopeValidator` — 파서보다 **먼저** + +> *Everything here runs before the GraphQL parser sees the document. That ordering is the point: a +> parser has to allocate proportionally to its input, so a size limit applied afterwards has already +> paid the cost it was supposed to prevent.* + +진단은 바이트 카운트와 필드 이름만 보고한다. 문서도, 변수도 아니다. + +### 7.5 `GraphQlJsonStructurePolicy` — 바이트 한계는 처리 비용을 묶지 않는다 + +이 클래스의 javadoc이 아주 구체적이다: + +> *Sixty kilobytes of `[[[[[…]]]]]` is small on the wire and expensive to walk, coerce and validate, +> and the same bytes as one enormous list turn into one enormous coercion loop.* + +그래서 depth, element count, key count가 **각자의 예산**을 가진다. + +### 7.6 `GraphQlJsonValues` — `Map.copyOf`를 쓸 수 없는 이유 + +스타일이 아니라 정확성 버그다. `Map.copyOf`는 null 값에 던진다. 그리고 **null 변수는 합법적이고 +의미 있는 GraphQL 입력이다.** 세 경우가 다르게 코어션된다: + +| 입력 | 의미 | +|---|---| +| `{"a": 1}` | 값 | +| `{"a": null}` | 명시적 null | +| `{}` | 부재 — 인자가 기본값으로 폴백 | + +이걸 합치면 세 번째와 두 번째를 구분할 수 없게 된다. (원래 리뷰 GQL-005의 절반.) + +### 7.7 나머지 + +- **`GraphQlExtensionsPolicy`** — `extensions`는 요청 봉투의 열린 슬롯이고, "문서화되지 않은 + 기능 플래그가 나타나서 조용히 실행을 바꾸는" 자연스러운 장소다. 등록된 키만 받는다. +- **`GraphQlRequestSize`** — 문자가 아니라 **바이트**. 멀티바이트 문자 하나가 메모리와 파서 작업 + 둘 다 더 든다. +- **`GraphQlHttpExecutor`** — 전송이 GraphQL 실행으로 나가는 seam. 이만큼 좁게 유지하는 게 + 전송 코드가 파싱/cost/resolver에 대한 의견을 갖지 않게 만든다. +- **`GraphQlHttpResponse`** — MVC/WebFlux 타입에서 의도적으로 독립. 그래서 계약 테스트 하나가 + 둘 다 커버한다. +- **`GraphQlRequestTooLargeException` / `GraphQlRequestFormatException` / `GraphQlHttpContractException`** + — 셋 다 **내용을 절대 담지 않는다.** 초과 크기 문서는 로그나 에러 바디에 에코되면 안 되는 + 바로 그 입력이다. + +### 7.8 `runtime/servlet/GraphQlRequestBodyLimitFilter` — 유일한 servlet 파일 + +디코딩 **전에** raw body를 자르려면 servlet 스택에서는 필터밖에 없다. 그래서 `compileOnly`로 +`jakarta.servlet:jakarta.servlet-api`를 받는다. build.gradle 주석이 이유를 쓴다: + +> *`compileOnly` is what keeps that from contradicting the paragraph above: it is the servlet API, +> not a server, and it stays off runtimeClasspath entirely — so the filter class simply never loads +> for an adopter who is not running servlets.* + +바로 위 문단에는 *"no production file imports `org.springframework.web`, `jakarta.servlet` or +`org.springframework.http`"*라고 쓰여 있다. 이 문장은 이제 **문자 그대로는 거짓**이다 (§28-P4). +정신은 유지되지만 문장은 갱신이 필요하다. + +--- + +## 8. `execution` — 파이프라인은 취향이 아니라 보안 속성이다 + +22개 파일 45KB. + +### 8.1 `GraphQlExecutionStage` — 6단계와 그 순서의 근거 + +```java +CONTEXT(true), // 검증된 인증 데이터로 불변 컨텍스트 구성 +PERSISTED_LOOKUP(false), // operation ID → 승인된 문서 (Advanced) +PARSE_VALIDATE(true), // 파싱·검증·오퍼레이션 선택 +AUTHORIZATION(true), // 오퍼레이션·좌표 인가 — 실행 전 +COST(true), // 구조·복잡도 예산 — resolver 실행 전 +EXECUTE(true); // 실행 +``` + +javadoc의 첫 문장이 이 패키지 전체의 논지다: + +> *The order is a security property, not a preference.* + +각 순서의 근거: + +| 제약 | 왜 | +|---|---| +| CONTEXT → 나머지 전부 | 누가 호출하는지 모르면 아무것도 결정 못 함 | +| PERSISTED_LOOKUP → PARSE_VALIDATE | 레지스트리가 **문서 공급원**이다. 파싱 전이어야 함 | +| PARSE_VALIDATE → AUTHORIZATION | **좌표 규칙은 검사할 좌표가 없다** — 오퍼레이션이 선택되기 전까지 | +| PARSE_VALIDATE → COST | 마찬가지 | +| COST → EXECUTE | **나중에 검사한 예산은 이미 써 버린 예산이다** | +| AUTHORIZATION → EXECUTE | 거부된 mutation이 이미 부수효과를 냈으면 늦음 | + +원래 리뷰 GQL-018이 정확히 이 문제였다 — *"파이프라인 단계 순서가 authorization에 필요한 정보를 +만들기 전에 authorize한다."* 지금 순서는 그 지적을 반영한 것이다. + +### 8.2 `GraphQlExecutionPipelineValidator` — 제약을 쌍으로 선언한다 + +7개의 `(earlier, later)` 쌍으로 순서를 표현한다. 이 형태의 장점: 새 단계가 끼어들어도 **관련 있는 +제약만** 검사하면 되고, 어떤 제약이 깨졌는지 메시지로 나온다. + +검사 세 가지: +1. `required()` 단계가 빠졌나 +2. 같은 단계가 두 번 등록됐나 +3. 순서 쌍 위반 + +javadoc이 왜 기동 시점인지 말한다: + +> *Both failure modes are silent at runtime — authorization after execution still "works", it just +> authorizes nothing.* + +### 8.3 `BoundedPreparsedDocumentProvider` — **문서**를 캐시하지 결과를 캐시하지 않는다 + +이 구분이 이 캐시가 안전한 유일한 이유다: + +> *A parsed document is the same for everyone, while a result depends on the actor, the tenant and +> their permissions — caching that here would be a cross-user data leak.* + +**`GraphQlPreparsedCacheKey`가 네 부분이다**, 그리고 넷 다 필수다: + +| 부분 | 왜 | +|---|---| +| 문서 해시 | 당연 | +| 스키마 해시 | 같은 텍스트가 다른 스키마에서 다르게 검증됨 | +| 검증 정책 버전 | 정책이 바뀌면 검증 결과가 바뀜 | +| client profile | **필드 가시성이 프로파일마다 다름** | + +문서 해시만으로 키잉하면 **다른 프로파일에서 검증된 문서를 돌려줄 수 있다.** + +`GraphQlPreparsedCachePolicy`는 entry 수와 weight(문서 문자 수)를 둘 다 묶는다. 이유: +**키 공간이 클라이언트 통제 하에 있다.** 서로 다른 문서 텍스트마다 새 엔트리니까, 무제한 맵은 +최적화가 아니라 메모리 고갈 벡터다. + +`GraphQlPreparsedCacheMetrics`는 **카운터만**이고 문서별 차원이 없다. 캐시 엔트리의 자연스러운 +라벨은 문서 자체인데, 그게 정확히 메트릭 태그가 되면 안 되는 것이다. + +### 8.4 `GraphQlTimeoutPolicy` / `GraphQlDeadlinePropagator` / `GraphQlResolverBudget` + +**`GraphQlTimeoutPolicy`는 오직 좁아지기만 한다.** GraphQL 아래의 모든 계층 — resolver, DataLoader +배치, DB 문장, HTTP 호출 — 이 자기 예산을 요청하고, **요청한 것과 실제 남은 것 중 작은 쪽**을 +받는다. + +> *Without that clamp a five-second database timeout under a two-second request budget keeps the +> connection busy for three seconds after the client has already been answered.* + +**`GraphQlDeadlinePropagator`가 계층별 예산을 유도한다.** 5층: 전송 핸드셰이크, 요청 실행, +resolver 하나, DataLoader 배치 하나, 그리고 subscription의 경우 **연결 자체**. 마지막 것은 +의도적으로 요청 예산에서 유도하지 **않는다** — subscription은 장수명 스트림이고 5초 요청 타임아웃을 +적용하면 안 된다. + +**`GraphQlResolverBudget`** — 느린 필드 하나가 요청 전체를 먹지 못하게. 그 필드는 실패하고 값은 +null이 되고 나머지 응답은 계속 해석된다. 단, resolver 자기 예산이 요청 잔여로 클램프될 때만 +건전하다. + +### 8.5 `GraphQlCancellation` — 취소는 일방향이고 리스너는 정확히 한 번 + +> *A timeout that only unblocks the caller is not a cancellation: the database statement, the HTTP +> call and the reactive publisher keep running, and under load that is how a slow dependency turns +> into an exhausted pool.* + +리스너는 **드레인**된다 (큐에서 빼면서 실행) — 그래서 정확히 한 번. 원래 리뷰 GQL-021이 +*"cancellation hook 하나의 실패가 나머지 cleanup을 막는다"*였고, 지금은 모든 hook이 실행되고 +예외는 suppressed로 모인다. + +`advanced/subscription/GraphQlSubscriptionCancellation`의 javadoc이 후속 정리를 기록한다: + +> *There used to be two one-way cancellation state machines in this platform with the same queue, +> the same flag and ...* + +두 개의 동일한 상태 기계가 있었고 하나로 합쳤다. + +### 8.6 `GraphQlOperationSelection` / `GraphQlOperationNamePolicy` + +**`GraphQlOperationSelection`은 이름과 함께 "몇 개 중에서"를 나른다.** 여러 오퍼레이션이 있고 +`operationName`이 없으면 **올바른 선택이 존재하지 않는다.** 추측하면 클라이언트가 문서 순서를 +바꿔서 실행되는 것을 바꿀 수 있다. + +**`GraphQlOperationNamePolicy`** — 프로덕션에서 이름을 요구한다. 스펙은 단일 익명 오퍼레이션을 +허용하고 로컬에서는 편하다. 프로덕션에서는 **오퍼레이션이 가진 유일한 안정 정체성을 없앤다** — +trace, cost 예외, persisted 레지스트리, 사용량 분석이 전부 이름으로 키잉하고, 이름이 없으면 대안 +키는 raw 문서다. 무제한이고 변수로 가득한. + +`GraphQlAnonymousOperationException`은 **문서 텍스트를 담지 않는다.** 이름을 요구하는 이유가 +정확히 "문서가 정체성이 되면 안 된다"이니까. + +### 8.7 `GraphQlExecutionProfile` — `MIXED_CONTROLLED`는 "아무거나"가 아니다 + +> *Mixing blocking and reactive work is legitimate, but only across a declared bridge — without +> one, the same code path either blocks an event loop or blocks a request thread waiting on a +> publisher, and which one it does depends on where it happened to be called from.* + +`GraphQlExecutionProfileValidator`가 **양방향**을 본다: +- reactive 스택 위의 bridge 없는 blocking resolver → 이벤트 루프 starvation +- servlet 스택 위의 reactive resolver → 누군가 기다리며 블록해야 하고, 그게 어디서 일어나는지가 + 운에 맡겨짐 + +--- + +## 9. `runtime` — 정책이 실제 요청을 만나는 seam + +19+1개 파일 83KB. **이 leaf에서 가장 큰 패키지이고, 원래 리뷰의 P0 지적(GQL-002)에 대한 답이다.** + +리뷰가 뭐라 했냐면: *"cost, authorization, DataLoader, cursor, idempotency, observation, persisted +operation, subscription 등 많은 정책과 값 객체가 구현되어 있지만, 대부분 Spring GraphQL이 실제 +`/graphql` 요청을 처리하는 extension point에 연결되지 않는다."* + +`runtime`은 그 연결이다. 그리고 각 파일 javadoc이 "연결되지 않았을 때 무슨 일이 있었나"를 기록한다. + +### 9.1 `GraphQlPlatformWebInterceptor` — CONTEXT 단계 + +`WebGraphQlInterceptor` 구현. 여기 있어야 하는 이유: **전송을 볼 수 있는 유일한 계층이다** — +헤더, 인증된 principal, 요청 locale. + +하는 일 순서: + +```java +public Mono intercept(WebGraphQlRequest request, Chain chain) { + GraphQlRequestContext context; + try { + verifyRequestBounds(request); // 1. 크기·형태 검사 + context = requestContext(request); // 2. 컨텍스트 구성 + } catch (RuntimeException rejection) { + return Mono.just(reject(request, rejection)); // 3. 거부도 wire error로 + } + + request.configureExecutionInput((executionInput, builder) -> + builder.graphQLContext(ctx -> ctx.put(GraphQlRequestContext.CONTEXT_KEY, context)).build()); + + // 4. Reactor context에도 — reactive resolver가 같은 값을 읽게 + return chain.next(request).contextWrite(view -> view.put(GraphQlRequestContext.class, context)); +} +``` + +**두 곳에 넣는다**는 게 중요하다. GraphQL context와 Reactor context 둘 다. 주석이 이유를 쓴다: +*"a reactive resolver reads the same value rather than a second one assembled from whatever it can +reach."* + +`verifyRequestBounds`가 UTF-8 **바이트**로 잰다: + +```java +int bytes = document.getBytes(StandardCharsets.UTF_8).length; +if (bytes > policy.maxDocumentBytes()) { throw new GraphQlRequestTooLargeException(...); } +``` + +그리고 익명 요청의 trace id를 **클라이언트 헤더가 아니라 Spring이 이미 할당한 execution id에서** +가져온다 — 호출자 공급 trace id는 로그와 메트릭 값이 되고, 이건 경계가 있어야 한다. + +### 9.2 `GraphQlPlatformInstrumentation` — 왜 `beginExecuteOperation`인가 + +이 클래스가 **"거부된 요청은 resolver를 0회 호출한다"를 사실로 만드는 것**이다. + +> *`beginExecuteOperation` is the last hook before graphql-java starts the execution strategy: the +> document is parsed, validated and bound to one operation, and no data fetcher has run. Rejecting +> here is what makes "a denied request invokes zero resolvers" true rather than aspirational — an +> earlier hook has no operation to judge, and a later one is judging work that has already happened.* + +훅 선택 자체가 설계 결정이고, 그 근거가 코드 옆에 있다. + +**컨텍스트가 없으면 통과시키지 않고 거부한다:** + +```java +GraphQlRequestContext requestContext = execution.getGraphQLContext().get(CONTEXT_KEY); +if (requestContext == null) { + throw new AbortExecutionException(List.of( + GraphQlWireErrors.toGraphQlError( + GraphQlWireError.internal(executionId).withConstraint(MISSING_CONTEXT_CONSTRAINT)))); +} +``` + +컨텍스트는 전송 인터셉터가 만드니까, 없다는 건 **인터셉터가 배선되지 않았다**는 뜻이다 — 그리고 +그 설정이 정확히 모든 정책이 조용히 아무것도 안 하는 설정이다. + +체인이 끝난 뒤 두 가지를 컨텍스트에 **다시 쓴다**: + +```java +// 파이프라인이 확정한 컨텍스트 (전송은 파싱 전 컨텍스트밖에 못 만든다) +execution.getGraphQLContext().put(GraphQlRequestContext.CONTEXT_KEY, completed.requestContext()); +// 확정된 상태 전체 — cost 단계가 잰 depth/complexity는 여기 말고 어디에도 없다 +execution.getGraphQLContext().put(GraphQlExecutionContext.CONTEXT_KEY, completed); +``` + +전송이 만든 컨텍스트는 **오퍼레이션이 선택되기 전**이라 placeholder 오퍼레이션 정체성을 갖는다. +그걸 그대로 두면 resolver가 placeholder를 보고, 파이프라인이 조인 deadline이 downstream에 +안 보인다. + +### 9.3 `GraphQlExecutionChain` — 카탈로그를 손으로 쓰지 않는다 + +**방향이 요점이다:** + +> *A stage catalogue written by hand can drift from the code without anything failing, whereas a +> catalogue derived from the registered handlers cannot describe a stage that is not there.* + +```java +public GraphQlExecutionPipeline pipeline() { + List stages = new ArrayList<>(); + stages.add(GraphQlExecutionStage.CONTEXT); // 전송 인터셉터 소유 + handlers.forEach(handler -> stages.add(handler.stage())); + stages.add(GraphQlExecutionStage.EXECUTE); // graphql-java 소유 + return new GraphQlExecutionPipeline(stages); +} +``` + +두 단계는 프레임워크 seam이 소유하므로 유도된 뷰에 **명시적으로 넣는다.** 그래야 순서 validator가 +요청 경로 **전체**를 판단하지 가운데만 판단하지 않는다. + +생성자에서 곧바로 `GraphQlExecutionPipelineValidator.validate(pipeline())`를 부른다 — **잘못된 +체인은 만들어질 수 없다.** + +Stable 체인은 3개 핸들러다: + +```java +public static GraphQlExecutionChain stable( + GraphQlOperationSelectionHandler selection, + GraphQlDocumentAuthorizationHandler authorization, + GraphQlCostBudgetHandler cost) { + return new GraphQlExecutionChain(List.of(selection, authorization, cost)); +} +``` + +### 9.4 `GraphQlExecutionContext` / `GraphQlExecutionHandler` — 단계가 필드다 + +핸들러는 "지금까지 만들어진 상태"를 받고 "자기가 만든 상태"를 반환한다. 각 단계는 **필드를 +추가하고 절대 제거하지 않는다.** 그래서 "이 단계가 돌았나?"는 등록 순서를 믿는 대신 값을 보면 +답이 나온다. + +> *This is what separates the pipeline from a list of stage names: the names could be in any order +> and nothing would notice, while a chain that authorizes before selecting an operation has nothing +> to authorize.* + +이게 §8.2의 순서 검증과 짝을 이룬다. **순서 검증은 선언을 검사하고, 필드 의존은 실행을 강제한다.** + +### 9.5 세 개의 Stable 핸들러 + +**`GraphQlOperationSelectionHandler`** — 이 요청이 실행할 오퍼레이션 하나를 고르고 컨텍스트를 +거기에 바인딩한다. 이후 전부가 선택된 오퍼레이션으로 키잉되므로, 여기서 고르는 게 **두 단계가 +서로 다른 오퍼레이션을 판단하는 상황**을 막는다. multi-operation + `operationName` 없음은 +기본값 대신 거부. + +**`GraphQlDocumentAuthorizationHandler`** — 오퍼레이션과 root 좌표를 인가한다. 실행 전이라는 게 +요점: 중간에 거부된 mutation은 이미 부수효과를 냈다. **introspection gate가 여기 산다** — cost가 +아니라. 이유가 정확하다: *"whether this client profile may see the schema"*는 인가 질문이지 예산 +질문이 아니다. + +**`GraphQlCostBudgetHandler`** — 구조·복잡도 예산. 그리고 **deadline을 재검사한다.** 요청이 전송이 +deadline을 설정한 뒤 다른 작업 뒤에 큐잉됐을 수 있고, *"예산이 이미 없어진 오퍼레이션을 시작하는 +건 아무도 읽지 않을 응답에 downstream 용량을 쓰는 것"*이다. + +### 9.6 `GraphQlPreparsedDocumentAdapter` / `GraphQlBatchLoaderRegistrar` / `GraphQlRequestObservationConventionAdapter` + +세 클래스가 같은 문장을 세 번 다르게 쓴다. **"잘 테스트된 객체인데 아무도 안 쓴다"의 세 사례.** + +| 클래스 | 무엇이 끊겨 있었나 | +|---|---| +| `GraphQlPreparsedDocumentAdapter` | graphql-java가 `PreparsedDocumentProvider`에 묻는데 아무도 공급 안 함 → **매 요청이 재파싱·재검증하는 동안 플랫폼 캐시는 비어 있었다** | +| `GraphQlBatchLoaderRegistrar` | 이름→`Object` 레지스트리가 Spring `BatchLoaderRegistry`나 java-dataloader에 연결 안 됨 → **N+1 보호가 어떤 요청도 닿을 수 없는 객체 집합으로 존재** | +| `GraphQlRequestObservationConventionAdapter` | convention이 Spring의 `ExecutionRequestObservationConvention`을 구현하지 않아서 **카디널리티 경계가 어떤 series에도 적용된 적 없음** | + +세 javadoc이 각각 같은 결론을 쓴다: + +- *"Bounds, weights and metrics that describe a cache nobody consults describe nothing."* +- *"the N+1 protection existed as a set of well-tested objects that no request could reach."* +- *"A tag policy that no exporter consults bounds nothing."* + +**이게 이 모듈에서 얻을 수 있는 가장 값진 교훈이다** — 정책 객체의 단위 테스트는 그 정책이 +강제된다는 증거가 아니다. 그래서 `CLAUDE.md`의 등급 어휘(`modelled`/`wired`/`integration-verified`/ +`production-verified`)가 존재한다 (§22.1). + +### 9.7 `GraphQlWireErrorMapper` — 세 개의 에러 계약이 하나가 되기까지 + +javadoc이 사건을 기록한다: + +> *There were two error contracts. The Spring-wired resolver mapped `ApiErrorCarrier` to a code and +> a category; a second, richer resolver did masking and registered mappings but was never on the +> Spring path; and the platform's own rejections had a third vocabulary. Which code, category, +> retryability and execution id a client received depended on which of the three paths the failure +> happened to take — and the two resolver classes differed only in the case of one letter.* + +**클래스 이름이 한 글자 대소문자만 달랐다.** `GraphqlExceptionResolver` vs +`GraphQlExceptionResolver`. Spring은 그중 하나만 불렀다. + +지금은 `GraphQlWireErrorMapper`가 유일한 결정자고, `GraphQlDataFetcherExceptionResolver`는 +**어댑터일 뿐**이다. 그래서 resolver 안에서 난 실패와 실행 전에 난 같은 실패가 같은 답을 만든다. + +`GraphQlPlatformRejectionMapper`는 파이프라인 거부를 담당한다. 단계마다 하면 **단계 수만큼의 에러 +어휘가 생기고, 그중 하나가 잊어버리면 예외 메시지를 흘린다.** 인식 못 한 예외는 자기 메시지가 +아니라 opaque internal error로 매핑 — fail-closed 방향. + +`GraphQlWireErrors`는 **한 방향만** 한다. 플랫폼이 `GraphQlWireError`에서 클라이언트가 볼 수 있는 +것을 정하고(extensions는 생성 시점 allowlist), 이 클래스는 그 결정을 엔진에 넘기기만 한다. +다른 데서 엔진 빌더로 직접 에러를 만들면 **예외 메시지와 응답 사이에 서 있는 유일한 검사를 +우회한다.** + +### 9.8 `GraphQlBlockingBridge` — 두 축이 다 유한해야 한다 + +> *A virtual-thread-per-task executor limits nothing: it accepts every task and the bound becomes +> whatever the downstream system will tolerate. A fixed pool bounds threads and then queues without +> limit, which converts an overload into unbounded memory and latency rather than into a refusal.* + +풀과 큐 둘 다 유한하고, 가득 차면 **즉시 거부**한다. 대기가 아니라. 거부는 호출자가 조치할 수 있는 +답이고, 대기는 요청 deadline이 발견할 때까지 숨는 latency다. + +`GraphQlBlockingBridgeFullException`은 설정된 경계를 담고 키/actor/tenant는 담지 않는다. + +### 9.9 `GraphQlPrincipalResolver` — 빈 반환은 "허용"이 아니다 + +> *Returning empty means "no verified credential", not "allow". The caller decides what an +> [anonymous request means].* + +인터셉터가 그 결정을 한다: + +```java +Optional principal = principalResolver.resolve(request); +if (principal.isPresent()) return contextFactory.create(principal.get(), deadline); +return contextFactory.createAnonymous( + anonymousProfile, anonymousTenant, traceId(request), deadline, anonymousProfileProtected); +``` + +`anonymousProfileProtected`가 true면 익명 요청은 아예 거부된다. + +--- + +## 10. `cost` — 네 개의 층 + +21개 파일 56KB. 비용 통제가 한 겹이 아니라 네 겹이다. + +| 층 | 클래스 | 무엇을 막나 | 언제 | +|---|---|---|---| +| 1 | `GraphQlParserLimitPolicy` / `GraphQlParserLimits` / `GraphQlParserOptionsFactory` | 파서 자체가 폭발하는 것 | 파싱 중 | +| 2 | `GraphQlDocumentShapeAnalyzer` / `GraphQlStructuralLimitPolicy` | depth, alias, 필드 수, fragment | 파싱 후 | +| 3 | `GraphQlComplexityCalculator` / `GraphQlDocumentComplexityScorer` / `GraphQlCostCatalog` | 카디널리티 곱셈 | 실행 전 | +| 4 | `GraphQlRuntimeBudgetTracker` / `GraphQlResponseNodeCounter` / `GraphQlResponseByteLimiter` | 실제 응답 크기 | 실행 중 | + +### 10.1 층 1 — 파서 한계 + +graphql-java의 `ParserOptions`에 넘길 값을 정책으로 만든다. 파서는 **입력에 비례해 할당**하므로, +파싱 후에 재는 건 이미 늦다 (§7.4와 같은 논리). + +### 10.2 층 2 — 문서 형태 + +`GraphQlDocumentShapeAnalyzer`가 파싱된 문서를 걸어서 `GraphQlDocumentShape`를 만든다. 원래 리뷰 +GQL-007이 *"named fragment가 custom introspection gate를 우회하고 variable 입력 구조는 측정하지 +않는다"*였고, 지금은 `GraphQlFragmentReachabilityTest`가 fragment를 통한 도달성을 고정한다. + +### 10.3 층 3 — 카디널리티 인지 복잡도 점수 + +`GraphQlDocumentComplexityScorer`가 이 패키지의 핵심이다. 세 가지가 인상적이다. + +**(1) 스키마 인지 순회.** 좌표는 `TypeName.fieldName`이고, 타입 절반은 각 selection set을 스키마에 +대해 해석해야 존재한다. + +> *Guessing it from the operation root would price `order { customer { orders { … } } }` as three +> root fields and miss the multiplication entirely.* + +**(2) 카디널리티는 스키마가 아니라 요청에서 온다.** + +> *a connection's children are multiplied by the effective page size, and a page size supplied +> through a variable is resolved from the request variables rather than assumed to be the default. +> That is the difference between a budget and a suggestion — `first: $n` would otherwise cost the +> same at 1 and at 1000.* + +**(3) 순회 자체가 유한하다.** 기본 `maximumVisitedNodes = 200_000`, fragment cycle은 확장 경로 +추적으로 끊는다. javadoc이 왜 검증된 문서에도 경계를 유지하는지 쓴다 — 이 scorer는 pre-execution +경로에서도 쓰이고, **거기서는 적대적 문서가 정확히 가격을 매기려는 대상이다.** + +introspection 필드(`__` 접두사)는 **가격이 아니라 게이트**로 처리된다 (§9.5). + +root 타입이 없으면 0을 반환한다. 주석: *"validation rejects the document, and pricing a tree with +no root would be inventing a number."* + +### 10.4 층 4 — 런타임 예산 + +`GraphQlRuntimeBudgetTracker`가 실행 중 실제 노드 수와 바이트를 센다. 정적 점수는 추정이고, +데이터가 예상보다 클 수 있다. `GraphQlRuntimeBudgetExceededException`이 그때 던진다. + +`advanced/incremental/GraphQlIncrementalTransportPolicy`가 이 예산을 `@defer` 패치까지 확장한다 — +*"Counting only the initial result would make a deferred fragment a way to send an unbounded +response one patch at a time."* + +--- + +## 11. `security` — 인증·인가·테넌트 + +15개 파일 33KB. + +### 11.1 `GraphQlAuthorizationPolicy` — 스키마 가시성은 인가가 아니다 + +> *Hiding a field from introspection stops it appearing in a schema dump and nothing else — the +> field is still executable by anyone who knows its name. So a hidden field still goes through the +> same check as a visible one, and hiding is only ever an additional measure.* + +이건 GraphQL을 처음 붙이는 팀이 가장 흔하게 하는 착각이고, 타입 하나가 그걸 막는다. + +`GraphQlAuthorizationDecision`은 거부 시 **메시지가 아니라 안정 코드**를 나른다. 클라이언트가 +분기할 수 있고 메트릭이 셀 수 있으면서, **이유가 "무엇이 존재하는가"에 대한 정보 누출이 되지 +않는다.** `GraphQlAuthorizationDeniedException`도 마찬가지 — 코드와 "존재를 숨겨야 하는가"만, +객체/actor/tenant는 절대. + +### 11.2 `GraphQlAuthenticationContextFactory` — 컨텍스트를 만드는 유일한 장소 + +> *This is the only place a request context is created, which is what makes "tenant comes from +> authentication, never from an argument" enforceable: a resolver has no way to build a context +> with a tenant of its choosing.* + +**규칙을 규칙으로 쓰지 않고 구조로 만든다.** §6.2의 `TenantContext`에 인자 기반 팩토리가 없는 것과 +같은 기법이다. + +### 11.3 `GraphQlTenantIsolationPolicy` — fail-closed 두 규칙 + +1. 보호된 오퍼레이션에 tenant가 없으면 **실패한다.** untenanted로 진행하지 않는다 — 대안은 모든 + tenant를 조용히 가로지르는 쿼리다. +2. tenant는 GraphQL 인자/변수에서 절대 오지 않는다. `TenantContext`가 기록한 `TenantSource`가 + 이걸 검사 가능하게 만든다. + +`GraphQlTenantIsolationException`의 javadoc: *"a missing tenant must stop the request rather than +fall back to 'all tenants', which is how a single missing propagation turns into a cross-tenant +read."* + +### 11.4 `ApplicationObjectAuthorization` / `GraphQlObjectAuthorizationPort` — ADR-GQL-001의 핵심 + +**이게 세 결정 중 유일하게 "실제 계층 결함"이었고, 문서화가 아니라 수정됐다.** + +문제: object 인가를 결정하는 포트가 이 leaf에 선언되어 있었고, 그 메서드가 +`GraphQlRequestContext`를 받았다. 즉 application 계층이 그 계약을 구현하려면 **인바운드 전송에 +의존해야 했다.** + +해결: 답하는 계약이 `application-core`로 갔다. + +| 타입 | 위치 | 역할 | +|---|---|---| +| `ObjectAccessPolicy`, `ObjectAccessRequest`, `ObjectAccessDecision` | `application-core` | **답한다** (전송 중립) | +| `GraphQlObjectAuthorizationPort` | graphql leaf | 플랫폼 쪽 seam — **언제 물을지** 결정 | +| `ApplicationObjectAuthorization` | graphql leaf | 둘을 잇는 bridge | + +javadoc이 분업을 한 문장으로 쓴다: + +> *The platform decides when an object needs an access check; the application decides the answer, +> because the answer depends on domain state.* + +현재 등급은 `modelled`다 — 실행 경로에 연결하는 configuration이 없다. `CLAUDE.md`가 그 사실을 +정직하게 적어 두었다. + +### 11.5 `GraphQlContextPropagator` / `GraphQlContextCleanup` — 스레드 홉과 풀 오염 + +**전파**: GraphQL 실행은 스레드를 계속 옮긴다 — async data fetcher, DataLoader dispatch, scheduler +bridge. thread local에만 있는 컨텍스트는 첫 홉에서 사라진다. + +> *That is not a lost tag: it is a batch load running with no tenant.* + +모든 홉이 여기를 지나고, 모든 홉이 **끝나고 이전 상태를 복원한다** — 풀 스레드에 아무것도 새지 +않게. + +**정리**: 풀/virtual-thread executor에서 남은 상태는 stale이 아니라, **그 스레드의 다음 요청이 +다른 호출자의 actor와 tenant를 상속한다.** 그래서 모든 경로에서 실행되고, 등록된 액션 각각이 +앞의 것이 던져도 실행된다. + +### 11.6 나머지 + +- **`GraphQlAuthenticatedPrincipal`** — 인증 객체 자체가 아니라 **투영**. access token/cookie/raw + claim 없음. 그래서 downstream이 실수로 자격증명을 forward하거나 로깅할 수 없다. +- **`GraphQlClientProfileResolver`** — 프로파일은 **신뢰된 자격증명 메타데이터**에서만. 변수/ + extension/헤더에서 절대. 프로파일이 cost·page·introspection 한계를 고르니까, 호출자가 자기 + 프로파일을 지정할 수 있으면 **스스로에게 admin 예산을 부여할 수 있다.** +- **`GraphQlBatchContext`** — DataLoader 배치는 resolver 콜스택 밖에서 돈다. 이게 없으면 actor도 + tenant도 없이 실행되고, *"tenant를 무시하는 loader는 일어나기를 기다리는 cross-tenant read고, + request-scoped 캐시가 결과를 들고 있어서 더 나쁘다."* +- **`GraphQlAuthorizationInterceptor`** — AUTHORIZATION 단계 적용. 거부된 오퍼레이션은 resolver에 + 도달하지 않고, **거부를 발견하러 가는 길에 mutation resolver가 냈을 부수효과도 없다.** + +--- + +## 12. `dataloader` — N+1과 3-값 대수 + +17개 파일 31KB. + +### 12.1 `GraphQlBatchValue` — 결과가 세 가지다 + +이 패키지의 중심 아이디어. + +``` +Present(v) — 값이 있다 +Missing — 그런 행이 없다 +Failed(e) — loader가 실패했다 +``` + +> *"No such row" and "the loader failed" both flatten to `null` in a naive loader, and that single +> conflation is what turns a dependency outage into a response that looks like legitimately empty +> data.* + +`GraphQlBatchResultMapper`가 loader 출력을 요청 키마다 하나씩 매핑한다. 그리고 javadoc이 수정된 +버그를 기록한다 — *"A null value means missing, in both loader shapes. The two used to disagree."* +mapped loader와 list loader가 null에 대해 다른 해석을 하고 있었다. + +### 12.2 `GraphQlBatchResult` — 정확히 요청 키 수만큼, 요청 순서로 + +ordered loader는 값을 **위치로** 반환한다. 크기나 순서가 다른 결과는 **조용히 부모에게 잘못된 +자식을 준다.** 에러가 아니라 그럴듯한 데이터를 만드는 결함이다. + +`GraphQlBatchChunker`도 같은 이유로 청크 간 순서를 보존한다. + +### 12.3 `GraphQlBatchExecutor` — 예산은 청크마다 **앞뒤로** 검사 + +> *The budget is checked before and after every chunk. Checking only before it meant the last chunk +> could run unbounded — a batch that started with a millisecond left was allowed to issue one [full +> chunk].* + +그리고 모든 청크가 같은 actor/tenant/deadline을 받는다 — 다른 스코프로 돈 청크는 **한 논리적 배치 +안에 두 tenant를 섞은 결과 집합**을 만든다. + +### 12.4 `GraphQlDataLoaderRequestRegistry` — 실행마다 새로 + +> *A new registry per execution, never a shared singleton. The request-scoped cache is the reason: +> it is keyed by entity id and holds whatever the loader returned for this caller, so sharing it +> across requests means one user's cached order is served to the next user who asks for the same id.* + +`GraphQlDataLoaderFactory`가 팩토리인 이유도 같다 — 실행마다 인스턴스를 만드는 것을 **구조적으로 +참**으로 만든다. 누가 기억해야 하는 관례가 아니라. + +### 12.5 `GraphQlBatchPolicy` / `GraphQlBatchPolicyRegistry` / `GraphQlMissingKeyPolicy` + +**배치 크기는 downstream이 실제로 받을 수 있는 것으로 묶인다** — JPA `IN` 리스트, Mongo `$in`, +HTTP 배치 엔드포인트의 문서화된 최대치. 무제한 배치는 우아하게 실패하지 않는다: DB가 플랜을 +거부하거나 API가 요청 전체를 거부한다. + +**등록되지 않은 loader는 해석이 실패한다.** 기본값으로 가지 않는다 — 배치 크기도, 타임아웃도, +missing-key 의미도 없으니까. 등록이 loader 이름을 경계 있게 유지하기도 한다(메트릭 라벨). + +**missing key의 의미는 loader마다 다르다.** 선택적 enrichment가 없는 건 정상이고, 필수 부모가 +없는 건 데이터 불일치인데 null을 조용히 반환하면 그걸 숨긴다. + +### 12.6 관측 + +`GraphQlBatchObservation`은 **카운트와 경계 있는 loader 이름만**. 배치 키는 흥미로운 값이고 +정확히 기록되면 안 되는 값이다 — 객체 식별자고 메트릭 차원으로는 무제한이다. + +`GraphQlBatchLoadException` / `GraphQlBatchTimeoutException` / `GraphQlMissingKeyException` 셋 다 +키를 담지 않는다. + +**증거**: `runtime/GraphQlBatchLoaderRegistrationTest`가 실제 graphql-java 실행 + Spring +`BatchLoaderRegistry`로 **부모 50개 → downstream 호출 3회**를 고정한다. 이게 `wired` 등급의 근거다. + +--- + +## 13. `pagination` — 서명된 keyset 커서 + +17개 파일 37KB. 알고리즘적으로 가장 밀도 높은 패키지다. + +### 13.1 왜 Base64로는 안 되나 + +> *Base64 is an encoding, not a protection: a plain Base64 cursor is a keyset the client can decode, +> edit and send back, which turns pagination into an arbitrary "start reading from here" parameter.* + +### 13.2 `GraphQlCursorPayload` — 7개 필드 + +```java +public record GraphQlCursorPayload( + int version, + String queryProfile, + String direction, // FORWARD | BACKWARD + Map keyset, + String filterFingerprint, + String tenantScope, + String keyId) { +``` + +각 필드가 하나의 공격을 막는다. + +| 필드 | 없으면 | +|---|---| +| `queryProfile` | 한 필터된 목록의 커서를 다른 목록에 replay — 서버가 전혀 다른 의미의 위치에서 이어감 | +| `filterFingerprint` | 동일. 새 필터 하에서 접근 권한 없던 행을 받음 | +| `direction` | forward 커서를 backward로 replay → **페이지 경계를 반대로 걸음** | +| `tenantScope` | 한 tenant의 커서를 다른 tenant가 replay → **시작할 수도 없던 스캔을 이어감** | +| `keyId` | 회전 불가 | +| `version` | 봉투 의미가 바뀌면 이미 발급된 커서를 해석할 수 없음 | + +**payload는 credential도 raw tenant도 담지 않는다.** `tenantScope`는 호출자가 공급하는 불투명 +fingerprint다 — 이 모듈이 context 타입에서 자유로워야 하고, **커서는 클라이언트가 들고 읽을 수 +있는 값**이기 때문이다. + +### 13.3 `GraphQlCursorScope` — 네 개를 한 값으로 넘기는 이유 + +```java +public record GraphQlCursorScope( + String queryProfile, String filterFingerprint, String direction, String tenantScope) +``` + +javadoc의 결정적 문장: + +> *Verifying the signature proves the server issued the cursor. It does not prove the server issued +> it for **this** request. ... Passing the whole expectation as one value is what stops a new caller +> from checking three of the four and looking correct.* + +**API 설계로 부분 검사를 불가능하게 만든다.** 4개를 개별 인자로 받으면 3개만 넘기는 호출자가 +컴파일되고 그럴듯해 보인다. + +### 13.4 `GraphQlCursorFraming` — 이스케이프 대신 길이 프레이밍 + +v1 봉투는 `|`로 필드를, `;`와 `=`로 쌍을 조인하고 keyset 값 안의 그 문자들을 이스케이프했다. +**라운드트립하지 않았다:** + +- 디코더가 **언이스케이프 전에** 구분자로 split → sort 값 안의 이스케이프된 구분자가 필드를 찢음 +- **세 필드(query profile, filter fingerprint, key id)는 아예 이스케이프되지 않았다** +- **파이프 하나가 들어간 sort 값이면 정상 발급된 커서가 읽히지 않았다** + +지금은 길이 프레이밍이다: `:`. 읽는 쪽이 정확히 얼마나 읽을지 알고, **특별한 문자가 +없다.** 길이는 `char` 단위 — `substring`이 소비하는 단위 — 라서 surrogate pair도 프레임되고 그대로 +읽힌다. + +> *Framing removes the problem rather than escaping around it.* + +이스케이프 버그를 이스케이프로 고치지 않는다는 게 요점이다. + +### 13.5 `HmacGraphQlCursorCodec` — 네 가지 방어 + +```java +public static final int MAXIMUM_CURSOR_CHARS = 4096; +private static final String ALGORITHM = "HmacSHA256"; +``` + +**(1) 디코드 전 크기 검사.** + +```java +if (cursor.length() > MAXIMUM_CURSOR_CHARS) throw new GraphQlCursorException("cursor is too large"); +``` + +주석: *"an oversized token is refused without allocating a copy of it."* + +**(2) 상수 시간 비교.** + +```java +if (!MessageDigest.isEqual(expectedSignature, presentedSignature)) +``` + +일반 문자열 비교는 **첫 다른 바이트의 위치를 흘린다.** + +**(3) 서명 키는 코덱이 고른다, payload가 아니라.** + +> *Letting the payload name it meant a caller could pin every new cursor to a key that was being +> retired, which is rotation that never completes.* + +**발급**은 `keyRing.activeKeyId()`로 서명하고, **검증**은 payload의 `keyId`로 키를 찾는다. 이 +비대칭이 회전을 완성시킨다. + +**(4) 현재 버전만 발급, 과거 버전은 해석만.** + +```java +if (payload.version() != GraphQlCursorVersion.CURRENT) + throw new GraphQlCursorException("only the current cursor version is issued"); +``` + +디코드 쪽은 `parseFramed`(현재)와 `parseLegacy`(과거)로 분기한다. **읽기는 관대하고 쓰기는 +엄격하다** — 이미 클라이언트 손에 있는 커서를 깨지 않으면서 새 커서는 하나의 형태로 수렴시킨다. + +### 13.6 `GraphQlCursorKeyRing` — 두 키가 동시에 살아 있어야 한다 + +> *Rotation needs two keys live at once: cursors already issued under the old key must keep +> verifying while new ones are signed with the new key.* + +`of(Map keys, String activeKeyId)` / `single(keyId, secret)`. +`DEFAULT_KEY_ID = "cursor-key-1"`. + +### 13.7 그런데 — 이게 요청 경로에 없다 (GQL-INT-003) + +`CLAUDE.md`가 이 사실을 정직하게 적어 두었고, 나도 grep으로 확인했다. +`backend.graphql.cursor.key-ids`를 읽는 곳은 셋뿐이다: + +| 읽는 곳 | 하는 일 | +|---|---| +| `GraphQlPlatformStartupValidator:42` | production인데 비어 있으면 **기동 거부** | +| `GraphQlPlatformActuatorEndpoint:62` | 리포트에 key id 노출 | +| `GraphQlPlatformConfigurationReport` | 그 값을 담음 | + +**커서에 서명하는 코드는 그 설정을 하나도 읽지 않는다.** `GraphQlPolicyRequestPathTest`가 그 사실을 +고정한다: + +```java +.withPropertyValues("backend.graphql.cursor.key-ids=cursor-key-1") +... +assertThat(context).doesNotHaveBean(GraphQlCursorKeyRing.class); +``` + +`CLAUDE.md`의 표현이 정확하다: + +> **결함은 "미완성"이 아니라 startup validator가 하나를 완성된 것처럼 보이게 만든다는 것이다.** + +프로덕션이 키 식별자를 요구하고, 운영자가 넣고, 엔드포인트가 "설정됨"이라고 확인해 주는데, +커서는 validator 메시지가 막는다고 말한 그대로 client-editable로 남는다. + +닫으려면 배선이 아니라 **설계 결정**이 필요하다. `GraphQlCursorKeyRing.of`는 +`Map`를 받고 설정 계약은 "키 자체는 설정에 나타나지 않는다"이므로, **키 재료가 +어디서 오는지**를 먼저 정해야 한다 (§28-P2). + +### 13.8 나머지 + +- **`GraphQlConnectionAssembler` / `GraphQlConnection` / `GraphQlEdge` / `GraphQlPageInfo`** — + Relay Connection 조립. +- **`GraphQlKeysetWindow` / `GraphQlCursorKeyset`** — keyset 윈도우 표현. +- **`GraphQlConnectionPolicy` / `GraphQlConnectionRequest`** — 페이지 크기 상한 등. + +--- + +## 14. `mutation` — 멱등성과 "비즈니스 결과는 에러가 아니다" + +14개 파일 26KB. + +### 14.1 `GraphQlBusinessResult` — 기대된 결과는 데이터다 + +> *Expected outcomes are data, not errors. A version conflict or a rejected state transition is +> something the client should render, and turning it into a GraphQL error both nulls the field and +> hides it among genuine failures.* + +두 가지 손해를 정확히 짚는다: **필드가 null이 되고**, **진짜 실패들 사이에 섞인다.** + +`GraphQlMutationPayload`가 wire 형태다 — `SUCCESS` / `CONFLICT` / `INVALID` 상태와 안정 코드가 +값과 함께 이동해서, 클라이언트가 payload 모양을 뜯어보거나 메시지를 매칭하지 않고 분기한다. + +`GraphQlMutationResultMapper`가 매핑하고, **예상치 못한 실패는 이 매퍼에 도달하지 않는다** — +GraphQL 에러로 남는다. + +### 14.2 `GraphQlBatchMutationItemResult` — 아이템별 결과 + +top-level 에러 하나가 **모든 아이템의 결과를 버린다** — 성공했고 부수효과가 이미 일어난 것들까지. +그래서 `index`를 담아 클라이언트가 상관지을 수 있게 한다. + +### 14.3 `GraphQlMutationIdempotencyContext` — 키 하나가 아니라 다섯 개의 스코프 + +``` +actor × tenant × mutation coordinate × contract version × normalised input +``` + +| 축 | 없으면 | +|---|---| +| actor | 한 클라이언트의 재시도가 다른 클라이언트의 결과를 반환 | +| **tenant** | actor 정체성이 tenant를 함의하지 않음 — 같은 service account가 두 tenant에서 행동 가능 | +| mutation coordinate | 같은 키를 다른 mutation에 보내면 다른 오퍼레이션인데 같은 결과 | +| contract version | 계약이 바뀌면 같은 입력이 다른 의미 | +| normalised input | 같은 키에 다른 입력 = 두 개의 다른 부수효과 | + +javadoc이 tenant를 왜 따로 두는지 명시한다: *"Tenant is part of the scope because actor identity +does not imply it."* + +### 14.4 `GraphQlCanonicalInput` — 지문 충돌 사건 + +**이 leaf에서 가장 교육적인 버그다.** + +이전 정규형은 최상위 키를 정렬하고 `key=value;`로 조인했다. 두 개의 서로 다른 입력이 동일하게 +정규화될 수 있었다: + +``` +{a: "b;c=d"} → a=b;c=d; +{a: "b", c: "d"} → a=b;c=d; +``` + +> *Two different requests sharing a fingerprint is an idempotency collision — the second one is +> answered with the first one's result.* + +§13.4의 커서 프레이밍 버그와 **같은 부류**다: 구분자를 값 안에서 구분할 수 없다. 그리고 같은 +레포 안에서 두 번 일어났다. 이게 §27의 원칙 하나가 된다. + +### 14.5 `GraphQlIdempotencyKey` — 양쪽이 다 묶인다 + +8~128자. 이유가 양방향이다: +- **너무 짧으면** 무관한 요청 간에 충돌 → 한 호출자의 재시도가 다른 호출자의 결과를 반환 +- **무제한이면** 저장·로깅 문제 + +`GraphQlIdempotencyConflictException`은 **키도 입력도 담지 않는다.** + +### 14.6 `GraphQlMutationContractValidator` — GraphQL의 serial 실행은 트랜잭션이 아니다 + +> *One mutation root field calls one Application use case, and several root fields in one document +> are several independent use cases. GraphQL executes them serially, which is easy to mistake for a +> shared transaction — it is not: the first can commit and the second fail.* + +이건 GraphQL을 쓰는 팀이 실제로 자주 착각하는 지점이고, 여기서는 **기동 시점 계약 검증**으로 +막는다. 원자적이어야 하는 작업은 필요한 걸 전부 받는 **하나의 mutation**에 속한다. + +그리고 §4.3의 `unsupported.requestWideTransaction`이 설정 축에서 같은 것을 막는다. + +### 14.7 `GraphQlExpectedVersion` — 여기서 행동하지 않는다 + +낙관적 동시성은 **저장소 보장**이고, 충돌 후 재시도 결정은 use case에 속한다. + +> *a transport-level retry would repeat a side effect the client never asked to repeat.* + +`GraphQlMutationIdempotencyInterceptor`는 HTTP 요청이 아니라 **mutation 단위**로 스코프한다 — +한 GraphQL 요청이 여러 root mutation을 나를 수 있고, 요청 전체를 하나의 멱등 단위로 다루면 +전부 replay하거나 전부 안 하거나가 된다. + +현재 등급은 `modelled`다 — 이 인터셉터를 참조하는 configuration이 없다. + +--- + +## 15. `fetch` — over-fetch를 막는 프로파일 + +10개 파일 16KB. 작지만 아이디어가 선명하다. + +### 15.1 문제 + +GraphQL selection 조합마다 쿼리를 유도하면 **클라이언트가 요청한 모양마다 새 플랜**이 생긴다. +리뷰 불가능하고 무제한이다. + +### 15.2 해법 — 유한한 카탈로그 + 최소 커버 프로파일 + +`GraphQlFetchProfile`은 **Application query profile 이름**을 지목한다. `EntityGraph`, JPQL 문자열, +Mongo projection이 여기 나타나면 **전송 계약에 저장소 세부가 들어오는 것** — 이 간접층이 막으려는 +바로 그 결합이다. + +`GraphQlFetchProfileClassifier`가 **가장 작은 커버 프로파일**을 고른다. `id status` selection은 +full detail이 아니라 basic query를 로드한다. + +**커버하는 게 없으면 실패한다.** 가장 큰 프로파일로 폴백하지 않는다: + +> *a silent upgrade to "load everything" is exactly the behaviour the fetch profile indirection +> exists to prevent.* + +`GraphQlUnmappedSelectionException`은 **타입과 선택된 필드 수만** 보고하고 필드 이름은 안 한다. + +### 15.3 정규화 — 별칭과 fragment + +`GraphQlSelectionSetView`가 별칭을 실제 선택하는 필드로 줄이고 fragment를 확장한다. 안 그러면 +`total: amount`와 `amount`가 **같은 컬럼을 로드하는데 다른 프로파일로 분류된다.** + +`GraphQlSelectionCoordinate`는 필드 이름을 정규화·정렬하고, `GraphQlSelectionSignature`가 안정된 +정체성 `Type{field,field}`를 만든다 — fetch 플랜 결정을 캐시하고 회귀 테스트로 고정할 수 있게. + +### 15.4 등록 규칙 + +중복 이름과 타입당 두 번째 default는 **등록 시점 거부**다. 둘 중 하나라도 있으면 **어느 쿼리가 +도는지가 등록 순서에 달리고**, 그건 리뷰어가 스키마에서 볼 수 없는 것이다. + +`GraphQlFetchProfileRule`은 좌표별 오버라이드다 — "선택은 싸지만 조인이 비싼 필드"처럼 최소 커버가 +답이 아닌 경우를, 프로파일 필드 목록에 인코딩하는 대신 명시한다. + +--- + +## 16. `error` — 클라이언트가 볼 수 있는 것을 타입이 정한다 + +10개 파일 22KB. + +### 16.1 `GraphQlWireError` — extensions는 생성 시점 allowlist + +> *Everything an exception naturally carries — its class, its message, a SQL statement, a downstream +> URL, a provider body — is exactly what an attacker wants and a client cannot use, so the type +> refuses to carry it rather than relying on every call site to remember.* + +**모든 호출부가 기억하는 대신 타입이 거부한다.** 이게 §9.7에서 `GraphQlWireErrors`가 한 방향만 +하는 이유와 짝을 이룬다. + +### 16.2 `GraphQlErrorCode` / `GraphQlErrorCategory` — 닫힌 집합 + +`GraphQlErrorCode`: `[A-Z][A-Z0-9_]{2,63}`. 예외 클래스나 메시지에서 유도된 코드는 **구현이 바뀔 +때마다 바뀌고, 그러면서 내부를 흘린다.** + +`GraphQlErrorCategory`: 닫힌 집합. 클라이언트가 프로즈를 파싱하지 않고 **재시도할지, 재인증할지, +요청을 고칠지, 비즈니스 메시지를 보여줄지** 결정할 수 있게. 그리고 닫혀 있어서 **메트릭 태그로 +안전하다** — 열린 카테고리는 actionable하지도 bounded하지도 않다. + +README에 `Category → ErrorType` 표가 있다. + +### 16.3 `GraphQlInternalErrorMasker` — 기본은 마스킹 + +> *Exception messages are written for operators and routinely contain a SQL statement, a Mongo +> filter, a downstream URL with credentials, an internal hostname or a raw identifier.* + +메시지는 **의도적으로 client-facing으로 만들어졌을 때만** 공개된다. 그리고 client-facing으로 +표시된 메시지에도 **패턴 스캔이 2차 방어**로 돈다. + +`GraphQlErrorContext`가 execution ID를 나른다: + +> *the point of masking is that the detail exists somewhere the client cannot see, not that it is +> discarded.* + +### 16.4 세 개의 resolver — 각자 다른 시점 + +| resolver | 언제 | 왜 별도인가 | +|---|---|---| +| `GraphQlRequestErrorMapper` | 실행 **전** | `DataFetcherExceptionResolver`가 이걸 절대 못 본다. parse/validation은 어떤 data fetcher도 호출되기 전이다. 응답 path도 없고 4xx다 | +| `GraphQlExceptionResolver` | resolver 실패 | 등록 안 된 실패는 opaque `INTERNAL_ERROR` + execution ID. **그 기본값이 중요한 절반** — 스택 어디서 새 예외 타입이 생겨도 자동으로 마스킹된다 | +| `GraphQlSubscriptionExceptionResolver` | 스트리밍 **시작 후** | 응답이 이미 커밋됨. 부분으로 만들 `data`도, 바꿀 status도 없다. **재구독이 가치 있는지**를 말해야 한다 | + +### 16.5 `GraphQlFailureBoundary` / `GraphQlNullabilityContract` — nullability는 실패 격리 결정이다 + +**이 문서에서 GraphQL 특유의 통찰 중 가장 좋은 것.** + +> *A null on a non-null field does not stay local: the error climbs to the nearest nullable +> ancestor, nulling everything on the way. So nullability is a failure-isolation decision, not a +> data-shape one — marking an enrichment field non-null can delete an entire order from the response +> when an unrelated service is down.* + +그래서 `GraphQlNullabilityContract`가 각 필드의 **선언된 실패 동작**을 기록한다: + +> *Making a field non-null is a promise that it can always be provided — including when a dependency +> is down and when authorization redacts it.* + +`!`를 붙이는 게 **오타가 아니라 리뷰 가능한 결정**이 된다. `GraphQlNullPropagationContractTest`가 +golden test로 고정한다. + +--- + +## 17. `schema` / `scalar` — 스키마 조립과 스칼라 + +### 17.1 `GraphQlSchemaAssembler` — 결정적 순서 + +fragment는 **소유 모듈과 경로로 정렬한 뒤** 파싱된다. 파일시스템/classpath 열거 순서는 기계나 +패키징 형식에 따라 안정적이지 않고, 불안정한 순서는 **스키마 해시를 움직이고 충돌하는 두 선언 중 +어느 쪽이 이기는지를 바꾼다.** + +`GraphQlSchemaAssemblyResult`가 "merge가 안 던졌다"보다 엄격하다. 중복 타입/필드/디렉티브/스칼라 +선언은 **선언한 리소스와 함께** 거부된다 — 안 그러면 merge 결과가 조용히 승자를 고르고 **외부 +계약이 classpath 순서에 의존하게 된다.** + +canonical SDL은 정렬된 fragment들의 **파싱된 AST를 프린트해서** 만든다. 텍스트 조인이 아니라. + +`GraphQlSchemaResource` / `GraphQlSchemaOwnership`이 모든 좌표를 **선언한 파일로 되짚을 수 있게** +한다. 중복 선언, unmapped 필드, breaking change — 모든 진단이 merge된 전체가 아니라 파일을 지목한다. + +발견은 승인된 스키마 경로 아래의 `.graphqls` / `.gqls`로 제한된다. + +### 17.2 `GraphQlSchemaContract` — 해시만으로는 부족하다 + +**네 부분이 전부 필수다:** + +``` +schema hash × breaking-change policy version × scalar manifest version × directive manifest version +``` + +> *Identical SDL bytes can still mean a different external contract if the breaking-change policy, a +> scalar's coercion or a directive's meaning moved underneath it.* + +`matches`는 넷 다 비교하고, `sameSchemaBytes`는 캐시 키잉용으로 제공되지만 **명시적으로 호환성 +판정이 아니다.** + +`GraphQlSchemaHash`는 소문자 hex 64자로 고정 — 같은 스키마가 두 개의 다른 키를 만들지 않게. +쓰임: preparsed 캐시 키, persisted operation의 승인 스키마 바인딩, actuator 리포트. + +### 17.3 `GraphQlMappingInspectionGate` — 리포트를 게이트로 + +Spring의 schema mapping inspection은 **정보로 보고**한다. Stable 프로파일은 그걸 **기동 실패로 +승격**한다. + +> *A silently unmapped field returns `null` at runtime instead of failing at boot.* + +**모든** controller/scalar/type-resolver wiring이 등록된 **뒤에** 돈다 — 더 일찍 돌면 아직 기여되지 +않은 resolver를 없다고 보고한다. + +Local 정책에서는 advisory인데 **예외 두 개**: forbidden scalar와 unknown resolver. 둘은 **어떤 +프로파일에서도 런타임에 동작할 수 없기** 때문이다. + +### 17.4 `@oneOf` — 스펙 규칙을 두 곳에서 강제 + +`GraphQlOneOfSchemaGate`(기동)와 `GraphQlOneOfInputValidator`(런타임)가 짝이다. +`GraphQlOneOfPolicy`가 규칙을 **한 번만** 서술해서 둘이 의견이 갈리지 않게 한다. + +스키마 게이트: 모든 멤버가 nullable이어야 하고 default가 없어야 한다. + +> *"exactly one is supplied" would otherwise be undecidable: a non-null member is always supplied, +> and a member with a default is always present after coercion.* + +런타임 validator: **0개도 2개도 거부.** 0개면 resolver가 추측해야 하고, 2개면 **resolver의 필드 +검사 순서가 어느 selector가 이기는지 결정한다** — 에러가 아니라 두 mutation 간의 일관성 없는 +동작으로 나타난다. + +진단은 멤버 **이름**만 담는다. 값은 절대 — *"a rejected selector can hold an account number."* + +### 17.5 `GraphQlScalarManifest` — 스칼라도 등급이 있다 + +중복 스칼라 이름은 생성 시점 거부 (두 코어션 → wiring 순서가 승자를 고름). + +`RESTRICTED` 등급 스칼라(실무에서는 `JSON`)는 **나타날 수 있는 좌표를 열거해야 한다.** 안 그러면 +자유형 `JSON` 스칼라가 어디서나 쓸 수 있는 **스키마 검증 우회**가 된다. + +`GraphQlScalarDefinition`은 **`Upload`를 생성 시점에 거부한다.** 플랫폼은 GraphQL multipart +upload를 구현하지 않고 바이너리 생명주기는 Fileserver capability 소유다 — `Upload` 스칼라는 +반쯤 동작하는 약속밖에 될 수 없다. + +`GraphQlScalarWiringConfigurer`는 Spring의 `RuntimeWiringConfigurer`를 쓴다 — raw code registry를 +애플리케이션 코드에 노출하는 대신. wiring은 매니페스트에서 구동되므로 **스칼라가 선언·등급 부여· +(restricted면) 좌표 제한 없이는 스키마에 도달할 수 없다.** + +### 17.6 여섯 개의 커스텀 스칼라 + +| 스칼라 | 결정 | 왜 | +|---|---|---| +| `BigDecimalScalar` | 문자열로 직렬화, **binary float 거부** | `0.1`은 정확한 `double` 표현이 없다. `Double` 수용은 클라이언트-DB 사이에서 금액을 조용히 바꾼다. NaN/Infinity도 거부 | +| `InstantScalar` | 출력은 **항상 UTC `Z`** | 두 서버가 다른 존에서 같은 instant를 다르게 직렬화하지 못하게. 입력의 offset은 수용·변환 (올바른 클라이언트를 깨지 않으려고) | +| `DateScalar` | `Instant`와 **엄격 분리** | *"the invoice is dated a day earlier in Sydney"* 부류의 결함 | +| `LongScalar` | 기본 범위 = **IEEE-754 double-safe 정수 범위** | JavaScript 클라이언트가 ±2^53−1 넘으면 **조용히 반올림**한다. 64비트 전체는 `FULL_RANGE_*`로 명시 opt-in | +| `UuidScalar` | canonical 8-4-4-4-12만 | `UUID.fromString`은 관대해서 `"1-1-1-1-1"`도 받는다 → wire 계약이 **JDK 특이사항에 의존**하고 두 문자열이 같은 식별자를 지시 | +| — | 코어션 실패는 **값을 에코하지 않음** | 거부된 입력은 클라이언트 데이터를 담을 수 있음 | + +`GraphQlDecimalBounds`가 특히 좋다. **네 개의 경계**를 두는 이유: + +> *`1E+1000000` is eleven characters, parses instantly, and holds a precision of one — and then +> `toPlainString()` materialises a million and one characters. Bounding the input text alone +> therefore proves nothing about the output; the output length is its own bound.* + +원래 리뷰 GQL-022(*"scalar input/output limit이 대칭이 아니고 작은 입력이 큰 출력을 만들 수 +있다"*)에 대한 답이다. + +--- + +## 18. `compat` — 스키마 호환성 37종 + +11개 파일 62KB. `runtime`, `autoconfigure` 다음으로 큰 패키지고, 대부분이 `GraphQlChangeKind`다. + +### 18.1 두 개의 청중을 따로 센다 + +**이게 이 패키지의 핵심 아이디어다.** + +```java +public enum GraphQlCompatibilityImpact { COMPATIBLE, REVIEW_REQUIRED, BREAKING } +``` + +그리고 모든 change kind가 **wire impact**와 **generated-client impact**를 따로 갖는다. + +> *Adding an enum value or a union member is additive on the wire, yet it can break a generated +> client whose `switch` is exhaustive — reporting a single "compatible" verdict would hide that.* + +예시: + +| change kind | wire | generated client | 이유 | +|---|---|---|---| +| `TYPE_ADDED` | COMPATIBLE | COMPATIBLE | 추가적 | +| `OUTPUT_FIELD_ADDED_NON_NULL` | COMPATIBLE | **REVIEW_REQUIRED** | 생성 모델이 바뀌고 데이터 보장이 필요 | +| `OUTPUT_FIELD_NULLABILITY_STRENGTHENED` (`T` → `T!`) | COMPATIBLE | **REVIEW_REQUIRED** | 계약은 강해지는데 생성 소스 타입이 바뀜 | +| `OUTPUT_FIELD_NULLABILITY_RELAXED` (`T!` → `T`) | BREAKING | BREAKING | **클라이언트가 의존하는 보장을 철회** | +| `ARGUMENT_ADDED_REQUIRED` | BREAKING | BREAKING | 기존 호출이 전부 빠뜨림 | +| `ARGUMENT_ADDED_OPTIONAL` | COMPATIBLE | COMPATIBLE | 추가적 | + +**impact가 kind에 붙어 있다**는 게 설계 결정이다: + +> *The impacts live on the kind rather than being decided at each call site, so the compatibility +> table is stated once and cannot drift between the comparator, the policy and the release gate.* + +총 **37종**이다. 각각이 세 번째 필드로 근거 문장을 들고 있다. + +### 18.2 `GraphQlSchemaComparator` — AST 비교 + +SDL 문자열 diff가 아니라 AST를 비교하고 결과를 **결정적으로 정렬**한다. 원래 리뷰가 "유지할 +설계"로 꼽은 항목이었다. + +원래 리뷰 GQL-015가 *"comparator가 kind/default/extension/applied directive를 놓친다"*였고, +지금 37종 목록이 그 답이다. + +### 18.3 `GraphQlDeprecationGate` / `GraphQlRemovalRequest` / `GraphQlRemovalDecision` / `GraphQlSchemaUsage` / `GraphQlClientOwnerApproval` + +제거의 전제조건 체인: +1. `GraphQlSchemaUsage` — 누가 이 좌표를 쓰는가 +2. `GraphQlDeprecationGate` — deprecation 기간을 지켰는가 +3. `GraphQlClientOwnerApproval` — 클라이언트 소유자가 승인했는가 +4. `GraphQlRemovalDecision` — 그래서 제거 가능한가 + +`GraphQlCompatibilityPolicy` / `GraphQlCompatibilityReport`가 정책과 결과를 담는다. + +--- + +## 19. `observation` — 카디널리티는 타입으로 지켜지지 않는다 + +9개 파일 23KB. + +### 19.1 `GraphQlMetricCardinalityPolicy` — allowlist, denylist 아님 + +```java +public static final Set ALLOWED = Set.of( + "graphql.operation.name", "graphql.operation.type", "graphql.client.profile", + "graphql.persisted", "graphql.schema.coordinate", "graphql.dataloader.name", + "graphql.outcome", "graphql.complexity.bucket", "graphql.depth.bucket", "error.type"); + +public static final List FORBIDDEN = List.of( + "graphql.document", "graphql.query", "graphql.variables", "graphql.cursor", + "graphql.object.id", "user.id", "tenant.id", "authorization", + "connection_init.payload", "graphql.field.path"); +``` + +> *Every value that is interesting to a debugger — the document, the variables, the object id, the +> cursor — is also unbounded, and one unbounded label is enough to make a metrics backend unusable.* + +`FORBIDDEN`이 따로 있는 건 allowlist가 이미 충분한데도 **의도를 문서화하기 위해서**다. 그리고 +`GraphQlSensitiveAttributeFilter`가 키 이름으로 필터링하며 **credential처럼 생긴 키도 떨어뜨린다.** + +### 19.2 `GraphQlOperationNameCardinality` — 정규식은 카디널리티를 묶지 않는다 + +**이 패키지에서 가장 좋은 통찰.** + +> *`GraphQlOperationName` bounds an operation name's syntax and length, which is a different +> property from bounding how many distinct ones exist. A client is free to send `Query0000001`, +> `Query0000002` and so on indefinitely: every one is valid, and every one used to become its own +> time series. That is a metrics backend brought down by a well-formed client, and the tag that did +> it looked bounded because a regular expression was checking it.* + +**형태 검사가 카디널리티 검사처럼 보인다**는 게 함정이다. 해법: 배포의 **레지스트리**에서 온 +이름만 라벨이 되고, 나머지는 하나의 bucket으로 접힌다. + +원래 리뷰 GQL-023이 이거였고, 증거는 `runtime/GraphQlRequestObservationConventionAdapterTest`가 +**실제 `MeterRegistry`에 임의 이름 10,000개 → series 1개**를 고정하는 것이다. + +### 19.3 `GraphQlResolverObservationConvention` — 좌표지 path가 아니다 + +> *Tagged by schema coordinate, not by response path. The coordinate is a static position in the +> schema; a response path contains list indices and therefore grows without bound with the data.* + +`orders.3.items.17.name`은 데이터에 따라 무제한이다. `Order.name`은 유한하다. + +### 19.4 `GraphQlDataLoaderObservationConvention` — 버킷 + +배치 크기와 캐시 효과는 **버킷 값**으로 기록된다. 유용한 질문은 "배칭이 동작하나"이고, 정확한 +크기는 **서로 다른 배치 크기마다 series를 만든다.** 키 자체는 기록 안 함. + +`GraphQlRequestObservationConvention`도 depth와 complexity를 버킷으로. 정확한 숫자는 사실상 +연속값이고 요청마다 새 series를 만든다. + +### 19.5 `GraphQlObservationNames` — 재발명하지 않는다 + +Spring for GraphQL이 이미 내는 관측 이름을 쓴다. 플랫폼의 기여는 **그 위의 이름과 카디널리티 +규율**이지, 모든 요청을 이중 계산할 두 번째 계측층이 아니다. + +### 19.6 `GraphQlProfilerAccessPolicy` — 프로파일러는 응답 extension이 아니다 + +GraphQL Java 프로파일러는 필드별 타이밍과 DataLoader 동작을 보고한다 — 로컬과 운영 평면에서는 +진짜 유용하고, **클라이언트에 반환하면 백엔드 내부의 정밀한 지도**다. + +--- + +## 20. `architecture` — resolver가 넘으면 안 되는 선 + +9개 파일 38KB. + +### 20.1 `GraphQlTypeGraph` — erasure가 규칙을 무력화했다 + +**이 패키지의 핵심 수정.** + +경계 규칙들이 `Method#getReturnType`과 `Parameter#getType`을 봤는데, 그건 **컨테이너로 erasure +된다**: + +| 선언 | 보이는 것 | +|---|---| +| `List` | `List` | +| `Mono` | `Mono` | +| `Optional` | `Optional` | + +> *Every one of those passed a rule whose whole purpose was to notice the type inside.* + +`GraphQlTypeGraph`가 제네릭 포함 **실제로 도달하는 모든 클래스**를 계산한다. + +### 20.2 `GraphQlAsyncReturnShape` — `Mono`는 `Publisher`다 + +규칙이 원래 subscription 밖의 모든 `Publisher`를 거부했다. 그런데 `Mono`도 `Publisher`다. +그래서 **Spring for GraphQL이 지원하고 문서화하는** `Mono order()`가 플랫폼 자체 +경계 검사에 거부됐다. + +지금은 **하나를 내는가 여럿을 내는가**로 구분한다. query는 비동기로 완료해도 되고, 하면 안 되는 건 +**스트림**이다. + +원래 리뷰 GQL-008 (*"valid reactive query도 거부한다"*)의 답. + +### 20.3 반환/입력 타입 정책 + +**`GraphQlReturnTypePolicy`** — JPA entity나 Mongo document 반환 금지: +- 컬럼 rename이 **breaking API change**가 됨 +- lazy association이 N+1이나 직렬화 중 예외가 됨 +- 공개될 의도가 없던 필드가 **기본으로** 샘 + +무제한 `Map`도 거부 — 타입 계약을 우회한다. + +**`GraphQlInputTypePolicy`** — 위의 쓰기 쪽 거울: + +> *it silently exposes every persistence field as a writable API field, including the ones — +> version, tenant, audit columns, internal flags — that a client must never set.* + +### 20.4 `GraphQlControllerTransactionRule` — resolver에 트랜잭션 금지 + +> *A transaction on a resolver would span the whole field's execution — including DataLoader +> dispatches and downstream calls that have no business inside a database transaction — and it would +> put the boundary in transport code where it cannot be reasoned about with the use case.* + +`architecture/fixture/violating/Transactional.java`라는 테스트 fixture가 있다 — 어노테이션을 +합성해서 negative fixture를 만든다. + +### 20.5 `GraphQlResolverBoundaryRules` / `GraphQlTransportTypeRules` / `GraphQlControllerInspector` + +- resolver는 Application use case와 DTO 매퍼에 의존 가능. repository / `EntityManager` / + `MongoTemplate` 직접 접근 불가. +- resolver 시그니처는 **transport 타입만** — DTO, read model, connection, mutation payload, + 그리고 subscription에서의 publisher. +- **raw `DataFetcher` 구현은 인프라 패키지로 제한.** annotated controller가 주류 API고, + 손으로 쓴 fetcher는 인자 바인딩·컨텍스트·경계를 우회한다. +- `GraphQlControllerInspector`는 **기동 시점**에 돌고 스키마 좌표와 Java 메서드를 함께 보고한다. + +negative/positive fixture가 짝으로 있다: +`fixture/compliant/{FindOrderUseCase, OrderGraphQlController, OrderView}` vs +`fixture/violating/{LeakyGraphQlController, OrderRepository, Transactional}`. + +--- + +## 21. `release` — 증거 없이는 릴리스 없다 + +9개 파일 18KB. + +### 21.1 `GraphQlReleaseEvidence` — 다섯 종류, 전부 필수 + +> *Five independent kinds, all mandatory. They fail in different ways and none substitutes for +> another: a passing contract suite says nothing about behaviour under load, and a passing load test +> says nothing about what happens when a dependency stops responding.* + +| 증거 | 무엇 | +|---|---| +| `schemaPassed` | 스키마 조립·매핑·호환성 게이트 | +| `contractsPassed` | transport / security / DataLoader / pagination 계약 스위트 | +| `performancePassed` | 부하 시나리오 | +| `faultPassed` | 장애·취소 시나리오 | +| (+ 다섯 번째) | compatibility matrix | + +### 21.2 `GraphQlPerformanceScenario` / `GraphQlFaultScenario` — 카탈로그 + +**성능 시나리오가 각각 다른 한계를 자극한다:** + +> *A deep query and a wide alias query stress different budgets; pool saturation and virtual-thread +> saturation fail differently; and event-loop blocking only shows up under concurrency. Running one +> and calling it "load tested" is how the others reach production untested.* + +**장애 시나리오 중 cancellation leak이 가장 자주 건너뛰고 가장 자주 깨진다:** + +> *a timeout that releases the caller while the database statement and the HTTP call keep running +> turns a slow dependency into an exhausted pool, and no functional test detects it.* + +### 21.3 `GraphQlReleaseOverride` — 만료 없는 예외는 꺼진 게이트다 + +> *An override without an expiry is simply a permanently disabled gate, which is how a gate stops +> meaning anything.* + +모든 필드 필수: approver, reason, waivedEvidence, expiresAt. +- approver 없으면 **질문할 수 없고** +- reason 없으면 **리뷰할 수 없고** +- expiry 없으면 **제거되지 않는다** + +### 21.4 `GraphQlCompatibilityMatrix` — BOM이 SSOT + +Spring Boot BOM이 Spring for GraphQL과 GraphQL Java 짝의 진실 원천이다. 둘 중 하나의 독립 +override는 **의도적 호환성 레인에서만** 허용 — 조용히 하나를 고정하는 게 아무도 테스트하지 않은 +조합에 배포가 도달하는 방식이다. + +### 21.5 `GraphQlStableCapabilityManifest` — 지원 안 하는 것도 결정이다 + +> *Written down so "unsupported" is a decision with a reason rather than a gap someone might fill by +> accident. Every entry in `UNSUPPORTED` has an alternative in the design: uploads go through the +> Fileserver, atomic multi-step work goes through one mutation use case, and cross-request caching +> goes through the cache capability with its own key model.* + +§4.3의 `unsupported.*` 설정 플래그가 이것의 런타임 짝이다. + +### 21.6 `GraphQlReleaseReportWriter` — 기계 판독 가능하게 + +파이프라인이 게이트로 쓸 수 있고 나중 감사가 **무엇이 실제로 검증됐는지** 읽을 수 있게. +리포트는 verdict / capability 이름 / 프레임워크 버전을 담고 — 스키마, persisted 문서, 키는 +절대 안 담는다. + +--- + +## 22. 등급 어휘와 `advanced/bootstrap` + +### 22.1 `CLAUDE.md`의 네 등급 — "구현됐다"를 쪼갠다 + +이 leaf가 가장 잘한 것 하나를 꼽으라면 이거다. + +> *"구현됐다"는 네 가지 서로 다른 사실을 한 단어로 덮는다.* + +| 등급 | 의미 | +|---|---| +| `modelled` | 정책·계약 객체가 있고 단위 테스트가 있다. **요청 경로에는 없다.** | +| `wired` | Spring 실행 경로에 연결돼 있고, **실제 endpoint 테스트가 그 사실을 증명한다.** | +| `integration-verified` | 실제 외부 시스템과의 통합 증거가 있다. | +| `production-verified` | 실부하·장애 시나리오 증거가 있다. | + +그리고 **현재 등급보다 높게 표현하지 않는다**는 규칙이 붙어 있다. + +현재 상태 (CLAUDE.md 기준, 증거 테스트명 포함): + +| Capability | 등급 | 증거 | +|---|---|---| +| 실행 파이프라인 / 인가 / cost 예산 | `wired` | `runtime/GraphQlPlatformExecutionPathTest` (random-port, 거부 시 resolver 호출 **0회**) | +| depth/complexity 제한 | `wired` | 같은 테스트 | +| preparsed document cache | `wired` | `GraphQlPreparsedDocumentAdapter` + 캐시 hit 케이스 | +| 커스텀 scalar | `wired` | 같은 테스트의 coercion 케이스 | +| 요청 크기 / Accept 협상 | `wired` | `GraphQlRequestBoundsTest`, `GraphQlAcceptNegotiationTest` | +| 관측 tag cardinality | `wired` | `GraphQlObservationWiringTest` + 실제 `MeterRegistry`에 임의 이름 10,000개 → **series 1개** | +| DataLoader / batching | `wired` | `GraphQlBatchLoaderRegistrationTest` (**50 parent → 3 downstream**) | +| object 인가 | `modelled` | 실행 경로에 연결하는 configuration 없음 | +| **cursor 서명** | `modelled` | **auto-configuration이 `HmacGraphQlCursorCodec`도 `GraphQlCursorKeyRing`도 만들지 않음** — `GraphQlPolicyRequestPathTest`가 고정 | +| mutation 멱등성 | `modelled` | 인터셉터를 참조하는 configuration 없음 | +| persisted operation | `modelled` | 중립 `OperationalRecordStorePort` 기반 레지스트리 + 방향성 테스트. durable 구현 미제공 | +| subscription / WebSocket / SSE / RSocket | `modelled` | 정책·상태기계 단위 테스트만. **Spring transport handler 없음 — 그래서 타입 이름도 `*Admission`이다** | +| federation / incremental / codegen / compat | `modelled` | 단위 테스트만 | +| 실부하·장애 | 미달성 | `graphqlPerformanceTest` 레인이 자리를 예약 | + +**타입 이름이 등급을 반영한다**는 게 특히 좋다. `GraphQlWebSocketAdmission`은 원래 +`GraphQlWebSocketHandlerFactory`였는데, 그 이름은 Spring `WebSocketHandler`를 약속했다. 실제로 +반환하는 건 소켓도 I/O도 없는 상태 기계다. + +> *An adopter who wired the old name where Spring expected a handler found a name that fit and +> behaviour that did not.* + +### 22.2 `advanced/bootstrap` — 하나의 문 + +6개 파일 9.5KB. + +**`GraphQlAdvancedCapability`** — 열거된 capability. *"enumerated so a capability cannot activate +merely by being on the classpath."* + +**`GraphQlAdvancedCapabilityGrade`** — 두 등급, 그리고 등급이 **활성화 요건을 정한다**: + +| 등급 | 필요한 것 | +|---|---| +| Advanced | feature flag | +| **Experimental** | feature flag **+ production에서는 승인 프로파일** | + +> *because the evidence for it has not been collected yet and a config change alone should not put +> it in front of users.* + +Experimental: **RSocket, incremental delivery, HTTP GET draft.** + +**`GraphQlAdvancedFeatureFlags`** — 기본은 전부 off. *"An Advanced capability that arrived because a +dependency was added is exactly what the Stable/Advanced split exists to prevent."* + +**`GraphQlAdvancedModuleGuard`** — **모든 Advanced capability가 통과하는 단 하나의 게이트.** + +> *One place to check means a capability cannot be half-enabled.* + +**`GraphQlAdvancedDependencyRules`** — Advanced가 Stable을 소비한다. 반대 방향 금지. + +> *If a Stable module depended on an Advanced one, every Stable deployment would carry the Advanced +> capability's code and configuration surface — and the feature flag would be the only thing standing +> between an ordinary service and a subscription runtime.* + +이게 §2.4 규칙 (a)의 근거다. + +**`GraphQlAdvancedCapabilityDisabledException`** — 크게 실패한다. 조용히 열화하지 않는다: +*"a subscription endpoint that silently does nothing because its flag is off is far harder to +diagnose than one that refuses to start."* + +--- + +## 23. `advanced/*` 17개 패키지 순회 + +135개 파일 233KB. 순서는 "이 레포에 얼마나 깊이 들어와 있는가" 순으로 잡았다. + +### 23.1 `persisted` (15) — 승인된 오퍼레이션 레지스트리 + +**의존 방향 문제를 푸는 방식이 이 leaf 전체에서 가장 정교하다.** + +문제: persisted operation의 durable 저장은 인프라에 살아야 하는데, **인프라가 인바운드 전송의 +타입을 구현하면 안 된다.** Postgres 어댑터가 `GraphQlPersistedOperationRegistry`를 구현하면 +의존이 DB에서 GraphQL 경계로 향한다. + +해법 (3단): + +``` +GraphQlPersistedOperationRegistry (SPI, graphql leaf 소유) + └ OperationalStoreGraphQlPersistedOperationRegistry ← 실제 구현 + └ dev.caskeleton.shared.opstore.OperationalRecordStorePort (shared-contract, 중립) + ↑ + └ Postgres/Redis 어댑터가 이걸 구현 — GraphQL 타입을 절대 이름짓지 않음 +``` + +`GraphQlPersistedOperationRecordMapping`이 **유일하게 양쪽을 아는 클래스**다: + +> *So the store speaks `OperationalRecord` and knows nothing about GraphQL, and this class is the +> only place that knows [both].* + +**상태 기계**: + +``` +GraphQlPersistedOperationStatus: ACTIVE / DEPRECATED / BLOCKED +GraphQlPersistedOperationTransition: ... UNBLOCK +``` + +`BLOCKED`가 boolean이 아니라 상태인 이유: 인시던트 중에 **비싼 오퍼레이션 하나를 재배포 없이, +다른 사람의 엔드포인트를 내리지 않고 즉시 멈출 수 있어야** 한다. + +그리고 전이 테이블이 있는 이유가 지독하다: + +> *Without a table, `updateStatus` accepted anything, and the sequence that mattered was +> `BLOCKED → DEPRECATED`: an operation stopped during an incident could be made executable again by +> a status change that read like a documentation update.* + +**`BLOCKED`는 `UNBLOCK`을 통해서만 벗어날 수 있는 terminal이다.** + +**CAS**: `OperationalStoreGraphQlPersistedOperationRegistry`의 등록과 전이는 **읽은 버전에 대한 +compare-and-set**이다. + +**요청 시점 검사** (`GraphQlPersistedOperationPolicy`): + +> *Being in the registry is not permission to run.* + +- 여전히 active인가 +- 이 client에 속하는가 +- 배포된 스키마와 매치하는가 (`GraphQlPersistedOperation`이 승인 시점 스키마 해시를 들고 있다) +- 자기 한계와 client 한계 중 **더 엄격한 쪽** 안에 있는가 + +**그리고 이 중 어느 것도 인가가 아니다.** 인가는 그 다음에 actor에 대해 일어난다. + +**`GraphQlPersistedOperationRequest.suppliedDocumentHash`** — 일부 클라이언트는 id **와** 문서를 +둘 다 보낸다. 그럴 때 둘이 일치해야 한다 — *"otherwise a caller could quote an approved id while +executing a document of their own."* + +**`GraphQlPersistedOperationLookup`** — 모르는 id와 blocked id가 **같은 메시지**로 거부된다. +응답이 어떤 id가 존재하는지 알려주지 않게. + +**`GraphQlPersistedPreparsedBridge`** — 레지스트리와 preparsed 캐시를 합치면 안 되는 이유: +레지스트리는 문서가 **돌아도 되는지**를 정하고 캐시는 이미 돼도 되는 문서의 재파싱을 피할 뿐이다. +그래서 **블록은 매 요청 검사**지 캐시 채울 때 한 번이 아니다. + +**인터셉터는 파싱 전에 돈다** — id가 있으면 **레지스트리가 문서 공급원**이니까 (§8.1의 +`PERSISTED_LOOKUP → PARSE_VALIDATE` 제약). + +### 23.2 `admin` (10) — 운영 평면 (G4) + +`persisted`의 짝. **여기 javadoc들이 이 leaf에서 가장 신랄하다.** + +**`GraphQlAdminPrincipal`** — 문자열은 증거가 아니다: + +> *The admin service used to take the operator as a bare `String` and check it against an allowlist. +> A string is not evidence: any caller that could reach the service could name any operator on the +> list, so the allowlist described who **may** administer the registry while proving nothing about who +> actually did. The audit trail then recorded that name as fact.* + +지금은 **전송만이 이걸 생성할 수 있고**, 자격증명이 request path에서 왔는지도 기록한다. +*"That check used to exist as a method nobody called."* + +**`GraphQlPersistedOperationAdminPort`** — 변경과 감사를 **하나의 durable 단위로**: + +> *The two used to be separate steps: the registry was mutated, then an entry was appended to an +> `ArrayList` field. A crash between them left a change nobody could account for, a failure in the +> append left a change with no record, and the list itself was not thread-safe, so two concurrent +> administrators could lose an entry outright. An audit trail with any of those properties is worse +> than none, because it is trusted.* + +마지막 문장이 이 문서 전체에서 가장 인용할 만하다: **믿어지기 때문에, 그런 성질을 가진 감사 기록은 +없느니만 못하다.** + +**`GraphQlPersistedOperationAdminService`** — 모든 명령이 저장된 오퍼레이션을 반환하고, 적용할 수 +없는 명령은 던진다: + +> *the audit trail records changes that happened rather than changes that were attempted — the +> previous service wrote `ABSENT -> BLOCKED` for operations that did not exist.* + +**`GraphQlPersistedOperationAdminAuthorization`** — request path와 **별개의 권한**: + +> *Application credentials are held by every running instance and reachable from any resolver; if +> one of them could register or block an operation, a compromised request path could rewrite what the +> whole platform is willing to execute.* + +**`GraphQlPersistedOperationRemovalGate` / `GraphQlPersistedOperationUsage`** — "지금 트래픽 없음"과 +"아무도 안 씀"은 다르다. 월간 리포트나 느린 롤아웃 중인 모바일 빌드는 몇 주 조용하다가 다시 보낸다. +usage는 **카운트와 마지막 사용 시각만**, 호출자는 절대 — *"the identity of who ran an operation is +not needed to decide whether it is still in use."* + +**`GraphQlPersistedOperationAudit`** — operator, reason, before/after, time, trace. **변수와 +자격증명은 절대** — 그래야 감사 기록 자체가 보관해도 안전하다. + +**`GraphQlPersistedOperationAdminDeniedException`** — operator 정체성을 안 담는다. 거부가 관리자 +목록 열거에 쓰이지 않게. + +### 23.3 `subscription` (16) — 장수명 스트림 + +**`GraphQlSlowConsumerPolicy`** — 기본은 **종료**: + +> *GraphQL has no replay: a dropped event is simply gone, and the client has no way to learn that it +> missed one. Dropping is only acceptable where losing an event is genuinely harmless — telemetry, +> presence — and that has to be an explicit choice.* + +**`GraphQlSubscriptionBufferPolicy`** — 이벤트 수와 바이트 둘 다: + +> *a slow consumer is a memory leak with a schedule: the source keeps producing, the socket keeps +> not draining, and the buffer grows until the process dies.* + +**`GraphQlSubscriptionLease`** — `register`/`deregister` 짝 대신 lease: + +> *a stream that ended on an error path without its matching deregister left the count permanently +> above zero, and the node then drained for the full timeout on every shutdown while reporting +> subscriptions that no longer existed. Releasing twice is just as damaging in the other direction — +> it decrements someone else's subscription.* + +**정확히 한 번** 해제된다. + +**`GraphQlSubscriptionDrainCoordinator`** — 우아한 종료: + +> *dropping thousands of subscriptions at once produces a reconnect storm against an instance that is +> already leaving.* + +새 subscription은 즉시 거부, 기존은 유한한 창, 창에는 deadline (멈춘 스트림이 종료를 무한정 +지연시키지 못하게). **phase / count / drain 시작이 하나의 불변 값으로 함께 움직인다** — 세 필드를 +따로 두면 그 사이 상태가 관찰된다. + +**`GraphQlSubscriptionDrainPhase`** — boolean이 아니라 3단계. *"'draining'과 'finished draining'은 +종료 질문에 대한 다른 답이고 boolean은 하나만 나를 수 있다."* 전이는 일방향. + +**`GraphQlSubscriptionOrderingProfile` / `GraphQlSubscriptionExecutionPolicy`** — 정직한 트레이드오프: + +> *Subscription items complete asynchronously, so without buffering they can arrive out of source +> order — fine for a live counter, wrong for a state machine. Preserving order costs head-of-line +> blocking and memory, which is why the choice is per subscription.* + +`keepSourceOrder`는 GraphQL Java의 subscription ordering 플래그에 매핑된다 — **전송이 척하는 게 +아니라 엔진이 버퍼링한다.** + +**`GraphQlSubscriptionEvent`** — 브로커 메시지를 forward하지 않고 GraphQL DTO로 번역한다: + +> *A messaging schema and a GraphQL schema evolve on different timelines and for different audiences; +> publishing the raw message would make every internal event-shape change a public API change.* + +**`GraphQlSubscriptionSource`** — `Publisher`인 이유는 **취소가 소스까지 전파되게** 하려고. +그리고 명시적으로: durability / acknowledgement / replay는 **messaging 플랫폼 소관**이고 GraphQL +subscription이 재구현하지 않는다. + +**`GraphQlSubscriptionMetrics`** — 종료는 reason별로(작은 enum). **connection/operation 정체성은 +차원이 아니다** — 클라이언트마다 하나씩이라 라벨이 되면 연결마다 series가 생긴다. + +**`GraphQlSubscriptionState`** — "connected"가 여러 상황을 덮는다: 인증 안 한 소켓, 인증했지만 +구독 안 한 소켓, 스트리밍 중인 소켓. 운영 질문(몇 개가 draining인가, 몇 개가 인증을 못 끝냈나)에 +답하려면 구분돼야 한다. + +### 23.4 `websocket` (7) — `graphql-transport-ws`만 + +**`GraphQlWebSocketProtocol`** — 오직 `graphql-transport-ws`. 낡은 `subscriptions-transport-ws`는 +superseded이고 유지보수되지 않으며, **광고하는 건 아무도 고치지 않는 프로토콜에 플랫폼을 +묶는 것**이다. + +**`GraphQlWebSocketAdmission`** — §22.1에서 다룬 이름 변경. + +**`GraphQlWebSocketHandlerFactory`** — 팩토리인 이유는 **입력이 셋이고 그중 하나만 플래그**여서다: +capability enabled + properties가 경계 지을 수 있는 연결을 서술 + admission policy 존재. + +> *A configuration class that checked only the flag would produce a handler with an unbounded +> connection lifetime whenever a deployment forgot the rest, and an unbounded WebSocket is a +> connection slot held by whoever opens [it].* + +**`GraphQlWebSocketProperties` / `GraphQlWebSocketLifecycle`** — +- `connectionInitTimeout`: **인증하지 않는 연결이 슬롯을 점유하지 못하게.** *"a client can open + connections and never authenticate, holding server slots at no cost to itself."* +- `idleTimeout`, `maximumAge`: **자격증명보다 며칠 오래 사는 연결에 재인증을 강제.** + +**`GraphQlWebSocketConnectionId`** — 로그 상관과 drain 회계용. **메트릭 라벨로는 절대.** + +**`GraphQlWebSocketProtocolException`** — `connection_init` payload를 안 담는다. 거기가 자격증명이 +도착하는 곳이다. + +### 23.5 `security` (7, advanced) — 장수명 연결의 인증 + +**`GraphQlWebSocketAuthenticationInterceptor`** — 자격증명은 `connection_init` payload로 오고 +**절대 로깅·측정되지 않는다.** 그리고 정체성이 pin된다: + +> *the connection ends when the credential expires or is revoked, rather than being silently +> refreshed — a silent refresh would mean the server decided the client should stay connected after +> the credential it presented stopped being valid.* + +**`GraphQlWebSocketPrincipal`** — actor/tenant **fingerprint**만, 연결 수명 내내 pin. + +> *allowing the tenant to change per operation on one connection would make the authenticated context +> negotiable by the client.* + +**`GraphQlWebSocketCredentialExpiry` + `GraphQlWebSocketRevocationSignal`** — 둘 다 필요하다: + +> *Expiry alone is not enough: a credential valid for another hour is still valid after the user's +> access is removed, and a live subscription would keep delivering until it expired.* + +**`GraphQlSubscriptionAuthorizationPolicy`** — 구독 시점 검사만으로 충분한 건 소유권이 바뀔 수 없는 +스트림뿐이다. 바뀔 수 있는 곳(공유 문서, 누군가 제외될 수 있는 주문)에서는 **이벤트마다** 검사 — +연결이 이벤트 생성 몇 시간 전에 인가됐을 수 있으니까. + +**`GraphQlWebSocketCloseReason`** — 경계 있는 집합. 메트릭 라벨로 안전하고, 클라이언트가 +"자격증명 만료, 재연결"과 "서버가 내려감, 재시도"를 구분할 수 있다. + +### 23.6 `replay` (8) — 스냅샷과 라이브의 접합 + +**`GraphQlSnapshotLiveHandoff`** — 재개는 스냅샷을 읽고 라이브로 잇는 것이고, **둘이 정확히 +만나야 한다.** + +``` +snapshotPosition = 스냅샷이 포함한 마지막 위치 +liveStartPosition = 라이브가 전달할 첫 위치 +``` + +> *A live stream starting later than the snapshot ends loses events; one starting earlier repeats +> them. Both are checked, because "roughly continuous" is not something a client can compensate for.* + +**`GraphQlReplayGapException`** — 갭은 크게 실패한다. *"silently continuing would present an +incomplete stream as a complete one."* + +**`GraphQlReplayHistoryLostException`** — 거부된 커서와 **구분되는 결과**다. 커서는 진짜인데 히스토리가 +사라진 것 — 클라이언트의 올바른 대응은 재인증이 아니라 **스냅샷을 다시 읽는 것**이다. + +**`GraphQlSubscriptionCursor`** — §13의 커서와 같은 원리. 서명되고 tenant/actor/subscription +프로파일에 바인딩. **tenant를 actor fingerprint가 함의하게 두지 않는다:** + +> *An actor identifier that happens to be unique per tenant today stops being a tenant check the +> moment one identity can act in two tenants, and the failure is silent — the cursor verifies, the +> actor [matches].* + +**`GraphQlReplayAuthorization`** — 라이브 구독보다 **엄격**하다. 재개하는 tenant와 actor가 커서가 +발급된 대상이어야 하고 **지금도 인가되어야 한다** — 이벤트 생성 시점에 부여된 접근이 그 사이 +철회됐을 수 있다. + +**`GraphQlReplayPosition` / `GraphQlReplaySource`** — 시퀀스는 **messaging 플랫폼의 것**이지 +GraphQL의 것이 아니다: + +> *GraphQL has no resume token, and inventing one here would promise a durability guarantee the +> transport cannot keep.* + +보관 기간도 messaging의 것. 이 인터페이스는 **위치가 아직 살아 있는지만** 묻는다. + +**`GraphQlReplayAuthorizationException`** — actor 정체성도 위치도 안 담는다. 누구 커서였는지, +얼마나 멀리 갔는지 드러나면 안 된다. + +### 23.7 `sse` (7) — Distinct Connection + +**`GraphQlSseConnectionPolicy`** — **subscription만.** + +> *Spring's SSE support is a distinct-connection mode — one HTTP connection per subscription — which +> suits a server-to-client stream and is a poor fit for a query, where the client is paying for a +> streaming connection to receive one response.* + +**`GraphQlSseHandlerFactory`** — 놀라게 하는 성질을 팩토리에서 말한다: + +> *`graphql-transport-ws` multiplexes many subscriptions onto one socket and this does not, so a +> client with twenty subscriptions holds twenty connections and a browser's six-per-origin limit is +> reached at six.* + +**`GraphQlSseHeartbeat`** — **마지막 쓰기(종류 무관)로부터** 측정. 바쁜 스트림은 불필요한 하트비트를 +안 보내고, 조용한 스트림은 타임아웃시킬 중간자를 통과해 열려 있다. + +**`GraphQlSseProperties`** — *"proxies and load balancers close idle connections, and without a +periodic comment frame a quiet subscription is dropped by infrastructure the application never +sees."* + +**`GraphQlSseTermination`** — WebSocket과 **같은 종료 어휘**. 운영자가 전송에 무관하게 하나의 +이유 집합을 읽는다. + +**`GraphQlSseAdmission`** — 요청 형태는 POST + JSON body + `Accept: text/event-stream`. **다른 모든 +전송과 같은 요청 봉투**에 스트리밍 응답. 인가와 cost 정책은 WebSocket 것을 쓰고 전달 메커니즘만 +다르다. + +### 23.8 `rsocket` (8) — Experimental, 게이트 두 개 + +**`GraphQlRSocketHandlerFactory`** — 게이트가 둘인 이유: + +> *A flag can be set by anyone editing configuration; the approval is a separate act, and separating +> them is what stops an experimental transport from being switched on the way a supported one would +> be.* + +세 번째 조건: **named consumer 목록.** + +> *This is an experimental transport for internal systems, and one that nobody is identified as using +> is one nobody is testing.* + +**`GraphQlRSocketRoutePolicy`** — prefix match가 아니라 allowlist. *"RSocket routing is string-based, +and a pattern is exactly how an unintended handler becomes reachable."* + +**`GraphQlRSocketCapability`** — 오퍼레이션 타입 → interaction model이 **고정 매핑**이지 선택이 +아니다. query를 request-stream으로 하면 클라이언트가 기대하는 방식으로 완료되지 않는 스트림을 +기다린다. + +**`GraphQlRSocketErrorMapper`** — 의도적으로 **같은 resolver**: + +> *a masked internal error over HTTP and a disclosed one over RSocket would mean the safest transport +> is whichever the attacker did not choose.* + +**`GraphQlRSocketAuthentication`** — MIME 타입도 allowlist. *"metadata drives how a credential is +parsed, and an unexpected encoding is an unexpected parser."* + +### 23.9 `springdata` (7) — 명시적으로 "호환 경로" + +**이 패키지가 원래 리뷰와 가장 크게 갈린 지점이다.** 리뷰 GQL-009은 *"repository 자동 노출은 +Advanced여도 제거한다"*였는데, 지금은 **allowlist를 붙여서 남아 있다.** + +`GraphQlRepositoryExposureValidator`의 javadoc이 그 선택을 방어한다: + +> *This whole capability is a compatibility path and is documented as one. It exists so an existing +> Spring Data GraphQL application can be brought onto this platform without rewriting every fetcher on +> day one; it is not the mainstream way to serve a field here.* + +그리고 `unsupported.repositoryAutoExposure`가 켜져 있으면 기동을 거부한다 (§4.3) — 즉 **Advanced +호환 capability 밖에서는 여전히 금지**다. + +안전 속성 4개: + +**`GraphQlRepositoryAllowlist`** — 기본이 비어 있다: + +> *Spring's `@GraphQlRepository` auto-registers a data fetcher for every annotated repository it +> finds, so without an allowlist the set of exposed fields is whatever somebody annotated — which is a +> decision made in a persistence class, by whoever was working on persistence, and reviewed as a +> persistence change.* + +**API 표면 결정이 persistence PR에서 리뷰된다**는 지적이 정확하다. + +**`GraphQlRepositoryExposure`** — **쌍**이지 repository 단독이 아니다. 같은 repository가 +`Query.orders`와 `Query.allOrders`에서 닿으면 **청중이 다른 두 개의 노출**이고, repository만으로 +키잉된 allowlist는 하나의 결정으로 둘 다 승인한다. + +**`GraphQlRepositoryArgumentPolicy`** — 놓치기 쉬운 절반: + +> *a query-by-example or Querydsl fetcher derives its predicate from whatever arguments arrive, so an +> un-allowlisted argument set means the client writes the `WHERE` clause — including over columns the +> schema never published, because the binder resolves against the entity rather than against the +> GraphQL type.* + +sort는 filter와 **따로** allowlist된다 — 비슷하게 읽히고 부하 하에서 다르게 동작한다. + +**`GraphQlRepositoryPaginationPolicy`** — 명시적, 절대 상속 아님: + +> *Spring Data's GraphQL integration defaults to offset pagination with a page size of twenty, and a +> default is exactly what nobody reviews: a field that quietly serves twenty rows is fine until the +> client asks for the offset at row four hundred thousand, at which point the database is sorting the +> whole table to skip it.* + +**`GraphQlRepositoryProjectionPolicy`** — entity 타입을 **이름으로 거부**한다: + +> *an entity returned from GraphQL is the database schema published as an API — every column, every +> relation the fetch plan happens to traverse, and a rename in the database becoming a breaking change +> for every client.* + +**`GraphQlRepositoryExposureValidator`는 schema build 시점**에 돈다: + +> *An unregistered exposure that only failed when somebody queried it would be a field that exists, +> appears in introspection and errors — which is worse than one that was never published, because a +> client has already written code against it.* + +### 23.10 `federation` (7) + `composition` (8) — subgraph만 소유한다 + +**`GraphQlFederationCapability`** — *"The subgraph, and nothing else. A router is a separate +deployment with its own availability, its own scaling and its own on-call — running one is a project, +not a library feature."* + +**`GraphQlFederationProperties`** — 기본 off. 단일 executable schema가 Stable 토폴로지: + +> *federation buys independent deployment at the cost of cross-subgraph N+1, network amplification, +> deployment ordering and duplicated authorization.* + +**네 가지 비용을 전부 이름 붙여 놓은 게** 좋다. + +**`GraphQlFederationSchemaFactory`** — 플래그 없으면 **아무것도 등록 안 함**: + +> *a schema that advertises `_entities` and `_service` tells a router it may send entity references, +> and the router will.* + +**`GraphQlFederationEntityResolver`** — 해석은 **항상 Application query service를 거친다**: + +> *an entity reference arriving from a router is still a request from outside, and it needs the same +> authorization and tenant scoping as one arriving over HTTP.* + +**`GraphQlFederationBatchResolver`** — router가 `_entities` 배열 전체를 보내므로 하나씩 해석하면 +**N+1 × subgraph × federated request**가 된다. + +**`GraphQlFederationRepresentationException`** — 키 필드가 빠지면 부분 해석이 아니라 거부: +*"without the full key the subgraph would have to guess which entity was meant, and a guess here +returns another tenant's or another user's object."* + +**`GraphQlFederationEntityKey`** — 버전 있는 공개 계약이지 구현 세부가 아니다. + +**composition (8)** — 릴리스 게이트 쪽: + +**`GraphQlFederationCompositionResult`** — router 프로젝트가 만들고 여기가 소비한다. 이 레포는 +subgraph를 소유하고 supergraph를 스스로 조합할 수 없다. + +**`GraphQlFederationReleaseEvidence`** — 네 증거 중 **composition이 가장 약하다**: + +> *It proves the schemas fit together, not that entity keys are stable, that cross-subgraph latency is +> within budget, or that a partial subgraph failure produces a sensible response rather than a nulled +> supergraph.* + +**`GraphQlFederationDeploymentOrder`** — **추가는 subgraph 먼저, 제거는 router 먼저.** + +**`GraphQlFederationLatencyBudget`** — 시간뿐 아니라 **hop 수**: + +> *A query that fans out to four subgraphs pays four network round trips before any data is read, and +> a per-entity downstream call turns that into a cross-subgraph N+1 — invisible in any single +> subgraph's own metrics.* + +**`GraphQlFederationUsageReport`** — entity key를 바꾸기 위한 전제조건: +*"without it, the only way to find out who depended on a field is to remove it and wait for another +team's incident."* + +**`GraphQlSubgraphContract`** — owner 필수. *"'who approves this' has to be answerable before the +change is proposed, not after it lands."* + +### 23.11 `codegen` (8) — "generator"라는 이름이 틀렸었다 + +**`GraphQlClientOperationGenerator`** — 이름이 약속을 어겼다: + +> *Named a plan because that is what it produces ... No source writer and no Gradle task exist behind +> it, and calling it a generator invited the reasonable assumption that running it emitted files.* + +**`GraphQlOperationValidator`** — 이 leaf에서 가장 인상적인 "가짜 검사" 사례: + +> *The previous check confirmed both strings were non-blank and then compared the schema with itself, +> which is true of every schema. The operation document was never read, so a document with invalid +> syntax, an unknown field, or an argument that does not exist passed validation and became generated +> client code that fails at runtime — in the client's repository, against a schema that had already +> changed.* + +**스키마를 자기 자신과 비교했다.** 항상 참인 assertion. 지금은 실제로 `GraphQLSchema`로 컴파일해서 +문서를 검증한다. + +**`GraphQlGeneratedCompatibilityGate`** — §18.1의 두 청중을 codegen 쪽에서 다시 말한다: +*"adding an enum value or a union member is additive on the wire, and it breaks a generated client +whose `switch` is exhaustive."* + +**`GraphQlGeneratedSourceBoundary`** — 생성해도 되는 것과 안 되는 것: +- ✅ client / transport 타입 — 스키마를 그대로 미러링하고 로직이 없다 +- ❌ domain entity, use case, repository — *"generating them from the schema inverts the dependency + the whole architecture rests on."* + +**`GraphQlScalarMapping`** — 명시적 선언. *"a generator that does not know a scalar falls back to +`String` — and a `BigDecimal` arriving as a `String` in generated client code is precisely the +precision loss the scalar was defined to prevent."* + +**`GraphQlCodegenProfile`** — 생성 소스는 자기 디렉터리·패키지로. 섞으면 다음 실행이 사람이 쓴 걸 +덮거나 조용히 재생성을 멈춘다. + +### 23.12 `get` (7) — HTTP GET draft + +**`GraphQlHttpGetOperationPolicy`** — query만: + +> *A mutation over GET is a side effect behind a URL: prefetchers follow it, caches store it, and a +> link is enough to trigger it.* + +**`GraphQlHttpGetCachePolicy`** — GET이 매력적인 이유와 위험한 이유가 같다: + +> *A GraphQL response depends on the actor, so a shared cache keyed by URL will serve one user's data +> to another.* + +**`GraphQlHttpGetCsrfPolicy`** — 쿠키가 자격증명이면 필수. *"A GET with cookies is triggerable by any +page that can make the browser fetch a URL, and the read it performs is a read of the victim's data."* + +**`GraphQlHttpGetProfile`** — URI 예산: *"query strings are truncated by proxies at lengths nobody +controls."* + +**`GraphQlHttpGetRejectedException`** — **쿼리 텍스트를 안 담는다.** *"a rejected GET's document is in +the URL, which is the reason GET is risky in the first place."* + +**`GraphQlHttpDraftCompatibilityReport`** — draft가 Stable POST 계약과 **별도로** 움직인다. +*"This report is where draft drift is observed, so adopting a change stays a decision rather than a +consequence of upgrading a library."* + +### 23.13 `incremental` (7) — `@defer` / `@stream` + +**`GraphQlIncrementalDeliveryProfile`** — 버전 게이트. `@defer`/`@stream`은 2025년 9월 Stable 계약에 +없고 엔진·전송 지원이 아직 정착 중이다. *"Pinning the profile is what keeps a library upgrade from +silently changing what clients receive."* + +**`GraphQlIncrementalCompatibilityGate`** — **절대 complete response로 폴백하지 않는다**: + +> *A silent fallback means the feature appears to work in every environment where it is not actually +> enabled, and fails only where someone relied on it.* + +**`GraphQlIncrementalDeliveryCapability`** — 엔진과 전송이 **독립적으로** 움직이고 **둘 다** 필요하다. + +**`GraphQlIncrementalPatch`** — 자기 path와 errors를 나른다. deferred fragment는 자기 혼자 실패할 수 +있고, 그 에러는 패치의 path에 속한다 — initial result에 귀속시키면 **클라이언트에게 틀린 필드가 +실패했다고 말한다.** + +**`GraphQlIncrementalCancellation`** — 클라이언트가 initial result 뒤에 끊으면 deferred 작업이 계속 +돈다. *"doing database and downstream work for a response that can no longer be delivered."* + +**`GraphQlIncrementalTransportPolicy`** — §10.4에서 다룸. + +### 23.14 `chaining` (6) — chained DataLoader dispatch + +**`GraphQlChainedDataLoaderPolicy`** — 기본 off. **이유가 좋다:** + +> *Chained dispatch changes when loaders fire, which changes query counts, batch sizes and result +> ordering — all things existing N+1 regression tests assert on. Enabling it is a deliberate change +> with its own regression evidence, not a free improvement.* + +**"공짜 개선이 아니다"**가 요점. + +**`GraphQlDataLoaderDependencyGraph`** — **선언**이지 추론이 아니다. *"the platform cannot discover +from bytecode that one loader's keys come from another's results."* + +**`GraphQlDataLoaderCycleDetector`** — DFS로 **찾은 사이클을 보고**한다, 존재만이 아니라. +*"with a dozen loaders, 'there is a cycle somewhere' is not an actionable diagnostic."* + +**`GraphQlChainedLoaderMetrics`** — 쿼리 수와 배치 크기가 chaining이 도왔는지 해쳤는지 증명하는 +숫자다. 없으면 **효과를 아무도 잴 수 없는 변경**이고, 그게 기본 off인 이유다. + +### 23.15 `release` (7, advanced) — Advanced 릴리스 게이트 + +**`GraphQlAdvancedReleaseEvidence`** — **Stable baseline이 먼저고 협상 불가:** + +> *an Advanced capability sits on top of the Stable platform's transport, error, security and cost +> guarantees, so releasing one on an unproven base means its own evidence was gathered against +> something that might not hold.* + +**`GraphQlAdvancedSoakScenario`** — 전부 **지속 시간 문제**: + +> *A buffer leak, a connection that never re-authenticates and a source that reconnects badly all look +> fine for the first minute.* + +**`GraphQlAdvancedRunbookIndex`** — 모든 Advanced capability는 Stable에 없는 실패 모드를 들여온다 — +연결 폭풍, 되돌려야 할 블록된 오퍼레이션, 롤백할 subgraph. 런북을 미리 요구하는 게 +**인시던트 중에 처음 쓰는 일을 막는다.** + +**`GraphQlAdvancedPromotionDecision`** — Experimental 탈출에는 owner 있는 기록된 결정이 필요하다: +*"Without one a capability drifts into production by habit — enabled in one environment, then another, +until nobody remembers it was never approved."* + +**`GraphQlAdvancedCompatibilityMatrix`** — Stable matrix를 **확장**하고 대체하지 않는다. Advanced만 +갖는 프로토콜 버전(WebSocket sub-protocol, federation spec 버전, codegen 엔진)을 추가. + +--- + +## 24. 테스트 레인, 계약 스위트, 아티팩트 게이트 + +### 24.1 네 개의 레인 + +`src/gradle/graphql-platform-conventions.gradle` (150줄). + +| 레인 | 무엇 | 태그 | +|---|---|---| +| `graphqlStableTest` | Stable 플랫폼 단위 + 경계 테스트 | (기본) `graphql-advanced` 제외 | +| `graphqlContractTest` | 크로스 모듈 계약 스위트 | `@Tag("graphql-contract")` | +| `graphqlAdvancedTest` | Advanced/Experimental capability | `@Tag("graphql-advanced")` | +| `graphqlPerformanceTest` | 부하·soak·장애 시나리오 | `@Tag("graphql-performance")` | + +기본 `test`는 `quarantine`과 `graphql-performance`를 **제외**한다 — *"so external load and soak work +can never run inside the unit lane."* + +`CLAUDE.md`가 마지막 green 실행의 실제 숫자를 적어 둔다: Stable 605, Contract 9, Advanced 152, +기본 `test` 757, transport qualification 8. 그리고 **그 숫자를 매번 다시 읽어 갱신하라**는 규칙이 +붙어 있다 — *"컴파일이 깨진 채로 남은 과거 숫자는 통과 증거가 아니라 통과했다는 인상일 뿐이다."* + +(참고: 현재 소스의 `@Test`+`@ParameterizedTest`가 762개다. 레인 숫자와는 세는 단위가 다르다 — +`@ParameterizedTest`는 여러 케이스를 만든다.) + +### 24.2 세 겹의 fail-closed + +**(1) `requiredStableClasses` — 빈 레인이 아니라 사라진 클래스를 잡는다** + +```groovy +List requiredStableClasses = [ + "${platformPackage}.moduleboundary.GraphQlModuleBoundaryTest".toString(), +] +``` + +주석이 이유를 정확히 쓴다: + +> *`failOnNoMatchingTests` only reacts to an empty lane, so deleting one boundary class out of four +> hundred tests is invisible to it — and losing exactly this class is how the platform shipped without +> an enforced module boundary in the first place.* + +구현이 JUnit XML을 읽어서 **실제 실행된 클래스 집합**을 확인한다. 실패 메시지가 훌륭하다: + +> *"The lane is green only because the class is gone; restore it rather than removing it from +> requiredStableClasses."* + +**(2) stale XML 삭제** + +```groovy +doFirst { + File staleResults = reports.junitXml.outputLocation.get().asFile + if (staleResults.exists() && !staleResults.deleteDir()) { throw new GradleException(...) } +} +``` + +> *Stale XML from a previous run would let a deleted class report as executed.* + +(1)이 XML을 읽으니까 (2)가 없으면 (1)이 무력화된다. **검사가 읽는 증거의 신선도까지 검사한다.** + +**(3) 빈 performance 레인은 실패다** + +```groovy +// `failOnNoDiscoveredTests` alone does NOT cover this: it reacts to an empty candidate class +// scan, and this lane always scans a non-empty test tree that JUnit then tag-filters down to +// zero. Without the explicit result check below the lane reports BUILD SUCCESSFUL while +// proving nothing. Verified empirically on Gradle 9.0.0. +``` + +**"Verified empirically on Gradle 9.0.0"** — 프레임워크 플래그가 기대대로 동작하지 않는다는 것을 +실제로 확인해 보고 적었다. 이런 주석이 많지 않다. + +에러 메시지도 대안을 준다: *"Register `@Tag("graphql-performance")` scenarios or run the lane against +the external load environment that owns them."* + +### 24.3 `testFixtures` — 계약 스위트와 in-memory 스텁 + +16개 파일 1,074줄. `java-test-fixtures` 플러그인. + +**`GraphQlContractFixture`** — 이 fixture의 javadoc이 핵심을 말한다: + +> *Runs the actual method and media-type checks, envelope validation and status mapping rather than +> asserting against a hand-built response. **A contract test that constructs the response it then +> asserts on proves nothing about the contract.*** + +그리고 **의도적으로 전송이 아니다**: + +> *This fixture executes nothing and owns no threads: it composes the pure policies, so it can never +> become the second execution path that the custom MVC and WebFlux adapters had turned into.* + +`close()`가 아무것도 안 하고, 주석이 그걸 말한다: +`// No executor, no threads, no route: there is deliberately nothing to release.` + +**`GraphQlHttpContractSuite`** — 어떤 전송에도 replay 가능한 HTTP 계약: + +> *The rule worth a suite of its own: a field error is HTTP 200 with whatever data resolved. It is +> easy to break by "improving" error handling into a 500, and the breakage looks like better hygiene +> until a client loses half a working response.* + +검사 항목: +- field error → **200**, sibling 데이터 생존, errors에 보고, 선호 미디어 타입 협상 +- legacy `application/json` 클라이언트 계속 지원 +- 비-POST → **405** + errors에 설명 + +**`GraphQlJpaIntegrationFixture` / `GraphQlMongoIntegrationFixture` / `GraphQlStorageIntegrationEvidence`** +— 실행되지 않은 증거는 **통과가 아니다**: + +```java +if (!evidence.executed()) { + violations.add(evidence.operationName() + + " has no executed PostgreSQL evidence; an unexecuted run is not a pass"); +} +``` + +> *"We did not run it" must not read the same as "it passed".* + +그리고 **타이밍이 아니라 카운트**를 잰다: + +> *an N+1 is invisible in latency on ten rows, and H2 does not reproduce PostgreSQL's planner, its +> locking or its examined-row accounting.* + +`statementCount`와 `examinedRows` — *"those are the numbers that make an N+1 or a missing index +visible. A latency figure alone hides both on a small dataset."* + +**나머지 testkit**: `GraphQlSchemaContractSuite`, `GraphQlSecurityContractSuite`, +`GraphQlDataLoaderContractSuite`, `GraphQlPaginationContractSuite`, `GraphQlDownstreamFailureFixture`, +`GraphQlRequestContexts`, `GraphQlContractResponse`, `GraphQlContractViolation`, +`error/GraphQlPartialResponseFixture`, +`advanced/persisted/InMemoryGraphQlPersistedOperationRegistry`, +`advanced/admin/InMemoryGraphQlPersistedOperationAdminPort`. + +### 24.4 `verifyGraphQlProductionJar` — jar 자체를 읽는다 + +**금지 마커 5개**: + +| 마커 | 이유 | +|---|---| +| `/testkit/` | 계약 스위트와 통합 fixture는 test fixtures 소속 | +| `InMemory` | **개발용 대역이지 출하 기본값이 아니다** — *"looks like a working bean until a second instance starts"* | +| `ForTests` | production 코드에서 도달 가능한 for-tests 팩토리 | +| `TestContext` | **자격증명 없는 인증 컨텍스트** — *"`testContext(String)` hands out an authenticated actor to anyone who calls it"* | +| `Fixture` | fixture는 test fixtures 소속 | + +**중요한 설계 결정**: 레이아웃이 아니라 **jar를 검사한다.** + +> *Moving the testkit into test fixtures is a source-tree decision, and source-tree decisions drift. +> One `implementation` where a `testFixturesImplementation` belonged, one file created in the wrong +> directory, and the contract suites are back inside the jar an adopter deploys.* + +*"So the claim is checked against the jar rather than against the layout that is supposed to produce +it."* — **의도가 아니라 산출물을 검사한다.** + +archive 인덱스(entry 이름)만 읽고 바이트코드는 안 읽는다. + +원래 리뷰 GQL-024의 답. + +### 24.5 `verifyGraphQlApiSurface` — 408개 public 타입 스냅샷 + +`docs/architecture/graphql-api-surface.txt` (416줄, 408 타입). + +주석이 문제를 정확히 진술한다: + +> *One jar, 40-odd packages, and a public type in any of them is reachable from every adopter's code. +> Package boundaries express the intended structure but enforce nothing across a single artifact: +> `public` inside a jar means public to everybody who has the jar.* + +그리고 **가설이 아니라는 증거**를 붙인다: + +> *a package that went missing from a commit was still compiled against by seven production files, and +> nothing in the build had an opinion about what the surface was supposed to be.* + +§2.1의 그 사건이다. + +**스냅샷은 표면을 줄이지 않는다:** + +> *A snapshot does not shrink the surface. It makes each addition visible in review, which is the +> prerequisite for shrinking it ... Until then the number cannot grow by accident.* + +승인 후 갱신: +```bash +./gradlew :adapter:inbound:graphql:updateGraphQlApiSurface -PapproveGraphQlApiSurfaceChange +``` + +원래 리뷰 GQL-025(*"373개 type의 과도한 public surface"*)의 부분적 답이다 — 표면을 게이트했지 +줄이지는 않았고, 오히려 373 → 408로 늘었다. 하지만 이제 **늘어난 35개는 전부 리뷰를 거쳤다.** + +### 24.6 `registerStrictQualificationTest` — no-skip 증거 + +```groovy +registerStrictQualificationTest( + name: 'graphqlTransportQualificationTest', + sourceSet: sourceSets.test, + requiredClasses: ['dev.caskeleton.adapter.inbound.graphql.GraphqlHttpBoundaryQualificationTest'], + description: 'Runs exact no-skip GraphQL conditional transport wire evidence.') +``` + +레포 전역 헬퍼를 쓴다. **skip을 통과로 세지 않는다**는 게 이 레포 전반의 규칙이고, GraphQL도 +같은 규칙을 따른다. + +--- + +## 25. app-bootstrap 배선과 스모크 레인 + +### 25.1 "shipped and gated" — CLAUDE.md/README와 다른 현재 상태 + +`src/app-bootstrap/build.gradle`: + +```groovy +// auto-configurations back — without both, adding the jar would publish /graphql. +implementation project(':adapter:inbound:graphql') // line 97 +... +conditionalTransportTestImplementation project(':adapter:inbound:graphql') // line 198 +``` + +`src/app-bootstrap/src/main/resources/application.yml:968`: + +```yaml +graphql: + # true | false — publishes /graphql, including the route Spring GraphQL would publish itself. + enabled: ${APP_GRAPHQL_ENABLED:false} + deployment-mode: ${APP_GRAPHQL_DEPLOYMENT_MODE:} +``` + +`GraphQlShippedAndGatedTest`가 **두 가지를 동시에** 주장한다: +1. facade가 classpath에 있다 +2. off일 때 `/graphql`이 **정의되지 않은 라우트와 구분 불가능하다** + +즉 지금은 "**출하되고 게이트됨**"이지 "classpath opt-in"이 아니다. §3의 4겹 구조가 그걸 가능하게 +만들었다 — **"꺼짐"을 구조로 만들었기 때문에 "빌드 안 됨"으로 끌 필요가 없어졌다.** + +`modules.json`의 `runtime_memberships: ["app-bootstrap"]`도 같은 말을 한다. + +**모듈 문서 두 개가 이 변화를 아직 반영하지 않았다** (§28-P1). + +### 25.2 `GraphQlInboundOwnershipBoundaryTest` — ADR의 전제를 상시 검사로 + +ADR-GQL-001의 세 번째 결정(persisted-operation SPI를 인바운드에 둔다)은 **"이 leaf 밖에서 아무도 +구현하지 않는다"는 근거 위에 서 있다.** 이 테스트가 그 스냅샷 관찰을 **상시 조건**으로 바꾼다. + +```java +private static final List INBOUND_OWNED = + List.of("GraphQlPersistedOperationRegistry", "GraphQlRequestContext"); +private static final String GRAPHQL_SOURCE = "/src/adapter/inbound/graphql/src/main/java/"; +``` + +javadoc이 왜 필요한지 쓴다: + +> *A decision whose premise nothing rechecks is a decision that quietly stops being true, and the +> commit that breaks it would read as an implementation rather than as a layering change.* + +**composition root에서 검사하는 이유:** *"A rule inside the GraphQL leaf could only prove the leaf +does not violate itself."* + +그리고 **두 번째 테스트가 있다:** + +```java +@Test +@DisplayName("both types still exist, so the rule is about the boundary and not about deletion") +void theOwnedTypesStillExist() { + // Without this the assertion above would also pass on the day the SPI and the context were + // deleted, which is a green rule reporting on nothing — the shape this repository's remediation + // pass exists to remove. +``` + +**"검사 대상이 사라져도 green"을 막는 짝 테스트.** §2.4의 규칙 (e)와 정확히 같은 발상이다. + +세 번째 디테일도 좋다. 소스 트리 walk가 `/build/`를 **방문 후 필터링하는 대신 가지치기**한다: + +> *Filtering `/build/` out of a `Files.walk` stream still descends into it, and those directories +> churn while any other module's tests are running — which made this suite fail once, unreproducibly, +> in a run that had three modules' tests going at the same time.* + +그리고 빈 walk는 **예외를 던진다** — *"An empty walk would make both assertions below pass vacuously, +which is the failure this whole suite exists to prevent."* + +### 25.3 `GraphQlResolverReachabilityTest` — GQL-INT-003의 다른 절반 + +**이 leaf에서 가장 교훈적인 실제 장애다.** + +`skeleton.graphqls`는 `_health: String!` 하나를 선언하고 `HealthGraphqlController`가 해석한다. +그런데 그 컨트롤러는 **composition root의 `@ComponentScan`이 정규식으로 제외하는 패키지**에 있는 +`@Controller`다 — capability를 optional하게 만드는 바로 그 제외 — 그리고 **어떤 +auto-configuration도 import하지 않았다.** + +결과: **GraphQL을 켠 배포가 유일한 필드에 아무것도 없는 스키마를 서빙했고, 모든 쿼리가 +`NullValueInNonNullableField`로 돌아왔다.** + +그리고 결정적인 부분: + +> *Two unit tests covered that controller and both passed: one registers the class in an +> `ApplicationContextRunner`, the other lists it among a fixed set of classes for a boundary +> qualification. **Both supply the registration the runtime was missing**, which is the same shape as +> `NotificationPlatformAutoConfiguration` and the persistence stereotypes before it.* + +**테스트가 런타임에 없던 등록을 스스로 공급했다.** 두 테스트 다 green인데 프로덕션은 깨져 있었다. +그리고 javadoc이 이게 이 레포에서 **세 번째** 같은 모양이라고 적는다. + +찾아낸 건 레인이다: + +> *`graphql-smoke` sends an authenticated query to `/graphql` and reads the body rather than the +> status, because a failed resolution is a `200` carrying an `errors` array.* + +수정은 `GraphQlRootAutoConfiguration`의 `@Import`에 컨트롤러를 넣는 것 (§3.2). + +테스트 자체가 두 가지를 고정한다: +1. 등록된 root가 resolver를 import한다 (소스 텍스트에서 `HealthGraphqlController.class` 검색) +2. 레인이 실제 요청을 보낸다 (`graphql-smoke.sh` 존재 + 내용 검사) + +### 25.4 `infra/graphql/smoke/graphql-smoke.sh` — 세 요청, 그 순서로 + +스크립트 헤더가 왜 이 스모크가 필요한지 쓴다: + +> *auth-smoke proves the realm issues a usable token and that the application answers its public +> health path. It never sends an authenticated request, and never touches /graphql at all — so the JWT +> decoder, the security filter chain and the GraphQL execution path were each covered by their own +> tests and by nothing that put them in one line together.* + +**셋을 한 줄에 놓는 것이 없었다**는 게 요점이다. + +세 요청, 그리고 **각각이 앞의 것이 있어야만 의미가 있다:** + +| # | 요청 | 기대 | 없으면 | +|---|---|---|---| +| 1 | 자격증명 없음 | 401/403 | 2·3이 인증에 대해 아무것도 증명 못 함. 그냥 엔드포인트가 답한다는 것만 | +| 2 | `Bearer not-a-real-token` | 401/403, **500 아님** | 500이면 디코더가 거부해야 할 곳에서 던진 것 | +| 3 | 진짜 토큰 (client_credentials) | 200 + `_health` | — | + +그리고 **바디를 읽는다:** + +```sh +# The body, not just the status. A 200 carrying a GraphQL `errors` array is how a refused or failed +# execution looks over HTTP, so a status-only check would pass on an unresolved field. +grep -q '"_health"' /tmp/gql.json || fail "the response carried no _health field: ..." +if grep -q '"errors"' /tmp/gql.json; then fail "the query returned GraphQL errors: ..."; fi +``` + +**GraphQL에서 상태 코드만 검사하는 스모크는 무의미하다** — §7.2의 "field error는 200"이 바로 그 +이유다. 이 스크립트가 그 결과를 정확히 다룬다. + +### 25.5 나머지 app-bootstrap 쪽 GraphQL 테스트 + +| 테스트 | 무엇 | +|---|---| +| `GraphQlDeploymentModeRegistryParityTest` | enum 목록과 운영자용 레지스트리 동기화 (§4.1) | +| `ShippedRuntimeFacadePresenceTest` | shipped facade 존재 | +| `FiveAdapterOffInventoryTest` | 다섯 어댑터 off 상태 인벤토리 | +| `CapabilityDependencyValidatorTest` | capability 의존 검증 | +| `RuntimeMembershipClasspathAgreementTest` | `runtime_memberships`와 실제 classpath 일치 | +| `ConditionalTransportQualificationContractTest` | 조건부 전송 계약 | +| `AdapterActivationInventory` / `ShippedCompositionHarness` | 헬퍼 | + +`RuntimeMembershipClasspathAgreementTest`가 특히 §25.1의 drift를 잡아 줄 수 있는 위치다 — +레지스트리와 classpath는 일치하고, 산문 문서만 어긋나 있다. + +--- + +## 26. 2026-08-14 리뷰 → 지금: 27개 발견은 어떻게 됐나 + +`docs/reviews/2026-08-14-graphql-module-code-review.md` (889줄)는 당시 판정이 +**CHANGES REQUIRED / 현재 컴파일 불가**였다. 그때 규모는 production 373 / test 75 / LOC 20,155. +지금은 408 / 101 / 26,303이다. + +이 문서를 읽는 것이 코드를 읽는 것만큼 유용했다. **javadoc에 적힌 "예전에는 이랬다"가 전부 이 +리뷰의 발견과 대응된다.** 그리고 대부분이 정말로 고쳐졌다. + +| ID | 우선순위 | 주제 | 지금 상태 | +|---|---|---|---| +| GQL-001 | P0 | ignored `build` 소스 패키지 | ✅ `moduleboundary`로 개명 + `verifyNoIgnoredSourcePackages` + required-class 검사 (§2.1, §24.2) | +| GQL-002 | P0 | 정책이 실제 `/graphql`에 미연결 | ✅ `runtime` 패키지 전체 (§9). 등급 어휘로 남은 gap을 정직하게 표기 (§22.1) | +| GQL-003 | P1 | auto-config 등록/바인딩/검증 불일치 | ✅ `AutoConfiguration.imports` + `GraphQlPlatformStartupValidator.validateRuntime` (§4.3) | +| GQL-004 | P1 | servlet artifact가 reactive 표방 | ✅ web을 test scope로 + `GraphQlRuntimeTransport` 감지 (§1.3, §4.3) | +| GQL-005 | P1 | body cap 미강제, null variable NPE | ✅ `GraphQlRequestBodyLimitFilter` + `GraphQlJsonValues` (§7.6, §7.8) | +| GQL-006 | P1 | Accept q-value/q=0 무시 | ✅ `GraphQlAcceptHeader` (§7.3) | +| GQL-007 | P1 | fragment 우회, variable nesting 공백 | ✅ `GraphQlFragmentReachabilityTest` + `GraphQlJsonStructurePolicy` (§7.5, §10.2) | +| GQL-008 | P1 | 경계 검사가 generic 놓치고 reactive 오판 | ✅ `GraphQlTypeGraph` + `GraphQlAsyncReturnShape` (§20.1, §20.2) | +| GQL-009 | P1 | repository 자동 노출 제거 | ⚠️ **제거하지 않고 allowlist로 남김** (§23.9). `unsupported.repositoryAutoExposure`가 Advanced 밖에서 금지 | +| GQL-010 | P1 | cursor framing/rotation/scope 결함 | ✅ `GraphQlCursorFraming` + `GraphQlCursorScope` + keyRing (§13). ❌ **다만 요청 경로에 미연결** (GQL-INT-003) | +| GQL-011 | P1 | mutation fingerprint 충돌, tenant 없는 scope | ✅ `GraphQlCanonicalInput` + 5축 스코프 (§14.3, §14.4) | +| GQL-012 | P1 | error contract 두 벌 | ✅ `GraphQlWireErrorMapper` 단일화 (§9.7) | +| GQL-013 | P1 | persisted admin durable하지 않음 | ✅ `GraphQlAdminPrincipal` + CAS 전이 + 단일 durable 단위 감사 (§23.1, §23.2) | +| GQL-014 | P1 | codegen이 문서를 검증 안 함 | ✅ `GraphQlOperationValidator`가 실제 스키마 컴파일 + 이름을 plan으로 정정 (§23.11) | +| GQL-015 | P1 | comparator가 breaking change 놓침 | ✅ 37종 `GraphQlChangeKind` (§18.1) | +| GQL-016 | P1 | DataLoader 계약이 실제 N+1 미보장 | ✅ `GraphQlBatchLoaderRegistrar` + **50 parent → 3 호출** 증거 (§9.6, §12.6) | +| GQL-017 | P1 | MVC/WebFlux unbounded, context 미전달 | ✅ 커스텀 어댑터 제거 + `GraphQlBlockingBridge` 양축 유한 + `GraphQlContextPropagator` (§9.8, §11.5) | +| GQL-018 | P1 | pipeline stage 순서 모순 | ✅ 6단계 + 7개 순서 제약 + 유도된 파이프라인 (§8.1, §8.2, §9.3) | +| GQL-019 | P1 | subscription/replay/drain race | ✅ `GraphQlSubscriptionLease` + 불변 drain 상태 + 3-phase (§23.3) | +| GQL-020 | P2 | preparsed expiry 미사용, miss 직렬화 | ✅ `BoundedPreparsedDocumentProvider` + 4부분 키 + adapter (§8.3, §9.6) | +| GQL-021 | P2 | cancellation hook 하나가 나머지 차단 | ✅ 드레인 방식, 각 리스너 정확히 한 번 (§8.5) | +| GQL-022 | P2 | scalar input/output 비대칭 | ✅ `GraphQlDecimalBounds` 네 경계 (§17.6) | +| GQL-023 | P2 | operation name 카디널리티 | ✅ `GraphQlOperationNameCardinality` + 실제 MeterRegistry 10,000 → 1 (§19.2) | +| GQL-024 | P2 | production jar 오염 | ✅ `verifyGraphQlProductionJar` 5개 마커 (§24.4) | +| GQL-025 | P2 | 과도한 public surface | ⚠️ **게이트했지 줄이지 않음.** 373 → 408 (§24.5) | +| GQL-026 | P2 | context/storage SPI ownership | ✅ ADR-GQL-001 + 세 갈래 해법 + composition root 검사 (§6.5, §11.4, §23.1, §25.2) | +| GQL-027 | P3 | 문서 drift | ⚠️ **새로운 drift가 생겼다** (§28-P1, P3, P4) | + +**요약: 27개 중 23개 완료, 2개 의도적 다른 선택(GQL-009, GQL-025), 1개 미완(GQL-010의 배선), +1개 재발(GQL-027).** + +그리고 리뷰 이후 **새로 발견된 통합 결함 두 개**가 있다 — `GQL-INT-002`(안전 축 split-brain, +§4.1)와 `GQL-INT-003`(resolver 도달 불가 §25.3 + cursor 미배선 §13.7). 둘 다 **단위 테스트가 +전부 green인 상태에서 프로덕션이 깨져 있던** 부류다. + +### 26.1 이 비교에서 얻은 것 + +리뷰 문서 §2.2의 "검토 깊이" 표가 정직하다: + +> *이 문서는 28,579 LOC의 모든 method를 line-by-line 승인한 결과가 아니다.* + +그리고 `advanced/**`, release/testkit의 세부는 명시적으로 `UNVERIFIED`라고 적었다. **읽지 않은 +것을 읽었다고 하지 않는 것** — 이게 이 레포 문서들의 공통 규율이고, 내가 이 문서 맨 위에 +커버리지 표를 붙인 이유이기도 하다. + +--- + +## 27. 이 모듈이 가르쳐 준 원칙 + +읽으면서 반복해서 나타난 것들. 대부분 GraphQL 특유가 아니라 **어떤 인바운드 어댑터에도 적용되는 +것**이다. + +### 27.1 정책 객체의 단위 테스트는 정책이 강제된다는 증거가 아니다 + +세 번 다르게 나타났다 (§9.6): + +- preparsed 캐시가 비어 있는 동안 매 요청이 재파싱했다 +- N+1 보호가 어떤 요청도 닿을 수 없는 객체 집합이었다 +- 카디널리티 경계가 어떤 series에도 적용된 적 없었다 + +그리고 네 번째: resolver 도달 불가(§25.3)에서 **테스트가 런타임에 없던 등록을 스스로 공급했다.** + +**대응**: `modelled` / `wired` / `integration-verified` / `production-verified` 등급 어휘. 그리고 +"현재 등급보다 높게 표현하지 않는다"는 규칙. + +### 27.2 "꺼져 있다"는 빈이 없다는 뜻이 아니다 + +프레임워크가 스스로 라우트를 여는 경우가 있다. 그래서 off 계약은 +`AutoConfigurationImportFilter` + **실제 포트 404 테스트**까지 가야 한다 (§3). + +그리고 그 필터는 **오타가 나면 조용히 열린다.** 방어 수단이 아니라 방어된 상태를 테스트해야 한다. + +### 27.3 없어진 검사와 통과한 검사를 구분할 수 없으면 검사가 아니다 + +이 원칙의 사례가 이 모듈에 **다섯 개** 있다: + +| 사례 | 어디 | +|---|---| +| `requiredStableClasses` — 400개 중 1개 사라져도 잡는다 | §24.2 | +| stale JUnit XML 삭제 — 위 검사가 읽는 증거의 신선도 | §24.2 | +| 빈 performance 레인은 실패 (`failOnNoDiscoveredTests`로는 부족) | §24.2 | +| 모듈 경계 규칙 (e): 선언만 있고 소스가 없는 모듈 금지 | §2.4 | +| `theOwnedTypesStillExist()` — 두 타입이 지워져도 green이 되지 않게 | §25.2 | +| 빈 소스 walk는 예외 | §25.2 | + +### 27.4 구분자를 값에서 구분할 수 없으면 정규형이 아니다 + +**같은 레포에서 두 번 일어났다:** + +- 커서 봉투: `|`/`;`/`=` 조인 + 이스케이프 → 라운드트립 실패 (§13.4) +- mutation 지문: `key=value;` 조인 → **서로 다른 두 입력이 같은 지문** (§14.4) + +**대응이 다르다는 게 흥미롭다.** 커서는 이스케이프를 **길이 프레이밍**으로 대체했고, 지문은 +타입 있는 정규 직렬화로 갔다. 둘 다 "이스케이프를 더 잘하기"를 선택하지 않았다. + +### 27.5 정규식은 형태를 묶지 카디널리티를 묶지 않는다 + +`Query0000001`, `Query0000002`, ... 전부 유효하고 전부 자기 time series였다. **정규식이 검사하고 +있어서 경계가 있어 보였다** (§19.2). + +### 27.6 저엔트로피 입력에 대한 다이제스트는 익명화가 아니다 + +tenant 이름 후보가 유한하면 전부 해싱해서 역매핑한다. keyed fingerprint여야 한다 (§6.4). + +### 27.7 하나의 질문에 대해 의견이 갈릴 수 있는 두 설정은 설정 두 개가 아니라 버그다 + +`production` boolean + `environment` enum → 출하 기본값이 "인터넷 노출 프로덕션인데 보호는 +노트북처럼" (§4.1). + +그리고 **지운 키는 무시가 아니라 거부여야 한다** (§4.2). + +### 27.8 규칙을 규칙으로 쓰지 말고 구조로 만들어라 + +| 규칙 | 구조 | +|---|---| +| "tenant는 인자에서 오면 안 된다" | 인자 기반 팩토리가 **존재하지 않는다** (§6.2) | +| "컨텍스트는 한 곳에서만 만든다" | `GraphQlAuthenticationContextFactory`가 유일한 생성자 (§11.2) | +| "커서 스코프 4개를 다 검사해라" | 4개를 **한 값**으로 넘긴다 (§13.3) | +| "에러 extensions에 아무거나 넣지 마라" | 생성 시점 allowlist (§16.1) | +| "loader는 실행마다 새로" | 팩토리 (§12.4) | +| "단계 순서를 지켜라" | 다음 단계가 이전 단계의 **필드**를 요구 (§9.4) | + +### 27.9 nullability는 데이터 모양이 아니라 실패 격리 결정이다 + +GraphQL 특유. non-null 필드의 null은 **가장 가까운 nullable 조상까지 올라가며 전부 null로 +만든다.** enrichment 필드에 `!`를 붙이면 무관한 서비스가 죽었을 때 주문 전체가 사라진다 (§16.5). + +### 27.10 실행이 시작됐는가가 에러 계약의 축이다 + +실행 전 = 4xx, 실행 중 = **200 + 부분 데이터**. field error를 5xx로 "개선"하면 위생이 좋아진 것처럼 +보이는데 클라이언트는 동작하는 응답의 절반을 잃는다 (§7.2, §24.3). + +**그리고 스모크 테스트가 상태 코드만 보면 무의미해진다** (§25.4). + +### 27.11 순서는 취향이 아니라 보안 속성이다 + +인가는 파싱된 문서가 필요하고, cost는 실행 전이어야 하고, persisted lookup은 파싱 전이어야 한다. +그리고 **잘못된 순서는 런타임에 조용하다** — 실행 후 인가는 여전히 "동작"한다. 아무것도 인가하지 +않을 뿐 (§8.1, §8.2). + +### 27.12 의도가 아니라 산출물을 검사하라 + +testkit을 test fixtures로 옮긴 건 **소스 트리 결정**이고 소스 트리 결정은 drift한다. 그래서 +레이아웃이 아니라 **jar를 연다** (§24.4). + +### 27.13 믿어지는 잘못된 감사 기록은 없느니만 못하다 + +> *An audit trail with any of those properties is worse than none, because it is trusted.* (§23.2) + +그리고 **시도된 변경이 아니라 일어난 변경을 기록해야 한다** — 존재하지 않는 오퍼레이션에 대해 +`ABSENT -> BLOCKED`를 쓰던 서비스. + +### 27.14 조용한 폴백은 전부 나중에 청구된다 + +이 모듈이 폴백을 거부하는 지점들: + +| 지점 | 폴백했으면 | +|---|---| +| 모르는 client profile | 무제한 문서·복잡도·페이지 크기 (§5.2) | +| 커버하는 fetch profile 없음 | **full-entity 로드** (§15.2) | +| 등록 안 된 DataLoader | 배치 크기·타임아웃·missing 의미 없음 (§12.5) | +| incremental 미지원 런타임 | *"모든 환경에서 동작하는 것처럼 보이고, 의존한 곳에서만 실패"* (§23.13) | +| 인식 못 한 예외 | 자기 메시지 노출 (§9.7) | +| 미등록 repository 노출 | introspection에 나오고 에러 나는 필드 (§23.9) | +| deployment mode 미설정 | 인가 없는 엔드포인트 또는 설명 불가한 장애 (§4.1) | + +### 27.15 "지원하지 않음"은 갭이 아니라 대안이 있는 결정이어야 한다 + +`GraphQlStableCapabilityManifest`의 `UNSUPPORTED` 엔트리는 **전부 설계상의 대안을 가진다** — +업로드는 Fileserver, 원자적 다단계 작업은 하나의 mutation use case, 크로스 요청 캐싱은 자기 키 +모델을 가진 cache capability (§21.5). + +그리고 그게 설정 키로도 존재해서, 켜면 기동을 거부하며 대안을 말한다 (§4.3). + +--- + +## 28. 손볼 것 + +우선순위 순. **P1~P2가 실제로 조치가 필요한 것**이고 나머지는 정리·결정 항목이다. + +### P1 — 모듈 문서 두 개가 "classpath opt-in"이라고 말한다 (실제로는 shipped-and-gated) + +**심각도: 높음 (문서)** · **비용: 낮음** + +| 위치 | 현재 문장 | +|---|---| +| `src/adapter/inbound/graphql/CLAUDE.md` Responsibility 마지막 bullet | *"classpath opt-in: 현재 `app-bootstrap`/`sample-portfolio` production runtime 은 이 leaf 를 의존하지 않는다."* | +| `src/adapter/inbound/graphql/README.md:31-32` | *"현재 `app-bootstrap` 과 `sample-portfolio` 의 production runtime 은 이 leaf 를 의존하지 않는다. 즉 이 모듈은 **classpath opt-in** 이며..."* | + +**반증 셋:** +- `src/app-bootstrap/build.gradle:97` — `implementation project(':adapter:inbound:graphql')` +- `src/config/architecture/modules.json` — `"runtime_memberships": ["app-bootstrap"]` +- `app-bootstrap/src/test/.../activation/GraphQlShippedAndGatedTest.java` — facade가 classpath에 + 있고 off일 때 `/graphql`이 미정의 라우트와 구분 불가능함을 주장 + +**왜 이게 단순 오타보다 나쁜가**: 이 leaf의 가장 큰 설계 성취가 **"꺼짐"을 구조로 만들어서 +"빌드 안 됨"으로 끌 필요를 없앤 것**이다(§3). 문서가 그걸 부정하면, 새 독자는 4겹 마스터 스위치가 +왜 있는지 이해하지 못하고 `GraphQlOffAutoConfigurationImportFilter`를 과잉으로 읽는다. + +**조치**: 두 문장을 "shipped and gated"로 교체하고, `APP_GRAPHQL_ENABLED` / `APP_GRAPHQL_DEPLOYMENT_MODE` +두 환경변수와 `GraphQlShippedAndGatedTest`를 근거로 명시. +`app-bootstrap/build.gradle:96`의 주석("without both, adding the jar would publish /graphql")을 +그대로 인용하면 된다. + +### P2 — cursor 서명이 요청 경로에 없는데 startup validator가 완성된 것처럼 보이게 한다 + +**심각도: 높음 (보안 표현)** · **비용: 설계 결정 필요** + +이미 `CLAUDE.md`가 GQL-INT-003으로 기록했고, `GraphQlPolicyRequestPathTest`가 사실을 고정하고 있다. +내가 grep으로 재확인했다 (§13.7): `backend.graphql.cursor.key-ids`를 읽는 곳은 startup validator, +actuator endpoint, configuration report **셋뿐**이고 **서명하는 코드는 하나도 안 읽는다.** + +문제의 형태: +``` +production 기동 → 키 ID 요구 → 운영자가 넣음 → actuator가 "설정됨" 확인 + ↓ + 커서는 여전히 client-editable +``` + +**닫으려면 배선이 아니라 설계 결정이 필요하다.** `GraphQlCursorKeyRing.of`는 `Map`를 +받고, 설정 계약은 "키 자체는 설정에 나타나지 않는다"다. 즉 **키 재료가 어디서 오는지**를 먼저 +정해야 한다. 후보: + +| 안 | 내용 | 비용 / 리스크 | +|---|---|---| +| (a) `shared-contract`에 secret 조회 포트 | 중립 계약, 인프라가 구현 | 새 추상화 하나. ADR-GQL-001이 경계한 "구현체 없는 포트" 위험 | +| (b) 환경변수 직접 (`APP_GRAPHQL_CURSOR_KEY_`) | 가장 단순, 즉시 배선 가능 | 키가 환경에 노출. 현재 설정 계약("키는 설정에 나타나지 않는다")과 충돌 | +| (c) composition root가 `GraphQlCursorKeyRing` 빈을 제공 | leaf는 결정 안 함, adopter가 소유 | leaf가 "서명한다"고 말할 수 없음. 다만 **현재 상태를 정직하게 만듦** | +| (d) capability를 명시적으로 철회 | `pagination` 커서 코덱을 Advanced로 강등하거나 제거 | 잘 만든 코드를 버림 | + +**추천은 (c) + 중간 조치.** (c)는 §11.4의 `GraphQlPrincipalResolver` 패턴과 동일하다 — 배포 결정을 +composition root에 남기고 leaf는 seam만 제공. 그리고 auto-configuration이 `GraphQlCursorKeyRing` +빈이 **있을 때만** `HmacGraphQlCursorCodec`을 만들면, startup validator 메시지가 참이 된다. + +**중간 조치 (한 줄)**: 지금 당장은 startup validator 메시지가 거짓이므로 — +`"a cursor signing key is required; unsigned cursors are client-editable"` — 배선 전까지 +`GraphQlPlatformConfigurationReport`에 `cursorSigningWired: false`를 노출하거나, 메시지를 +"키 ID는 등록되지만 이 배포는 아직 커서에 서명하지 않는다"로 정정. + +### P3 — `CLAUDE.md`가 존재하지 않는 클래스 이름을 두 번 쓴다 + +**심각도: 낮음** · **비용: 낮음** + +`CLAUDE.md`의 Responsibility와 "Error mapping" 절이 루트 패키지의 `GraphqlExceptionResolver` +(소문자 q)를 지목한다. **그런 타입은 없다.** 실제 구조는 세 갈래다: + +| 실제 타입 | 역할 | +|---|---| +| `error/GraphQlExceptionResolver` | 등록된 예외 → 코드/카테고리/메시지 매핑 | +| `runtime/GraphQlWireErrorMapper` | **결정자** — 무엇을 클라이언트가 보는가 | +| `runtime/GraphQlDataFetcherExceptionResolver` | Spring 계약 어댑터 | + +**아이러니한 점**: 이 이름 자체가 §9.7에서 고친 버그의 잔재다 — *"the two resolver classes differed +only in the case of one letter"*, 그리고 Spring은 그중 하나만 불렀다. **문서가 사라진 쪽의 이름을 +계속 들고 있다.** + +### P4 — `build.gradle` 주석이 자기 파일 안에서 모순된다 + +**심각도: 낮음** · **비용: 낮음** + +- line 36-38: *"no production file imports `org.springframework.web`, `jakarta.servlet` or + `org.springframework.http`"* +- line 55-59: `compileOnly 'jakarta.servlet:jakarta.servlet-api'` + *"compileOnly is what keeps that + from contradicting the paragraph above"* + +`runtime/servlet/GraphQlRequestBodyLimitFilter`가 `jakarta.servlet.*`을 9개 import한다. +**정신은 유지된다**(compileOnly라 runtimeClasspath에 없고 서버를 강제하지 않는다) — 문장만 +갱신하면 된다: *"no production file imports `org.springframework.web` or `org.springframework.http`; +the one servlet import is `compileOnly` and never reaches an adopter's runtime classpath."* + +`CLAUDE.md`의 같은 문장(*"production 파일 중 ... `jakarta.servlet` ... 을 import 하는 것은 하나도 +없다"*)도 같이 고쳐야 한다. + +### P5 — `GraphQlAdvancedModule`에 고아 javadoc + +**심각도: 낮음** · **비용: 즉시** + +`moduleboundary/GraphQlAdvancedModule.java` 89행 근처: + +```java + /** The GraphQL over WebSocket protocol state machine. */ + /** + * The allowlisted Spring Data compatibility path. + * ... + */ + SPRING_DATA("advanced.springdata", "advanced.springdata"), + + WEBSOCKET("advanced.websocket", "advanced.websocket", "advanced.bootstrap"); +``` + +WebSocket javadoc이 `SPRING_DATA` 위에 떠 있고, `WEBSOCKET`에는 javadoc이 없다. 컴파일은 되지만 +javadoc 도구는 마지막 것만 연결한다. **`SPRING_DATA` 상수 다음, `WEBSOCKET` 앞으로 옮기면 끝.** + +### P6 — `modelled` 세 개의 배선 결정을 한 번에 정리 + +**심각도: 중간** · **비용: 각각 설계 결정** + +| capability | 정책 완성도 | 없는 것 | 결정해야 할 것 | +|---|---|---|---| +| cursor 서명 | 완성 | 키 재료 출처 | P2 | +| object 인가 | 완성 (`ObjectAccessPolicy` + bridge) | 실행 경로 연결 configuration | **누가 `GraphQlObjectAuthorizationPort`를 제공하나** — leaf 기본값? adopter? | +| mutation 멱등성 | 완성 (5축 스코프) | 인터셉터 참조 configuration | **멱등성 레코드 저장소** — persisted operation과 같은 `OperationalRecordStorePort`를 쓸 수 있나? | + +**셋이 같은 모양이다**: 정책은 다 만들었고 **배포가 공급해야 하는 것 하나**가 비어 있다. +`GraphQlPrincipalResolver`(§9.9)가 이미 그 패턴을 확립했으니, 셋 다 같은 방식으로 정리하는 게 +일관적이다 — leaf는 seam과 "없으면 이 기능은 꺼진다"를 제공하고, composition root가 채운다. + +**그리고 그걸 actuator report에 노출**하면 `modelled`/`wired` 구분이 문서가 아니라 런타임 사실이 +된다. 이게 §27.1의 원칙을 한 단계 더 밀어붙이는 방법이다. + +### P7 — 408개 public 타입, 하나의 jar + +**심각도: 중간 (장기)** · **비용: 큼** + +`verifyGraphQlApiSurface`가 늘어남을 리뷰 대상으로 만들었지만(§24.5) **줄이지는 않았고**, +리뷰 시점 373 → 지금 408이다. build.gradle 주석이 계획을 적어 둔다: `api`와 `spi`가 의도된 +외부 표면이고 나머지는 **capability artifact로 쪼갤 때 internal이 될 후보**. + +지금 할 수 있는 저비용 단계: +1. `api-surface.txt`에 **패키지별 집계**를 주석으로 추가 — 어디가 비대한지 보이게 +2. `advanced/**`부터 `sealed`/package-private 후보를 표시 (Advanced는 adopter가 직접 쓸 이유가 + 가장 적다) +3. §1.2의 미결 결정(단일 leaf vs 레지스트리 leaf)을 ADR로 매듭 — messaging이 반대 선택을 했으므로 + **레포 차원의 결정**이지 GraphQL만의 결정이 아니다 + +### P8 — 모듈 경계 스캐너가 소스 텍스트 기반이다 + +**심각도: 낮음** · **비용: 중간** + +`GraphQlBuildModel`이 import 문을 텍스트로 읽는다 (§2.5). fully-qualified 이름을 본문에 직접 쓰면 +빠져나간다. 실제로 `GraphQlExecutionChain`이 `dev.caskeleton.adapter.inbound.graphql.execution. +GraphQlExecutionPipelineException`을 javadoc `@throws`에 FQN으로 쓰고, +`GraphQlPlatformStartupValidator`가 `dev.caskeleton...GraphQlExecutionPipelineValidator`를 본문에 +FQN으로 호출한다 — **둘 다 합법적인 edge**라 지금은 문제가 없다. 하지만 **금지된 edge를 FQN으로 +쓰면 규칙이 침묵한다.** + +저비용 보강: 스캐너에 "본문에 `dev.caskeleton.adapter.inbound.graphql..`이 나타나면 +edge로 센다"를 추가. 이건 정규식 한 줄이고 negative fixture도 쉽다. + +### P9 — 실부하·장애 증거가 없다 + +**심각도: 중간** · **비용: 인프라** + +`graphqlPerformanceTest` 레인이 자리를 예약하고 **비어 있으면 실패한다**(§24.2). `GraphQlReleaseGate`가 +증거 없으면 릴리스를 거부한다. 즉 **구조는 정직한데 증거가 없다.** + +`GraphQlPerformanceScenario`와 `GraphQlFaultScenario`가 시나리오 카탈로그를 이미 정의하고 있으니, +가장 값싼 첫 걸음은 **cancellation leak 하나**다 — javadoc이 "가장 자주 건너뛰고 가장 자주 깨진다"고 +지목한 것이고, 기능 테스트로는 절대 안 잡힌다. + +### P10 — GQL-009 편차에 ADR이 없다 + +**심각도: 낮음 (프로세스)** · **비용: 낮음** + +2026-08-14 리뷰의 GQL-009는 *"repository 자동 노출은 Advanced여도 제거한다. application use case를 +우회하는 예외를 만들지 않는다"*였다. 구현은 **제거하지 않고** allowlist 4중 방어를 붙여 남겼다 +(§23.9). 그 선택 자체는 방어 가능하고 javadoc이 잘 방어한다 — 하지만 **ADR이 없다.** + +ADR-GQL-001은 다른 세 가지(context / object 인가 / persisted SPI)를 다룬다. springdata 유지 결정에 +대한 기록이 없으면 **다음 리뷰가 같은 지적을 반복**하고, 그때 이 javadoc을 다시 발견해야 한다. + +ADR-GQL-002 한 장이면 충분하다: 리뷰 지적 → 실제 선택 → 4중 방어 → `unsupported.repositoryAutoExposure`가 +Advanced 밖에서 여전히 금지한다는 사실 → 언제 제거할 것인가. + +### P11 — 문서의 테스트 숫자 재확인 + +**심각도: 낮음** · **비용: 명령 4개** + +`CLAUDE.md`가 Stable 605 / Contract 9 / Advanced 152 / 기본 test 757 / qualification 8을 적고, +**마지막 green 실행에서 다시 읽어 갱신하라**는 규칙도 함께 적어 두었다. 현재 소스에 +`@Test`+`@ParameterizedTest`가 762개이므로 (세는 단위가 다르지만) 한 번 돌려서 갱신할 시점이다. +이건 이미 문서화된 규칙의 실행일 뿐이다. + +### 우선순위 요약 + +| ID | 무엇 | 비용 | 지금 할까 | +|---|---|---|---| +| **P1** | classpath opt-in 문서 drift | 낮음 | **예 — 즉시** | +| **P3** | 존재하지 않는 클래스 이름 | 낮음 | **예 — P1과 함께** | +| **P4** | build.gradle 자기모순 주석 | 낮음 | **예 — P1과 함께** | +| **P5** | 고아 javadoc | 즉시 | **예 — P1과 함께** | +| **P2** | cursor 미배선 | 설계 결정 | **결정만 먼저** | +| P6 | modelled 3개 배선 | 각 설계 결정 | P2 결정 후 같은 패턴으로 | +| P10 | GQL-009 ADR | 낮음 | 다음 리뷰 전에 | +| P8 | 스캐너 FQN 보강 | 중간 | 여유 있을 때 | +| P9 | 부하·장애 증거 | 인프라 | 릴리스 주장 전에 | +| P11 | 테스트 숫자 갱신 | 명령 4개 | 아무 때나 | +| P7 | public 표면 축소 | 큼 | ADR 먼저 | + +**P1/P3/P4/P5는 전부 문서·주석 수정이고 한 커밋에 들어간다.** 그리고 넷 다 "코드는 맞는데 산문이 +틀렸다"는 같은 부류다 — 이 leaf가 코드에 왜를 쓰는 규율이 강한 만큼, 산문 쪽이 뒤처졌다. + +--- + +## 29. 기술 블로그 글감 + +`tech-log`용. 각각 독립적으로 읽히고, 코드 인용이 이미 확보돼 있다. + +### 1. "꺼져 있다"를 구조로 만들기 — Spring Boot 인바운드 어댑터의 off 계약 + +**핵심**: `@ConditionalOnProperty`만으로는 안 된다. 프레임워크가 스스로 라우트를 연다. +4겹(imports 진입점 / 마스터 조건 / `AutoConfigurationImportFilter` / `EnvironmentPostProcessor`)과 +**실제 포트 404 테스트**. 그리고 *"a misspelled entry fails open silently"*. +→ §3. 가장 재사용성이 높은 글감이다. + +### 2. 정책 객체의 단위 테스트는 정책이 강제된다는 증거가 아니다 + +**핵심**: 같은 레포에서 네 번 나타난 같은 결함 — 캐시, DataLoader, 관측 convention, resolver 등록. +그리고 **테스트가 런타임에 없던 등록을 스스로 공급했다**는 결정적 사례. +대응으로서의 `modelled`/`wired`/`integration-verified`/`production-verified` 등급 어휘. +→ §9.6, §22.1, §25.3. + +### 3. 하나의 질문에 두 설정 — split-brain 안전 축이 만든 출하 기본값 + +**핵심**: `production=false` + `environment=PRODUCTION_PUBLIC`이 같은 record에. 결과가 +"인터넷 노출 프로덕션인데 보호는 노트북처럼". 축을 하나로 합치는 리팩터링과, **은퇴한 키를 +무시하지 않고 거부**하기. +→ §4.1, §4.2. + +### 4. 커서에 서명하기 — 그리고 그것만으로 부족한 이유 + +**핵심**: HMAC 서명은 "서버가 발급했다"만 증명한다. **"이 요청을 위해 발급했다"는 증명하지 않는다.** +`GraphQlCursorScope`의 4축(query profile / filter fingerprint / direction / tenant scope)과 +**4개를 한 값으로 넘겨 부분 검사를 불가능하게 만드는 API 설계**. +→ §13.2, §13.3. + +### 5. 이스케이프 대신 길이 프레이밍 — 같은 버그를 두 번 만난 이야기 + +**핵심**: 커서 봉투와 mutation 지문이 각각 구분자 문제로 깨졌다. 커서는 라운드트립 실패, 지문은 +**충돌**(= 멱등성 붕괴). 두 수정이 서로 다른 방향으로 갔다는 점. +→ §13.4, §14.4, §27.4. + +### 6. 정규식은 카디널리티를 묶지 않는다 — 잘 만든 클라이언트가 메트릭 백엔드를 죽이는 법 + +**핵심**: `Query0000001`... 전부 유효. **정규식이 검사하고 있어서 경계가 있어 보였다.** +allowlist + 레지스트리 기반 이름 + 실제 `MeterRegistry`로 10,000 → 1을 측정하는 테스트. +→ §19.1, §19.2. + +### 7. GraphQL nullability는 실패 격리 결정이다 + +**핵심**: non-null 필드의 null은 가장 가까운 nullable 조상까지 올라간다. **enrichment 필드에 `!`를 +붙이면 무관한 서비스 장애가 주문 전체를 지운다.** `!`를 리뷰 가능한 결정으로 만드는 계약 타입. +→ §16.5. GraphQL 특유 글감 중 가장 강하다. + +### 8. field error는 200이다 — 그리고 그게 스모크 테스트를 무의미하게 만드는 법 + +**핵심**: 실행 시작 여부가 4xx/200을 가른다. "에러 처리를 개선"해서 500으로 바꾸면 위생이 좋아 +보이는데 클라이언트는 동작하는 응답의 절반을 잃는다. **그리고 상태 코드만 보는 스모크는 통과한다.** +`graphql-smoke.sh`가 바디를 읽는 이유. +→ §7.2, §24.3, §25.4. + +### 9. 없어진 검사와 통과한 검사를 구분하기 — fail-closed 테스트 레인 설계 + +**핵심**: `.gitignore`의 `build/`가 Java 패키지를 삼킨 사건에서 시작해서, `requiredStableClasses` / +stale XML 삭제 / 빈 performance 레인 실패 / "타입이 아직 존재한다" 짝 테스트 / 빈 walk 예외까지 +**여섯 개의 같은 원리**. +→ §2.1, §24.2, §25.2, §27.3. + +### 10. 레지스트리 없이 모듈 경계 지키기 — 단일 leaf 안의 40개 모듈 + +**핵심**: messaging은 24 leaf를 등록했고 graphql은 40개 sub-package를 골랐다. **둘 다 같은 레포에 +있고 어느 쪽으로 통일할지가 미결이다.** enum으로 모듈을 선언하고 소스 스캔으로 강제하는 방법, +purity 등급, longest-prefix ownership, 그리고 다섯 규칙 각각의 negative fixture. +→ §1.2, §2. + +### 11. 의존 방향을 뒤집지 않고 인바운드가 durable 저장을 갖는 법 + +**핵심**: persisted operation 레지스트리가 인바운드 SPI인데 Postgres 어댑터가 그걸 구현하면 +방향이 뒤집힌다. 중립 `OperationalRecordStorePort` + 매핑 클래스 하나 + **ADR이 "아무도 구현하지 +않는다"는 전제를 상시 테스트로 바꾸는 것**. +→ §23.1, §11.4, §25.2. ADR-GQL-001 원문이 통째로 좋은 인용거리다. + +### 12. 감사 기록이 없느니만 못한 순간 + +**핵심**: 문자열 operator + `ArrayList` 감사 + 비원자적 변경. *"An audit trail with any of those +properties is worse than none, because it is trusted."* 그리고 **시도된 변경이 아니라 일어난 변경**을 +기록해야 한다는 것 (`ABSENT -> BLOCKED`). +→ §23.2. + +### 13. 977KB로 필드 한 개짜리 스키마를 지키기 — 플랫폼과 API를 분리한다는 것 + +**핵심**: 이 leaf의 정체성 자체가 글감이다. feature-agnostic 인바운드 플랫폼, 서버를 고르지 않기, +인증을 구현하지 않기, `_health` 하나만 소유하기. 그리고 **그 선택이 만든 테스트 전략**(계약 +스위트를 testFixtures로 내보내고 production jar를 열어서 검사). +→ §0, §1.3, §24.3, §24.4. + +--- + +## 부록 A — 이 문서를 쓰면서 쓴 명령 + +```bash +cd src + +# 포커스 테스트 +./gradlew :adapter:inbound:graphql:test --console=plain + +# 플랫폼 레인 4개 +./gradlew :adapter:inbound:graphql:graphqlStableTest --console=plain +./gradlew :adapter:inbound:graphql:graphqlContractTest --console=plain +./gradlew :adapter:inbound:graphql:graphqlAdvancedTest --console=plain +./gradlew :adapter:inbound:graphql:graphqlPerformanceTest --console=plain # 실부하 인프라 필요 + +# 전송 qualification (no-skip) +./gradlew :adapter:inbound:graphql:test \ + --tests dev.caskeleton.adapter.inbound.graphql.GraphqlHttpBoundaryQualificationTest \ + --console=plain + +# 아티팩트 게이트 +./gradlew :adapter:inbound:graphql:verifyGraphQlProductionJar --console=plain +./gradlew :adapter:inbound:graphql:verifyGraphQlApiSurface --console=plain +./gradlew :adapter:inbound:graphql:updateGraphQlApiSurface -PapproveGraphQlApiSurfaceChange + +# 아키텍처 전역 +./gradlew verifyCleanArchitectureDependencies --console=plain +./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain +./gradlew verifyPublicPathSnapshot --console=plain + +# app-bootstrap 쪽 GraphQL 경계·활성화 테스트 +./gradlew :app-bootstrap:test --tests '*GraphQl*' --console=plain +``` + +규모·사실 확인에 쓴 것들: + +```bash +G=src/adapter/inbound/graphql +B=$G/src/main/java/dev/caskeleton/adapter/inbound/graphql + +# 규모 +find $G/src/main/java -name '*.java' | wc -l +find $G/src/main/java -name '*.java' -exec cat {} + | wc -l + +# 패키지별 규모 +for d in $B/*/; do printf "%-16s %3s %8s\n" "$(basename $d)" \ + "$(ls $d*.java | wc -l)" "$(cat $d*.java | wc -c)"; done + +# 프레임워크 바인딩 파일 (purity 등급 교차검증) +grep -rlE '^import (org\.springframework|graphql\.|reactor\.|io\.micrometer|jakarta\.servlet|org\.dataloader)' \ + $B --include='*.java' | wc -l + +# 클래스 javadoc만 훑기 (전 파일 표면 읽기에 사용) +for f in $B//*.java; do + echo "--- $(basename $f .java)" + awk '/^\/\*\*/{p=1} p{print} /\*\//{if(p){exit}}' "$f" \ + | sed 's/^ \* \?//;s/^\/\*\*//;s/^ \*\///' | grep -v '^$' | head -6 +done + +# 문서 주장 반증 +grep -n "graphql" src/app-bootstrap/build.gradle +grep -rn "keyIds\|GraphQlCursorKeyRing" $B --include='*.java' +grep -rn "^import jakarta.servlet" $B --include='*.java' +grep -rn "GraphqlExceptionResolver" src/ --include='*.java' +``` + +--- + +## 부록 B — 이 leaf를 처음 읽는 사람을 위한 순서 + +내가 실제로 읽은 순서이고, 다시 읽어도 이 순서로 할 것 같다. + +**1단계 — 정체성 (30분)** +1. `src/config/architecture/modules.json`의 GraphQL 레코드 — 네 줄 +2. `src/adapter/inbound/graphql/CLAUDE.md` — 모듈 규칙 SSOT. **§28-P1/P3의 drift를 알고 읽을 것** +3. `src/adapter/inbound/graphql/README.md` — 설계 근거 +4. `src/gradle/graphql-platform-conventions.gradle` — 150줄. 레인 4개와 fail-closed 3겹 +5. `src/adapter/inbound/graphql/build.gradle` — 의존 결정의 근거가 전부 주석에 있다 + +**2단계 — 경계와 스위치 (1시간)** +6. `moduleboundary/` 4개 — 이 leaf가 자기를 어떻게 나누는지 +7. `autoconfigure/GraphQlRootAutoConfiguration` → `GraphQlOffAutoConfigurationImportFilter` + → `GraphQlActivationEnvironmentPostProcessor` → `META-INF/spring.factories` +8. `autoconfigure/GraphQlDeploymentMode` — javadoc이 사건 기록이다 +9. `autoconfigure/GraphQlPlatformStartupValidator` — `problems`와 `runtimeProblems` 둘 다 + +**3단계 — 요청 경로 (2시간)** +10. `execution/GraphQlExecutionStage` + `GraphQlExecutionPipelineValidator` — 순서와 근거 +11. `runtime/GraphQlPlatformWebInterceptor` — CONTEXT 단계 +12. `runtime/GraphQlPlatformInstrumentation` — 훅 선택이 설계 결정이다 +13. `runtime/GraphQlExecutionChain` + `GraphQlExecutionContext` + `GraphQlExecutionHandler` +14. 세 핸들러: `GraphQlOperationSelectionHandler` → `GraphQlDocumentAuthorizationHandler` + → `GraphQlCostBudgetHandler` +15. `runtime/GraphQlWireErrorMapper` — 세 계약이 하나가 된 기록 + +**4단계 — 정책 (3시간, 관심 순서대로)** +16. `context/GraphQlCommandAttribution` + `TenantContext` + `GraphQlIdentityFingerprinter` +17. `http/` — `GraphQlHttpStatusMapper`, `GraphQlAcceptHeader`, `GraphQlJsonValues` +18. `cost/GraphQlDocumentComplexityScorer` +19. `pagination/HmacGraphQlCursorCodec` + `GraphQlCursorScope` + `GraphQlCursorFraming` +20. `security/GraphQlAuthorizationPolicy` + `GraphQlTenantIsolationPolicy` + `GraphQlContextPropagator` +21. `dataloader/GraphQlBatchValue` + `GraphQlBatchResultMapper` +22. `error/GraphQlFailureBoundary` + `GraphQlNullabilityContract` +23. `observation/GraphQlOperationNameCardinality` + +**5단계 — Advanced (2시간, 필요할 때)** +24. `advanced/bootstrap/` 6개 — 게이트 구조 +25. `advanced/persisted/` + `advanced/admin/` — 이 leaf에서 가장 정교한 부분 +26. 나머지 15개 패키지는 **클래스 javadoc만** 읽어도 설계 결정이 전부 잡힌다 + +**6단계 — 증거 (1시간)** +27. `docs/adr/ADR-GQL-001-graphql-context-and-storage-ownership.md` +28. `app-bootstrap/src/test/.../architecture/GraphQlInboundOwnershipBoundaryTest` +29. `app-bootstrap/src/test/.../contract/graphql/GraphQlResolverReachabilityTest` +30. `infra/graphql/smoke/graphql-smoke.sh` +31. `src/testFixtures/java/.../testkit/` — 계약 스위트가 무엇을 주장하는지 + +**7단계 — 역사 (읽으면 나머지가 전부 이해된다)** +32. `docs/reviews/2026-08-14-graphql-module-code-review.md` — 889줄. + **javadoc의 "예전에는 이랬다"가 전부 여기 있다.** §26의 대조표와 함께 읽을 것. diff --git a/docs/superpowers/plans/2026-07-25-module-gradle-hygiene.md b/docs/superpowers/plans/2026-07-25-module-gradle-hygiene.md index 4e77c7ec..a95af829 100644 --- a/docs/superpowers/plans/2026-07-25-module-gradle-hygiene.md +++ b/docs/superpowers/plans/2026-07-25-module-gradle-hygiene.md @@ -79,7 +79,7 @@ Run: --tests 'dev.caskeleton.application.outbox.*' --console=plain ./gradlew :adapter:outbound:messaging:test --console=plain ./gradlew :app-bootstrap:test \ - --tests 'dev.caskeleton.bootstrap.outbox.*' --console=plain + --tests 'dev.caskeleton.bootstrap.autoconfigure.outbox.*' --console=plain ./gradlew verifyApplicationCoreDependencyPurity --console=plain ./gradlew :application-core:dependencies \ --configuration runtimeClasspath --console=plain diff --git a/docs/superpowers/plans/2026-07-28-messaging-first-r2-polling-producer.md b/docs/superpowers/plans/2026-07-28-messaging-first-r2-polling-producer.md index 556554d3..791b2a51 100644 --- a/docs/superpowers/plans/2026-07-28-messaging-first-r2-polling-producer.md +++ b/docs/superpowers/plans/2026-07-28-messaging-first-r2-polling-producer.md @@ -2899,12 +2899,12 @@ authority and destructive downgrade are forbidden. dev.caskeleton.adapter.outbound.persistence.outbox.OutboxClaimRepository dev.caskeleton.adapter.outbound.persistence.postgresql.PostgreSqlOutboxClaimRepository dev.caskeleton.adapter.outbound.persistence.outbox.OutboxReaper - dev.caskeleton.bootstrap.outbox.OutboxLeaderElectionToken - dev.caskeleton.bootstrap.outbox.OutboxLegacyToV2CutoverCoordinator - dev.caskeleton.bootstrap.outbox.OutboxLegacyPreCommitRecoveryCoordinator - dev.caskeleton.bootstrap.outbox.LegacyOutboxRelayControlAdapter - dev.caskeleton.bootstrap.outbox.MessagingAuthorityCutoverJobSettings - dev.caskeleton.bootstrap.outbox.MessagingAuthorityCutoverApplicationRunner + dev.caskeleton.bootstrap.autoconfigure.outbox.OutboxLeaderElectionToken + dev.caskeleton.bootstrap.autoconfigure.outbox.OutboxLegacyToV2CutoverCoordinator + dev.caskeleton.bootstrap.autoconfigure.outbox.OutboxLegacyPreCommitRecoveryCoordinator + dev.caskeleton.bootstrap.autoconfigure.outbox.LegacyOutboxRelayControlAdapter + dev.caskeleton.bootstrap.autoconfigure.outbox.MessagingAuthorityCutoverJobSettings + dev.caskeleton.bootstrap.autoconfigure.outbox.MessagingAuthorityCutoverApplicationRunner OutboxEventJpaRepository.deletePublishedBefore OutboxEventJpaRepository.countGroupedByStatus OutboxEventJpaRepository.findOldestUnpublishedOccurredAtByEventType diff --git a/docs/superpowers/plans/2026-08-10-httpclient-platform-activation-and-env-ssot.md b/docs/superpowers/plans/2026-08-10-httpclient-platform-activation-and-env-ssot.md index 6ab00263..e6172474 100644 --- a/docs/superpowers/plans/2026-08-10-httpclient-platform-activation-and-env-ssot.md +++ b/docs/superpowers/plans/2026-08-10-httpclient-platform-activation-and-env-ssot.md @@ -162,7 +162,7 @@ into the new test package, rewritten to run through the single auto-configuratio `@Bean @ConditionalOnMissingBean(Clock.class) Clock httpClientClock()`. Once the whole capability is gated, that bean would vanish whenever HTTP Client is off — and Redis, idempotency and the Fileserver all inject `Clock`. The application context is unaffected because -`dev.caskeleton.bootstrap.idempotency.IdempotencyConfig#systemClock` declares one unconditionally in +`dev.caskeleton.bootstrap.autoconfigure.idempotency.IdempotencyConfig#systemClock` declares one unconditionally in a scanned package, so the httpclient copy is redundant *in the application* and dangerous *in the gate*. Isolated `ApplicationContextRunner` tests must supply their own, exactly as `FileserverPlatformAutoConfigurationTest` supplies a `MeterRegistry`. diff --git a/docs/superpowers/plans/2026-08-15-wave0-red-baseline.md b/docs/superpowers/plans/2026-08-15-wave0-red-baseline.md index e46b4cf6..3655e44c 100644 --- a/docs/superpowers/plans/2026-08-15-wave0-red-baseline.md +++ b/docs/superpowers/plans/2026-08-15-wave0-red-baseline.md @@ -382,7 +382,7 @@ public final class AdapterActivationInventory { NOTIFICATION( Set.of( "dev.caskeleton.adapter.outbound.notification", - "dev.caskeleton.bootstrap.notification"), + "dev.caskeleton.bootstrap.autoconfigure.notification"), Set.of(), "notification-"), GRAPHQL( diff --git a/docs/superpowers/plans/2026-08-15-wave1-activation-ssot.md b/docs/superpowers/plans/2026-08-15-wave1-activation-ssot.md index f7ccdfe0..e22f97e2 100644 --- a/docs/superpowers/plans/2026-08-15-wave1-activation-ssot.md +++ b/docs/superpowers/plans/2026-08-15-wave1-activation-ssot.md @@ -668,7 +668,7 @@ class CompositionScanNarrownessTest { Pattern excluded = Pattern.compile(regexExcludeOf(APPLICATION.getAnnotation(ComponentScan.class).excludeFilters())); for (String type : new String[] { - "dev.caskeleton.bootstrap.autoconfigure.persistencejpa.PersistenceJpaRootAutoConfiguration", + "dev.caskeleton.bootstrap.autoconfigure.jpa.PersistenceJpaRootAutoConfiguration", "dev.caskeleton.adapter.outbound.mongo.MongoPersistenceConfig", "dev.caskeleton.adapter.outbound.messaging.MessagingSettings", "dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationPlatformSettings", @@ -800,7 +800,7 @@ design: - [ ] **Step 1: Write the failing test** ```java -package dev.caskeleton.bootstrap.autoconfigure.persistencejpa; +package dev.caskeleton.bootstrap.autoconfigure.jpa; import static org.assertj.core.api.Assertions.assertThat; @@ -871,7 +871,7 @@ class JpaMasterGateTest { - [ ] **Step 3: Write `DataSourceRequirement`** ```java -package dev.caskeleton.bootstrap.autoconfigure.persistencejpa; +package dev.caskeleton.bootstrap.autoconfigure.jpa; import dev.caskeleton.shared.activation.MasterSwitch; import java.util.ArrayList; @@ -943,7 +943,7 @@ public final class DataSourceRequirement { - [ ] **Step 4: Write the off-filter** ```java -package dev.caskeleton.bootstrap.autoconfigure.persistencejpa; +package dev.caskeleton.bootstrap.autoconfigure.jpa; import java.util.Set; import org.springframework.boot.autoconfigure.AutoConfigurationImportFilter; @@ -1008,7 +1008,7 @@ public final class JpaOffAutoConfigurationImportFilter Create `PersistenceJpaRootAutoConfiguration`: ```java -package dev.caskeleton.bootstrap.autoconfigure.persistencejpa; +package dev.caskeleton.bootstrap.autoconfigure.jpa; import dev.caskeleton.bootstrap.autoconfigure.jpa.JpaPlatformRuntimeAutoConfiguration; import dev.caskeleton.bootstrap.autoconfigure.jpa.JpaSafetySettings; @@ -1048,9 +1048,9 @@ Then: owns that import — and keep the vendor-specific beans. - Remove `dev.caskeleton.bootstrap.autoconfigure.jpa.JpaPlatformRuntimeAutoConfiguration` from `AutoConfiguration.imports` and add - `dev.caskeleton.bootstrap.autoconfigure.persistencejpa.PersistenceJpaRootAutoConfiguration`. + `dev.caskeleton.bootstrap.autoconfigure.jpa.PersistenceJpaRootAutoConfiguration`. - Create `AutoConfigurationImportFilter.imports` containing - `dev.caskeleton.bootstrap.autoconfigure.persistencejpa.JpaOffAutoConfigurationImportFilter`. + `dev.caskeleton.bootstrap.autoconfigure.jpa.JpaOffAutoConfigurationImportFilter`. - [ ] **Step 6: Run the test to verify it passes.** Run: `cd src && ./gradlew :app-bootstrap:test --tests '*JpaMasterGateTest*' --console=plain --no-daemon` diff --git a/docs/superpowers/plans/2026-09-16-ci-stage-separation.md b/docs/superpowers/plans/2026-09-16-ci-stage-separation.md new file mode 100644 index 00000000..fbcc3e6f --- /dev/null +++ b/docs/superpowers/plans/2026-09-16-ci-stage-separation.md @@ -0,0 +1,108 @@ +# CI 단계 분리 + 컨테이너 릴리스 도입 + +- 작성: 2026-09-16 +- 상태: Track A 진행 중 / Track B·C 착수 대기 +- 근거 감사: 빌드·CI 레이어 전수 리뷰 133건 (파일 110개 / 12,800줄) + +## 확정된 결정 + +1. **배포 단위는 `app-bootstrap` 하나.** 어댑터는 독립 배포되지 않는다. + 따라서 release 워크플로는 8개가 아니라 1개다. 어댑터별로 필요한 것은 + release가 아니라 PR 단계의 선택적 테스트다. +2. **GitOps 매니페스트는 별도 repo.** 단, 이번 작업 범위 밖이다. + 이 repo는 "이미지를 만들고 태그를 확정"하는 데서 끝난다. + ArgoCD Application 정의와 매니페스트는 이미지가 생긴 뒤 착수한다. +3. **작업은 main에서 직접 하고 커밋한다** (사용자 지시). + `CLAUDE.md:36` 의 `commit policy is human-only` 와 충돌하므로 + 그 줄도 이번에 함께 갱신한다. push 는 하지 않는다. + +## 현재 구조의 문제 — 한 줄 + +**워크플로가 단계가 아니라 모듈로 쪼개져 있다.** + +28개 워크플로가 전부 "어느 모듈이냐"(`web-*`, `jpa-*`, `httpclient-*` …)로 갈렸고 +"어느 단계냐"로는 갈리지 않았다. 결과: + +- 같은 성격의 일이 9개 파일에 흩어진다 +- 한 파일 안에 PR 검증과 릴리스 게이트가 섞인다 +- 동일한 13줄 setup 블록이 35회 복붙됐다 (CI 366줄) +- 어떤 게이트가 개발을 막고 어떤 게이트가 안 막는지 파일만 봐서는 모른다 + +이 상태에서는 "이 게이트가 쓸모 있나"를 물을 수 없다. 단계가 하나뿐이면 +모든 게이트가 똑같이 개발을 막기 때문이다. + +## 목표 구조 + +| 단계 | 답하는 질문 | 예산 | 트리거 | 실패 시 | +| --- | --- | --- | --- | --- | +| 1 PR 게이트 | 이 diff가 안전한가 | 5분 | PR, 변경 모듈만 | 머지 차단 | +| 2 통합 | 합쳐진 상태가 건강한가 | 30분 | main push | 알림, 머지는 이미 끝남 | +| 3 릴리스 | 배포 가능한 산출물 생성 | — | tag | 릴리스 중단 | +| 4 CD | 클러스터를 산출물로 수렴 | — | ArgoCD 폴링 | (이번 범위 밖) | + +### 게이트 재배치 원칙 + +감사에서 나온 B등급 36건(어겨도 프로그램은 도는 문서·네이밍·개수 검증)은 +**삭제 여부를 논쟁하지 않는다. 단계를 지정한다.** + +- 1단계: 컴파일, 의존성 방향, 잠금파일, 시크릿·취약점 스캔, 변경 모듈 테스트 +- 2단계: 문서-코드 일치, 공개 경로 스냅샷, env 키, 전체 테스트, 느린 통합 테스트 +- 3단계: 이미지 빌드, SBOM, 서명, 릴리스 차단 게이트 집계 + +1단계에 있으면 개발을 막고, 2단계에 있으면 안 막는다. 이 배치가 +"쓸모 있나"라는 질문을 대체한다. + +## 작업 순서 + +### Track A — 깨진 게이트 (진행 중) + +설계 논쟁이 필요 없는 E등급 24건. 구조는 건드리지 않는다. + +핵심: **아키텍처 게이트가 규칙을 0개 실행하고 있다.** +워크플로 7곳과 `CLAUDE.md:109` 가 `--tests '*CleanArchitectureTest'` 를 지정하는데 +그 이름의 클래스는 존재하지 않는다. 실재하는 ArchUnit 테스트는 17개 +(`AdapterBoundaryArchitectureTest`, `DomainPurityArchitectureTest` 등, ArchUnit 사용 37개 클래스). +4곳은 하드 실패하고, 3곳은 다른 필터와 병기돼 아키텍처 규칙 0개를 돌고 초록으로 통과한다. + +그 외: `release_blocking` 미강제(trivy-fs 가 빨개도 release-gate 초록), +`strict-test-lane` 이 skip 을 실행으로 셈, `public-path-snapshot` 이 gitignore 된 `src/.env` 를 읽음, +`verifyEnvKeys` 가 build 산출물을 소스로 읽음, `jpa-next-*` 3개가 실제 테스트 없이 continue-on-error, +`spring70CompatibilityTest` fail-closed 상실, `fileserver-pr` 존재하지 않는 path 필터. + +### Track B — 단계 분리 + +1. 재사용 워크플로(`workflow_call`) + composite action 으로 setup 블록 공통화 +2. release 워크플로 8개 → 1개. 릴리스 태그 네임스페이스 분열 버그도 여기서 해소 +3. PR 단계는 경로 필터로 변경 모듈만 실행 +4. 게이트를 위 표대로 재배치. 문서 검증류는 2단계로 내린다 +5. `ci-gate-matrix.yml` 이 실제로 release 차단을 강제하도록 연결 + (이 파일은 죽은 문서가 아니다 — `ci-quality-gates.yml:88` 이 런타임 파싱한다) +6. 도달 불가 Gradle 태스크 39개 정리 (grpc 워크플로가 0개인 것이 주원인) + +### Track C — 컨테이너 릴리스 (축소된 범위) + +지금 없는 것: 이미지 빌드·푸시. `src/Dockerfile` 은 있으나 +`build-push-action` / `bootBuildImage` / `jib` 사용처가 0건이다. +`*-release.yml` 8개는 테스트 실행 + evidence 업로드로 끝난다 — 이름만 릴리스다. + +1. 3단계 릴리스 워크플로에 이미지 빌드 + 레지스트리 푸시 추가 (기본 ghcr.io) +2. 태그 규칙 확정 (semver + git sha) +3. SBOM 생성, 이미지 스캔 +4. **CI 는 배포하지 않는다.** `kubectl apply` 를 CI 에 넣지 않는다 — GitOps 원칙. + 현재 그런 코드가 없으므로 걷어낼 것도 없다. + +이후(별도 작업): GitOps repo, 매니페스트, ArgoCD Application, image tag bump 연결. + +## 검증 + +- Track A 완료 시: 변경한 YAML 전수 파싱, 아키텍처 테스트가 실제로 실행되는지 확인 +- Track B 완료 시: `verifyCleanArchitectureDependencies`, 아키텍처 테스트, 워크플로 파싱 +- 각 Track 종료 시 무엇을 실행했고 무엇을 실행하지 못했는지 명시한다. + 실행하지 못한 검증은 "실행하지 않음"이라고 적는다. + +## 감사 산출물 + +- 루브릭: `scratchpad/gradle-audit/RUBRIC.md` +- finding 전체(243KB, `file:line` 근거): `scratchpad/gradle-audit/findings/R1~R8.md` +- 등급 분포: A=25 B=36 C=17 D=31 E=24 · 정리 시 3,368줄 감소 추정 +- 아키텍처 위반 0건 (`modules.json` 전수 대조, messaging/grpc 격리 확인) diff --git a/docs/superpowers/plans/evidence/2026-08-15-wave0-baseline.md b/docs/superpowers/plans/evidence/2026-08-15-wave0-baseline.md index fdd46e53..a6e141e6 100644 --- a/docs/superpowers/plans/evidence/2026-08-15-wave0-baseline.md +++ b/docs/superpowers/plans/evidence/2026-08-15-wave0-baseline.md @@ -113,7 +113,7 @@ dev.caskeleton.adapter.outbound.notification.core.RoutingNotifier dev.caskeleton.adapter.outbound.notification.email.google.GoogleEmailNotificationAdapterConfig dev.caskeleton.adapter.outbound.notification.platform.autoconfigure.NotificationPlatformSettings dev.caskeleton.adapter.outbound.notification.slack.webhook.SlackNotificationAdapterConfig -dev.caskeleton.bootstrap.notification.NotificationPlatformSecretsConfig$NotificationSecretsSettings +dev.caskeleton.bootstrap.autoconfigure.notification.NotificationPlatformSecretsConfig$NotificationSecretsSettings ``` ## Task 5 — default-profile boot diff --git a/docs/testing/TESTING_STRATEGY.md b/docs/testing/TESTING_STRATEGY.md new file mode 100644 index 00000000..27f3a191 --- /dev/null +++ b/docs/testing/TESTING_STRATEGY.md @@ -0,0 +1,177 @@ +# 테스트 전략 — 레벨 정의와 소스셋 매핑 (SSOT) + +- 기준 일자: 2026-09-07 +- 상태: **활성 계약.** `verifyTestSourceSetRegistry` 가 이 문서의 §3 표와 실제 Gradle 소스셋 선언의 + 불일치를 빌드 실패로 만든다. +- 근거 리뷰: `docs/reviews/2026-09-07-app-bootstrap-module-code-review.md` (BOOT-014, BOOT-015, + BOOT-016) + +## 1. 이 문서가 존재하는 이유 + +이 저장소는 이미 테스트 레벨 계약을 **기계로** 강제하고 있었다 — +`TestTaxonomyArchitectureTest` 가 contract/architecture 트리의 Testcontainers 의존을 금지하고, +slice 애노테이션 혼합을 막고, fixture 유출을 잡는다. 없던 것은 **사람이 읽을 수 있는 정의**였다. + +그 결과 계약은 "패키지 이름"에만 걸려 있었고 "어느 소스셋이 컴파일하는가"에는 걸려 있지 않았다. +Testcontainers 를 쓰는 통합 테스트 9개가 `app-bootstrap/src/test` 안에 있었고, `@Testcontainers` 5개 +중 가드가 있는 것은 하나뿐이었다. 즉 `./gradlew :app-bootstrap:test` — 이 저장소가 leaf 별 기본 +명령으로 권장하는 바로 그 명령 — 이 Docker 데몬을 요구했다 (BOOT-014). + +그래서 규칙을 두 가지 방식으로 동시에 고정한다. 사람은 이 문서를 읽고, 빌드는 §3 표를 읽는다. + +## 2. 레벨 정의 + +레벨은 **이름이 아니라 "어디까지 실제로 붙여서 검증하는가"**로 정의한다. `smoke`, `regression`, +`acceptance` 같은 말은 범위가 아니라 목적이라 레벨이 될 수 없다 — 하나의 E2E 테스트가 동시에 +smoke 이고 regression 일 수 있다. + +| 레벨 | 무엇을 검증 | 외부 시스템 | 소스셋 | +| --- | --- | --- | --- | +| **unit** | 클래스·함수·도메인 규칙 | 없음 | `src/test` | +| **slice** | 프레임워크 한 계층 | 인메모리/모의 | `src/test` | +| **contract** | 모듈 경계의 형태와 약속 | 없음 | `src/test` | +| **architecture** | 코드 의존 관계, 테스트 분류 자체 | 없음 | `src/test` | +| **integration** | 실제 인프라와의 연결 | 실제 DB/브로커/스토리지 | `src/integrationTest` 또는 leaf 전용 레인 | +| **qualification** | 벤더·프로토콜·배포 형상 | 실제 벤더 런타임 | leaf 전용 레인 | +| **build-qualification** | 빌드·조립 계약 자체 | 없음 (별도 클래스패스) | leaf 전용 레인 | +| **performance** | 지연·처리량 | 실제에 가까움 | leaf 전용 레인 | + +### 2.1 소스셋을 나누는 기준은 하나다 + +> **테스트 코드는 production 패키지 구조를 그대로 미러링한다. 별도 소스셋으로 분리하는 것은 +> 실행 환경·의존성·클래스패스가 달라지는 경우뿐이다.** + +`unit/`, `service/`, `repository/`, `regression/` 같은 폴더는 만들지 않는다. 서로 다른 분류 축을 +한 디렉터리에 섞으면 `UserServiceTest` 가 어디에 속하는지 아무도 답할 수 없게 된다. 이 저장소의 +`src/test` 는 이미 production 패키지를 미러링하고 있으며 그 상태를 유지한다. + +### 2.2 build-qualification 은 폴더 취향이 아니다 + +`app-bootstrap` 의 세 레인은 "테스트를 분류하려고" 나눈 것이 아니라 **하나의 소스셋으로 표현할 수 +없는 클래스패스 차이** 때문에 존재한다. 합치면 검증 자체가 성립하지 않는다. + +- `sampleOffTest` — `src/test` 와 **같은 소스 파일**을 `sample-portfolio` 없는 클래스패스로 다시 + 컴파일한다. "샘플을 지워도 템플릿이 성립하는가"의 증명이며, 같은 파일을 두 클래스패스로 컴파일하는 + 것이 그 정의다. +- `conditionalTransportTest` — GraphQL/gRPC/WebSocket 을 **테스트 전용으로만** 클래스패스에 올린다. + 이 의존을 `testImplementation` 으로 옮기면 "기본 클래스패스에는 없다"는 증명 대상 명제가 그 순간 + 거짓이 된다. +- `functionalTest` — Gradle TestKit 이 별도 Gradle 빌드를 띄운다. + +## 3. 소스셋 레지스트리 (기계 검증 대상) + +`verifyTestSourceSetRegistry` 가 이 표를 읽어 실제 `sourceSets` 선언과 대조한다. 표에 없는 소스셋을 +추가하거나 표에 있는 소스셋을 지우면 빌드가 실패한다. + + +| Gradle 경로 | 소스셋 | 레벨 | +| --- | --- | --- | +| `:adapter:inbound:graphql` | `testFixtures` | fixtures | +| `:adapter:inbound:web` | `jettyCompatTest` | qualification | +| `:adapter:inbound:web` | `nginxProxyTest` | qualification | +| `:adapter:inbound:web` | `testFixtures` | fixtures | +| `:adapter:inbound:web` | `webfluxContractTest` | qualification | +| `:adapter:inbound:websocket` | `brokerRelayTest` | integration | +| `:adapter:inbound:websocket` | `jettyWebSocketTest` | qualification | +| `:adapter:inbound:websocket` | `nginxWebSocketTest` | qualification | +| `:adapter:inbound:websocket` | `testFixtures` | fixtures | +| `:adapter:outbound:httpclient` | `httpClientPerformanceTest` | performance | +| `:adapter:outbound:httpclient` | `jmh` | performance | +| `:adapter:outbound:httpclient` | `testFixtures` | fixtures | +| `:adapter:outbound:objectstorage` | `objectStorageAwsQualificationTest` | qualification | +| `:adapter:outbound:objectstorage` | `objectStorageMinioContractTest` | integration | +| `:adapter:outbound:objectstorage` | `objectStorageMinioFaultTest` | integration | +| `:adapter:outbound:persistence-jpa` | `jpaPlatformPerformanceTest` | performance | +| `:adapter:outbound:persistence-jpa` | `postgresqlIntegrationTest` | integration | +| `:adapter:outbound:persistence-jpa` | `testFixtures` | fixtures | +| `:adapter:outbound:persistence-mongo` | `mongoPerformanceTest` | performance | +| `:adapter:outbound:persistence-mongo` | `testFixtures` | fixtures | +| `:app-bootstrap` | `conditionalTransportTest` | build-qualification | +| `:app-bootstrap` | `functionalTest` | build-qualification | +| `:app-bootstrap` | `integrationTest` | integration | +| `:app-bootstrap` | `sampleOffTest` | build-qualification | +| `:messaging:messaging-kafka` | `jmh` | performance | +| `:messaging:messaging-rabbit` | `jmh` | performance | +| `:messaging:messaging-testkit` | `jmh` | performance | +| `:sample-portfolio` | `posterImageMigrationTest` | qualification | +| `:shared-contract` | `edgeRateLimitContractTest` | contract | + + +`src/test` 는 모든 leaf 가 갖는 기본 소스셋이므로 표에 적지 않는다. + +## 4. 판단표 — 새 테스트를 어디에 쓰는가 + +대상 코드가 정해지면 위치와 방식이 기계적으로 결정되어야 한다. + +| 대상 | 레벨 | 협력자 | 위치 | +| --- | --- | --- | --- | +| 도메인 엔티티·값 객체 | unit | 없음 | 해당 leaf `src/test` | +| 유스케이스 | unit | 손으로 만든 Fake (Mockito 아님) | `application-core/src/test` | +| 시작 검증기 (`*Validator`) | unit | `MockEnvironment` | `app-bootstrap/src/test` | +| `@Configuration` 조립 | slice | `ApplicationContextRunner` | `app-bootstrap/src/test` | +| 컨트롤러 | slice | `@WebMvcTest` + 모의 유스케이스 | `adapter/inbound/web/src/test` | +| JPA 리포지토리 매핑 | integration | Testcontainers PostgreSQL | `postgresqlIntegrationTest` | +| 아웃박스·멱등성 행 수명주기 | integration | Testcontainers PostgreSQL | `app-bootstrap/src/integrationTest` | +| 브로커 발행/수신 | integration | 실제 브로커 | leaf 전용 레인 | +| 에러 응답 스키마 | contract | 없음 (스냅샷) | `app-bootstrap/src/test/.../contract` | +| 의존 방향·패키지 경계 | architecture | 없음 (ArchUnit) | `app-bootstrap/src/test/.../architecture` | + +### 4.1 금지 + +- `src/test` 안에서 `org.testcontainers` 의존 — `TestTaxonomyArchitectureTest` 가 막는다 (BOOT-014). +- 필요 없는 `@SpringBootTest`. 조립을 검증할 것이 아니면 `ApplicationContextRunner` 나 순수 단위 + 테스트로 충분하다. +- slice 애노테이션 혼합 (`@WebMvcTest` + `@DataJpaTest`) — Spring 이 지원하지 않는다. +- production 코드가 test fixture 에 의존하는 것. +- 픽스처를 `TestUtil`·`CommonUtil` 같은 이름으로 묶는 것. 역할을 드러내는 이름 + (`fixture/`, `fake/`, `container/`, `assertion/`) 을 쓴다. + +## 5. 공용 테스트 지원 코드 — `testFixtures` + +**표준은 `java-test-fixtures` 하나다 (ADR-BUILD-001).** 공용 테스트 지원 코드는 +`src/testFixtures/java` 에 두고, 다른 leaf 는 `testFixtures(project(':x'))` 로 소비한다. + +두 관례가 공존하던 상태(BOOT-015)는 해소됐다. `ca.testkit-publisher` 컨벤션 플러그인과 그것을 쓰던 +`testkit` 소스셋 5개는 모두 이관됐고, 플러그인 자체도 제거됐다. 이관하면서 드러난 사실 하나는 기록해 +둘 값어치가 있다: `testkit*` 구성이 `testImplementation` 을 상속했기 때문에 fixture 들은 각 leaf 가 +선언한 모든 테스트 라이브러리를 **말없이** 보고 있었다. `testFixturesImplementation` 으로 옮기면서 +그 표면이 드러났고, 다섯 leaf 에서 도합 30개가 넘는 의존을 명시적으로 적어야 했다. + +`test` 가 아닌 lane 은 fixture 를 소비한다고 선언해야 한다 — `java-test-fixtures` 는 `test` 만 +자동으로 배선한다: + +```groovy +strictTestLanes { + sourceSet('postgresqlIntegrationTest') { compilesAgainst 'main', 'testFixtures' } +} +``` + +디렉터리는 역할을 드러내는 형태를 권고한다 (`fixture/`, `fake/`, `container/`, `assertion/`). +`TestUtil`·`CommonUtil` 같은 무의미한 이름 묶음은 금지한다. + +## 6. CI 단계 매핑 + +폴더만 나누고 CI 에서 한꺼번에 돌리면 분리의 의미가 없다. + +``` +커밋 / IDE → unit · slice · contract · architecture (`test`) +Pull Request → + integration (integration 레인) +머지 / 스테이징 → + build-qualification (functionalTest, sampleOffTest, + conditionalTransportTest) +야간 / 스케줄 → + qualification · performance +``` + +`check` 에는 인프라 레인을 붙이지 않는다. 이것은 이 저장소가 이미 따르고 있는 관례이며 +(`persistence-jpa` 의 `postgresqlIntegrationTest` 도 `check` 에 붙어 있지 않다), Docker 없는 +환경에서 `check` 가 실패하지 않게 하는 유일한 방법이다. + +## 7. LLM 에이전트에게 적용할 때 + +이 저장소는 에이전트 협업을 전제로 설계되어 있다. 테스트 생성을 맡길 때는 다음 순서를 강제한다. + +1. 이 테스트가 §2 의 어느 레벨인지 판정하고 근거를 적는다. +2. §3 표에서 해당 소스셋을 찾는다. +3. 이미 존재하는 fixture 를 먼저 검색한다. +4. 테스트를 작성한다. +5. 판정한 레벨보다 큰 레벨로 작성하지 않았는지 확인한다 (`@SpringBootTest` 를 썼다면 왜 필요한지 + 설명할 수 있어야 한다).