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-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()를 GraphQLErrorType으로 매핑한다(아래 표). 정확한code/category는 errorextensions{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 는 존재한 적이 없다).
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 밖에서 생성해야 한다 — 다만 그 부재가 릴리스를 막도록 게이트가 이미 서 있다.