Files
llm-wiki/wiki/concepts/api-evolution-and-schema.md

24 KiB

title, source_type, status, confidence, tags, related_projects, last_reviewed
title source_type status confidence tags related_projects last_reviewed
API Evolution & Schema (compatibility + serialization + HTTP contract surface) llm-generated reviewed medium
api-design
versioning
schema
deprecation
pagination
conditional-request
http-cache
ca-skeleton
2026-06-04

API Evolution & Schema (compatibility + serialization)

Layer: wiki/concepts/ — 일반 개념. 특정 프로젝트의 결정/구현 사실은 wiki/projects/에서 다룬다.

Summary

API evolution은 두 축으로 나뉜다. (1) compatibility / deprecation — 응답 필드 제거나 의미 변화를 막기 위해 breaking change를 분류하고 migration window 동안 deprecated marker와 Sunset 헤더로 client에게 신호를 보낸다. (2) schema / serialization — date·money·enum·null·unknown field의 의미를 framework default에 맡기지 않고 명시 계약으로 고정한다. 대표 결정 라인업은 90d public + 30d internal migration window, RFC 8594 Sunset 헤더, ISO-8601 offset datetime (UTC default), BigDecimal scale 2 + HALF_UP, strict inbound / tolerant outbound 정책이다.

Standard (공식 정의)

Compatibility / deprecation 표준 후보

  • RFC 8594 Sunset header (IETF): 응답 헤더로 자원이 응답 불가가 될 시점을 HTTP-date로 알린다. Sunset 단독은 언제 사라지는지 신호일 뿐이고, deprecation 자체는 별도 Deprecation 헤더(IETF draft)로 표시하는 것이 표준 의도다.
  • Microsoft REST API versioning policy: api-version query/header를 정식 권고. major version 단위 breaking change 허용, minor/preview는 additive only. preview API는 별도 lifecycle.
  • GitHub REST API: 2022년부터 X-GitHub-Api-Version: YYYY-MM-DD 날짜 헤더. 새 버전 release 후 24개월 EOL 정책, EOL된 버전 호출은 410 Gone 응답. preview API는 Accept 헤더 application/vnd.github.<name>-preview+json로 옵트인.
  • Stripe date-based versioning: account마다 첫 호출 시 version pin. 이후 새 version이 나와도 client가 명시적으로 upgrade하지 않으면 freeze forever (Stripe가 영구적으로 구버전 응답을 유지). 외부 컨슈머 규모가 큰 결제 도메인 특화.
  • Google AIP-180 (Backwards compatibility): enum value 제거 / 의미 변경 / 응답 필드 제거 / 기본값 변경 / required request field 추가 모두 breaking으로 분류. additive (optional response field 추가)만 minor에 허용.
  • Twitter tier-based: legacy / current / beta 트랙 병렬 운영.
  • Spring HATEOAS: 응답에 _links로 다음 자원 URI를 동봉해 client가 version이 아닌 link relation에 결합하게 한다.

