# adapter:inbound:graphql — inbound GraphQL adapter (skeleton machinery) ## Registered identity - Module ID: `adapter-inbound-graphql` - Gradle path: `:adapter:inbound:graphql` - Focused test (derived from Gradle path): `./gradlew :adapter:inbound:graphql:test --console=plain` - Runtime baseline: Java 21; repository framework baseline: Spring Boot 4.0.0. - Registry SSOT: `src/config/architecture/modules.json`. Package root: `dev.caskeleton.adapter.inbound.graphql`. 코드 주석에서 덜어낸 **설계 결정의 근거**는 [README.md](README.md) 가 모아둔다 (이 문서는 모듈 규칙 SSOT). ## 플랫폼 모듈 = sub-package (Gradle 모듈 아님) GraphQL 플랫폼 설계서는 Stable 16개 + Advanced 12개 "모듈"을 말하지만, 이 구현은 그 28개를 이 leaf 안의 **bounded sub-package** 로 매핑한다(선례: httpclient leaf 가 366개 java 파일을 같은 방식으로 담는다). GraphQL 표면은 하나의 인바운드 전송 경계이고, 그 내부 분할을 leaf 정체성 SSOT(`src/config/architecture/modules.json`)까지 밀어올리지 않는다는 선택이다. **이는 레지스트리가 닫혀 있어서가 아니다.** `modules.json` 은 확장 가능하며, 실제로 자매 플랫폼인 messaging 은 **정반대 선택**을 해서 자기 leaf 들을 레지스트리에 개별 등록했다. 개수는 `modules.json` 이 소유하며 여기서 되풀이하지 않는다 — 산문에 적힌 숫자는 leaf 가 하나 추가되는 순간 낡는다. 즉 이 레포에는 두 패턴이 공존한다: | | 방식 | 경계 강제 | | --- | --- | --- | | messaging | 레지스트리에 leaf 등록 | Gradle 의존 게이트 | | graphql | 단일 leaf 내 sub-package | 아래 경계 테스트 | 어느 쪽으로 통일할지는 **미결 아키텍처 결정**이다. graphql 을 leaf 로 분해하려면 이 문서의 모듈 레코드가 그대로 leaf 명세로 승격될 수 있게 설계해 두었다. 그때까지 모듈 경계는 문서가 아니라 기계가 지킨다: - main 의 `moduleboundary/GraphQlStableModule` · `moduleboundary/GraphQlAdvancedModule` 이 모듈 정체성·purity 등급·허용 의존 edge 를 값으로 선언하고, `moduleboundary/GraphQlModuleBoundary` 가 "이 패키지의 주인은 누구인가 / 이 edge 는 선언됐는가"를 답한다. - test 의 `moduleboundary/GraphQlBuildModel` 이 실제 소스 트리를 스캔하고, `moduleboundary/GraphQlModuleBoundaryTest` 가 (a) Stable 패키지의 `...graphql.advanced` import 금지, (b) `CORE` 등급 모듈의 Spring/GraphQL Java/Reactor/Micrometer/Jakarta import 금지, (c) 선언되지 않은 cross-module edge 금지, (d) 미등록 패키지 금지, (e) 선언만 있고 소스가 없는 모듈 금지를 강제한다. 각 규칙은 **거부되는 합성 트리(negative fixture)** 를 함께 가진다. **패키지 이름은 `build` 가 아니라 `moduleboundary` 다.** `src/.gitignore:2` 의 anchor 없는 `build/` 규칙은 Gradle 산출물과 Java 패키지를 구분하지 못해서, 예전에 이 경계 모델 전체를 커밋에서 삼켰다(프로덕션 코드는 계속 import 하고, 작성자 작업본만 컴파일되고, fresh checkout 은 7개 오류로 깨졌다). 레포 전역 `verifyNoIgnoredSourcePackages` 가 이 부류를 막고, `graphqlStableTest` 의 required-class 검사가 "경계 테스트만 조용히 사라지고 레인은 green" 인 나머지 절반을 막는다. **새 플랫폼 sub-package 를 추가할 때는 반드시 해당 모듈 레코드에 정체성과 허용 edge 를 먼저 등록한다.** 등록 없이 추가된 패키지는 경계 테스트가 실패시킨다. ## Responsibility - GraphQL 전송 인프라만: 최소 health 스키마(`skeleton.graphqls`) + `HealthGraphqlController`, 프로토콜 에러 매핑(`GraphqlExceptionResolver`). Spring for GraphQL 이 스키마와 컨트롤러를 자동 합성/바인딩하도록 얹는 얇은 계층이다. - 그 위에 **GraphQL API 실행 플랫폼**(`...graphql` 하위 sub-package 군)이 스키마 계약·전송 프로파일·실행 정책·비용 한계·DataLoader·페이지네이션·에러/보안 경계·관측·릴리스 게이트를 소유한다. 플랫폼은 여전히 **feature-agnostic** 이며 정책·계약·검증 기계만 제공한다. - feature-agnostic: `classpath:graphql/**` 스키마와 모든 `@Controller` `@QueryMapping`/ `@MutationMapping` 을 generic 하게 합성한다. **WorkLog 등 구체 기능을 이름으로 알지 않는다.** - classpath opt-in: 현재 `app-bootstrap`/`sample-portfolio` production runtime 은 이 leaf 를 의존하지 않는다. 실제 채택 시 composition root 가 GraphQL leaf 와 인증/인가·CORS 정책, GraphiQL/introspection 운영 설정을 함께 명시해야 한다. ## Allowed - `:application-core`, `:domain-core`, `:shared-contract`. `:application-core` 는 선언만이 아니라 실제 의존이다 — object 인가의 **답하는 계약**(`dev.caskeleton.application.security.ObjectAccessPolicy`)이 거기 살기 때문이다. 이 leaf 가 그 계약을 소유했다면 application 구현체가 인바운드 전송을 컴파일 의존해야 했고, 그건 의존 방향이 뒤집힌다. - `spring-boot-starter-graphql` (Spring Boot BOM 관리 — 버전 명시 없음). - test scope 에 한해 `spring-boot-starter-web`(random-port 전송 테스트용), `spring-boot-starter-security`(HTTP 인증/CORS qualification 용), `io.micrometer:micrometer-core`(실제 `MeterRegistry` 로 metric label cardinality 를 **측정**). - `java-test-fixtures` — 계약 스위트·통합 fixture·in-memory 스텁은 `src/testFixtures/java` 가 소유하고 production jar 에 들어가지 않는다. 모듈 경계 스캐너 (`moduleboundary/GraphQlBuildModel`)는 main 과 testFixtures 를 **함께** 스캔한다: 아티팩트가 갈렸다고 패키지 경계 규칙까지 갈리면, 규칙이 조용히 절반만 남는다. **서버는 이 leaf 가 고르지 않는다.** production 파일 중 `org.springframework.web`· `jakarta.servlet`·`org.springframework.http` 을 import 하는 것은 **하나도 없다**. 예전에는 `spring-boot-starter-web` 을 production `implementation` 으로 두어 모든 adopter 의 runtimeClasspath 에 Tomcat 을 올리면서, 동시에 같은 artifact 가 `REACTIVE_WEBFLUX` 실행 프로파일을 표방했다 — leaf 와 함께 servlet 컨테이너가 따라오므로 결코 성립할 수 없는 조합이었다. 이제 서버 선택은 composition root 의 결정이고, `gradle.lockfile` 이 이를 고정한다 (`spring-boot-starter-web`·`spring-webmvc`·`spring-webflux`·`tomcat-embed-*` 전부 `testCompileClasspath,testRuntimeClasspath` 만). `GraphQlRuntimeTransport` 가 실제 실행 중인 서버를 감지해 `backend.graphql.execution-profile` 과 어긋나면 **부팅을 거부**한다. - `annotationProcessor` 로 `spring-boot-configuration-processor` — `GraphQlPlatformProperties` 가 `@ConfigurationProperties` 이므로 레포 전역 `verifyConfigurationPropertiesProcessor` 패리티 게이트가 이 선언을 요구한다. ## Forbidden - outbound 어댑터(`:adapter:outbound:*`)에 대한 직접 의존 — 인바운드는 application 아웃바운드 포트를 통해서만 persistence/messaging/cache/http 에 닿는다 (ArchUnit `INBOUND_ADAPTERS_DO_NOT_DEPEND_ON_OUTBOUND_ADAPTERS`, 일반 `..adapter.inbound..` 규칙이 이 모듈을 자동 커버 — per-module 규칙 추가 불필요). - 프로덕션 feature 쿼리/뮤테이션을 스켈레톤에 두는 것 — health 표면만 (web 의 `HealthcheckController` 와 동일 원칙). feature 스키마/컨트롤러/매퍼는 sample 모듈이 소유한다. - 모듈별 `yml` — 설정은 프레임워크 `spring.graphql.*` 로 composition-root `application.yml` 에 산다. ## Error mapping (`Category → ErrorType`) feature 는 `ApiErrorCarrier` 를 구현한 예외(자신의 `ApiErrorCode` 를 실어)를 던지면 `GraphqlExceptionResolver` 가 `GraphQLError`(ErrorType + `extensions{code, category}`)로 매핑한다. 비-`ApiErrorCarrier` 예외는 `null` 반환 → 다른 resolver / Spring 기본 처리. 표는 [README.md](README.md). ## 향후 adopter 의 feature 기여 방법 - **스키마**: adopter feature 가 `src/main/resources/graphql/*.graphqls` 를 두면 `classpath:graphql/**` 병합으로 합칠 수 있다. - **핸들러**: adopter 가 `@Controller` + `@QueryMapping`/`@MutationMapping` 빈을 등록하면 자동 바인딩된다. - **도메인 예외 매핑**: adopter 는 자신의 `DataFetcherExceptionResolver` 를 추가하거나 `ApiErrorCarrier` 를 사용해 안정적 코드로 매핑할 수 있다. 현재 sample 에 feature GraphQL schema/controller/resolver 가 있다고 가정하지 않는다. 이 leaf 는 health 스키마만 소유한다. ## 구현된 플랫폼 범위 — 등급으로 말한다 "구현됐다" 는 네 가지 서로 다른 사실을 한 단어로 덮는다. 그래서 capability 마다 아래 등급을 쓰고, **현재 등급보다 높게 표현하지 않는다.** | 등급 | 의미 | | --- | --- | | `modelled` | 정책·계약 객체가 있고 단위 테스트가 있다. 요청 경로에는 없다. | | `wired` | Spring 실행 경로에 연결돼 있고, 실제 endpoint 테스트가 그 사실을 증명한다. | | `integration-verified` | 실제 외부 시스템(datastore/broker) 과의 통합 증거가 있다. | | `production-verified` | 실부하·장애 시나리오 증거가 있다. | | Capability | 등급 | 증거 | | --- | --- | --- | | 실행 파이프라인 / 인가 / cost 예산 | `wired` | `runtime/GraphQlPlatformExecutionPathTest` (random-port, 거부 시 resolver 호출 0회) | | depth/complexity 제한 (`cost/`) | `wired` | 같은 테스트의 depth/alias/complexity 케이스 | | preparsed document cache (`execution/`) | `wired` | `GraphQlPreparsedDocumentAdapter` + 같은 테스트의 캐시 hit 케이스 | | 커스텀 scalar (`scalar/`) | `wired` | 같은 테스트의 scalar coercion 케이스 | | 요청 크기/Accept 협상 (`http/`) | `wired` | `GraphQlRequestBoundsTest`, `GraphQlAcceptNegotiationTest` | | 관측 tag cardinality (`observation/`) | `wired` | `runtime/GraphQlRequestObservationConventionAdapter` 가 Spring 의 `ExecutionRequestObservationConvention` 을 구현해 Boot 의 `GraphQlObservationAutoConfiguration` 이 이 컨벤션을 가져간다. `autoconfigure/GraphQlObservationWiringTest`(프레임워크가 실제로 해석), `runtime/GraphQlRequestObservationConventionAdapterTest`(실제 `MeterRegistry` 에 임의 이름 10,000개 → series 1개) | | object 인가 (`security/`) | `modelled` | 답하는 계약은 중립 `dev.caskeleton.application.security.ObjectAccessPolicy` 가 소유하고 이 leaf 는 `ApplicationObjectAuthorization` 매핑만 가진다. 실행 경로에 연결하는 configuration 은 없다 | | DataLoader/batching (`dataloader/`) | `wired` | `runtime/GraphQlBatchLoaderRegistrationTest` (실제 graphql-java 실행 + Spring `BatchLoaderRegistry`, 50 parent → 3 downstream 호출), `dataloader/GraphQlBatchContractTest` | | cursor 서명 (`pagination/`) | `modelled` | `HmacGraphQlCursorCodec`·`GraphQlCursorKeyRing` 단위 테스트만. **auto-configuration 이 둘 중 무엇도 생성하지 않는다** — `autoconfigure/GraphQlPolicyRequestPathTest` 가 그 사실을 고정 | | mutation 멱등성 (`mutation/`) | `modelled` | `GraphQlMutationIdempotencyInterceptor` 를 참조하는 configuration 이 없다. 같은 테스트가 고정 | | persisted operation (`advanced/persisted/`) | `modelled` | 중립 `OperationalRecordStorePort` 기반 레지스트리 + 방향성 테스트. durable 구현체는 미제공 | | subscription / WebSocket / SSE / RSocket | `modelled` | 정책·상태기계 단위 테스트만. Spring transport handler 는 없다(그래서 타입 이름도 `*Admission` 이다) | | federation / incremental / codegen / compat | `modelled` | 단위 테스트만 | | 실부하·장애 | 미달성 | `graphqlPerformanceTest` 레인이 자리를 예약, 증거 없으면 릴리스 게이트가 거부 | 여전히 미구현인 것: - feature GraphQL schema/resolver — 이 leaf 는 health 표면만 소유한다(변경 없음). - 실부하 성능/장애 시나리오 증거 — `release/GraphQlPerformanceScenario`, `GraphQlFaultScenario` 는 시나리오 카탈로그를 정의하고 `GraphQlReleaseGate` 는 그 증거가 없으면 릴리스를 **거부**한다. 증거 자체는 실제 부하 인프라를 요구하므로 이 leaf 밖에서 생성한다(`graphqlPerformanceTest` 레인이 그 자리를 예약해 둔다). - 실제 datastore 통합 증거 — `testkit/GraphQlJpaIntegrationFixture` / `GraphQlMongoIntegrationFixture` 가 계약을 정의하고 `GraphQlStorageIntegrationEvidence` 가 증거를 요구한다. 실 datastore 기동은 persistence leaf 의 책임 범위다. 이 testkit 은 **production jar 에 없다** — `src/testFixtures/java` 에 살고 `verifyGraphQlProductionJar` 가 그 사실을 jar 내용으로 확인한다. - **cursor 서명이 요청 경로에 없다 (GQL-INT-003).** `backend.graphql.cursor.key-ids` 를 읽는 곳은 둘 뿐이다: 프로덕션 기동을 거부하는 `GraphQlPlatformStartupValidator` 와 그 값을 돌려주는 `GraphQlPlatformActuatorEndpoint`. **커서에 서명하는 코드는 아무것도 읽지 않는다.** 즉 프로덕션은 키 식별자를 요구하고, 운영자가 넣고, 엔드포인트가 "설정됨"이라고 확인해 주는데, 커서는 validator 메시지가 막는다고 말한 그대로 client-editable 로 남는다. 결함은 "미완성"이 아니라 **startup validator 가 하나를 완성된 것처럼 보이게 만든다**는 것이다. 닫으려면 배선이 아니라 설계 결정이 필요하다 — `GraphQlCursorKeyRing.of` 는 `Map` 를 받고 설정 계약은 "키 자체는 설정에 나타나지 않는다"이므로, **키 재료가 어디서 오는지**를 먼저 정해야 한다. - persisted operation 의 durable 저장 구현체 — 이 leaf 는 중립 계약 `dev.caskeleton.shared.opstore.OperationalRecordStorePort` 에만 의존하고 key/value 매핑만 소유한다. Postgres/Redis 구현체는 **그 중립 계약을** 구현하며, 이 leaf 의 타입을 구현하지 않는다(그랬다면 인프라 → 인바운드 전송으로 의존이 뒤집힌다). - Advanced capability 는 전부 **기본 비활성**이다(`advanced/bootstrap/GraphQlAdvancedFeatureFlags`). EXPERIMENTAL 등급(RSocket, incremental delivery, HTTP GET draft)은 명시적 승인 없이는 `GraphQlAdvancedModuleGuard` 가 production 활성화를 거부한다. ## Test ```bash cd src ./gradlew :adapter:inbound:graphql:test --console=plain ./gradlew :adapter:inbound:graphql:test \ --tests dev.caskeleton.adapter.inbound.graphql.GraphqlHttpBoundaryQualificationTest \ --console=plain ``` 플랫폼 테스트 레인(`gradle/graphql-platform-conventions.gradle` 등록). 기본 `test` 는 `quarantine`·`graphql-performance` 태그를 제외한다: ```bash ./gradlew :adapter:inbound:graphql:graphqlStableTest --console=plain # 605 tests ./gradlew :adapter:inbound:graphql:graphqlContractTest --console=plain # 9 tests ./gradlew :adapter:inbound:graphql:graphqlAdvancedTest --console=plain # 152 tests ./gradlew :adapter:inbound:graphql:graphqlPerformanceTest --console=plain # 실부하 인프라 필요 ``` `graphqlPerformanceTest` 는 `@Tag("graphql-performance")` 가 하나도 없으면 **실패한다** — 이는 버그가 아니라 "성능 증거 없음"을 통과로 위장하지 않기 위한 fail-closed 설계다. 위 숫자는 `build/test-results//*.xml` 의 실제 실행 결과다(기본 `test` 757, transport qualification 8). 문서에 옮겨 적은 숫자는 반드시 마지막 green 실행에서 다시 읽어 갱신한다 — 컴파일이 깨진 채로 남은 과거 숫자는 통과 증거가 아니라 통과했다는 인상일 뿐이다. ## 아티팩트 게이트 ```bash ./gradlew :adapter:inbound:graphql:verifyGraphQlProductionJar --console=plain ./gradlew :adapter:inbound:graphql:verifyGraphQlApiSurface --console=plain ``` - `verifyGraphQlProductionJar` — production jar 에 `testkit`/`InMemory`/`Fixture`/`TestContext` 클래스가 하나라도 있으면 실패한다. 계약 스위트와 in-memory 스텁은 `src/testFixtures/java` 가 소유한다. - `verifyGraphQlApiSurface` — `docs/architecture/graphql-api-surface.txt` 스냅샷과 실제 public 타입 목록이 다르면 실패한다. 단일 jar 안에서 `public` 은 모든 adopter 에게 public 이므로, 표면 증가는 리뷰 결정이지 빌드 부산물이 아니다. 승인 후: `./gradlew :adapter:inbound:graphql:updateGraphQlApiSurface -PapproveGraphQlApiSurfaceChange`.