Skip to content

Split View: API 설계 완전 가이드: 되돌릴 수 없는 결정부터 정하기

|

API 설계 완전 가이드: 되돌릴 수 없는 결정부터 정하기

들어가며

API 설계에서 진짜 위험한 결정은 어려운 결정이 아니라 되돌릴 수 없는 결정입니다. 캐시 전략을 잘못 고르면 다음 스프린트에 고칩니다. 식별자 형식을 잘못 고르면 3년 뒤에도 고치지 못합니다. 둘 다 "설계"라는 한 단어로 묶여 있어서 대부분의 팀이 같은 시간을 씁니다.

이 블로그에는 이미 API 디자인 완전 가이드 — REST·OpenAPI·Versioning·Pagination·Idempotency·Webhook이 있고, 레이트 리밋 알고리즘 고르기처럼 게이트웨이 계층을 다룬 글도 있습니다. 그 글들은 넓은 목록입니다. 이 글은 같은 재료를 되돌림 비용이라는 단 하나의 축으로 다시 배열합니다. 넓이 대신 깊이입니다. 무엇을 오늘 확정해야 하고 무엇을 미뤄도 되는지, 그리고 확정할 때 스펙 원문이 실제로 뭐라고 말하는지를 봅니다.

근거는 RFC 9110(HTTP Semantics), RFC 6585(추가 상태 코드), RFC 9457(Problem Details), 그리고 마틴 파울러의 Parallel Change입니다.


1. 되돌릴 수 없는 것과 되돌릴 수 있는 것 — 판별 기준

1-1. 판별 질문 네 가지

되돌릴 수 있는지는 기술적 난이도가 아니라 클라이언트가 그 값과 맺는 관계로 정해집니다.

  1. 클라이언트가 이 값을 자기 저장소에 보관하는가?
  2. 클라이언트가 이 값으로 코드 분기를 하는가?
  3. 이 값이 다른 시스템의 키, 로그, 정산의 근거가 되는가?
  4. 이 값을 바꾸면 클라이언트의 코드 수정과 재배포가 필요한가?

하나라도 예라면 되돌릴 수 없는 결정으로 취급합니다.

1-2. 실제 목록

결정되돌림 비용이유
리소스 경계와 URL 구조매우 높음클라이언트 코드에 박힘. 리다이렉트로도 못 지움
식별자 형식과 의미매우 높음외부 시스템이 저장하고 인덱싱함
메서드 의미론매우 높음재시도·캐시·프록시 동작이 묶임
상태 코드와 오류 식별자높음클라이언트의 분기 조건
페이지네이션 계약높음커서 형식과 응답 봉투가 함께 굳음
시간·금액·열거형의 표현높음파싱 코드와 저장 스키마에 반영됨
인증 방식높음모든 클라이언트의 배포가 필요
레이트 리밋 수치중간낮추면 파괴적, 올리면 안전
응답 필드 추가낮음모르는 필드를 무시한다면 안전
내부 구현·저장소·성능낮음계약이 유지되면 자유

1-3. 문서에 없어도 계약이 되는 것들

문서에 적지 않은 동작도 클라이언트가 의존하기 시작하면 계약이 됩니다. 정렬 순서를 명시하지 않았는데 클라이언트가 "대체로 최신순"에 맞춰 UI를 만들었다면, 순서를 바꾸는 순간 장애로 보고됩니다. 대응은 명시하지 않는다는 사실을 명시하는 것뿐입니다.

  • "정렬 순서는 보장하지 않습니다. 순서가 필요하면 sort 파라미터를 쓰세요."
  • "알 수 없는 필드는 무시해야 합니다. 필드는 예고 없이 추가될 수 있습니다."
  • "이 열거형에는 새 값이 추가될 수 있습니다. 알 수 없는 값의 처리 규약은 아래를 따르세요."

이 세 문장을 v1 문서에 넣는 비용은 10분이고, 넣지 않으면 v2를 만들어야 합니다.

1-4. 그래서 정하는 순서

첫 릴리스 전에 확정할 것은 위 표의 상단 다섯 줄입니다. 나머지는 첫 사용자가 붙은 뒤에 정해도 늦지 않습니다. 반대 순서로 일하는 팀이 많습니다. 응답 필드 이름을 두 시간 논쟁하고 식별자 형식은 15분에 정합니다.


2. 리소스 경계와 식별자

2-1. 경계는 조직도가 아니라 클라이언트의 명사

리소스를 팀 경계대로 자르면 팀이 개편될 때마다 API가 흔들립니다. 기준은 클라이언트가 인지하는 명사입니다. 클라이언트가 "주문"이라는 하나의 개념으로 다루는 것을 서버 내부 구조 때문에 셋으로 쪼개면, 클라이언트는 셋을 합치는 코드를 쓰고 그 규칙이 사실상의 계약이 됩니다. 검증 질문은 셋입니다. 단독 조회할 이유가 있는가, 수명이 상위와 다른가, 별도의 권한 경계가 있는가. 셋 다 아니라면 상위 리소스의 필드입니다.

2-2. URL 구조가 굳는 지점

예시 — 경로 템플릿은 항상 코드 블록 안에 둡니다.

GET    /v1/orders/{orderId}
GET    /v1/orders/{orderId}/items
POST   /v1/orders/{orderId}/cancellations
GET    /v1/customers/{customerId}/orders?status=paid&limit=50

중첩은 두 단계까지가 실용적인 한계입니다. /v1/customers/:customerId/orders/:orderId/items/:itemId:itemId 가 전역 유일하다면 앞부분이 장식이고, 장식은 오타와 404를 만듭니다.

상태 전이 표현도 여기서 결정됩니다. 취소를 POST /v1/orders/:orderId/cancellations 처럼 하위 리소스 생성으로 볼 수도, 상태 필드의 부분 수정으로 볼 수도 있습니다. 전자는 이력이 리소스로 남고 멱등성 키를 붙이기 쉽고, 후자는 엔드포인트 수가 적습니다. 한 API 안에서 섞어 쓰는 것만 피하면 됩니다.

2-3. 식별자 세 가지 선택지

방식열거 가능성규모 노출정렬 가능인덱스 지역성
순차 정수높음노출됨좋음
무작위 UUID낮음없음아니오나쁨
시간 정렬 ID낮음부분 노출좋음

순차 정수를 노출하면 경쟁사가 하루 간격으로 두 번 호출해 일일 주문량을 추정할 수 있고, 공격자가 식별자를 순회하며 권한 검사를 시험할 수 있습니다.

2-4. 식별자에 의미를 넣지 않는다

ORD-2026-KR-000123 같은 식별자의 문제는 클라이언트가 이것을 파싱한다는 것입니다. 국가 코드가 세 자리가 되는 날 파싱 코드가 전부 깨집니다. 접두사를 굳이 쓴다면 ord_ 처럼 타입만 나타내는 고정 접두사로 제한합니다. 내부 식별자와 외부 식별자를 분리하는 선택지도 있습니다. 비용은 매핑 테이블 하나, 이득은 저장소 교체가 API에 새어 나가지 않는 것입니다.

2-5. 논쟁 지점 — REST와 RPC·GraphQL의 경계