Schema / serialization 표준 후보

  • ISO-8601: date·time·datetime·duration의 wire 표현 표준. offset datetime(2026-05-22T11:30:00+09:00 또는 Z)이 timezone ambiguity 회피의 정석.
  • JSON Schema (draft 2020-12): JSON payload의 shape 검증 spec. additionalProperties: false로 unknown field strict, nullable / required / enum으로 의미 분리.
  • OpenAPI 3.1: JSON Schema 2020-12 정합. response shape SSOT 후보. deprecated: true 플래그를 schema/operation 양쪽에 둘 수 있어 deprecation marker 표준 위치가 된다.
  • Avro schema evolution: backward / forward / full compatibility를 schema registry가 자동 검사. 필드 추가/삭제 시 default 의무, alias로 rename. event/outbox 환경에 우위.
  • Protobuf: reserved 키워드로 field number와 name 재사용을 영구 차단. wire-format 기반 strict typing.
  • Jackson (Java): DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES는 default true. 단, FAIL_ON_NULL_FOR_PRIMITIVES는 default false라 null/missing primitive가 묵시적으로 0이 된다. 출력측은 SerializationFeature.WRITE_DATES_AS_TIMESTAMPS(default falseJavaTimeModule 경유 ISO-8601 문자열, true 면 epoch/배열)와 JsonGenerator.Feature.WRITE_BIGDECIMAL_AS_PLAIN(default false → 큰 값이 지수 표기 1.23E+10)이 wire 형식을 좌우한다. 이 둘은 프레임워크 기본값이라 버전 업그레이드로 flip 될 수 있으므로 계약을 명시 핀하고 effective bean 동작 테스트로 회귀를 잡는 것이 안전하다.
  • Property naming strategy: Jackson PropertyNamingStrategies(camelCase default / SNAKE_CASE / KEBAB_CASE)는 wire 의 field 이름 컨벤션을 결정한다. 한 번 정하면 client 가 그 이름에 결합하므로 변경 자체가 breaking — 전역 strategy 변경은 모든 응답 field rename 과 동치다.
  • Null vs absent (@JsonInclude): JsonInclude.Include.NON_NULL/NON_ABSENT/NON_EMPTY 는 null 또는 빈 값을 출력에서 생략 한다. 생략(absent)과 명시적 null 은 client 에게 다른 의미(부재 vs 값이 null) 일 수 있어, JSON Merge Patch 같은 부분 갱신 의미가 필요하면 JsonNullable<T> 로 3-상태(present-null / present-value / absent)를 구분한다.
  • Java BigDecimal: 금액 계산 표준. new BigDecimal(double) 함정 (0.10.1000000000000000055511151231257827021181583404541015625), setScale(2, RoundingMode.HALF_UP) 패턴, JSON에서는 string 직렬화로 client 부동소수 손실 회피가 표준 권고.
  • Smithy: AWS의 API modeling DSL. SDK 코드 생성 친화적, 단 외부 ecosystem에서는 OpenAPI보다 미성숙.

HTTP contract surface 표준 (conditional request / cache / pagination)

versioning·schema 와 별개로, HTTP API surface 자체의 일반 계약 표준. (RFC 9110/9111 은 IETF official-standard, AIP 는 Google community guideline)

  • Conditional request (RFC 9110 §13): ETag 는 representation 의 opaque validator (weak W/"..." 또는 strong). write 는 If-Match 로 optimistic concurrency 검증 — condition 이 false 면 412 Precondition Failed. read 는 If-None-Match 로 cache validation — match 면 304 Not Modified (body 없음, client 저장본 사용). RFC 9110 은 If-Matchstrong comparison 을 MUST 로 요구한다.
  • HTTP caching (RFC 9111 §5.2): Cache-Control directive — no-store (저장 금지, 인증 API 안전 default), private (shared cache 저장 금지), public (Authorization 있어도 shared cache 허용), max-age=N (stale 판정 초). 협상/인증 응답은 Vary (RFC 9110 §12.5.5) 로 어떤 request 부분이 content 선택에 영향을 줬는지 명시해 proxy/CDN cache poisoning 을 막는다.
  • Pagination (Google AIP-158, JSON:API): offset (page/size) vs cursor (opaque token). AIP-158 은 page token 이 opaque + URL-safe MUST, server-side size cap SHOULD coerce, empty next-token = end-of-collection 을 규정. JSON:API 는 links object 안의 first/last/prev/next key 위치를 정의. 구체 숫자(size cap, TTL)는 표준이 아닌 구현 trade-off.
  • Transport error 의미 구분 (RFC 9110 §15): 413 Content Too Large, 406 Not Acceptable (응답 표현 협상 실패) vs 415 Unsupported Media Type (요청 본문 format), 405 Method Not Allowed (+ Allow header MUST). 같은 code 로 뭉개면 표준 의미가 손실된다.
  • Long-running operation (Google AIP-151 + RFC 9110): 비동기 처리는 202 Accepted + Location polling URL + Operation 객체(done/response/error). Retry-After 로 polling interval 권고.

