- CI 단계 분리 계획 추가 (docs/superpowers/plans/2026-09-16-ci-stage-separation.md). 빌드·CI 레이어 전수 리뷰 133건의 결론과 Track A/B/C 작업 순서를 담는다. - public-path 보안 기준선을 실제 배포 기본값(/v1/healthcheck)으로 재생성. 이전 값은 gitignore 된 src/.env 에서 유래해 재현이 불가능했다. - 진행 중이던 ADR·리뷰·테스트 전략 문서 반영, 대체된 grpc 계획 문서 제거. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
192 KiB
GraphQL 인바운드 모듈 완전 해부
성격: 정책 문서가 아니라 읽기 기록이다.
adapter:inbound:graphqlleaf와 그 주변 (app-bootstrap 배선, 테스트 레인, 아티팩트 게이트, 스모크 스크립트, ADR, 코드 리뷰 문서)에 실제로 구현되어 있는 것을 코드 기준으로 하나씩 확인하면서, "무엇이 있는가"와 "왜 그렇게 되어 있는가"를 정리한 글이다.SSOT는 여전히
src/config/architecture/modules.json, 모듈 CLAUDE.md, README.md, ADR-GQL-001이다. 이 문서와 그것들이 어긋나면 그쪽이 맞다 — 단, §28에 적은 것들은 예외다. 거기 적힌 것은 문서 쪽이 코드와 어긋나 있는 항목이고, 코드가 맞다.마지막 장(§28)에 읽으면서 발견한 손볼 거리를 우선순위대로 모아 두었다.
커버리지 범위 (읽은 것과 안 읽은 것)
이 leaf는 production Java 408개 파일이고, 그중 전부를 최소한 클래스 javadoc 수준까지 읽었다. 정독(본문 전체를 읽음)한 범위와 표면 읽기(클래스 javadoc + 시그니처 + 참조 스캔)로 끝낸 범위는 아래와 같다.
영역 파일 이 문서에서 moduleboundary,autoconfigure,runtime,execution,http,cost,security116 전문 정독 (§2–§11) api,policy,context,error,dataloader,pagination,mutation,fetch,schema,scalar,compat,observation,architecture,release157 핵심 타입 정독 + 나머지 javadoc/시그니처 (§5–§21) advanced/**17개 패키지135 전 파일 javadoc + 핵심 타입 본문 (§22–§23) test / testFixtures 117 레인 구조, 계약 스위트, 경계 fixture (§24) 정독하지 않은 것을 정독한 것처럼 쓰지 않았다. advanced 쪽은 대부분이 정책 값 객체와 상태 기계여서 javadoc이 곧 설계 결정문이고 — 이 레포는 javadoc에 "왜"를 쓰는 규율이 유난히 강하다 — 본문은 그 결정을 검증(
Objects.requireNonNull, 범위 검사, CAS)하는 코드다. 그래서 §23은 "무엇을 결정했는가"까지 쓰고, 알고리즘 세부는 정독한 것만 인용했다.
0. 숫자로 먼저 보는 지도
무작정 파일을 열기 전에 규모부터 쟀다. 규모를 모르면 "다 읽었다"는 말을 할 수 없다.
| 항목 | 값 |
|---|---|
| production Java 파일 | 408 |
| production Java LOC | 26,303 |
| production Java bytes | 1,000,564 (≈ 977 KB) |
| public top-level 타입 (스냅샷) | 408 |
| 최상위 production 패키지 | 22 (root 포함) |
advanced/ 하위 패키지 |
17 |
| 프레임워크(Spring/graphql-java/Reactor/Micrometer/servlet) import 하는 production 파일 | 33 (8.1%) |
| test Java 파일 / LOC | 101 / 13,671 |
@Test + @ParameterizedTest |
762 |
| testFixtures 파일 / LOC | 16 / 1,074 |
| main 리소스 | graphql/skeleton.graphqls, META-INF/spring.factories, AutoConfiguration.imports |
| 스키마가 선언하는 필드 | 1개 (Query._health) |
마지막 줄이 이 모듈 전체를 이해하는 열쇠다. 977KB의 코드가 필드 한 개짜리 스키마를 지킨다. 이건 낭비가 아니라 이 leaf의 정체다 — 여기 있는 건 API가 아니라 API를 실행하는 플랫폼이고, API는 이 leaf를 채택하는 쪽이 가져온다.
패키지별 규모:
| 패키지 | 파일 | bytes | 역할 |
|---|---|---|---|
runtime |
19+1 | 83,085 | 프레임워크 seam — 정책이 실제 요청을 만나는 곳 |
autoconfigure |
13 | 77,897 | 마스터 스위치, 설정 계약, 기동 검증 |
compat |
11 | 62,476 | 스키마 호환성 분류(37종) |
cost |
21 | 56,114 | 파서 한계 → 문서 형태 → 복잡도 → 런타임 예산 |
http |
19 | 48,894 | GraphQL over HTTP 프로파일 |
execution |
22 | 44,925 | 파이프라인 단계, preparsed 캐시, 데드라인 |
schema |
19 | 42,627 | SDL 조립, @oneOf, 스칼라 매니페스트 |
architecture |
9 | 38,128 | resolver 경계 검사 |
pagination |
17 | 37,370 | 서명 keyset 커서 |
security |
15 | 32,931 | 인증/인가/테넌트 |
scalar |
7 | 32,376 | 커스텀 스칼라 6종 |
dataloader |
17 | 31,125 | 배치/N+1 |
mutation |
14 | 25,748 | 멱등성, 비즈니스 결과 |
observation |
9 | 22,509 | 태그 카디널리티 |
error |
10 | 22,091 | 에러 계약 |
moduleboundary |
4 | 21,118 | 모듈 정체성·purity·edge |
context |
6 | 20,501 | 요청 컨텍스트, 지문 |
release |
9 | 18,224 | 릴리스 게이트 |
policy |
9 | 17,227 | 클라이언트 프로파일, 오퍼레이션 카탈로그 |
fetch |
10 | 15,590 | fetch 프로파일 |
api |
5 | 8,059 | 경계 있는 식별자 |
advanced/* |
135 | 232,984 | 17개 옵트인 capability |
1. 이 leaf의 정체 — "플랫폼을 leaf 하나에 담는다"는 선택
1.1 레지스트리 레코드
src/config/architecture/modules.json이 이 leaf에 대해 말하는 전부는 이것이다:
{
"id": "adapter-inbound-graphql",
"gradle_path": ":adapter:inbound:graphql",
"source_path": "src/adapter/inbound/graphql",
"allowed_dependencies": ["domain-core", "application-core", "shared-contract"],
"runtime_memberships": ["app-bootstrap"]
}
네 줄이 네 가지를 못 박는다.
- outbound 어댑터에 대한 의존이 없다. 인바운드는 application 아웃바운드 포트를 통해서만
persistence/messaging/cache/http에 닿는다. Gradle 게이트
(
verifyCleanArchitectureDependencies)와 ArchUnitINBOUND_ADAPTERS_DO_NOT_DEPEND_ON_OUTBOUND_ADAPTERS가 둘 다 지킨다. application-core가 선언만이 아니라 실제 의존이다. object 인가의 답하는 계약dev.caskeleton.application.security.ObjectAccessPolicy가 거기 살기 때문이다. 이 leaf가 그 계약을 소유했다면 application 구현체가 인바운드 전송을 컴파일 의존해야 했다 — 방향이 뒤집힌다. 이게 ADR-GQL-001의 세 결정 중 하나고, §11.4에서 다시 다룬다.shared-contract가 있다. persisted operation의 durable 저장을 위한 중립 계약dev.caskeleton.shared.opstore.OperationalRecordStorePort가 거기 있다 (§23.2).runtime_memberships: ["app-bootstrap"]— 이 leaf는 app-bootstrap 런타임에 들어간다. 이 한 줄이 모듈CLAUDE.md/README.md의 "classpath opt-in, 현재 app-bootstrap은 이 leaf를 의존하지 않는다"는 문장과 정면으로 충돌한다. 코드가 맞다 (§25, §28-P1).
1.2 "28개 모듈 → 22개 패키지" — 이건 미결 아키텍처 결정이다
GraphQL 플랫폼 설계서는 Stable 16개 + Advanced 12개, 총 28개 "모듈"을 말한다. 이 구현은 그것을
레지스트리 leaf가 아니라 이 leaf 안의 bounded sub-package로 매핑했다. 실제로는
moduleboundary가 Stable 23개 + Advanced 17개 = 40개 모듈을 선언하고 있으니, 설계서의
28개보다 더 잘게 쪼개졌다.
모듈 CLAUDE.md가 이 선택을 방어하는 방식이 훌륭하다. **"레지스트리가 닫혀 있어서가 아니다"**라고
먼저 못 박는다. 같은 레포의 자매 플랫폼인 messaging은 정반대 선택을 해서 자기 leaf들을
레지스트리에 개별 등록했다. 즉 이 레포에는 두 패턴이 공존한다:
| 방식 | 경계 강제 | |
|---|---|---|
| messaging | 레지스트리에 leaf 등록 | Gradle 의존 게이트 |
| graphql | 단일 leaf 내 sub-package | 소스 스캔 경계 테스트 (§2) |
| httpclient | 단일 leaf 내 sub-package (366 파일) | 동일 패턴 |
어느 쪽으로 통일할지는 미결이다. 그리고 이 문서가 그걸 그대로 쓸 수 있다는 게 좋은 신호다 — 정직하게 "아직 안 정했다"고 쓰인 문서는 드물다.
1.3 이 leaf가 명시적으로 아닌 것
- feature GraphQL API가 아니다. 스키마는
_health: String!하나뿐이고, 이건 web 어댑터의HealthcheckController와 같은 원칙이다. feature 스키마/컨트롤러/매퍼는 채택하는 쪽이 소유한다. - 서버를 고르지 않는다.
spring-boot-starter-web은 test scope에만 있다. 예전에는 productionimplementation이어서 모든 adopter의 runtimeClasspath에 Tomcat을 올리면서 동시에 같은 artifact가REACTIVE_WEBFLUX프로파일을 표방했다 — 성립할 수 없는 조합이었다. 이제GraphQlRuntimeTransport가 실제 실행 중인 서버를 감지해 설정과 어긋나면 부팅을 거부한다. - 인증을 구현하지 않는다.
GraphQlPrincipalResolver는 인터페이스다. javadoc이 이유를 쓴다: "어느 provider인지, 어느 claim인지, 어느 tenant claim을 믿는지는 composition root의 관심사고, 자기 인증을 들고 오는 인바운드 어댑터는 대부분의 adopter에게 틀리거나, 세상 모든 스킴을 위한 설정 표면을 키워야 한다." - 모듈별
yml이 없다. 설정은 composition root의application.yml에 산다.
2. moduleboundary — 레지스트리 없이 모듈 경계를 기계로 지키기
4개 파일 21KB. 이 leaf에서 제일 먼저 읽어야 하는 패키지다. §1.2의 "sub-package로 나눈다"는 선택이 문서가 아니라 기계로 지켜지는지가 여기서 결정된다.
2.1 패키지 이름이 build가 아니라 moduleboundary인 이유
이 이름은 사고의 흔적이다. 원래 이 모델은 ...graphql.build 패키지에 있었고, src/.gitignore:2의
anchor 없는 build/ 규칙이 Gradle 산출물과 Java 패키지를 구분하지 못해서 경계 모델 전체를
커밋에서 삼켰다. 결과가 지독하다:
- production 코드는 계속 그 타입들을 import 했다.
- 작성자 워킹 트리에서는 컴파일됐다.
- fresh checkout은 7개 오류로 깨졌다.
- 그리고 경계 테스트가 통째로 사라졌는데 레인은 여전히 green이었다.
지금은 두 겹으로 막는다: 레포 전역 verifyNoIgnoredSourcePackages가 "ignore된 소스 패키지" 자체를
막고, graphqlStableTest 레인의 required-class 검사가 "경계 테스트만 조용히 사라지고 레인은
green"인 나머지 절반을 막는다 (§24.2).
이건 이 레포 전체를 관통하는 원칙의 첫 사례다 — 없어진 검사와 통과한 검사를 구분할 수 없으면 그건 검사가 아니다.
2.2 GraphQlStableModule / GraphQlAdvancedModule — 모듈을 값으로 선언한다
enum 상수 하나가 모듈 하나다. 각 상수는 (id, packageSuffix, purity, allowedDependencies...)를
들고, Advanced 쪽은 compact 생성자가 purity를 CORE로 기본값 처리한다.
// Stable
HTTP("http", "http", GraphQlModulePurity.CORE, "api", "context", "execution", "policy"),
SCALAR("scalar", "scalar", GraphQlModulePurity.FRAMEWORK_BOUND, "schema"),
// Advanced — purity 인자가 없으면 CORE
SUBSCRIPTION(
"advanced.subscription", "advanced.subscription",
"advanced.security", "api", "execution", "http", "security"),
purity는 두 등급이고, GraphQlModulePurity의 javadoc이 왜 나누는지 쓴다:
A
COREmodule holds the decision ("this document is too deep", "this cursor is out of scope") as plain Java, so the same rule can be exercised by a unit test, reused from a different transport, or promoted to its own leaf without dragging a servlet container along. AFRAMEWORK_BOUNDmodule is the seam where that decision meets Spring, GraphQL Java or Reactor.
**"자기 leaf로 승격할 수 있게"**가 §1.2의 미결 결정과 이어진다.
| 등급 | 의미 | Stable | Advanced |
|---|---|---|---|
CORE |
JDK만. Spring / graphql-java / Reactor / Micrometer / Jakarta import 금지 | 15 | 16 |
FRAMEWORK_BOUND |
프레임워크 타입을 바인딩해도 되는 seam | 8 | 1 |
Stable의 FRAMEWORK_BOUND 8개: TRANSPORT_ROOT, ARCHITECTURE, AUTOCONFIGURE, COMPAT,
COST, RUNTIME, SCALAR, SCHEMA. Advanced는 CODEGEN 하나뿐이다 — graphql-java로 스키마를
컴파일해서 operation document를 검증해야 하기 때문이다 (§23.11).
HTTP가 CORE인 게 기록으로 남아 있다: "CORE since the custom MVC and WebFlux transport
adapters were removed: what remains [is pure policy]." — 즉 purity 등급이 GQL-004 수정의
결과를 그대로 반영한다.
2.3 GraphQlModuleBoundary — longest-prefix ownership
"이 패키지의 주인은 누구인가"를 답한다. 가장 긴 prefix가 이긴다. 그래서
advanced.subscription은 ADVANCED_* 중 SUBSCRIPTION이 갖고, 루트 패키지는 오직 자기 자신만
소유한다 — 이게 중요하다. 루트가 하위 전부를 소유해 버리면 "등록되지 않은 패키지" 규칙이
영원히 발화하지 않는다.
2.4 다섯 개 규칙, 각각 negative fixture와 함께
GraphQlModuleBoundaryTest가 GraphQlBuildModel(테스트 쪽, 실제 소스 트리 스캐너)로 강제한다:
| # | 규칙 |
|---|---|
| (a) | Stable 패키지가 ...graphql.advanced를 import 금지 |
| (b) | CORE 등급 모듈의 Spring/graphql-java/Reactor/Micrometer/Jakarta import 금지 |
| (c) | 선언되지 않은 cross-module edge 금지 |
| (d) | 미등록 패키지 금지 |
| (e) | 선언만 있고 소스가 없는 모듈 금지 |
각 규칙이 "거부되는 합성 트리(negative fixture)"를 함께 가진다. (e)가 특히 좋다 — 모듈 선언은 남아 있는데 소스가 지워지면 (a)~(d)는 전부 통과한다. 검사할 대상이 없으니까. (e)가 없으면 "모듈을 지웠는데 규칙이 계속 green"인 상태가 된다.
그리고 스캐너는 main과 testFixtures를 함께 스캔한다. CLAUDE.md가 이유를 쓴다: "아티팩트가
갈렸다고 패키지 경계 규칙까지 갈리면, 규칙이 조용히 절반만 남는다."
2.5 이 접근의 한계 — 소스 텍스트 스캔이다
바이트코드 분석이 아니라 소스 텍스트를 읽어서 import 문을 본다. 그래서:
- 잡을 수 있는 것:
import dev.caskeleton...advanced.X; - 못 잡는 것: fully-qualified 이름을 본문에 직접 쓴 경우, 리플렉션, 문자열 클래스 이름.
이건 트레이드오프고, 이 leaf에서는 합리적이다 — 여기 있는 건 대부분 순수 값 객체와 정책이고, 동적 참조를 쓸 이유가 거의 없다. 다만 §28에 후속으로 적어 둔다.
3. 마스터 스위치 — "꺼져 있다"를 구조로 만들기
이 leaf에서 가장 배울 게 많은 부분이다. 3개 파일 + 2개 리소스로 이루어진다.
3.1 문제: Spring GraphQL은 프로젝트 빈 없이도 /graphql을 연다
일반적인 Spring Boot 조건부 어댑터는 이렇게 쓴다:
@AutoConfiguration
@ConditionalOnProperty(prefix = "backend.graphql", name = "enabled", havingValue = "true")
class MyGraphQlConfig { ... }
이걸로는 부족하다. spring-boot-starter-graphql이 자기 auto-configuration을 Boot의 import
메타데이터로 기여하기 때문에, 평범한 @EnableAutoConfiguration 애플리케이션은 classpath만으로
/graphql을 발행한다. 프로젝트 빈은 하나도 없이. 즉 "우리 빈이 없다"와 "엔드포인트가 없다"는
전혀 다른 이야기다.
이 leaf의 CLAUDE.md가 이 상황을 정확히 요약한다: 예전에는 이 문제 때문에 leaf를 아예 shipped jar에서 빼 두었고, 그래서 "꺼짐"과 "빌드 안 됨"이 같은 뜻이 되어 있었다.
3.2 해법: 4겹
(1) META-INF/spring/...AutoConfiguration.imports — 진입점은 딱 하나.
dev.caskeleton.adapter.inbound.graphql.autoconfigure.GraphQlRootAutoConfiguration
(2) GraphQlRootAutoConfiguration — 마스터 조건을 가진 유일한 클래스.
@AutoConfiguration
@ConditionalOnProperty(prefix = "backend.graphql", name = "enabled", havingValue = "true")
@EnableConfigurationProperties(GraphQlPlatformSettings.class)
@Import({
GraphQlPlatformAutoConfiguration.class,
dev.caskeleton.adapter.inbound.graphql.HealthGraphqlController.class
})
public class GraphQlRootAutoConfiguration {}
두 번째 import에 붙은 주석이 GQL-INT-003 결함의 기록이다 — §25.3에서 다룬다.
(3) META-INF/spring.factories + GraphQlOffAutoConfigurationImportFilter — 프레임워크의
auto-configuration 후보 자체를 걷어낸다. 10개를 명시적으로 막는다:
"org.springframework.boot.graphql.autoconfigure.GraphQlAutoConfiguration",
"...observation.GraphQlObservationAutoConfiguration",
"...data.GraphQlQuerydslAutoConfiguration",
"...data.GraphQlReactiveQuerydslAutoConfiguration",
"...rsocket.GraphQlRSocketAutoConfiguration",
"...rsocket.RSocketGraphQlClientAutoConfiguration",
"...reactive.GraphQlWebFluxAutoConfiguration",
"...reactive.GraphQlWebFluxSecurityAutoConfiguration",
"...servlet.GraphQlWebMvcAutoConfiguration",
"...servlet.GraphQlWebMvcSecurityAutoConfiguration"
이 필터의 javadoc에 있는 문장이 이 패턴 전체의 핵심이다:
A misspelled entry fails open silently — the filter simply never matches — so the test that protects this asserts a 404 on the real port rather than checking what this method returns.
문자열 리스트로 하는 방어는 오타가 나면 조용히 열린다. 그래서 이 리스트를 검증하는 테스트는
match()의 반환값이 아니라 실제 포트에서 404가 나오는지를 본다. 방어 수단을 테스트하지 말고
방어된 상태를 테스트하라는 것.
(4) GraphQlActivationEnvironmentPostProcessor — 빈이 생기기 전에 끝나야 하는 일.
두 가지를 한다.
- 은퇴한 안전 키를 거부한다.
backend.graphql.production과backend.graphql.environment는 더 이상 record 컴포넌트가 아니고, Spring 바인더는 모르는 키를 무시한다. 그대로 두면 "늘 설정하던 키를 설정한 운영자가 깨끗한 기동과 조용히 달라진 안전 태세를 얻는" 상황이 된다.GraphQlRetiredSafetyAxis의 javadoc이 그걸 이렇게 표현한다: "split-brain이 고쳐진 것보다 나쁜 결과다 — 옛 설정은 적어도 뭔가를 하기는 했으니까." - 프레임워크 콘솔 플래그에 플랫폼 기본값을 준다. Spring Boot는 introspection을 기본 허용하고 이 플랫폼은 기본 거부한다. 둘 다 각자 합당한데, 합쳐 놓으면 아무도 설정하지 않은 모순으로 기동이 실패한다. 플랫폼 값을 가장 낮은 우선순위로 기여해서, 운영자가 설정하면 여전히 운영자가 이기고 여전히 검증된다. 없앤 건 "아무것도 설정하지 않았을 때의 불일치"뿐이다.
getOrder()는 LOWEST_PRECEDENCE다. 이유가 정확하다 — 더 일찍 돌면 application-prod.yml의
키를 "없음"으로 읽는데, 그건 가장 중요한 배포에 대해 검사하지 않는 것과 같다.
3.3 이 패턴의 일반형
정리하면 인바운드 어댑터의 "구조적 off"는 이렇게 생겼다:
| 층 | 무엇을 막나 | 실패 모드 |
|---|---|---|
AutoConfiguration.imports 진입점 1개 |
우리 빈 | 명시적 |
@ConditionalOnProperty 마스터 |
우리 빈 | 명시적 |
AutoConfigurationImportFilter |
프레임워크가 스스로 여는 라우트 | 오타 시 조용히 열림 |
EnvironmentPostProcessor |
빈 이전에 결정돼야 할 것 | 명시적 |
| 실제 포트 404 테스트 | 위 전부 | — |
세 번째 줄이 대부분의 프로젝트가 빠뜨리는 것이고, 다섯 번째 줄이 세 번째 줄을 믿을 수 있게 만드는 유일한 것이다.
4. autoconfigure — 설정이 하나의 축이 되기까지
13개 파일 78KB. runtime 다음으로 큰 패키지고, 대부분이 기동 시점 검증이다.
4.1 GraphQlDeploymentMode — 두 축을 하나로 합친 사건 (GQL-INT-002)
이 enum의 javadoc은 이 레포에서 가장 잘 쓰인 설계 기록 중 하나다.
예전 상태: 안전 태세가 두 설정이었다.
productionboolean → 익명 principal 처리, allow-by-default 인가, 요청 보호 일부environmentenum → introspection, GraphiQL
같은 record 안에서 각각 false와 PRODUCTION_PUBLIC으로 기본값을 잡고 있었다. 그래서
출하 기본값이 "인터넷에 노출된 프로덕션 엔드포인트인데 보호는 노트북처럼 동작하는" 상태를
서술했다. javadoc의 결론:
Two axes that can disagree about the same question are not two settings; they are one setting and a bug.
지금은 축이 하나다:
| 모드 | introspection | GraphiQL | production 규칙 |
|---|---|---|---|
LOCAL |
✅ | ✅ | ❌ |
DEV |
✅ | ✅ | ❌ |
PRODUCTION_INTERNAL |
✅ | ❌ | ✅ |
PRODUCTION_PUBLIC |
❌ | ❌ | ✅ |
기본값도 없고 관대한 파싱도 없다. 설정 안 하면 기동 에러다. 이유가 좋다 — "development로 추측하면 인가 없는 엔드포인트가 되고, production으로 추측하면 운영자가 설명할 수 없는 장애가 된다."
그리고 TEST/STAGING 상수를 지운 기록이 있다. 그 둘에 도달할 수 있는 런타임 프로파일이
없었다 — 아무도 배포할 수 없고 아무도 테스트할 수 없는 태세. 지금은
GraphQlDeploymentModeRegistryParityTest(app-bootstrap 쪽)가 이 목록과 운영자용 레지스트리가
다시 어긋나지 않게 지킨다.
4.2 GraphQlRetiredSafetyAxis — 은퇴한 키를 무시하지 않고 거부한다
Spring 바인더는 모르는 키를 조용히 무시한다. 그래서 backend.graphql.production을 여전히 설정한
운영자는 깨끗한 기동 + 조용히 달라진 안전 태세를 얻는다. 이 클래스는 그 두 키가 environment에
있으면 EnvironmentPostProcessor에서 즉시 던진다.
설정 키를 지울 때의 일반 규칙: 지운 키는 무시가 아니라 거부여야 한다.
4.3 GraphQlPlatformStartupValidator — 두 종류의 검증
(1) problems(settings) — 설정 값 검증. 첫 검사가 특별하다:
// First and alone. Every check below asks the mode a question, so reporting them against an
// absent mode would bury the one problem an operator can act on under its own consequences.
if (properties.deploymentMode() == null) {
return List.of(GraphQlDeploymentMode.REQUIRED_MESSAGE);
}
모드가 없으면 그 하나만 보고한다. 나머지 검사가 전부 모드에 질문하기 때문에, 모드 없이 돌리면 "운영자가 조치할 수 있는 문제 하나"가 "그 문제의 결과 열 개" 밑에 묻힌다. 나머지는 전부 모아서 한 번에 던진다 — "한 번에 하나씩 고치며 재시작하는 건 롤아웃이 멈추는 방식이다."
검사 목록:
| 검사 | 왜 |
|---|---|
| GraphiQL이 모드에서 허용되나 | 공개 쿼리 콘솔 |
| production인데 cursor 서명 키가 없나 | 서명 없는 커서는 client-editable |
| introspection이 모드에서 허용되나 | 익명 호출자에게 전체 타입 시스템 |
| maximumPageSize ≥ 1 | 0이면 cost 예산에 곱할 승수가 없음 |
| maximumComplexity ≥ 1 | 동일 |
unsupported.* 6개 플래그 |
multipart upload / HTTP array batch / request-wide transaction / repository auto-exposure / response cache / Stable starter가 Advanced 활성화 |
| REACTIVE_WEBFLUX인데 bridge 없는 blocking resolver | 이벤트 루프 starvation |
unsupported.*가 재밌다. "지원하지 않는다"를 설정 키로 만들어 두고, 켜면 기동을 거부한다.
문서에 "지원 안 함"이라고 쓰는 대신 코드가 거부하고, 거부 메시지가 대안을 알려준다:
"use the Fileserver upload reservation", "use one mutation use case".
(2) validateRuntime(runtime) — 실제 조립된 빈 검증. javadoc이 핵심을 말한다:
Validating
GraphQlExecutionPipeline.stable()would prove the platform's own default is well formed and say nothing about the pipeline an adopter actually replaced it with — which is the only one that will serve requests.
플랫폼 빈은 전부 @ConditionalOnMissingBean이다. 그게 요점이자 위험이다. adopter가
authorization이 execution 뒤에 오는 파이프라인, 코어션 없는 스칼라를 선언한 매니페스트,
운영자가 설정한 것보다 높은 page 상한을 가진 client policy를 넣을 수 있다. 셋 다 wiring 시점에
실패하지 않는다. 요청 시점에, 프로덕션에서, 조용히 실패한다.
그래서 GraphQlPlatformRuntime이 "컨텍스트가 실제로 조립한 것"을 한 덩어리로 모으고,
runtimeProblems가 그걸 검증한다:
- 파이프라인 단계 순서 (§8.2)
- 선언된 execution profile과 실제 실행 중인 서버의 일치 (
GraphQlRuntimeTransport) - reactive transport인데 bridge 없는 blocking resolver — 선언된 프로파일과 무관하게 검사한다.
MIXED_CONTROLLED가 reactive에서 허용되는 건 crossing이 선언되어 있기 때문이고, 선언 안 된 crossing이 있으면 그 프로파일 자체가 거짓말이 된다. - 스칼라 매니페스트가 실제로 wiring 가능한가
- client policy의
maxPageSize≤ 운영자 설정maximumPageSize
4.4 GraphQlPlatformSettings — @DefaultValue가 없으면 0이 기본값이다
javadoc이 기록하는 버그:
Every default is declared with
@DefaultValue, which is the only kind of default the binder actually applies. A primitive with no annotation binds to zero, and this record's zeros — a page size of nothing, a complexity budget of nothing — are exactly the values the startup validator refuses, so the platform used to refuse to start until an operator supplied two numbers that have perfectly good defaults.
@ConfigurationProperties record에서 int maximumPageSize를 @DefaultValue 없이 쓰면 0이 된다.
그리고 §4.3의 validator가 0을 거부한다. 결과: 완벽하게 좋은 기본값이 있는 두 숫자를 운영자가
직접 넣기 전까지 플랫폼이 기동을 거부했다.
prefix는 backend.graphql이다. build.gradle 주석이 정직하게 적는다: "this comment used to say
spring.graphql.platform.*, which never existed."
4.5 GraphQlPlatformActuatorEndpoint / GraphQlPlatformConfigurationReport
운영 조회면. 의도적으로 @Endpoint가 아니라 평범한 컴포넌트다 — 이 leaf는 actuator에 의존하지
않고, 운영 평면을 노출할지와 어느 인가 뒤에 둘지는 composition root의 결정이다.
리포트가 담는 것: 스키마 해시, execution profile, cursor key id(키 아님), capability 이름, 경계 있는 카운트. 담지 않는 것: SDL, persisted document, 키. 이유가 정확하다 — "운영 엔드포인트는 배포 파이프라인보다 많은 사람이 닿을 수 있고, 스키마는 introspection을 끈 이유 그 자체다."
4.6 GraphQlPlatformAutoConfiguration — Advanced는 여기로 못 들어온다
두 가지가 명시적이다.
- Stable만 조립한다. Advanced가 Stable starter를 통해 들어오는 건 모듈 경계가 막으려는 사고 그 자체다.
- Boot의 GraphQL auto-configuration 뒤에 순서가 잡힌다. 앞이면 프레임워크가 나중에 덮어쓴다.
그리고 production에서 authorization policy 빈이 없으면 기동을 거부한다:
if (properties.production()) {
throw new GraphQlPlatformConfigurationException(List.of(
"backend.graphql.deployment-mode=" + properties.deploymentMode()
+ " requires an explicit GraphQlAuthorizationPolicy bean; ..."));
}
기본 인가 정책을 제공하지 않는다. 제공했다면 그게 "아무도 리뷰하지 않은 기본 인가"가 된다.
5. api / policy — 이름을 타입으로, 한계를 값으로
5.1 api — 다섯 개의 경계 있는 식별자
5개 파일 8KB. 전부 record 또는 검증된 문자열 래퍼다.
| 타입 | 규칙 | 왜 경계가 필요한가 |
|---|---|---|
GraphQlClientProfile |
짧은 정적 식별자 | 정책을 고르고 메트릭 라벨이 된다 |
GraphQlClientProfileName |
대문자 짧은 식별자 (닫힌 enum 아님) | PUBLIC/PARTNER/FIRST_PARTY/ADMIN/INTROSPECTION + 배포별 추가 |
GraphQlOperationId |
등록된 오퍼레이션의 안정 핸들 | 레지스트리 키 + 메트릭 라벨. 요청 식별자가 아니다 |
GraphQlOperationName |
저카디널리티 오퍼레이션 이름 | 익명 오퍼레이션은 이름의 부재로 표현. 빈 문자열이나 placeholder 아님 |
GraphQlSchemaCoordinate |
TypeName.fieldName |
경로 구분자/공백/UUID-like 거부 — 요청별 값이 라벨에 새면 안 됨 |
관통하는 규칙 하나: 이 다섯 개는 전부 메트릭 라벨이 될 수 있다. 그래서 형태가 타입에서 강제된다. 각 소비자가 기억할 일이 아니다.
GraphQlClientProfile의 javadoc이 두 번째 이유를 덧붙인다 — 동적 값이면 호출자가 자기 정책을
고를 수 있다. 프로파일이 cost/page/introspection 한계를 선택하니까.
5.2 policy — 벤치마크된 숫자를 코드 밖으로
9개 파일 17KB.
GraphQlClientPolicy — 한 클라이언트 프로파일이 측정되는 모든 숫자가 검증된 값 하나에
들어간다. 테스트 fixture에서 실제 인스턴스를 보면 15개 필드다:
new GraphQlClientPolicy(
65536, // maxDocumentBytes
65536, // maxVariablesBytes
12, // maxDepth
500, // ...
50, 50, 1000, 20, 100, 10_000, 10_000, 5_242_880,
Duration.ofSeconds(5), // maxExecutionTime
false, false, true);
javadoc의 핵심: 정책은 문서/변수/actor/tenant/자격증명을 절대 담지 않는다. 전부 경계 있는 카운트와 duration이다. 그래서 환경 매니페스트로 출하할 수 있고, 벤치마크할 수 있다.
GraphQlClientPolicyManifest — 프로파일이 해석되는 단 하나의 장소. 모르는 프로파일은
기동/요청 실패지 관대한 기본값으로의 조용한 fallback이 아니다. 중복 프로파일은 생성 시점 거부 —
한 클라이언트에 대한 두 개의 모순된 정책은 결정적으로 해소할 수 없다.
GraphQlOperationCatalog / GraphQlOperationPolicy — 프로덕션에서 실행할 오퍼레이션의
레지스트리. 모든 프로덕션 오퍼레이션은 이름과 스키마 좌표를 둘 다 가져야 한다. 이름은
tracing/cost 예외/persisted 레지스트리/사용량 분석이 키로 쓰고, 좌표는 cost 카탈로그/fetch
프로파일/필드 인가가 키로 쓴다.
GraphQlOperationPolicy가 런타임 대신 여기서 강제하는 두 불변식:
STREAMresolver는 subscription에서만 말이 된다.- idempotency나 expected version은 mutation에서만 의미가 있다 — query에 선언하면 읽기 경로가 구현하지 않는 보장을 약속하는 것이다.
GraphQlPolicyViolation — 초과된 한계 하나를 보고한다. 한계 이름과 두 숫자만. 문서도,
변수도, 트리거한 값도 아니다. 그래서 거부를 클라이언트에게 설명하면서 동시에 메트릭으로 셀 수 있다.
ResolverExecutionType — resolver마다 "실제로 어떻게 실행되는가"를 등록한다. 런타임 프로파일이
서빙할 수 없는 조합(WebFlux 이벤트 루프 위의 blocking repository 호출, subscription 밖의 stream)을
부하 중이 아니라 기동 시점에 거부하게 해 준다.
6. context — 요청 정체성과 지문
6개 파일 20KB. 작지만 이 leaf에서 가장 미묘한 패키지다.
6.1 GraphQlRequestContext — 불변 요청 컨텍스트
한 값이 실행 파이프라인·resolver·DataLoader 배치·Application 계층이 합의해야 할 전부를 나른다: 누가 호출하는가, 어느 tenant인가, 어느 client policy 하에서, 어느 locale로, 어느 오퍼레이션에 대해, 어느 trace로 상관되며, 언제까지인가.
불변인 이유가 명시적이다 — virtual-thread executor와 Reactor context에 넘겨도 안전해야 한다.
6.2 TenantContext — tenant는 인자에서 오지 않는다
tenant는 보안 경계다. 그래서 검증된 인증 데이터에서만 해석되고 GraphQL 인자/변수에서는 절대 오지 않는다. 구현이 이걸 타입으로 만든다:
That is why there is no factory for an argument-derived tenant: the only ways to build this type are the trusted sources below, and
TenantSourcerecords which one was used.
인자에서 만드는 팩토리가 존재하지 않는다. 그리고 TenantSource가 어느 신뢰 경로로 왔는지를
기록하므로, GraphQlTenantIsolationPolicy가 그걸 검사할 수 있다 (§11.3).
6.3 ActorRef — actor는 있고 토큰은 없다
플랫폼은 인가/멱등성 스코프/감사를 위해 actor 정체성이 필요하지만, 에러 계약과 관측 계약은 raw user identifier가 응답이나 메트릭 라벨에 도달하는 것을 둘 다 금지한다. 그래서 컨텍스트는 이 불투명 참조를 나르고 access token/cookie/raw provider claim은 절대 나르지 않는다. 밖으로 공유되는 건 keyed fingerprint다.
6.4 GraphQlIdentityFingerprinter — 저엔트로피 입력에 대한 다이제스트는 보호가 아니다
이 클래스가 이 패키지에서 제일 날카롭다.
A plain digest is not that value. An actor reference and a tenant name are low-entropy: they come from a bounded set a reader can enumerate —
tenant-a,acme, a numeric user id, an email address — and a digest of a guessable input is recovered by digesting the guesses.
즉 SHA-256(tenant_name)은 익명화가 아니다. tenant 이름 후보가 수천 개뿐이면 전부 해싱해서
역매핑 테이블을 만들면 끝이다. 그래서 keyed fingerprint를 쓰고, 키 회전을 지원한다
(keyIds() 메서드가 있고 GraphQlIdentityFingerprinterTest가 rotation을 고정한다).
멱등성 레코드가 그런 다이제스트로 키잉되어 있으면, 그 레코드를 가진 누구에게든 여전히 호출자를 지목한다.
6.5 GraphQlCommandAttribution — anti-corruption 경계
이 문서에서 가장 중요한 타입 중 하나다. 방향이 요점이다.
GraphQlRequestContext는 인바운드 전송 타입이다 — client profile, operation id, GraphQL locale을
안다. 이걸 use case에 넘기면 application-core가, 그리고 그 use case가 닿는 모든 persistence /
HTTP client 어댑터가 GraphQL 전송에 컴파일 의존하게 된다.
그래서 GraphQlCommandAttribution이 컨텍스트를 "application command가 나를 수 있는 값"으로
줄인다 — actor, tenant, deadline. 명시적 매핑이고, 타입이 넘어가지 않는다.
ADR-GQL-001의 첫 번째 결정이 이거고, 경계 테스트는 **"이 leaf 밖에서 GraphQlRequestContext를
grep하면 아무것도 안 나온다"**이다. 그 테스트는 app-bootstrap에 있다 (§25.2) — leaf 안에 두면
"leaf가 자기 자신을 위반하지 않는다"만 증명하기 때문이다.
6.6 GraphQlDeadline — duration이 아니라 instant
같은 예산을 resolver, DataLoader 배치, JPA/Mongo/HTTP client deadline에 넘겨야 하는데 각 계층이
시계를 다시 시작하면 안 된다. 그래서 절대 시각이고, 남은 시간은 항상 주입된 Clock에서 계산한다.
7. http — GraphQL over HTTP 프로파일
19개 파일 49KB.
7.1 GraphQlHttpProfile — 움직이는 draft를 버전으로 고정한다
GraphQL over HTTP는 Stage 2 Draft고, status code 절이 아직 움직인다. draft는 부분 응답
(data + errors)에 294를 제안하고 Spring for GraphQL은 200을 반환한다.
해법: 명시적으로 버전이 붙은 프로파일에 플랫폼을 고정한다. V1이 현재 배포 계약이고, draft가
바뀌어도 클라이언트 밑에서 계약이 바뀌지 않는다.
7.2 GraphQlHttpOutcome / GraphQlHttpStatusMapper — 실행이 시작됐는가
에러 계약 전체가 이 한 줄 위에 서 있다:
The dividing line is whether execution started.
| 언제 실패했나 | 분류 | HTTP |
|---|---|---|
| 실행 전 (malformed JSON, parse, validation, variable coercion, transport 규칙) | request error | 4xx |
| 실행 중 | field error | 200 + 부분 data + errors |
field error를 5xx로 바꾸면 해석된 데이터를 버리고, 모든 프록시와 클라이언트 재시도 정책에게 "서버가 망가졌다"고 말한다 — 실제로는 필드 하나가 실패했을 뿐인데.
GraphQlHttpResponseFactory가 하나만 있는 이유도 같다: 4xx/200 분기가 drift하지 않게, 모든 전송이
여기서 응답을 만든다.
7.3 GraphQlAcceptHeader — q=0은 약한 선호가 아니라 거부다
떨어뜨리기 쉽고 틀리면 비싼 두 성질:
application/graphql-response+json;q=0은 **"절대 그거 보내지 마"**다.- quality가 클라이언트의 대안들을 서로 순위 매긴다.
서버 자기 선호 리스트를 순회하면서 헤더에 나타나는 첫 타입을 반환하면 둘 다 틀린다. (이게 원래 리뷰 GQL-006이었다.)
미디어 타입은 두 개를 지원한다: 응답은 application/graphql-response+json을 선호 —
4xx/200이 GraphQL over HTTP 프로파일이 말하는 의미라고 클라이언트에게 알려주는 게 이거다 —
하고 application/json도 계속 지원한다. 그 미디어 타입이 생기기 전에 쓰인 클라이언트를 위해서.
7.4 GraphQlRequestEnvelopeValidator — 파서보다 먼저
Everything here runs before the GraphQL parser sees the document. That ordering is the point: a parser has to allocate proportionally to its input, so a size limit applied afterwards has already paid the cost it was supposed to prevent.
진단은 바이트 카운트와 필드 이름만 보고한다. 문서도, 변수도 아니다.
7.5 GraphQlJsonStructurePolicy — 바이트 한계는 처리 비용을 묶지 않는다
이 클래스의 javadoc이 아주 구체적이다:
Sixty kilobytes of
[[[[[…]]]]]is small on the wire and expensive to walk, coerce and validate, and the same bytes as one enormous list turn into one enormous coercion loop.
그래서 depth, element count, key count가 각자의 예산을 가진다.
7.6 GraphQlJsonValues — Map.copyOf를 쓸 수 없는 이유
스타일이 아니라 정확성 버그다. Map.copyOf는 null 값에 던진다. 그리고 null 변수는 합법적이고
의미 있는 GraphQL 입력이다. 세 경우가 다르게 코어션된다:
| 입력 | 의미 |
|---|---|
{"a": 1} |
값 |
{"a": null} |
명시적 null |
{} |
부재 — 인자가 기본값으로 폴백 |
이걸 합치면 세 번째와 두 번째를 구분할 수 없게 된다. (원래 리뷰 GQL-005의 절반.)
7.7 나머지
GraphQlExtensionsPolicy—extensions는 요청 봉투의 열린 슬롯이고, "문서화되지 않은 기능 플래그가 나타나서 조용히 실행을 바꾸는" 자연스러운 장소다. 등록된 키만 받는다.GraphQlRequestSize— 문자가 아니라 바이트. 멀티바이트 문자 하나가 메모리와 파서 작업 둘 다 더 든다.GraphQlHttpExecutor— 전송이 GraphQL 실행으로 나가는 seam. 이만큼 좁게 유지하는 게 전송 코드가 파싱/cost/resolver에 대한 의견을 갖지 않게 만든다.GraphQlHttpResponse— MVC/WebFlux 타입에서 의도적으로 독립. 그래서 계약 테스트 하나가 둘 다 커버한다.GraphQlRequestTooLargeException/GraphQlRequestFormatException/GraphQlHttpContractException— 셋 다 내용을 절대 담지 않는다. 초과 크기 문서는 로그나 에러 바디에 에코되면 안 되는 바로 그 입력이다.
7.8 runtime/servlet/GraphQlRequestBodyLimitFilter — 유일한 servlet 파일
디코딩 전에 raw body를 자르려면 servlet 스택에서는 필터밖에 없다. 그래서 compileOnly로
jakarta.servlet:jakarta.servlet-api를 받는다. build.gradle 주석이 이유를 쓴다:
compileOnlyis what keeps that from contradicting the paragraph above: it is the servlet API, not a server, and it stays off runtimeClasspath entirely — so the filter class simply never loads for an adopter who is not running servlets.
바로 위 문단에는 *"no production file imports org.springframework.web, jakarta.servlet or
org.springframework.http"*라고 쓰여 있다. 이 문장은 이제 문자 그대로는 거짓이다 (§28-P4).
정신은 유지되지만 문장은 갱신이 필요하다.
8. execution — 파이프라인은 취향이 아니라 보안 속성이다
22개 파일 45KB.
8.1 GraphQlExecutionStage — 6단계와 그 순서의 근거
CONTEXT(true), // 검증된 인증 데이터로 불변 컨텍스트 구성
PERSISTED_LOOKUP(false), // operation ID → 승인된 문서 (Advanced)
PARSE_VALIDATE(true), // 파싱·검증·오퍼레이션 선택
AUTHORIZATION(true), // 오퍼레이션·좌표 인가 — 실행 전
COST(true), // 구조·복잡도 예산 — resolver 실행 전
EXECUTE(true); // 실행
javadoc의 첫 문장이 이 패키지 전체의 논지다:
The order is a security property, not a preference.
각 순서의 근거:
| 제약 | 왜 |
|---|---|
| CONTEXT → 나머지 전부 | 누가 호출하는지 모르면 아무것도 결정 못 함 |
| PERSISTED_LOOKUP → PARSE_VALIDATE | 레지스트리가 문서 공급원이다. 파싱 전이어야 함 |
| PARSE_VALIDATE → AUTHORIZATION | 좌표 규칙은 검사할 좌표가 없다 — 오퍼레이션이 선택되기 전까지 |
| PARSE_VALIDATE → COST | 마찬가지 |
| COST → EXECUTE | 나중에 검사한 예산은 이미 써 버린 예산이다 |
| AUTHORIZATION → EXECUTE | 거부된 mutation이 이미 부수효과를 냈으면 늦음 |
원래 리뷰 GQL-018이 정확히 이 문제였다 — "파이프라인 단계 순서가 authorization에 필요한 정보를 만들기 전에 authorize한다." 지금 순서는 그 지적을 반영한 것이다.
8.2 GraphQlExecutionPipelineValidator — 제약을 쌍으로 선언한다
7개의 (earlier, later) 쌍으로 순서를 표현한다. 이 형태의 장점: 새 단계가 끼어들어도 관련 있는
제약만 검사하면 되고, 어떤 제약이 깨졌는지 메시지로 나온다.
검사 세 가지:
required()단계가 빠졌나- 같은 단계가 두 번 등록됐나
- 순서 쌍 위반
javadoc이 왜 기동 시점인지 말한다:
Both failure modes are silent at runtime — authorization after execution still "works", it just authorizes nothing.
8.3 BoundedPreparsedDocumentProvider — 문서를 캐시하지 결과를 캐시하지 않는다
이 구분이 이 캐시가 안전한 유일한 이유다:
A parsed document is the same for everyone, while a result depends on the actor, the tenant and their permissions — caching that here would be a cross-user data leak.
GraphQlPreparsedCacheKey가 네 부분이다, 그리고 넷 다 필수다:
| 부분 | 왜 |
|---|---|
| 문서 해시 | 당연 |
| 스키마 해시 | 같은 텍스트가 다른 스키마에서 다르게 검증됨 |
| 검증 정책 버전 | 정책이 바뀌면 검증 결과가 바뀜 |
| client profile | 필드 가시성이 프로파일마다 다름 |
문서 해시만으로 키잉하면 다른 프로파일에서 검증된 문서를 돌려줄 수 있다.
GraphQlPreparsedCachePolicy는 entry 수와 weight(문서 문자 수)를 둘 다 묶는다. 이유:
키 공간이 클라이언트 통제 하에 있다. 서로 다른 문서 텍스트마다 새 엔트리니까, 무제한 맵은
최적화가 아니라 메모리 고갈 벡터다.
GraphQlPreparsedCacheMetrics는 카운터만이고 문서별 차원이 없다. 캐시 엔트리의 자연스러운
라벨은 문서 자체인데, 그게 정확히 메트릭 태그가 되면 안 되는 것이다.
8.4 GraphQlTimeoutPolicy / GraphQlDeadlinePropagator / GraphQlResolverBudget
GraphQlTimeoutPolicy는 오직 좁아지기만 한다. GraphQL 아래의 모든 계층 — resolver, DataLoader
배치, DB 문장, HTTP 호출 — 이 자기 예산을 요청하고, 요청한 것과 실제 남은 것 중 작은 쪽을
받는다.
Without that clamp a five-second database timeout under a two-second request budget keeps the connection busy for three seconds after the client has already been answered.
GraphQlDeadlinePropagator가 계층별 예산을 유도한다. 5층: 전송 핸드셰이크, 요청 실행,
resolver 하나, DataLoader 배치 하나, 그리고 subscription의 경우 연결 자체. 마지막 것은
의도적으로 요청 예산에서 유도하지 않는다 — subscription은 장수명 스트림이고 5초 요청 타임아웃을
적용하면 안 된다.
GraphQlResolverBudget — 느린 필드 하나가 요청 전체를 먹지 못하게. 그 필드는 실패하고 값은
null이 되고 나머지 응답은 계속 해석된다. 단, resolver 자기 예산이 요청 잔여로 클램프될 때만
건전하다.
8.5 GraphQlCancellation — 취소는 일방향이고 리스너는 정확히 한 번
A timeout that only unblocks the caller is not a cancellation: the database statement, the HTTP call and the reactive publisher keep running, and under load that is how a slow dependency turns into an exhausted pool.
리스너는 드레인된다 (큐에서 빼면서 실행) — 그래서 정확히 한 번. 원래 리뷰 GQL-021이 *"cancellation hook 하나의 실패가 나머지 cleanup을 막는다"*였고, 지금은 모든 hook이 실행되고 예외는 suppressed로 모인다.
advanced/subscription/GraphQlSubscriptionCancellation의 javadoc이 후속 정리를 기록한다:
There used to be two one-way cancellation state machines in this platform with the same queue, the same flag and ...
두 개의 동일한 상태 기계가 있었고 하나로 합쳤다.
8.6 GraphQlOperationSelection / GraphQlOperationNamePolicy
GraphQlOperationSelection은 이름과 함께 "몇 개 중에서"를 나른다. 여러 오퍼레이션이 있고
operationName이 없으면 올바른 선택이 존재하지 않는다. 추측하면 클라이언트가 문서 순서를
바꿔서 실행되는 것을 바꿀 수 있다.
GraphQlOperationNamePolicy — 프로덕션에서 이름을 요구한다. 스펙은 단일 익명 오퍼레이션을
허용하고 로컬에서는 편하다. 프로덕션에서는 오퍼레이션이 가진 유일한 안정 정체성을 없앤다 —
trace, cost 예외, persisted 레지스트리, 사용량 분석이 전부 이름으로 키잉하고, 이름이 없으면 대안
키는 raw 문서다. 무제한이고 변수로 가득한.
GraphQlAnonymousOperationException은 문서 텍스트를 담지 않는다. 이름을 요구하는 이유가
정확히 "문서가 정체성이 되면 안 된다"이니까.
8.7 GraphQlExecutionProfile — MIXED_CONTROLLED는 "아무거나"가 아니다
Mixing blocking and reactive work is legitimate, but only across a declared bridge — without one, the same code path either blocks an event loop or blocks a request thread waiting on a publisher, and which one it does depends on where it happened to be called from.
GraphQlExecutionProfileValidator가 양방향을 본다:
- reactive 스택 위의 bridge 없는 blocking resolver → 이벤트 루프 starvation
- servlet 스택 위의 reactive resolver → 누군가 기다리며 블록해야 하고, 그게 어디서 일어나는지가 운에 맡겨짐
9. runtime — 정책이 실제 요청을 만나는 seam
19+1개 파일 83KB. 이 leaf에서 가장 큰 패키지이고, 원래 리뷰의 P0 지적(GQL-002)에 대한 답이다.
리뷰가 뭐라 했냐면: "cost, authorization, DataLoader, cursor, idempotency, observation, persisted
operation, subscription 등 많은 정책과 값 객체가 구현되어 있지만, 대부분 Spring GraphQL이 실제
/graphql 요청을 처리하는 extension point에 연결되지 않는다."
runtime은 그 연결이다. 그리고 각 파일 javadoc이 "연결되지 않았을 때 무슨 일이 있었나"를 기록한다.
9.1 GraphQlPlatformWebInterceptor — CONTEXT 단계
WebGraphQlInterceptor 구현. 여기 있어야 하는 이유: 전송을 볼 수 있는 유일한 계층이다 —
헤더, 인증된 principal, 요청 locale.
하는 일 순서:
public Mono<WebGraphQlResponse> intercept(WebGraphQlRequest request, Chain chain) {
GraphQlRequestContext context;
try {
verifyRequestBounds(request); // 1. 크기·형태 검사
context = requestContext(request); // 2. 컨텍스트 구성
} catch (RuntimeException rejection) {
return Mono.just(reject(request, rejection)); // 3. 거부도 wire error로
}
request.configureExecutionInput((executionInput, builder) ->
builder.graphQLContext(ctx -> ctx.put(GraphQlRequestContext.CONTEXT_KEY, context)).build());
// 4. Reactor context에도 — reactive resolver가 같은 값을 읽게
return chain.next(request).contextWrite(view -> view.put(GraphQlRequestContext.class, context));
}
두 곳에 넣는다는 게 중요하다. GraphQL context와 Reactor context 둘 다. 주석이 이유를 쓴다: "a reactive resolver reads the same value rather than a second one assembled from whatever it can reach."
verifyRequestBounds가 UTF-8 바이트로 잰다:
int bytes = document.getBytes(StandardCharsets.UTF_8).length;
if (bytes > policy.maxDocumentBytes()) { throw new GraphQlRequestTooLargeException(...); }
그리고 익명 요청의 trace id를 클라이언트 헤더가 아니라 Spring이 이미 할당한 execution id에서 가져온다 — 호출자 공급 trace id는 로그와 메트릭 값이 되고, 이건 경계가 있어야 한다.
9.2 GraphQlPlatformInstrumentation — 왜 beginExecuteOperation인가
이 클래스가 "거부된 요청은 resolver를 0회 호출한다"를 사실로 만드는 것이다.
beginExecuteOperationis the last hook before graphql-java starts the execution strategy: the document is parsed, validated and bound to one operation, and no data fetcher has run. Rejecting here is what makes "a denied request invokes zero resolvers" true rather than aspirational — an earlier hook has no operation to judge, and a later one is judging work that has already happened.
훅 선택 자체가 설계 결정이고, 그 근거가 코드 옆에 있다.
컨텍스트가 없으면 통과시키지 않고 거부한다:
GraphQlRequestContext requestContext = execution.getGraphQLContext().get(CONTEXT_KEY);
if (requestContext == null) {
throw new AbortExecutionException(List.of(
GraphQlWireErrors.toGraphQlError(
GraphQlWireError.internal(executionId).withConstraint(MISSING_CONTEXT_CONSTRAINT))));
}
컨텍스트는 전송 인터셉터가 만드니까, 없다는 건 인터셉터가 배선되지 않았다는 뜻이다 — 그리고 그 설정이 정확히 모든 정책이 조용히 아무것도 안 하는 설정이다.
체인이 끝난 뒤 두 가지를 컨텍스트에 다시 쓴다:
// 파이프라인이 확정한 컨텍스트 (전송은 파싱 전 컨텍스트밖에 못 만든다)
execution.getGraphQLContext().put(GraphQlRequestContext.CONTEXT_KEY, completed.requestContext());
// 확정된 상태 전체 — cost 단계가 잰 depth/complexity는 여기 말고 어디에도 없다
execution.getGraphQLContext().put(GraphQlExecutionContext.CONTEXT_KEY, completed);
전송이 만든 컨텍스트는 오퍼레이션이 선택되기 전이라 placeholder 오퍼레이션 정체성을 갖는다. 그걸 그대로 두면 resolver가 placeholder를 보고, 파이프라인이 조인 deadline이 downstream에 안 보인다.
9.3 GraphQlExecutionChain — 카탈로그를 손으로 쓰지 않는다
방향이 요점이다:
A stage catalogue written by hand can drift from the code without anything failing, whereas a catalogue derived from the registered handlers cannot describe a stage that is not there.
public GraphQlExecutionPipeline pipeline() {
List<GraphQlExecutionStage> stages = new ArrayList<>();
stages.add(GraphQlExecutionStage.CONTEXT); // 전송 인터셉터 소유
handlers.forEach(handler -> stages.add(handler.stage()));
stages.add(GraphQlExecutionStage.EXECUTE); // graphql-java 소유
return new GraphQlExecutionPipeline(stages);
}
두 단계는 프레임워크 seam이 소유하므로 유도된 뷰에 명시적으로 넣는다. 그래야 순서 validator가 요청 경로 전체를 판단하지 가운데만 판단하지 않는다.
생성자에서 곧바로 GraphQlExecutionPipelineValidator.validate(pipeline())를 부른다 — 잘못된
체인은 만들어질 수 없다.
Stable 체인은 3개 핸들러다:
public static GraphQlExecutionChain stable(
GraphQlOperationSelectionHandler selection,
GraphQlDocumentAuthorizationHandler authorization,
GraphQlCostBudgetHandler cost) {
return new GraphQlExecutionChain(List.of(selection, authorization, cost));
}
9.4 GraphQlExecutionContext / GraphQlExecutionHandler — 단계가 필드다
핸들러는 "지금까지 만들어진 상태"를 받고 "자기가 만든 상태"를 반환한다. 각 단계는 필드를 추가하고 절대 제거하지 않는다. 그래서 "이 단계가 돌았나?"는 등록 순서를 믿는 대신 값을 보면 답이 나온다.
This is what separates the pipeline from a list of stage names: the names could be in any order and nothing would notice, while a chain that authorizes before selecting an operation has nothing to authorize.
이게 §8.2의 순서 검증과 짝을 이룬다. 순서 검증은 선언을 검사하고, 필드 의존은 실행을 강제한다.
9.5 세 개의 Stable 핸들러
GraphQlOperationSelectionHandler — 이 요청이 실행할 오퍼레이션 하나를 고르고 컨텍스트를
거기에 바인딩한다. 이후 전부가 선택된 오퍼레이션으로 키잉되므로, 여기서 고르는 게 두 단계가
서로 다른 오퍼레이션을 판단하는 상황을 막는다. multi-operation + operationName 없음은
기본값 대신 거부.
GraphQlDocumentAuthorizationHandler — 오퍼레이션과 root 좌표를 인가한다. 실행 전이라는 게
요점: 중간에 거부된 mutation은 이미 부수효과를 냈다. introspection gate가 여기 산다 — cost가
아니라. 이유가 정확하다: *"whether this client profile may see the schema"*는 인가 질문이지 예산
질문이 아니다.
GraphQlCostBudgetHandler — 구조·복잡도 예산. 그리고 deadline을 재검사한다. 요청이 전송이
deadline을 설정한 뒤 다른 작업 뒤에 큐잉됐을 수 있고, *"예산이 이미 없어진 오퍼레이션을 시작하는
건 아무도 읽지 않을 응답에 downstream 용량을 쓰는 것"*이다.
9.6 GraphQlPreparsedDocumentAdapter / GraphQlBatchLoaderRegistrar / GraphQlRequestObservationConventionAdapter
세 클래스가 같은 문장을 세 번 다르게 쓴다. "잘 테스트된 객체인데 아무도 안 쓴다"의 세 사례.
| 클래스 | 무엇이 끊겨 있었나 |
|---|---|
GraphQlPreparsedDocumentAdapter |
graphql-java가 PreparsedDocumentProvider에 묻는데 아무도 공급 안 함 → 매 요청이 재파싱·재검증하는 동안 플랫폼 캐시는 비어 있었다 |
GraphQlBatchLoaderRegistrar |
이름→Object 레지스트리가 Spring BatchLoaderRegistry나 java-dataloader에 연결 안 됨 → N+1 보호가 어떤 요청도 닿을 수 없는 객체 집합으로 존재 |
GraphQlRequestObservationConventionAdapter |
convention이 Spring의 ExecutionRequestObservationConvention을 구현하지 않아서 카디널리티 경계가 어떤 series에도 적용된 적 없음 |
세 javadoc이 각각 같은 결론을 쓴다:
- "Bounds, weights and metrics that describe a cache nobody consults describe nothing."
- "the N+1 protection existed as a set of well-tested objects that no request could reach."
- "A tag policy that no exporter consults bounds nothing."
이게 이 모듈에서 얻을 수 있는 가장 값진 교훈이다 — 정책 객체의 단위 테스트는 그 정책이
강제된다는 증거가 아니다. 그래서 CLAUDE.md의 등급 어휘(modelled/wired/integration-verified/
production-verified)가 존재한다 (§22.1).
9.7 GraphQlWireErrorMapper — 세 개의 에러 계약이 하나가 되기까지
javadoc이 사건을 기록한다:
There were two error contracts. The Spring-wired resolver mapped
ApiErrorCarrierto a code and a category; a second, richer resolver did masking and registered mappings but was never on the Spring path; and the platform's own rejections had a third vocabulary. Which code, category, retryability and execution id a client received depended on which of the three paths the failure happened to take — and the two resolver classes differed only in the case of one letter.
클래스 이름이 한 글자 대소문자만 달랐다. GraphqlExceptionResolver vs
GraphQlExceptionResolver. Spring은 그중 하나만 불렀다.
지금은 GraphQlWireErrorMapper가 유일한 결정자고, GraphQlDataFetcherExceptionResolver는
어댑터일 뿐이다. 그래서 resolver 안에서 난 실패와 실행 전에 난 같은 실패가 같은 답을 만든다.
GraphQlPlatformRejectionMapper는 파이프라인 거부를 담당한다. 단계마다 하면 단계 수만큼의 에러
어휘가 생기고, 그중 하나가 잊어버리면 예외 메시지를 흘린다. 인식 못 한 예외는 자기 메시지가
아니라 opaque internal error로 매핑 — fail-closed 방향.
GraphQlWireErrors는 한 방향만 한다. 플랫폼이 GraphQlWireError에서 클라이언트가 볼 수 있는
것을 정하고(extensions는 생성 시점 allowlist), 이 클래스는 그 결정을 엔진에 넘기기만 한다.
다른 데서 엔진 빌더로 직접 에러를 만들면 예외 메시지와 응답 사이에 서 있는 유일한 검사를
우회한다.
9.8 GraphQlBlockingBridge — 두 축이 다 유한해야 한다
A virtual-thread-per-task executor limits nothing: it accepts every task and the bound becomes whatever the downstream system will tolerate. A fixed pool bounds threads and then queues without limit, which converts an overload into unbounded memory and latency rather than into a refusal.
풀과 큐 둘 다 유한하고, 가득 차면 즉시 거부한다. 대기가 아니라. 거부는 호출자가 조치할 수 있는 답이고, 대기는 요청 deadline이 발견할 때까지 숨는 latency다.
GraphQlBlockingBridgeFullException은 설정된 경계를 담고 키/actor/tenant는 담지 않는다.
9.9 GraphQlPrincipalResolver — 빈 반환은 "허용"이 아니다
Returning empty means "no verified credential", not "allow". The caller decides what an [anonymous request means].
인터셉터가 그 결정을 한다:
Optional<GraphQlAuthenticatedPrincipal> principal = principalResolver.resolve(request);
if (principal.isPresent()) return contextFactory.create(principal.get(), deadline);
return contextFactory.createAnonymous(
anonymousProfile, anonymousTenant, traceId(request), deadline, anonymousProfileProtected);
anonymousProfileProtected가 true면 익명 요청은 아예 거부된다.
10. cost — 네 개의 층
21개 파일 56KB. 비용 통제가 한 겹이 아니라 네 겹이다.
| 층 | 클래스 | 무엇을 막나 | 언제 |
|---|---|---|---|
| 1 | GraphQlParserLimitPolicy / GraphQlParserLimits / GraphQlParserOptionsFactory |
파서 자체가 폭발하는 것 | 파싱 중 |
| 2 | GraphQlDocumentShapeAnalyzer / GraphQlStructuralLimitPolicy |
depth, alias, 필드 수, fragment | 파싱 후 |
| 3 | GraphQlComplexityCalculator / GraphQlDocumentComplexityScorer / GraphQlCostCatalog |
카디널리티 곱셈 | 실행 전 |
| 4 | GraphQlRuntimeBudgetTracker / GraphQlResponseNodeCounter / GraphQlResponseByteLimiter |
실제 응답 크기 | 실행 중 |
10.1 층 1 — 파서 한계
graphql-java의 ParserOptions에 넘길 값을 정책으로 만든다. 파서는 입력에 비례해 할당하므로,
파싱 후에 재는 건 이미 늦다 (§7.4와 같은 논리).
10.2 층 2 — 문서 형태
GraphQlDocumentShapeAnalyzer가 파싱된 문서를 걸어서 GraphQlDocumentShape를 만든다. 원래 리뷰
GQL-007이 *"named fragment가 custom introspection gate를 우회하고 variable 입력 구조는 측정하지
않는다"*였고, 지금은 GraphQlFragmentReachabilityTest가 fragment를 통한 도달성을 고정한다.
10.3 층 3 — 카디널리티 인지 복잡도 점수
GraphQlDocumentComplexityScorer가 이 패키지의 핵심이다. 세 가지가 인상적이다.
(1) 스키마 인지 순회. 좌표는 TypeName.fieldName이고, 타입 절반은 각 selection set을 스키마에
대해 해석해야 존재한다.
Guessing it from the operation root would price
order { customer { orders { … } } }as three root fields and miss the multiplication entirely.
(2) 카디널리티는 스키마가 아니라 요청에서 온다.
a connection's children are multiplied by the effective page size, and a page size supplied through a variable is resolved from the request variables rather than assumed to be the default. That is the difference between a budget and a suggestion —
first: $nwould otherwise cost the same at 1 and at 1000.
(3) 순회 자체가 유한하다. 기본 maximumVisitedNodes = 200_000, fragment cycle은 확장 경로
추적으로 끊는다. javadoc이 왜 검증된 문서에도 경계를 유지하는지 쓴다 — 이 scorer는 pre-execution
경로에서도 쓰이고, 거기서는 적대적 문서가 정확히 가격을 매기려는 대상이다.
introspection 필드(__ 접두사)는 가격이 아니라 게이트로 처리된다 (§9.5).
root 타입이 없으면 0을 반환한다. 주석: "validation rejects the document, and pricing a tree with no root would be inventing a number."
10.4 층 4 — 런타임 예산
GraphQlRuntimeBudgetTracker가 실행 중 실제 노드 수와 바이트를 센다. 정적 점수는 추정이고,
데이터가 예상보다 클 수 있다. GraphQlRuntimeBudgetExceededException이 그때 던진다.
advanced/incremental/GraphQlIncrementalTransportPolicy가 이 예산을 @defer 패치까지 확장한다 —
"Counting only the initial result would make a deferred fragment a way to send an unbounded
response one patch at a time."
11. security — 인증·인가·테넌트
15개 파일 33KB.
11.1 GraphQlAuthorizationPolicy — 스키마 가시성은 인가가 아니다
Hiding a field from introspection stops it appearing in a schema dump and nothing else — the field is still executable by anyone who knows its name. So a hidden field still goes through the same check as a visible one, and hiding is only ever an additional measure.
이건 GraphQL을 처음 붙이는 팀이 가장 흔하게 하는 착각이고, 타입 하나가 그걸 막는다.
GraphQlAuthorizationDecision은 거부 시 메시지가 아니라 안정 코드를 나른다. 클라이언트가
분기할 수 있고 메트릭이 셀 수 있으면서, 이유가 "무엇이 존재하는가"에 대한 정보 누출이 되지
않는다. GraphQlAuthorizationDeniedException도 마찬가지 — 코드와 "존재를 숨겨야 하는가"만,
객체/actor/tenant는 절대.
11.2 GraphQlAuthenticationContextFactory — 컨텍스트를 만드는 유일한 장소
This is the only place a request context is created, which is what makes "tenant comes from authentication, never from an argument" enforceable: a resolver has no way to build a context with a tenant of its choosing.
규칙을 규칙으로 쓰지 않고 구조로 만든다. §6.2의 TenantContext에 인자 기반 팩토리가 없는 것과
같은 기법이다.
11.3 GraphQlTenantIsolationPolicy — fail-closed 두 규칙
- 보호된 오퍼레이션에 tenant가 없으면 실패한다. untenanted로 진행하지 않는다 — 대안은 모든 tenant를 조용히 가로지르는 쿼리다.
- tenant는 GraphQL 인자/변수에서 절대 오지 않는다.
TenantContext가 기록한TenantSource가 이걸 검사 가능하게 만든다.
GraphQlTenantIsolationException의 javadoc: "a missing tenant must stop the request rather than
fall back to 'all tenants', which is how a single missing propagation turns into a cross-tenant
read."
11.4 ApplicationObjectAuthorization / GraphQlObjectAuthorizationPort — ADR-GQL-001의 핵심
이게 세 결정 중 유일하게 "실제 계층 결함"이었고, 문서화가 아니라 수정됐다.
문제: object 인가를 결정하는 포트가 이 leaf에 선언되어 있었고, 그 메서드가
GraphQlRequestContext를 받았다. 즉 application 계층이 그 계약을 구현하려면 인바운드 전송에
의존해야 했다.
해결: 답하는 계약이 application-core로 갔다.
| 타입 | 위치 | 역할 |
|---|---|---|
ObjectAccessPolicy, ObjectAccessRequest, ObjectAccessDecision |
application-core |
답한다 (전송 중립) |
GraphQlObjectAuthorizationPort |
graphql leaf | 플랫폼 쪽 seam — 언제 물을지 결정 |
ApplicationObjectAuthorization |
graphql leaf | 둘을 잇는 bridge |
javadoc이 분업을 한 문장으로 쓴다:
The platform decides when an object needs an access check; the application decides the answer, because the answer depends on domain state.
현재 등급은 modelled다 — 실행 경로에 연결하는 configuration이 없다. CLAUDE.md가 그 사실을
정직하게 적어 두었다.
11.5 GraphQlContextPropagator / GraphQlContextCleanup — 스레드 홉과 풀 오염
전파: GraphQL 실행은 스레드를 계속 옮긴다 — async data fetcher, DataLoader dispatch, scheduler bridge. thread local에만 있는 컨텍스트는 첫 홉에서 사라진다.
That is not a lost tag: it is a batch load running with no tenant.
모든 홉이 여기를 지나고, 모든 홉이 끝나고 이전 상태를 복원한다 — 풀 스레드에 아무것도 새지 않게.
정리: 풀/virtual-thread executor에서 남은 상태는 stale이 아니라, 그 스레드의 다음 요청이 다른 호출자의 actor와 tenant를 상속한다. 그래서 모든 경로에서 실행되고, 등록된 액션 각각이 앞의 것이 던져도 실행된다.
11.6 나머지
GraphQlAuthenticatedPrincipal— 인증 객체 자체가 아니라 투영. access token/cookie/raw claim 없음. 그래서 downstream이 실수로 자격증명을 forward하거나 로깅할 수 없다.GraphQlClientProfileResolver— 프로파일은 신뢰된 자격증명 메타데이터에서만. 변수/ extension/헤더에서 절대. 프로파일이 cost·page·introspection 한계를 고르니까, 호출자가 자기 프로파일을 지정할 수 있으면 스스로에게 admin 예산을 부여할 수 있다.GraphQlBatchContext— DataLoader 배치는 resolver 콜스택 밖에서 돈다. 이게 없으면 actor도 tenant도 없이 실행되고, "tenant를 무시하는 loader는 일어나기를 기다리는 cross-tenant read고, request-scoped 캐시가 결과를 들고 있어서 더 나쁘다."GraphQlAuthorizationInterceptor— AUTHORIZATION 단계 적용. 거부된 오퍼레이션은 resolver에 도달하지 않고, 거부를 발견하러 가는 길에 mutation resolver가 냈을 부수효과도 없다.
12. dataloader — N+1과 3-값 대수
17개 파일 31KB.
12.1 GraphQlBatchValue — 결과가 세 가지다
이 패키지의 중심 아이디어.
Present(v) — 값이 있다
Missing — 그런 행이 없다
Failed(e) — loader가 실패했다
"No such row" and "the loader failed" both flatten to
nullin a naive loader, and that single conflation is what turns a dependency outage into a response that looks like legitimately empty data.
GraphQlBatchResultMapper가 loader 출력을 요청 키마다 하나씩 매핑한다. 그리고 javadoc이 수정된
버그를 기록한다 — "A null value means missing, in both loader shapes. The two used to disagree."
mapped loader와 list loader가 null에 대해 다른 해석을 하고 있었다.
12.2 GraphQlBatchResult — 정확히 요청 키 수만큼, 요청 순서로
ordered loader는 값을 위치로 반환한다. 크기나 순서가 다른 결과는 조용히 부모에게 잘못된 자식을 준다. 에러가 아니라 그럴듯한 데이터를 만드는 결함이다.
GraphQlBatchChunker도 같은 이유로 청크 간 순서를 보존한다.
12.3 GraphQlBatchExecutor — 예산은 청크마다 앞뒤로 검사
The budget is checked before and after every chunk. Checking only before it meant the last chunk could run unbounded — a batch that started with a millisecond left was allowed to issue one [full chunk].
그리고 모든 청크가 같은 actor/tenant/deadline을 받는다 — 다른 스코프로 돈 청크는 한 논리적 배치 안에 두 tenant를 섞은 결과 집합을 만든다.
12.4 GraphQlDataLoaderRequestRegistry — 실행마다 새로
A new registry per execution, never a shared singleton. The request-scoped cache is the reason: it is keyed by entity id and holds whatever the loader returned for this caller, so sharing it across requests means one user's cached order is served to the next user who asks for the same id.
GraphQlDataLoaderFactory가 팩토리인 이유도 같다 — 실행마다 인스턴스를 만드는 것을 구조적으로
참으로 만든다. 누가 기억해야 하는 관례가 아니라.
12.5 GraphQlBatchPolicy / GraphQlBatchPolicyRegistry / GraphQlMissingKeyPolicy
배치 크기는 downstream이 실제로 받을 수 있는 것으로 묶인다 — JPA IN 리스트, Mongo $in,
HTTP 배치 엔드포인트의 문서화된 최대치. 무제한 배치는 우아하게 실패하지 않는다: DB가 플랜을
거부하거나 API가 요청 전체를 거부한다.
등록되지 않은 loader는 해석이 실패한다. 기본값으로 가지 않는다 — 배치 크기도, 타임아웃도, missing-key 의미도 없으니까. 등록이 loader 이름을 경계 있게 유지하기도 한다(메트릭 라벨).
missing key의 의미는 loader마다 다르다. 선택적 enrichment가 없는 건 정상이고, 필수 부모가 없는 건 데이터 불일치인데 null을 조용히 반환하면 그걸 숨긴다.
12.6 관측
GraphQlBatchObservation은 카운트와 경계 있는 loader 이름만. 배치 키는 흥미로운 값이고
정확히 기록되면 안 되는 값이다 — 객체 식별자고 메트릭 차원으로는 무제한이다.
GraphQlBatchLoadException / GraphQlBatchTimeoutException / GraphQlMissingKeyException 셋 다
키를 담지 않는다.
증거: runtime/GraphQlBatchLoaderRegistrationTest가 실제 graphql-java 실행 + Spring
BatchLoaderRegistry로 부모 50개 → downstream 호출 3회를 고정한다. 이게 wired 등급의 근거다.
13. pagination — 서명된 keyset 커서
17개 파일 37KB. 알고리즘적으로 가장 밀도 높은 패키지다.
13.1 왜 Base64로는 안 되나
Base64 is an encoding, not a protection: a plain Base64 cursor is a keyset the client can decode, edit and send back, which turns pagination into an arbitrary "start reading from here" parameter.
13.2 GraphQlCursorPayload — 7개 필드
public record GraphQlCursorPayload(
int version,
String queryProfile,
String direction, // FORWARD | BACKWARD
Map<String, String> keyset,
String filterFingerprint,
String tenantScope,
String keyId) {
각 필드가 하나의 공격을 막는다.
| 필드 | 없으면 |
|---|---|
queryProfile |
한 필터된 목록의 커서를 다른 목록에 replay — 서버가 전혀 다른 의미의 위치에서 이어감 |
filterFingerprint |
동일. 새 필터 하에서 접근 권한 없던 행을 받음 |
direction |
forward 커서를 backward로 replay → 페이지 경계를 반대로 걸음 |
tenantScope |
한 tenant의 커서를 다른 tenant가 replay → 시작할 수도 없던 스캔을 이어감 |
keyId |
회전 불가 |
version |
봉투 의미가 바뀌면 이미 발급된 커서를 해석할 수 없음 |
payload는 credential도 raw tenant도 담지 않는다. tenantScope는 호출자가 공급하는 불투명
fingerprint다 — 이 모듈이 context 타입에서 자유로워야 하고, 커서는 클라이언트가 들고 읽을 수
있는 값이기 때문이다.
13.3 GraphQlCursorScope — 네 개를 한 값으로 넘기는 이유
public record GraphQlCursorScope(
String queryProfile, String filterFingerprint, String direction, String tenantScope)
javadoc의 결정적 문장:
Verifying the signature proves the server issued the cursor. It does not prove the server issued it for this request. ... Passing the whole expectation as one value is what stops a new caller from checking three of the four and looking correct.
API 설계로 부분 검사를 불가능하게 만든다. 4개를 개별 인자로 받으면 3개만 넘기는 호출자가 컴파일되고 그럴듯해 보인다.
13.4 GraphQlCursorFraming — 이스케이프 대신 길이 프레이밍
v1 봉투는 |로 필드를, ;와 =로 쌍을 조인하고 keyset 값 안의 그 문자들을 이스케이프했다.
라운드트립하지 않았다:
- 디코더가 언이스케이프 전에 구분자로 split → sort 값 안의 이스케이프된 구분자가 필드를 찢음
- 세 필드(query profile, filter fingerprint, key id)는 아예 이스케이프되지 않았다
- 파이프 하나가 들어간 sort 값이면 정상 발급된 커서가 읽히지 않았다
지금은 길이 프레이밍이다: <length>:<value>. 읽는 쪽이 정확히 얼마나 읽을지 알고, 특별한 문자가
없다. 길이는 char 단위 — substring이 소비하는 단위 — 라서 surrogate pair도 프레임되고 그대로
읽힌다.
Framing removes the problem rather than escaping around it.
이스케이프 버그를 이스케이프로 고치지 않는다는 게 요점이다.
13.5 HmacGraphQlCursorCodec — 네 가지 방어
public static final int MAXIMUM_CURSOR_CHARS = 4096;
private static final String ALGORITHM = "HmacSHA256";
(1) 디코드 전 크기 검사.
if (cursor.length() > MAXIMUM_CURSOR_CHARS) throw new GraphQlCursorException("cursor is too large");
주석: "an oversized token is refused without allocating a copy of it."
(2) 상수 시간 비교.
if (!MessageDigest.isEqual(expectedSignature, presentedSignature))
일반 문자열 비교는 첫 다른 바이트의 위치를 흘린다.
(3) 서명 키는 코덱이 고른다, payload가 아니라.
Letting the payload name it meant a caller could pin every new cursor to a key that was being retired, which is rotation that never completes.
발급은 keyRing.activeKeyId()로 서명하고, 검증은 payload의 keyId로 키를 찾는다. 이
비대칭이 회전을 완성시킨다.
(4) 현재 버전만 발급, 과거 버전은 해석만.
if (payload.version() != GraphQlCursorVersion.CURRENT)
throw new GraphQlCursorException("only the current cursor version is issued");
디코드 쪽은 parseFramed(현재)와 parseLegacy(과거)로 분기한다. 읽기는 관대하고 쓰기는
엄격하다 — 이미 클라이언트 손에 있는 커서를 깨지 않으면서 새 커서는 하나의 형태로 수렴시킨다.
13.6 GraphQlCursorKeyRing — 두 키가 동시에 살아 있어야 한다
Rotation needs two keys live at once: cursors already issued under the old key must keep verifying while new ones are signed with the new key.
of(Map<String, byte[]> keys, String activeKeyId) / single(keyId, secret).
DEFAULT_KEY_ID = "cursor-key-1".
13.7 그런데 — 이게 요청 경로에 없다 (GQL-INT-003)
CLAUDE.md가 이 사실을 정직하게 적어 두었고, 나도 grep으로 확인했다.
backend.graphql.cursor.key-ids를 읽는 곳은 셋뿐이다:
| 읽는 곳 | 하는 일 |
|---|---|
GraphQlPlatformStartupValidator:42 |
production인데 비어 있으면 기동 거부 |
GraphQlPlatformActuatorEndpoint:62 |
리포트에 key id 노출 |
GraphQlPlatformConfigurationReport |
그 값을 담음 |
커서에 서명하는 코드는 그 설정을 하나도 읽지 않는다. GraphQlPolicyRequestPathTest가 그 사실을
고정한다:
.withPropertyValues("backend.graphql.cursor.key-ids=cursor-key-1")
...
assertThat(context).doesNotHaveBean(GraphQlCursorKeyRing.class);
CLAUDE.md의 표현이 정확하다:
결함은 "미완성"이 아니라 startup validator가 하나를 완성된 것처럼 보이게 만든다는 것이다.
프로덕션이 키 식별자를 요구하고, 운영자가 넣고, 엔드포인트가 "설정됨"이라고 확인해 주는데, 커서는 validator 메시지가 막는다고 말한 그대로 client-editable로 남는다.
닫으려면 배선이 아니라 설계 결정이 필요하다. GraphQlCursorKeyRing.of는
Map<String, byte[]>를 받고 설정 계약은 "키 자체는 설정에 나타나지 않는다"이므로, 키 재료가
어디서 오는지를 먼저 정해야 한다 (§28-P2).
13.8 나머지
GraphQlConnectionAssembler/GraphQlConnection/GraphQlEdge/GraphQlPageInfo— Relay Connection 조립.GraphQlKeysetWindow/GraphQlCursorKeyset— keyset 윈도우 표현.GraphQlConnectionPolicy/GraphQlConnectionRequest— 페이지 크기 상한 등.
14. mutation — 멱등성과 "비즈니스 결과는 에러가 아니다"
14개 파일 26KB.
14.1 GraphQlBusinessResult — 기대된 결과는 데이터다
Expected outcomes are data, not errors. A version conflict or a rejected state transition is something the client should render, and turning it into a GraphQL error both nulls the field and hides it among genuine failures.
두 가지 손해를 정확히 짚는다: 필드가 null이 되고, 진짜 실패들 사이에 섞인다.
GraphQlMutationPayload가 wire 형태다 — SUCCESS / CONFLICT / INVALID 상태와 안정 코드가
값과 함께 이동해서, 클라이언트가 payload 모양을 뜯어보거나 메시지를 매칭하지 않고 분기한다.
GraphQlMutationResultMapper가 매핑하고, 예상치 못한 실패는 이 매퍼에 도달하지 않는다 —
GraphQL 에러로 남는다.
14.2 GraphQlBatchMutationItemResult — 아이템별 결과
top-level 에러 하나가 모든 아이템의 결과를 버린다 — 성공했고 부수효과가 이미 일어난 것들까지.
그래서 index를 담아 클라이언트가 상관지을 수 있게 한다.
14.3 GraphQlMutationIdempotencyContext — 키 하나가 아니라 다섯 개의 스코프
actor × tenant × mutation coordinate × contract version × normalised input
| 축 | 없으면 |
|---|---|
| actor | 한 클라이언트의 재시도가 다른 클라이언트의 결과를 반환 |
| tenant | actor 정체성이 tenant를 함의하지 않음 — 같은 service account가 두 tenant에서 행동 가능 |
| mutation coordinate | 같은 키를 다른 mutation에 보내면 다른 오퍼레이션인데 같은 결과 |
| contract version | 계약이 바뀌면 같은 입력이 다른 의미 |
| normalised input | 같은 키에 다른 입력 = 두 개의 다른 부수효과 |
javadoc이 tenant를 왜 따로 두는지 명시한다: "Tenant is part of the scope because actor identity does not imply it."
14.4 GraphQlCanonicalInput — 지문 충돌 사건
이 leaf에서 가장 교육적인 버그다.
이전 정규형은 최상위 키를 정렬하고 key=value;로 조인했다. 두 개의 서로 다른 입력이 동일하게
정규화될 수 있었다:
{a: "b;c=d"} → a=b;c=d;
{a: "b", c: "d"} → a=b;c=d;
Two different requests sharing a fingerprint is an idempotency collision — the second one is answered with the first one's result.
§13.4의 커서 프레이밍 버그와 같은 부류다: 구분자를 값 안에서 구분할 수 없다. 그리고 같은 레포 안에서 두 번 일어났다. 이게 §27의 원칙 하나가 된다.
14.5 GraphQlIdempotencyKey — 양쪽이 다 묶인다
8~128자. 이유가 양방향이다:
- 너무 짧으면 무관한 요청 간에 충돌 → 한 호출자의 재시도가 다른 호출자의 결과를 반환
- 무제한이면 저장·로깅 문제
GraphQlIdempotencyConflictException은 키도 입력도 담지 않는다.
14.6 GraphQlMutationContractValidator — GraphQL의 serial 실행은 트랜잭션이 아니다
One mutation root field calls one Application use case, and several root fields in one document are several independent use cases. GraphQL executes them serially, which is easy to mistake for a shared transaction — it is not: the first can commit and the second fail.
이건 GraphQL을 쓰는 팀이 실제로 자주 착각하는 지점이고, 여기서는 기동 시점 계약 검증으로 막는다. 원자적이어야 하는 작업은 필요한 걸 전부 받는 하나의 mutation에 속한다.
그리고 §4.3의 unsupported.requestWideTransaction이 설정 축에서 같은 것을 막는다.
14.7 GraphQlExpectedVersion — 여기서 행동하지 않는다
낙관적 동시성은 저장소 보장이고, 충돌 후 재시도 결정은 use case에 속한다.
a transport-level retry would repeat a side effect the client never asked to repeat.
GraphQlMutationIdempotencyInterceptor는 HTTP 요청이 아니라 mutation 단위로 스코프한다 —
한 GraphQL 요청이 여러 root mutation을 나를 수 있고, 요청 전체를 하나의 멱등 단위로 다루면
전부 replay하거나 전부 안 하거나가 된다.
현재 등급은 modelled다 — 이 인터셉터를 참조하는 configuration이 없다.
15. fetch — over-fetch를 막는 프로파일
10개 파일 16KB. 작지만 아이디어가 선명하다.
15.1 문제
GraphQL selection 조합마다 쿼리를 유도하면 클라이언트가 요청한 모양마다 새 플랜이 생긴다. 리뷰 불가능하고 무제한이다.
15.2 해법 — 유한한 카탈로그 + 최소 커버 프로파일
GraphQlFetchProfile은 Application query profile 이름을 지목한다. EntityGraph, JPQL 문자열,
Mongo projection이 여기 나타나면 전송 계약에 저장소 세부가 들어오는 것 — 이 간접층이 막으려는
바로 그 결합이다.
GraphQlFetchProfileClassifier가 가장 작은 커버 프로파일을 고른다. id status selection은
full detail이 아니라 basic query를 로드한다.
커버하는 게 없으면 실패한다. 가장 큰 프로파일로 폴백하지 않는다:
a silent upgrade to "load everything" is exactly the behaviour the fetch profile indirection exists to prevent.
GraphQlUnmappedSelectionException은 타입과 선택된 필드 수만 보고하고 필드 이름은 안 한다.
15.3 정규화 — 별칭과 fragment
GraphQlSelectionSetView가 별칭을 실제 선택하는 필드로 줄이고 fragment를 확장한다. 안 그러면
total: amount와 amount가 같은 컬럼을 로드하는데 다른 프로파일로 분류된다.
GraphQlSelectionCoordinate는 필드 이름을 정규화·정렬하고, GraphQlSelectionSignature가 안정된
정체성 Type{field,field}를 만든다 — fetch 플랜 결정을 캐시하고 회귀 테스트로 고정할 수 있게.
15.4 등록 규칙
중복 이름과 타입당 두 번째 default는 등록 시점 거부다. 둘 중 하나라도 있으면 어느 쿼리가 도는지가 등록 순서에 달리고, 그건 리뷰어가 스키마에서 볼 수 없는 것이다.
GraphQlFetchProfileRule은 좌표별 오버라이드다 — "선택은 싸지만 조인이 비싼 필드"처럼 최소 커버가
답이 아닌 경우를, 프로파일 필드 목록에 인코딩하는 대신 명시한다.
16. error — 클라이언트가 볼 수 있는 것을 타입이 정한다
10개 파일 22KB.
16.1 GraphQlWireError — extensions는 생성 시점 allowlist
Everything an exception naturally carries — its class, its message, a SQL statement, a downstream URL, a provider body — is exactly what an attacker wants and a client cannot use, so the type refuses to carry it rather than relying on every call site to remember.
모든 호출부가 기억하는 대신 타입이 거부한다. 이게 §9.7에서 GraphQlWireErrors가 한 방향만
하는 이유와 짝을 이룬다.
16.2 GraphQlErrorCode / GraphQlErrorCategory — 닫힌 집합
GraphQlErrorCode: [A-Z][A-Z0-9_]{2,63}. 예외 클래스나 메시지에서 유도된 코드는 구현이 바뀔
때마다 바뀌고, 그러면서 내부를 흘린다.
GraphQlErrorCategory: 닫힌 집합. 클라이언트가 프로즈를 파싱하지 않고 재시도할지, 재인증할지,
요청을 고칠지, 비즈니스 메시지를 보여줄지 결정할 수 있게. 그리고 닫혀 있어서 메트릭 태그로
안전하다 — 열린 카테고리는 actionable하지도 bounded하지도 않다.
README에 Category → ErrorType 표가 있다.
16.3 GraphQlInternalErrorMasker — 기본은 마스킹
Exception messages are written for operators and routinely contain a SQL statement, a Mongo filter, a downstream URL with credentials, an internal hostname or a raw identifier.
메시지는 의도적으로 client-facing으로 만들어졌을 때만 공개된다. 그리고 client-facing으로 표시된 메시지에도 패턴 스캔이 2차 방어로 돈다.
GraphQlErrorContext가 execution ID를 나른다:
the point of masking is that the detail exists somewhere the client cannot see, not that it is discarded.
16.4 세 개의 resolver — 각자 다른 시점
| resolver | 언제 | 왜 별도인가 |
|---|---|---|
GraphQlRequestErrorMapper |
실행 전 | DataFetcherExceptionResolver가 이걸 절대 못 본다. parse/validation은 어떤 data fetcher도 호출되기 전이다. 응답 path도 없고 4xx다 |
GraphQlExceptionResolver |
resolver 실패 | 등록 안 된 실패는 opaque INTERNAL_ERROR + execution ID. 그 기본값이 중요한 절반 — 스택 어디서 새 예외 타입이 생겨도 자동으로 마스킹된다 |
GraphQlSubscriptionExceptionResolver |
스트리밍 시작 후 | 응답이 이미 커밋됨. 부분으로 만들 data도, 바꿀 status도 없다. 재구독이 가치 있는지를 말해야 한다 |
16.5 GraphQlFailureBoundary / GraphQlNullabilityContract — nullability는 실패 격리 결정이다
이 문서에서 GraphQL 특유의 통찰 중 가장 좋은 것.
A null on a non-null field does not stay local: the error climbs to the nearest nullable ancestor, nulling everything on the way. So nullability is a failure-isolation decision, not a data-shape one — marking an enrichment field non-null can delete an entire order from the response when an unrelated service is down.
그래서 GraphQlNullabilityContract가 각 필드의 선언된 실패 동작을 기록한다:
Making a field non-null is a promise that it can always be provided — including when a dependency is down and when authorization redacts it.
!를 붙이는 게 오타가 아니라 리뷰 가능한 결정이 된다. GraphQlNullPropagationContractTest가
golden test로 고정한다.
17. schema / scalar — 스키마 조립과 스칼라
17.1 GraphQlSchemaAssembler — 결정적 순서
fragment는 소유 모듈과 경로로 정렬한 뒤 파싱된다. 파일시스템/classpath 열거 순서는 기계나 패키징 형식에 따라 안정적이지 않고, 불안정한 순서는 스키마 해시를 움직이고 충돌하는 두 선언 중 어느 쪽이 이기는지를 바꾼다.
GraphQlSchemaAssemblyResult가 "merge가 안 던졌다"보다 엄격하다. 중복 타입/필드/디렉티브/스칼라
선언은 선언한 리소스와 함께 거부된다 — 안 그러면 merge 결과가 조용히 승자를 고르고 외부
계약이 classpath 순서에 의존하게 된다.
canonical SDL은 정렬된 fragment들의 파싱된 AST를 프린트해서 만든다. 텍스트 조인이 아니라.
GraphQlSchemaResource / GraphQlSchemaOwnership이 모든 좌표를 선언한 파일로 되짚을 수 있게
한다. 중복 선언, unmapped 필드, breaking change — 모든 진단이 merge된 전체가 아니라 파일을 지목한다.
발견은 승인된 스키마 경로 아래의 .graphqls / .gqls로 제한된다.
17.2 GraphQlSchemaContract — 해시만으로는 부족하다
네 부분이 전부 필수다:
schema hash × breaking-change policy version × scalar manifest version × directive manifest version
Identical SDL bytes can still mean a different external contract if the breaking-change policy, a scalar's coercion or a directive's meaning moved underneath it.
matches는 넷 다 비교하고, sameSchemaBytes는 캐시 키잉용으로 제공되지만 명시적으로 호환성
판정이 아니다.
GraphQlSchemaHash는 소문자 hex 64자로 고정 — 같은 스키마가 두 개의 다른 키를 만들지 않게.
쓰임: preparsed 캐시 키, persisted operation의 승인 스키마 바인딩, actuator 리포트.
17.3 GraphQlMappingInspectionGate — 리포트를 게이트로
Spring의 schema mapping inspection은 정보로 보고한다. Stable 프로파일은 그걸 기동 실패로 승격한다.
A silently unmapped field returns
nullat runtime instead of failing at boot.
모든 controller/scalar/type-resolver wiring이 등록된 뒤에 돈다 — 더 일찍 돌면 아직 기여되지 않은 resolver를 없다고 보고한다.
Local 정책에서는 advisory인데 예외 두 개: forbidden scalar와 unknown resolver. 둘은 어떤 프로파일에서도 런타임에 동작할 수 없기 때문이다.
17.4 @oneOf — 스펙 규칙을 두 곳에서 강제
GraphQlOneOfSchemaGate(기동)와 GraphQlOneOfInputValidator(런타임)가 짝이다.
GraphQlOneOfPolicy가 규칙을 한 번만 서술해서 둘이 의견이 갈리지 않게 한다.
스키마 게이트: 모든 멤버가 nullable이어야 하고 default가 없어야 한다.
"exactly one is supplied" would otherwise be undecidable: a non-null member is always supplied, and a member with a default is always present after coercion.
런타임 validator: 0개도 2개도 거부. 0개면 resolver가 추측해야 하고, 2개면 resolver의 필드 검사 순서가 어느 selector가 이기는지 결정한다 — 에러가 아니라 두 mutation 간의 일관성 없는 동작으로 나타난다.
진단은 멤버 이름만 담는다. 값은 절대 — "a rejected selector can hold an account number."
17.5 GraphQlScalarManifest — 스칼라도 등급이 있다
중복 스칼라 이름은 생성 시점 거부 (두 코어션 → wiring 순서가 승자를 고름).
RESTRICTED 등급 스칼라(실무에서는 JSON)는 나타날 수 있는 좌표를 열거해야 한다. 안 그러면
자유형 JSON 스칼라가 어디서나 쓸 수 있는 스키마 검증 우회가 된다.
GraphQlScalarDefinition은 Upload를 생성 시점에 거부한다. 플랫폼은 GraphQL multipart
upload를 구현하지 않고 바이너리 생명주기는 Fileserver capability 소유다 — Upload 스칼라는
반쯤 동작하는 약속밖에 될 수 없다.
GraphQlScalarWiringConfigurer는 Spring의 RuntimeWiringConfigurer를 쓴다 — raw code registry를
애플리케이션 코드에 노출하는 대신. wiring은 매니페스트에서 구동되므로 스칼라가 선언·등급 부여·
(restricted면) 좌표 제한 없이는 스키마에 도달할 수 없다.
17.6 여섯 개의 커스텀 스칼라
| 스칼라 | 결정 | 왜 |
|---|---|---|
BigDecimalScalar |
문자열로 직렬화, binary float 거부 | 0.1은 정확한 double 표현이 없다. Double 수용은 클라이언트-DB 사이에서 금액을 조용히 바꾼다. NaN/Infinity도 거부 |
InstantScalar |
출력은 항상 UTC Z |
두 서버가 다른 존에서 같은 instant를 다르게 직렬화하지 못하게. 입력의 offset은 수용·변환 (올바른 클라이언트를 깨지 않으려고) |
DateScalar |
Instant와 엄격 분리 |
"the invoice is dated a day earlier in Sydney" 부류의 결함 |
LongScalar |
기본 범위 = IEEE-754 double-safe 정수 범위 | JavaScript 클라이언트가 ±2^53−1 넘으면 조용히 반올림한다. 64비트 전체는 FULL_RANGE_*로 명시 opt-in |
UuidScalar |
canonical 8-4-4-4-12만 | UUID.fromString은 관대해서 "1-1-1-1-1"도 받는다 → wire 계약이 JDK 특이사항에 의존하고 두 문자열이 같은 식별자를 지시 |
| — | 코어션 실패는 값을 에코하지 않음 | 거부된 입력은 클라이언트 데이터를 담을 수 있음 |
GraphQlDecimalBounds가 특히 좋다. 네 개의 경계를 두는 이유:
1E+1000000is eleven characters, parses instantly, and holds a precision of one — and thentoPlainString()materialises a million and one characters. Bounding the input text alone therefore proves nothing about the output; the output length is its own bound.
원래 리뷰 GQL-022("scalar input/output limit이 대칭이 아니고 작은 입력이 큰 출력을 만들 수 있다")에 대한 답이다.
18. compat — 스키마 호환성 37종
11개 파일 62KB. runtime, autoconfigure 다음으로 큰 패키지고, 대부분이 GraphQlChangeKind다.
18.1 두 개의 청중을 따로 센다
이게 이 패키지의 핵심 아이디어다.
public enum GraphQlCompatibilityImpact { COMPATIBLE, REVIEW_REQUIRED, BREAKING }
그리고 모든 change kind가 wire impact와 generated-client impact를 따로 갖는다.
Adding an enum value or a union member is additive on the wire, yet it can break a generated client whose
switchis exhaustive — reporting a single "compatible" verdict would hide that.
예시:
| change kind | wire | generated client | 이유 |
|---|---|---|---|
TYPE_ADDED |
COMPATIBLE | COMPATIBLE | 추가적 |
OUTPUT_FIELD_ADDED_NON_NULL |
COMPATIBLE | REVIEW_REQUIRED | 생성 모델이 바뀌고 데이터 보장이 필요 |
OUTPUT_FIELD_NULLABILITY_STRENGTHENED (T → T!) |
COMPATIBLE | REVIEW_REQUIRED | 계약은 강해지는데 생성 소스 타입이 바뀜 |
OUTPUT_FIELD_NULLABILITY_RELAXED (T! → T) |
BREAKING | BREAKING | 클라이언트가 의존하는 보장을 철회 |
ARGUMENT_ADDED_REQUIRED |
BREAKING | BREAKING | 기존 호출이 전부 빠뜨림 |
ARGUMENT_ADDED_OPTIONAL |
COMPATIBLE | COMPATIBLE | 추가적 |
impact가 kind에 붙어 있다는 게 설계 결정이다:
The impacts live on the kind rather than being decided at each call site, so the compatibility table is stated once and cannot drift between the comparator, the policy and the release gate.
총 37종이다. 각각이 세 번째 필드로 근거 문장을 들고 있다.
18.2 GraphQlSchemaComparator — AST 비교
SDL 문자열 diff가 아니라 AST를 비교하고 결과를 결정적으로 정렬한다. 원래 리뷰가 "유지할 설계"로 꼽은 항목이었다.
원래 리뷰 GQL-015가 *"comparator가 kind/default/extension/applied directive를 놓친다"*였고, 지금 37종 목록이 그 답이다.
18.3 GraphQlDeprecationGate / GraphQlRemovalRequest / GraphQlRemovalDecision / GraphQlSchemaUsage / GraphQlClientOwnerApproval
제거의 전제조건 체인:
GraphQlSchemaUsage— 누가 이 좌표를 쓰는가GraphQlDeprecationGate— deprecation 기간을 지켰는가GraphQlClientOwnerApproval— 클라이언트 소유자가 승인했는가GraphQlRemovalDecision— 그래서 제거 가능한가
GraphQlCompatibilityPolicy / GraphQlCompatibilityReport가 정책과 결과를 담는다.
19. observation — 카디널리티는 타입으로 지켜지지 않는다
9개 파일 23KB.
19.1 GraphQlMetricCardinalityPolicy — allowlist, denylist 아님
public static final Set<String> ALLOWED = Set.of(
"graphql.operation.name", "graphql.operation.type", "graphql.client.profile",
"graphql.persisted", "graphql.schema.coordinate", "graphql.dataloader.name",
"graphql.outcome", "graphql.complexity.bucket", "graphql.depth.bucket", "error.type");
public static final List<String> FORBIDDEN = List.of(
"graphql.document", "graphql.query", "graphql.variables", "graphql.cursor",
"graphql.object.id", "user.id", "tenant.id", "authorization",
"connection_init.payload", "graphql.field.path");
Every value that is interesting to a debugger — the document, the variables, the object id, the cursor — is also unbounded, and one unbounded label is enough to make a metrics backend unusable.
FORBIDDEN이 따로 있는 건 allowlist가 이미 충분한데도 의도를 문서화하기 위해서다. 그리고
GraphQlSensitiveAttributeFilter가 키 이름으로 필터링하며 credential처럼 생긴 키도 떨어뜨린다.
19.2 GraphQlOperationNameCardinality — 정규식은 카디널리티를 묶지 않는다
이 패키지에서 가장 좋은 통찰.
GraphQlOperationNamebounds an operation name's syntax and length, which is a different property from bounding how many distinct ones exist. A client is free to sendQuery0000001,Query0000002and so on indefinitely: every one is valid, and every one used to become its own time series. That is a metrics backend brought down by a well-formed client, and the tag that did it looked bounded because a regular expression was checking it.
형태 검사가 카디널리티 검사처럼 보인다는 게 함정이다. 해법: 배포의 레지스트리에서 온 이름만 라벨이 되고, 나머지는 하나의 bucket으로 접힌다.
원래 리뷰 GQL-023이 이거였고, 증거는 runtime/GraphQlRequestObservationConventionAdapterTest가
실제 MeterRegistry에 임의 이름 10,000개 → series 1개를 고정하는 것이다.
19.3 GraphQlResolverObservationConvention — 좌표지 path가 아니다
Tagged by schema coordinate, not by response path. The coordinate is a static position in the schema; a response path contains list indices and therefore grows without bound with the data.
orders.3.items.17.name은 데이터에 따라 무제한이다. Order.name은 유한하다.
19.4 GraphQlDataLoaderObservationConvention — 버킷
배치 크기와 캐시 효과는 버킷 값으로 기록된다. 유용한 질문은 "배칭이 동작하나"이고, 정확한 크기는 서로 다른 배치 크기마다 series를 만든다. 키 자체는 기록 안 함.
GraphQlRequestObservationConvention도 depth와 complexity를 버킷으로. 정확한 숫자는 사실상
연속값이고 요청마다 새 series를 만든다.
19.5 GraphQlObservationNames — 재발명하지 않는다
Spring for GraphQL이 이미 내는 관측 이름을 쓴다. 플랫폼의 기여는 그 위의 이름과 카디널리티 규율이지, 모든 요청을 이중 계산할 두 번째 계측층이 아니다.
19.6 GraphQlProfilerAccessPolicy — 프로파일러는 응답 extension이 아니다
GraphQL Java 프로파일러는 필드별 타이밍과 DataLoader 동작을 보고한다 — 로컬과 운영 평면에서는 진짜 유용하고, 클라이언트에 반환하면 백엔드 내부의 정밀한 지도다.
20. architecture — resolver가 넘으면 안 되는 선
9개 파일 38KB.
20.1 GraphQlTypeGraph — erasure가 규칙을 무력화했다
이 패키지의 핵심 수정.
경계 규칙들이 Method#getReturnType과 Parameter#getType을 봤는데, 그건 컨테이너로 erasure
된다:
| 선언 | 보이는 것 |
|---|---|
List<OrderEntity> |
List |
Mono<OrderEntity> |
Mono |
Optional<OrderRepository> |
Optional |
Every one of those passed a rule whose whole purpose was to notice the type inside.
GraphQlTypeGraph가 제네릭 포함 실제로 도달하는 모든 클래스를 계산한다.
20.2 GraphQlAsyncReturnShape — Mono는 Publisher다
규칙이 원래 subscription 밖의 모든 Publisher를 거부했다. 그런데 Mono<T>도 Publisher다.
그래서 Spring for GraphQL이 지원하고 문서화하는 Mono<OrderView> order()가 플랫폼 자체
경계 검사에 거부됐다.
지금은 하나를 내는가 여럿을 내는가로 구분한다. query는 비동기로 완료해도 되고, 하면 안 되는 건 스트림이다.
원래 리뷰 GQL-008 ("valid reactive query도 거부한다")의 답.
20.3 반환/입력 타입 정책
GraphQlReturnTypePolicy — JPA entity나 Mongo document 반환 금지:
- 컬럼 rename이 breaking API change가 됨
- lazy association이 N+1이나 직렬화 중 예외가 됨
- 공개될 의도가 없던 필드가 기본으로 샘
무제한 Map도 거부 — 타입 계약을 우회한다.
GraphQlInputTypePolicy — 위의 쓰기 쪽 거울:
it silently exposes every persistence field as a writable API field, including the ones — version, tenant, audit columns, internal flags — that a client must never set.
20.4 GraphQlControllerTransactionRule — resolver에 트랜잭션 금지
A transaction on a resolver would span the whole field's execution — including DataLoader dispatches and downstream calls that have no business inside a database transaction — and it would put the boundary in transport code where it cannot be reasoned about with the use case.
architecture/fixture/violating/Transactional.java라는 테스트 fixture가 있다 — 어노테이션을
합성해서 negative fixture를 만든다.
20.5 GraphQlResolverBoundaryRules / GraphQlTransportTypeRules / GraphQlControllerInspector
- resolver는 Application use case와 DTO 매퍼에 의존 가능. repository /
EntityManager/MongoTemplate직접 접근 불가. - resolver 시그니처는 transport 타입만 — DTO, read model, connection, mutation payload, 그리고 subscription에서의 publisher.
- raw
DataFetcher구현은 인프라 패키지로 제한. annotated controller가 주류 API고, 손으로 쓴 fetcher는 인자 바인딩·컨텍스트·경계를 우회한다. GraphQlControllerInspector는 기동 시점에 돌고 스키마 좌표와 Java 메서드를 함께 보고한다.
negative/positive fixture가 짝으로 있다:
fixture/compliant/{FindOrderUseCase, OrderGraphQlController, OrderView} vs
fixture/violating/{LeakyGraphQlController, OrderRepository, Transactional}.
21. release — 증거 없이는 릴리스 없다
9개 파일 18KB.
21.1 GraphQlReleaseEvidence — 다섯 종류, 전부 필수
Five independent kinds, all mandatory. They fail in different ways and none substitutes for another: a passing contract suite says nothing about behaviour under load, and a passing load test says nothing about what happens when a dependency stops responding.
| 증거 | 무엇 |
|---|---|
schemaPassed |
스키마 조립·매핑·호환성 게이트 |
contractsPassed |
transport / security / DataLoader / pagination 계약 스위트 |
performancePassed |
부하 시나리오 |
faultPassed |
장애·취소 시나리오 |
| (+ 다섯 번째) | compatibility matrix |
21.2 GraphQlPerformanceScenario / GraphQlFaultScenario — 카탈로그
성능 시나리오가 각각 다른 한계를 자극한다:
A deep query and a wide alias query stress different budgets; pool saturation and virtual-thread saturation fail differently; and event-loop blocking only shows up under concurrency. Running one and calling it "load tested" is how the others reach production untested.
장애 시나리오 중 cancellation leak이 가장 자주 건너뛰고 가장 자주 깨진다:
a timeout that releases the caller while the database statement and the HTTP call keep running turns a slow dependency into an exhausted pool, and no functional test detects it.
21.3 GraphQlReleaseOverride — 만료 없는 예외는 꺼진 게이트다
An override without an expiry is simply a permanently disabled gate, which is how a gate stops meaning anything.
모든 필드 필수: approver, reason, waivedEvidence, expiresAt.
- approver 없으면 질문할 수 없고
- reason 없으면 리뷰할 수 없고
- expiry 없으면 제거되지 않는다
21.4 GraphQlCompatibilityMatrix — BOM이 SSOT
Spring Boot BOM이 Spring for GraphQL과 GraphQL Java 짝의 진실 원천이다. 둘 중 하나의 독립 override는 의도적 호환성 레인에서만 허용 — 조용히 하나를 고정하는 게 아무도 테스트하지 않은 조합에 배포가 도달하는 방식이다.
21.5 GraphQlStableCapabilityManifest — 지원 안 하는 것도 결정이다
Written down so "unsupported" is a decision with a reason rather than a gap someone might fill by accident. Every entry in
UNSUPPORTEDhas an alternative in the design: uploads go through the Fileserver, atomic multi-step work goes through one mutation use case, and cross-request caching goes through the cache capability with its own key model.
§4.3의 unsupported.* 설정 플래그가 이것의 런타임 짝이다.
21.6 GraphQlReleaseReportWriter — 기계 판독 가능하게
파이프라인이 게이트로 쓸 수 있고 나중 감사가 무엇이 실제로 검증됐는지 읽을 수 있게. 리포트는 verdict / capability 이름 / 프레임워크 버전을 담고 — 스키마, persisted 문서, 키는 절대 안 담는다.
22. 등급 어휘와 advanced/bootstrap
22.1 CLAUDE.md의 네 등급 — "구현됐다"를 쪼갠다
이 leaf가 가장 잘한 것 하나를 꼽으라면 이거다.
"구현됐다"는 네 가지 서로 다른 사실을 한 단어로 덮는다.
| 등급 | 의미 |
|---|---|
modelled |
정책·계약 객체가 있고 단위 테스트가 있다. 요청 경로에는 없다. |
wired |
Spring 실행 경로에 연결돼 있고, 실제 endpoint 테스트가 그 사실을 증명한다. |
integration-verified |
실제 외부 시스템과의 통합 증거가 있다. |
production-verified |
실부하·장애 시나리오 증거가 있다. |
그리고 현재 등급보다 높게 표현하지 않는다는 규칙이 붙어 있다.
현재 상태 (CLAUDE.md 기준, 증거 테스트명 포함):
| Capability | 등급 | 증거 |
|---|---|---|
| 실행 파이프라인 / 인가 / cost 예산 | wired |
runtime/GraphQlPlatformExecutionPathTest (random-port, 거부 시 resolver 호출 0회) |
| depth/complexity 제한 | wired |
같은 테스트 |
| preparsed document cache | wired |
GraphQlPreparsedDocumentAdapter + 캐시 hit 케이스 |
| 커스텀 scalar | wired |
같은 테스트의 coercion 케이스 |
| 요청 크기 / Accept 협상 | wired |
GraphQlRequestBoundsTest, GraphQlAcceptNegotiationTest |
| 관측 tag cardinality | wired |
GraphQlObservationWiringTest + 실제 MeterRegistry에 임의 이름 10,000개 → series 1개 |
| DataLoader / batching | wired |
GraphQlBatchLoaderRegistrationTest (50 parent → 3 downstream) |
| object 인가 | modelled |
실행 경로에 연결하는 configuration 없음 |
| cursor 서명 | modelled |
auto-configuration이 HmacGraphQlCursorCodec도 GraphQlCursorKeyRing도 만들지 않음 — GraphQlPolicyRequestPathTest가 고정 |
| mutation 멱등성 | modelled |
인터셉터를 참조하는 configuration 없음 |
| persisted operation | modelled |
중립 OperationalRecordStorePort 기반 레지스트리 + 방향성 테스트. durable 구현 미제공 |
| subscription / WebSocket / SSE / RSocket | modelled |
정책·상태기계 단위 테스트만. Spring transport handler 없음 — 그래서 타입 이름도 *Admission이다 |
| federation / incremental / codegen / compat | modelled |
단위 테스트만 |
| 실부하·장애 | 미달성 | graphqlPerformanceTest 레인이 자리를 예약 |
타입 이름이 등급을 반영한다는 게 특히 좋다. GraphQlWebSocketAdmission은 원래
GraphQlWebSocketHandlerFactory였는데, 그 이름은 Spring WebSocketHandler를 약속했다. 실제로
반환하는 건 소켓도 I/O도 없는 상태 기계다.
An adopter who wired the old name where Spring expected a handler found a name that fit and behaviour that did not.
22.2 advanced/bootstrap — 하나의 문
6개 파일 9.5KB.
GraphQlAdvancedCapability — 열거된 capability. "enumerated so a capability cannot activate
merely by being on the classpath."
GraphQlAdvancedCapabilityGrade — 두 등급, 그리고 등급이 활성화 요건을 정한다:
| 등급 | 필요한 것 |
|---|---|
| Advanced | feature flag |
| Experimental | feature flag + production에서는 승인 프로파일 |
because the evidence for it has not been collected yet and a config change alone should not put it in front of users.
Experimental: RSocket, incremental delivery, HTTP GET draft.
GraphQlAdvancedFeatureFlags — 기본은 전부 off. "An Advanced capability that arrived because a
dependency was added is exactly what the Stable/Advanced split exists to prevent."
GraphQlAdvancedModuleGuard — 모든 Advanced capability가 통과하는 단 하나의 게이트.
One place to check means a capability cannot be half-enabled.
GraphQlAdvancedDependencyRules — Advanced가 Stable을 소비한다. 반대 방향 금지.
If a Stable module depended on an Advanced one, every Stable deployment would carry the Advanced capability's code and configuration surface — and the feature flag would be the only thing standing between an ordinary service and a subscription runtime.
이게 §2.4 규칙 (a)의 근거다.
GraphQlAdvancedCapabilityDisabledException — 크게 실패한다. 조용히 열화하지 않는다:
"a subscription endpoint that silently does nothing because its flag is off is far harder to
diagnose than one that refuses to start."
23. advanced/* 17개 패키지 순회
135개 파일 233KB. 순서는 "이 레포에 얼마나 깊이 들어와 있는가" 순으로 잡았다.
23.1 persisted (15) — 승인된 오퍼레이션 레지스트리
의존 방향 문제를 푸는 방식이 이 leaf 전체에서 가장 정교하다.
문제: persisted operation의 durable 저장은 인프라에 살아야 하는데, 인프라가 인바운드 전송의
타입을 구현하면 안 된다. Postgres 어댑터가 GraphQlPersistedOperationRegistry를 구현하면
의존이 DB에서 GraphQL 경계로 향한다.
해법 (3단):
GraphQlPersistedOperationRegistry (SPI, graphql leaf 소유)
└ OperationalStoreGraphQlPersistedOperationRegistry ← 실제 구현
└ dev.caskeleton.shared.opstore.OperationalRecordStorePort (shared-contract, 중립)
↑
└ Postgres/Redis 어댑터가 이걸 구현 — GraphQL 타입을 절대 이름짓지 않음
GraphQlPersistedOperationRecordMapping이 유일하게 양쪽을 아는 클래스다:
So the store speaks
OperationalRecordand knows nothing about GraphQL, and this class is the only place that knows [both].
상태 기계:
GraphQlPersistedOperationStatus: ACTIVE / DEPRECATED / BLOCKED
GraphQlPersistedOperationTransition: ... UNBLOCK
BLOCKED가 boolean이 아니라 상태인 이유: 인시던트 중에 비싼 오퍼레이션 하나를 재배포 없이,
다른 사람의 엔드포인트를 내리지 않고 즉시 멈출 수 있어야 한다.
그리고 전이 테이블이 있는 이유가 지독하다:
Without a table,
updateStatusaccepted anything, and the sequence that mattered wasBLOCKED → DEPRECATED: an operation stopped during an incident could be made executable again by a status change that read like a documentation update.
BLOCKED는 UNBLOCK을 통해서만 벗어날 수 있는 terminal이다.
CAS: OperationalStoreGraphQlPersistedOperationRegistry의 등록과 전이는 읽은 버전에 대한
compare-and-set이다.
요청 시점 검사 (GraphQlPersistedOperationPolicy):
Being in the registry is not permission to run.
- 여전히 active인가
- 이 client에 속하는가
- 배포된 스키마와 매치하는가 (
GraphQlPersistedOperation이 승인 시점 스키마 해시를 들고 있다) - 자기 한계와 client 한계 중 더 엄격한 쪽 안에 있는가
그리고 이 중 어느 것도 인가가 아니다. 인가는 그 다음에 actor에 대해 일어난다.
GraphQlPersistedOperationRequest.suppliedDocumentHash — 일부 클라이언트는 id 와 문서를
둘 다 보낸다. 그럴 때 둘이 일치해야 한다 — "otherwise a caller could quote an approved id while
executing a document of their own."
GraphQlPersistedOperationLookup — 모르는 id와 blocked id가 같은 메시지로 거부된다.
응답이 어떤 id가 존재하는지 알려주지 않게.
GraphQlPersistedPreparsedBridge — 레지스트리와 preparsed 캐시를 합치면 안 되는 이유:
레지스트리는 문서가 돌아도 되는지를 정하고 캐시는 이미 돼도 되는 문서의 재파싱을 피할 뿐이다.
그래서 블록은 매 요청 검사지 캐시 채울 때 한 번이 아니다.
인터셉터는 파싱 전에 돈다 — id가 있으면 레지스트리가 문서 공급원이니까 (§8.1의
PERSISTED_LOOKUP → PARSE_VALIDATE 제약).
23.2 admin (10) — 운영 평면 (G4)
persisted의 짝. 여기 javadoc들이 이 leaf에서 가장 신랄하다.
GraphQlAdminPrincipal — 문자열은 증거가 아니다:
The admin service used to take the operator as a bare
Stringand check it against an allowlist. A string is not evidence: any caller that could reach the service could name any operator on the list, so the allowlist described who may administer the registry while proving nothing about who actually did. The audit trail then recorded that name as fact.
지금은 전송만이 이걸 생성할 수 있고, 자격증명이 request path에서 왔는지도 기록한다. "That check used to exist as a method nobody called."
GraphQlPersistedOperationAdminPort — 변경과 감사를 하나의 durable 단위로:
The two used to be separate steps: the registry was mutated, then an entry was appended to an
ArrayListfield. A crash between them left a change nobody could account for, a failure in the append left a change with no record, and the list itself was not thread-safe, so two concurrent administrators could lose an entry outright. An audit trail with any of those properties is worse than none, because it is trusted.
마지막 문장이 이 문서 전체에서 가장 인용할 만하다: 믿어지기 때문에, 그런 성질을 가진 감사 기록은 없느니만 못하다.
GraphQlPersistedOperationAdminService — 모든 명령이 저장된 오퍼레이션을 반환하고, 적용할 수
없는 명령은 던진다:
the audit trail records changes that happened rather than changes that were attempted — the previous service wrote
ABSENT -> BLOCKEDfor operations that did not exist.
GraphQlPersistedOperationAdminAuthorization — request path와 별개의 권한:
Application credentials are held by every running instance and reachable from any resolver; if one of them could register or block an operation, a compromised request path could rewrite what the whole platform is willing to execute.
GraphQlPersistedOperationRemovalGate / GraphQlPersistedOperationUsage — "지금 트래픽 없음"과
"아무도 안 씀"은 다르다. 월간 리포트나 느린 롤아웃 중인 모바일 빌드는 몇 주 조용하다가 다시 보낸다.
usage는 카운트와 마지막 사용 시각만, 호출자는 절대 — "the identity of who ran an operation is
not needed to decide whether it is still in use."
GraphQlPersistedOperationAudit — operator, reason, before/after, time, trace. 변수와
자격증명은 절대 — 그래야 감사 기록 자체가 보관해도 안전하다.
GraphQlPersistedOperationAdminDeniedException — operator 정체성을 안 담는다. 거부가 관리자
목록 열거에 쓰이지 않게.
23.3 subscription (16) — 장수명 스트림
GraphQlSlowConsumerPolicy — 기본은 종료:
GraphQL has no replay: a dropped event is simply gone, and the client has no way to learn that it missed one. Dropping is only acceptable where losing an event is genuinely harmless — telemetry, presence — and that has to be an explicit choice.
GraphQlSubscriptionBufferPolicy — 이벤트 수와 바이트 둘 다:
a slow consumer is a memory leak with a schedule: the source keeps producing, the socket keeps not draining, and the buffer grows until the process dies.
GraphQlSubscriptionLease — register/deregister 짝 대신 lease:
a stream that ended on an error path without its matching deregister left the count permanently above zero, and the node then drained for the full timeout on every shutdown while reporting subscriptions that no longer existed. Releasing twice is just as damaging in the other direction — it decrements someone else's subscription.
정확히 한 번 해제된다.
GraphQlSubscriptionDrainCoordinator — 우아한 종료:
dropping thousands of subscriptions at once produces a reconnect storm against an instance that is already leaving.
새 subscription은 즉시 거부, 기존은 유한한 창, 창에는 deadline (멈춘 스트림이 종료를 무한정 지연시키지 못하게). phase / count / drain 시작이 하나의 불변 값으로 함께 움직인다 — 세 필드를 따로 두면 그 사이 상태가 관찰된다.
GraphQlSubscriptionDrainPhase — boolean이 아니라 3단계. "'draining'과 'finished draining'은
종료 질문에 대한 다른 답이고 boolean은 하나만 나를 수 있다." 전이는 일방향.
GraphQlSubscriptionOrderingProfile / GraphQlSubscriptionExecutionPolicy — 정직한 트레이드오프:
Subscription items complete asynchronously, so without buffering they can arrive out of source order — fine for a live counter, wrong for a state machine. Preserving order costs head-of-line blocking and memory, which is why the choice is per subscription.
keepSourceOrder는 GraphQL Java의 subscription ordering 플래그에 매핑된다 — 전송이 척하는 게
아니라 엔진이 버퍼링한다.
GraphQlSubscriptionEvent — 브로커 메시지를 forward하지 않고 GraphQL DTO로 번역한다:
A messaging schema and a GraphQL schema evolve on different timelines and for different audiences; publishing the raw message would make every internal event-shape change a public API change.
GraphQlSubscriptionSource — Publisher인 이유는 취소가 소스까지 전파되게 하려고.
그리고 명시적으로: durability / acknowledgement / replay는 messaging 플랫폼 소관이고 GraphQL
subscription이 재구현하지 않는다.
GraphQlSubscriptionMetrics — 종료는 reason별로(작은 enum). connection/operation 정체성은
차원이 아니다 — 클라이언트마다 하나씩이라 라벨이 되면 연결마다 series가 생긴다.
GraphQlSubscriptionState — "connected"가 여러 상황을 덮는다: 인증 안 한 소켓, 인증했지만
구독 안 한 소켓, 스트리밍 중인 소켓. 운영 질문(몇 개가 draining인가, 몇 개가 인증을 못 끝냈나)에
답하려면 구분돼야 한다.
23.4 websocket (7) — graphql-transport-ws만
GraphQlWebSocketProtocol — 오직 graphql-transport-ws. 낡은 subscriptions-transport-ws는
superseded이고 유지보수되지 않으며, 광고하는 건 아무도 고치지 않는 프로토콜에 플랫폼을
묶는 것이다.
GraphQlWebSocketAdmission — §22.1에서 다룬 이름 변경.
GraphQlWebSocketHandlerFactory — 팩토리인 이유는 입력이 셋이고 그중 하나만 플래그여서다:
capability enabled + properties가 경계 지을 수 있는 연결을 서술 + admission policy 존재.
A configuration class that checked only the flag would produce a handler with an unbounded connection lifetime whenever a deployment forgot the rest, and an unbounded WebSocket is a connection slot held by whoever opens [it].
GraphQlWebSocketProperties / GraphQlWebSocketLifecycle —
connectionInitTimeout: 인증하지 않는 연결이 슬롯을 점유하지 못하게. "a client can open connections and never authenticate, holding server slots at no cost to itself."idleTimeout,maximumAge: 자격증명보다 며칠 오래 사는 연결에 재인증을 강제.
GraphQlWebSocketConnectionId — 로그 상관과 drain 회계용. 메트릭 라벨로는 절대.
GraphQlWebSocketProtocolException — connection_init payload를 안 담는다. 거기가 자격증명이
도착하는 곳이다.
23.5 security (7, advanced) — 장수명 연결의 인증
GraphQlWebSocketAuthenticationInterceptor — 자격증명은 connection_init payload로 오고
절대 로깅·측정되지 않는다. 그리고 정체성이 pin된다:
the connection ends when the credential expires or is revoked, rather than being silently refreshed — a silent refresh would mean the server decided the client should stay connected after the credential it presented stopped being valid.
GraphQlWebSocketPrincipal — actor/tenant fingerprint만, 연결 수명 내내 pin.
allowing the tenant to change per operation on one connection would make the authenticated context negotiable by the client.
GraphQlWebSocketCredentialExpiry + GraphQlWebSocketRevocationSignal — 둘 다 필요하다:
Expiry alone is not enough: a credential valid for another hour is still valid after the user's access is removed, and a live subscription would keep delivering until it expired.
GraphQlSubscriptionAuthorizationPolicy — 구독 시점 검사만으로 충분한 건 소유권이 바뀔 수 없는
스트림뿐이다. 바뀔 수 있는 곳(공유 문서, 누군가 제외될 수 있는 주문)에서는 이벤트마다 검사 —
연결이 이벤트 생성 몇 시간 전에 인가됐을 수 있으니까.
GraphQlWebSocketCloseReason — 경계 있는 집합. 메트릭 라벨로 안전하고, 클라이언트가
"자격증명 만료, 재연결"과 "서버가 내려감, 재시도"를 구분할 수 있다.
23.6 replay (8) — 스냅샷과 라이브의 접합
GraphQlSnapshotLiveHandoff — 재개는 스냅샷을 읽고 라이브로 잇는 것이고, 둘이 정확히
만나야 한다.
snapshotPosition = 스냅샷이 포함한 마지막 위치
liveStartPosition = 라이브가 전달할 첫 위치
A live stream starting later than the snapshot ends loses events; one starting earlier repeats them. Both are checked, because "roughly continuous" is not something a client can compensate for.
GraphQlReplayGapException — 갭은 크게 실패한다. "silently continuing would present an
incomplete stream as a complete one."
GraphQlReplayHistoryLostException — 거부된 커서와 구분되는 결과다. 커서는 진짜인데 히스토리가
사라진 것 — 클라이언트의 올바른 대응은 재인증이 아니라 스냅샷을 다시 읽는 것이다.
GraphQlSubscriptionCursor — §13의 커서와 같은 원리. 서명되고 tenant/actor/subscription
프로파일에 바인딩. tenant를 actor fingerprint가 함의하게 두지 않는다:
An actor identifier that happens to be unique per tenant today stops being a tenant check the moment one identity can act in two tenants, and the failure is silent — the cursor verifies, the actor [matches].
GraphQlReplayAuthorization — 라이브 구독보다 엄격하다. 재개하는 tenant와 actor가 커서가
발급된 대상이어야 하고 지금도 인가되어야 한다 — 이벤트 생성 시점에 부여된 접근이 그 사이
철회됐을 수 있다.
GraphQlReplayPosition / GraphQlReplaySource — 시퀀스는 messaging 플랫폼의 것이지
GraphQL의 것이 아니다:
GraphQL has no resume token, and inventing one here would promise a durability guarantee the transport cannot keep.
보관 기간도 messaging의 것. 이 인터페이스는 위치가 아직 살아 있는지만 묻는다.
GraphQlReplayAuthorizationException — actor 정체성도 위치도 안 담는다. 누구 커서였는지,
얼마나 멀리 갔는지 드러나면 안 된다.
23.7 sse (7) — Distinct Connection
GraphQlSseConnectionPolicy — subscription만.
Spring's SSE support is a distinct-connection mode — one HTTP connection per subscription — which suits a server-to-client stream and is a poor fit for a query, where the client is paying for a streaming connection to receive one response.
GraphQlSseHandlerFactory — 놀라게 하는 성질을 팩토리에서 말한다:
graphql-transport-wsmultiplexes many subscriptions onto one socket and this does not, so a client with twenty subscriptions holds twenty connections and a browser's six-per-origin limit is reached at six.
GraphQlSseHeartbeat — 마지막 쓰기(종류 무관)로부터 측정. 바쁜 스트림은 불필요한 하트비트를
안 보내고, 조용한 스트림은 타임아웃시킬 중간자를 통과해 열려 있다.
GraphQlSseProperties — "proxies and load balancers close idle connections, and without a
periodic comment frame a quiet subscription is dropped by infrastructure the application never
sees."
GraphQlSseTermination — WebSocket과 같은 종료 어휘. 운영자가 전송에 무관하게 하나의
이유 집합을 읽는다.
GraphQlSseAdmission — 요청 형태는 POST + JSON body + Accept: text/event-stream. 다른 모든
전송과 같은 요청 봉투에 스트리밍 응답. 인가와 cost 정책은 WebSocket 것을 쓰고 전달 메커니즘만
다르다.
23.8 rsocket (8) — Experimental, 게이트 두 개
GraphQlRSocketHandlerFactory — 게이트가 둘인 이유:
A flag can be set by anyone editing configuration; the approval is a separate act, and separating them is what stops an experimental transport from being switched on the way a supported one would be.
세 번째 조건: named consumer 목록.
This is an experimental transport for internal systems, and one that nobody is identified as using is one nobody is testing.
GraphQlRSocketRoutePolicy — prefix match가 아니라 allowlist. "RSocket routing is string-based,
and a pattern is exactly how an unintended handler becomes reachable."
GraphQlRSocketCapability — 오퍼레이션 타입 → interaction model이 고정 매핑이지 선택이
아니다. query를 request-stream으로 하면 클라이언트가 기대하는 방식으로 완료되지 않는 스트림을
기다린다.
GraphQlRSocketErrorMapper — 의도적으로 같은 resolver:
a masked internal error over HTTP and a disclosed one over RSocket would mean the safest transport is whichever the attacker did not choose.
GraphQlRSocketAuthentication — MIME 타입도 allowlist. "metadata drives how a credential is
parsed, and an unexpected encoding is an unexpected parser."
23.9 springdata (7) — 명시적으로 "호환 경로"
이 패키지가 원래 리뷰와 가장 크게 갈린 지점이다. 리뷰 GQL-009은 *"repository 자동 노출은 Advanced여도 제거한다"*였는데, 지금은 allowlist를 붙여서 남아 있다.
GraphQlRepositoryExposureValidator의 javadoc이 그 선택을 방어한다:
This whole capability is a compatibility path and is documented as one. It exists so an existing Spring Data GraphQL application can be brought onto this platform without rewriting every fetcher on day one; it is not the mainstream way to serve a field here.
그리고 unsupported.repositoryAutoExposure가 켜져 있으면 기동을 거부한다 (§4.3) — 즉 Advanced
호환 capability 밖에서는 여전히 금지다.
안전 속성 4개:
GraphQlRepositoryAllowlist — 기본이 비어 있다:
Spring's
@GraphQlRepositoryauto-registers a data fetcher for every annotated repository it finds, so without an allowlist the set of exposed fields is whatever somebody annotated — which is a decision made in a persistence class, by whoever was working on persistence, and reviewed as a persistence change.
API 표면 결정이 persistence PR에서 리뷰된다는 지적이 정확하다.
GraphQlRepositoryExposure — 쌍이지 repository 단독이 아니다. 같은 repository가
Query.orders와 Query.allOrders에서 닿으면 청중이 다른 두 개의 노출이고, repository만으로
키잉된 allowlist는 하나의 결정으로 둘 다 승인한다.
GraphQlRepositoryArgumentPolicy — 놓치기 쉬운 절반:
a query-by-example or Querydsl fetcher derives its predicate from whatever arguments arrive, so an un-allowlisted argument set means the client writes the
WHEREclause — including over columns the schema never published, because the binder resolves against the entity rather than against the GraphQL type.
sort는 filter와 따로 allowlist된다 — 비슷하게 읽히고 부하 하에서 다르게 동작한다.
GraphQlRepositoryPaginationPolicy — 명시적, 절대 상속 아님:
Spring Data's GraphQL integration defaults to offset pagination with a page size of twenty, and a default is exactly what nobody reviews: a field that quietly serves twenty rows is fine until the client asks for the offset at row four hundred thousand, at which point the database is sorting the whole table to skip it.
GraphQlRepositoryProjectionPolicy — entity 타입을 이름으로 거부한다:
an entity returned from GraphQL is the database schema published as an API — every column, every relation the fetch plan happens to traverse, and a rename in the database becoming a breaking change for every client.
GraphQlRepositoryExposureValidator는 schema build 시점에 돈다:
An unregistered exposure that only failed when somebody queried it would be a field that exists, appears in introspection and errors — which is worse than one that was never published, because a client has already written code against it.
23.10 federation (7) + composition (8) — subgraph만 소유한다
GraphQlFederationCapability — "The subgraph, and nothing else. A router is a separate
deployment with its own availability, its own scaling and its own on-call — running one is a project,
not a library feature."
GraphQlFederationProperties — 기본 off. 단일 executable schema가 Stable 토폴로지:
federation buys independent deployment at the cost of cross-subgraph N+1, network amplification, deployment ordering and duplicated authorization.
네 가지 비용을 전부 이름 붙여 놓은 게 좋다.
GraphQlFederationSchemaFactory — 플래그 없으면 아무것도 등록 안 함:
a schema that advertises
_entitiesand_servicetells a router it may send entity references, and the router will.
GraphQlFederationEntityResolver — 해석은 항상 Application query service를 거친다:
an entity reference arriving from a router is still a request from outside, and it needs the same authorization and tenant scoping as one arriving over HTTP.
GraphQlFederationBatchResolver — router가 _entities 배열 전체를 보내므로 하나씩 해석하면
N+1 × subgraph × federated request가 된다.
GraphQlFederationRepresentationException — 키 필드가 빠지면 부분 해석이 아니라 거부:
"without the full key the subgraph would have to guess which entity was meant, and a guess here
returns another tenant's or another user's object."
GraphQlFederationEntityKey — 버전 있는 공개 계약이지 구현 세부가 아니다.
composition (8) — 릴리스 게이트 쪽:
GraphQlFederationCompositionResult — router 프로젝트가 만들고 여기가 소비한다. 이 레포는
subgraph를 소유하고 supergraph를 스스로 조합할 수 없다.
GraphQlFederationReleaseEvidence — 네 증거 중 composition이 가장 약하다:
It proves the schemas fit together, not that entity keys are stable, that cross-subgraph latency is within budget, or that a partial subgraph failure produces a sensible response rather than a nulled supergraph.
GraphQlFederationDeploymentOrder — 추가는 subgraph 먼저, 제거는 router 먼저.
GraphQlFederationLatencyBudget — 시간뿐 아니라 hop 수:
A query that fans out to four subgraphs pays four network round trips before any data is read, and a per-entity downstream call turns that into a cross-subgraph N+1 — invisible in any single subgraph's own metrics.
GraphQlFederationUsageReport — entity key를 바꾸기 위한 전제조건:
"without it, the only way to find out who depended on a field is to remove it and wait for another
team's incident."
GraphQlSubgraphContract — owner 필수. "'who approves this' has to be answerable before the
change is proposed, not after it lands."
23.11 codegen (8) — "generator"라는 이름이 틀렸었다
GraphQlClientOperationGenerator — 이름이 약속을 어겼다:
Named a plan because that is what it produces ... No source writer and no Gradle task exist behind it, and calling it a generator invited the reasonable assumption that running it emitted files.
GraphQlOperationValidator — 이 leaf에서 가장 인상적인 "가짜 검사" 사례:
The previous check confirmed both strings were non-blank and then compared the schema with itself, which is true of every schema. The operation document was never read, so a document with invalid syntax, an unknown field, or an argument that does not exist passed validation and became generated client code that fails at runtime — in the client's repository, against a schema that had already changed.
스키마를 자기 자신과 비교했다. 항상 참인 assertion. 지금은 실제로 GraphQLSchema로 컴파일해서
문서를 검증한다.
GraphQlGeneratedCompatibilityGate — §18.1의 두 청중을 codegen 쪽에서 다시 말한다:
"adding an enum value or a union member is additive on the wire, and it breaks a generated client
whose switch is exhaustive."
GraphQlGeneratedSourceBoundary — 생성해도 되는 것과 안 되는 것:
- ✅ client / transport 타입 — 스키마를 그대로 미러링하고 로직이 없다
- ❌ domain entity, use case, repository — "generating them from the schema inverts the dependency the whole architecture rests on."
GraphQlScalarMapping — 명시적 선언. "a generator that does not know a scalar falls back to
String — and a BigDecimal arriving as a String in generated client code is precisely the
precision loss the scalar was defined to prevent."
GraphQlCodegenProfile — 생성 소스는 자기 디렉터리·패키지로. 섞으면 다음 실행이 사람이 쓴 걸
덮거나 조용히 재생성을 멈춘다.
23.12 get (7) — HTTP GET draft
GraphQlHttpGetOperationPolicy — query만:
A mutation over GET is a side effect behind a URL: prefetchers follow it, caches store it, and a link is enough to trigger it.
GraphQlHttpGetCachePolicy — GET이 매력적인 이유와 위험한 이유가 같다:
A GraphQL response depends on the actor, so a shared cache keyed by URL will serve one user's data to another.
GraphQlHttpGetCsrfPolicy — 쿠키가 자격증명이면 필수. "A GET with cookies is triggerable by any
page that can make the browser fetch a URL, and the read it performs is a read of the victim's data."
GraphQlHttpGetProfile — URI 예산: "query strings are truncated by proxies at lengths nobody
controls."
GraphQlHttpGetRejectedException — 쿼리 텍스트를 안 담는다. "a rejected GET's document is in
the URL, which is the reason GET is risky in the first place."
GraphQlHttpDraftCompatibilityReport — draft가 Stable POST 계약과 별도로 움직인다.
"This report is where draft drift is observed, so adopting a change stays a decision rather than a
consequence of upgrading a library."
23.13 incremental (7) — @defer / @stream
GraphQlIncrementalDeliveryProfile — 버전 게이트. @defer/@stream은 2025년 9월 Stable 계약에
없고 엔진·전송 지원이 아직 정착 중이다. "Pinning the profile is what keeps a library upgrade from
silently changing what clients receive."
GraphQlIncrementalCompatibilityGate — 절대 complete response로 폴백하지 않는다:
A silent fallback means the feature appears to work in every environment where it is not actually enabled, and fails only where someone relied on it.
GraphQlIncrementalDeliveryCapability — 엔진과 전송이 독립적으로 움직이고 둘 다 필요하다.
GraphQlIncrementalPatch — 자기 path와 errors를 나른다. deferred fragment는 자기 혼자 실패할 수
있고, 그 에러는 패치의 path에 속한다 — initial result에 귀속시키면 클라이언트에게 틀린 필드가
실패했다고 말한다.
GraphQlIncrementalCancellation — 클라이언트가 initial result 뒤에 끊으면 deferred 작업이 계속
돈다. "doing database and downstream work for a response that can no longer be delivered."
GraphQlIncrementalTransportPolicy — §10.4에서 다룸.
23.14 chaining (6) — chained DataLoader dispatch
GraphQlChainedDataLoaderPolicy — 기본 off. 이유가 좋다:
Chained dispatch changes when loaders fire, which changes query counts, batch sizes and result ordering — all things existing N+1 regression tests assert on. Enabling it is a deliberate change with its own regression evidence, not a free improvement.
**"공짜 개선이 아니다"**가 요점.
GraphQlDataLoaderDependencyGraph — 선언이지 추론이 아니다. "the platform cannot discover
from bytecode that one loader's keys come from another's results."
GraphQlDataLoaderCycleDetector — DFS로 찾은 사이클을 보고한다, 존재만이 아니라.
"with a dozen loaders, 'there is a cycle somewhere' is not an actionable diagnostic."
GraphQlChainedLoaderMetrics — 쿼리 수와 배치 크기가 chaining이 도왔는지 해쳤는지 증명하는
숫자다. 없으면 효과를 아무도 잴 수 없는 변경이고, 그게 기본 off인 이유다.
23.15 release (7, advanced) — Advanced 릴리스 게이트
GraphQlAdvancedReleaseEvidence — Stable baseline이 먼저고 협상 불가:
an Advanced capability sits on top of the Stable platform's transport, error, security and cost guarantees, so releasing one on an unproven base means its own evidence was gathered against something that might not hold.
GraphQlAdvancedSoakScenario — 전부 지속 시간 문제:
A buffer leak, a connection that never re-authenticates and a source that reconnects badly all look fine for the first minute.
GraphQlAdvancedRunbookIndex — 모든 Advanced capability는 Stable에 없는 실패 모드를 들여온다 —
연결 폭풍, 되돌려야 할 블록된 오퍼레이션, 롤백할 subgraph. 런북을 미리 요구하는 게
인시던트 중에 처음 쓰는 일을 막는다.
GraphQlAdvancedPromotionDecision — Experimental 탈출에는 owner 있는 기록된 결정이 필요하다:
"Without one a capability drifts into production by habit — enabled in one environment, then another,
until nobody remembers it was never approved."
GraphQlAdvancedCompatibilityMatrix — Stable matrix를 확장하고 대체하지 않는다. Advanced만
갖는 프로토콜 버전(WebSocket sub-protocol, federation spec 버전, codegen 엔진)을 추가.
24. 테스트 레인, 계약 스위트, 아티팩트 게이트
24.1 네 개의 레인
src/gradle/graphql-platform-conventions.gradle (150줄).
| 레인 | 무엇 | 태그 |
|---|---|---|
graphqlStableTest |
Stable 플랫폼 단위 + 경계 테스트 | (기본) graphql-advanced 제외 |
graphqlContractTest |
크로스 모듈 계약 스위트 | @Tag("graphql-contract") |
graphqlAdvancedTest |
Advanced/Experimental capability | @Tag("graphql-advanced") |
graphqlPerformanceTest |
부하·soak·장애 시나리오 | @Tag("graphql-performance") |
기본 test는 quarantine과 graphql-performance를 제외한다 — "so external load and soak work
can never run inside the unit lane."
CLAUDE.md가 마지막 green 실행의 실제 숫자를 적어 둔다: Stable 605, Contract 9, Advanced 152,
기본 test 757, transport qualification 8. 그리고 그 숫자를 매번 다시 읽어 갱신하라는 규칙이
붙어 있다 — "컴파일이 깨진 채로 남은 과거 숫자는 통과 증거가 아니라 통과했다는 인상일 뿐이다."
(참고: 현재 소스의 @Test+@ParameterizedTest가 762개다. 레인 숫자와는 세는 단위가 다르다 —
@ParameterizedTest는 여러 케이스를 만든다.)
24.2 세 겹의 fail-closed
(1) requiredStableClasses — 빈 레인이 아니라 사라진 클래스를 잡는다
List<String> requiredStableClasses = [
"${platformPackage}.moduleboundary.GraphQlModuleBoundaryTest".toString(),
]
주석이 이유를 정확히 쓴다:
failOnNoMatchingTestsonly reacts to an empty lane, so deleting one boundary class out of four hundred tests is invisible to it — and losing exactly this class is how the platform shipped without an enforced module boundary in the first place.
구현이 JUnit XML을 읽어서 실제 실행된 클래스 집합을 확인한다. 실패 메시지가 훌륭하다:
"The lane is green only because the class is gone; restore it rather than removing it from requiredStableClasses."
(2) stale XML 삭제
doFirst {
File staleResults = reports.junitXml.outputLocation.get().asFile
if (staleResults.exists() && !staleResults.deleteDir()) { throw new GradleException(...) }
}
Stale XML from a previous run would let a deleted class report as executed.
(1)이 XML을 읽으니까 (2)가 없으면 (1)이 무력화된다. 검사가 읽는 증거의 신선도까지 검사한다.
(3) 빈 performance 레인은 실패다
// `failOnNoDiscoveredTests` alone does NOT cover this: it reacts to an empty candidate class
// scan, and this lane always scans a non-empty test tree that JUnit then tag-filters down to
// zero. Without the explicit result check below the lane reports BUILD SUCCESSFUL while
// proving nothing. Verified empirically on Gradle 9.0.0.
"Verified empirically on Gradle 9.0.0" — 프레임워크 플래그가 기대대로 동작하지 않는다는 것을 실제로 확인해 보고 적었다. 이런 주석이 많지 않다.
에러 메시지도 대안을 준다: "Register @Tag("graphql-performance") scenarios or run the lane against
the external load environment that owns them."
24.3 testFixtures — 계약 스위트와 in-memory 스텁
16개 파일 1,074줄. java-test-fixtures 플러그인.
GraphQlContractFixture — 이 fixture의 javadoc이 핵심을 말한다:
Runs the actual method and media-type checks, envelope validation and status mapping rather than asserting against a hand-built response. A contract test that constructs the response it then asserts on proves nothing about the contract.
그리고 의도적으로 전송이 아니다:
This fixture executes nothing and owns no threads: it composes the pure policies, so it can never become the second execution path that the custom MVC and WebFlux adapters had turned into.
close()가 아무것도 안 하고, 주석이 그걸 말한다:
// No executor, no threads, no route: there is deliberately nothing to release.
GraphQlHttpContractSuite — 어떤 전송에도 replay 가능한 HTTP 계약:
The rule worth a suite of its own: a field error is HTTP 200 with whatever data resolved. It is easy to break by "improving" error handling into a 500, and the breakage looks like better hygiene until a client loses half a working response.
검사 항목:
- field error → 200, sibling 데이터 생존, errors에 보고, 선호 미디어 타입 협상
- legacy
application/json클라이언트 계속 지원 - 비-POST → 405 + errors에 설명
GraphQlJpaIntegrationFixture / GraphQlMongoIntegrationFixture / GraphQlStorageIntegrationEvidence
— 실행되지 않은 증거는 통과가 아니다:
if (!evidence.executed()) {
violations.add(evidence.operationName()
+ " has no executed PostgreSQL evidence; an unexecuted run is not a pass");
}
"We did not run it" must not read the same as "it passed".
그리고 타이밍이 아니라 카운트를 잰다:
an N+1 is invisible in latency on ten rows, and H2 does not reproduce PostgreSQL's planner, its locking or its examined-row accounting.
statementCount와 examinedRows — "those are the numbers that make an N+1 or a missing index
visible. A latency figure alone hides both on a small dataset."
나머지 testkit: GraphQlSchemaContractSuite, GraphQlSecurityContractSuite,
GraphQlDataLoaderContractSuite, GraphQlPaginationContractSuite, GraphQlDownstreamFailureFixture,
GraphQlRequestContexts, GraphQlContractResponse, GraphQlContractViolation,
error/GraphQlPartialResponseFixture,
advanced/persisted/InMemoryGraphQlPersistedOperationRegistry,
advanced/admin/InMemoryGraphQlPersistedOperationAdminPort.
24.4 verifyGraphQlProductionJar — jar 자체를 읽는다
금지 마커 5개:
| 마커 | 이유 |
|---|---|
/testkit/ |
계약 스위트와 통합 fixture는 test fixtures 소속 |
InMemory |
개발용 대역이지 출하 기본값이 아니다 — "looks like a working bean until a second instance starts" |
ForTests |
production 코드에서 도달 가능한 for-tests 팩토리 |
TestContext |
자격증명 없는 인증 컨텍스트 — "testContext(String) hands out an authenticated actor to anyone who calls it" |
Fixture |
fixture는 test fixtures 소속 |
중요한 설계 결정: 레이아웃이 아니라 jar를 검사한다.
Moving the testkit into test fixtures is a source-tree decision, and source-tree decisions drift. One
implementationwhere atestFixturesImplementationbelonged, one file created in the wrong directory, and the contract suites are back inside the jar an adopter deploys.
"So the claim is checked against the jar rather than against the layout that is supposed to produce it." — 의도가 아니라 산출물을 검사한다.
archive 인덱스(entry 이름)만 읽고 바이트코드는 안 읽는다.
원래 리뷰 GQL-024의 답.
24.5 verifyGraphQlApiSurface — 408개 public 타입 스냅샷
docs/architecture/graphql-api-surface.txt (416줄, 408 타입).
주석이 문제를 정확히 진술한다:
One jar, 40-odd packages, and a public type in any of them is reachable from every adopter's code. Package boundaries express the intended structure but enforce nothing across a single artifact:
publicinside a jar means public to everybody who has the jar.
그리고 가설이 아니라는 증거를 붙인다:
a package that went missing from a commit was still compiled against by seven production files, and nothing in the build had an opinion about what the surface was supposed to be.
§2.1의 그 사건이다.
스냅샷은 표면을 줄이지 않는다:
A snapshot does not shrink the surface. It makes each addition visible in review, which is the prerequisite for shrinking it ... Until then the number cannot grow by accident.
승인 후 갱신:
./gradlew :adapter:inbound:graphql:updateGraphQlApiSurface -PapproveGraphQlApiSurfaceChange
원래 리뷰 GQL-025("373개 type의 과도한 public surface")의 부분적 답이다 — 표면을 게이트했지 줄이지는 않았고, 오히려 373 → 408로 늘었다. 하지만 이제 늘어난 35개는 전부 리뷰를 거쳤다.
24.6 registerStrictQualificationTest — no-skip 증거
registerStrictQualificationTest(
name: 'graphqlTransportQualificationTest',
sourceSet: sourceSets.test,
requiredClasses: ['dev.caskeleton.adapter.inbound.graphql.GraphqlHttpBoundaryQualificationTest'],
description: 'Runs exact no-skip GraphQL conditional transport wire evidence.')
레포 전역 헬퍼를 쓴다. skip을 통과로 세지 않는다는 게 이 레포 전반의 규칙이고, GraphQL도 같은 규칙을 따른다.
25. app-bootstrap 배선과 스모크 레인
25.1 "shipped and gated" — CLAUDE.md/README와 다른 현재 상태
src/app-bootstrap/build.gradle:
// auto-configurations back — without both, adding the jar would publish /graphql.
implementation project(':adapter:inbound:graphql') // line 97
...
conditionalTransportTestImplementation project(':adapter:inbound:graphql') // line 198
src/app-bootstrap/src/main/resources/application.yml:968:
graphql:
# true | false — publishes /graphql, including the route Spring GraphQL would publish itself.
enabled: ${APP_GRAPHQL_ENABLED:false}
deployment-mode: ${APP_GRAPHQL_DEPLOYMENT_MODE:}
GraphQlShippedAndGatedTest가 두 가지를 동시에 주장한다:
- facade가 classpath에 있다
- off일 때
/graphql이 정의되지 않은 라우트와 구분 불가능하다
즉 지금은 "출하되고 게이트됨"이지 "classpath opt-in"이 아니다. §3의 4겹 구조가 그걸 가능하게 만들었다 — "꺼짐"을 구조로 만들었기 때문에 "빌드 안 됨"으로 끌 필요가 없어졌다.
modules.json의 runtime_memberships: ["app-bootstrap"]도 같은 말을 한다.
모듈 문서 두 개가 이 변화를 아직 반영하지 않았다 (§28-P1).
25.2 GraphQlInboundOwnershipBoundaryTest — ADR의 전제를 상시 검사로
ADR-GQL-001의 세 번째 결정(persisted-operation SPI를 인바운드에 둔다)은 "이 leaf 밖에서 아무도 구현하지 않는다"는 근거 위에 서 있다. 이 테스트가 그 스냅샷 관찰을 상시 조건으로 바꾼다.
private static final List<String> INBOUND_OWNED =
List.of("GraphQlPersistedOperationRegistry", "GraphQlRequestContext");
private static final String GRAPHQL_SOURCE = "/src/adapter/inbound/graphql/src/main/java/";
javadoc이 왜 필요한지 쓴다:
A decision whose premise nothing rechecks is a decision that quietly stops being true, and the commit that breaks it would read as an implementation rather than as a layering change.
composition root에서 검사하는 이유: "A rule inside the GraphQL leaf could only prove the leaf does not violate itself."
그리고 두 번째 테스트가 있다:
@Test
@DisplayName("both types still exist, so the rule is about the boundary and not about deletion")
void theOwnedTypesStillExist() {
// Without this the assertion above would also pass on the day the SPI and the context were
// deleted, which is a green rule reporting on nothing — the shape this repository's remediation
// pass exists to remove.
"검사 대상이 사라져도 green"을 막는 짝 테스트. §2.4의 규칙 (e)와 정확히 같은 발상이다.
세 번째 디테일도 좋다. 소스 트리 walk가 /build/를 방문 후 필터링하는 대신 가지치기한다:
Filtering
/build/out of aFiles.walkstream still descends into it, and those directories churn while any other module's tests are running — which made this suite fail once, unreproducibly, in a run that had three modules' tests going at the same time.
그리고 빈 walk는 예외를 던진다 — "An empty walk would make both assertions below pass vacuously, which is the failure this whole suite exists to prevent."
25.3 GraphQlResolverReachabilityTest — GQL-INT-003의 다른 절반
이 leaf에서 가장 교훈적인 실제 장애다.
skeleton.graphqls는 _health: String! 하나를 선언하고 HealthGraphqlController가 해석한다.
그런데 그 컨트롤러는 composition root의 @ComponentScan이 정규식으로 제외하는 패키지에 있는
@Controller다 — capability를 optional하게 만드는 바로 그 제외 — 그리고 어떤
auto-configuration도 import하지 않았다.
결과: GraphQL을 켠 배포가 유일한 필드에 아무것도 없는 스키마를 서빙했고, 모든 쿼리가
NullValueInNonNullableField로 돌아왔다.
그리고 결정적인 부분:
Two unit tests covered that controller and both passed: one registers the class in an
ApplicationContextRunner, the other lists it among a fixed set of classes for a boundary qualification. Both supply the registration the runtime was missing, which is the same shape asNotificationPlatformAutoConfigurationand the persistence stereotypes before it.
테스트가 런타임에 없던 등록을 스스로 공급했다. 두 테스트 다 green인데 프로덕션은 깨져 있었다. 그리고 javadoc이 이게 이 레포에서 세 번째 같은 모양이라고 적는다.
찾아낸 건 레인이다:
graphql-smokesends an authenticated query to/graphqland reads the body rather than the status, because a failed resolution is a200carrying anerrorsarray.
수정은 GraphQlRootAutoConfiguration의 @Import에 컨트롤러를 넣는 것 (§3.2).
테스트 자체가 두 가지를 고정한다:
- 등록된 root가 resolver를 import한다 (소스 텍스트에서
HealthGraphqlController.class검색) - 레인이 실제 요청을 보낸다 (
graphql-smoke.sh존재 + 내용 검사)
25.4 infra/graphql/smoke/graphql-smoke.sh — 세 요청, 그 순서로
스크립트 헤더가 왜 이 스모크가 필요한지 쓴다:
auth-smoke proves the realm issues a usable token and that the application answers its public health path. It never sends an authenticated request, and never touches /graphql at all — so the JWT decoder, the security filter chain and the GraphQL execution path were each covered by their own tests and by nothing that put them in one line together.
셋을 한 줄에 놓는 것이 없었다는 게 요점이다.
세 요청, 그리고 각각이 앞의 것이 있어야만 의미가 있다:
| # | 요청 | 기대 | 없으면 |
|---|---|---|---|
| 1 | 자격증명 없음 | 401/403 | 2·3이 인증에 대해 아무것도 증명 못 함. 그냥 엔드포인트가 답한다는 것만 |
| 2 | Bearer not-a-real-token |
401/403, 500 아님 | 500이면 디코더가 거부해야 할 곳에서 던진 것 |
| 3 | 진짜 토큰 (client_credentials) | 200 + _health |
— |
그리고 바디를 읽는다:
# The body, not just the status. A 200 carrying a GraphQL `errors` array is how a refused or failed
# execution looks over HTTP, so a status-only check would pass on an unresolved field.
grep -q '"_health"' /tmp/gql.json || fail "the response carried no _health field: ..."
if grep -q '"errors"' /tmp/gql.json; then fail "the query returned GraphQL errors: ..."; fi
GraphQL에서 상태 코드만 검사하는 스모크는 무의미하다 — §7.2의 "field error는 200"이 바로 그 이유다. 이 스크립트가 그 결과를 정확히 다룬다.
25.5 나머지 app-bootstrap 쪽 GraphQL 테스트
| 테스트 | 무엇 |
|---|---|
GraphQlDeploymentModeRegistryParityTest |
enum 목록과 운영자용 레지스트리 동기화 (§4.1) |
ShippedRuntimeFacadePresenceTest |
shipped facade 존재 |
FiveAdapterOffInventoryTest |
다섯 어댑터 off 상태 인벤토리 |
CapabilityDependencyValidatorTest |
capability 의존 검증 |
RuntimeMembershipClasspathAgreementTest |
runtime_memberships와 실제 classpath 일치 |
ConditionalTransportQualificationContractTest |
조건부 전송 계약 |
AdapterActivationInventory / ShippedCompositionHarness |
헬퍼 |
RuntimeMembershipClasspathAgreementTest가 특히 §25.1의 drift를 잡아 줄 수 있는 위치다 —
레지스트리와 classpath는 일치하고, 산문 문서만 어긋나 있다.
26. 2026-08-14 리뷰 → 지금: 27개 발견은 어떻게 됐나
docs/reviews/2026-08-14-graphql-module-code-review.md (889줄)는 당시 판정이
CHANGES REQUIRED / 현재 컴파일 불가였다. 그때 규모는 production 373 / test 75 / LOC 20,155.
지금은 408 / 101 / 26,303이다.
이 문서를 읽는 것이 코드를 읽는 것만큼 유용했다. javadoc에 적힌 "예전에는 이랬다"가 전부 이 리뷰의 발견과 대응된다. 그리고 대부분이 정말로 고쳐졌다.
| ID | 우선순위 | 주제 | 지금 상태 |
|---|---|---|---|
| GQL-001 | P0 | ignored build 소스 패키지 |
✅ moduleboundary로 개명 + verifyNoIgnoredSourcePackages + required-class 검사 (§2.1, §24.2) |
| GQL-002 | P0 | 정책이 실제 /graphql에 미연결 |
✅ runtime 패키지 전체 (§9). 등급 어휘로 남은 gap을 정직하게 표기 (§22.1) |
| GQL-003 | P1 | auto-config 등록/바인딩/검증 불일치 | ✅ AutoConfiguration.imports + GraphQlPlatformStartupValidator.validateRuntime (§4.3) |
| GQL-004 | P1 | servlet artifact가 reactive 표방 | ✅ web을 test scope로 + GraphQlRuntimeTransport 감지 (§1.3, §4.3) |
| GQL-005 | P1 | body cap 미강제, null variable NPE | ✅ GraphQlRequestBodyLimitFilter + GraphQlJsonValues (§7.6, §7.8) |
| GQL-006 | P1 | Accept q-value/q=0 무시 | ✅ GraphQlAcceptHeader (§7.3) |
| GQL-007 | P1 | fragment 우회, variable nesting 공백 | ✅ GraphQlFragmentReachabilityTest + GraphQlJsonStructurePolicy (§7.5, §10.2) |
| GQL-008 | P1 | 경계 검사가 generic 놓치고 reactive 오판 | ✅ GraphQlTypeGraph + GraphQlAsyncReturnShape (§20.1, §20.2) |
| GQL-009 | P1 | repository 자동 노출 제거 | ⚠️ 제거하지 않고 allowlist로 남김 (§23.9). unsupported.repositoryAutoExposure가 Advanced 밖에서 금지 |
| GQL-010 | P1 | cursor framing/rotation/scope 결함 | ✅ GraphQlCursorFraming + GraphQlCursorScope + keyRing (§13). ❌ 다만 요청 경로에 미연결 (GQL-INT-003) |
| GQL-011 | P1 | mutation fingerprint 충돌, tenant 없는 scope | ✅ GraphQlCanonicalInput + 5축 스코프 (§14.3, §14.4) |
| GQL-012 | P1 | error contract 두 벌 | ✅ GraphQlWireErrorMapper 단일화 (§9.7) |
| GQL-013 | P1 | persisted admin durable하지 않음 | ✅ GraphQlAdminPrincipal + CAS 전이 + 단일 durable 단위 감사 (§23.1, §23.2) |
| GQL-014 | P1 | codegen이 문서를 검증 안 함 | ✅ GraphQlOperationValidator가 실제 스키마 컴파일 + 이름을 plan으로 정정 (§23.11) |
| GQL-015 | P1 | comparator가 breaking change 놓침 | ✅ 37종 GraphQlChangeKind (§18.1) |
| GQL-016 | P1 | DataLoader 계약이 실제 N+1 미보장 | ✅ GraphQlBatchLoaderRegistrar + 50 parent → 3 호출 증거 (§9.6, §12.6) |
| GQL-017 | P1 | MVC/WebFlux unbounded, context 미전달 | ✅ 커스텀 어댑터 제거 + GraphQlBlockingBridge 양축 유한 + GraphQlContextPropagator (§9.8, §11.5) |
| GQL-018 | P1 | pipeline stage 순서 모순 | ✅ 6단계 + 7개 순서 제약 + 유도된 파이프라인 (§8.1, §8.2, §9.3) |
| GQL-019 | P1 | subscription/replay/drain race | ✅ GraphQlSubscriptionLease + 불변 drain 상태 + 3-phase (§23.3) |
| GQL-020 | P2 | preparsed expiry 미사용, miss 직렬화 | ✅ BoundedPreparsedDocumentProvider + 4부분 키 + adapter (§8.3, §9.6) |
| GQL-021 | P2 | cancellation hook 하나가 나머지 차단 | ✅ 드레인 방식, 각 리스너 정확히 한 번 (§8.5) |
| GQL-022 | P2 | scalar input/output 비대칭 | ✅ GraphQlDecimalBounds 네 경계 (§17.6) |
| GQL-023 | P2 | operation name 카디널리티 | ✅ GraphQlOperationNameCardinality + 실제 MeterRegistry 10,000 → 1 (§19.2) |
| GQL-024 | P2 | production jar 오염 | ✅ verifyGraphQlProductionJar 5개 마커 (§24.4) |
| GQL-025 | P2 | 과도한 public surface | ⚠️ 게이트했지 줄이지 않음. 373 → 408 (§24.5) |
| GQL-026 | P2 | context/storage SPI ownership | ✅ ADR-GQL-001 + 세 갈래 해법 + composition root 검사 (§6.5, §11.4, §23.1, §25.2) |
| GQL-027 | P3 | 문서 drift | ⚠️ 새로운 drift가 생겼다 (§28-P1, P3, P4) |
요약: 27개 중 23개 완료, 2개 의도적 다른 선택(GQL-009, GQL-025), 1개 미완(GQL-010의 배선), 1개 재발(GQL-027).
그리고 리뷰 이후 새로 발견된 통합 결함 두 개가 있다 — GQL-INT-002(안전 축 split-brain,
§4.1)와 GQL-INT-003(resolver 도달 불가 §25.3 + cursor 미배선 §13.7). 둘 다 단위 테스트가
전부 green인 상태에서 프로덕션이 깨져 있던 부류다.
26.1 이 비교에서 얻은 것
리뷰 문서 §2.2의 "검토 깊이" 표가 정직하다:
이 문서는 28,579 LOC의 모든 method를 line-by-line 승인한 결과가 아니다.
그리고 advanced/**, release/testkit의 세부는 명시적으로 UNVERIFIED라고 적었다. 읽지 않은
것을 읽었다고 하지 않는 것 — 이게 이 레포 문서들의 공통 규율이고, 내가 이 문서 맨 위에
커버리지 표를 붙인 이유이기도 하다.
27. 이 모듈이 가르쳐 준 원칙
읽으면서 반복해서 나타난 것들. 대부분 GraphQL 특유가 아니라 어떤 인바운드 어댑터에도 적용되는 것이다.
27.1 정책 객체의 단위 테스트는 정책이 강제된다는 증거가 아니다
세 번 다르게 나타났다 (§9.6):
- preparsed 캐시가 비어 있는 동안 매 요청이 재파싱했다
- N+1 보호가 어떤 요청도 닿을 수 없는 객체 집합이었다
- 카디널리티 경계가 어떤 series에도 적용된 적 없었다
그리고 네 번째: resolver 도달 불가(§25.3)에서 테스트가 런타임에 없던 등록을 스스로 공급했다.
대응: modelled / wired / integration-verified / production-verified 등급 어휘. 그리고
"현재 등급보다 높게 표현하지 않는다"는 규칙.
27.2 "꺼져 있다"는 빈이 없다는 뜻이 아니다
프레임워크가 스스로 라우트를 여는 경우가 있다. 그래서 off 계약은
AutoConfigurationImportFilter + 실제 포트 404 테스트까지 가야 한다 (§3).
그리고 그 필터는 오타가 나면 조용히 열린다. 방어 수단이 아니라 방어된 상태를 테스트해야 한다.
27.3 없어진 검사와 통과한 검사를 구분할 수 없으면 검사가 아니다
이 원칙의 사례가 이 모듈에 다섯 개 있다:
| 사례 | 어디 |
|---|---|
requiredStableClasses — 400개 중 1개 사라져도 잡는다 |
§24.2 |
| stale JUnit XML 삭제 — 위 검사가 읽는 증거의 신선도 | §24.2 |
빈 performance 레인은 실패 (failOnNoDiscoveredTests로는 부족) |
§24.2 |
| 모듈 경계 규칙 (e): 선언만 있고 소스가 없는 모듈 금지 | §2.4 |
theOwnedTypesStillExist() — 두 타입이 지워져도 green이 되지 않게 |
§25.2 |
| 빈 소스 walk는 예외 | §25.2 |
27.4 구분자를 값에서 구분할 수 없으면 정규형이 아니다
같은 레포에서 두 번 일어났다:
- 커서 봉투:
|/;/=조인 + 이스케이프 → 라운드트립 실패 (§13.4) - mutation 지문:
key=value;조인 → 서로 다른 두 입력이 같은 지문 (§14.4)
대응이 다르다는 게 흥미롭다. 커서는 이스케이프를 길이 프레이밍으로 대체했고, 지문은 타입 있는 정규 직렬화로 갔다. 둘 다 "이스케이프를 더 잘하기"를 선택하지 않았다.
27.5 정규식은 형태를 묶지 카디널리티를 묶지 않는다
Query0000001, Query0000002, ... 전부 유효하고 전부 자기 time series였다. 정규식이 검사하고
있어서 경계가 있어 보였다 (§19.2).
27.6 저엔트로피 입력에 대한 다이제스트는 익명화가 아니다
tenant 이름 후보가 유한하면 전부 해싱해서 역매핑한다. keyed fingerprint여야 한다 (§6.4).
27.7 하나의 질문에 대해 의견이 갈릴 수 있는 두 설정은 설정 두 개가 아니라 버그다
production boolean + environment enum → 출하 기본값이 "인터넷 노출 프로덕션인데 보호는
노트북처럼" (§4.1).
그리고 지운 키는 무시가 아니라 거부여야 한다 (§4.2).
27.8 규칙을 규칙으로 쓰지 말고 구조로 만들어라
| 규칙 | 구조 |
|---|---|
| "tenant는 인자에서 오면 안 된다" | 인자 기반 팩토리가 존재하지 않는다 (§6.2) |
| "컨텍스트는 한 곳에서만 만든다" | GraphQlAuthenticationContextFactory가 유일한 생성자 (§11.2) |
| "커서 스코프 4개를 다 검사해라" | 4개를 한 값으로 넘긴다 (§13.3) |
| "에러 extensions에 아무거나 넣지 마라" | 생성 시점 allowlist (§16.1) |
| "loader는 실행마다 새로" | 팩토리 (§12.4) |
| "단계 순서를 지켜라" | 다음 단계가 이전 단계의 필드를 요구 (§9.4) |
27.9 nullability는 데이터 모양이 아니라 실패 격리 결정이다
GraphQL 특유. non-null 필드의 null은 가장 가까운 nullable 조상까지 올라가며 전부 null로
만든다. enrichment 필드에 !를 붙이면 무관한 서비스가 죽었을 때 주문 전체가 사라진다 (§16.5).
27.10 실행이 시작됐는가가 에러 계약의 축이다
실행 전 = 4xx, 실행 중 = 200 + 부분 데이터. field error를 5xx로 "개선"하면 위생이 좋아진 것처럼 보이는데 클라이언트는 동작하는 응답의 절반을 잃는다 (§7.2, §24.3).
그리고 스모크 테스트가 상태 코드만 보면 무의미해진다 (§25.4).
27.11 순서는 취향이 아니라 보안 속성이다
인가는 파싱된 문서가 필요하고, cost는 실행 전이어야 하고, persisted lookup은 파싱 전이어야 한다. 그리고 잘못된 순서는 런타임에 조용하다 — 실행 후 인가는 여전히 "동작"한다. 아무것도 인가하지 않을 뿐 (§8.1, §8.2).
27.12 의도가 아니라 산출물을 검사하라
testkit을 test fixtures로 옮긴 건 소스 트리 결정이고 소스 트리 결정은 drift한다. 그래서 레이아웃이 아니라 jar를 연다 (§24.4).
27.13 믿어지는 잘못된 감사 기록은 없느니만 못하다
An audit trail with any of those properties is worse than none, because it is trusted. (§23.2)
그리고 시도된 변경이 아니라 일어난 변경을 기록해야 한다 — 존재하지 않는 오퍼레이션에 대해
ABSENT -> BLOCKED를 쓰던 서비스.
27.14 조용한 폴백은 전부 나중에 청구된다
이 모듈이 폴백을 거부하는 지점들:
| 지점 | 폴백했으면 |
|---|---|
| 모르는 client profile | 무제한 문서·복잡도·페이지 크기 (§5.2) |
| 커버하는 fetch profile 없음 | full-entity 로드 (§15.2) |
| 등록 안 된 DataLoader | 배치 크기·타임아웃·missing 의미 없음 (§12.5) |
| incremental 미지원 런타임 | "모든 환경에서 동작하는 것처럼 보이고, 의존한 곳에서만 실패" (§23.13) |
| 인식 못 한 예외 | 자기 메시지 노출 (§9.7) |
| 미등록 repository 노출 | introspection에 나오고 에러 나는 필드 (§23.9) |
| deployment mode 미설정 | 인가 없는 엔드포인트 또는 설명 불가한 장애 (§4.1) |
27.15 "지원하지 않음"은 갭이 아니라 대안이 있는 결정이어야 한다
GraphQlStableCapabilityManifest의 UNSUPPORTED 엔트리는 전부 설계상의 대안을 가진다 —
업로드는 Fileserver, 원자적 다단계 작업은 하나의 mutation use case, 크로스 요청 캐싱은 자기 키
모델을 가진 cache capability (§21.5).
그리고 그게 설정 키로도 존재해서, 켜면 기동을 거부하며 대안을 말한다 (§4.3).
28. 손볼 것
우선순위 순. P1~P2가 실제로 조치가 필요한 것이고 나머지는 정리·결정 항목이다.
P1 — 모듈 문서 두 개가 "classpath opt-in"이라고 말한다 (실제로는 shipped-and-gated)
심각도: 높음 (문서) · 비용: 낮음
| 위치 | 현재 문장 |
|---|---|
src/adapter/inbound/graphql/CLAUDE.md Responsibility 마지막 bullet |
"classpath opt-in: 현재 app-bootstrap/sample-portfolio production runtime 은 이 leaf 를 의존하지 않는다." |
src/adapter/inbound/graphql/README.md:31-32 |
"현재 app-bootstrap 과 sample-portfolio 의 production runtime 은 이 leaf 를 의존하지 않는다. 즉 이 모듈은 classpath opt-in 이며..." |
반증 셋:
src/app-bootstrap/build.gradle:97—implementation project(':adapter:inbound:graphql')src/config/architecture/modules.json—"runtime_memberships": ["app-bootstrap"]app-bootstrap/src/test/.../activation/GraphQlShippedAndGatedTest.java— facade가 classpath에 있고 off일 때/graphql이 미정의 라우트와 구분 불가능함을 주장
왜 이게 단순 오타보다 나쁜가: 이 leaf의 가장 큰 설계 성취가 "꺼짐"을 구조로 만들어서
"빌드 안 됨"으로 끌 필요를 없앤 것이다(§3). 문서가 그걸 부정하면, 새 독자는 4겹 마스터 스위치가
왜 있는지 이해하지 못하고 GraphQlOffAutoConfigurationImportFilter를 과잉으로 읽는다.
조치: 두 문장을 "shipped and gated"로 교체하고, APP_GRAPHQL_ENABLED / APP_GRAPHQL_DEPLOYMENT_MODE
두 환경변수와 GraphQlShippedAndGatedTest를 근거로 명시.
app-bootstrap/build.gradle:96의 주석("without both, adding the jar would publish /graphql")을
그대로 인용하면 된다.
P2 — cursor 서명이 요청 경로에 없는데 startup validator가 완성된 것처럼 보이게 한다
심각도: 높음 (보안 표현) · 비용: 설계 결정 필요
이미 CLAUDE.md가 GQL-INT-003으로 기록했고, GraphQlPolicyRequestPathTest가 사실을 고정하고 있다.
내가 grep으로 재확인했다 (§13.7): backend.graphql.cursor.key-ids를 읽는 곳은 startup validator,
actuator endpoint, configuration report 셋뿐이고 서명하는 코드는 하나도 안 읽는다.
문제의 형태:
production 기동 → 키 ID 요구 → 운영자가 넣음 → actuator가 "설정됨" 확인
↓
커서는 여전히 client-editable
닫으려면 배선이 아니라 설계 결정이 필요하다. GraphQlCursorKeyRing.of는 Map<String, byte[]>를
받고, 설정 계약은 "키 자체는 설정에 나타나지 않는다"다. 즉 키 재료가 어디서 오는지를 먼저
정해야 한다. 후보:
| 안 | 내용 | 비용 / 리스크 |
|---|---|---|
(a) shared-contract에 secret 조회 포트 |
중립 계약, 인프라가 구현 | 새 추상화 하나. ADR-GQL-001이 경계한 "구현체 없는 포트" 위험 |
(b) 환경변수 직접 (APP_GRAPHQL_CURSOR_KEY_<id>) |
가장 단순, 즉시 배선 가능 | 키가 환경에 노출. 현재 설정 계약("키는 설정에 나타나지 않는다")과 충돌 |
(c) composition root가 GraphQlCursorKeyRing 빈을 제공 |
leaf는 결정 안 함, adopter가 소유 | leaf가 "서명한다"고 말할 수 없음. 다만 현재 상태를 정직하게 만듦 |
| (d) capability를 명시적으로 철회 | pagination 커서 코덱을 Advanced로 강등하거나 제거 |
잘 만든 코드를 버림 |
추천은 (c) + 중간 조치. (c)는 §11.4의 GraphQlPrincipalResolver 패턴과 동일하다 — 배포 결정을
composition root에 남기고 leaf는 seam만 제공. 그리고 auto-configuration이 GraphQlCursorKeyRing
빈이 있을 때만 HmacGraphQlCursorCodec을 만들면, startup validator 메시지가 참이 된다.
중간 조치 (한 줄): 지금 당장은 startup validator 메시지가 거짓이므로 —
"a cursor signing key is required; unsigned cursors are client-editable" — 배선 전까지
GraphQlPlatformConfigurationReport에 cursorSigningWired: false를 노출하거나, 메시지를
"키 ID는 등록되지만 이 배포는 아직 커서에 서명하지 않는다"로 정정.
P3 — CLAUDE.md가 존재하지 않는 클래스 이름을 두 번 쓴다
심각도: 낮음 · 비용: 낮음
CLAUDE.md의 Responsibility와 "Error mapping" 절이 루트 패키지의 GraphqlExceptionResolver
(소문자 q)를 지목한다. 그런 타입은 없다. 실제 구조는 세 갈래다:
| 실제 타입 | 역할 |
|---|---|
error/GraphQlExceptionResolver |
등록된 예외 → 코드/카테고리/메시지 매핑 |
runtime/GraphQlWireErrorMapper |
결정자 — 무엇을 클라이언트가 보는가 |
runtime/GraphQlDataFetcherExceptionResolver |
Spring 계약 어댑터 |
아이러니한 점: 이 이름 자체가 §9.7에서 고친 버그의 잔재다 — "the two resolver classes differed only in the case of one letter", 그리고 Spring은 그중 하나만 불렀다. 문서가 사라진 쪽의 이름을 계속 들고 있다.
P4 — build.gradle 주석이 자기 파일 안에서 모순된다
심각도: 낮음 · 비용: 낮음
- line 36-38: "no production file imports
org.springframework.web,jakarta.servletororg.springframework.http" - line 55-59:
compileOnly 'jakarta.servlet:jakarta.servlet-api'+ "compileOnly is what keeps that from contradicting the paragraph above"
runtime/servlet/GraphQlRequestBodyLimitFilter가 jakarta.servlet.*을 9개 import한다.
정신은 유지된다(compileOnly라 runtimeClasspath에 없고 서버를 강제하지 않는다) — 문장만
갱신하면 된다: "no production file imports org.springframework.web or org.springframework.http;
the one servlet import is compileOnly and never reaches an adopter's runtime classpath."
CLAUDE.md의 같은 문장("production 파일 중 ... jakarta.servlet ... 을 import 하는 것은 하나도
없다")도 같이 고쳐야 한다.
P5 — GraphQlAdvancedModule에 고아 javadoc
심각도: 낮음 · 비용: 즉시
moduleboundary/GraphQlAdvancedModule.java 89행 근처:
/** The GraphQL over WebSocket protocol state machine. */
/**
* The allowlisted Spring Data compatibility path.
* ...
*/
SPRING_DATA("advanced.springdata", "advanced.springdata"),
WEBSOCKET("advanced.websocket", "advanced.websocket", "advanced.bootstrap");
WebSocket javadoc이 SPRING_DATA 위에 떠 있고, WEBSOCKET에는 javadoc이 없다. 컴파일은 되지만
javadoc 도구는 마지막 것만 연결한다. SPRING_DATA 상수 다음, WEBSOCKET 앞으로 옮기면 끝.
P6 — modelled 세 개의 배선 결정을 한 번에 정리
심각도: 중간 · 비용: 각각 설계 결정
| capability | 정책 완성도 | 없는 것 | 결정해야 할 것 |
|---|---|---|---|
| cursor 서명 | 완성 | 키 재료 출처 | P2 |
| object 인가 | 완성 (ObjectAccessPolicy + bridge) |
실행 경로 연결 configuration | 누가 GraphQlObjectAuthorizationPort를 제공하나 — leaf 기본값? adopter? |
| mutation 멱등성 | 완성 (5축 스코프) | 인터셉터 참조 configuration | 멱등성 레코드 저장소 — persisted operation과 같은 OperationalRecordStorePort를 쓸 수 있나? |
셋이 같은 모양이다: 정책은 다 만들었고 배포가 공급해야 하는 것 하나가 비어 있다.
GraphQlPrincipalResolver(§9.9)가 이미 그 패턴을 확립했으니, 셋 다 같은 방식으로 정리하는 게
일관적이다 — leaf는 seam과 "없으면 이 기능은 꺼진다"를 제공하고, composition root가 채운다.
그리고 그걸 actuator report에 노출하면 modelled/wired 구분이 문서가 아니라 런타임 사실이
된다. 이게 §27.1의 원칙을 한 단계 더 밀어붙이는 방법이다.
P7 — 408개 public 타입, 하나의 jar
심각도: 중간 (장기) · 비용: 큼
verifyGraphQlApiSurface가 늘어남을 리뷰 대상으로 만들었지만(§24.5) 줄이지는 않았고,
리뷰 시점 373 → 지금 408이다. build.gradle 주석이 계획을 적어 둔다: api와 spi가 의도된
외부 표면이고 나머지는 capability artifact로 쪼갤 때 internal이 될 후보.
지금 할 수 있는 저비용 단계:
api-surface.txt에 패키지별 집계를 주석으로 추가 — 어디가 비대한지 보이게advanced/**부터sealed/package-private 후보를 표시 (Advanced는 adopter가 직접 쓸 이유가 가장 적다)- §1.2의 미결 결정(단일 leaf vs 레지스트리 leaf)을 ADR로 매듭 — messaging이 반대 선택을 했으므로 레포 차원의 결정이지 GraphQL만의 결정이 아니다
P8 — 모듈 경계 스캐너가 소스 텍스트 기반이다
심각도: 낮음 · 비용: 중간
GraphQlBuildModel이 import 문을 텍스트로 읽는다 (§2.5). fully-qualified 이름을 본문에 직접 쓰면
빠져나간다. 실제로 GraphQlExecutionChain이 dev.caskeleton.adapter.inbound.graphql.execution. GraphQlExecutionPipelineException을 javadoc @throws에 FQN으로 쓰고,
GraphQlPlatformStartupValidator가 dev.caskeleton...GraphQlExecutionPipelineValidator를 본문에
FQN으로 호출한다 — 둘 다 합법적인 edge라 지금은 문제가 없다. 하지만 금지된 edge를 FQN으로
쓰면 규칙이 침묵한다.
저비용 보강: 스캐너에 "본문에 dev.caskeleton.adapter.inbound.graphql.<other-module>.이 나타나면
edge로 센다"를 추가. 이건 정규식 한 줄이고 negative fixture도 쉽다.
P9 — 실부하·장애 증거가 없다
심각도: 중간 · 비용: 인프라
graphqlPerformanceTest 레인이 자리를 예약하고 비어 있으면 실패한다(§24.2). GraphQlReleaseGate가
증거 없으면 릴리스를 거부한다. 즉 구조는 정직한데 증거가 없다.
GraphQlPerformanceScenario와 GraphQlFaultScenario가 시나리오 카탈로그를 이미 정의하고 있으니,
가장 값싼 첫 걸음은 cancellation leak 하나다 — javadoc이 "가장 자주 건너뛰고 가장 자주 깨진다"고
지목한 것이고, 기능 테스트로는 절대 안 잡힌다.
P10 — GQL-009 편차에 ADR이 없다
심각도: 낮음 (프로세스) · 비용: 낮음
2026-08-14 리뷰의 GQL-009는 *"repository 자동 노출은 Advanced여도 제거한다. application use case를 우회하는 예외를 만들지 않는다"*였다. 구현은 제거하지 않고 allowlist 4중 방어를 붙여 남겼다 (§23.9). 그 선택 자체는 방어 가능하고 javadoc이 잘 방어한다 — 하지만 ADR이 없다.
ADR-GQL-001은 다른 세 가지(context / object 인가 / persisted SPI)를 다룬다. springdata 유지 결정에 대한 기록이 없으면 다음 리뷰가 같은 지적을 반복하고, 그때 이 javadoc을 다시 발견해야 한다.
ADR-GQL-002 한 장이면 충분하다: 리뷰 지적 → 실제 선택 → 4중 방어 → unsupported.repositoryAutoExposure가
Advanced 밖에서 여전히 금지한다는 사실 → 언제 제거할 것인가.
P11 — 문서의 테스트 숫자 재확인
심각도: 낮음 · 비용: 명령 4개
CLAUDE.md가 Stable 605 / Contract 9 / Advanced 152 / 기본 test 757 / qualification 8을 적고,
마지막 green 실행에서 다시 읽어 갱신하라는 규칙도 함께 적어 두었다. 현재 소스에
@Test+@ParameterizedTest가 762개이므로 (세는 단위가 다르지만) 한 번 돌려서 갱신할 시점이다.
이건 이미 문서화된 규칙의 실행일 뿐이다.
우선순위 요약
| ID | 무엇 | 비용 | 지금 할까 |
|---|---|---|---|
| P1 | classpath opt-in 문서 drift | 낮음 | ��� — 즉시 |
| P3 | 존재하지 않는 클래스 이름 | 낮음 | 예 — P1과 함께 |
| P4 | build.gradle 자기모순 주석 | 낮음 | 예 — P1과 함께 |
| P5 | 고아 javadoc | 즉시 | 예 — P1과 함께 |
| P2 | cursor 미배선 | 설계 결정 | 결정만 먼저 |
| P6 | modelled 3개 배선 | 각 설계 결정 | P2 결정 후 같은 패턴으로 |
| P10 | GQL-009 ADR | 낮음 | 다음 리뷰 전에 |
| P8 | 스캐너 FQN 보강 | 중간 | 여유 있을 때 |
| P9 | 부하·장애 증거 | 인프라 | 릴리스 주장 전에 |
| P11 | 테스트 숫자 갱신 | 명령 4개 | 아무 때나 |
| P7 | public 표면 축소 | 큼 | ADR 먼저 |
P1/P3/P4/P5는 전부 문서·주석 수정이고 한 커밋에 들어간다. 그리고 넷 다 "코드는 맞는데 산문이 틀렸다"는 같은 부류다 — 이 leaf가 코드에 왜를 쓰는 규율이 강한 만큼, 산문 쪽이 뒤처졌다.
29. 기술 블로그 글감
tech-log용. 각각 독립적으로 읽히고, 코드 인용이 이미 확보돼 있다.
1. "꺼져 있다"를 구조로 만들기 — Spring Boot 인바운드 어댑터의 off 계약
핵심: @ConditionalOnProperty만으로는 안 된다. 프레임워크가 스스로 라우트를 연다.
4겹(imports 진입점 / 마스터 조건 / AutoConfigurationImportFilter / EnvironmentPostProcessor)과
실제 포트 404 테스트. 그리고 "a misspelled entry fails open silently".
→ §3. 가장 재사용성이 높은 글감이다.
2. 정책 객체의 단위 테스트는 정책이 강제된다는 증거가 아니다
핵심: 같은 레포에서 네 번 나타난 같은 결함 — 캐시, DataLoader, 관측 convention, resolver 등록.
그리고 테스트가 런타임에 없던 등록을 스스로 공급했다는 결정적 사례.
대응으로서의 modelled/wired/integration-verified/production-verified 등급 어휘.
→ §9.6, §22.1, §25.3.
3. 하나의 질문에 두 설정 — split-brain 안전 축이 만든 출하 기본값
핵심: production=false + environment=PRODUCTION_PUBLIC이 같은 record에. 결과가
"인터넷 노출 프로덕션인데 보호는 노트북처럼". 축을 하나로 합치는 리팩터링과, 은퇴한 키를
무시하지 않고 거부하기.
→ §4.1, §4.2.
4. 커서에 서명하기 — 그리고 그것만으로 부족한 이유
핵심: HMAC 서명은 "서버가 발급했다"만 증명한다. "이 요청을 위해 발급했다"는 증명하지 않는다.
GraphQlCursorScope의 4축(query profile / filter fingerprint / direction / tenant scope)과
4개를 한 값으로 넘겨 부분 검사를 불가능하게 만드는 API 설계.
→ §13.2, §13.3.
5. 이스케이프 대신 길이 프레이밍 — 같은 버그를 두 번 만난 이야기
핵심: 커서 봉투와 mutation 지문이 각각 구분자 문제로 깨졌다. 커서는 라운드트립 실패, 지문은 충돌(= 멱등성 붕괴). 두 수정이 서로 다른 방향으로 갔다는 점. → §13.4, §14.4, §27.4.
6. 정규식은 카디널리티를 묶지 않는다 — 잘 만든 클라이언트가 메트릭 백엔드를 죽이는 법
핵심: Query0000001... 전부 유효. 정규식이 검사하고 있어서 경계가 있어 보였다.
allowlist + 레지스트리 기반 이름 + 실제 MeterRegistry로 10,000 → 1을 측정하는 테스트.
→ §19.1, §19.2.
7. GraphQL nullability는 실패 격리 결정이다
핵심: non-null 필드의 null은 가장 가까운 nullable 조상까지 올라간다. enrichment 필드에 !를
붙이면 무관한 서비스 장애가 주문 전체를 지운다. !를 리뷰 가능한 결정으로 만드는 계약 타입.
→ §16.5. GraphQL 특유 글감 중 가장 강하다.
8. field error는 200이다 — 그리고 그게 스모크 테스트를 무의미하게 만드는 법
핵심: 실행 시작 여부가 4xx/200을 가른다. "에러 처리를 개선"해서 500으로 바꾸면 위생이 좋아
보이는데 클라이언트는 동작하는 응답의 절반을 잃는다. 그리고 상태 코드만 보는 스모크는 통과한다.
graphql-smoke.sh가 바디를 읽는 이유.
→ §7.2, §24.3, §25.4.
9. 없어진 검사와 통과한 검사를 구분하기 — fail-closed 테스트 레인 설계
핵심: .gitignore의 build/가 Java 패키지를 삼킨 사건에서 시작해서, requiredStableClasses /
stale XML 삭제 / 빈 performance 레인 실패 / "타입이 아직 존재한다" 짝 테스트 / 빈 walk 예외까지
여섯 개의 같은 원리.
→ §2.1, §24.2, §25.2, §27.3.
10. 레지스트리 없이 모듈 경계 지키기 — 단일 leaf 안의 40개 모듈
핵심: messaging은 24 leaf를 등록했고 graphql은 40개 sub-package를 골랐다. 둘 다 같은 레포에 있고 어느 쪽으로 통일할지가 미결이다. enum으로 모듈을 선언하고 소스 스캔으로 강제하는 방법, purity 등급, longest-prefix ownership, 그리고 다섯 규칙 각각의 negative fixture. → §1.2, §2.
11. 의존 방향을 뒤집지 않고 인바운드가 durable 저장을 갖는 법
핵심: persisted operation 레지스트리가 인바운드 SPI인데 Postgres 어댑터가 그걸 구현하면
방향이 뒤집힌다. 중립 OperationalRecordStorePort + 매핑 클래스 하나 + ADR이 "아무도 구현하지
않는다"는 전제를 상시 테스트로 바꾸는 것.
→ §23.1, §11.4, §25.2. ADR-GQL-001 원문이 통째로 좋은 인용거리다.
12. 감사 기록이 없느니만 못한 순간
핵심: 문자열 operator + ArrayList 감사 + 비원자적 변경. "An audit trail with any of those
properties is worse than none, because it is trusted." 그리고 시도된 변경이 아니라 일어난 변경을
기록해야 한다는 것 (ABSENT -> BLOCKED).
→ §23.2.
13. 977KB로 필드 한 개짜리 스키마를 지키기 — 플랫폼과 API를 분리한다는 것
핵심: 이 leaf의 정체성 자체가 글감이다. feature-agnostic 인바운드 플랫폼, 서버를 고르지 않기,
인증을 구현하지 않기, _health 하나만 소유하기. 그리고 그 선택이 만든 테스트 전략(계약
스위트를 testFixtures로 내보내고 production jar를 열어서 검사).
→ §0, §1.3, §24.3, §24.4.
부록 A — 이 문서를 쓰면서 쓴 명령
cd src
# 포커스 테스트
./gradlew :adapter:inbound:graphql:test --console=plain
# 플랫폼 레인 4개
./gradlew :adapter:inbound:graphql:graphqlStableTest --console=plain
./gradlew :adapter:inbound:graphql:graphqlContractTest --console=plain
./gradlew :adapter:inbound:graphql:graphqlAdvancedTest --console=plain
./gradlew :adapter:inbound:graphql:graphqlPerformanceTest --console=plain # 실부하 인프라 필요
# 전송 qualification (no-skip)
./gradlew :adapter:inbound:graphql:test \
--tests dev.caskeleton.adapter.inbound.graphql.GraphqlHttpBoundaryQualificationTest \
--console=plain
# 아티팩트 게이트
./gradlew :adapter:inbound:graphql:verifyGraphQlProductionJar --console=plain
./gradlew :adapter:inbound:graphql:verifyGraphQlApiSurface --console=plain
./gradlew :adapter:inbound:graphql:updateGraphQlApiSurface -PapproveGraphQlApiSurfaceChange
# 아키텍처 전역
./gradlew verifyCleanArchitectureDependencies --console=plain
./gradlew :app-bootstrap:test --tests '*CleanArchitectureTest' --console=plain
./gradlew verifyPublicPathSnapshot --console=plain
# app-bootstrap 쪽 GraphQL 경계·활성화 테스트
./gradlew :app-bootstrap:test --tests '*GraphQl*' --console=plain
규모·사실 확인에 쓴 것들:
G=src/adapter/inbound/graphql
B=$G/src/main/java/dev/caskeleton/adapter/inbound/graphql
# 규모
find $G/src/main/java -name '*.java' | wc -l
find $G/src/main/java -name '*.java' -exec cat {} + | wc -l
# 패키지별 규모
for d in $B/*/; do printf "%-16s %3s %8s\n" "$(basename $d)" \
"$(ls $d*.java | wc -l)" "$(cat $d*.java | wc -c)"; done
# 프레임워크 바인딩 파일 (purity 등급 교차검증)
grep -rlE '^import (org\.springframework|graphql\.|reactor\.|io\.micrometer|jakarta\.servlet|org\.dataloader)' \
$B --include='*.java' | wc -l
# 클래스 javadoc만 훑기 (전 파일 표면 읽기에 사용)
for f in $B/<pkg>/*.java; do
echo "--- $(basename $f .java)"
awk '/^\/\*\*/{p=1} p{print} /\*\//{if(p){exit}}' "$f" \
| sed 's/^ \* \?//;s/^\/\*\*//;s/^ \*\///' | grep -v '^$' | head -6
done
# 문서 주장 반증
grep -n "graphql" src/app-bootstrap/build.gradle
grep -rn "keyIds\|GraphQlCursorKeyRing" $B --include='*.java'
grep -rn "^import jakarta.servlet" $B --include='*.java'
grep -rn "GraphqlExceptionResolver" src/ --include='*.java'
부록 B — 이 leaf를 처음 읽는 사람을 위한 순서
내가 실제로 읽은 순서이고, 다시 읽어도 이 순서로 할 것 같다.
1단계 — 정체성 (30분)
src/config/architecture/modules.json의 GraphQL 레코드 — 네 줄src/adapter/inbound/graphql/CLAUDE.md— 모듈 규칙 SSOT. §28-P1/P3의 drift를 알고 읽을 것src/adapter/inbound/graphql/README.md— 설계 근거src/gradle/graphql-platform-conventions.gradle— 150줄. 레인 4개와 fail-closed 3겹src/adapter/inbound/graphql/build.gradle— 의존 결정의 근거가 전부 주석에 있다
2단계 — 경계와 스위치 (1시간)
6. moduleboundary/ 4개 — 이 leaf가 자기를 어떻게 나누는지
7. autoconfigure/GraphQlRootAutoConfiguration → GraphQlOffAutoConfigurationImportFilter
→ GraphQlActivationEnvironmentPostProcessor → META-INF/spring.factories
8. autoconfigure/GraphQlDeploymentMode — javadoc이 사건 기록이다
9. autoconfigure/GraphQlPlatformStartupValidator — problems와 runtimeProblems 둘 다
3단계 — 요청 경로 (2시간)
10. execution/GraphQlExecutionStage + GraphQlExecutionPipelineValidator — 순서와 근거
11. runtime/GraphQlPlatformWebInterceptor — CONTEXT 단계
12. runtime/GraphQlPlatformInstrumentation — 훅 선택이 설계 결정이다
13. runtime/GraphQlExecutionChain + GraphQlExecutionContext + GraphQlExecutionHandler
14. 세 핸들러: GraphQlOperationSelectionHandler → GraphQlDocumentAuthorizationHandler
→ GraphQlCostBudgetHandler
15. runtime/GraphQlWireErrorMapper — 세 계약이 하나가 된 기록
4단계 — 정책 (3시간, 관심 순서대로)
16. context/GraphQlCommandAttribution + TenantContext + GraphQlIdentityFingerprinter
17. http/ — GraphQlHttpStatusMapper, GraphQlAcceptHeader, GraphQlJsonValues
18. cost/GraphQlDocumentComplexityScorer
19. pagination/HmacGraphQlCursorCodec + GraphQlCursorScope + GraphQlCursorFraming
20. security/GraphQlAuthorizationPolicy + GraphQlTenantIsolationPolicy + GraphQlContextPropagator
21. dataloader/GraphQlBatchValue + GraphQlBatchResultMapper
22. error/GraphQlFailureBoundary + GraphQlNullabilityContract
23. observation/GraphQlOperationNameCardinality
5단계 — Advanced (2시간, 필요할 때)
24. advanced/bootstrap/ 6개 — 게이트 구조
25. advanced/persisted/ + advanced/admin/ — 이 leaf에서 가장 정교한 부분
26. 나머지 15개 패키지는 클래스 javadoc만 읽어도 설계 결정이 전부 잡힌다
6단계 — 증거 (1시간)
27. docs/adr/ADR-GQL-001-graphql-context-and-storage-ownership.md
28. app-bootstrap/src/test/.../architecture/GraphQlInboundOwnershipBoundaryTest
29. app-bootstrap/src/test/.../contract/graphql/GraphQlResolverReachabilityTest
30. infra/graphql/smoke/graphql-smoke.sh
31. src/testFixtures/java/.../testkit/ — 계약 스위트가 무엇을 주장하는지
7단계 — 역사 (읽으면 나머지가 전부 이해된다)
32. docs/reviews/2026-08-14-graphql-module-code-review.md — 889줄.
javadoc의 "예전에는 이랬다"가 전부 여기 있다. §26의 대조표와 함께 읽을 것.