- 들어가며
- 1. 되돌릴 수 없는 것과 되돌릴 수 있는 것 — 판별 기준
- 2. 리소스 경계와 식별자
- 3. 메서드 의미론: safe와 idempotent
- 4. 상태 코드는 계약이다
- 5. 오류 응답 형식 (RFC 9457)
- 6. 페이지네이션: offset과 cursor
- 7. 시간·돈·열거형의 표현
- 8. 변경과 버저닝 — 확장·이행·축소
- 9. 계약을 검증하는 법
- 퀴즈: 실력을 확인해 보세요
- 마치며
- 참고 자료
- 이어서 읽기
들어가며
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. 실제 목록
| 결정 | 되돌림 비용 | 이유 |
|---|---|---|
| 리소스 경계와 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는 멱등하지 않습니다.
| 메서드 | safe | idempotent |
|---|---|---|
| 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+json과 application/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. 사용자에게 보일 문장과 클라이언트가 읽을 값을 분리한다
title과 detail은 사람용이고 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 Builder와 HTTP 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 게이트의 분류는 위 자료에 그대로 나오는 것이 아니라 이 글에서 정리한 절차입니다.
이어서 읽기
- 이 블로그의 관련 글: API 디자인 완전 가이드 — REST·OpenAPI·Versioning·Pagination·Idempotency·Webhook
- 이 블로그의 관련 글: 멱등성과 재시도: 신뢰할 수 있는 API
- 이 블로그의 관련 글: HTTP 캐싱 제대로 쓰기
- 관련 도구: HTTP Status Codes
- 관련 도구: HTTP Request Builder
완전 가이드 시리즈
현재 단락 (1/207)
API 설계에서 진짜 위험한 결정은 어려운 결정이 아니라 **되돌릴 수 없는 결정**입니다. 캐시 전략을 잘못 고르면 다음 스프린트에 고칩니다. 식별자 형식을 잘못 고르면 3년 뒤...