Files
tech-log-frontend/docs/architecture/decisions/VD-24-runtime-schema-and-boundary-mapper.md
T

20 KiB

VD-24: Runtime Schema와 boundary Mapper

  • 상태: Accepted — reference typed codec/mapper baseline composed, artifact governance pending
  • 결정일: 2026-07-28
  • reference REST Schema/Mapper vertical: COMPOSED
  • semantic compatibility/codegen governance delta: DESIGNED_NOT_IMPLEMENTED
  • generated API product selection: NOT_SELECTED
  • 관련 결정: VD-13, VD-23, VD-25, VD-26, VD-27, VD-29, VD-30
  • 상세 설계: API contract, Schema, Mapper와 Server State

배경

현재 reference vertical은 다음 trust path를 실제 실행한다.

HTTP response
  -> common envelope Zod
  -> feature payload Zod
  -> feature mapper
  -> domain factory
  -> application view
  -> TanStack Query

reference baseline은 schema contribution collision을 boot 전에 거절하고, schema version/direction/unknown-field policy를 기록한다. request는 strict reject, ordinary response DTO는 strip projection을 사용하며 list item 수와 response/cache byte admission을 제한한다. mapper는 no-throw MappingResult를 반환하고 예상 가능한 drift는 MAPPING_CONTRACT_VIOLATION으로 분류한다.

runtime schema codec과 mapper contribution은 collision-aware composer로 설치되고, 각 REST operation의 path/request/response schema와 mapper input schema reference를 boot 전에 exact resolve한다. operation별 cast-free result guard도 raw executor의 성공 값을 fail-closed로 재검증한다. 남은 delta는 actual codec fingerprint/source provenance/generated artifact join과 전체 scalar policy set이다.

이 결정은 runtime validation을 특정 library 이름으로 축소하지 않는다. 현재 owned schema는 Zod를 사용하지만 GraphQL generated types와 protobuf messages에도 동일한 trust transition을 적용한다.

결정

1. 다섯 validation 경계를 분리한다

경계 owner 목적
route/form input presentation/feature 사용자 입력 정규화와 UX issue
application command/query input application/feature use-case precondition과 canonical semantic input
transport request wire adapter/contract exact outbound representation
transport response wire adapter/contract untrusted server bytes/message 검증
domain invariant domain 업무상 유효한 entity/value 생성

하나의 Zod schema를 form, API request, response와 domain에 재사용하지 않는다. field 이름이 같아도 trust source와 failure semantics가 다르다.

2. TypeScript와 generated type은 proof가 아니다

unknown bytes/message
  -> bounded decoder
  -> RuntimeCodec<ValidatedDto>
  -> ValidatedDto
  -> BoundaryMapper<ValidatedDto, ApplicationValue>
  -> MappingResult<ApplicationValue>

as Dto, generic execute<T>(), generated TypeScript interface와 protobuf class instance는 runtime proof를 만들지 않는다.

목표 API:

RuntimeCodec<Input, Output>
  schemaId
  parse(input, budget) -> ValidationResult<Output>

BoundaryMapper<ValidatedDto, ApplicationValue>
  mapperId
  inputSchemaId
  map(dto) -> MappingResult<ApplicationValue>

BoundOperation<Input, Dto, Value>
  requestCodec
  responseCodec
  mapper

operation definition 생성 시 codec output과 mapper input type을 compiler가 연결한다. runtime registry도 same IDs/fingerprints를 검증한다.

3. Schema registry v2

SchemaDefinitionV2
  schemaId
  wireVersion
  boundary
  protocol
  sourceKind = OWNED | GENERATED
  sourceArtifactId
  sourceArtifactDigest
  codecId
  codecFingerprint
  unknownFieldPolicy
  numericPolicyId
  temporalPolicyId
  providerMaxEncodedBytes
  maxDecodedBytes
  maxDepth
  maxNodes
  maxObjectKeys
  maxStringBytes
  maxCollectionItems
  compatibilityPolicy
  dataClassification
  owner

