- CI 단계 분리 계획 추가 (docs/superpowers/plans/2026-09-16-ci-stage-separation.md). 빌드·CI 레이어 전수 리뷰 133건의 결론과 Track A/B/C 작업 순서를 담는다. - public-path 보안 기준선을 실제 배포 기본값(/v1/healthcheck)으로 재생성. 이전 값은 gitignore 된 src/.env 에서 유래해 재현이 불가능했다. - 진행 중이던 ADR·리뷰·테스트 전략 문서 반영, 대체된 grpc 계획 문서 제거. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
3658 lines
192 KiB
Markdown
3658 lines
192 KiB
Markdown
# 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<WebGraphQlResponse> intercept(WebGraphQlRequest request, Chain chain) {
|
||
GraphQlRequestContext context;
|
||
try {
|
||
verifyRequestBounds(request); // 1. 크기·형태 검사
|
||
context = requestContext(request); // 2. 컨텍스트 구성
|
||
} catch (RuntimeException rejection) {
|
||
return Mono.just(reject(request, rejection)); // 3. 거부도 wire error로
|
||
}
|
||
|
||
request.configureExecutionInput((executionInput, builder) ->
|
||
builder.graphQLContext(ctx -> ctx.put(GraphQlRequestContext.CONTEXT_KEY, context)).build());
|
||
|
||
// 4. Reactor context에도 — reactive resolver가 같은 값을 읽게
|
||
return chain.next(request).contextWrite(view -> view.put(GraphQlRequestContext.class, context));
|
||
}
|
||
```
|
||
|
||
**두 곳에 넣는다**는 게 중요하다. GraphQL context와 Reactor context 둘 다. 주석이 이유를 쓴다:
|
||
*"a reactive resolver reads the same value rather than a second one assembled from whatever it can
|
||
reach."*
|
||
|
||
`verifyRequestBounds`가 UTF-8 **바이트**로 잰다:
|
||
|
||
```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<GraphQlExecutionStage> stages = new ArrayList<>();
|
||
stages.add(GraphQlExecutionStage.CONTEXT); // 전송 인터셉터 소유
|
||
handlers.forEach(handler -> stages.add(handler.stage()));
|
||
stages.add(GraphQlExecutionStage.EXECUTE); // graphql-java 소유
|
||
return new GraphQlExecutionPipeline(stages);
|
||
}
|
||
```
|
||
|
||
두 단계는 프레임워크 seam이 소유하므로 유도된 뷰에 **명시적으로 넣는다.** 그래야 순서 validator가
|
||
요청 경로 **전체**를 판단하지 가운데만 판단하지 않는다.
|
||
|
||
생성자에서 곧바로 `GraphQlExecutionPipelineValidator.validate(pipeline())`를 부른다 — **잘못된
|
||
체인은 만들어질 수 없다.**
|
||
|
||
Stable 체인은 3개 핸들러다:
|
||
|
||
```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<GraphQlAuthenticatedPrincipal> principal = principalResolver.resolve(request);
|
||
if (principal.isPresent()) return contextFactory.create(principal.get(), deadline);
|
||
return contextFactory.createAnonymous(
|
||
anonymousProfile, anonymousTenant, traceId(request), deadline, anonymousProfileProtected);
|
||
```
|
||
|
||
`anonymousProfileProtected`가 true면 익명 요청은 아예 거부된다.
|
||
|
||
---
|
||
|
||
## 10. `cost` — 네 개의 층
|
||
|
||
21개 파일 56KB. 비용 통제가 한 겹이 아니라 네 겹이다.
|
||
|
||
| 층 | 클래스 | 무엇을 막나 | 언제 |
|
||
|---|---|---|---|
|
||
| 1 | `GraphQlParserLimitPolicy` / `GraphQlParserLimits` / `GraphQlParserOptionsFactory` | 파서 자체가 폭발하는 것 | 파싱 중 |
|
||
| 2 | `GraphQlDocumentShapeAnalyzer` / `GraphQlStructuralLimitPolicy` | depth, alias, 필드 수, fragment | 파싱 후 |
|
||
| 3 | `GraphQlComplexityCalculator` / `GraphQlDocumentComplexityScorer` / `GraphQlCostCatalog` | 카디널리티 곱셈 | 실행 전 |
|
||
| 4 | `GraphQlRuntimeBudgetTracker` / `GraphQlResponseNodeCounter` / `GraphQlResponseByteLimiter` | 실제 응답 크기 | 실행 중 |
|
||
|
||
### 10.1 층 1 — 파서 한계
|
||
|
||
graphql-java의 `ParserOptions`에 넘길 값을 정책으로 만든다. 파서는 **입력에 비례해 할당**하므로,
|
||
파싱 후에 재는 건 이미 늦다 (§7.4와 같은 논리).
|
||
|
||
### 10.2 층 2 — 문서 형태
|
||
|
||
`GraphQlDocumentShapeAnalyzer`가 파싱된 문서를 걸어서 `GraphQlDocumentShape`를 만든다. 원래 리뷰
|
||
GQL-007이 *"named fragment가 custom introspection gate를 우회하고 variable 입력 구조는 측정하지
|
||
않는다"*였고, 지금은 `GraphQlFragmentReachabilityTest`가 fragment를 통한 도달성을 고정한다.
|
||
|
||
### 10.3 층 3 — 카디널리티 인지 복잡도 점수
|
||
|
||
`GraphQlDocumentComplexityScorer`가 이 패키지의 핵심이다. 세 가지가 인상적이다.
|
||
|
||
**(1) 스키마 인지 순회.** 좌표는 `TypeName.fieldName`이고, 타입 절반은 각 selection set을 스키마에
|
||
대해 해석해야 존재한다.
|
||
|
||
> *Guessing it from the operation root would price `order { customer { orders { … } } }` as three
|
||
> root fields and miss the multiplication entirely.*
|
||
|
||
**(2) 카디널리티는 스키마가 아니라 요청에서 온다.**
|
||
|
||
> *a connection's children are multiplied by the effective page size, and a page size supplied
|
||
> through a variable is resolved from the request variables rather than assumed to be the default.
|
||
> That is the difference between a budget and a suggestion — `first: $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<String, String> keyset,
|
||
String filterFingerprint,
|
||
String tenantScope,
|
||
String keyId) {
|
||
```
|
||
|
||
각 필드가 하나의 공격을 막는다.
|
||
|
||
| 필드 | 없으면 |
|
||
|---|---|
|
||
| `queryProfile` | 한 필터된 목록의 커서를 다른 목록에 replay — 서버가 전혀 다른 의미의 위치에서 이어감 |
|
||
| `filterFingerprint` | 동일. 새 필터 하에서 접근 권한 없던 행을 받음 |
|
||
| `direction` | forward 커서를 backward로 replay → **페이지 경계를 반대로 걸음** |
|
||
| `tenantScope` | 한 tenant의 커서를 다른 tenant가 replay → **시작할 수도 없던 스캔을 이어감** |
|
||
| `keyId` | 회전 불가 |
|
||
| `version` | 봉투 의미가 바뀌면 이미 발급된 커서를 해석할 수 없음 |
|
||
|
||
**payload는 credential도 raw tenant도 담지 않는다.** `tenantScope`는 호출자가 공급하는 불투명
|
||
fingerprint다 — 이 모듈이 context 타입에서 자유로워야 하고, **커서는 클라이언트가 들고 읽을 수
|
||
있는 값**이기 때문이다.
|
||
|
||
### 13.3 `GraphQlCursorScope` — 네 개를 한 값으로 넘기는 이유
|
||
|
||
```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 값이면 정상 발급된 커서가 읽히지 않았다**
|
||
|
||
지금은 길이 프레이밍이다: `<length>:<value>`. 읽는 쪽이 정확히 얼마나 읽을지 알고, **특별한 문자가
|
||
없다.** 길이는 `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<String, byte[]> keys, String activeKeyId)` / `single(keyId, secret)`.
|
||
`DEFAULT_KEY_ID = "cursor-key-1"`.
|
||
|
||
### 13.7 그런데 — 이게 요청 경로에 없다 (GQL-INT-003)
|
||
|
||
`CLAUDE.md`가 이 사실을 정직하게 적어 두었고, 나도 grep으로 확인했다.
|
||
`backend.graphql.cursor.key-ids`를 읽는 곳은 셋뿐이다:
|
||
|
||
| 읽는 곳 | 하는 일 |
|
||
|---|---|
|
||
| `GraphQlPlatformStartupValidator:42` | production인데 비어 있으면 **기동 거부** |
|
||
| `GraphQlPlatformActuatorEndpoint:62` | 리포트에 key id 노출 |
|
||
| `GraphQlPlatformConfigurationReport` | 그 값을 담음 |
|
||
|
||
**커서에 서명하는 코드는 그 설정을 하나도 읽지 않는다.** `GraphQlPolicyRequestPathTest`가 그 사실을
|
||
고정한다:
|
||
|
||
```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<String, byte[]>`를 받고 설정 계약은 "키 자체는 설정에 나타나지 않는다"이므로, **키 재료가
|
||
어디서 오는지**를 먼저 정해야 한다 (§28-P2).
|
||
|
||
### 13.8 나머지
|
||
|
||
- **`GraphQlConnectionAssembler` / `GraphQlConnection` / `GraphQlEdge` / `GraphQlPageInfo`** —
|
||
Relay Connection 조립.
|
||
- **`GraphQlKeysetWindow` / `GraphQlCursorKeyset`** — keyset 윈도우 표현.
|
||
- **`GraphQlConnectionPolicy` / `GraphQlConnectionRequest`** — 페이지 크기 상한 등.
|
||
|
||
---
|
||
|
||
## 14. `mutation` — 멱등성과 "비즈니스 결과는 에러가 아니다"
|
||
|
||
14개 파일 26KB.
|
||
|
||
### 14.1 `GraphQlBusinessResult` — 기대된 결과는 데이터다
|
||
|
||
> *Expected outcomes are data, not errors. A version conflict or a rejected state transition is
|
||
> something the client should render, and turning it into a GraphQL error both nulls the field and
|
||
> hides it among genuine failures.*
|
||
|
||
두 가지 손해를 정확히 짚는다: **필드가 null이 되고**, **진짜 실패들 사이에 섞인다.**
|
||
|
||
`GraphQlMutationPayload`가 wire 형태다 — `SUCCESS` / `CONFLICT` / `INVALID` 상태와 안정 코드가
|
||
값과 함께 이동해서, 클라이언트가 payload 모양을 뜯어보거나 메시지를 매칭하지 않고 분기한다.
|
||
|
||
`GraphQlMutationResultMapper`가 매핑하고, **예상치 못한 실패는 이 매퍼에 도달하지 않는다** —
|
||
GraphQL 에러로 남는다.
|
||
|
||
### 14.2 `GraphQlBatchMutationItemResult` — 아이템별 결과
|
||
|
||
top-level 에러 하나가 **모든 아이템의 결과를 버린다** — 성공했고 부수효과가 이미 일어난 것들까지.
|
||
그래서 `index`를 담아 클라이언트가 상관지을 수 있게 한다.
|
||
|
||
### 14.3 `GraphQlMutationIdempotencyContext` — 키 하나가 아니라 다섯 개의 스코프
|
||
|
||
```
|
||
actor × tenant × mutation coordinate × contract version × normalised input
|
||
```
|
||
|
||
| 축 | 없으면 |
|
||
|---|---|
|
||
| actor | 한 클라이언트의 재시도가 다른 클라이언트의 결과를 반환 |
|
||
| **tenant** | actor 정체성이 tenant를 함의하지 않음 — 같은 service account가 두 tenant에서 행동 가능 |
|
||
| mutation coordinate | 같은 키를 다른 mutation에 보내면 다른 오퍼레이션인데 같은 결과 |
|
||
| contract version | 계약이 바뀌면 같은 입력이 다른 의미 |
|
||
| normalised input | 같은 키에 다른 입력 = 두 개의 다른 부수효과 |
|
||
|
||
javadoc이 tenant를 왜 따로 두는지 명시한다: *"Tenant is part of the scope because actor identity
|
||
does not imply it."*
|
||
|
||
### 14.4 `GraphQlCanonicalInput` — 지문 충돌 사건
|
||
|
||
**이 leaf에서 가장 교육적인 버그다.**
|
||
|
||
이전 정규형은 최상위 키를 정렬하고 `key=value;`로 조인했다. 두 개의 서로 다른 입력이 동일하게
|
||
정규화될 수 있었다:
|
||
|
||
```
|
||
{a: "b;c=d"} → a=b;c=d;
|
||
{a: "b", c: "d"} → a=b;c=d;
|
||
```
|
||
|
||
> *Two different requests sharing a fingerprint is an idempotency collision — the second one is
|
||
> answered with the first one's result.*
|
||
|
||
§13.4의 커서 프레이밍 버그와 **같은 부류**다: 구분자를 값 안에서 구분할 수 없다. 그리고 같은
|
||
레포 안에서 두 번 일어났다. 이게 §27의 원칙 하나가 된다.
|
||
|
||
### 14.5 `GraphQlIdempotencyKey` — 양쪽이 다 묶인다
|
||
|
||
8~128자. 이유가 양방향이다:
|
||
- **너무 짧으면** 무관한 요청 간에 충돌 → 한 호출자의 재시도가 다른 호출자의 결과를 반환
|
||
- **무제한이면** 저장·로깅 문제
|
||
|
||
`GraphQlIdempotencyConflictException`은 **키도 입력도 담지 않는다.**
|
||
|
||
### 14.6 `GraphQlMutationContractValidator` — GraphQL의 serial 실행은 트랜잭션이 아니다
|
||
|
||
> *One mutation root field calls one Application use case, and several root fields in one document
|
||
> are several independent use cases. GraphQL executes them serially, which is easy to mistake for a
|
||
> shared transaction — it is not: the first can commit and the second fail.*
|
||
|
||
이건 GraphQL을 쓰는 팀이 실제로 자주 착각하는 지점이고, 여기서는 **기동 시점 계약 검증**으로
|
||
막는다. 원자적이어야 하는 작업은 필요한 걸 전부 받는 **하나의 mutation**에 속한다.
|
||
|
||
그리고 §4.3의 `unsupported.requestWideTransaction`이 설정 축에서 같은 것을 막는다.
|
||
|
||
### 14.7 `GraphQlExpectedVersion` — 여기서 행동하지 않는다
|
||
|
||
낙관적 동시성은 **저장소 보장**이고, 충돌 후 재시도 결정은 use case에 속한다.
|
||
|
||
> *a transport-level retry would repeat a side effect the client never asked to repeat.*
|
||
|
||
`GraphQlMutationIdempotencyInterceptor`는 HTTP 요청이 아니라 **mutation 단위**로 스코프한다 —
|
||
한 GraphQL 요청이 여러 root mutation을 나를 수 있고, 요청 전체를 하나의 멱등 단위로 다루면
|
||
전부 replay하거나 전부 안 하거나가 된다.
|
||
|
||
현재 등급은 `modelled`다 — 이 인터셉터를 참조하는 configuration이 없다.
|
||
|
||
---
|
||
|
||
## 15. `fetch` — over-fetch를 막는 프로파일
|
||
|
||
10개 파일 16KB. 작지만 아이디어가 선명하다.
|
||
|
||
### 15.1 문제
|
||
|
||
GraphQL selection 조합마다 쿼리를 유도하면 **클라이언트가 요청한 모양마다 새 플랜**이 생긴다.
|
||
리뷰 불가능하고 무제한이다.
|
||
|
||
### 15.2 해법 — 유한한 카탈로그 + 최소 커버 프로파일
|
||
|
||
`GraphQlFetchProfile`은 **Application query profile 이름**을 지목한다. `EntityGraph`, JPQL 문자열,
|
||
Mongo projection이 여기 나타나면 **전송 계약에 저장소 세부가 들어오는 것** — 이 간접층이 막으려는
|
||
바로 그 결합이다.
|
||
|
||
`GraphQlFetchProfileClassifier`가 **가장 작은 커버 프로파일**을 고른다. `id status` selection은
|
||
full detail이 아니라 basic query를 로드한다.
|
||
|
||
**커버하는 게 없으면 실패한다.** 가장 큰 프로파일로 폴백하지 않는다:
|
||
|
||
> *a silent upgrade to "load everything" is exactly the behaviour the fetch profile indirection
|
||
> exists to prevent.*
|
||
|
||
`GraphQlUnmappedSelectionException`은 **타입과 선택된 필드 수만** 보고하고 필드 이름은 안 한다.
|
||
|
||
### 15.3 정규화 — 별칭과 fragment
|
||
|
||
`GraphQlSelectionSetView`가 별칭을 실제 선택하는 필드로 줄이고 fragment를 확장한다. 안 그러면
|
||
`total: amount`와 `amount`가 **같은 컬럼을 로드하는데 다른 프로파일로 분류된다.**
|
||
|
||
`GraphQlSelectionCoordinate`는 필드 이름을 정규화·정렬하고, `GraphQlSelectionSignature`가 안정된
|
||
정체성 `Type{field,field}`를 만든다 — fetch 플랜 결정을 캐시하고 회귀 테스트로 고정할 수 있게.
|
||
|
||
### 15.4 등록 규칙
|
||
|
||
중복 이름과 타입당 두 번째 default는 **등록 시점 거부**다. 둘 중 하나라도 있으면 **어느 쿼리가
|
||
도는지가 등록 순서에 달리고**, 그건 리뷰어가 스키마에서 볼 수 없는 것이다.
|
||
|
||
`GraphQlFetchProfileRule`은 좌표별 오버라이드다 — "선택은 싸지만 조인이 비싼 필드"처럼 최소 커버가
|
||
답이 아닌 경우를, 프로파일 필드 목록에 인코딩하는 대신 명시한다.
|
||
|
||
---
|
||
|
||
## 16. `error` — 클라이언트가 볼 수 있는 것을 타입이 정한다
|
||
|
||
10개 파일 22KB.
|
||
|
||
### 16.1 `GraphQlWireError` — extensions는 생성 시점 allowlist
|
||
|
||
> *Everything an exception naturally carries — its class, its message, a SQL statement, a downstream
|
||
> URL, a provider body — is exactly what an attacker wants and a client cannot use, so the type
|
||
> refuses to carry it rather than relying on every call site to remember.*
|
||
|
||
**모든 호출부가 기억하는 대신 타입이 거부한다.** 이게 §9.7에서 `GraphQlWireErrors`가 한 방향만
|
||
하는 이유와 짝을 이룬다.
|
||
|
||
### 16.2 `GraphQlErrorCode` / `GraphQlErrorCategory` — 닫힌 집합
|
||
|
||
`GraphQlErrorCode`: `[A-Z][A-Z0-9_]{2,63}`. 예외 클래스나 메시지에서 유도된 코드는 **구현이 바뀔
|
||
때마다 바뀌고, 그러면서 내부를 흘린다.**
|
||
|
||
`GraphQlErrorCategory`: 닫힌 집합. 클라이언트가 프로즈를 파싱하지 않고 **재시도할지, 재인증할지,
|
||
요청을 고칠지, 비즈니스 메시지를 보여줄지** 결정할 수 있게. 그리고 닫혀 있어서 **메트릭 태그로
|
||
안전하다** — 열린 카테고리는 actionable하지도 bounded하지도 않다.
|
||
|
||
README에 `Category → ErrorType` 표가 있다.
|
||
|
||
### 16.3 `GraphQlInternalErrorMasker` — 기본은 마스킹
|
||
|
||
> *Exception messages are written for operators and routinely contain a SQL statement, a Mongo
|
||
> filter, a downstream URL with credentials, an internal hostname or a raw identifier.*
|
||
|
||
메시지는 **의도적으로 client-facing으로 만들어졌을 때만** 공개된다. 그리고 client-facing으로
|
||
표시된 메시지에도 **패턴 스캔이 2차 방어**로 돈다.
|
||
|
||
`GraphQlErrorContext`가 execution ID를 나른다:
|
||
|
||
> *the point of masking is that the detail exists somewhere the client cannot see, not that it is
|
||
> discarded.*
|
||
|
||
### 16.4 세 개의 resolver — 각자 다른 시점
|
||
|
||
| resolver | 언제 | 왜 별도인가 |
|
||
|---|---|---|
|
||
| `GraphQlRequestErrorMapper` | 실행 **전** | `DataFetcherExceptionResolver`가 이걸 절대 못 본다. parse/validation은 어떤 data fetcher도 호출되기 전이다. 응답 path도 없고 4xx다 |
|
||
| `GraphQlExceptionResolver` | resolver 실패 | 등록 안 된 실패는 opaque `INTERNAL_ERROR` + execution ID. **그 기본값이 중요한 절반** — 스택 어디서 새 예외 타입이 생겨도 자동으로 마스킹된다 |
|
||
| `GraphQlSubscriptionExceptionResolver` | 스트리밍 **시작 후** | 응답이 이미 커밋됨. 부분으로 만들 `data`도, 바꿀 status도 없다. **재구독이 가치 있는지**를 말해야 한다 |
|
||
|
||
### 16.5 `GraphQlFailureBoundary` / `GraphQlNullabilityContract` — nullability는 실패 격리 결정이다
|
||
|
||
**이 문서에서 GraphQL 특유의 통찰 중 가장 좋은 것.**
|
||
|
||
> *A null on a non-null field does not stay local: the error climbs to the nearest nullable
|
||
> ancestor, nulling everything on the way. So nullability is a failure-isolation decision, not a
|
||
> data-shape one — marking an enrichment field non-null can delete an entire order from the response
|
||
> when an unrelated service is down.*
|
||
|
||
그래서 `GraphQlNullabilityContract`가 각 필드의 **선언된 실패 동작**을 기록한다:
|
||
|
||
> *Making a field non-null is a promise that it can always be provided — including when a dependency
|
||
> is down and when authorization redacts it.*
|
||
|
||
`!`를 붙이는 게 **오타가 아니라 리뷰 가능한 결정**이 된다. `GraphQlNullPropagationContractTest`가
|
||
golden test로 고정한다.
|
||
|
||
---
|
||
|
||
## 17. `schema` / `scalar` — 스키마 조립과 스칼라
|
||
|
||
### 17.1 `GraphQlSchemaAssembler` — 결정적 순서
|
||
|
||
fragment는 **소유 모듈과 경로로 정렬한 뒤** 파싱된다. 파일시스템/classpath 열거 순서는 기계나
|
||
패키징 형식에 따라 안정적이지 않고, 불안정한 순서는 **스키마 해시를 움직이고 충돌하는 두 선언 중
|
||
어느 쪽이 이기는지를 바꾼다.**
|
||
|
||
`GraphQlSchemaAssemblyResult`가 "merge가 안 던졌다"보다 엄격하다. 중복 타입/필드/디렉티브/스칼라
|
||
선언은 **선언한 리소스와 함께** 거부된다 — 안 그러면 merge 결과가 조용히 승자를 고르고 **외부
|
||
계약이 classpath 순서에 의존하게 된다.**
|
||
|
||
canonical SDL은 정렬된 fragment들의 **파싱된 AST를 프린트해서** 만든다. 텍스트 조인이 아니라.
|
||
|
||
`GraphQlSchemaResource` / `GraphQlSchemaOwnership`이 모든 좌표를 **선언한 파일로 되짚을 수 있게**
|
||
한다. 중복 선언, unmapped 필드, breaking change — 모든 진단이 merge된 전체가 아니라 파일을 지목한다.
|
||
|
||
발견은 승인된 스키마 경로 아래의 `.graphqls` / `.gqls`로 제한된다.
|
||
|
||
### 17.2 `GraphQlSchemaContract` — 해시만으로는 부족하다
|
||
|
||
**네 부분이 전부 필수다:**
|
||
|
||
```
|
||
schema hash × breaking-change policy version × scalar manifest version × directive manifest version
|
||
```
|
||
|
||
> *Identical SDL bytes can still mean a different external contract if the breaking-change policy, a
|
||
> scalar's coercion or a directive's meaning moved underneath it.*
|
||
|
||
`matches`는 넷 다 비교하고, `sameSchemaBytes`는 캐시 키잉용으로 제공되지만 **명시적으로 호환성
|
||
판정이 아니다.**
|
||
|
||
`GraphQlSchemaHash`는 소문자 hex 64자로 고정 — 같은 스키마가 두 개의 다른 키를 만들지 않게.
|
||
쓰임: preparsed 캐시 키, persisted operation의 승인 스키마 바인딩, actuator 리포트.
|
||
|
||
### 17.3 `GraphQlMappingInspectionGate` — 리포트를 게이트로
|
||
|
||
Spring의 schema mapping inspection은 **정보로 보고**한다. Stable 프로파일은 그걸 **기동 실패로
|
||
승격**한다.
|
||
|
||
> *A silently unmapped field returns `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<String> ALLOWED = Set.of(
|
||
"graphql.operation.name", "graphql.operation.type", "graphql.client.profile",
|
||
"graphql.persisted", "graphql.schema.coordinate", "graphql.dataloader.name",
|
||
"graphql.outcome", "graphql.complexity.bucket", "graphql.depth.bucket", "error.type");
|
||
|
||
public static final List<String> FORBIDDEN = List.of(
|
||
"graphql.document", "graphql.query", "graphql.variables", "graphql.cursor",
|
||
"graphql.object.id", "user.id", "tenant.id", "authorization",
|
||
"connection_init.payload", "graphql.field.path");
|
||
```
|
||
|
||
> *Every value that is interesting to a debugger — the document, the variables, the object id, the
|
||
> cursor — is also unbounded, and one unbounded label is enough to make a metrics backend unusable.*
|
||
|
||
`FORBIDDEN`이 따로 있는 건 allowlist가 이미 충분한데도 **의도를 문서화하기 위해서**다. 그리고
|
||
`GraphQlSensitiveAttributeFilter`가 키 이름으로 필터링하며 **credential처럼 생긴 키도 떨어뜨린다.**
|
||
|
||
### 19.2 `GraphQlOperationNameCardinality` — 정규식은 카디널리티를 묶지 않는다
|
||
|
||
**이 패키지에서 가장 좋은 통찰.**
|
||
|
||
> *`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<OrderEntity>` | `List` |
|
||
| `Mono<OrderEntity>` | `Mono` |
|
||
| `Optional<OrderRepository>` | `Optional` |
|
||
|
||
> *Every one of those passed a rule whose whole purpose was to notice the type inside.*
|
||
|
||
`GraphQlTypeGraph`가 제네릭 포함 **실제로 도달하는 모든 클래스**를 계산한다.
|
||
|
||
### 20.2 `GraphQlAsyncReturnShape` — `Mono`는 `Publisher`다
|
||
|
||
규칙이 원래 subscription 밖의 모든 `Publisher`를 거부했다. 그런데 `Mono<T>`도 `Publisher`다.
|
||
그래서 **Spring for GraphQL이 지원하고 문서화하는** `Mono<OrderView> order()`가 플랫폼 자체
|
||
경계 검사에 거부됐다.
|
||
|
||
지금은 **하나를 내는가 여럿을 내는가**로 구분한다. query는 비동기로 완료해도 되고, 하면 안 되는 건
|
||
**스트림**이다.
|
||
|
||
원래 리뷰 GQL-008 (*"valid reactive query도 거부한다"*)의 답.
|
||
|
||
### 20.3 반환/입력 타입 정책
|
||
|
||
**`GraphQlReturnTypePolicy`** — JPA entity나 Mongo document 반환 금지:
|
||
- 컬럼 rename이 **breaking API change**가 됨
|
||
- lazy association이 N+1이나 직렬화 중 예외가 됨
|
||
- 공개될 의도가 없던 필드가 **기본으로** 샘
|
||
|
||
무제한 `Map`도 거부 — 타입 계약을 우회한다.
|
||
|
||
**`GraphQlInputTypePolicy`** — 위의 쓰기 쪽 거울:
|
||
|
||
> *it silently exposes every persistence field as a writable API field, including the ones —
|
||
> version, tenant, audit columns, internal flags — that a client must never set.*
|
||
|
||
### 20.4 `GraphQlControllerTransactionRule` — resolver에 트랜잭션 금지
|
||
|
||
> *A transaction on a resolver would span the whole field's execution — including DataLoader
|
||
> dispatches and downstream calls that have no business inside a database transaction — and it would
|
||
> put the boundary in transport code where it cannot be reasoned about with the use case.*
|
||
|
||
`architecture/fixture/violating/Transactional.java`라는 테스트 fixture가 있다 — 어노테이션을
|
||
합성해서 negative fixture를 만든다.
|
||
|
||
### 20.5 `GraphQlResolverBoundaryRules` / `GraphQlTransportTypeRules` / `GraphQlControllerInspector`
|
||
|
||
- resolver는 Application use case와 DTO 매퍼에 의존 가능. repository / `EntityManager` /
|
||
`MongoTemplate` 직접 접근 불가.
|
||
- resolver 시그니처는 **transport 타입만** — DTO, read model, connection, mutation payload,
|
||
그리고 subscription에서의 publisher.
|
||
- **raw `DataFetcher` 구현은 인프라 패키지로 제한.** annotated controller가 주류 API고,
|
||
손으로 쓴 fetcher는 인자 바인딩·컨텍스트·경계를 우회한다.
|
||
- `GraphQlControllerInspector`는 **기동 시점**에 돌고 스키마 좌표와 Java 메서드를 함께 보고한다.
|
||
|
||
negative/positive fixture가 짝으로 있다:
|
||
`fixture/compliant/{FindOrderUseCase, OrderGraphQlController, OrderView}` vs
|
||
`fixture/violating/{LeakyGraphQlController, OrderRepository, Transactional}`.
|
||
|
||
---
|
||
|
||
## 21. `release` — 증거 없이는 릴리스 없다
|
||
|
||
9개 파일 18KB.
|
||
|
||
### 21.1 `GraphQlReleaseEvidence` — 다섯 종류, 전부 필수
|
||
|
||
> *Five independent kinds, all mandatory. They fail in different ways and none substitutes for
|
||
> another: a passing contract suite says nothing about behaviour under load, and a passing load test
|
||
> says nothing about what happens when a dependency stops responding.*
|
||
|
||
| 증거 | 무엇 |
|
||
|---|---|
|
||
| `schemaPassed` | 스키마 조립·매핑·호환성 게이트 |
|
||
| `contractsPassed` | transport / security / DataLoader / pagination 계약 스위트 |
|
||
| `performancePassed` | 부하 시나리오 |
|
||
| `faultPassed` | 장애·취소 시나리오 |
|
||
| (+ 다섯 번째) | compatibility matrix |
|
||
|
||
### 21.2 `GraphQlPerformanceScenario` / `GraphQlFaultScenario` — 카탈로그
|
||
|
||
**성능 시나리오가 각각 다른 한계를 자극한다:**
|
||
|
||
> *A deep query and a wide alias query stress different budgets; pool saturation and virtual-thread
|
||
> saturation fail differently; and event-loop blocking only shows up under concurrency. Running one
|
||
> and calling it "load tested" is how the others reach production untested.*
|
||
|
||
**장애 시나리오 중 cancellation leak이 가장 자주 건너뛰고 가장 자주 깨진다:**
|
||
|
||
> *a timeout that releases the caller while the database statement and the HTTP call keep running
|
||
> turns a slow dependency into an exhausted pool, and no functional test detects it.*
|
||
|
||
### 21.3 `GraphQlReleaseOverride` — 만료 없는 예외는 꺼진 게이트다
|
||
|
||
> *An override without an expiry is simply a permanently disabled gate, which is how a gate stops
|
||
> meaning anything.*
|
||
|
||
모든 필드 필수: approver, reason, waivedEvidence, expiresAt.
|
||
- approver 없으면 **질문할 수 없고**
|
||
- reason 없으면 **리뷰할 수 없고**
|
||
- expiry 없으면 **제거되지 않는다**
|
||
|
||
### 21.4 `GraphQlCompatibilityMatrix` — BOM이 SSOT
|
||
|
||
Spring Boot BOM이 Spring for GraphQL과 GraphQL Java 짝의 진실 원천이다. 둘 중 하나의 독립
|
||
override는 **의도적 호환성 레인에서만** 허용 — 조용히 하나를 고정하는 게 아무도 테스트하지 않은
|
||
조합에 배포가 도달하는 방식이다.
|
||
|
||
### 21.5 `GraphQlStableCapabilityManifest` — 지원 안 하는 것도 결정이다
|
||
|
||
> *Written down so "unsupported" is a decision with a reason rather than a gap someone might fill by
|
||
> accident. Every entry in `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<String> 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<String> INBOUND_OWNED =
|
||
List.of("GraphQlPersistedOperationRegistry", "GraphQlRequestContext");
|
||
private static final String GRAPHQL_SOURCE = "/src/adapter/inbound/graphql/src/main/java/";
|
||
```
|
||
|
||
javadoc이 왜 필요한지 쓴다:
|
||
|
||
> *A decision whose premise nothing rechecks is a decision that quietly stops being true, and the
|
||
> commit that breaks it would read as an implementation rather than as a layering change.*
|
||
|
||
**composition root에서 검사하는 이유:** *"A rule inside the GraphQL leaf could only prove the leaf
|
||
does not violate itself."*
|
||
|
||
그리고 **두 번째 테스트가 있다:**
|
||
|
||
```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<String, byte[]>`를
|
||
받고, 설정 계약은 "키 자체는 설정에 나타나지 않는다"다. 즉 **키 재료가 어디서 오는지**를 먼저
|
||
정해야 한다. 후보:
|
||
|
||
| 안 | 내용 | 비용 / 리스크 |
|
||
|---|---|---|
|
||
| (a) `shared-contract`에 secret 조회 포트 | 중립 계약, 인프라가 구현 | 새 추상화 하나. ADR-GQL-001이 경계한 "구현체 없는 포트" 위험 |
|
||
| (b) 환경변수 직접 (`APP_GRAPHQL_CURSOR_KEY_<id>`) | 가장 단순, 즉시 배선 가능 | 키가 환경에 노출. 현재 설정 계약("키는 설정에 나타나지 않는다")과 충돌 |
|
||
| (c) composition root가 `GraphQlCursorKeyRing` 빈을 제공 | leaf는 결정 안 함, adopter가 소유 | leaf가 "서명한다"고 말할 수 없음. 다만 **현재 상태를 정직하게 만듦** |
|
||
| (d) capability를 명시적으로 철회 | `pagination` 커서 코덱을 Advanced로 강등하거나 제거 | 잘 만든 코드를 버림 |
|
||
|
||
**추천은 (c) + 중간 조치.** (c)는 §11.4의 `GraphQlPrincipalResolver` 패턴과 동일하다 — 배포 결정을
|
||
composition root에 남기고 leaf는 seam만 제공. 그리고 auto-configuration이 `GraphQlCursorKeyRing`
|
||
빈이 **있을 때만** `HmacGraphQlCursorCodec`을 만들면, startup validator 메시지가 참이 된다.
|
||
|
||
**중간 조치 (한 줄)**: 지금 당장은 startup validator 메시지가 거짓이므로 —
|
||
`"a cursor signing key is required; unsigned cursors are client-editable"` — 배선 전까지
|
||
`GraphQlPlatformConfigurationReport`에 `cursorSigningWired: false`를 노출하거나, 메시지를
|
||
"키 ID는 등록되지만 이 배포는 아직 커서에 서명하지 않는다"로 정정.
|
||
|
||
### P3 — `CLAUDE.md`가 존재하지 않는 클래스 이름을 두 번 쓴다
|
||
|
||
**심각도: 낮음** · **비용: 낮음**
|
||
|
||
`CLAUDE.md`의 Responsibility와 "Error mapping" 절이 루트 패키지의 `GraphqlExceptionResolver`
|
||
(소문자 q)를 지목한다. **그런 타입은 없다.** 실제 구조는 세 갈래다:
|
||
|
||
| 실제 타입 | 역할 |
|
||
|---|---|
|
||
| `error/GraphQlExceptionResolver` | 등록된 예외 → 코드/카테고리/메시지 매핑 |
|
||
| `runtime/GraphQlWireErrorMapper` | **결정자** — 무엇을 클라이언트가 보는가 |
|
||
| `runtime/GraphQlDataFetcherExceptionResolver` | Spring 계약 어댑터 |
|
||
|
||
**아이러니한 점**: 이 이름 자체가 §9.7에서 고친 버그의 잔재다 — *"the two resolver classes differed
|
||
only in the case of one letter"*, 그리고 Spring은 그중 하나만 불렀다. **문서가 사라진 쪽의 이름을
|
||
계속 들고 있다.**
|
||
|
||
### P4 — `build.gradle` 주석이 자기 파일 안에서 모순된다
|
||
|
||
**심각도: 낮음** · **비용: 낮음**
|
||
|
||
- line 36-38: *"no production file imports `org.springframework.web`, `jakarta.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.<other-module>.`이 나타나면
|
||
edge로 센다"를 추가. 이건 정규식 한 줄이고 negative fixture도 쉽다.
|
||
|
||
### P9 — 실부하·장애 증거가 없다
|
||
|
||
**심각도: 중간** · **비용: 인프라**
|
||
|
||
`graphqlPerformanceTest` 레인이 자리를 예약하고 **비어 있으면 실패한다**(§24.2). `GraphQlReleaseGate`가
|
||
증거 없으면 릴리스를 거부한다. 즉 **구조는 정직한데 증거가 없다.**
|
||
|
||
`GraphQlPerformanceScenario`와 `GraphQlFaultScenario`가 시나리오 카탈로그를 이미 정의하고 있으니,
|
||
가장 값싼 첫 걸음은 **cancellation leak 하나**다 — javadoc이 "가장 자주 건너뛰고 가장 자주 깨진다"고
|
||
지목한 것이고, 기능 테스트로는 절대 안 잡힌다.
|
||
|
||
### P10 — GQL-009 편차에 ADR이 없다
|
||
|
||
**심각도: 낮음 (프로세스)** · **비용: 낮음**
|
||
|
||
2026-08-14 리뷰의 GQL-009는 *"repository 자동 노출은 Advanced여도 제거한다. application use case를
|
||
우회하는 예외를 만들지 않는다"*였다. 구현은 **제거하지 않고** allowlist 4중 방어를 붙여 남겼다
|
||
(§23.9). 그 선택 자체는 방어 가능하고 javadoc이 잘 방어한다 — 하지만 **ADR이 없다.**
|
||
|
||
ADR-GQL-001은 다른 세 가지(context / object 인가 / persisted SPI)를 다룬다. springdata 유지 결정에
|
||
대한 기록이 없으면 **다음 리뷰가 같은 지적을 반복**하고, 그때 이 javadoc을 다시 발견해야 한다.
|
||
|
||
ADR-GQL-002 한 장이면 충분하다: 리뷰 지적 → 실제 선택 → 4중 방어 → `unsupported.repositoryAutoExposure`가
|
||
Advanced 밖에서 여전히 금지한다는 사실 → 언제 제거할 것인가.
|
||
|
||
### P11 — 문서의 테스트 숫자 재확인
|
||
|
||
**심각도: 낮음** · **비용: 명령 4개**
|
||
|
||
`CLAUDE.md`가 Stable 605 / Contract 9 / Advanced 152 / 기본 test 757 / qualification 8을 적고,
|
||
**마지막 green 실행에서 다시 읽어 갱신하라**는 규칙도 함께 적어 두었다. 현재 소스에
|
||
`@Test`+`@ParameterizedTest`가 762개이므로 (세는 단위가 다르지만) 한 번 돌려서 갱신할 시점이다.
|
||
이건 이미 문서화된 규칙의 실행일 뿐이다.
|
||
|
||
### 우선순위 요약
|
||
|
||
| ID | 무엇 | 비용 | 지금 할까 |
|
||
|---|---|---|---|
|
||
| **P1** | classpath opt-in 문서 drift | 낮음 | **예 — 즉시** |
|
||
| **P3** | 존재하지 않는 클래스 이름 | 낮음 | **예 — P1과 함께** |
|
||
| **P4** | build.gradle 자기모순 주석 | 낮음 | **예 — P1과 함께** |
|
||
| **P5** | 고아 javadoc | 즉시 | **예 — P1과 함께** |
|
||
| **P2** | cursor 미배선 | 설계 결정 | **결정만 먼저** |
|
||
| P6 | modelled 3개 배선 | 각 설계 결정 | P2 결정 후 같은 패턴으로 |
|
||
| P10 | GQL-009 ADR | 낮음 | 다음 리뷰 전에 |
|
||
| P8 | 스캐너 FQN 보강 | 중간 | 여유 있을 때 |
|
||
| P9 | 부하·장애 증거 | 인프라 | 릴리스 주장 전에 |
|
||
| P11 | 테스트 숫자 갱신 | 명령 4개 | 아무 때나 |
|
||
| P7 | public 표면 축소 | 큼 | ADR 먼저 |
|
||
|
||
**P1/P3/P4/P5는 전부 문서·주석 수정이고 한 커밋에 들어간다.** 그리고 넷 다 "코드는 맞는데 산문이
|
||
틀렸다"는 같은 부류다 — 이 leaf가 코드에 왜를 쓰는 규율이 강한 만큼, 산문 쪽이 뒤처졌다.
|
||
|
||
---
|
||
|
||
## 29. 기술 블로그 글감
|
||
|
||
`tech-log`용. 각각 독립적으로 읽히고, 코드 인용이 이미 확보돼 있다.
|
||
|
||
### 1. "꺼져 있다"를 구조로 만들기 — Spring Boot 인바운드 어댑터의 off 계약
|
||
|
||
**핵심**: `@ConditionalOnProperty`만으로는 안 된다. 프레임워크가 스스로 라우트를 연다.
|
||
4겹(imports 진입점 / 마스터 조건 / `AutoConfigurationImportFilter` / `EnvironmentPostProcessor`)과
|
||
**실제 포트 404 테스트**. 그리고 *"a misspelled entry fails open silently"*.
|
||
→ §3. 가장 재사용성이 높은 글감이다.
|
||
|
||
### 2. 정책 객체의 단위 테스트는 정책이 강제된다는 증거가 아니다
|
||
|
||
**핵심**: 같은 레포에서 네 번 나타난 같은 결함 — 캐시, DataLoader, 관측 convention, resolver 등록.
|
||
그리고 **테스트가 런타임에 없던 등록을 스스로 공급했다**는 결정적 사례.
|
||
대응으로서의 `modelled`/`wired`/`integration-verified`/`production-verified` 등급 어휘.
|
||
→ §9.6, §22.1, §25.3.
|
||
|
||
### 3. 하나의 질문에 두 설정 — split-brain 안전 축이 만든 출하 기본값
|
||
|
||
**핵심**: `production=false` + `environment=PRODUCTION_PUBLIC`이 같은 record에. 결과가
|
||
"인터넷 노출 프로덕션인데 보호는 노트북처럼". 축을 하나로 합치는 리팩터링과, **은퇴한 키를
|
||
무시하지 않고 거부**하기.
|
||
→ §4.1, §4.2.
|
||
|
||
### 4. 커서에 서명하기 — 그리고 그것만으로 부족한 이유
|
||
|
||
**핵심**: HMAC 서명은 "서버가 발급했다"만 증명한다. **"이 요청을 위해 발급했다"는 증명하지 않는다.**
|
||
`GraphQlCursorScope`의 4축(query profile / filter fingerprint / direction / tenant scope)과
|
||
**4개를 한 값으로 넘겨 부분 검사를 불가능하게 만드는 API 설계**.
|
||
→ §13.2, §13.3.
|
||
|
||
### 5. 이스케이프 대신 길이 프레이밍 — 같은 버그를 두 번 만난 이야기
|
||
|
||
**핵심**: 커서 봉투와 mutation 지문이 각각 구분자 문제로 깨졌다. 커서는 라운드트립 실패, 지문은
|
||
**충돌**(= 멱등성 붕괴). 두 수정이 서로 다른 방향으로 갔다는 점.
|
||
→ §13.4, §14.4, §27.4.
|
||
|
||
### 6. 정규식은 카디널리티를 묶지 않는다 — 잘 만든 클라이언트가 메트릭 백엔드를 죽이는 법
|
||
|
||
**핵심**: `Query0000001`... 전부 유효. **정규식이 검사하고 있어서 경계가 있어 보였다.**
|
||
allowlist + 레지스트리 기반 이름 + 실제 `MeterRegistry`로 10,000 → 1을 측정하는 테스트.
|
||
→ §19.1, §19.2.
|
||
|
||
### 7. GraphQL nullability는 실패 격리 결정이다
|
||
|
||
**핵심**: non-null 필드의 null은 가장 가까운 nullable 조상까지 올라간다. **enrichment 필드에 `!`를
|
||
붙이면 무관한 서비스 장애가 주문 전체를 지운다.** `!`를 리뷰 가능한 결정으로 만드는 계약 타입.
|
||
→ §16.5. GraphQL 특유 글감 중 가장 강하다.
|
||
|
||
### 8. field error는 200이다 — 그리고 그게 스모크 테스트를 무의미하게 만드는 법
|
||
|
||
**핵심**: 실행 시작 여부가 4xx/200을 가른다. "에러 처리를 개선"해서 500으로 바꾸면 위생이 좋아
|
||
보이는데 클라이언트는 동작하는 응답의 절반을 잃는다. **그리고 상태 코드만 보는 스모크는 통과한다.**
|
||
`graphql-smoke.sh`가 바디를 읽는 이유.
|
||
→ §7.2, §24.3, §25.4.
|
||
|
||
### 9. 없어진 검사와 통과한 검사를 구분하기 — fail-closed 테스트 레인 설계
|
||
|
||
**핵심**: `.gitignore`의 `build/`가 Java 패키지를 삼킨 사건에서 시작해서, `requiredStableClasses` /
|
||
stale XML 삭제 / 빈 performance 레인 실패 / "타입이 아직 존재한다" 짝 테스트 / 빈 walk 예외까지
|
||
**여섯 개의 같은 원리**.
|
||
→ §2.1, §24.2, §25.2, §27.3.
|
||
|
||
### 10. 레지스트리 없이 모듈 경계 지키기 — 단일 leaf 안의 40개 모듈
|
||
|
||
**핵심**: messaging은 24 leaf를 등록했고 graphql은 40개 sub-package를 골랐다. **둘 다 같은 레포에
|
||
있고 어느 쪽으로 통일할지가 미결이다.** enum으로 모듈을 선언하고 소스 스캔으로 강제하는 방법,
|
||
purity 등급, longest-prefix ownership, 그리고 다섯 규칙 각각의 negative fixture.
|
||
→ §1.2, §2.
|
||
|
||
### 11. 의존 방향을 뒤집지 않고 인바운드가 durable 저장을 갖는 법
|
||
|
||
**핵심**: persisted operation 레지스트리가 인바운드 SPI인데 Postgres 어댑터가 그걸 구현하면
|
||
방향이 뒤집힌다. 중립 `OperationalRecordStorePort` + 매핑 클래스 하나 + **ADR이 "아무도 구현하지
|
||
않는다"는 전제를 상시 테스트로 바꾸는 것**.
|
||
→ §23.1, §11.4, §25.2. ADR-GQL-001 원문이 통째로 좋은 인용거리다.
|
||
|
||
### 12. 감사 기록이 없느니만 못한 순간
|
||
|
||
**핵심**: 문자열 operator + `ArrayList` 감사 + 비원자적 변경. *"An audit trail with any of those
|
||
properties is worse than none, because it is trusted."* 그리고 **시도된 변경이 아니라 일어난 변경**을
|
||
기록해야 한다는 것 (`ABSENT -> BLOCKED`).
|
||
→ §23.2.
|
||
|
||
### 13. 977KB로 필드 한 개짜리 스키마를 지키기 — 플랫폼과 API를 분리한다는 것
|
||
|
||
**핵심**: 이 leaf의 정체성 자체가 글감이다. feature-agnostic 인바운드 플랫폼, 서버를 고르지 않기,
|
||
인증을 구현하지 않기, `_health` 하나만 소유하기. 그리고 **그 선택이 만든 테스트 전략**(계약
|
||
스위트를 testFixtures로 내보내고 production jar를 열어서 검사).
|
||
→ §0, §1.3, §24.3, §24.4.
|
||
|
||
---
|
||
|
||
## 부록 A — 이 문서를 쓰면서 쓴 명령
|
||
|
||
```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/<pkg>/*.java; do
|
||
echo "--- $(basename $f .java)"
|
||
awk '/^\/\*\*/{p=1} p{print} /\*\//{if(p){exit}}' "$f" \
|
||
| sed 's/^ \* \?//;s/^\/\*\*//;s/^ \*\///' | grep -v '^$' | head -6
|
||
done
|
||
|
||
# 문서 주장 반증
|
||
grep -n "graphql" src/app-bootstrap/build.gradle
|
||
grep -rn "keyIds\|GraphQlCursorKeyRing" $B --include='*.java'
|
||
grep -rn "^import jakarta.servlet" $B --include='*.java'
|
||
grep -rn "GraphqlExceptionResolver" src/ --include='*.java'
|
||
```
|
||
|
||
---
|
||
|
||
## 부록 B — 이 leaf를 처음 읽는 사람을 위한 순서
|
||
|
||
내가 실제로 읽은 순서이고, 다시 읽어도 이 순서로 할 것 같다.
|
||
|
||
**1단계 — 정체성 (30분)**
|
||
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의 대조표와 함께 읽을 것.
|