# adapter-graphql — 설계 결정 참조 인바운드 GraphQL 어댑터 **스켈레톤 머시너리** 모듈. 패키지 루트: `dev.caskeleton.adapter.inbound.graphql`. 허용/금지 의존, 모듈 규칙, 설정 knob, 테스트 명령 같은 **모듈 규칙**은 [CLAUDE.md](CLAUDE.md) 가 SSOT 다. 이 문서는 코드 주석에서 덜어낸 **설계 결정의 근거**를 모아둔 참조용 기록이다. --- ## 왜 스켈레톤에 health 스키마 + 컨트롤러만 두는가 Spring for GraphQL 은 schema-first 다. 빈 스키마로는 부팅이 실패하므로 스켈레톤은 `src/main/resources/graphql/skeleton.graphqls` 에 최소 스키마(`type Query { _health: String! }`)를 싣고, `HealthGraphqlController` 가 그 필드를 상태 토큰(`UP`)으로 resolve 한다. web 어댑터의 `HealthcheckController` 와 동일 원칙 — 스켈레톤은 **RPC/쿼리 0개**의 feature 로도 health 표면만으로 부팅한다. 프로덕션 feature 쿼리/뮤테이션을 스켈레톤에 두지 않는다. ## 기능(feature)은 어떻게 기여하는가 — machinery/feature 분리 스켈레톤은 **WorkLog 를 이름으로 알지 못한다.** 향후 composition root 가 이 모듈을 classpath 에 명시적으로 채택하고 feature 를 추가하면 Spring for GraphQL 이 다음 두 축으로 합성할 수 있다: - **스키마**: `classpath:graphql/**/*.graphqls` 를 전부 병합한다. sample 모듈의 향후 `worklog.graphqls` 같은 feature 스키마는 스켈레톤의 `skeleton.graphqls` 와 합쳐진다. - **resolver(핸들러)**: 컨텍스트의 모든 `@Controller` 의 `@QueryMapping`/`@MutationMapping` 메서드를 바인딩한다. 향후 feature 의 GraphQL controller 는 스켈레톤을 수정하지 않고 등록할 수 있다. 현재 `app-bootstrap` 과 `sample-portfolio` 의 production runtime 은 이 leaf 를 의존하지 않는다. 즉 이 모듈은 **classpath opt-in** 이며, 현재 sample 에 feature GraphQL 스키마/controller 가 있다는 뜻이 아니다. leaf 자체는 최소 health 스키마로 독립 기동할 수 있다. ## 에러 매핑 — web `GlobalExceptionHandler` / gRPC 인터셉터의 GraphQL 형제 `GraphqlExceptionResolver` 는 `DataFetcherExceptionResolverAdapter` 를 확장해, 데이터 페처가 동기적으로 던진 예외 중 안정적 `ApiErrorCode` 를 실은 것(전송-중립 hook `ApiErrorCarrier` 구현)을 `GraphQLError` 로 변환한다. 데이터 페처는 web 컨트롤러처럼 "그냥 던지기만" 하고, 이 resolver 가 와이어 계약을 단일 소유한다. - **`ErrorType` 분류**: `errorCode().category()` 를 GraphQL `ErrorType` 으로 매핑한다(아래 표). 정확한 `code`/`category` 는 error `extensions{code, category}` 로 실어 클라이언트가 switch 하게 한다(gRPC 가 status trailer 에 싣는 것과 동형). - **`ApiErrorCode` 추출**: shared-contract 의 `PersistenceFailureException` / `DependencyFailureException`(outbound 어댑터에서 올라온 분류된 실패)과 feature 예외(자신의 도메인 `ApiErrorCode` 를 실은 것)를 단일 `instanceof ApiErrorCarrier` 분기로 인식한다. - **leak 방지**: 인식된 코드는 안정적 `code` 문자열만 error message/extensions 로 노출하고, raw 예외 메시지(SQLState/업스트림 세부를 담을 수 있음)는 절대 클라이언트에 내보내지 않는다. - **비-`ApiErrorCarrier`** 예외는 `null` 을 반환해 다른 `DataFetcherExceptionResolver` 빈(예: sample 의 도메인 예외 resolver)과 Spring 기본 처리로 넘긴다. `Category → ErrorType` 표(설계 스펙 Error Mapping SSOT): | `Category` | GraphQL `ErrorType` | |---|---| | VALIDATION | BAD_REQUEST | | AUTH | UNAUTHORIZED | | AUTHZ | FORBIDDEN | | NOT_FOUND | NOT_FOUND | | CONFLICT | BAD_REQUEST | | RATE_LIMIT | BAD_REQUEST | | TRANSIENT_DEPENDENCY | INTERNAL_ERROR | | PERMANENT_DEPENDENCY | INTERNAL_ERROR | | DATA_INTEGRITY | INTERNAL_ERROR | | INTERNAL | INTERNAL_ERROR | ## 의존성 버전 — strict locking gRPC 와 달리 spring-graphql / graphql-java 는 Spring Boot BOM 이 관리한다. 그래서 이 모듈은 버전 명시도, 모듈 스코프 platform import 도 필요 없다 — `build.gradle` 은 BOM-managed 좌표만 선언하고, per-module `gradle.lockfile` 이 strict locking 으로 정확한 버전을 고정한다. ## 설정 — 프레임워크 `spring.graphql.*` + 플랫폼 `backend.graphql.*` 전송 계층 설정(path, graphiql, introspection, schema location)은 프레임워크 `spring.graphql.*` 가 소유한다. composition-root `application.yml` 에서 설정하며 모듈별 `yml` 은 없다. 플랫폼 정책은 `spring.graphql.*` 로 표현할 수 없다 — 실행 프로파일, cost/page 한계, preparsed 캐시 경계, cursor 키 링, 관측 label 로 허용할 operation 이름은 전부 이 leaf 의 결정이다. 그래서 `GraphQlPlatformProperties` 가 **`backend.graphql`** prefix 로 `@ConfigurationProperties` 를 바인딩한다(`spring.graphql.platform.*` 이 아니다 — 그 prefix 는 존재한 적이 없다). ```yaml backend: graphql: production: true environment: PRODUCTION_PUBLIC execution-profile: BLOCKING_MVC validation-policy-version: v1 # preparsed 캐시 키의 일부 console: graphiql-enabled: false introspection-enabled: false limits: maximum-page-size: 100 maximum-complexity: 10000 preparsed-cache-entries: 1000 preparsed-cache-weight: 10000000 preparsed-cache-expire-after-access: 30m cursor: key-ids: [cursor-key-1] # 키 자체는 설정에 오지 않는다 observed-operation-names: [] # 비우면 모든 operation 이름이 `other` 로 접힌다 ``` `observed-operation-names` 가 비어 있는 것이 기본값이자 안전한 값이다. operation 이름은 문법만 검증될 뿐 개수가 제한되지 않으므로, 원본을 그대로 metric label 로 쓰면 정상 클라이언트 하나가 metrics 백엔드를 무너뜨릴 수 있다(`observation/GraphQlOperationNameCardinality`). `GraphqlHttpBoundaryQualificationTest` 는 실제 random-port MVC HTTP 서버 위에서 test-only SecurityFilterChain 과 CORS allowlist 를 조합해 인증, origin, GraphiQL 비활성화, introspection 비활성화, 오류 redaction 을 검증한다. 이 테스트 구성은 production 정책 bean 이 아니다. 실제 composition root 는 이 leaf 를 채택할 때 인증/인가 및 CORS 정책을 함께 제공하고 `spring.graphql.graphiql.enabled=false`, `spring.graphql.schema.introspection.enabled=false` 를 운영 설정으로 명시해야 한다. --- # GraphQL API 실행 플랫폼 — 설계 결정의 근거 위 스켈레톤 머시너리 위에, GraphQL 설계 문서(Stable 48 Task / Advanced 19 Task)의 내용을 이 leaf 의 sub-package 로 구현했다. 아래는 그 과정에서 내린 **되돌리기 어려운 결정**과 근거다. ## 설계서의 "모듈"을 Gradle 모듈로 만들지 않은 이유 설계서는 Stable 16 + Advanced 12 = 28개 Gradle 모듈을 전제한다. 구현 시점의 레지스트리 (`src/config/architecture/modules.json`)는 19개 leaf 였고, 28개를 추가하는 것은 레지스트리· `settings.gradle` fail-closed 검증·의존 게이트를 동시에 건드리는 변경이다. GraphQL 표면은 하나의 인바운드 전송 경계이므로 그 **내부** 분할을 레포 전역 SSOT 까지 밀어올리지 않기로 하고, **모듈 = bounded sub-package** 로 매핑했다(선례: httpclient leaf 가 366개 java 파일을 같은 방식으로 담는다). **정직하게 기록해 둘 반례:** 이후 main 에 통합된 자매 플랫폼 messaging 은 정반대로 **24개 leaf 를 레지스트리에 등록**했다(현재 총 43개 leaf). 즉 레지스트리는 닫혀 있지 않았고, "확장하면 게이트가 깨진다"는 전제는 과했다. 두 플랫폼이 서로 다른 패턴을 쓰고 있으므로 **어느 쪽으로 통일할지는 미결 아키텍처 결정**이다. graphql 쪽은 모듈 레코드 (`GraphQlStableModule`/`GraphQlAdvancedModule`)가 그대로 leaf 명세로 승격될 수 있는 형태라 분해 비용은 낮게 유지했다. 어느 패턴이든 "패키지는 경계가 아니다"라는 약점은 기계 검증으로 메웠다 — `moduleboundary/GraphQlStableModule`·`GraphQlAdvancedModule` 이 모듈 정체성과 허용 edge 를 값으로 선언하고, `GraphQlModuleBoundaryTest` 가 **실제 소스 트리를 스캔**해 Stable→Advanced import, CORE 모듈의 프레임워크 import, 선언되지 않은 edge, 미등록 패키지를 실패시킨다. Gradle 이 해주던 일을 테스트가 한다. 스캔은 컴파일된 클래스가 아니라 **소스 텍스트**를 읽는다. 경계가 금지하는 import 는 상수 인라이닝이나 미보존 시그니처로 바이트코드에서 지워지는 경우가 많아서, 바이트코드 스캔은 리뷰어가 읽는 소스가 여전히 경계를 넘는데도 clean 이라고 보고한다. 그리고 스캐너는 **파일을 하나도 못 찾으면 통과가 아니라 실패한다** — 0개 스캔으로 green 이 되는 것이 이 모델이 막으려는 실패 그 자체다. ## ArchUnit/JPA 없이 아키텍처 규칙을 강제한 방법 `architecture/` 의 규칙(리졸버 경계, 전송 타입, `@Transactional` 금지, Entity/Document 반환 금지)은 **리플렉션 + 단순명(simple name) 매칭**으로 구현했다. 인바운드 어댑터가 자신이 금지하는 대상 (`jakarta.persistence`, `spring-tx`)에 의존해야 그걸 검사할 수 있다면 본말전도이기 때문이다. `GraphQlControllerTransactionRule` 이 애노테이션 타입이 아니라 단순명 `Transactional` 을 보는 것은 이 때문이며, 의도된 트레이드오프다. `GraphQlResolverBoundaryRules` 는 스캔 대상 패키지가 비어 있으면 **실패한다** — 검사할 게 없어서 통과하는 조용한 무력화를 막는다. ## 부분 실패는 200, 요청 실패는 4xx `http/GraphQlHttpStatusMapper.V1` 은 검증 통과 후 발생한 필드 에러를 HTTP 200 + partial data 로 매핑한다. GraphQL over HTTP 초안의 status 294 는 **채택하지 않았다** (`GraphQlHttpProfile.usesDraftPartialResponseStatus()` 가 `false` 로 못 박고, 초안 프로파일은 `advanced/get/GraphQlHttpDraftCompatibilityReport` 가 "의도적 미채택"으로 기록한다). 초안 상태 코드를 프로덕션 와이어 계약에 넣으면 초안이 바뀔 때 클라이언트가 깨진다. 이 때문에 `Map.copyOf` 를 응답 데이터 경로에 쓸 수 없다 — partial data 는 **정당하게 null 값을 가진다**. `GraphQlExecutionOutcome`·`GraphQlHttpResponse`·`GraphQlContractResponse` 는 null 을 허용하는 `LinkedHashMap` 복사를 쓴다. ## 요청 단위 DB 트랜잭션을 열지 않는다 GraphQL 한 요청은 여러 root field 를 담을 수 있고, 각 root 는 자기 use case 를 호출한다. 요청 전체를 하나의 트랜잭션으로 묶으면 커넥션을 요청 수명만큼 점유하고 부분 실패 의미론이 무너진다. `mutation/GraphQlMutationContractValidator.rejectRequestWideTransaction` 이 이를 계약으로 강제하고, `GraphQlControllerTransactionRule` 이 컨트롤러의 `@Transactional` 을 막는다. ## 커서는 HMAC 서명된 버전 있는 keyset `pagination/HmacGraphQlCursorCodec` 은 offset 이 아니라 keyset payload 를 담고, 버전과 서명을 붙인다. 비교는 `MessageDigest.isEqual` 로 상수 시간이다. 클라이언트가 커서를 조작해 다른 tenant/정렬 축으로 넘어가는 것을 막기 위함이며, 키 회전은 `GraphQlCursorKeyRing` 이 담당한다. ## DataLoader 는 요청 스코프, 캐시 키는 actor·tenant 지문으로 격리 `dataloader/GraphQlDataLoaderRequestRegistry` 는 요청마다 새 인스턴스를 만든다. 전역 캐시는 tenant 간 데이터 누출 경로가 된다. `security/GraphQlBatchContext.cacheScope()` 는 raw tenant id 가 아니라 **sha256 지문**을 캐시 스코프에 쓴다(로그·메트릭에 tenant 원문이 새지 않도록). ## 관측은 저-카디널리티 강제 `observation/GraphQlMetricCardinalityPolicy` 는 operation name·필드 좌표처럼 유한 집합만 태그로 허용하고, 변수·인자·actor id 는 `GraphQlSensitiveAttributeFilter` 가 걸러낸다. GraphQL 은 카디널리티 폭발이 쉬운 전송이라 이 게이트가 없으면 메트릭 백엔드가 먼저 죽는다. ## Advanced 는 전부 기본 비활성 + 등급제 `advanced/bootstrap/GraphQlAdvancedFeatureFlags` 는 기본 전부 off 다. EXPERIMENTAL 등급 (RSocket, incremental delivery, HTTP GET draft)은 명시적 승인 플래그 없이는 `GraphQlAdvancedModuleGuard` 가 production 활성화를 거부한다. 등급 승격은 `advanced/release/GraphQlAdvancedPromotionDecision` 이 ADR 번호·승인자·미해결 증거를 요구한다 — "조용히 켜짐"을 구조적으로 불가능하게 만든다. ## 릴리스 게이트는 증거가 없으면 거부한다 `release/GraphQlReleaseGate` 와 `advanced/release/GraphQlAdvancedReleaseGate` 는 성능·장애·보안· 호환성 증거가 없으면 **통과시키지 않는다**. Advanced 는 Stable 기준선 없이는 릴리스 자체가 불가능하다. 같은 이유로 `graphqlPerformanceTest` 레인은 성능 태그가 하나도 없으면 실패한다 — 증거 부재를 통과로 위장하지 않기 위한 fail-closed 설계다. ## 설계서의 내부 불일치 처리 Stable 16번째 모듈이 산문에서는 `graphql-testkit-security`, Task 1 파일 목록과 설계 §5 에서는 `graphql-testkit-integration` 으로 서로 다르게 적혀 있다. **파일 목록 쪽(testkit-integration)을 채택**하고, 보안 계약 표면은 `testkit/GraphQlSecurityContractSuite` 로 제공했다. 둘 다 실제로 존재하므로 어느 쪽 독법이든 표면은 비지 않는다. ## 아직 구현하지 않은 범위 feature GraphQL schema/resolver 는 여전히 이 leaf 밖이다(스켈레톤은 health 표면만 소유). 실부하 성능 증거와 실 datastore 통합 증거도 이 leaf 밖에서 생성해야 한다 — 다만 그 **부재가 릴리스를 막도록** 게이트가 이미 서 있다.