Files
llm-wiki/wiki/blog/ca-tmpl-api-evolution-and-schema-2026-07-02.md
T

22 KiB

title, source_type, status, confidence, tags, related_projects, last_reviewed, canonical_sources, audience, target_publish, status_label
title source_type status confidence tags related_projects last_reviewed canonical_sources audience target_publish status_label
API Evolution은 버전 번호가 아니라 계약의 문제다 blog verified high
blog
ca-tmpl
api-design
versioning
schema
ca-tmpl
2026-07-02
wiki/projects/ca-tmpl/api-evolution-and-schema
wiki/concepts/api-evolution-and-schema
backend-engineer ready

API Evolution은 버전 번호가 아니라 계약의 문제다

Layer: wiki/blog/외부 공개용 블로그 글 초안·완성본. canonical (wiki/concepts/ + wiki/projects/) 에서 파생된 산출물. 상태: draft → reviewed → verified → published-ready (외부 게시 가능) status_label: outline | drafting | review | ready | published | retired audience: backend-engineer | senior-engineer | tech-lead | general — 깊이·전문용어 사용량이 달라짐.

Parent / 부모 (필수)

wiki/blog/ 는 derived layer. 반드시 canonical wiki/concepts/ 또는 wiki/projects/ 에서 파생. raw 또는 branch에서 직접 파생 금지.

타깃 독자 / Target reader

이 글을 누가 읽을 것인지. 톤·전문용어·깊이가 결정됨.

  • 독자 profile: Spring Boot 기반 REST API를 만들면서 versioning, pagination, deprecation, serialization contract를 어디까지 정해야 하는지 고민하는 백엔드 엔지니어.
  • 독자가 이미 알고 있을 것이라 가정하는 것: HTTP status, REST endpoint, OpenAPI, Jackson, Spring MVC의 기본 역할.
  • 독자가 처음 듣는다고 가정하는 것: API evolution을 단순히 /v1 prefix가 아니라 migration window, compatibility catalog, conditional request, serialization pin까지 포함하는 계약으로 보는 관점.

도입 / Hook

왜 이 글을 쓰는가. 독자에게 이 글의 가치를 1~2문장으로.

  • 문제 / 궁금증: API versioning을 /v1만 붙이면 끝난다고 생각하기 쉽지만, 실제로는 pagination cap, ETag, cache header, deprecation signal, serialization default drift까지 모두 contract surface가 된다.
  • 이 글이 답하는 것: ca-tmpl이 API evolution을 어떤 하위 계약으로 쪼갰고, 그중 무엇은 코드로 구현·로컬 검증됐으며, 무엇은 아직 documented-only인지 구분한다.
  • 이 글이 답하지 않는 것 (스코프): 실제 외부 client migration 운영 경험, production cutover, 410 응답 전환 실측, Avro Schema Registry 운영 경험.

본문 outline / Body outline

글의 흐름. 초안 단계에서는 outline 만, drafting 단계부터 본문.

  1. API evolution은 /v1 prefix 하나가 아니다 — versioning, pagination, cache, conditional request, OpenAPI, batch/LRO, serialization policy까지 surface로 본다.
  2. ca-tmpl에서 실제 구현된 contract baseline — /v1, pagination/sort, ETag/If-Match/304/412, no-store/Vary, OpenAPI producer, LRO, batch endpoint.
  3. compatibility/deprecation은 아직 문서 계약이다 — 90d/30d window, 7행 breaking change catalog, Sunset + Deprecation, OpenAPI deprecated: true는 구현됐다고 말하지 않는다.
  4. serialization은 출력측만 로컬 검증됐다 — datetime/BigDecimal pin, effective ObjectMapper test, new BigDecimal(double/float) ArchUnit ban.
  5. 표준과 project-local trade-off를 분리하기 — RFC 8594, RFC 9110, RFC 3339, OpenAPI 같은 source-backed 사실과 90d/30d·size cap 100·weak ETag 같은 project decision을 구분한다.
  6. 블로그에서 과장하면 안 되는 경계 — 운영 deprecation 경험, strong ETag, idempotency replay, OpenAPI drift release gate, money string serialization은 아직 말하면 안 된다.

