# GraphQL 인바운드 모듈 완전 해부 > **성격**: 정책 문서가 아니라 **읽기 기록**이다. `adapter:inbound:graphql` leaf와 그 주변 > (app-bootstrap 배선, 테스트 레인, 아티팩트 게이트, 스모크 스크립트, ADR, 코드 리뷰 문서)에 > 실제로 구현되어 있는 것을 코드 기준으로 하나씩 확인하면서, "무엇이 있는가"와 "왜 그렇게 > 되어 있는가"를 정리한 글이다. > > SSOT는 여전히 `src/config/architecture/modules.json`, 모듈 > [CLAUDE.md](../../src/adapter/inbound/graphql/CLAUDE.md), > [README.md](../../src/adapter/inbound/graphql/README.md), > [ADR-GQL-001](../adr/ADR-GQL-001-graphql-context-and-storage-ownership.md)이다. > 이 문서와 그것들이 어긋나면 그쪽이 맞다 — **단, §28에 적은 것들은 예외다. 거기 적힌 것은 > 문서 쪽이 코드와 어긋나 있는 항목이고, 코드가 맞다.** > > 마지막 장(§28)에 **읽으면서 발견한 손볼 거리**를 우선순위대로 모아 두었다. > **커버리지 범위 (읽은 것과 안 읽은 것)** > > 이 leaf는 production Java 408개 파일이고, 그중 **전부**를 최소한 클래스 javadoc 수준까지 > 읽었다. 정독(본문 전체를 읽음)한 범위와 표면 읽기(클래스 javadoc + 시그니처 + 참조 스캔)로 > 끝낸 범위는 아래와 같다. > > | 영역 | 파일 | 이 문서에서 | > |---|---:|---| > | `moduleboundary`, `autoconfigure`, `runtime`, `execution`, `http`, `cost`, `security` | 116 | **전문 정독** (§2–§11) | > | `api`, `policy`, `context`, `error`, `dataloader`, `pagination`, `mutation`, `fetch`, `schema`, `scalar`, `compat`, `observation`, `architecture`, `release` | 157 | **핵심 타입 정독 + 나머지 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에 대해 말하는 전부는 이것이다: ```json { "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"] } ``` 네 줄이 네 가지를 못 박는다. 1. **outbound 어댑터에 대한 의존이 없다.** 인바운드는 application 아웃바운드 포트를 통해서만 persistence/messaging/cache/http에 닿는다. Gradle 게이트 (`verifyCleanArchitectureDependencies`)와 ArchUnit `INBOUND_ADAPTERS_DO_NOT_DEPEND_ON_OUTBOUND_ADAPTERS`가 둘 다 지킨다. 2. **`application-core`가 선언만이 아니라 실제 의존이다.** object 인가의 *답하는* 계약 `dev.caskeleton.application.security.ObjectAccessPolicy`가 거기 살기 때문이다. 이 leaf가 그 계약을 소유했다면 application 구현체가 인바운드 전송을 컴파일 의존해야 했다 — 방향이 뒤집힌다. 이게 ADR-GQL-001의 세 결정 중 하나고, §11.4에서 다시 다룬다. 3. **`shared-contract`가 있다.** persisted operation의 durable 저장을 위한 중립 계약 `dev.caskeleton.shared.opstore.OperationalRecordStorePort`가 거기 있다 (§23.2). 4. **`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에만** 있다. 예전에는 production `implementation`이어서 모든 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`로 기본값 처리한다. ```java // 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 `CORE` module 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. A > `FRAMEWORK_BOUND` module 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 조건부 어댑터는 이렇게 쓴다: ```java @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`** — 마스터 조건을 가진 유일한 클래스. ```java @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개를 명시적으로 막는다: ```java "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은 이 레포에서 가장 잘 쓰인 설계 기록 중 하나다. **예전 상태**: 안전 태세가 두 설정이었다. - `production` boolean → 익명 principal 처리, allow-by-default 인가, 요청 보호 일부 - `environment` enum → 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)` — 설정 값 검증.** 첫 검사가 특별하다: ```java // 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는 여기로 못 들어온다 두 가지가 명시적이다. 1. **Stable만 조립한다.** Advanced가 Stable starter를 통해 들어오는 건 모듈 경계가 막으려는 사고 그 자체다. 2. **Boot의 GraphQL auto-configuration 뒤에 순서가 잡힌다.** 앞이면 프레임워크가 나중에 덮어쓴다. 그리고 production에서 authorization policy 빈이 없으면 **기동을 거부한다**: ```java 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개 필드다: ```java 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`가 런타임 대신 여기서 강제하는 두 불변식: - `STREAM` resolver는 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 `TenantSource` records 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 주석이 이유를 쓴다: > *`compileOnly` is 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단계와 그 순서의 근거 ```java 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)` 쌍으로 순서를 표현한다. 이 형태의 장점: 새 단계가 끼어들어도 **관련 있는 제약만** 검사하면 되고, 어떤 제약이 깨졌는지 메시지로 나온다. 검사 세 가지: 1. `required()` 단계가 빠졌나 2. 같은 단계가 두 번 등록됐나 3. 순서 쌍 위반 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. 하는 일 순서: ```java public Mono 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 **바이트**로 잰다: ```java 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회 호출한다"를 사실로 만드는 것**이다. > *`beginExecuteOperation` is 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.* 훅 선택 자체가 설계 결정이고, 그 근거가 코드 옆에 있다. **컨텍스트가 없으면 통과시키지 않고 거부한다:** ```java GraphQlRequestContext requestContext = execution.getGraphQLContext().get(CONTEXT_KEY); if (requestContext == null) { throw new AbortExecutionException(List.of( GraphQlWireErrors.toGraphQlError( GraphQlWireError.internal(executionId).withConstraint(MISSING_CONTEXT_CONSTRAINT)))); } ``` 컨텍스트는 전송 인터셉터가 만드니까, 없다는 건 **인터셉터가 배선되지 않았다**는 뜻이다 — 그리고 그 설정이 정확히 모든 정책이 조용히 아무것도 안 하는 설정이다. 체인이 끝난 뒤 두 가지를 컨텍스트에 **다시 쓴다**: ```java // 파이프라인이 확정한 컨텍스트 (전송은 파싱 전 컨텍스트밖에 못 만든다) 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.* ```java public GraphQlExecutionPipeline pipeline() { List 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개 핸들러다: ```java 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 `ApiErrorCarrier` to 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].* 인터셉터가 그 결정을 한다: ```java Optional 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: $n` would 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 두 규칙 1. 보호된 오퍼레이션에 tenant가 없으면 **실패한다.** untenanted로 진행하지 않는다 — 대안은 모든 tenant를 조용히 가로지르는 쿼리다. 2. 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 `null` in 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개 필드 ```java public record GraphQlCursorPayload( int version, String queryProfile, String direction, // FORWARD | BACKWARD Map 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` — 네 개를 한 값으로 넘기는 이유 ```java 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 값이면 정상 발급된 커서가 읽히지 않았다** 지금은 길이 프레이밍이다: `:`. 읽는 쪽이 정확히 얼마나 읽을지 알고, **특별한 문자가 없다.** 길이는 `char` 단위 — `substring`이 소비하는 단위 — 라서 surrogate pair도 프레임되고 그대로 읽힌다. > *Framing removes the problem rather than escaping around it.* 이스케이프 버그를 이스케이프로 고치지 않는다는 게 요점이다. ### 13.5 `HmacGraphQlCursorCodec` — 네 가지 방어 ```java public static final int MAXIMUM_CURSOR_CHARS = 4096; private static final String ALGORITHM = "HmacSHA256"; ``` **(1) 디코드 전 크기 검사.** ```java 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) 상수 시간 비교.** ```java 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) 현재 버전만 발급, 과거 버전은 해석만.** ```java 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 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`가 그 사실을 고정한다: ```java .withPropertyValues("backend.graphql.cursor.key-ids=cursor-key-1") ... assertThat(context).doesNotHaveBean(GraphQlCursorKeyRing.class); ``` `CLAUDE.md`의 표현이 정확하다: > **결함은 "미완성"이 아니라 startup validator가 하나를 완성된 것처럼 보이게 만든다는 것이다.** 프로덕션이 키 식별자를 요구하고, 운영자가 넣고, 엔드포인트가 "설정됨"이라고 확인해 주는데, 커서는 validator 메시지가 막는다고 말한 그대로 client-editable로 남는다. 닫으려면 배선이 아니라 **설계 결정**이 필요하다. `GraphQlCursorKeyRing.of`는 `Map`를 받고 설정 계약은 "키 자체는 설정에 나타나지 않는다"이므로, **키 재료가 어디서 오는지**를 먼저 정해야 한다 (§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 `null` at 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+1000000` is eleven characters, parses instantly, and holds a precision of one — and then > `toPlainString()` 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 두 개의 청중을 따로 센다 **이게 이 패키지의 핵심 아이디어다.** ```java 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 `switch` is 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` 제거의 전제조건 체인: 1. `GraphQlSchemaUsage` — 누가 이 좌표를 쓰는가 2. `GraphQlDeprecationGate` — deprecation 기간을 지켰는가 3. `GraphQlClientOwnerApproval` — 클라이언트 소유자가 승인했는가 4. `GraphQlRemovalDecision` — 그래서 제거 가능한가 `GraphQlCompatibilityPolicy` / `GraphQlCompatibilityReport`가 정책과 결과를 담는다. --- ## 19. `observation` — 카디널리티는 타입으로 지켜지지 않는다 9개 파일 23KB. ### 19.1 `GraphQlMetricCardinalityPolicy` — allowlist, denylist 아님 ```java public static final Set 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 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` — 정규식은 카디널리티를 묶지 않는다 **이 패키지에서 가장 좋은 통찰.** > *`GraphQlOperationName` bounds an operation name's syntax and length, which is a different > property from bounding how many distinct ones exist. A client is free to send `Query0000001`, > `Query0000002` and 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` | `List` | | `Mono` | `Mono` | | `Optional` | `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`도 `Publisher`다. 그래서 **Spring for GraphQL이 지원하고 문서화하는** `Mono 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 `UNSUPPORTED` has 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 `OperationalRecord` and 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, `updateStatus` accepted anything, and the sequence that mattered was > `BLOCKED → 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 `String` and 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 > `ArrayList` field. 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 -> BLOCKED` for 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-ws` multiplexes 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 `@GraphQlRepository` auto-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 `WHERE` clause — 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 `_entities` and `_service` tells 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` — 빈 레인이 아니라 사라진 클래스를 잡는다** ```groovy List requiredStableClasses = [ "${platformPackage}.moduleboundary.GraphQlModuleBoundaryTest".toString(), ] ``` 주석이 이유를 정확히 쓴다: > *`failOnNoMatchingTests` only 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 삭제** ```groovy 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 레인은 실패다** ```groovy // `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`** — 실행되지 않은 증거는 **통과가 아니다**: ```java 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 `implementation` where a `testFixturesImplementation` belonged, 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: > `public` inside 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.* 승인 후 갱신: ```bash ./gradlew :adapter:inbound:graphql:updateGraphQlApiSurface -PapproveGraphQlApiSurfaceChange ``` 원래 리뷰 GQL-025(*"373개 type의 과도한 public surface"*)의 부분적 답이다 — 표면을 게이트했지 줄이지는 않았고, 오히려 373 → 408로 늘었다. 하지만 이제 **늘어난 35개는 전부 리뷰를 거쳤다.** ### 24.6 `registerStrictQualificationTest` — no-skip 증거 ```groovy 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`: ```groovy // 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`: ```yaml 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`가 **두 가지를 동시에** 주장한다: 1. facade가 classpath에 있다 2. 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 밖에서 아무도 구현하지 않는다"는 근거 위에 서 있다.** 이 테스트가 그 스냅샷 관찰을 **상시 조건**으로 바꾼다. ```java private static final List 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."* 그리고 **두 번째 테스트가 있다:** ```java @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 a `Files.walk` stream 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 as > `NotificationPlatformAutoConfiguration` and the persistence stereotypes before it.* **테스트가 런타임에 없던 등록을 스스로 공급했다.** 두 테스트 다 green인데 프로덕션은 깨져 있었다. 그리고 javadoc이 이게 이 레포에서 **세 번째** 같은 모양이라고 적는다. 찾아낸 건 레인이다: > *`graphql-smoke` sends an authenticated query to `/graphql` and reads the body rather than the > status, because a failed resolution is a `200` carrying an `errors` array.* 수정은 `GraphQlRootAutoConfiguration`의 `@Import`에 컨트롤러를 넣는 것 (§3.2). 테스트 자체가 두 가지를 고정한다: 1. 등록된 root가 resolver를 import한다 (소스 텍스트에서 `HealthGraphqlController.class` 검색) 2. 레인이 실제 요청을 보낸다 (`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` | — | 그리고 **바디를 읽는다:** ```sh # 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`를 받고, 설정 계약은 "키 자체는 설정에 나타나지 않는다"다. 즉 **키 재료가 어디서 오는지**를 먼저 정해야 한다. 후보: | 안 | 내용 | 비용 / 리스크 | |---|---|---| | (a) `shared-contract`에 secret 조회 포트 | 중립 계약, 인프라가 구현 | 새 추상화 하나. ADR-GQL-001이 경계한 "구현체 없는 포트" 위험 | | (b) 환경변수 직접 (`APP_GRAPHQL_CURSOR_KEY_`) | 가장 단순, 즉시 배선 가능 | 키가 환경에 노출. 현재 설정 계약("키는 설정에 나타나지 않는다")과 충돌 | | (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.servlet` or `org.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행 근처: ```java /** 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이 될 후보**. 지금 할 수 있는 저비용 단계: 1. `api-surface.txt`에 **패키지별 집계**를 주석으로 추가 — 어디가 비대한지 보이게 2. `advanced/**`부터 `sealed`/package-private 후보를 표시 (Advanced는 adopter가 직접 쓸 이유가 가장 적다) 3. §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..`이 나타나면 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 — 이 문서를 쓰면서 쓴 명령 ```bash 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 ``` 규모·사실 확인에 쓴 것들: ```bash 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//*.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분)** 1. `src/config/architecture/modules.json`의 GraphQL 레코드 — 네 줄 2. `src/adapter/inbound/graphql/CLAUDE.md` — 모듈 규칙 SSOT. **§28-P1/P3의 drift를 알고 읽을 것** 3. `src/adapter/inbound/graphql/README.md` — 설계 근거 4. `src/gradle/graphql-platform-conventions.gradle` — 150줄. 레인 4개와 fail-closed 3겹 5. `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의 대조표와 함께 읽을 것.