업계 의견이 갈리는 자리입니다. 승자 대신 축을 봅시다.

  • 클라이언트의 다양성: 자사 웹앱 하나면 서버가 화면에 맞춰 응답을 만드는 쪽이 효율적이고, 많고 통제 불가능하면 자원 중심의 일반적 계약이 유리합니다.
  • 캐싱 요구: HTTP 캐시 인프라를 쓰려면 자원과 메서드 의미론이 필요합니다. 단일 엔드포인트에 POST로 질의를 보내는 방식은 이 층을 포기합니다.
  • 조직 경계: 계약을 넘겨받는 쪽이 외부 조직이면 자기 기술적이고 문서화가 쉬운 편이 유리합니다.
  • 질의 형태의 변동성과 운영 복잡도: 필드 조합이 계속 달라지면 질의 언어의 이득이 크지만, 비용 상한과 깊이 제한과 캐시 전략을 새로 설계해야 합니다.

중요한 것은 경계를 문서로 정하는 것입니다.


3. 메서드 의미론: safe와 idempotent

오해가 가장 많은 자리이므로 원문으로 갑니다. RFC 9110 §9.2.1은 safe 메서드를 "본질적으로 읽기 전용이며 서버 상태를 변경하지 않는다"고 정의하고 GET, HEAD, OPTIONS, TRACE를 safe로 분류합니다. §9.2.2는 idempotent를 "여러 번의 동일한 요청이 서버에 의도한 효과가 한 번의 요청과 같다"고 정의하고 GET, HEAD, PUT, DELETE, OPTIONS, TRACE를 idempotent로 분류합니다. POST는 멱등하지 않습니다.

메서드safeidempotent
GET
HEAD
OPTIONS
TRACE
PUT아니오
DELETE아니오
POST아니오아니오

3-1. 오해 1 — "PUT은 수정, POST는 생성"

스펙은 그렇게 말하지 않습니다. §9.3.4는 PUT을 "대상 리소스의 상태가 생성되거나 대체되도록" 요청하는 것으로 정의하므로 PUT은 생성도 합니다. §9.3.3의 POST는 "리소스 자신의 고유한 의미론에 따라 표현을 처리"하도록 요청하는 것입니다. 구분선은 대상 URI가 결과 리소스를 가리키느냐입니다. 클라이언트가 URI를 정하면 PUT, 서버가 정하면 POST입니다.

3-2. 오해 2 — "DELETE를 두 번 호출해 404가 나면 멱등이 아니다"

멱등의 정의는 서버에 대한 의도된 효과가 같다는 것이지 응답이 같다는 것이 아닙니다. 첫 호출에 204, 두 번째에 404가 나와도 "그 리소스가 없는 상태"라는 효과는 같으므로 멱등입니다. 404를 성공으로 볼지는 별도의 계약입니다.

3-3. 오해 3 — "멱등이면 재시도해도 안전하다"

멱등성은 요청이 서버에 도달했을 때의 성질입니다. 네트워크 타임아웃으로 응답을 못 받으면 요청이 도달했는지조차 모릅니다. PUT이라면 재시도가 안전하지만 POST라면 중복 생성이 발생하므로, POST에는 멱등성 키가 필요합니다.

예시 — 멱등성 키를 헤더로 받는 형태.

POST /v1/payments
Idempotency-Key: 5f2a9c1e-6f6c-4a54-9f2f-27ab19d1b3c4
Content-Type: application/json

{ "orderId": "ord_01J9X", "amount": 15000, "currency": "KRW" }

키의 보존 기간, 키가 같고 본문이 다를 때의 동작(대개 409), 동시에 같은 키가 들어왔을 때의 동작까지 적어야 계약이 완성됩니다. 자세한 내용은 멱등성과 재시도: 신뢰할 수 있는 API에서 다룹니다.

3-4. safe 메서드에 부수효과를 넣지 않는다

GET으로 상태를 바꾸면 프리페치, 프록시 캐시, 크롤러가 그 변경을 무작위로 유발합니다. 요점은 부수효과의 금지가 아니라 클라이언트가 그 효과에 책임을 지지 않는다는 것입니다. 조회 카운터 증가는 괜찮고, 결제 승인은 안 됩니다.


4. 상태 코드는 계약이다

상태 코드는 클라이언트가 분기하는 값이므로 되돌릴 수 없습니다. RFC 9110의 정의로 자주 헷갈리는 것만 정리합니다.

코드RFC 9110의 정의
400클라이언트 오류로 인해 서버가 처리할 수 없거나 처리하지 않음
401"대상 리소스에 대한 유효한 인증 자격 증명이 요청에 없음"
403"서버가 요청을 이해했지만 이행을 거부함"
404대상 리소스의 현재 표현을 찾지 못함
409"요청이 대상 리소스의 현재 상태와 충돌함"
422콘텐츠 타입과 문법은 이해했지만 지시를 처리할 수 없음
500 / 503서버 내부 오류 / 일시적 과부하 또는 유지보수

4-1. 401과 403의 경계

RFC 9110은 §15.5.2에서 401을, §15.5.4에서 403을 정의합니다. 401은 인증, 403은 인가입니다. 실무에서 자주 나오는 세 번째 선택지는 존재를 숨기기 위해 403 대신 404를 주는 것입니다. 이것도 계약이므로 문서에 적습니다. 적지 않으면 클라이언트가 404를 "삭제됨"으로 해석해 캐시를 지웁니다.

4-2. 400과 422의 경계

스펙 기준으로는 문법이 깨졌으면 400, 문법은 맞는데 값의 의미가 틀렸으면 422입니다. 어느 쪽이든 하나만 고르고 일관되게 씁니다. 섞어 쓰면 클라이언트가 결국 둘 다 같은 분기로 처리하게 됩니다.

4-3. 429는 RFC 9110에 없다

429 Too Many Requests는 RFC 6585에 정의되어 있습니다. 원문은 "사용자가 주어진 시간 동안 너무 많은 요청을 보냈다(레이트 리미팅)"는 뜻이라고 하고, 응답에 "얼마나 기다려야 하는지를 나타내는 Retry-After 헤더를 포함할 수 있다(MAY)"고 씁니다. MAY이므로 클라이언트는 이 헤더가 없을 수도 있다고 가정해야 합니다. 같은 RFC의 428 Precondition Required는 "원 서버가 요청이 조건부이기를 요구한다"는 코드로, 갱신 손실(lost update) 문제를 막는 용도입니다.

4-4. 안티패턴 — 200에 오류를 담기

예시 — 이렇게 하면 상태 코드가 계약에서 빠집니다.

HTTP/1.1 200 OK
{ "success": false, "errorCode": "INSUFFICIENT_BALANCE", "message": "잔액 부족" }

이 응답은 프록시·게이트웨이·모니터링·재시도 미들웨어에게 전부 "성공"으로 보입니다. 오류율 대시보드는 0%를 가리키고 자동 재시도는 동작하지 않으며 서킷 브레이커도 열리지 않습니다.


5. 오류 응답 형식 (RFC 9457)