본문 / Body

API evolution을 처음 생각할 때 가장 먼저 떠오르는 것은 보통 version number입니다. /v1을 붙일지, header로 받을지, 날짜 기반으로 갈지 같은 질문입니다. 그런데 실제로 API가 오래 살아남으려면 version number만으로는 부족합니다.

API는 한 번 배포되면 client와 약속이 됩니다. 응답 field를 없애는 일, enum 값을 줄이는 일, pagination limit을 바꾸는 일, datetime을 숫자로 보내던 것을 문자열로 바꾸는 일도 모두 client에게는 변화입니다. 그래서 API evolution은 "버전을 어떻게 붙일까"보다 넓은 문제입니다. 더 정확히는 API surface 전체가 어떻게 변할 수 있고, 그 변화가 client를 언제 깨뜨리는지 정하는 계약입니다.

ca-tmpl의 API Evolution & Schema 문서는 이 문제를 세 갈래로 나눕니다. 첫 번째는 실제 HTTP API의 기본 계약입니다. /v1 prefix, pagination/sort, ETag와 conditional request, cache header, OpenAPI producer, long-running operation, batch endpoint 같은 것들입니다. 두 번째는 compatibility와 deprecation입니다. 어떤 변경을 breaking으로 볼지, deprecated API를 얼마나 오래 살릴지, SunsetDeprecation header를 어떻게 보낼지에 대한 정책입니다. 세 번째는 schema와 serialization입니다. 날짜와 decimal이 어떤 JSON 모양으로 나가야 하는지, Jackson default가 바뀌어도 계약이 흔들리지 않게 어떻게 고정할지에 대한 문제입니다.

중요한 점은 이 세 갈래의 검증 수준이 서로 다르다는 것입니다. ca-tmpl에서 API contract baseline은 상당 부분 코드로 구현되고 로컬 테스트로 검증됐습니다. 반면 compatibility/deprecation 정책은 아직 문서 계약입니다. serialization은 출력측 일부가 구현·검증됐지만, 모든 schema evolution 도구가 구현된 것은 아닙니다. 이 구분을 흐리면 블로그 글은 읽기 좋아져도 사실 경계가 무너집니다.

먼저 구현된 API contract baseline부터 보겠습니다. ca-tmpl은 public endpoint에 /v1 prefix를 사용합니다. 이것은 단지 URL을 예쁘게 만드는 선택이 아니라, API major version을 route surface에 드러내는 결정입니다. PresentationSettingsca-skeleton.presentation.api-base-path를 읽고, 값이 빠졌거나 /로 시작하지 않을 때 보정합니다. canonical 문서 기준으로 운영 default는 /v1이고, VersioningPrefixTest/v1/probe는 열리고 /probe는 열리지 않는다는 것을 검증합니다.

pagination도 계약입니다. client가 size=100000을 던질 수 있게 두면 서버 resource를 쉽게 압박할 수 있습니다. ca-tmpl의 PageParams는 기본 size를 20으로 두고, 1 이상 100 이하만 허용합니다. page는 0-indexed이며, deep offset은 page > 10000일 때 표시합니다. 여기서 숫자 100과 10000은 표준이 정한 값이 아닙니다. DoS 방어와 cursor pagination 유도라는 project-local trade-off입니다. 따라서 글에서는 "표준이라서 100"이라고 말하면 안 되고, "ca-tmpl이 skeleton 기본값으로 선택한 제한"이라고 말해야 합니다.

conditional request도 흥미로운 부분입니다. conditional request는 client가 "내가 알고 있는 이전 상태와 같을 때만 처리해 달라" 또는 "내가 가진 버전과 같으면 body를 다시 보내지 않아도 된다"고 말하는 HTTP 메커니즘입니다. ca-tmpl은 entity의 optimistic lock version에서 W/"<version>" 형태의 ETag를 만들고, read에서는 If-None-Match로 304를, write에서는 If-Match mismatch로 412를 냅니다. 이 흐름은 ETagsPreconditionFailedException, controller wire test로 검증됩니다.