한계 / 주의점

Compatibility / deprecation 측

  • Stripe freeze-forever: 무기한 구버전 유지 비용이 외부 결제 컨슈머 규모에서만 정당화된다. internal API에 그대로 차용하면 server 코드에 N개 버전 분기를 영구 운반하게 된다.
  • GitHub 24개월 EOL + 410 Gone: 길어 보이는 EOL window지만 catalog에 EOL 응답 코드(410)를 명시하지 않으면 client 입장에서 어느 날 갑자기 410과 다를 바 없다. EOL 응답 코드 자체를 contract에 박는 것이 필요하다.
  • Twitter tier-based (legacy/current/beta): 트랙별 행위 분기가 server-side 복잡도와 운영 비용을 곱한다. 단일 팀 / internal-first 환경에 과하다.
  • Spring HATEOAS (links over versions): 이론적으로 우아하지만 실제 client가 _links를 dynamic하게 따라가는 경우는 드물고, 학습 곡선과 client 구현 강제 비용이 크다.
  • Google AIP-180 enum value 제거 = breaking: client switch/case 누락을 유발하므로 strict 분류가 맞지만, enum value 추가 또한 client 입장에서 unknown enum 처리 정책이 없으면 깨진다 — server-side enum addition을 "additive"로만 분류하는 단순화는 위험하다.
  • Sunset 단독 사용: RFC 8594는 언제 사라지는지만 알린다. 같은 자원이 이미 deprecated인지Deprecation 헤더로 함께 보내야 정합이다. Sunset만 보내면 "사라질 날짜는 알지만 지금 권장 여부는 모름" 상태가 된다.
  • Sunset 헤더 단독 사용 금지 — Deprecation draft와 paired: IETF httpapi WG 권고에 따르면 Sunset 헤더는 Deprecation 헤더(draft-ietf-httpapi-deprecation-header, RFC 9745 진행)와 paired로 송신해야 client tooling이 deprecation 상태를 감지할 수 있다. paired invariant는 "Sunset 시점 ≥ Deprecation 시점". 추가로 Link: <url>; rel="deprecation" / rel="sunset"을 함께 보내 사람-가독 가이드를 연결한다. ca-tmpl처럼 marker만 OpenAPI에 박고 응답 헤더 paired 송신을 누락하면 외부 client interceptor가 deprecation을 자동 인지하지 못한다.

