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.advancedimport 금지, (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-portfolioproduction 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 에 닿는다 (ArchUnitINBOUND_ADAPTERS_DO_NOT_DEPEND_ON_OUTBOUND_ADAPTERS, 일반..adapter.inbound..규칙이 이 모듈을 자동 커버 — per-module 규칙 추가 불필요). - 프로덕션 feature 쿼리/뮤테이션을 스켈레톤에 두는 것 — health 표면만 (web 의
HealthcheckController와 동일 원칙). feature 스키마/컨트롤러/매퍼는 sample 모듈이 소유한다. - 모듈별
yml— 설정은 프레임워크spring.graphql.*로 composition-rootapplication.yml에 산다.
Error mapping (Category → ErrorType)
feature 는 ApiErrorCarrier 를 구현한 예외(자신의 ApiErrorCode 를 실어)를 던지면
GraphqlExceptionResolver 가 GraphQLError(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.of는Map<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 등록). 기본 test 는
quarantine·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가 소유한다.verifyGraphQlApiSurface—docs/architecture/graphql-api-surface.txt스냅샷과 실제 public 타입 목록이 다르면 실패한다. 단일 jar 안에서public은 모든 adopter 에게 public 이므로, 표면 증가는 리뷰 결정이지 빌드 부산물이 아니다. 승인 후:./gradlew :adapter:inbound:graphql:updateGraphQlApiSurface -PapproveGraphQlApiSurfaceChange.