다만 여기에도 경계가 있습니다. ca-tmpl의 ETag 비교는 RFC 9110의 strict한 strong comparison 구현이 아닙니다. ETags.matchesW/ marker와 따옴표를 벗겨 opaque value를 비교하는 lenient 구현입니다. skeleton에서 이해하기 쉬운 optimistic lock bridge를 택한 것이지, production-grade strong ETag semantics를 모두 구현했다고 말하면 안 됩니다.

cache policy는 더 보수적입니다. 인증된 API에서 cache default를 열어 두면 proxy나 browser cache가 민감한 응답을 붙잡을 수 있습니다. ca-tmpl의 CacheControlFilter는 모든 응답에 Cache-Control: no-storeVary: Accept, Accept-Encoding, Authorization을 먼저 박습니다. cacheable endpoint가 필요하면 명시적으로 opt-in해야 합니다. 기본값을 닫고 예외를 열게 만든 셈입니다.

OpenAPI producer, long-running operation, batch endpoint도 baseline에 들어갑니다. OpenAPI는 /v3/api-docs가 열리는지 확인하는 producer 수준까지 구현됐습니다. long-running operation은 POST /worklogs:export가 202 Accepted와 Location header, polling URL을 돌려주는 sample fixture로 구현됐습니다. batch endpoint는 POST /worklogs:batchCreate에서 단일 transaction atomic 처리와 size cap을 검증합니다. 여기까지는 "코드로 구현했고 로컬 검증했다"고 말할 수 있는 범위입니다.

반대로 compatibility/deprecation은 조심해야 합니다. ca-tmpl은 90일 public, 30일 internal migration window를 문서 계약으로 정했습니다. 응답 field 제거, 응답 field 의미 변화, required request field 추가, enum value 제거, enum value 의미 변화, narrow enum, 기본값 변경을 breaking change catalog로 분류했습니다. 또한 Sunset header와 Deprecation header를 함께 보내기로 결정했습니다.

하지만 이것들은 아직 response interceptor나 release gate로 구현된 것이 아닙니다. 실제 API를 deprecated 상태로 운영해 본 것도 아니고, 외부 client가 90일 안에 migration을 끝냈는지 검증한 경험도 없습니다. 따라서 이 부분은 "설계했다", "문서 계약으로 잡았다", "표준과 사례를 비교해 이런 정책을 택했다"까지만 말해야 합니다. "운영에서 검증했다"는 표현은 쓰면 안 됩니다.

SunsetDeprecation의 차이는 글에서 꼭 풀어야 합니다. Sunset은 언제 사라질지를 알려주는 날짜 신호입니다. Deprecation은 지금 이미 deprecated 상태인지를 알려주는 신호입니다. 하나만 보내면 정보가 반쪽이 됩니다. ca-tmpl은 그래서 둘을 함께 보내기로 결정했습니다. 여기에 Link rel="deprecation"이나 Link rel="sunset"을 붙여 사람이 읽을 migration guide로 연결하는 방향도 문서에 잡혀 있습니다. 다시 말하지만, 현재는 결정과 설계이지 구현은 아닙니다.

schema/serialization 축은 조금 다릅니다. 여기서는 출력측 일부가 실제로 구현됐습니다. ca-tmpl은 Jackson 설정에서 WRITE_DATES_AS_TIMESTAMPS=false를 명시해 OffsetDateTimeLocalDate가 숫자나 배열이 아니라 ISO-8601 문자열로 나가도록 고정합니다. WRITE_BIGDECIMAL_AS_PLAIN=true도 명시해 큰 BigDecimal이 scientific notation으로 나가지 않게 합니다.

흥미로운 점은 이 설정들이 현재 Spring Boot 기본값과 크게 어긋나지 않는다는 것입니다. 그런데도 ca-tmpl은 명시적으로 pin을 둡니다. 이유는 default에 기대면 future default drift를 잡기 어렵기 때문입니다. 그래서 JacksonSerializationPolicyTest는 설정 binding만 보는 것이 아니라 실제 wired ObjectMapperOffsetDateTime, LocalDate, BigDecimal을 직렬화해 봅니다. JavaTimeModule이 빠져서 날짜가 배열로 나가는 회귀도 이 테스트가 잡을 수 있습니다.