정적 metadata는 실행 codec definition에서 결정적으로 투영한다. 실제 codec resolver가 없는 schema ID, fingerprint가 다른 resolver와 duplicate ID는 contribution composition에서 실패한다.

codecFingerprint는 library 내부 AST serialization을 무조건 신뢰하지 않는다. 프로젝트가 소유한 canonical schema manifest를 사용한다.

canonical schema manifest
  field/path
  required/nullability
  scalar/format/range
  enum/discriminant
  collection/item ceiling
  unknown-field policy
  transform identifier/version

Zod upgrade로 내부 representation이 바뀌어도 canonical meaning diff가 안정적이어야 한다.

4. Decode budget

Content-Length나 protobuf frame length만으로 충분하지 않다.

ValidationBudget
  decodedBytesRemaining
  nodesRemaining
  depthRemaining
  objectKeysRemaining
  stringBytesRemaining
  collectionItemsRemaining
  deadlineRemaining
  • BFF/proxy/provider가 actual wire/encoded transfer와 decompression-ratio cap을 집행한다.
  • browser Fetch adapter는 present/valid Content-Length를 advisory preflight로만 사용하고 browser-visible decoded stream bytes를 hard cap으로 센다.
  • codec은 depth/node/key/string/item cap을 적용한다.
  • collection nested item도 global budget을 함께 소모한다.
  • transform/refine도 남은 logical deadline 안에서 동기적이고 bounded해야 한다.
  • async network/storage refinement를 runtime wire schema에 넣지 않는다.
  • budget 초과는 validation issue list를 무한 생성하지 않고 첫 bounded summary로 닫는다.

표준 JSON.parse profile에서 decoded-byte cap은 pre-parse guard지만 depth/node/key/string/item cap은 materialization 뒤 admission guard다. parse 중 구조 cap이 필요한 payload는 bounded tokenizing parser를 별도 profile로 선택하며, 그 구현 전에는 큰 byte ceiling을 승인하지 않는다.

current request limit <= 100은 response item ceiling이 아니다. response schema가 items maximum과 total byte budget을 별도로 검증한다.

5. Unknown-field 정책

UnknownFieldPolicy
  REJECT_UNKNOWN
  STRIP_UNKNOWN

PRESERVE_UNKNOWN은 application boundary에서 허용하지 않는다.

schema class 기본 정책
request, config, command, capability REJECT_UNKNOWN
auth/authorization/control envelope REJECT_UNKNOWN
ordinary additive REST response DTO STRIP_UNKNOWN
GraphQL selected data object requested field shape만 투영
sealed discriminated union unknown discriminator 거절
protobuf generated message codec/library unknown-field behavior 뒤 mapper는 known projection만 사용

current response .strict()를 모두 .passthrough()로 바꾸지 않는다. unknown field를 제거한 typed projection만 mapper로 보낸다. unknown field 이름/value를 log에 남기지 않는다. 필요한 경우 low-cardinality unknown-field-detected observation만 sampling한다.

6. Request와 response 방향성

request:

  • strict field set
  • trim/coerce/default/normalization 정책이 명시됨
  • parsed output만 transport가 serialize
  • input 원본을 query key나 request에 따로 사용하지 않음
  • route/search/application command 변환이 같은 canonical semantic input을 공유

response:

  • untrusted value를 coerce하지 않음
  • required/nullability/discriminant/range를 검증
  • additive unknown은 profile에 따라 strip
  • default value를 서버가 보낸 값처럼 조용히 생성하지 않음
  • missing/null/empty를 mapper가 명시적으로 소진

z.coerce는 URL/form 같은 string input 경계에서만 허용한다. JSON/protobuf response에 적용하지 않는다.

7. Scalar 의미

ID

  • opaque bounded string
  • empty/control/overlong 거절
  • 업무 계약이 없는 case folding, Unicode normalization과 numeric parse 금지
  • account/resource ID를 diagnostics label이나 physical cache key에 직접 넣지 않음

Integer와 decimal

  • JSON integer는 finite safe integer 범위를 증명
  • int64/uint64는 JavaScript number로 mapping하지 않음
  • protobuf bigint/string representation은 adapter-private
  • money/decimal/high precision은 canonical decimal string + currency/scale policy
  • NaN, Infinity와 negative zero가 의미상 허용되는지 explicit
  • string-to-number response coercion 금지