RFC 9457은 RFC 7807을 대체하는 문서로, HTTP API의 오류 표현을 application/problem+jsonapplication/problem+xml 미디어 타입으로 정의합니다. 특별한 이유가 없으면 형식을 직접 만들지 말고 이것을 씁니다. §3.1이 정의하는 멤버는 다섯입니다.

  • type: URI 참조이며 문제 유형의 주 식별자입니다. 없으면 기본값은 about:blank입니다.
  • status: 조언용(advisory)이며 실제 HTTP 상태 코드와 일치해야 합니다.
  • title: 사람이 읽는 요약. 지역화를 빼면 "발생 건마다 바뀌어서는 안 된다(SHOULD NOT)"고 스펙이 말합니다.
  • detail: 이번 발생에 대한 설명. "디버깅 정보를 주기보다 클라이언트가 문제를 바로잡도록 돕는 데 초점을 맞춰야" 합니다.
  • instance: 이번 발생을 식별하는 URI.

§3.2는 확장 멤버를 허용하며, 클라이언트는 "인식하지 못하는 확장을 반드시 무시해야 한다(MUST)"고 규정합니다. 그래서 오류 응답에 필드를 추가하는 것은 파괴적 변경이 아닙니다.

예시 — 필드 단위 검증 오류를 확장 멤버로 실어 보내는 형태.

HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json

{
  "type": "https://api.example.com/problems/validation-failed",
  "title": "Validation failed",
  "status": 422,
  "detail": "amount must be greater than 0",
  "instance": "/v1/payments/req_01J9XQ",
  "errors": [
    { "field": "amount", "code": "min_value", "min": 1 }
  ]
}

5-1. type URI를 설계하는 법

type은 클라이언트가 분기하는 값이므로 되돌릴 수 없습니다. 규칙 셋이면 충분합니다. 안정적일 것(도메인이 바뀌어도 값은 그대로), 역참조 가능하면 좋을 것(필수는 아님), 세분도를 처리 방식에 맞출 것(다르게 처리할 이유가 없는 두 오류에 다른 type을 주면 클라이언트가 둘을 합치는 코드를 씁니다).

5-2. 사용자에게 보일 문장과 클라이언트가 읽을 값을 분리한다

titledetail은 사람용이고 type과 확장 멤버는 코드용입니다. 이 분리를 지키지 않으면 클라이언트가 detail 문자열을 정규식으로 매칭하기 시작하고, 그 순간 오류 메시지가 계약이 되어 문구 하나 고치는 데 클라이언트 배포가 필요해집니다.

§5는 보안도 다룹니다. 문제 상세에 담기는 정보는 "신중하게 검토되어야" 하며, "스택 덤프 같은 구현 세부 정보"를 노출하지 말라고 명시합니다.


6. 페이지네이션: offset과 cursor

6-1. 두 방식의 실제 차이

항목offset 기반cursor 기반
임의 페이지 점프가능불가능
전체 개수 표시쉬움비쌈 또는 근사치
깊은 페이지 비용깊이에 비례해 증가깊이와 무관
삽입·삭제 중 일관성중복·누락 발생안정적
계약이 굳는 지점페이지 번호커서 문자열

offset의 문제는 성능만이 아닙니다. 1페이지를 보는 동안 새 항목이 앞에 삽입되면 2페이지에서 같은 항목을 다시 보게 되고, 삭제되면 항목 하나가 통째로 건너뛰어집니다. 목록이 자주 바뀌는 API에서 이것은 버그 리포트로 돌아옵니다.

6-2. 커서는 불투명해야 한다

예시 — 응답 봉투와 불투명 커서.

{
  "data": [
    { "id": "ord_01J9XQ", "createdAt": "2026-08-15T09:30:00Z" }
  ],
  "nextCursor": "eyJjIjoiMjAyNi0wOC0xNVQwOTozMDowMFoiLCJpIjoib3JkXzAxSjlYUSJ9",
  "hasMore": true
}

커서 내부 구조를 문서에 적으면 클라이언트가 디코드해서 조작하고, 그러면 구조를 바꿀 수 없습니다. 커서는 서버가 준 값을 그대로 돌려주는 토큰이라고만 규정하면, 나중에 정렬 키를 바꾸거나 서명을 추가해도 깨지지 않습니다.

6-3. 정렬 키가 유일하지 않으면 커서가 깨진다

createdAt 만으로 커서를 만들면 같은 밀리초의 항목에서 중복이나 누락이 발생합니다. 정렬 키에는 항상 유일한 보조 키를 덧붙입니다. 위 예시의 커서가 시각과 식별자를 함께 담은 이유입니다.

6-4. 되돌릴 수 없는 것은 응답 봉투

data / nextCursor / hasMore 라는 봉투 구조는 나중에 못 바꿉니다. 배열을 최상위로 반환했다가 메타데이터가 필요해지는 순간 응답 형태 전체를 바꿔야 하고, 그것은 파괴적 변경입니다.


7. 시간·돈·열거형의 표현

셋 다 파싱 코드와 저장 스키마에 그대로 새겨지므로 되돌릴 수 없습니다.

예시 — 이 절의 권장 표현.

{
  "createdAt": "2026-08-15T09:30:00Z",
  "scheduledAt": "2026-09-01T14:00:00+09:00",
  "scheduleTimeZone": "Asia/Seoul",
  "amount": 15000,
  "currency": "KRW",
  "status": "partially_refunded",
  "canceledAt": null,
  "externalId": "9007199254740993"
}

7-1. 시간

  • 문자열로 보냅니다. ISO 8601 확장 형식(예: 2026-08-15T09:30:00Z)이 안전합니다. 숫자 epoch은 초와 밀리초를 구분할 수 없어 잘못 파싱하면 1970년이나 5만 년 후가 됩니다.
  • 오프셋을 반드시 포함합니다. 오프셋 없는 문자열은 서버 시간대를 알아야 해석되고, 그 시간대는 인프라 사정으로 바뀝니다.
  • 미래의 약속에는 시간대 이름이 필요합니다. 오프셋은 그 시점의 규칙을 고정하지만 서머타임과 국가 정책은 바뀝니다. "9월 1일 오후 2시 서울"은 시간대 이름으로 저장해야 합니다.

7-2. 돈

  • 정수 최소 단위와 통화 코드를 함께 보냅니다. 부동소수는 쓰지 않습니다. 0.1 + 0.2가 0.3이 아닌 문제를 정산에서 발견하면 원인 추적에 며칠이 걸립니다.
  • 소수 자릿수는 통화마다 다릅니다. 원은 0자리, 달러는 2자리, 일부는 3자리입니다. "센트 단위 정수"는 통화 코드 없이 성립하지 않으므로 ISO 4217 코드를 함께 보냅니다.
  • 환율이 개입하면 환산 시점과 환율값도 넣습니다. 나중에 넣으면 과거 데이터에 값이 없어 이중 로직이 생깁니다.

7-3. 열거형

새 열거값 추가가 파괴적인지는 클라이언트의 처리 규약에 달려 있습니다. 모든 값을 빠짐없이 분기하는 코드를 썼다면 파괴적이고, 알 수 없는 값의 기본 동작이 정의되어 있다면 아닙니다. 그래서 v1 문서에 이 문장을 넣습니다.

  • "이 필드에는 새 값이 추가될 수 있습니다. 알 수 없는 값은 unknown 으로 취급하고, 원본 문자열을 보존해 재전송하세요."

값 이름은 소문자와 밑줄로 고정합니다. 대소문자 혼용은 언어마다 다르게 정규화되어 비교 버그를 만듭니다.

7-4. null과 필드 부재와 빈 값