BigDecimal은 정적 차단까지 들어갑니다. Java에서 new BigDecimal(0.1)은 사람이 기대하는 0.1이 아니라 binary floating-point 오차를 품은 값을 만들 수 있습니다. ca-tmpl은 production code에서 new BigDecimal(double)new BigDecimal(float) 생성자를 호출하지 못하도록 ArchUnit rule을 둡니다. 이건 "조심하자"가 아니라 build에서 깨지는 계약입니다.

하지만 serialization도 모든 것이 끝난 것은 아닙니다. 입력측 strict deserialization, null/empty/missing 3-state 처리, per-API money string-vs-number 선택, OpenAPI drift release gate, 제거 field 재사용 방지 도구, Avro compatibility 자동검사는 각각 다른 owner나 planned 범위에 있습니다. 특히 sample domain에 money field가 없기 때문에 @JsonSerialize(ToStringSerializer) 같은 money string serialization 코드 시연은 없습니다.

이 글의 핵심은 API evolution을 넓게 보되, 구현 등급을 섞지 않는 데 있습니다. /v1, pagination, ETag, cache header, OpenAPI producer, LRO, batch endpoint는 로컬 검증된 구현으로 말할 수 있습니다. deprecation policy는 문서 계약으로 말해야 합니다. serialization output pin과 BigDecimal guard는 로컬 검증으로 말할 수 있습니다. strong ETag, idempotency replay, OpenAPI release gate, production deprecation 운영은 아직 말하면 안 됩니다.

좋은 skeleton은 단지 "예제 endpoint가 동작한다"에서 끝나지 않습니다. 나중에 API가 변할 때 어떤 변화가 안전하고, 어떤 변화가 client를 깨뜨리며, 어떤 값은 default에 기대지 않고 명시적으로 고정해야 하는지까지 알려 줘야 합니다. ca-tmpl의 API Evolution & Schema 결정은 그 방향을 잡은 문서입니다. 일부는 이미 코드와 테스트로 내려왔고, 일부는 앞으로 구현해야 할 계약으로 남아 있습니다. 이 둘을 구분해서 설명할 수 있을 때, 비로소 이 주제를 내 프로젝트 경험으로 말할 수 있습니다.

코드 예제 / Code samples (있다면)

가능한 실제 프로젝트 코드 인용. 가짜 예제 금지. 추출 시 출처 PR·커밋 명시.

// 출처: [[wiki/projects/ca-tmpl/api-evolution-and-schema]]
// 실제 파일: adapter-web/src/main/java/dev/caskeleton/adapter/web/settings/PresentationSettings.java
@ConfigurationProperties(prefix = "ca-skeleton.presentation")
public record PresentationSettings(String apiBasePath) {

  public PresentationSettings {
    if (apiBasePath == null) {
      apiBasePath = "";
    } else if (!apiBasePath.isEmpty() && !apiBasePath.startsWith("/")) {
      apiBasePath = "/" + apiBasePath;
    }
  }
}
// 출처: [[wiki/projects/ca-tmpl/api-evolution-and-schema]]
// 실제 파일: adapter-web/src/main/java/dev/caskeleton/adapter/web/pagination/PageParams.java
public record PageParams(int page, int size) {

  public static final int DEFAULT_SIZE = 20;
  public static final int MIN_SIZE = 1;
  public static final int MAX_SIZE = 100;
  public static final int DEEP_OFFSET_THRESHOLD = 10000;

