--- title: API Evolution은 버전 번호가 아니라 계약의 문제다 source_type: blog status: draft confidence: unknown tags: [blog, ca-tmpl, api-design, spring-boot, api-contract, semver] related_projects: [ca-tmpl] last_reviewed: canonical_sources: [] audience: backend-engineer target_publish: status_label: draft --- # API Evolution은 버전 번호가 아니라 계약의 문제다 > `/v1`을 어디에 붙일지 고민하기 전에, API가 실제로 무엇을 약속하고 있는지부터 정리한 글입니다. ## TL;DR - API versioning은 보통 `/v1` prefix 하나로 끝난다고 생각하기 쉽지만, 실제로는 pagination, 캐시 정책, conditional request, 직렬화 방식까지 전부 client와의 계약(contract surface)입니다. - ca-tmpl은 이 계약을 세 갈래로 나눴습니다: **① API contract baseline**(구현·검증 완료), **② compatibility/deprecation 정책**(아직 문서 계약), **③ schema/serialization**(출력측만 검증 완료). - 세 갈래의 **검증 수준이 다르다는 걸 숨기지 않는 것**이 이 글의 핵심입니다. "설계했다"와 "운영에서 검증했다"는 다른 문장입니다. --- ## 1. 버전 번호 하나로는 부족한 이유 API를 처음 설계할 때 가장 먼저 떠오르는 질문은 보통 이거죠. *"버전을 URL에 넣을까, 헤더로 받을까, 날짜 기반으로 갈까?"* 그런데 API가 한 번 배포되고 나면, 그 순간부터 client와의 **약속**이 시작됩니다. 응답 필드를 하나 빼는 일, enum 값을 줄이는 일, pagination 상한을 바꾸는 일, 날짜를 숫자에서 문자열로 바꾸는 일 — 이 모두가 client 입장에서는 "변화"입니다. 그래서 API evolution은 "버전을 어떻게 붙일까"보다 훨씬 넓은 문제입니다. 더 정확히 말하면, **API surface 전체가 언제 어떻게 변할 수 있고, 그 변화가 client를 언제 깨뜨리는지를 미리 정해두는 계약**입니다. ca-tmpl은 이 문제를 세 갈래로 나눠서 다룹니다. | 갈래 | 내용 | 검증 수준 | |---|---|---| | ① API contract baseline | `/v1`, pagination, ETag, 캐시 헤더, OpenAPI, LRO, batch endpoint | ✅ 코드 구현 + 로컬 테스트 검증 | | ② compatibility/deprecation | breaking change 기준, migration window, `Sunset`/`Deprecation` 헤더 | 📝 문서 계약 (구현 아직) | | ③ schema/serialization | 날짜·decimal 직렬화 형식 | ✅ 출력측만 검증 (입력측은 별도 트랙) | 이 표를 먼저 보여드리는 이유가 있습니다. 이 글에서 "구현됐다"와 "설계만 했다"를 섞어서 말하면, 읽기는 편해도 나중에 사실관계가 흐트러지거든요. 그래서 갈래별로 나눠서 설명하겠습니다. --- ## 2. ① 이미 구현되고 검증된 것들 ### `/v1` — 단순한 prefix가 아니라 명시적 결정 `/v1`을 붙이는 건 URL을 예쁘게 만드는 선택이 아니라, **API의 major version을 route surface에 드러내겠다는 결정**입니다. ca-tmpl은 설정값이 비어있거나 `/`로 시작하지 않으면 자동으로 보정합니다. ```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; } } } ``` `/v1/probe`는 열리고 `/probe`는 열리지 않는다는 것까지 테스트로 고정되어 있습니다. ### Pagination — 표준이 아니라 프로젝트가 선택한 제한 client가 `size=100000` 같은 값을 자유롭게 보낼 수 있으면 서버 리소스가 쉽게 압박받습니다. ca-tmpl은 기본 size를 20으로 두고, 1~100 사이만 허용합니다. ```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; } } ``` 여기서 **100과 10000이라는 숫자는 표준이 정한 값이 아니라는 점**이 중요합니다. DoS 방어와 cursor pagination 유도를 위한 프로젝트 고유의 선택이에요. "표준이라서 100"이 아니라 "ca-tmpl이 skeleton 기본값으로 고른 제한"이라고 말하는 게 정확합니다. ### Conditional Request — ETag로 "내가 아는 버전과 같을 때만" 처리하기 conditional request는 client가 "내가 알고 있는 이전 상태와 같을 때만 처리해줘" 또는 "내가 가진 버전과 같으면 body를 다시 안 보내도 돼"라고 말하는 HTTP 메커니즘입니다. ca-tmpl은 엔티티의 optimistic lock 버전에서 ETag를 만들어, 읽기에서는 `If-None-Match`로 304를, 쓰기에서는 `If-Match` 불일치로 412를 반환합니다. ```java public static String weakFromVersion(long version) { return "W/\"" + version + "\""; } ``` 다만 여기엔 명확한 경계가 있습니다. 이 ETag 비교는 RFC 9110이 정의하는 엄격한 strong comparison 구현이 아니라, `W/` 마커와 따옴표를 벗겨 값을 비교하는 **lenient한 구현**이에요. optimistic lock을 이해하기 쉽게 연결한 것이지, production급 strong ETag semantics를 전부 구현했다고 말할 수는 없습니다. ### 캐시 정책 — 기본은 닫고, 필요한 곳만 연다 인증된 API에서 캐시를 기본으로 열어두면 proxy나 브라우저 캐시가 민감한 응답을 붙잡을 수 있습니다. 그래서 모든 응답에 `no-store`를 먼저 박아둡니다. ```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); } ``` 캐시 가능한 endpoint가 필요하면 명시적으로 opt-in해야 하는 구조입니다. 기본값을 안전한 쪽으로 닫아두고, 예외를 여는 방식이에요. ### 그 외 — OpenAPI, 비동기 작업, 배치 - **OpenAPI**: `/v3/api-docs`가 정상적으로 열리는 producer 수준까지 구현 - **Long-running operation**: `POST /worklogs:export`가 202 Accepted + `Location` 헤더로 polling URL을 돌려주는 흐름이 샘플로 구현됨 - **Batch endpoint**: `POST /worklogs:batchCreate`에서 단일 트랜잭션 원자성과 size cap을 검증 여기까지가 **"코드로 구현했고 로컬 검증했다"**고 자신 있게 말할 수 있는 범위입니다. --- ## 3. ② 아직은 문서 계약인 것들 — Compatibility & Deprecation 여기서부터는 톤이 달라집니다. ca-tmpl은 breaking change의 기준을 정해뒀습니다: 응답 필드 제거, 응답 필드 의미 변화, 필수 요청 필드 추가, enum 값 제거·의미 변화·축소, 기본값 변경 — 이런 것들을 breaking change로 분류하고, **90일(public) / 30일(internal) migration window**를 두기로 결정했습니다. 또한 두 개의 헤더를 함께 보내기로 했습니다. - **`Sunset`**: 언제 사라질지 알려주는 날짜 신호 - **`Deprecation`**: 지금 이미 deprecated 상태인지 알려주는 신호 둘 중 하나만 보내면 정보가 반쪽이 됩니다. 그래서 항상 함께 보내기로 설계했습니다. 여기에 사람이 읽을 migration guide로 연결하는 `Link rel="deprecation"` / `Link rel="sunset"`도 문서에 잡혀 있습니다. **하지만 이건 아직 구현이 아닙니다.** response interceptor나 release gate로 코드에 내려온 상태가 아니고, 실제로 API를 deprecated 상태로 운영해본 적도, 외부 client가 90일 안에 migration을 끝냈는지 검증해본 적도 없습니다. 그래서 이 갈래는 정확히 이렇게만 말할 수 있습니다: *"설계했다", "문서 계약으로 정했다", "표준과 사례를 비교해서 이 정책을 택했다"*. "운영에서 검증했다"는 표현은 아직 쓸 수 없습니다. --- ## 4. ③ 출력측만 검증된 것들 — Schema & Serialization 이 갈래는 조금 다릅니다. 여기서는 **출력측 일부가 실제로 구현되고 검증됐습니다.** Jackson 설정에서 두 가지를 명시적으로 고정했습니다. ```java // WRITE_DATES_AS_TIMESTAMPS=false // → OffsetDateTime, LocalDate가 숫자·배열이 아니라 ISO-8601 문자열로 나감 // WRITE_BIGDECIMAL_AS_PLAIN=true // → 큰 BigDecimal이 scientific notation으로 나가지 않음 ``` 흥미로운 점은, 이 설정들이 현재 Spring Boot 기본값과 크게 다르지 않다는 것입니다. 그런데도 명시적으로 pin을 둔 이유는 **default에 기대면 나중에 default가 바뀌었을 때 알아채기 어렵기 때문**입니다. 그래서 설정 바인딩만 확인하는 게 아니라, 실제로 배선된 `ObjectMapper`로 직렬화까지 해보는 테스트를 둡니다. ```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"); ``` `JavaTimeModule`이 빠져서 날짜가 배열로 새는 회귀도 이 테스트가 잡아낼 수 있습니다. `BigDecimal`에는 정적 차단도 걸어뒀습니다. Java에서 `new BigDecimal(0.1)`은 사람이 기대하는 0.1이 아니라 binary floating-point 오차를 품은 값을 만들 수 있기 때문입니다. ```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); ``` 이건 "조심하자"는 약속이 아니라 **빌드가 깨지는 계약**입니다. 다만 여기도 전부 끝난 건 아닙니다. 입력측 strict deserialization, null/empty/missing 3-state 처리, API별 money 값을 문자열로 보낼지 숫자로 보낼지 선택하는 정책, OpenAPI drift release gate, Avro 호환성 자동 검사는 각각 별도 트랙이거나 아직 계획 단계입니다. 특히 샘플 도메인에 money 필드가 없어서, money string serialization 코드 예제도 이 글엔 없습니다. --- ## 5. 정리 — 넓게 보되, 등급을 섞지 않기 이 글에서 가장 중요하게 지키고 싶었던 건 하나입니다. **API evolution을 넓게 다루되, 구현 등급을 섞지 않는 것.** - `/v1`, pagination, ETag, 캐시 헤더, OpenAPI producer, LRO, batch endpoint → **로컬 검증된 구현**으로 말할 수 있습니다. - deprecation 정책과 migration window → **문서 계약**으로만 말해야 합니다. - serialization 출력 pin과 BigDecimal guard → **로컬 검증된 구현**으로 말할 수 있습니다. - strong ETag, idempotency replay, OpenAPI release gate, 실제 운영 deprecation 경험 → **아직 말할 수 없습니다.** 좋은 API 설계는 "예제 endpoint가 잘 동작한다"에서 끝나지 않습니다. 나중에 API가 바뀔 때 어떤 변화가 안전하고, 어떤 변화가 client를 깨뜨리며, 어떤 값은 default에 기대지 않고 명시적으로 고정해야 하는지까지 답할 수 있어야 합니다. ca-tmpl의 API Evolution & Schema 결정은 그 방향을 잡아둔 문서입니다. 일부는 이미 코드와 테스트로 내려왔고, 일부는 앞으로 구현해야 할 계약으로 남아 있습니다. 이 둘을 구분해서 말할 수 있을 때, 비로소 이 주제를 내 프로젝트 경험으로 이야기할 수 있습니다.