Files
tech-log-frontend/docs/architecture/decisions/VD-26-persisted-graphql-operation.md

22 KiB

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

배경

현재 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다

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

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 생성:

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

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/jsonAccept: 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으로 고정한다. 최소 의미:

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로 사용하지 않는다.

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

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:

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다.

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:

partialDataPolicy = REJECT

query에만 다음 explicit profile을 허용할 수 있다.

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를 만들지 않는다.

GraphqlSubscriptionCapability
  subscribe(registered subscription, validated variables, signal)
  -> AsyncIterable<Result<MappedEvent>>
  -> 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-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이 통과한다.