  public boolean isDeepOffset() {
    return page > DEEP_OFFSET_THRESHOLD;
  }
}
// 출처: [[wiki/projects/ca-tmpl/api-evolution-and-schema]]
// 실제 파일: adapter-web/src/main/java/dev/caskeleton/adapter/web/conditional/ETags.java
public static String weakFromVersion(long version) {
  return "W/\"" + version + "\"";
}
// 출처: [[wiki/projects/ca-tmpl/api-evolution-and-schema]]
// 실제 파일: adapter-web/src/main/java/dev/caskeleton/adapter/web/filter/CacheControlFilter.java
@Override
protected void doFilterInternal(
    HttpServletRequest request, HttpServletResponse response, FilterChain chain)
    throws ServletException, IOException {
  response.setHeader(ApiHeaders.CACHE_CONTROL, "no-store");
  response.setHeader(ApiHeaders.VARY, "Accept, Accept-Encoding, Authorization");
  chain.doFilter(request, response);
}
// 출처: [[wiki/projects/ca-tmpl/api-evolution-and-schema]]
// 실제 파일: sample-portfolio/src/main/java/.../OperationsController.java
@PostMapping("/worklogs:export")
public ResponseEntity<Operation<WorkLogExportResult>> export() {
  Operation<WorkLogExportResult> accepted =
      operations.startExport(presentationSettings.apiBasePath(), 0L);
  return ResponseEntity.accepted().location(URI.create(accepted.statusUrl())).body(accepted);
}
// 출처: [[wiki/projects/ca-tmpl/api-evolution-and-schema]]
// 실제 파일: app-bootstrap/src/test/java/.../JacksonSerializationPolicyTest.java
String dateTimeJson = mapper.writeValueAsString(utc);
assertThat(dateTimeJson).isEqualTo("\"1985-04-12T23:20:50.52Z\"");

String scaledJson = mapper.writeValueAsString(new BigDecimal("1.10"));
assertThat(scaledJson).isEqualTo("1.10");
// 출처: [[wiki/projects/ca-tmpl/api-evolution-and-schema]]
// 실제 파일: app-bootstrap/src/test/java/.../CleanArchitectureTest.java
@ArchTest
static final ArchRule NO_BIGDECIMAL_DOUBLE_CONSTRUCTOR =
    noClasses()
        .that()
        .resideInAPackage("dev.caskeleton..")
        .should()
        .callConstructor(BigDecimal.class, double.class)
        .orShould()
        .callConstructor(BigDecimal.class, float.class);

Sources / 근거 (canonical 인용 필수, derived layer 의무)

모든 사실 주장은 canonical 또는 raw 인용으로 뒷받침. 자기 추론은 명시적으로 "내 해석" 으로 분리.

사실 vs 의견 / Fact vs opinion 구분

독자가 자신 있게 인용할 수 있도록.

  • 사실 (검증됨):
  • 내 해석·의견 (검증 안 된 추론):
    • API evolution을 versioning 하나가 아니라 "API surface 전체의 변화 관리"로 보면 skeleton 단계에서 정해야 할 계약이 더 선명해진다.
    • default 값을 그대로 믿는 것보다 명시 pin과 effective-bean test를 두는 편이 skeleton template에는 설명 가능하다.
  • 알지 못하는 것:
    • 실제 external client migration이 90d/30d window로 충분했는지 알 수 없다. 운영 배포가 없다.
    • compatibility/deprecation header를 실제 response interceptor로 구현하고 cutover까지 운영해 본 경험은 없다.
    • OpenAPI drift release gate, Avro compatibility, money string-vs-number per-API serialization은 아직 구현·검증 범위가 아니다.

답할 수 있는 범위 / Answer boundary

이 글을 읽은 사람에게 후속 질문을 받았을 때 자신 있게 답할 수 있는 범위.

  • 자신 있게 답할 수 있는 후속 질문:
    • ca-tmpl에서 API contract baseline을 어떤 항목으로 나눴는가?
    • /v1 path prefix와 ETag/If-Match/304/412를 어떤 테스트로 검증했는가?
    • SunsetDeprecation header는 어떤 차이가 있고 왜 함께 보내기로 했는가?
    • Jackson serialization pin과 BigDecimal constructor guard는 왜 두었는가?
  • "그건 다음 글에서 다루겠다" 라고 해야 하는 부분:
    • 실제 deprecation 운영과 client migration coordination.
    • release-blocking OpenAPI drift gate 구현.
    • idempotency replay semantics.
    • strong ETag 전환.
    • per-API money string serialization code sample.

게시 체크리스트 / Publish checklist

readypublished 로 올리기 전 확인.

  • 모든 사실 주장에 canonical 링크 있음
  • 사실 vs 의견 분리 명시됨
  • 금지 마케팅 표현 없음
  • 코드 예제 출처 명시
  • 타깃 독자 가정과 톤 일치
  • /lint 통과
  • 게시 URL 기록 (게시 후):