세 상태를 구분할지 지금 정합니다. "취소되지 않음"을 canceledAt: null 로 볼지 필드 부재로 볼지에 따라 부분 갱신 로직이 달라지고, 부분 수정 API가 있다면 "지워라"와 "건드리지 마라"를 구분할 방법이 필요합니다.

7-5. 큰 정수는 문자열로

JSON의 숫자는 여러 언어에서 배정밀도 부동소수로 파싱됩니다. 안전한 정수 범위를 넘는 식별자를 숫자로 보내면 마지막 자릿수가 조용히 바뀝니다. 위 예시의 externalId 를 문자열로 둔 이유입니다.


8. 변경과 버저닝 — 확장·이행·축소

8-1. Parallel Change

마틴 파울러가 정리한 Parallel Change는 인터페이스 변경을 셋으로 나눕니다. 확장 단계에서는 "인터페이스를 확장해 이전 버전과 새 버전을 모두 지원"하고, 이행 단계에서는 "이전 버전을 쓰는 모든 클라이언트를 새 버전으로 갱신"하며 이 작업은 "점진적으로 수행할 수 있"습니다. 그리고 "모든 사용처가 새 버전으로 이행되면 축소 단계를 수행해 이전 버전을 제거"합니다. 파울러는 이 패턴의 출처를 조슈아 케리에프스키로 밝힙니다.

확장(expand)   신·구 둘 다 지원  ── 서버 배포만으로 가능
이행(migrate)  클라이언트를 하나씩 이동 ── 사용량 측정이 필수
축소(contract) 구 버전 제거      ── 사용량 0을 확인한 뒤에만

핵심은 이행 단계의 길이를 서버가 통제하지 못한다는 사실을 인정하는 것입니다. 축소 시점은 달력이 아니라 사용량이 정합니다. 그래서 확장 단계에 반드시 함께 넣어야 하는 것이 구 버전 사용량 계측입니다.

8-2. 무엇이 파괴적 변경인가

파괴적인 것: 응답 필드 제거·이름 변경·타입 변경, 요청에 필수 필드 추가, 상태 코드나 오류 식별자 변경, 기본값과 기본 정렬 변경, 레이트 리밋 하향, 그리고 클라이언트가 모든 값을 분기할 때에 한해 열거형에 새 값 추가.

파괴적이지 않은 것: 응답과 요청에 선택 필드 추가(문서에 무시 규약이 있을 때), 새 엔드포인트 추가, 오류 응답에 확장 멤버 추가, 성능 개선.

경계에 있는 것들이 사고를 냅니다. "선택 필드 추가"가 안전한 것은 클라이언트가 알 수 없는 필드를 무시할 때뿐이고, 엄격한 역직렬화를 쓰는 쪽에는 필드 추가도 파괴적입니다. 그래서 1-3절의 세 문장이 필요합니다.

8-3. 논쟁 지점 — 경로 버저닝과 헤더·미디어 타입 버저닝

승자가 없는 논쟁입니다. 축만 정리합니다.

  • 가시성과 디버깅: 경로에 버전이 있으면 로그와 주소창에서 바로 보이고, 헤더 방식은 요청을 뜯어봐야 압니다.
  • 라우팅과 캐싱: 경로 방식은 라우팅과 캐시 키 분리가 자명합니다. 헤더 방식은 Vary 처리를 정확히 해야 하고, 중간 캐시가 이를 잘못 다루면 교차 오염이 생깁니다.
  • 세분도와 버전 폭발: 경로 방식은 API 전체를 한 번에 올려 버전이 크게 뜁니다. 미디어 타입 방식은 리소스 단위로 나눌 수 있지만 유지 대상이 늘어납니다.
  • 클라이언트 편의: 브라우저에서 URL만으로 호출하는 소비자가 많다면 경로 방식이 쉽습니다.

어느 쪽이든 버전을 올리지 않고 해결할 수 있는지를 먼저 확인하는 편이 실익이 큽니다. 버전 번호가 빨리 올라가는 API는 대개 확장 단계를 건너뛰고 있습니다.

8-4. 폐기 절차

  • 폐기를 응답 헤더로 알립니다. 문서에만 적으면 아무도 읽지 않습니다. 예정 시점과 대체 경로를 함께 알립니다.
  • 사용량을 클라이언트 단위로 계측합니다. 총량만 보면 누구에게 연락해야 하는지 알 수 없습니다.
  • 축소 전에 짧은 차단 리허설을 합니다. 구 버전을 잠시 410으로 응답한 뒤 되돌리면 남은 소비자가 드러납니다.

단계적 대체의 큰 그림은 Strangler Fig 패턴 완벽 가이드에서 다룹니다.


9. 계약을 검증하는 법

문서에 적은 계약과 실제로 나가는 응답이 다르면, 계약은 문서가 아니라 응답 쪽입니다.

9-1. 스키마를 소스로 삼는다

OpenAPI 스키마를 코드에서 생성할지 그 반대일지는 팀마다 다릅니다. 중요한 것은 둘 중 하나가 단일 소스여야 한다는 것입니다. 양쪽을 손으로 관리하면 반드시 어긋나고, 어긋난 사실은 클라이언트가 먼저 발견합니다.

9-2. 파괴적 변경을 CI에서 막는다

예시 — 스키마 diff를 게이트로 거는 흐름.

PR 열림
  └─ 이전 커밋의 스키마와 현재 스키마를 비교
       ├─ 필드 제거 / 타입 변경 / 필수 추가  → 실패, 리뷰어 승인 필요
       ├─ 선택 필드 추가 / 새 엔드포인트     → 통과
       └─ 오류 type URI 변경                 → 실패

이 게이트의 값어치는 막는 것보다 드러내는 것에 있습니다. 파괴적 변경이 필요할 때는 사람이 승인하면 되고, 문제는 파괴적인 줄 모르고 병합되는 경우입니다.

9-3. 소비자 주도 계약 테스트

소비자가 "나는 이 필드를 이렇게 쓴다"는 기대를 계약으로 등록하고 제공자 파이프라인이 그 기대를 검증합니다. 진짜 이득은 테스트가 아니라 누가 무엇에 의존하는지가 목록으로 남는 것이며, 그 목록이 8-4절 폐기 절차의 연락처가 됩니다.

9-4. 문서의 예제를 스키마로 검증한다

손으로 적은 예제 응답은 가장 빨리 낡습니다. 예제를 스키마 검증 대상에 넣으면 문서가 자동으로 최신 상태를 유지합니다.

9-5. 실트래픽 회귀

운영 트래픽 표본을 신·구 버전에 동시에 흘려 응답 차이를 비교합니다. 값의 분포 변화, 정렬 순서 변화, 빈 배열과 null의 뒤바뀜처럼 스키마 검증이 잡지 못하는 것을 잡습니다. 응답을 손으로 만들어 확인할 때는 HTTP Request BuilderHTTP Status Codes가 도움이 됩니다.


퀴즈: 실력을 확인해 보세요

퀴즈 1: 목록 API가 최상위에 배열을 반환하고 있습니다. 여기에 전체 개수를 추가해 달라는 요청이 왔습니다. 무엇이 문제인가요?

정답: 최상위 배열에는 메타데이터를 붙일 자리가 없어서, 응답 전체 형태를 객체로 바꿔야 하고 이는 파괴적 변경입니다.

