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

14 KiB

adapter-graphql — 설계 결정 참조

인바운드 GraphQL 어댑터 스켈레톤 머시너리 모듈. 패키지 루트: dev.caskeleton.adapter.inbound.graphql.

허용/금지 의존, 모듈 규칙, 설정 knob, 테스트 명령 같은 모듈 규칙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-bootstrapsample-portfolio 의 production runtime 은 이 leaf 를 의존하지 않는다. 즉 이 모듈은 classpath opt-in 이며, 현재 sample 에 feature GraphQL 스키마/controller 가 있다는 뜻이 아니다. leaf 자체는 최소 health 스키마로 독립 기동할 수 있다.

에러 매핑 — web GlobalExceptionHandler / gRPC 인터셉터의 GraphQL 형제

GraphqlExceptionResolverDataFetcherExceptionResolverAdapter 를 확장해, 데이터 페처가 동기적으로 던진 예외 중 안정적 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 의 결정이다. 그래서 GraphQlPlatformPropertiesbackend.graphql prefix 로 @ConfigurationProperties 를 바인딩한다(spring.graphql.platform.* 이 아니다 — 그 prefix 는 존재한 적이 없다).

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/GraphQlReleaseGateadvanced/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 밖에서 생성해야 한다 — 다만 그 부재가 릴리스를 막도록 게이트가 이미 서 있다.