API 를 한 번 공개하면 클라이언트가 그 모양에 의존하기 시작한다. 필드 이름을 바꾸거나 응답 구조를 바꾸면 — 모르는 어딘가의 모바일 앱이 5 시간 뒤 부서진다. 그래서 버저닝 이 필요하다. 이 가이드는 버전을 어디에 두는지 (URL / header / Accept), SemVer + 호환 정책, 디프리케이션 절차, 그리고 Stripe / GitHub / AWS 가 실제로 어떻게 운영하는지 정리한다.
왜 버저닝인가
v1 응답:
{ "user_id": 42, "name": "jade" }
→ "user_id 를 id 로 줄이자" + "name 을 first_name/last_name 으로 분리"
v2 응답:
{ "id": 42, "first_name": "jade", "last_name": "kim" }
기존 모바일 앱 (v1.0.5) 은 user_id 기대 → null → crash
→ 한 번 외부에 공개된 응답 모양은 거의 영구 계약.
선택지:
1. 절대 깨지 않는다 (additive only) — 추가만 OK, 변경 / 제거는 못함
2. 버전 분기 — v1 과 v2 를 동시에 운영하다가 v1 sunset
3. (불가) 모든 클라이언트 강제 업데이트 — SaaS 도 모바일도 X버전을 어디에 두나 — 4 가지
1. URL 경로 (가장 흔함)
GET /v1/users/42
GET /v2/users/42
장점:
- 눈에 보임. curl / 로그 / 라우터에서 바로 분기
- 캐시 키가 자동 분리 (URL 다름 → CDN 자연 분기)
- 클라이언트 코드 가장 단순
단점:
- "resource 의 위치" 가 바뀐 듯한 인상 (REST 순수주의자 반대)
- 마이너 변경마다 v3/v4 늘리기 어색 → 메이저만 박는 게 일반적
채택: Twitter (v1.1, v2), GitHub (v3 URL + media type), 대부분의
SaaS public API2. 커스텀 헤더
GET /users/42
X-API-Version: 2
장점:
- URL 깔끔
- 헤더만 바꾸면 클라이언트 코드 일부 분기 가능
- 날짜 버전 (Stripe 스타일) 과 잘 어울림
단점:
- curl 에서 안 보임 — 디버깅 시 헤더 까봐야 앎
- 캐시 키에 헤더 포함 안 하면 CDN 잘못 캐싱
- 표준 헤더가 아니라 vendor 마다 이름 다름 (X-Api-Version / Api-Version / Stripe-Version ...)
채택: Stripe (Stripe-Version: 2024-06-20), Shopify (X-Shopify-Api-Version)3. Accept media type (content negotiation)
GET /users/42
Accept: application/vnd.github.v3+json
Accept: application/vnd.example.v2+json
장점:
- HTTP 표준 (RFC 6838 vendor tree) 에 부합
- 같은 URL 에서 representation 만 협상 — REST 원칙 정합
- 응답 Content-Type 도 매칭 가능
단점:
- 가장 verbose, 클라이언트 마다 직접 박아야
- Accept 헤더가 또 wildcard / q-value 와 섞이면 라우팅 복잡
- 학습 곡선 — 신입 / 외부 통합 어려움
채택: GitHub v3 (현재는 default), Atom / RSS 일부4. 쿼리 파라미터
GET /users/42?api-version=2
장점:
- 시도 / 디버깅 쉬움 (브라우저 주소창에 박기)
단점:
- URL 이 verbose
- 쿼리는 보통 "filter / search" 의미 — 의미 혼동
- 캐시 키에 자동 포함되지만 정렬 안 하면 미스 자주
- 누락 시 default 가 뭐냐 명세 헷갈림
채택: 일부 Azure API, Microsoft Graph (?api-version=...)SemVer + 호환 정책
Semantic Versioning MAJOR.MINOR.PATCH:
MAJOR — backward-incompatible 변경 (breaking)
MINOR — backward-compatible 추가 (새 endpoint / 새 필드)
PATCH — backward-compatible 버그 수정
API 에 적용:
- public API 는 보통 MAJOR 만 URL 에 노출 — /v1, /v2
- MINOR / PATCH 는 같은 v1 안에서 add-only 변경
→ 필드 추가 OK, 필드 제거 / rename / 타입 변경 X
"backward-compatible 추가" 의 함정:
- 필드 추가 → 기존 클라이언트가 unknown field 무시한다고 가정
→ 실제로 strict JSON schema 검증하는 클라이언트는 깨짐 (예: protobuf
의 unknown field 처리, 일부 Swift Codable 설정)
- enum 값 추가 → 기존 클라이언트가 switch / match 의 default 없으면 crash
→ enum 추가는 사실상 breaking 으로 취급해야 안전호환 깨는 변경 — 어디까지가 breaking 인가
| 변경 | 분류 | 설명 |
|---|---|---|
| 필드 추가 | compatible (대부분) | strict schema 클라이언트는 깨질 수 있음 |
| 새 endpoint 추가 | compatible | 안전 |
| optional 쿼리 / 헤더 추가 | compatible | default 가 기존 동작과 같으면 안전 |
| 필드 제거 | breaking | 읽던 클라이언트 깨짐 |
| 필드 rename | breaking | 제거 + 추가 = 둘 다 깨짐 |
| 필드 타입 변경 | breaking | int → string / nullable 도입 모두 깨짐 |
| 응답 구조 변경 | breaking | 예: object → array 또는 wrapping 추가 |
| HTTP 상태 코드 변경 | 보통 breaking | 200 → 201 도 strict 클라이언트엔 깨짐 — 자세한 분류는 http-status-codes |
| error 응답 모양 변경 | breaking | error 파싱 로직이 부서짐 |
| 새 required 필드 | breaking | 옛 client 요청이 400 됨 |
| rate limit 강화 | breaking (운영상) | 코드는 같지만 429 폭증 |
Stripe 의 날짜 버전 — 흥미로운 해법
Stripe-Version: 2024-06-20
특징:
- 모든 breaking change 가 "release 날짜" 라벨로 묶임
- 계정 등록 시점의 날짜로 자동 pinned — 명시 안 하면 그 버전 사용
- 새 기능 / 호환 변경 위해 명시적으로 upgrade
- v1, v2 같은 메이저 점프 없음 → 점진적 진화
장점:
- "n 개월 안에 모두 옮겨라" 같은 강제 디프리케이션 거의 없음
- 클라이언트가 자기 페이스로 upgrade
단점:
- 서버가 N 년치 모든 버전을 simultaneously 지원
- 코드베이스에 if (version >= "2024-06-20") 분기 누적
- 새 직원 onboard 어려움 — 모든 분기를 머리에 넣어야
이게 가능한 이유: Stripe 가 수입의 큰 부분을 이 API 에서 직접 벌어
모든 breaking 비용을 자기들이 흡수. 일반 SaaS 가 흉내내면 운영비 폭증.GitHub 의 진화
GitHub API:
- v1, v2 (initial) → v3 (REST, 가장 오래 유지) → GraphQL (v4 가 아닌 별도 라우트)
- v3 는 URL prefix 없이 호스트 분리: api.github.com (default v3)
- preview features: Accept: application/vnd.github.<feature>-preview+json
→ 정식 채택 전에 미리 시도 가능, breaking 가능성 명시
깨우침: 메이저 점프는 5-10 년에 한 번. 그 사이는 additive + preview header
로 진화. 진짜 깨는 큰 점프 (REST → GraphQL) 는 차라리 별도 product
로 분리하는 게 운영 비용 ↓.디프리케이션 절차 — Sunset header
IETF RFC 8594 의 Sunset 헤더는 "이 endpoint 가 언제 사라지는지" 를 응답에 박는 표준.
HTTP/1.1 200 OK
Content-Type: application/json
Sunset: Sat, 31 Dec 2024 23:59:59 GMT
Deprecation: true
Link: <https://api.example.com/v2/users/42>; rel="successor-version"
→ 클라이언트는 응답 헤더 보고 자동 알림 / 로깅 가능
→ 운영자는 "Deprecation: true 응답 비율" metric 으로 마이그레이션 진척 파악
표준 절차:
1. 결정 — v1 → v2 마이그 시작
2. 알림 — 블로그 + 이메일 + dashboard 배너
3. Deprecation 헤더 박기 — 모든 v1 응답에
4. Sunset 날짜 박기 — 보통 6-12 개월 후
5. 사용량 모니터 — 일별 v1 요청 수 / 고객 별 분포
6. 개별 outreach — 상위 N % 고객에게 마이그 지원
7. Sunset 임박 — 50% 의 v1 요청에 410 Gone 으로 응답 (canary)
8. Sunset — 모두 410 Gone 또는 308 Permanent Redirect
권장: 최소 6 개월 deprecation 기간. enterprise 고객은 12-24 개월 흔함.breaking change 회피 — 실전 패턴
- "add, don't change" — 새 필드 추가, 옛 필드는 유지. 옛 필드는 deprecated 라벨만 박고 한참 보존.
- 응답 expansion 패턴 — 작은 응답 default,
?expand=user,billing같은 쿼리로 추가 필드. 새 영역 추가가 기존 응답 모양에 영향 X. - nullable 새 필드 추가 시 default 명시 — 응답에 새 nullable 필드 추가는 OK 지만, 기존 항목은 일관된 null 값으로 채워야. 일부는 있고 일부는 없으면 클라이언트 코드 곤란.
- enum 대신 string + Open API list — enum 값 추가가 breaking 인 문제 회피. 단점: 타입 안전 ↓.
- 응답 envelope —
{ "data": {...}, "meta": {...} }처럼 wrap. 나중에 meta / pagination 등 추가 시 data 모양 안 깨짐. - feature flag — 새 동작을 헤더
X-Feature-Flag: new-billing로 opt-in. 안정화 후 default 로 승격.
흔한 함정 — 실제 사고 패턴
- "이 필드 안 쓸 거 같은데" → 제거 → 다음 날 모바일 앱 crash 폭증. 외부 노출 필드는 사용량 로깅 후에만 제거.
- "int → long 으로 늘리자" — JavaScript 의 Number 가 53 bit 정밀도. ID 가 2^53 넘으면 string 으로 변환하거나 처음부터 string ID. Twitter 가 ID 가 2^32 넘었을 때 겪은 유명한 사고 (tweet ID 가 64-bit 이라 JS 에서 잘림 →
id_str필드 추가). - error 응답 모양을 "더 좋게" 바꿈 — 옛 클라이언트가 error.code 파싱하던 코드가 모두 깨짐. error 모양은 더 보수적으로.
- 날짜 / 타임존 format 변경 — Unix timestamp → ISO 8601 같은 변경. 둘 다 같이 보내거나 (deprecation 기간만) 메이저 버전 전용.
- "이 status code 가 더 정확해서 401 → 403 으로 변경" — strict client 가 401 만 보고 token refresh 했는데 이제 403 → refresh 안 하고 즉시 실패.
- CORS 정책 변경 후 사전 통보 X — 옛 origin 금지하면 브라우저 클라이언트 즉시 깨짐. CORS 동작과 디버깅은
cors-explained. - "비공개 endpoint 라 자유롭게" → 서드파티가 reverse-engineer 해서 사용. private API 라도 외부 노출되면 마찬가지 보수적 취급.
실전 권장 — 처음 시작할 때
- URL 에 메이저만 박기 —
/v1/.... 헤더 / Accept 는 운영 비용 ↑. - response envelope 사용 — 나중에 meta / pagination 추가 가능.
- 모든 응답에
X-API-Version헤더 박기 (디버깅 / 로깅용). - breaking change 의 정의를 팀 문서로 박기 — 무엇이 깨는 변경인지 PR 리뷰에서 합의.
- Sunset + Deprecation 헤더 표준 채택.
- 외부 노출 후엔 "필드 추가만 자유, 변경은 항상 PR 리뷰 + 사용량 확인" 정책.
마무리
API 버저닝은 "한 번 공개한 모양은 거의 영구 계약"이라는 현실을 운영 가능하게 만드는 절차. URL prefix 가 가장 단순하고 실용적. SemVer 의 메이저만 노출, MINOR/PATCH 는 같은 버전 안에서 add-only.
진짜 어려운 건 protocol 이 아니라 디프리케이션. 6-12 개월 deprecation + Sunset 헤더 + 사용량 모니터링 + 상위 고객 개별 outreach. 이 운영 체력이 API 의 진짜 가치다.