설명: 목록 응답의 봉투 구조는 되돌릴 수 없는 결정입니다. 처음부터 객체로 감싸 두면 나중에 필드를 추가하는 것은 선택 필드 추가에 불과해 안전합니다. 이미 배열로 나갔다면 확장·이행·축소를 밟아야 합니다.

퀴즈 2: DELETE를 두 번 호출했더니 첫 번째는 204, 두 번째는 404가 나왔습니다. 이 API는 멱등성을 어긴 것일까요?

정답: 아니요. RFC 9110의 멱등 정의는 서버에 의도한 효과가 같다는 것이지 응답이 같다는 것이 아닙니다.

설명: §9.2.2는 멱등성을 "여러 번의 동일한 요청이 서버에 의도한 효과가 한 번의 요청과 같다"고 정의합니다. 두 번 호출한 뒤의 상태는 "그 리소스가 없음"으로 같으므로 멱등합니다. 다만 재시도 로직이 404를 성공으로 볼지는 별도의 계약이며, 문서에 적지 않으면 재시도 중 발생한 404가 오류로 집계되어 대시보드가 왜곡됩니다.

퀴즈 3: 결제 API가 잔액 부족일 때 HTTP 200에 오류 본문을 담아 돌려주고 있습니다. 애플리케이션은 잘 동작하는데, 무엇이 망가지고 있나요?

정답: 경로상의 모든 중간 계층이 이 응답을 성공으로 읽습니다. 오류율 지표, 자동 재시도, 서킷 브레이커, 게이트웨이 정책이 전부 무력화됩니다.

설명: 상태 코드는 애플리케이션만 읽는 값이 아니라 프록시, 로드 밸런서, 관측 파이프라인, 클라이언트 라이브러리가 모두 이 값으로 동작합니다. 잔액 부족은 클라이언트가 고칠 수 있는 요청 오류이므로 4xx가 맞고, 본문은 RFC 9457의 problem details로 담아 오류 유형을 type URI로 식별하게 합니다.

퀴즈 4: 응답의 status 열거형에 새 값 하나를 추가하려고 합니다. 파괴적 변경인지 어떻게 판단하나요?

정답: 클라이언트가 알 수 없는 값을 만났을 때의 처리 규약이 문서에 있는지로 판단합니다. 없으면 파괴적입니다.

설명: 열거값 추가 자체는 중립적이고 파괴성은 소비자 쪽 규약이 결정합니다. 모든 값을 빠짐없이 분기하는 클라이언트에게는 새 값이 곧 런타임 오류입니다. v1 문서에 처리 규약을 미리 적는 편이 v2를 만드는 것보다 압도적으로 쌉니다.

퀴즈 5: 구 버전 엔드포인트를 제거하려는데 사용량이 0인지 확신이 없습니다. 무엇을 먼저 해야 할까요?

정답: 클라이언트 단위 사용량 계측을 먼저 붙이고, 그다음 짧은 차단 리허설을 합니다.

설명: Parallel Change의 축소 단계는 "모든 사용처가 새 버전으로 이행된 뒤"에만 수행합니다. 총량만 보면 남은 소비자가 누구인지 모르므로 연락할 수 없고, 계측 없이 제거하면 장애로 발견하게 됩니다.


마치며

API 설계에서 시간을 쓸 곳은 어려운 문제가 아니라 되돌리기 어려운 문제입니다. 두 개념은 자주 어긋납니다. 캐시 무효화는 어렵지만 되돌릴 수 있고, 식별자 형식은 쉽지만 되돌릴 수 없습니다.

첫 릴리스 전에 확정할 다섯 가지는 리소스 경계, 식별자, 메서드 의미론, 상태 코드 체계, 오류 형식입니다. 여기에 1-3절의 세 문장을 넣어 두면 나중에 필요한 변경의 상당수가 파괴적 변경에서 확장으로 내려갑니다. 바꿀 수 있는 것에 완벽을 요구하느라 바꿀 수 없는 것을 대충 정하는 일만 피하면, API 설계의 절반은 끝납니다.


참고 자료

  • RFC 9110 — HTTP Semantics — §9.2.1의 safe 정의와 대상 메서드, §9.2.2의 idempotent 정의와 POST가 멱등이 아니라는 점, §9.3.3 POST와 §9.3.4 PUT의 정의, 400·401·403·404·409·422·500·503의 정의, §15.5.2와 §15.5.4에 따른 401(인증)과 403(인가)의 구분을 인용했습니다. 2026-08-15 확인.
  • RFC 6585 — Additional HTTP Status Codes — 429의 정의와 Retry-After 헤더가 MAY라는 점, 428 Precondition Required가 갱신 손실 문제를 막는 용도라는 점, 429가 RFC 9110이 아니라 이 문서에 있다는 사실을 인용했습니다. 2026-08-15 확인.
  • RFC 9457 — Problem Details for HTTP APIs — RFC 7807을 대체한다는 점, application/problem+json 미디어 타입, §3.1의 다섯 멤버 정의, §3.2의 확장 멤버와 클라이언트의 무시 의무, §5의 스택 덤프 노출 금지를 인용했습니다. 2026-08-15 확인.
  • Parallel Change — Martin Fowler — 확장·이행·축소 세 단계의 원문 설명, 이행이 점진적으로 수행 가능하다는 점, 이 패턴이 조슈아 케리에프스키의 것이라는 출처를 인용했습니다. 2026-08-15 확인.
  • 되돌림 비용 판별 질문 네 가지, 결정별 되돌림 비용 표, 식별자 세 방식 비교표, 첫 문서에 넣을 세 문장, 폐기 전 차단 리허설, 스키마 diff 게이트의 분류는 위 자료에 그대로 나오는 것이 아니라 이 글에서 정리한 절차입니다.

이어서 읽기

완전 가이드 시리즈

The Complete Guide to API Design: Settle the Irreversible Decisions First

Introduction

The genuinely dangerous decisions in API design are not the hard ones but the irreversible ones. Pick the wrong caching strategy and you fix it next sprint. Pick the wrong identifier format and you still cannot fix it three years later. Both live under the single word "design," so most teams give them the same amount of time.

This blog already has The Complete Guide to API Design — REST, OpenAPI, Versioning, Pagination, Idempotency, Webhooks, plus gateway-layer posts such as Choosing a Rate Limiting Algorithm. Those are broad catalogues. This post rearranges the same material along one axis: cost of reversal. Depth instead of breadth. What has to be settled today, what can wait, and what the specs actually say when you settle it.

The sources are RFC 9110 (HTTP Semantics), RFC 6585 (additional status codes), RFC 9457 (Problem Details), and Martin Fowler's Parallel Change.


1. Reversible vs Irreversible — the Screening Criteria

1-1. Four Screening Questions

Whether a decision is reversible is decided not by technical difficulty but by the relationship the client forms with that value.

  1. Does the client store this value in its own storage?
  2. Does the client branch on this value in code?
  3. Does this value become a key in another system, a log field, or the basis for settlement?
  4. Does changing it require the client to modify and redeploy code?

One yes is enough to treat it as irreversible.

1-2. The Actual List

DecisionCost of reversalWhy
Resource boundaries and URL structureVery highEmbedded in client code. Redirects never fully erase it
Identifier format and meaningVery highExternal systems store and index it
Method semanticsVery highRetry, cache, and proxy behavior all hang off it
Status codes and error identifiersHighThe client's branch conditions
Pagination contractHighCursor format and response envelope harden together
Representation of time, money, enumsHighBaked into parsing code and storage schemas
Authentication schemeHighRequires every client to deploy
Rate limit numbersMediumLowering is breaking, raising is safe
Adding response fieldsLowSafe if unknown fields are ignored
Internals, storage, performanceLowFree as long as the contract holds