Schema / serialization 측

  • Avro / Protobuf strict typing: schema registry가 backward/forward 자동 검사로 강력하나, 외부 REST API가 JSON인 환경에서는 outbox / event 한정 도입이 현실적이다.
  • Smithy: AWS SDK 친화적이지만 외부 ecosystem(예: third-party tooling, doc generator) 성숙도가 OpenAPI 대비 낮다.
  • Jackson default: FAIL_ON_UNKNOWN_PROPERTIES=true는 strict inbound와 정합하나, FAIL_ON_NULL_FOR_PRIMITIVES=false는 null/empty/missing 분리 정책과 불일치다 — 명시적으로 override하지 않으면 contract가 깨진 줄도 모르고 0이 흘러간다.
  • "Jackson은 unknown field tolerant가 default"라는 오해: 보안/계약 측면에서 unknown inbound를 silently 허용하면 typo로 인한 데이터 손실 + payload smuggling 모두 위험. strict inbound가 안전 default.
  • JSON 환경의 Protobuf reserved 흉내: Protobuf는 field number / name 재사용을 wire-format 수준에서 영구 차단한다(reserved 3, 5; / reserved "foo";). OpenAPI 3.1 / JSON Schema 2020-12에는 동등 시맨틱이 없다 — deprecated: true비권장 신호일 뿐 재사용 차단이 아니고, field가 사라지면 schema에서도 사라져 미래 재사용 방지 불가. 현실적 대안은 두 가지: (1) OpenAPI x-removed-fields 같은 Specification Extension으로 schema SSOT에 catalog를 통합하고 자체 lint로 재사용 검출, (2) 별도 markdown catalog(예: docs/removed-fields-catalog.md)에 제거된 이름/번호/일자 기록 후 CI에서 OpenAPI diff와 cross-check. 둘 다 표준 검증 도구가 없어 자체 도구 작성이 따라온다. (needs-confirmation)
  • new BigDecimal(double) 함정: 같은 0.1BigDecimal.valueOf(0.1) (정확)과 new BigDecimal(0.1) (부동소수 잔차)으로 갈린다. 코드 review 규칙으로 차단하지 않으면 unit test 통과 + 운영에서 1원 차이 인시던트가 흔하다.
  • ISO-8601 offset 없는 datetime: 2026-05-22T11:30:00는 표준상 valid이지만 timezone이 누락된다. 서버 timezone에 따라 의미가 달라지므로 contract에서는 offset 필수로 강제해야 한다. 직렬화 형식을 WRITE_DATES_AS_TIMESTAMPS=false로만 핀해도 JavaTimeModule(jackson-datatype-jsr310)이 등록되지 않으면 LocalDateTime[2026,5,22,...] 배열로 직렬화되므로, module 등록 + effective 직렬화 동작 테스트가 함께 필요하다.
  • naming strategy 변경 = 전역 breaking change: snake_case ↔ camelCase 같은 PropertyNamingStrategy 전역 변경은 모든 응답 field 이름이 바뀌는 것과 같아 deprecation window 없이 적용하면 client 가 일제히 깨진다. naming 은 초기에 고정하고 이후 변경을 breaking change catalog 대상으로 다뤄야 한다.
  • @JsonInclude(NON_NULL) 의 의미 손실: null 생략은 payload 를 줄이지만 "값이 null" 과 "field 부재" 를 구분 불가하게 만든다. 부분 갱신(PATCH/merge-patch) contract 에서는 이 구분이 의미를 가지므로 3-상태(JsonNullable/Optional) 표현을 별도로 둬야 하고, 무분별한 NON_NULL 전역 적용은 이 의미 분리를 무너뜨린다.

흔한 오해

  • "Stripe 방식이 표준이다" — IETF/W3C 표준이 아니고 진영별 사례다. 외부 결제 컨슈머 규모를 가정한 trade-off의 결과다.
  • "Sunset 헤더만 보내면 deprecation은 끝이다" — 잘못. Deprecation 헤더(현재 진행 중인지)와 Sunset 헤더(언제 사라지는지)는 함께 사용해야 정합이다.
  • "Jackson은 unknown field tolerant가 안전한 default다" — 잘못. inbound strict가 보안/계약 안전 default이고, outbound는 schema에 없는 field가 노출되지 않도록 controlled해야 한다(소위 strict inbound / tolerant outbound가 아니라 "strict inbound / schema-controlled outbound"가 정확).
  • "enum 값 추가는 무조건 additive다" — server-side 입장에서는 additive지만 client 입장에서는 unknown enum 처리 정책이 없으면 깨진다. client side에 unknown enum fallback이 contract로 명시되어야 비로소 additive다.

Project Application

위 branch-note들이 (a) breaking change 7 분류 + migration window + deprecation marker 위치, (b) serialization producer 책임(date/time/money/enum/null/unknown)을 계약으로 둔다. canonical 승급 여부와 검증 등급은 해당 project 문서가 판정한다.

ca-tmpl 의 HTTP contract surface (versioning/pagination/conditional/cache/OpenAPI) 는 위 두 축과 달리 실제 코드로 구현·로컬 검증됐다 — 구현 사실과 검증 등급은 wiki/projects/ca-tmpl/api-evolution-and-schema 의 "API contract baseline 구현" 절 참조.

