--- title: ca-tmpl - API Evolution & Schema 결정 (versioning + contract baseline + serialization) source_type: project status: verified confidence: high tags: [ca-tmpl, api-design, versioning, pagination, conditional-request, http-cache, openapi, schema] related_projects: [ca-tmpl] last_reviewed: 2026-07-02 --- # ca-tmpl - API Evolution & Schema 결정 (versioning + contract baseline + serialization) > Layer: `wiki/projects/` — 내 프로젝트 사실. 일반 개념은 [[wiki/concepts/api-evolution-and-schema]] 참고. ## 프로젝트 컨텍스트 ca-tmpl은 Clean Architecture 기반 백엔드 skeleton 프로젝트다. 이 문서는 API surface 의 세 영역을 다룬다. - **API contract baseline (구현됨)** — versioning (`/v1` path prefix), pagination/sort, conditional request (ETag/If-Match/304/412), HTTP cache policy, OpenAPI producer, long-running operation, batch endpoint. `feature-api-contract-baseline` branch 가 producer-소유 결정을 실제 코드(`adapter-web` + `sample-portfolio`)에 구현하고 단위/슬라이스/임베디드 테스트로 검증했다. **`locally-verified`**. - **Compatibility / deprecation 축 (설계만)** — `90d public + 30d internal migration window`, 7행 breaking change catalog, RFC 8594 `Sunset` + `Deprecation` 헤더 병기, OpenAPI `deprecated: true` marker. `feature-api-compatibility-deprecation-contract` branch 의 결정이며 **코드 미구현 (`documented-only`)**. - **Schema / serialization 축 (출력측 부분 구현)** — ISO-8601 offset datetime, `BigDecimal` scale 2 + `HALF_UP`, unknown field strict inbound, null/empty/missing 분리. `feature-schema-serialization-contract` branch 의 결정이다. **직렬화 출력측 핀 (`WRITE_DATES_AS_TIMESTAMPS=false` / `WRITE_BIGDECIMAL_AS_PLAIN=true`) + `new BigDecimal(double)` 정적 차단 ArchUnit 룰 + 직렬화 동작 테스트는 실제 코드로 구현·로컬 검증됨 (`locally-verified`)**. 단 입력측 deser switch·null/empty/missing 3-상태(`Patch`)는 sibling `feature-boundary-validation-mapping-contract` 가 소유하며, OpenAPI drift release gate (D5) · 제거-field 재사용 도구 (D6) · Avro Schema Registry (D7) · money string-vs-number per-API 코드 시연은 미구현 (`documented-only` / `planned` / `needs-confirmation`). > **Ground-truth 대조 (2026-06-04, ca-tmpl @b15dcf5 "API 계약 baseline 구현")**: contract baseline 축의 아래 `actually-implemented` / `locally-verified` 항목은 ca-tmpl 저장소 commit `b15dcf5` 의 실제 코드(`dev.caskeleton.*` package root)와 1:1 대조해 확인했다. > > **Ground-truth 대조 (2026-06-04, ca-tmpl @5d89766 "schema/serialization 계약 직렬화 출력측 구현")**: schema/serialization 축 *출력측* 항목 — `no_bigdecimal_double_constructor` ArchUnit 룰(`CleanArchitectureTest`), `JacksonSerializationPolicyTest`, `application.yml`/`application-test.yml`/`.env` 의 직렬화 핀 두 키 — 은 commit `5d89766` 의 실제 코드와 1:1 대조해 확인했다 (`locally-verified`). compatibility/deprecation 축 + schema 의 D5/D6/D7 + per-API money 직렬화 코드 시연은 여전히 `documented-only` / `planned` / `needs-confirmation`. ## 실제 구현 내용 (`actually-implemented`) > **API contract baseline 축** (`feature-api-contract-baseline`) + **schema/serialization 축의 출력측** (`feature-schema-serialization-contract`) 이 구현됨. compatibility/deprecation 축 + schema 의 D5/D6/D7 은 코드 부재 (§문서/계획만 존재). 코드에 존재하는 클래스/필터 (테스트 유무와 무관하게 production main 소스에 존재): - **D2 versioning** — `/v1` path prefix 는 설정 주도(`app-bootstrap/.../application.yml` 의 `ca-skeleton.presentation.api-base-path: ${PRESENTATION_API_BASE_PATH:/v1}`) + `adapter-web` `PresentationSettings` (env 누락/`/` 누락 시 warn + 보정). 코드 자체의 default 는 `""`, 운영 default 는 `/v1`. - **D18/D20 pagination/sort** — `adapter-web` `PageParams` (page≥0, size 1..100, deep-offset>10000 플래그), `SortParam` (Spring native `field,direction` 파싱 + 비-네이티브 reject), `shared-contract` `PageMeta`/`ResponseMeta.page`. - **D15 conditional request** — `adapter-web/conditional/ETags` (`weakFromVersion` = `W/""`, lenient `matches`), `PreconditionFailedException`. - **D16 cache policy** — `adapter-web/filter/CacheControlFilter` (`@Order(HIGHEST_PRECEDENCE+20)`, 모든 응답에 `Cache-Control: no-store` + `Vary: Accept, Accept-Encoding, Authorization`). - **D22 cursor (SEAM)** — `adapter-web/cursor/CursorCodec` (base64url(`iat:payload`) + HMAC-SHA256 + 24h TTL) + `CursorException`. - **D17 LRO** — `sample-portfolio` `OperationsController` (`POST /worklogs:export` → 202 + `Location` + `Operation`, `GET /operations/{id}` polling), `shared-contract` `Operation`/`OperationStatus`, `SampleOperationStore`. - **D8/D9/D12 transport errors** — `adapter-web/error/GlobalExceptionHandler` 가 413(`PAYLOAD_TOO_LARGE`)/406(`NOT_ACCEPTABLE`)/415(`UNSUPPORTED_MEDIA_TYPE`)/405(`METHOD_NOT_ALLOWED` + `Allow` header)/412(`PRECONDITION_FAILED`) 를 envelope 로 매핑. - **D23 batch** — `sample-portfolio` `WorkLogController` 의 `POST /worklogs:batchCreate` (단일 tx atomic, `@Size(max=1000)` cap) + `BatchCreateWorkLogsUseCase`. - **D10 OpenAPI producer** — `adapter-web/build.gradle` 에 `springdoc-openapi-starter-webmvc-api:2.8.6` 의존 추가, `/v3/api-docs` 노출. ### Schema / serialization 출력측 (`feature-schema-serialization-contract`, ca-tmpl @5d89766) 직렬화 *출력측* 계약을 코드에 핀하고 정적으로 차단했다. 입력측 deser switch(`FAIL_ON_UNKNOWN_PROPERTIES`/`FAIL_ON_NULL_FOR_PRIMITIVES`/`READ_UNKNOWN_ENUM_VALUES_AS_NULL=false`)와 null/empty/missing 3-상태(`Patch`)는 sibling `feature-boundary-validation-mapping-contract` 소유이므로 본 축 *출력측* 만 여기서 다룬다. - **D2 datetime 직렬화 핀** — `app-bootstrap/.../application.yml` 의 `spring.jackson.serialization.write-dates-as-timestamps=false` (env `SPRING_JACKSON_SER_WRITE_DATES_AS_TIMESTAMPS` 바인딩). `java.time` 값이 epoch/배열이 아니라 ISO-8601 문자열로 직렬화됨. `JavaTimeModule` 은 Spring Boot `starter-json` auto-config 가 classpath 의 `jackson-datatype-jsr310` 을 자동 등록 — 명시 등록 코드는 없음. - **D3 BigDecimal plain 직렬화 핀** — `application.yml` 의 `spring.jackson.generator.write-bigdecimal-as-plain=true` (env `SPRING_JACKSON_GEN_WRITE_BIGDECIMAL_AS_PLAIN` 바인딩). 지수 표기(`1.23E+10`) 대신 plain notation 으로 직렬화. - **D3 정적 차단 ArchUnit 룰** — `app-bootstrap/.../architecture/CleanArchitectureTest` 의 `no_bigdecimal_double_constructor` (`@ArchTest`). `dev.caskeleton..` production 패키지에서 `callConstructor(BigDecimal.class, double.class)` / `float.class` 호출을 build fail. (`new BigDecimal(0.1)` 의 부동소수 잔차 함정 = SBMS-C3 차단) - **위반 fixture** — `architecture/violations/serialization/BigDecimalDoubleConstructorFixture` (`new BigDecimal(double/float)` 사용) — 룰의 vacuous-pass 방지용 negative fixture. - **테스트 리소스 핀** — `application-test.yml` 에 위 두 키를 리터럴(`false`/`true`)로 박아 테스트 프로파일에서도 동일 계약 유지. 이 핀들은 *현재 Spring Boot 기본값과 일치*하나, future default flip 회귀를 차단하기 위해 명시했다 (rationale 은 `.env` 주석에 `spring.mvc.problemdetails.enabled=false` 와 동일 논리로 기록). compatibility/deprecation 축: 없음 (version interceptor, Sunset/Deprecation header bean, OpenAPI deprecation marker 모두 부재). schema 축의 D5 OpenAPI drift release gate · D6 제거-field 재사용 도구 · D7 Avro Schema Registry · money string-vs-number per-API 코드 시연: 부재 (§문서/계획만 존재 / SEAM). ## 로컬/dev 검증 (`locally-verified`) 위 contract baseline 구현은 단위/슬라이스/임베디드-컨테이너 테스트로 동작이 확인됐다 (`./gradlew check` + ArchUnit gate PASS): - `TransportErrorHandlingTest` — 413/406/415 distinct + 405 + `Allow` header. - `WorkLogControllerWireTest` — D15 ETag 발행 / `If-None-Match`→304 / `If-Match` mismatch→412, D7/D18 `meta.page` + size·page 경계 400 + 빈 list `[]` + deep-offset `Deprecation` 헤더, D20 sort 네이티브/비-네이티브, D21 flat filter 무시(`filter_dsl_is_ignored_not_parsed`), D13 HEAD-mirror-GET(`head_on_get_endpoint_is_supported_not_405`), D23 batch size cap(`batch_over_size_cap_is_400`, 1001→400), D3 `Idempotency-Key` POST surface(`post_accepts_idempotency_key_header`, server-tolerant). - `CacheControlFilterTest` — D16 default `no-store` + `Vary`. - `CursorCodecTest` — D22 opacity / integrity(서명 변조 탐지) / TTL 3-invariant. - `ETagsTest`, `PageParamsTest`, `SortParamTest` — adapter 단위 검증. - `OperationsControllerWireTest` — D17 202 + `Location` + `data.{operationId,statusUrl}` + polling. - `OpenApiSnapshotTest` — D10 임베디드 RANDOM_PORT 컨테이너에서 `/v3/api-docs` 200 응답 + `WorkLogController` 반영. - `VersioningPrefixTest` — D2 `/v1/probe` 200, `/probe` 404 (unversioned public endpoint 불가). - `DateHeaderContractTest` — D24 임베디드 Tomcat 200·404 응답에 `Date` 헤더. - `ErrorCodeRegistryMappingTest` — D11 405/406/412/413/414/415 row 와 controller 응답 drift FAIL (producer contract test). Schema / serialization 출력측 (`feature-schema-serialization-contract`, @5d89766) 테스트: - `JacksonSerializationPolicyTest` — ① `JacksonProperties` 바인딩 assert (`WRITE_DATES_AS_TIMESTAMPS=false`, `WRITE_BIGDECIMAL_AS_PLAIN=true`), ② wired `ObjectMapper` 직렬화 동작 assert: `OffsetDateTime`(UTC)→`"1985-04-12T23:20:50.52Z"`, `LocalDate`→`"2026-06-02"`, `new BigDecimal("1.10")`→`1.10` (trailing zero 보존), 대형 값(`12300000000000000000.00`)이 비-scientific notation. `ApplicationContextRunner` 로 effective bean 동작까지 검증해 `JavaTimeModule` 누락 회귀(배열 직렬화)도 잡는다. - `ArchitectureViolationFixtureTest.no_bigdecimal_double_constructor_catches_double_and_float_constructors` — D3 ArchUnit 룰이 fixture 의 `new BigDecimal(double/float)` 를 실제로 잡는지 검증 (vacuous-pass 방지). - 검증 명령: `./gradlew verifyCleanArchitectureDependencies` + `:app-bootstrap:test` + 전체 `test` 모두 BUILD SUCCESSFUL. compatibility/deprecation 축 + schema 의 D5/D6/D7: 없음. Sunset+Deprecation 헤더 응답·`api-version` 헤더 라우팅·OpenAPI drift release gate·제거-field 재사용 도구·Avro compat 자동검사 어느 것도 로컬에서 실행/통합 테스트로 확인된 바 없다. per-API money string-vs-number 직렬화도 sample 도메인에 money 필드가 없어 코드 시연 없음(문서 의무만). ## 운영 검증 (`prod-verified`) 없음. ca-tmpl 은 운영 배포가 없다. contract baseline 항목은 전부 로컬/CI 검증까지이며, compatibility/schema 축은 90d/30d migration window·deprecation cutover·Sunset 시점 410 응답 같은 운영 검증 0건이다. ### SEAM / 계획만 존재 (`planned`) — contract baseline 축 형제 branch 또는 인프라에 막혀 의도적으로 seam 또는 planned 로 남긴 항목 — 면접에서 "구현했다"고 말하면 안 되는 경계: - **D22 HMAC 키 회전 / 운영 key 주입** — `CursorCodec` 은 주입식 key 와 `withDevKey()` (dev/test 전용) factory 만 제공. production key wiring + rotation 은 `feature-security-operational-baseline` 소유, 미구현. encode/decode·opacity·integrity·TTL 메커니즘 자체는 구현됨. - **D8 414 URI Too Long end-to-end** — Tomcat/gateway 가 Spring dispatch 전에 거부하므로 code + registry row 만 존재, end-to-end 검증 없음. - **D3 key shape / replay semantics** — header 이름(`Idempotency-Key`)과 POST surface 수용만 구현. key shape/scope/replay 는 `feature-rate-limit-idempotency-contract` 소유. - **D5 / D10 drift 릴리스 게이트** — OpenAPI drift release-blocking 집행은 `feature-contract-verification-test-suite` 소유. 이 branch 는 producer(snapshot 발행 + registry mapping 정합 test)까지. - **D16 cache layer** — Redis/CDN 구현은 `feature-cache-consistency-contract` 소유. 이 branch 는 HTTP 응답 header 정책(`no-store`/`Vary`)만. - **D22 sample cursor endpoint** — `CursorCodec` 만 있고 cursor 페이징을 노출하는 sample endpoint 는 §Test Contract 미요구 (optional). - **D14 PATCH `merge-patch+json` 차단** — content type 정책은 이 branch 가 producer 지만 ArchUnit rule `no_merge_patch_json_media_type_string` 와 mapper 구현은 `feature-boundary-validation-mapping-contract` B2 소유 (cross-branch SSOT). ### 근거 미명시 구현 결정 (`UNSUPPORTED_IMPL_DECISION` 잔존) 표준이 *원칙* 만 권고하고 *숫자/메커니즘* 은 project-internal trade-off 인 지점 — 면접에서 "표준이라서"가 아니라 "내가 이렇게 trade-off 했다"로 말해야 함: - **pagination size cap 100 / min 1 / deep-offset 10000** — Spring 기본 `DEFAULT_MAX_PAGE_SIZE` 는 2000(`PageParams` 주석에도 명시). 100 cap 은 DoS 방지용 추가 제한, 숫자는 표준 근거 없음. - **ETag lenient(weak) 비교** — RFC 9110 은 `If-Match` 에 *strong* comparison 을 MUST 로 규정하나(`ETags` javadoc 에 명시), skeleton 은 `W/` 마커·따옴표를 무시하는 lenient 비교로 weak-ETag 형태가 그대로 optimistic lock 을 구동하게 했다. production fork 는 strong ETag 로 교체 가능. - **cursor 24h TTL + HMAC-SHA256 선택** — AIP-158 은 opacity/URL-safe 만 MUST, TTL 숫자와 서명 알고리즘은 project-internal. - **LRO status enum 5종(PENDING/RUNNING/SUCCEEDED/FAILED/CANCELLED)** — AIP-151 은 `done`/`response`/`error` 이진 모델만 정의, 5종 어휘 매핑은 project-internal. ## 문서/계획만 존재 (`documented-only` / `planned`) > **Compatibility / deprecation 축** (`feature-api-compatibility-deprecation-contract`) 은 결정/설계만 있고 **코드 미구현(`documented-only`)** 이다. **Schema / serialization 축** 은 *출력측* (datetime/BigDecimal 핀 + ArchUnit) 만 `locally-verified` (위 §실제 구현 내용 참조) 이고, 아래 D5/D6/D7 + per-API money 직렬화는 여전히 미구현이다. contract baseline 의 `locally-verified` 와 혼동하면 안 된다. 다음 항목은 모두 canonical 계약 문서와 branch-note 단계에 머물러 있다. 면접에서 "구현했다 / 운영했다"고 말하면 안 된다. ### Compatibility / deprecation 결정 - **90d public + 30d internal migration window**: 외부 client는 90일, internal client는 30일의 이중 window로 deprecated API를 계속 응답하면서 marker로 신호한다. Stripe의 freeze-forever, GitHub의 24mo EOL과 비교 검토 후 internal-first 환경 trade-off로 90d/30d를 선택. - **7행 breaking change catalog**: 응답 필드 제거 / 응답 필드 의미 변화 / required request field 추가 / enum value 제거 / enum value 의미 변화 / narrow enum(허용값 축소) / 기본값 변경 — 7항목을 breaking으로 분류. Google AIP-180 정의를 ca-tmpl 도메인에 맞게 행 단위로 catalog화. - **`Sunset` 헤더 (RFC 8594) + `Deprecation` 헤더 병기**: Sunset 단독은 *언제 사라지는지*만 알리므로 *지금 deprecated인지* 신호인 `Deprecation` 헤더를 함께 보낸다. concept §흔한 오해 항목과 정합. - **OpenAPI `deprecated: true` marker**: operation / schema 양쪽에 둘 수 있는 표준 marker로 deprecation을 schema SSOT에 박는다. - **Sunset + Deprecation 헤더 paired 전송 결정 (2026-05-22)**: API deprecation 응답은 `Sunset: ` + `Deprecation: @` 헤더를 **함께** 송신한다. 단독 Sunset 금지. paired invariant는 "Sunset 시점 ≥ Deprecation 시점". 추가로 `Link: ; rel="deprecation"` (정책 문서), `Link: ; rel="sunset"` (마이그레이션 가이드)를 권장. 근거: [[raw/official-docs/sunset-deprecation-headers-paired-usage]]. 상태: `documented-only` — bean / interceptor 코드 미작성. 출처: [[raw/project-notes/ca-skeleton-operational-contract]] §13 API Contract Surface + §29 G-F (외부 근거 인덱스), [[raw/branch-notes/feature-api-compatibility-deprecation-contract]]. ### Blog-topic ingest: api-deprecation-sunset-header-migration-window (2026-07-02) [[raw/blog-topics/api-deprecation-sunset-header-migration-window-2026-07-02]] 는 위 compatibility/deprecation 축을 블로그로 풀기 위한 raw seed다. canonical 승격 기준은 다음처럼 정리했다. - **프로젝트 사실로 보존**: D5-D8은 `feature-api-compatibility-deprecation-contract` 에 기록된 ca-tmpl 결정이다. 단 구현/운영 검증이 없으므로 등급은 `documented-only` / `needs-confirmation` 이다. - **source-backed 로 말할 수 있는 부분**: RFC 8594 `Sunset`, `Deprecation` header paired usage, Google AIP-180 기반 breaking-change 분류, OpenAPI `deprecated: true` marker 의 존재. - **project-local policy 로만 말할 부분**: `90d public + 30d internal` 숫자, release-blocking diff gate, compatibility fixture 결합 방식. 표준 요구사항처럼 쓰지 않는다. - **블로그 전 과장 방지**: 실제 API deprecation 운영 경험, 외부 client migration coordination, 410 cutover 실측은 없다. ### Schema / serialization 결정 (출력측은 위 §에서 구현, 아래는 미구현분만) > 아래 항목 중 datetime/BigDecimal *출력측 핀* 과 `new BigDecimal(double)` 정적 차단은 @5d89766 에서 `locally-verified` (§실제 구현 내용 참조). unknown field strict inbound 와 null/empty/missing 분리는 sibling `feature-boundary-validation-mapping-contract` 가 `locally-verified` (입력측 deser + `Patch`). 여기 남는 미구현분은 D5/D6/D7 + per-API money 직렬화 코드 시연이다. - **per-API money string-vs-number 직렬화 시연** (`documented-only`): scale 2 + `HALF_UP` 기본 + plain notation 핀은 구현됐으나, 외부/금융 API = string vs 내부 API = number+plain 의 endpoint별 명시 선택은 **문서 의무**(adapter-web 계약 문서)로만 박혔다. sample 도메인(WorkLog)에 money 필드가 없어 `@JsonSerialize(ToStringSerializer)` 같은 코드 시연은 없다. - **Field 재사용 금지 catalog 정책 (자체 markdown 또는 OpenAPI `x-removed-fields`)** (`needs-confirmation`, D6): Protobuf `reserved` 시맨틱(field number/name 재사용 영구 차단)을 JSON 환경에서 흉내내기 위해 제거된 field 이름/번호를 catalog로 관리하고 CI에서 재사용을 검출. 두 후보 — (a) OpenAPI Specification Extension `x-removed-fields` + 자체 lint, (b) 별도 markdown catalog + CI cross-check — 중 도구 선택이 미정. 2026-05-22 needs-confirmation. 출처: [[raw/official-docs/protobuf-reserved-vs-json-openapi-extension]]. - **OpenAPI drift release gate** (`planned`, D5): response 측 "schema 없는 field 미노출" 의 실제 강제는 verification suite 소유. springdoc producer 는 존재하나 release-blocking drift gate 는 `feature-contract-verification-test-suite` 미구현. - **Avro Schema Registry compat 자동검사** (`needs-confirmation`, D7): outbox/event 한정 검토 가치. 외부 REST/JSON 은 JSON 유지. Confluent compatibility level enforcement 메커니즘 미확보. 출처: [[raw/project-notes/ca-skeleton-operational-contract]] §16 Schema / Serialization Contract + §29 G-F, [[raw/branch-notes/feature-schema-serialization-contract]]. ### Blog-topic ingest: spring-boot-serialization-contract-pins (2026-07-02) [[raw/blog-topics/spring-boot-serialization-contract-pins-2026-07-02]] 는 schema/serialization 출력측 구현을 블로그로 풀기 위한 raw seed다. canonical 승격 기준은 다음처럼 정리했다. - **locally-verified 로 말할 수 있는 부분**: `WRITE_DATES_AS_TIMESTAMPS=false`, `WRITE_BIGDECIMAL_AS_PLAIN=true` 설정 pin, wired `ObjectMapper` 직렬화 테스트, `new BigDecimal(double/float)` ArchUnit 차단과 negative fixture. - **source-backed 로 말할 수 있는 부분**: RFC 3339 datetime 표현, Java `BigDecimal` 생성자/scale/rounding 의미, Jackson serialization feature의 역할. - **project-local policy 로만 말할 부분**: 현재 Spring Boot 기본값과 같아도 future default drift를 막기 위해 명시 pin + effective-bean test를 둔 결정. - **블로그 전 과장 방지**: 입력측 deser switch, null/empty/missing 3-상태, per-API money string-vs-number 직렬화 예제는 이 branch의 구현 범위가 아니다. 특히 sample 도메인에는 money field 코드 시연이 없다. ### 5종 대안 검토 결과 concept 문서([[wiki/concepts/api-evolution-and-schema]]) Standard 섹션의 5개 진영 — Stripe date-based / GitHub `X-GitHub-Api-Version` + 24mo EOL / Google AIP-180 / Twitter tier-based / Spring HATEOAS — 을 비교한 결과 internal-first + 단일 팀 trade-off로 **`api-version` 헤더 + 90d/30d migration window + Sunset+Deprecation 병기**를 채택. 사유는 concept 문서 한계 / 주의점 섹션과 동일. versioning/compatibility 대안 비교 자체는 문서/설계 단계 — version interceptor, Sunset header bean 미작성. (Jackson 직렬화 출력측 핀은 별개로 구현됨, §실제 구현 내용 참조.) ## 면접에서 말할 수 있는 범위 ### 자신 있게 답할 수 있는 질문 - **90d public + 30d internal migration window 근거** — Stripe(freeze forever)는 외부 결제 컨슈머 규모에 특화된 trade-off라 internal에 그대로 차용 시 server에 N개 버전 분기를 영구 운반, GitHub 24mo EOL은 catalog에 410 응답 명시가 없으면 사실상 *어느 날 갑자기 410*과 같음. internal-first 단일 팀 환경에서는 deploy lag을 흡수할 수 있는 가장 짧은 두 layer로 90d/30d. - **`Sunset` vs `Deprecation` 헤더 차이 + 함께 보내는 이유** — `Sunset`(RFC 8594)은 *언제* 사라지는지의 HTTP-date 신호(ABNF: `Sunset = HTTP-date`), `Deprecation` 헤더(draft-ietf-httpapi-deprecation-header)는 *지금 deprecated인지*의 Structured Date 상태 신호. 하나만 보내면 "사라질 날짜는 아는데 권장 여부는 모름" 또는 그 반대 상태가 되므로 **paired 송신이 IETF httpapi WG 권고**. paired invariant는 "Sunset 시점 ≥ Deprecation 시점". 추가로 `Link rel="deprecation"` / `rel="sunset"`으로 사람-가독 가이드 연결. ca-tmpl도 결정 사항에 paired 전송을 명시 박음(2026-05-22). - **Narrow enum이 breaking인 이유** — server-side에서는 허용값 축소가 invariant 강화처럼 보이지만, 이전 enum value를 합법적으로 보내던 client 입장에서는 어제까지 통과하던 요청이 오늘 거부됨. enum value 추가도 client side에 unknown enum fallback이 contract로 없으면 breaking. - **Strict inbound + tolerant outbound 의미** — 요청은 unknown field를 거부해 typo/payload smuggling 방어, 응답은 schema 정의 외 field 누출을 막음. 단 concept 문서가 지적하듯 정확한 표현은 "strict inbound / schema-controlled outbound". - **BigDecimal `new BigDecimal(double)` 함정 + ArchUnit 정적 차단** — `new BigDecimal(0.1)`은 `0.1000...555` 잔차를 담고 `new BigDecimal("0.1")`/`BigDecimal.valueOf`는 정확하다. ca-tmpl 은 이 함정을 `no_bigdecimal_double_constructor` ArchUnit 룰(`callConstructor(BigDecimal.class, double.class)`/`float.class`)로 production 패키지에서 build fail 시키고, vacuous-pass 방지 fixture 테스트까지 둔다 (`locally-verified`, @5d89766). HALF_UP 은 금융 round-half-up 관례와 정합. JSON number 직렬화 시 JS `Number` 정밀도 손실이 있어 외부/금융 API 는 string 직렬화 권장 — 단 per-API string-vs-number 는 *문서 의무*로만 박혔고 sample 도메인에 money 필드가 없어 코드 시연은 없다. - **serialization 계약을 '기본값'이 아니라 '명시 핀 + effective-bean 테스트'로 고정한 이유** — `WRITE_DATES_AS_TIMESTAMPS=false`/`WRITE_BIGDECIMAL_AS_PLAIN=true`는 현재 Spring Boot 기본값과 일치하나, future default flip 회귀를 차단하려고 `application.yml`/`application-test.yml`/`.env`에 명시 핀했다 (`spring.mvc.problemdetails.enabled=false`와 동일 논리). `JacksonSerializationPolicyTest`가 `ApplicationContextRunner`로 wired `ObjectMapper`의 `OffsetDateTime`→`"...Z"`/`LocalDate`→`"YYYY-MM-DD"`/`BigDecimal`→plain 직렬화 동작까지 검증해 `JavaTimeModule` 누락 회귀(배열 직렬화)도 잡는다 (`locally-verified`, @5d89766). - **conditional request 가 DB optimistic lock 과 같은 충돌의 두 표현이라는 점** — read 응답이 entity `@Version` 으로부터 `W/""` ETag 를 발행하고(`ETags.weakFromVersion`), write 가 `If-Match` 로 그 버전을 제시한다. 버전이 안 맞으면 HTTP layer 에서 412 Precondition Failed (`PreconditionFailedException` → `GlobalExceptionHandler`), 같은 충돌이 persistence layer 면 serialization failure 로 표현된다. ca-tmpl 은 `WorkLogControllerWireTest` 로 ETag 발행/304/412 를 검증했다 (locally-verified). - **인증된 API 의 안전한 cache default = `no-store`** — `CacheControlFilter` 가 모든 응답에 `Cache-Control: no-store` + `Vary: Accept, Accept-Encoding, Authorization` 를 박아 proxy/CDN cache poisoning 을 막는다. cacheable endpoint 만 `ResponseEntity` 의 `Cache-Control` 로 opt-in. Spring Security 자체 cache-control 은 비활성화해서 이 필터를 단일 owner 로 둠. - **pagination 의 size cap 이 왜 DoS 방어인가 + Spring 기본값과의 관계** — `size` 를 1..100 으로 제한하고 `page<0`/`size` 범위 밖은 400 VALIDATION_FAILED (`PageParams`). Spring 의 기본 `DEFAULT_MAX_PAGE_SIZE` 는 Integer.MAX_VALUE 가 아니라 2000 이며, 100 cap 은 그 위에 얹은 project-internal 추가 제한이라는 점까지 말할 수 있다. - **batch endpoint 의 sync = atomic 결정** — `POST /worklogs:batchCreate` 는 AIP-136 colon-verb + 단일 트랜잭션 all-or-nothing (partial 금지), `@Size(max=1000)` cap. partial failure 는 async LRO polling 응답에서만 허용. `batch_over_size_cap_is_400` 으로 검증. ### 적당히 답할 수 있는 질문 - **Stripe date-based versioning vs ca-tmpl** — Stripe는 account 단위 version pin + freeze forever로 외부 결제 컨슈머 deploy lag을 server 측 영구 분기로 흡수, ca-tmpl은 헤더 기반 + 시한 migration window로 server 분기 부담을 한정. 다만 외부 컨슈머 규모 차이가 trade-off의 본질이라 "ca-tmpl이 더 낫다" 식의 단정은 금지. ### 답하면 안 되는 질문 (모른다고 해야 함) - **"API deprecation을 운영해 본 경험"** — 답: 없음. ca-tmpl은 운영 배포 자체가 없다. - **"외부 컨슈머와 migration coordination을 해본 경험"** — 답: 없음. 외부 컨슈머가 존재하지 않는다. - **"compatibility/deprecation 결정을 코드로 구현했는가"** — 답: 아니다. 계약·설계 단계. (schema/serialization 출력측은 별개로 C2 에서 `locally-verified` — 위 §실제 구현 참조. compatibility 축만 미구현.) - **"운영 측정값 / cutover 인시던트 / 410 응답 실측"** — 답: 모두 없다. ## 과장 금지 지점 - **"Stripe 방식이 API versioning의 표준이다"** — ❌. IETF/W3C 표준이 아니고 외부 결제 컨슈머 규모에 특화된 trade-off다. ca-tmpl은 다른 trade-off를 택한 것이지 우열을 판정한 게 아니다. - **"`Sunset` 헤더만 보내면 deprecation 정책으로 충분하다"** — ❌. `Sunset`은 *언제* 신호이고 `Deprecation`은 *지금 상태* 신호다. 병기해야 정합. - **"OpenAPI `deprecated: true`로 marker만 박으면 client가 알아서 migrate한다"** — ❌. schema marker는 신호일 뿐, 실제 cutover는 migration window + contract test + compatibility fixture가 함께 강제해야 한다. - **"Protobuf `reserved` 시맨틱을 JSON 환경에서 동등하게 흉내낼 수 있다"** — ❌. OpenAPI에는 동등 시맨틱이 없고 `x-` extension으로 흉내내야 하는데 검증 도구 표준이 부재해 효과가 제한적이다. **needs-confirmation**. - **"Jackson default가 안전하다"** — ❌. `FAIL_ON_UNKNOWN_PROPERTIES=true`는 strict이지만 `FAIL_ON_NULL_FOR_PRIMITIVES=false`는 lenient라 null/missing primitive가 묵시적으로 0이 된다. ca-tmpl 은 후자(입력측 deser switch)를 sibling `feature-boundary-validation-mapping-contract` 가 명시 override 했고 (`locally-verified`), 직렬화 출력측은 본 branch 가 핀했다. "default 라서 안전"이 아니라 "명시 핀 + 테스트"로 강제했다고 말해야 한다. - **"90d/30d window를 운영에서 검증했다"** — ❌. 운영 배포 0건. 설계 결정의 *근거*는 말할 수 있지만 *경험*은 없다. - **"envelope처럼 compatibility 결정도 구현했다"** — ❌. compatibility/deprecation 축은 계약/설계 단계, 코드 미구현. schema/serialization 축은 *출력측* (datetime/BigDecimal 핀 + ArchUnit + 직렬화 테스트) 만 `locally-verified` 이고, D5 OpenAPI drift gate · D6 제거-field 도구 · D7 Avro · per-API money string-vs-number 코드 시연은 미구현이다. (contract baseline 축은 별개로 locally-verified) - **"BigDecimal 을 금액 string 직렬화로 구현했다"** — ❌. `WRITE_BIGDECIMAL_AS_PLAIN=true` + `new BigDecimal(double)` 정적 차단은 구현했으나, 외부 API string 직렬화(`@JsonSerialize(ToStringSerializer)`)는 sample 도메인에 money 필드가 없어 코드 시연이 없다 — per-API string-vs-number 는 *문서 의무*까지다. - **"OpenAPI drift 로 schema 없는 response field 노출을 차단한다"** — ❌. 직렬화 출력측 핀은 했으나 response 측 "schema 없는 field 미노출"의 release-blocking 강제(D5)는 verification suite(`feature-contract-verification-test-suite`) 소유 planned 이다. - **"conditional request 를 RFC 9110 대로 strong ETag 로 구현했다"** — ❌. `If-Match` 비교는 weak/lenient 다 (`ETags.matches` 가 `W/`·따옴표 무시). RFC 9110 의 strong comparison MUST 와는 다른 skeleton 단순화이며, production fork 에서 교체해야 한다. - **"cursor pagination 을 운영 key 로 서명해 구현했다"** — ❌. `CursorCodec` 은 dev key factory(`withDevKey()`)만 있고 운영 key 주입/회전은 security branch 소유 planned. 메커니즘(opaque base64url + HMAC + TTL)은 구현·검증됨. - **"414 URI Too Long 을 end-to-end 로 처리한다"** — ❌. Tomcat/gateway 가 Spring dispatch 전에 거부하므로 registry row + code 만 있고 end-to-end 검증은 없다. - **"Idempotency 를 구현했다"** — ❌. `Idempotency-Key` header 이름 수용(server-tolerant)만. key shape/replay 는 rate-limit branch 소유. - **"OpenAPI drift 를 릴리스에서 차단한다"** — ❌. 이 branch 는 snapshot producer + registry mapping 정합 test 까지. release-blocking 집행은 verification-test-suite branch 소유. ## 관련 개념 - [[wiki/concepts/api-evolution-and-schema]] ## Sources - [[raw/project-notes/ca-skeleton-operational-contract]] §13 API Contract Surface / §16 Schema / Serialization Contract / §25 Default Decisions (API versioning) / §29 G-F (외부 근거 / 대안 조사 인덱스) - [[raw/branch-notes/feature-api-contract-baseline]] — versioning(`/v1`), pagination/sort, conditional request(ETag/If-Match/304/412 = D15), HTTP cache(`no-store`/`Vary`), OpenAPI producer, LRO, batch endpoint. Ground-truth @b15dcf5 로 대조해 `locally-verified` 확정. - [[raw/branch-notes/feature-api-compatibility-deprecation-contract]] — 90d/30d migration window, 7행 breaking change catalog, Sunset + Deprecation 헤더 병기, OpenAPI `deprecated: true` marker (documented-only) - [[raw/blog-topics/api-deprecation-sunset-header-migration-window-2026-07-02]] — compatibility/deprecation 블로그 글감 raw seed. canonical 반영 범위: documented-only project decision + source-backed/header-role 경계 + 과장 금지 항목. - [[raw/branch-notes/feature-schema-serialization-contract]] — ISO-8601 offset/UTC, BigDecimal scale 2 + HALF_UP, unknown field strict inbound, null/empty/missing 분리. 직렬화 출력측(datetime/BigDecimal 핀 + `no_bigdecimal_double_constructor` ArchUnit + `JacksonSerializationPolicyTest`)은 Ground-truth @5d89766 로 대조해 `locally-verified`; D5/D6/D7 + per-API money 코드 시연은 미구현. - [[raw/blog-topics/spring-boot-serialization-contract-pins-2026-07-02]] — Spring Boot serialization pin 블로그 글감 raw seed. canonical 반영 범위: output serialization pin + effective ObjectMapper test + BigDecimal constructor guard. - [[raw/official-docs/sunset-deprecation-headers-paired-usage]] — IETF RFC 8594 + Deprecation draft paired 사용 권고 (Sunset 단독 금지) - [[raw/official-docs/rfc9110-http-semantics]] — D15 conditional request(ETag/If-Match/If-None-Match/304/412), D8/D9/D12 transport error 의미론 - [[raw/official-docs/rfc9111-http-caching]] — D16 `no-store`/`private`/`max-age` directive 정의 - [[raw/official-docs/openapi-spec-3-1-0]] — D10 OpenAPI = machine-readable contract - [[raw/official-docs/google-aip-185-resource-versioning]] — D2 major-only `/v1` path versioning - [[raw/official-docs/spring-data-pageable-defaults]] — D18/D20 Pageable zero-indexed + size default + `DEFAULT_MAX_PAGE_SIZE` 2000 - [[raw/official-docs/schema-jackson-unknown-field-handling]] — Jackson DeserializationFeature default (직렬화/역직렬화 정책 근거) - [[raw/official-docs/schema-bigdecimal-money-serialization-java]] — Java BigDecimal scale/HALF_UP + `new BigDecimal(double)` 함정 (D3 / SBMS-C1~C4) - [[raw/official-docs/rfc3339-datetime-utc]] — IETF RFC 3339 datetime UTC + "Z" suffix (D2 datetime 직렬화 normative 근거) ## Cluster / 묶음 - [[wiki/blog/ca-tmpl-api-evolution-and-schema-2026-07-02]]