1-3. Things That Become Contracts Without Being Documented

Behavior you never wrote down still becomes a contract once clients depend on it. If you never specified sort order and a client built a UI around "roughly newest first," changing the order gets reported as an outage. The only defense is to explicitly state what you are not specifying.

  • "Sort order is not guaranteed. Use the sort parameter if you need one."
  • "Unknown fields must be ignored. Fields may be added without notice."
  • "New values may be added to this enum. Follow the rule below for unknown values."

Putting those three sentences in the v1 documentation costs ten minutes. Leaving them out costs you a v2.

1-4. So, the Order to Decide In

What must be settled before the first release is the top five rows of the table above. The rest can wait until you have your first users. Plenty of teams work in the opposite order: two hours arguing about response field names, fifteen minutes on the identifier format.


2. Resource Boundaries and Identifiers

2-1. Boundaries Follow the Client's Nouns, Not the Org Chart

Cut resources along team boundaries and the API wobbles every time the org is reshuffled. The criterion is the noun the client perceives. If something the client treats as a single "order" is split into three because of internal server structure, the client writes code to reassemble them and that reassembly rule becomes the de facto contract. Three verification questions: is there a reason to fetch it alone, does its lifetime differ from its parent, does it have a separate authorization boundary. If all three are no, it is a field on the parent resource.

2-2. Where the URL Structure Hardens

Example — path templates always live inside a code block.

GET    /v1/orders/{orderId}
GET    /v1/orders/{orderId}/items
POST   /v1/orders/{orderId}/cancellations
GET    /v1/customers/{customerId}/orders?status=paid&limit=50

Two levels of nesting is the practical limit. In /v1/customers/:customerId/orders/:orderId/items/:itemId, if :itemId is globally unique then the prefix is decoration, and decoration produces typos and 404s.

How you express state transitions is decided here too. A cancellation can be a subresource creation like POST /v1/orders/:orderId/cancellations, or a partial update of a status field. The former leaves history as a resource and makes idempotency keys easy; the latter keeps the endpoint count low. Just avoid mixing both inside one API.

2-3. Three Identifier Options

SchemeEnumerableLeaks scaleSortableIndex locality
Sequential integerHighYesYesGood
Random UUIDLowNoNoPoor
Time-sorted IDLowPartiallyYesGood

Expose sequential integers and a competitor can call twice a day apart to estimate your daily order volume, while an attacker can walk the identifier space probing your authorization checks.

2-4. Do Not Encode Meaning Into Identifiers

The problem with an identifier like ORD-2026-KR-000123 is that clients parse it. The day a country code becomes three characters, every parser breaks. If you insist on a prefix, restrict it to a fixed one that indicates type only, such as ord_. Separating internal from external identifiers is another option: the cost is one mapping table, the benefit is that a storage swap never leaks into the API.

2-5. A Contested Point — Where REST Ends and RPC or GraphQL Begins

The industry genuinely disagrees here. Instead of a winner, look at the axes.

  • Client diversity: with a single first-party web app, having the server shape responses to the screen is efficient; with many uncontrolled clients, a general resource-oriented contract wins.
  • Caching needs: using HTTP cache infrastructure requires resources and method semantics. POSTing queries to a single endpoint gives up that layer.
  • Organizational boundaries: when the consumer is an outside organization, the self-describing and easily documented option has the advantage.
  • Query volatility and operational complexity: when the needed field combinations keep changing, a query language pays off — but you must newly design cost ceilings, depth limits, and a caching strategy.

What matters is writing the boundary down.


3. Method Semantics: safe and idempotent

This is where misunderstanding concentrates, so go to the source. RFC 9110 §9.2.1 defines safe methods as "essentially read-only; they do not alter server state" and classifies GET, HEAD, OPTIONS, and TRACE as safe. §9.2.2 defines idempotent as "the intended effect on the server of multiple identical requests is the same as for a single request" and classifies GET, HEAD, PUT, DELETE, OPTIONS, and TRACE as idempotent. POST is not idempotent.

Methodsafeidempotent
GETYesYes
HEADYesYes
OPTIONSYesYes
TRACEYesYes
PUTNoYes
DELETENoYes
POSTNoNo

3-1. Misconception 1 — "PUT is update, POST is create"

The spec does not say that. §9.3.4 defines PUT as requesting that "the state of the target resource be created or replaced," so PUT creates too. §9.3.3 defines POST as requesting that the resource "process the representation … according to the resource's own specific semantics." The dividing line is whether the target URI names the resulting resource. Client picks the URI, use PUT; server picks it, use POST.

3-2. Misconception 2 — "DELETE twice returns 404, so it is not idempotent"

Idempotency is defined by the intended effect on the server being the same, not by the responses being the same. A 204 on the first call and a 404 on the second still leave the same effect — that resource does not exist — so it is idempotent. Whether the client treats the 404 as success is a separate contract.

3-3. Misconception 3 — "Idempotent means retrying is safe"

Idempotency is a property of the request once it reaches the server. When a network timeout eats the response, you do not even know whether it arrived. With PUT, retrying is safe; with POST, you get a duplicate creation. That is why POST needs an idempotency key.

Example — taking an idempotency key as a header.

POST /v1/payments
Idempotency-Key: 5f2a9c1e-6f6c-4a54-9f2f-27ab19d1b3c4
Content-Type: application/json

{ "orderId": "ord_01J9X", "amount": 15000, "currency": "KRW" }

The contract is only complete once you also document the key's retention window, the behavior when the key matches but the body differs (usually 409), and the behavior when the same key arrives concurrently. Idempotency and Retries: APIs You Can Trust goes deeper.

3-4. Do Not Put Side Effects Behind Safe Methods

If GET changes state, prefetchers, proxy caches, and crawlers will trigger that change at random. The point is not that side effects are forbidden but that the client takes no responsibility for them. Incrementing a view counter is fine; authorizing a payment is not.


4. Status Codes Are a Contract

Status codes are values clients branch on, so they are irreversible. Here are only the ones people get wrong, per RFC 9110's definitions.

CodeRFC 9110's definition
400The server cannot or will not process the request due to a client error
401"The request lacks valid authentication credentials for the target resource"
403"The server understood the request but refuses to fulfill it"
404The origin server did not find a current representation for the target resource
409"The request conflicts with the current state of the target resource"
422Content type and syntax understood, but the instructions cannot be processed
500 / 503Internal server error / temporary overload or maintenance

4-1. Where 401 Ends and 403 Begins

RFC 9110 defines 401 in §15.5.2 and 403 in §15.5.4. 401 is authentication, 403 is authorization. A common third option in practice is returning 404 instead of 403 to hide existence. That is also a contract, so document it. Leave it out and clients read the 404 as "deleted" and purge their caches.

4-2. Where 400 Ends and 422 Begins

By the spec, broken syntax is 400 and syntactically valid but semantically wrong values are 422. Either way, pick one and be consistent. Mix them and clients end up handling both in the same branch.

4-3. 429 Is Not in RFC 9110