Time

  • exact RFC 3339 profile과 offset/precision을 검증
  • date-only, instant, local date-time과 duration을 다른 type으로 둠
  • leap/invalid date를 JavaScript Date normalization에 맡기지 않음
  • protobuf Timestamp/Duration range/nanos를 검증
  • mapper가 application temporal value로 변환
  • locale/timezone formatting은 presentation에서만 수행

Null과 absent

ABSENT
NULL
EMPTY
VALUE

네 의미를 schema/mapper contract에 명시한다. current mapper처럼 “string이 아니면 모두 null”로 합치지 않는다. optional server field의 default가 필요하면 application policy가 이름 있는 결정으로 적용한다.

Enum/union/oneof

  • unknown discriminator는 sealed control union에서 fail-closed
  • evolvable business enum은 domain이 explicit UNKNOWN case와 UX를 소유한 경우에만 mapping
  • raw unknown string/number를 domain에 전달하지 않음
  • protobuf enum zero value, unknown numeric enum과 oneof absence를 명시적으로 처리

Binary

  • REST base64는 decoded byte cap과 canonical encoding profile 필요
  • GraphQL upload/binary는 이 JSON schema 경계의 기본 기능이 아님
  • protobuf bytes는 bounded copy/stream policy 뒤에만 application으로 projection
  • large binary는 File/transfer capability를 사용

8. Transport-specific schema

REST

  • exact status/media/envelope profile 뒤 operation DTO codec 실행
  • error body도 별도 bounded codec
  • Problem Details의 type/title/detail/instance를 raw UI copy로 사용하지 않음
  • response envelope와 payload unknown policy를 따로 설정

GraphQL

  • variables와 selected data shape에 separate codec
  • top-level data, errors, extensions를 GraphQL response codec이 검증
  • errors path/message/extensions는 safe failure mapper 전 untrusted
  • partial policy가 허용한 missing/null만 operation DTO type에 표현
  • persisted operation manifest의 schema digest와 codec fingerprint 일치

gRPC-Web

  • frame/trailer 검증 뒤 generated protobuf decoder 실행
  • generated decode success 뒤에도 semantic validator가 range/presence/enum/oneof를 검증
  • descriptor digest/message full name과 codec binding 일치
  • google.rpc.Status details는 allowlisted type만 decode

Connect-Web/Connect

  • Connect unary HTTP/error 또는 stream EndStream proof 뒤 generated message decode
  • JSON/binary encoding과 descriptor/message binding을 operation profile에 고정
  • generated decode와 ConnectError code는 semantic domain proof가 아니므로 같은 validator/mapper와 safe failure vocabulary를 통과

Protobuf REST Gateway

  • HttpRule/ProtoJSON/status/error profile 뒤 ordinary REST DTO codec 실행
  • generated OpenAPI type이나 ProtoJSON message를 application model로 사용하지 않음
  • direct gateway와 curated BFF의 envelope/schema를 같은 codec으로 추측하지 않음

9. Boundary Mapper v2

MapperDefinitionV2
  mapperId
  mapperVersion
  inputSchemaId
  outputContractId
  scalarPolicySetId
  maxOutputItems
  maxEstimatedOutputBytes
  owner

mapper는:

  • pure
  • deterministic
  • synchronous
  • side-effect-free
  • locale/timezone-independent
  • input mutation 없음
  • immutable output
  • exhaustive
  • bounded

mapper가 호출하면 안 되는 것:

  • fetch, generated client, QueryClient
  • clock/random
  • storage/cache
  • telemetry/logger
  • DOM/browser API
  • authorization/feature flag

10. Mapping result

MappingResult<T>
  { ok: true, value: T }
  { ok: false,
    error:
      MAPPING_INVARIANT_REJECTED |
      UNSUPPORTED_WIRE_VALUE |
      OUTPUT_LIMIT_EXCEEDED }