Claim-backed Knowledge

각 Knowledge Point 는 이미 §Sources 에 인용된 자료로만 뒷받침된다. company-tech-blog 출처는 사례일 뿐 official best practice 로 격상하지 않는다.

Knowledge Point Supporting Claims Confidence Notes
Sunset 헤더는 자원이 응답 불가가 될 시점을 HTTP-date 로 알리며 Deprecation 헤더와 paired 송신해야 client tooling 이 deprecation 상태를 감지 raw/official-docs/compat-rfc-8594-sunset-header, raw/official-docs/sunset-deprecation-headers-paired-usage high official-standard(RFC 8594) + IETF httpapi draft. "Sunset 단독 충분" 금지. invariant: Sunset 시점 ≥ Deprecation 시점
Google AIP-180 은 enum 제거/의미변경, 응답 필드 제거, 기본값 변경, required request field 추가를 breaking 으로 분류 raw/official-docs/api-versioning-google-aip-180 high official-reference (Google community guideline, IETF/W3C 표준 아님). additive 만 minor 허용
Jackson FAIL_ON_UNKNOWN_PROPERTIES default true (strict inbound) 이나 FAIL_ON_NULL_FOR_PRIMITIVES default false (null/missing primitive → 묵시적 0) raw/official-docs/schema-jackson-unknown-field-handling high official-vendor-doc. "Jackson default 가 안전" 금지 — 후자는 명시 override 필요
new BigDecimal(double) 은 부동소수 잔차를 남기므로 BigDecimal.valueOf + setScale(2, HALF_UP) + JSON string 직렬화 권고 raw/official-docs/schema-bigdecimal-money-serialization-java high official-vendor-doc. client 부동소수 손실 회피
ISO-8601 offset datetime 이 timezone ambiguity 회피의 정석, offset 없는 표현은 서버 timezone 의존 raw/official-docs/schema-jackson-unknown-field-handling medium wire 계약에서 offset 강제 근거 (ISO-8601 일반 상식 + Jackson 직렬화 자료)
OpenAPI 3.1 은 JSON Schema 2020-12 정합의 machine-readable HTTP API contract 이며 deprecated: true marker 를 schema/operation 양쪽에 둘 수 있음 raw/official-docs/openapi-spec-3-1-0 high official-standard(OAS/Linux Foundation). "marker 만으로 client 가 알아서 migrate" 금지
Protobuf reserved 는 field number/name 재사용을 wire-format 수준에서 영구 차단하나 OpenAPI/JSON Schema 에는 동등 시맨틱이 없음 raw/official-docs/schema-protobuf-vs-json-evolution, raw/official-docs/protobuf-reserved-vs-json-openapi-extension medium official-reference. "JSON 에서 완벽 흉내" 금지 — x- extension + 자체 lint 필요, needs-confirmation
RFC 9110 conditional request: ETag validator + If-Match(write, strong comparison MUST)→412 + If-None-Match(read)→304; RFC 9111 cache directive(no-store/private/public/max-age) + Vary 로 cache poisoning 방지 raw/official-docs/rfc9110-http-semantics, raw/official-docs/rfc9111-http-caching high official-standard(IETF). ca-tmpl 의 weak/lenient If-Match 비교는 skeleton 단순화 — project 문서 참조
Pagination: AIP-158 은 page token opaque+URL-safe MUST, server-side size cap SHOULD coerce, empty next-token = EoC. JSON:API 는 links 의 first/last/prev/next 위치 정의 raw/official-docs/spring-data-pageable-defaults (offset/zero-indexed) medium official-vendor-doc(Spring). size cap 숫자/TTL 은 표준 아닌 구현 trade-off