429 Too Many Requests is defined in RFC 6585. The text says it means "the user has sent too many requests in a given amount of time ('rate limiting')," and that the response "MAY include a Retry-After header indicating how long to wait." Because it is a MAY, clients must assume the header can be missing. The same RFC's 428 Precondition Required says "the origin server requires the request to be conditional," and exists to prevent the lost-update problem.

4-4. Anti-Pattern — Errors Inside a 200

Example — this removes the status code from the contract.

HTTP/1.1 200 OK
{ "success": false, "errorCode": "INSUFFICIENT_BALANCE", "message": "Insufficient balance" }

This response looks like a success to every proxy, gateway, monitor, and retry middleware on the path. The error-rate dashboard reads 0%, automatic retries never fire, and the circuit breaker never opens.


5. Error Response Format (RFC 9457)

RFC 9457 obsoletes RFC 7807 and defines HTTP API error representation with the application/problem+json and application/problem+xml media types. Unless you have a specific reason, do not invent a format — use this one. §3.1 defines five members.

  • type: a URI reference and the primary identifier of the problem type. Its default when absent is about:blank.
  • status: advisory, and must match the actual HTTP status code.
  • title: a human-readable summary. Apart from localization, the spec says it SHOULD NOT change from occurrence to occurrence.
  • detail: an explanation of this occurrence. It should "focus on helping the client correct the problem, rather than giving debugging information."
  • instance: a URI identifying this occurrence.

§3.2 permits extension members and requires that clients "MUST ignore any such extensions that they don't recognize." That is why adding a field to an error response is not a breaking change.

Example — carrying field-level validation errors as an extension member.

HTTP/1.1 422 Unprocessable Content
Content-Type: application/problem+json

{
  "type": "https://api.example.com/problems/validation-failed",
  "title": "Validation failed",
  "status": 422,
  "detail": "amount must be greater than 0",
  "instance": "/v1/payments/req_01J9XQ",
  "errors": [
    { "field": "amount", "code": "min_value", "min": 1 }
  ]
}

5-1. Designing the type URI

type is a value clients branch on, so it is irreversible. Three rules suffice. Be stable (the value should not change even if the domain does), be dereferenceable if convenient (not required), and match granularity to handling (give two errors with no reason to be handled differently distinct type values and clients will write code merging them back).

5-2. Separate What Humans Read From What Code Reads

title and detail are for humans; type and extension members are for code. Break that separation and clients start regex-matching the detail string, at which point the error message becomes the contract and a wording fix requires a client deployment.

§5 also covers security. Information included in problem details "must be carefully vetted," and the spec says to avoid exposing "implementation details such as a stack dump."


6. Pagination: offset vs cursor

6-1. The Real Differences

PropertyOffset-basedCursor-based
Arbitrary page jumpsPossibleNot possible
Total countEasyExpensive or approximate
Cost of deep pagesGrows with depthIndependent of depth
Consistency under inserts and deletesDuplicates and gapsStable
Where the contract hardensPage numberCursor string

Offset's problem is not only performance. If a new item is inserted at the front while a user views page 1, page 2 shows the same item again; if one is deleted, an item is skipped entirely. On an API whose lists change often, this comes back as bug reports.

6-2. Cursors Must Be Opaque

Example — response envelope with an opaque cursor.

{
  "data": [
    { "id": "ord_01J9XQ", "createdAt": "2026-08-15T09:30:00Z" }
  ],
  "nextCursor": "eyJjIjoiMjAyNi0wOC0xNVQwOTozMDowMFoiLCJpIjoib3JkXzAxSjlYUSJ9",
  "hasMore": true
}

Document the cursor's internal structure and clients will decode and tamper with it, after which you cannot change the structure. Specify only that the cursor is a token to be returned exactly as the server issued it, and you can later change the sort key or add a signature without breaking anyone.

6-3. A Non-Unique Sort Key Breaks the Cursor

Build the cursor from createdAt alone and items sharing a millisecond produce duplicates or gaps. Always append a unique secondary key to the sort key. That is why the cursor in the example above carries both a timestamp and an identifier.

6-4. The Irreversible Part Is the Envelope

The data / nextCursor / hasMore envelope cannot be changed later. Return a top-level array and the moment you need metadata you must change the entire response shape, which is a breaking change.


7. Representing Time, Money, and Enums

All three get carved directly into parsing code and storage schemas, so all three are irreversible.

Example — the representation recommended in this section.

{
  "createdAt": "2026-08-15T09:30:00Z",
  "scheduledAt": "2026-09-01T14:00:00+09:00",
  "scheduleTimeZone": "Asia/Seoul",
  "amount": 15000,
  "currency": "KRW",
  "status": "partially_refunded",
  "canceledAt": null,
  "externalId": "9007199254740993"
}

7-1. Time

  • Send strings. ISO 8601 extended format (e.g. 2026-08-15T09:30:00Z) is safe. Numeric epochs cannot distinguish seconds from milliseconds, so a misparse lands you in 1970 or fifty thousand years out.
  • Always include the offset. A string without one requires knowing the server's time zone, and that time zone changes for infrastructure reasons.
  • Future commitments need a time zone name. An offset pins the rule as of that moment, but daylight saving rules and national policy change. "2pm on September 1 in Seoul" must be stored as a time zone name.

7-2. Money

  • Send an integer minor unit together with a currency code. Never floating point. Discovering during settlement that 0.1 + 0.2 is not 0.3 costs days of tracing.
  • Decimal places differ by currency. Won has zero, dollars two, some currencies three. "Integer cents" does not hold without the currency code, so always send the ISO 4217 code alongside.
  • If exchange rates are involved, include the conversion time and the rate. Add them later and historical rows lack the values, producing dual logic.

7-3. Enums

Whether adding a new enum value is breaking depends on the client's handling rule. If clients exhaustively branch on every value, it is breaking; if the default behavior for unknown values is defined, it is not. So put this sentence in the v1 documentation.

  • "New values may be added to this field. Treat unknown values as unknown and preserve the original string when echoing it back."

Fix value names to lowercase with underscores. Mixed case is normalized differently in different languages and produces comparison bugs.

7-4. null vs Absent Field vs Empty Value

Decide now whether you distinguish the three states. Whether "not canceled" is canceledAt: null or an absent field changes the client's partial-update logic, and if you have a partial-update API you need a way to distinguish "clear this field" from "leave it alone."

7-5. Send Large Integers as Strings

JSON numbers are parsed as double-precision floats in many languages. Send an identifier beyond the safe integer range as a number and the last digits silently change. That is why externalId above is a string.


8. Change and Versioning — Expand, Migrate, Contract

8-1. Parallel Change

Martin Fowler's Parallel Change splits an interface change into three. In the expand phase you "augment the interface to support both the old and the new versions." During the migrate phase you "update all clients using the old version to the new version," and this "can be done incrementally." Then, "once all usages have been migrated to the new version, you perform the contract phase to remove the old version." Fowler attributes the pattern to Joshua Kerievsky.

expand     support old and new    ── a server deploy is enough
migrate    move clients one by one ── usage measurement is mandatory
contract   remove the old version  ── only after usage reaches zero

The core of this is admitting that the server does not control the length of the migrate phase. The contract date is set by usage, not by the calendar. That is why the expand phase must ship with usage instrumentation for the old version.

8-2. What Counts as a Breaking Change

