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

654 lines
22 KiB
Markdown

# 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<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 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이 통과한다.