# VD-26: Persisted GraphQL operation - 상태: Accepted design — reference runtime implementation pending - 결정일: 2026-07-28 - provider-neutral GraphQL reference adapter: `DESIGNED_NOT_IMPLEMENTED` - product GraphQL composition: `NOT_SELECTED` - batching/subscription/`@defer`/`@stream`: `NOT_SELECTED` - normalized GraphQL cache: `NOT_SELECTED` - 관련 결정: VD-13, VD-23, VD-24, VD-25, VD-28 - 상세 설계: [API contract, Schema, Mapper와 Server State](../api-contract-schema-mapper-and-server-state.md) ## 배경 현재 source, package direct dependency, config와 test에는 GraphQL runtime, operation document/codegen, persisted manifest나 endpoint provider가 없다. lockfile의 transitive `graphql` package는 MSW 개발 의존성일 뿐 capability 구현 증거가 아니다. GraphQL은 임의 query string을 보내는 범용 API escape hatch로 도입하지 않는다. 제품이 여러 backend aggregate를 화면별 shape로 조회해야 하고 schema/router, field authorization, persisted allowlist와 cost budget을 운영할 수 있을 때만 bounded-context operation family로 선택한다. ## 결정 ### 1. Production GraphQL은 persisted operation only다 ```text semantic application query/command -> registered GraphqlOperationDefinition -> fixed endpoint -> persisted operation ID/hash -> validated variables -> bounded GraphQL response decoder -> operation data schema -> boundary mapper -> application projection -> TanStack Query or command result ``` production runtime은 다음을 받지 않는다. - arbitrary GraphQL document - caller-provided operation name/hash - arbitrary endpoint/header - generated SDK selection set builder - field/fragment string ### 2. Operation artifact ```text PersistedGraphqlOperationV1 protocol = PERSISTED_GRAPHQL_V1 semanticOperationId operationName operationKind = QUERY | MUTATION canonicalDocumentSha256 persistedOperationId schemaArtifactId schemaDigest variablesSchemaId dataSchemaId mapperId errorProfileId partialDataPolicy endpointId graphqlHttpProfileRevision persistedEnvelopeProfileId responseStatusMediaProfileId authProfileId csrfProfileId replayPolicy deadlineProfileId retryProfileId serverStateProfileId | null maxVariablesBytes maxResponseBytes maxErrorCount maxCost maxDepth maxAliases owner ``` canonical document는 build artifact이고 runtime string이 아니다. stable operation ID와 hash는 schema/operation manifest에 binding한다. manifest 생성: ```text authenticated immutable schema -> named operation sources -> parse/validate against schema -> canonical document -> operation hash/ID -> variables/result type generation -> runtime codec manifest -> mapper/query profile binding -> persisted operation manifest ``` ### 3. Schema와 codegen - schema source URL에서 normal build마다 latest를 받지 않는다. - authenticated explicit update workflow가 immutable artifact와 provenance를 저장한다. - schema/source/operation manifest digest를 release contract set에 binding한다. - anonymous operation, duplicate operation name와 invalid fragment를 거절한다. - generator, plugins, Node와 runtime version을 pin한다. - clean checkout regenerate diff가 0이어야 한다. - schema breaking diff, operation validation, deprecated field budget와 generated output digest를 CI gate로 둔다. - generated type은 adapter-private DTO다. - generated TypeScript type만 믿지 않고 variables/data runtime codec과 mapper를 유지한다. - schema introspection을 production에서 끄는 결정은 server 보안 옵션일 뿐 authorization/cost control을 대체하지 않는다. ### 4. Endpoint와 HTTP profile ```text GraphqlProviderProfile endpointId fixedHttpsUrl graphqlHttpProfileRevision persistedEnvelopeProfileId methodPolicy credentialsMode corsProfile referrerPolicy redirect = ERROR mediaProfile ``` GraphQL-over-HTTP draft를 움직이는 implicit `latest`로 구현하지 않는다. selected revision의 request/response/status 규칙과 provider의 persisted-operation extension을 immutable profile/fixture에 고정한다. persisted ID/hash-only envelope는 표준 request의 required `query` field를 생략하는 provider extension일 수 있으므로 generic GraphQL-over-HTTP compliance로 가장하지 않는다. private query와 mutation은 POST가 기본이다. GET은 다음을 모두 만족하는 public read profile에서만 선택한다. - persisted ID/hash와 non-sensitive bounded variables - URL byte ceiling - no credential/private representation 또는 명시된 safe cache contract - exact cache key/Vary/CDN policy - mutation 아님 raw document와 sensitive variables를 URL에 넣지 않는다. request `Content-Type: application/json`과 `Accept: application/graphql-response+json`을 기본 exact profile로 둔다. `application/json` response 지원은 legacy provider profile로 분리한다. caller가 `fetch` option, headers와 credentials를 override하지 않는다. status/media matrix: - final URL/origin과 media/body ceiling을 먼저 확인한다. - `application/graphql-response+json`은 profile이 허용한 HTTP status 전체에서 bounded GraphQL envelope를 먼저 decode하고 selected revision의 status/body 불변조건을 교차 검증한다. - non-null `data`가 있는 response는 selected revision이 요구하는 2xx여야 한다. no-data/error와 partial response의 status는 pinned revision/provider fixture와 exact match해야 한다. - legacy `application/json`은 허용된 2xx body만 GraphQL envelope로 신뢰한다. non-2xx body는 intermediary일 수 있으므로 GraphQL error/extensions로 해석하지 않고 bounded generic HTTP failure로 닫는다. ### 5. Request envelope wire shape는 provider의 persisted-envelope extension이 versioned codec으로 고정한다. 최소 의미: ```text protocol persisted operation ID canonical document hash operation name validated variables client contract manifest version ``` full document는 포함하지 않는다. provider가 ID/hash-only envelope를 지원하지 않으면 이 capability를 그 endpoint에 compose하지 않는다. production에서 표준 `query` field를 채우기 위해 full document fallback을 보내는 것으로 우회하지 않는다. variables: - request runtime schema의 parsed output만 사용 - unknown field 거절 - depth/node/string/list/encoded byte ceiling - File/Blob/stream/native/generated class 금지 - ID/decimal/int64/time semantics는 VD-24 - secret/credential를 variable로 전달하는 operation 금지 ### 6. APQ와 manifest miss runtime Automatic Persisted Query negotiation을 production default로 사용하지 않는다. ```text persisted miss/hash mismatch -> body/reader cancel -> PERSISTED_OPERATION_MISMATCH -> operation traffic disable or coherent manifest recovery ``` hash miss 뒤 full document를 자동 전송하면 server allowlist와 cost governance를 우회할 수 있다. trusted development profile에서만 explicit opt-in 가능하며 production promotion 증거로 사용하지 않는다. frontend manifest와 router manifest의 N/N-1 rollout을 먼저 증명한다. ### 7. Total deadline, cancellation과 retry VD-23 common logical deadline을 사용한다. - credential/CSRF attach - network attempts/backoff - response read/JSON parse - GraphQL envelope/data/error validation - mapper Query retry와 GraphQL transport retry를 중복하지 않는다. retry: - idempotent query의 selected network/408/429/502/503/504 - keyed mutation은 backend idempotency evidence가 있을 때만 - GraphQL validation, persisted miss, cost/depth, schema/data/error mismatch는 retry하지 않음 - HTTP 200 GraphQL business error를 transient network failure로 자동 retry하지 않음 - UNAUTHENTICATED recovery는 safe query/keyed mutation만 same logical binding으로 한 번 AbortSignal은 fetch와 body/incremental reader를 cancel한다. local cancel이 mutation 미적용을 의미하지 않으며 ambiguous effect는 status/reconcile contract로 닫는다. ### 8. Response decoder ```text HTTP response -> final URL/origin/media/header -> present/valid Content-Length advisory preflight -> bounded stream reader -> decoded byte/depth/node/string/list cap -> GraphQL response envelope -> pinned HTTP status/body matrix -> data/errors state machine -> operation data codec -> mapper ``` top-level: ```text GraphqlResponse data? errors? extensions? ``` unknown top-level/extension behavior는 provider profile과 VD-24 unknown-field 정책을 따른다. response body, error message, path와 extensions를 log에 복사하지 않는다. ### 9. Data/error state machine 다음 순서로 배타적으로 처리한다. 1. network/final URL/unsupported media/body limit 실패 또는 legacy `application/json` non-2xx: transport 또는 media/limit failure, data/cache write 0. 2. `application/graphql-response+json`은 profile이 허용한 status 전체에서, legacy `application/json`은 profile-admitted 2xx에서만 bounded parse한다. top-level response shape 불일치는 `GRAPHQL_ENVELOPE_MISMATCH`. 3. `errors` key가 있으면 non-empty list여야 한다. `errors=[]`는 항상 `GRAPHQL_ENVELOPE_MISMATCH`다. 4. selected GraphQL-over-HTTP revision의 status/body matrix가 맞지 않으면 `GRAPHQL_HTTP_PROFILE_MISMATCH`다. 5. `data` key 존재 + non-null, `errors` 없음: data codec → mapper → generation fence → success. 6. `data` 없음/null, non-empty `errors`: safe error mapping; success/cache write 0. 7. non-null `data`와 non-empty `errors` 동시: operation `partialDataPolicy` 적용. 8. `data` 없음/null이고 errors도 없음: contract mismatch. ### 10. Error projection GraphQL error는 untrusted다. ```text GraphqlError message locations path extensions ``` application에 허용: - operation error profile이 allowlist한 `extensions.code` - bounded typed validation field issue - effect certainty/conflict category - bounded server request/trace ID projection 금지: - raw `message` - source location - path actual value - arbitrary extensions - resolver/service/stack/database detail error count, path segment/count/string와 extensions decoded byte cap을 적용한다. unknown code는 generic closed failure다. backend `retryable` boolean은 retry authority가 아니다. common mapping 예: | safe GraphQL category | AppFailure | | --- | --- | | unauthenticated | `AUTH_REQUIRED` | | forbidden | `FORBIDDEN` | | not found | `NOT_FOUND` 또는 existence-hiding policy | | validation | `VALIDATION_REJECTED` | | conflict/precondition | `CONFLICT` 또는 typed precondition | | rate limited | `RATE_LIMITED` | | internal/unavailable | `SERVER_FAILURE` | | unknown | `UNKNOWN_CLIENT_FAILURE` 또는 contract failure | ### 11. Partial data default: ```text partialDataPolicy = REJECT ``` query에만 다음 explicit profile을 허용할 수 있다. ```text ALLOW_TYPED_PARTIAL requiredCompletePaths optionalPartialPaths errorCodeAllowlist completenessSchemaId staleVisibilityPolicy ``` 조건: - data codec이 missing/null path를 정확히 표현 - mapper가 completeness를 application result로 투영 - UI가 complete success와 partial-degraded를 구분 - partial value/result size ceiling - authorization/error path를 숨기며 unsafe field를 사용하지 않음 - previous complete cache와 field 단위로 임의 merge하지 않음 mutation은 errors가 있으면 partial success data를 ordinary command success로 cache하지 않는다. backend가 effect certainty/receipt를 제공해야 `COMMITTED | NOT_APPLIED | UNKNOWN`을 판단한다. error가 있다는 이유만으로 optimistic layer 전체를 즉시 rollback해 다른 commit을 덮지 않는다. ### 12. Null bubbling GraphQL nullability propagation은 application null 의미와 다르다. - nullable field, error-caused null과 absent partial field를 data/error state machine이 함께 해석 - generated type의 `T | null`만으로 cause를 추측하지 않음 - operation data codec/mapper가 approved partial path와 error code를 결합 - required root/aggregate null은 default failure - unauthorized field null을 stale previous field로 자동 채우지 않음 ### 13. Cache identity VD-25 TanStack Query가 기본 sole owner다. - query key는 semantic operation input + VD-13 scope - persisted operation ID/hash/document를 key에 넣지 않음 - GraphQL data/envelope/generated type을 cache하지 않음 - mapped bounded application projection만 cache - schema/mapper meaning change는 query/release epoch invalidation - GraphQL client library cache는 disabled/`no-cache` normalized cache가 필요하면 separate ADR: - key fields/`__typename` - fragment completeness - pagination merge - optimistic layers - eviction/gc/logout/scope - persistence/SSR - TanStack replacement/removal dual cache는 금지한다. ### 14. Batching 현재 `NOT_SELECTED`. `@defer`/`@stream`은 한 GraphQL HTTP operation의 finite incremental response다. 장기 subscription이나 unsolicited realtime event가 아니며, reconnect/resume owner를 realtime runtime에 넘기지 않는다. 선택 조건: - 같은 endpoint/auth/scope - query only - same credentials/CSRF policy - max operation count - total variables/request bytes - total cost/depth - per-operation deadline/result/error/observation 보존 - one operation cancel/failure가 다른 operation semantics를 바꾸지 않음 금지: - mutation 포함 - query+mutation mixed batch - 서로 다른 account/session - batching으로 idempotency/retry owner 합치기 - one HTTP result를 one query cache value로 저장 batch transport failure와 per-operation GraphQL failure를 분리한다. ### 15. Incremental `@defer`/`@stream` 현재 `NOT_SELECTED`. 선택 시 별도 profile: - exact incremental-delivery draft/provider revision - exact `Accept`, response `Content-Type`와 boundary/version parameter - exact `multipart/mixed` media/boundary parser - total bytes/parts/depth/patch count - initial/subsequent/terminal payload discriminant와 completion grammar - operation-owned ID/label/path allowlist와 path progression - patch/data/items/errors/extensions runtime schema - part별 및 cumulative error/extension count/byte ceiling - duplicate/out-of-order/missing path - terminal marker - idle/total deadline - backpressure/cancel/reader cleanup - proxy/CDN buffering conformance cache: - staging projection에 immutable patch 적용 - terminal integrity/completeness 뒤 atomic commit - 또는 UI가 explicit progressive state를 소유 - existing cached object를 in-place mutate하지 않음 - truncated stream을 complete success로 cache하지 않음 Chromium/Firefox/WebKit과 actual proxy 증거 없이는 traffic promotion 금지다. exact protocol revision/profile이 없으면 registry composition 자체를 거절한다. ### 16. Subscription GraphQL HTTP query adapter에 subscription을 넣지 않는다. 현재 `NOT_SELECTED`. 선택 시 transport-specific registered GraphQL subscription capability와 feature-owned `FeatureEventInput`이 필요하다. 범용 `RealtimePort`를 만들지 않는다. ```text GraphqlSubscriptionCapability subscribe(registered subscription, validated variables, signal) -> AsyncIterable> -> unsubscribe() ``` 선택된 WebSocket/SSE subprotocol adapter가 frame, media, auth, reconnect/resume를 소유하고 GraphQL event schema와 pure mapper를 통과한 event만 `FeatureEventInput` 또는 invalidation bridge로 전달한다. backend contract가 명시적으로 같은 의미를 채택하지 않는 한 GraphQL payload를 `REALTIME_EVENT_V1`로 강제하거나 다시 감싸지 않는다. backend 계약: - exact WebSocket/SSE protocol/version - auth attach/refresh/revoke - heartbeat/idle timeout - reconnect/backoff - sequence/duplicate/gap/resume cursor - bounded queue/overflow - logout/route unmount unsubscribe event는 invalidation hint 또는 registered bounded reducer를 통해 server-state를 갱신한다. raw event history를 Query cache에 무한 적재하지 않는다. ### 17. Authorization, CSRF와 DoS - BFF/router가 field/resource authorization을 매 request에 수행 - persisted allowlist는 authorization이 아님 - cookie mutation은 POST + exact Origin/Fetch Metadata + approved CSRF proof - SameSite/custom header/preflight 단일 요소만 방어라고 주장하지 않음 - cross-origin credential wildcard 금지 - server에서 depth, aliases, fragments, variables/list/page/field cost, total execution와 response bytes 강제 - frontend ceiling은 server DoS 방어를 대체하지 않음 - introspection off는 field authorization/cost control 대체 아님 - persisted operation manifest와 field authorization change를 coherent rollout ### 18. Backend/router 계약 provider가 제공: - immutable schema artifact/provenance - persisted operation registration/lookup - exact operation hash/schema digest binding - N/N-1 manifest window와 retirement - cost/depth/alias/list/response budget enforcement - stable safe error code vocabulary - partial/null/effect certainty semantics - idempotency/conflict/revision - auth/CSRF/CORS - request/trace projection - kill switch와 per-operation traffic frontend manifest echo만으로 등록/authorization을 승인하지 않는다. router가 server-owned manifest에서 operation binding을 재계산한다. ### 19. Observability 허용: - semantic operation ID/persisted profile ID - schema/manifest compatibility outcome - full/partial/rejected/transport outcome - safe GraphQL error category - cost/depth/variables/response/error/part count bucket - duration/deadline/retry/auth recovery bucket - cache hit/stale/admission outcome 금지: - document/hash actual value - variables/data - raw error message/path/extensions - field/resolver name high-cardinality label - account/resource/cursor/revision server가 resolver-level telemetry를 소유한다. browser가 raw field trace를 수집하지 않는다. ### 20. Testing build/contract: - schema source provenance/digest - schema lint/breaking/deprecation budget - named operation validation - canonical hash/manifest determinism - clean codegen diff - generated import boundary - variables/data codec parity - N/N-1 persisted manifest and retirement runtime: - unknown/hash mismatch, full-document fallback 0 - variables depth/node/string/list/byte cap - GraphQL HTTP revision/media/status-body matrix와 legacy intermediary body - HTTP/media/body cap - all data/errors state branches - empty errors와 null/absent data matrix - error count/path/extensions cap/redaction - null bubbling - partial allowed/rejected/completeness - timeout/cancel/retry/auth recovery - mutation effect certainty/idempotency - scope/generation late result - cache admission/write 0 on failure optional: - batching mixed/mutation/limit rejection - multipart boundary/truncated/duplicate/out-of-order/terminal - subscription ordering/reconnect/resume/logout provider/browser: - actual BFF/router allowlist/cost/auth/CSRF/CORS - manifest rollout/retirement - proxy/CDN media/body behavior - Chromium/Firefox/WebKit for selected incremental/subscription capability ### 21. Rollout 1. product owner가 GraphQL이 필요한 bounded operation family를 승인한다. 2. schema/router/manifest owner와 endpoint/auth/cost/error contract를 확정한다. 3. provider-neutral codec/adapter/fake를 구현한다. 4. generated source, boundary mapper와 TanStack query definition을 연결한다. 5. REST current read와 GraphQL shadow read를 비교하되 shadow result는 UI/cache에 쓰지 않는다. 6. actual router conformance를 통과한다. 7. `AVAILABLE_NOT_COMPOSED`에서 product composition behind `TrafficAdmission=DISABLED`로 이동한다. 8. read-only internal canary 뒤 selected operation만 traffic을 올린다. 9. mutation은 idempotency/effect certainty provider evidence 뒤 별도 canary한다. 10. batching/incremental/subscription은 계속 `NOT_SELECTED` 또는 독립 gate다. rollback: - 신규 GraphQL operation admission 중지 - in-flight query cancel, mutation effect reconcile - current scope GraphQL-mapped query cache clear - coherent frontend/schema/manifest/router rollback - approved REST read fallback이 있으면 새 logical read로 전환 - arbitrary/full-document fallback 금지 ### 22. Removal 1. operation traffic/registration retirement 시작 2. query/subscription cancel과 mutation reconcile 3. Query cache/invalidation listener clear 4. operation/codec/mapper/query profile 제거 5. generated files, GraphQL runtime/codegen dependencies 제거 6. schema/operation manifest/config/endpoint 제거 7. router persisted entries는 N/N-1 window 뒤 제거 8. production module/dependency/SBOM/removal test 통과 ## 규범 기준 - [GraphQL Specification, September 2025](https://spec.graphql.org/September2025/) - [GraphQL over HTTP draft](https://graphql.github.io/graphql-over-http/draft/) GraphQL-over-HTTP 문서는 현재 draft이므로 링크의 moving text를 production profile로 쓰지 않고 위에서 결정한 revision/provider fixture로 고정한다. ## 완료 기준 - production에서 registered persisted operation 외 document가 전송되지 않는다. - schema/operation/codegen/runtime codec/mapper manifest가 digest로 연결된다. - variables/response/errors가 bounded runtime validation을 거친다. - persisted envelope extension과 GraphQL-over-HTTP revision/media/status matrix가 actual router profile에 고정된다. - data/errors/partial/null/effect certainty 상태가 배타적으로 닫힌다. - auth/CSRF/cost/field authorization과 manifest N/N-1을 actual router에서 증명한다. - GraphQL SDK normalized cache와 TanStack dual cache가 없다. - query key/cache에 document/hash/envelope/generated DTO가 없다. - batching/incremental/subscription은 선택 전 설치되지 않는다. - kill switch, rollback과 dependency/manifest removal drill이 통과한다.