Breaking: removing, renaming, or retyping a response field; adding a required request field; changing a status code or error identifier; changing defaults or the default sort; lowering rate limits; and adding an enum value only when clients exhaustively branch on every value.

Not breaking: adding optional fields to responses and requests (when the ignore rule is documented), adding endpoints, adding extension members to error responses, and performance improvements.

The borderline cases cause the accidents. "Adding an optional field" is only safe when clients ignore unknown fields; for a client using strict deserialization, adding a field is breaking too. That is exactly why the three sentences in section 1-3 exist.

8-3. A Contested Point — Path Versioning vs Header or Media-Type Versioning

No winner here either. Just the axes.

  • Visibility and debugging: a version in the path shows up directly in logs and the address bar; header versioning requires inspecting the request.
  • Routing and caching: path versioning makes routing and cache-key separation obvious. Header versioning requires exact Vary handling, and intermediate caches that get it wrong cause cross-contamination.
  • Granularity and version explosion: path versioning tends to bump the whole API at once, so versions jump in big steps. Media-type versioning can vary per resource but increases what you must maintain.
  • Client convenience: if many consumers call by URL alone from a browser, path versioning is far easier.

Whichever you choose, first check whether the change can be made without a version bump. An API whose version number climbs quickly is usually skipping the expand phase.

8-4. Deprecation Procedure

  • Announce deprecation in a response header. Documentation alone goes unread. Include the planned date and the replacement path.
  • Instrument usage per client. Aggregate totals cannot tell you whom to contact.
  • Run a short blackout rehearsal before contracting. Return 410 on the old version briefly, then revert, and the remaining consumers reveal themselves.

The larger picture of phased replacement is covered in The Complete Guide to the Strangler Fig Pattern.


9. How to Verify the Contract

When the contract in the documentation and the response actually on the wire disagree, the contract is the response.

9-1. Make the Schema the Source

Whether you generate the OpenAPI schema from code or the reverse varies by team. What matters is that one of the two is the single source. Maintain both by hand and they will drift, and the client will notice the drift first.

9-2. Block Breaking Changes in CI

Example — wiring a schema diff as a gate.

PR opened
  └─ compare the previous commit's schema against the current one
       ├─ field removed / type changed / required added  → fail, needs reviewer approval
       ├─ optional field added / new endpoint            → pass
       └─ error type URI changed                         → fail

The value of this gate is less in blocking than in surfacing. When a breaking change is genuinely needed, a human approves it; the problem is a breaking change merged by someone who did not know it was one.

9-3. Consumer-Driven Contract Tests

Consumers register expectations of the form "I use this field this way" as contracts, and the provider pipeline verifies them. The real benefit is not the tests but an explicit list of who depends on what, and that list becomes the contact list for the deprecation procedure in 8-4.

9-4. Validate Documentation Examples Against the Schema

Hand-written example responses go stale fastest. Put the examples under schema validation and the documentation stays current automatically.

9-5. Real-Traffic Regression

Replay a sample of production requests against both the old and new versions and diff the responses. This catches what schema validation cannot: distribution shifts in values, changes in sort order, empty arrays swapped for null. When building responses by hand, HTTP Request Builder and HTTP Status Codes help.


Quiz: Check Your Understanding

Quiz 1: A list API returns a top-level array. A request comes in to add a total count. What is the problem?

Answer: A top-level array has nowhere to attach metadata, so you must change the whole response into an object — a breaking change.

Explanation: The envelope of a list response is an irreversible decision. Wrap it in an object from the start and adding fields later is just an optional-field addition, which is safe. If it already shipped as an array, you must walk through expand, migrate, and contract.

Quiz 2: DELETE called twice returned 204 then 404. Has this API violated idempotency?

Answer: No. RFC 9110 defines idempotency by the intended effect on the server, not by identical responses.

Explanation: §9.2.2 defines idempotency as "the intended effect on the server of multiple identical requests is the same as for a single request." After two calls the state is the same — the resource does not exist — so it is idempotent. Whether retry logic treats 404 as success is a separate contract, and leaving it undocumented means 404s produced during retries get counted as errors and skew the dashboard.

Quiz 3: A payments API returns HTTP 200 with an error body when the balance is insufficient. The application works fine. What is breaking?

Answer: Every intermediate layer on the path reads this as a success. Error-rate metrics, automatic retries, circuit breakers, and gateway policies are all neutralized.

Explanation: Status codes are not read only by the application — proxies, load balancers, observability pipelines, and client libraries all act on them. Insufficient balance is a request error the client can correct, so a 4xx is right, and the body should be RFC 9457 problem details identifying the error type by type URI.

Quiz 4: You want to add one value to a status enum in a response. How do you judge whether it is a breaking change?

Answer: By whether the documentation defines how clients handle unknown values. Without that rule, it is breaking.

Explanation: Adding an enum value is neutral in itself; the breaking-ness is decided by the consumer's rule. For a client that exhaustively branches on every value, a new value is a runtime error. Writing the handling rule into the v1 documentation is overwhelmingly cheaper than producing a v2.

Quiz 5: You want to remove an old endpoint but are not sure usage is zero. What comes first?

Answer: Add per-client usage instrumentation first, then run a short blackout rehearsal.

Explanation: The contract phase of Parallel Change happens only "once all usages have been migrated to the new version." Aggregate totals do not tell you which consumers remain, so you cannot contact them, and removing without instrumentation means discovering them as an outage.


Conclusion

The place to spend time in API design is not the hard problems but the hard-to-reverse ones. The two often diverge: cache invalidation is hard but reversible, identifier format is easy but irreversible.

The five things to settle before the first release are resource boundaries, identifiers, method semantics, the status code scheme, and the error format. Add the three sentences from section 1-3 and a large share of the changes you will later need drop from breaking change to expansion. Avoid only the failure of demanding perfection on the changeable things while deciding the unchangeable ones carelessly, and half of API design is done.


References

  • RFC 9110 — HTTP Semantics — quoted for the §9.2.1 safe definition and its methods, the §9.2.2 idempotent definition and the fact that POST is not idempotent, the §9.3.3 POST and §9.3.4 PUT definitions, the definitions of 400, 401, 403, 404, 409, 422, 500, and 503, and the 401 (authentication) vs 403 (authorization) split per §15.5.2 and §15.5.4. Retrieved 2026-08-15.
  • RFC 6585 — Additional HTTP Status Codes — quoted for the definition of 429, the fact that Retry-After is a MAY, 428 Precondition Required as the defense against the lost-update problem, and the fact that 429 lives here rather than in RFC 9110. Retrieved 2026-08-15.
  • RFC 9457 — Problem Details for HTTP APIs — quoted for obsoleting RFC 7807, the application/problem+json media type, the five members defined in §3.1, §3.2 extension members and the client's obligation to ignore unrecognized ones, and the §5 prohibition on exposing stack dumps. Retrieved 2026-08-15.
  • Parallel Change — Martin Fowler — quoted for the descriptions of the expand, migrate, and contract phases, the fact that migration can be incremental, and the attribution of the pattern to Joshua Kerievsky. Retrieved 2026-08-15.
  • The four screening questions, the cost-of-reversal table, the three-identifier comparison, the three sentences for the first documentation, the pre-contraction blackout rehearsal, and the schema-diff gate classification are not taken verbatim from the sources above; they are the procedure organized in this post.

Further reading

Complete Guide Series