Files
clean-architecture-backend-…/src/adapter/inbound/graphql/CLAUDE.md
T

17 KiB

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 가 모아둔다 (이 문서는 모듈 규칙 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 과 어긋나면 부팅을 거부한다.

  • annotationProcessorspring-boot-configuration-processorGraphQlPlatformProperties@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 를 실어)를 던지면 GraphqlExceptionResolverGraphQLError(ErrorType + extensions{code, category})로 매핑한다. 비-ApiErrorCarrier 예외는 null 반환 → 다른 resolver / Spring 기본 처리. 표는 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.ofMap<String, byte[]> 를 받고 설정 계약은 "키 자체는 설정에 나타나지 않는다"이므로, 키 재료가 어디서 오는지를 먼저 정해야 한다.
  • 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

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 등록). 기본 testquarantine·graphql-performance 태그를 제외한다:

./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/<lane>/*.xml 의 실제 실행 결과다(기본 test 757, transport qualification 8). 문서에 옮겨 적은 숫자는 반드시 마지막 green 실행에서 다시 읽어 갱신한다 — 컴파일이 깨진 채로 남은 과거 숫자는 통과 증거가 아니라 통과했다는 인상일 뿐이다.

아티팩트 게이트

./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 가 소유한다.
  • verifyGraphQlApiSurfacedocs/architecture/graphql-api-surface.txt 스냅샷과 실제 public 타입 목록이 다르면 실패한다. 단일 jar 안에서 public 은 모든 adopter 에게 public 이므로, 표면 증가는 리뷰 결정이지 빌드 부산물이 아니다. 승인 후: ./gradlew :adapter:inbound:graphql:updateGraphQlApiSurface -PapproveGraphQlApiSurfaceChange.