예상 가능한 domain invariant/unknown enum/temporal conversion 실패는 throw하지 않는다. programming defect가 throw되더라도 adapter boundary가 MAPPING_CONTRACT_VIOLATION으로 정규화한다. raw DTO/value/path/message를 failure에 복사하지 않는다.

UNKNOWN_FAILURE는 mapper drift의 정상 분류가 아니다. operation ID, schema/mapper profile/version과 safe outcome만 관측한다.

11. Domain, application projection과 view

ValidatedDto
  -> domain value/entity factory
  -> ApplicationReadModel / command result
  -> presentation-only ViewModel
  • DTO는 adapter/contracts 내부
  • domain은 transport nullability/error/envelope를 모름
  • application read model은 query cache에 넣을 수 있는 immutable plain value
  • presentation view는 locale/formatted copy와 UI-only optimistic marker를 소유
  • domain class/service, function, native object와 generated message를 Query cache에 넣지 않음

current reference가 domain을 거쳐 view를 만드는 구조는 유지한다. 단 collection과 return object를 immutable/bounded하게 만들고 mapping type proof를 연결한다.

12. Collection mapping

  • input array/page count는 codec에서 먼저 제한
  • mapper는 output count와 estimated bytes를 다시 제한
  • item 하나 실패 시 partial collection을 success/cache하지 않음
  • stable identity, ordering, duplicate 의미는 feature contract가 결정
  • duplicate ID를 임의로 마지막 값으로 덮지 않음
  • mapper가 sort/filter/deduplicate를 한다면 이름 있는 policy와 fixture 필요
  • pagination page/snapshot binding을 보존

estimated output bytes는 quota/serialization exact value가 아니라 cache admission ceiling용 보수적 측정이다. 측정 실패는 unlimited로 간주하지 않고 cache admission을 거절한다.

13. Generated와 owned source

backend-authoritative contract
  -> authenticated immutable source artifact
  -> source digest + provenance
  -> pinned codegen
  -> adapter-private DTO/client/codec
  -> owned semantic validator where required
  -> handwritten boundary mapper
protocol generated source 후보 반드시 owned인 것
REST OpenAPI DTO/client/codec gateway, mapper, application model, query policy
GraphQL schema types, operation types persisted manifest policy, runtime result/error codec, mapper
gRPC-Web protobuf messages/client semantic validation, failure mapping, mapper, stream reducer

normal build가 네트워크에서 최신 schema를 암묵적으로 내려받지 않는다. source fetch/update는 authenticated explicit workflow이며 reviewable diff를 만든다.

generator:

  • exact package/plugin/runtime/Node version pin
  • reproducible output
  • generated directory 수동 수정 금지
  • clean regenerate diff 0
  • license/SBOM/secret scan
  • vendor import boundary
  • generated artifact removal gate

현재 generated-api recipe의 generic execute<TOutput>(unknown)와 caller-selected cast는 production schema proof가 아니다.

14. Compatibility

change classification:

변경 기본 판정
optional ordinary response field 추가 + strip policy additive
request required field 추가 breaking
response required field 제거/rename/type/nullability 축소 breaking
enum value 추가 domain unknown policy에 따라 additive 또는 breaking
numeric range/precision/temporal profile 변경 semantic breaking
mapper output meaning/identity/order 변경 application breaking
unknown-field policy 변경 compatibility review
codec transform/default 변경 semantic diff 필수

apiContractVersion 하나만 올리지 않는다.

ContractSetManifest
  globalCompatibilityVersion
  REST/OpenAPI artifact digest
  GraphQL schema + persisted operation digest
  protobuf descriptor digest
  runtime schema registry digest
  mapper registry digest
  query policy digest
  • N과 N-1 fixture를 보존
  • breaking deployment는 old frontend window와 backend compatibility를 고려
  • future major는 fail-closed
  • rollback은 frontend, generated artifacts, config, BFF/router/proxy와 backend compatibility를 coherent set으로 복구
  • mapper-only semantic change도 cache/release epoch invalidation을 검토

15. Failure와 cache admission