내가 설명할 수 있어야 하는 것

  • API evolution 의 세 영역 분리: compatibility/deprecation vs schema/serialization vs HTTP contract surface (versioning/pagination/conditional/cache). 세 영역이 framework default 가 아니라 명시 계약이어야 하는 이유.
  • Sunset vs Deprecation 헤더의 역할 분리와 paired 송신 이유, paired invariant.
  • breaking change 분류 기준 (enum 축소/제거, 응답 필드 제거, 기본값 변경, required request field 추가) 과 "internal API 니까 그냥 한다" 가 위험한 이유 (client deploy lag).
  • strict inbound / schema-controlled outbound 의 정확한 의미와 Jackson 의 두 feature default 차이.
  • money 직렬화에서 double 위험 / BigDecimal.valueOf / HALF_UP / JSON string 직렬화 근거.
  • conditional request 가 DB optimistic lock 과 같은 충돌의 HTTP 표현이라는 점 (ETag → If-Match → 412, If-None-Match → 304), strong vs weak comparison 차이.
  • 인증 API 의 안전한 cache default = no-store + Vary 가 cache poisoning 을 막는 원리.
  • offset vs cursor pagination trade-off, size cap 이 DoS 방어인 이유, page token opacity 의 의미.
  • transport error 의미 구분 (406 vs 415, 405 + Allow, 413/414) 을 같은 code 로 뭉개면 안 되는 이유.

Interview Questions

  • 90d public + 30d internal migration window의 근거는? 더 짧게/길게 잡으면 어떤 비용이 생기는지? Stripe(freeze forever)나 GitHub(24mo EOL)와 비교했을 때 internal-first 환경에서 90d가 합리적인 이유는?
  • Sunset 헤더와 Deprecation 헤더의 차이는? 둘 중 하나만 보내면 client 입장에서 어떤 정보가 빠지는지?
  • enum value 추가/제거가 breaking change가 되는 이유는? client side에 unknown enum fallback이 있을 때와 없을 때 분류가 어떻게 달라지는지?
  • strict inbound / tolerant outbound가 무슨 의미인지? Jackson FAIL_ON_UNKNOWN_PROPERTIESFAIL_ON_NULL_FOR_PRIMITIVES는 default가 어떻게 잡혀 있고, 어느 쪽을 override해야 하는지?
  • money 직렬화에서 BigDecimal scale 2 + HALF_UP을 택한 이유는? double이 위험한 이유, new BigDecimal(double) 함정, JSON string 직렬화로 client 부동소수 손실을 회피하는 이유를 설명할 수 있는지?

Do Not Overclaim

  • "Stripe 방식이 API versioning의 표준이다"라고 말하면 안 된다 — 진영별 사례이며 외부 결제 컨슈머 규모에 특화된 trade-off다.
  • "Sunset 헤더만 보내면 deprecation 정책으로 충분하다"라고 말하면 안 된다 — Deprecation 헤더와 함께 사용해야 정합이다.
  • "OpenAPI deprecated: true로 표시했으니 client가 알아서 migration한다"라고 단정하면 안 된다 — schema marker는 신호일 뿐이고 실제 cutover는 migration window + contract test + compatibility fixture가 함께 강제해야 한다.
  • "Jackson default가 안전하다"고 단정하면 안 된다 — FAIL_ON_UNKNOWN_PROPERTIES는 strict default이지만 FAIL_ON_NULL_FOR_PRIMITIVES는 lenient라 null/missing primitive가 묵시적으로 0이 된다.
  • "Avro / Protobuf로 가면 schema evolution이 자동 검사된다"라고 일반화하면 안 된다 — registry 인프라(예: Confluent Schema Registry)와 wire format 변경 비용이 따라온다. 외부 REST가 JSON인 환경에서는 outbox/event 한정 도입이 현실적이다.
  • "narrow enum / 응답 필드 제거 / 필드 rename"을 "internal API니까 그냥 한다"라고 정당화하면 안 된다 — client가 deploy lag을 가지면 internal에서도 breaking이다.

Sources

공식 표준 / 표준 후보

진영별 사례 (표준 아님)

Canonical (프로젝트 결정 사실)

Cluster / 묶음