다음 상태에서는 cache write가 0회다.

  • body/frame limit
  • envelope/status/media mismatch
  • operation schema mismatch
  • mapper failure
  • scope/runtime generation mismatch
  • output item/byte ceiling 초과
  • incompatible contract/source digest

stale data를 유지할지는 VD-25 query profile이 결정한다. schema/mapper incompatibility를 ordinary transient network failure와 동일하게 retry하지 않는다.

16. Security와 privacy

  • validation failure에 raw value를 포함하지 않음
  • issue path/code를 allowlist와 count/byte cap으로 projection
  • schema/mapper error가 PII field/value를 diagnostics에 넣지 않음
  • prototype pollution key와 accessor/class/native object 거절
  • structuredClone 성공을 safe plain-data proof로 사용하지 않음
  • generated code가 arbitrary URL/header/logger를 application에 노출하지 않음
  • source artifact와 generator provenance 검증
  • schema가 frontend authorization boundary라는 주장 금지

관측 허용:

  • operation/schema/mapper ID와 version
  • source/compatibility outcome
  • validation/mapping failure kind
  • encoded/decoded/output size와 item bucket
  • unknown-field detected bucket

source digest 실제 값, field path/value, DTO, GraphQL error path와 protobuf payload는 high-cardinality/sensitive이므로 telemetry label에 넣지 않는다.

17. Testing

schema:

  • missing/null/empty/unknown field
  • numeric safe bounds, decimal, negative zero, NaN/Infinity
  • RFC 3339/Timestamp/Duration edge
  • enum/union/oneof future value
  • depth/node/key/string/array/byte cap
  • invalid UTF-8, base64와 binary cap
  • N/N-1/future-major

registry:

  • duplicate ID before spread
  • missing/mismatched codec/mapper resolver
  • codec fingerprint/source digest drift
  • operation schema/mapper output type binding
  • orphan/owner/version mismatch

mapper:

  • typed DTO only
  • deterministic/pure/input unmodified
  • immutable output
  • no throw for expected semantic rejection
  • collection partial failure
  • item/output byte ceiling
  • missing/null/date/numeric/unknown enum matrix
  • no raw value leakage

codegen:

  • source provenance/digest
  • lint/breaking
  • clean reproducible generation
  • generated import boundary
  • runtime codec fixture parity
  • dependency/removal inventory

integration:

  • bytes → decoder → schema → mapper → application/query
  • schema/mapper drift에서 cache write 0
  • old runtime generation result 폐기
  • actual REST/GraphQL/gRPC provider fixture

18. Rollout

  1. current string-dispatch schema/mapper를 그대로 두고 typed definition builder를 추가한다.
  2. reference operation에 shadow validation/mapping을 실행하되 secondary result를 UI/cache에 쓰지 않는다.
  3. collision-aware contribution composer와 codec fingerprint를 먼저 blocking한다.
  4. bounded decoder/collection ceiling을 query read부터 canary한다.
  5. typed mapping result와 failure taxonomy를 적용한다.
  6. current unchecked binder/cast를 제거한다.
  7. OpenAPI/GraphQL/proto generation은 제품 contract source가 선택된 것만 별도 canary한다.
  8. schema/mapper version을 release/cache epoch와 연결한다.

rollback은 codec schema number를 낮추거나 cache의 incompatible value를 억지로 decode하지 않는다. old adapter/backend path와 coherent artifact로 돌리고 current scope의 incompatible mapped cache를 폐기한다.

완료 기준

  • runtime codec output과 mapper input이 compiler/runtime registry 양쪽에서 연결된다.
  • schema registry가 actual meaning fingerprint, provenance와 budget을 가진다.
  • duplicate contribution이 overwrite 전에 실패한다.
  • request strict/response additive 방향 정책이 test로 증명된다.
  • scalar/null/enum/collection 의미가 mapper policy로 닫힌다.
  • mapper가 typed DTO만 받고 expected failure를 Result로 반환한다.
  • generated DTO/message가 domain/application/presentation/query public type에 없다.
  • schema/mapper failure에서 cache write와 raw-data observation이 0회다.
  • N/N-1, breaking diff, provider fixture와 rollback/removal drill이 통과한다.