API 버전 전략
TL;DR: 데이트 기반 버전을 API 키별로 고정하는 (Stripe 모델) 방식은
/v1/,/v2/보다 장수명 공개 API에 적합합니다. 작은 브레이킹 체인지를 수행할 때 클라이언트를 새로운 트리로 강제하지 않기 때문입니다. 내부 API의 경우, 추가적 진화(additive evolution)와Deprecation및Sunset헤더(RFC 9745, RFC 8594)로 충분합니다. 항상 공지하고, 항상 특정 날짜에 종료(sunset)하며, 절대 그냥 제거하지 마세요.
상황별 바로가기
| 증상 | 섹션 |
|---|---|
| "필드를 변경해야 하는데 10만 개의 API 키가 있어요" | 데이트 기반 고정 버전 |
"/v1/ vs Accept: application/vnd.foo.v2+json 중 무엇을 선택해야 할까요?" | 전략 비교 |
| "클라이언트에게 엔드포인트가 사라질 것임을 어떻게 알리나요?" | Deprecation + Sunset |
| "코드에서 6개 버전을 유지하는 게 너무 힘들어요" | 버전 변환기 |
| "내부 API인데 버전 관리가 필요한가요?" | 내부 vs 공개 API |
결정 다이어그램
flowchart TD
A[API 동작을 변경해야 함] --> B{추가적(additive)인가?<br/>새 필드, 새 엔드포인트, 새 선택적 파라미터}
B -->|예| C[그냥 배포<br/>버전 관리 불필요]
B -->|아니오, 브레이킹| D{API가 공개(public) +<br/>클라이언트가 많고 불투명한가?}
D -->|아니오, 내부/소수 클라이언트| E[마이그레이션 조정<br/>+ Deprecation/Sunset 헤더]
D -->|예, 공개| F{요청별로 호환성 레이어를<br/>제공할 수 있는가?}
F -->|예, 변환기 있음| G[데이트 기반 고정 버전<br/>Stripe 모델]
F -->|아니오, 대규모 재작성| H[메이저 버전 URI 변경<br/>/v1 → /v2]
E --> I[Deprecation: @타임스탬프<br/>+ Sunset: HTTP-date<br/>+ Link: rel=deprecation 설정]
G --> I
H --> I
1. 데이트 기반 고정 버전 (Stripe 모델)
Stripe는 API versioning at Stripe에서 자신의 아키텍처를 공개했습니다:
사용자가 처음 API 요청을 보내면, 해당 계정은 자동으로 가장 최신 버전에 고정(pin)되며, 그 이후로 모든 API 호출은 암시적으로 해당 버전을 할당받습니다.
[버전은] 출시된 날짜로 이름이 지어진 롤링 버전입니다 (예:
2017-05-24).
핵심 속성:
| 속성 | 작동 이유 |
|---|---|
| 계정별 기본 버전 (첫 호출 시 설정) | 신규 고객은 자동으로 최신 버전에 고정; 기존 고객은 변경 사항 배포 시 영향을 받지 않음 |
Stripe-Version 헤더로 요청별 재정의 | 점진적 클라이언트 마이그레이션 가능: SDK가 계정보다 먼저 새 버전을 선택할 수 있음 |
데이트 기반 이름 (2024-04-10) | 최근성을 전달; "v3"의 의미에 대한 논란 없음; 큰 v3를 위해 모아두지 않고 작은 브레이크를 여러 번 수행 가능 |
| 대시보드 업그레이드 경로 | 고객이 변경 사항을 미리 보고 계정 기본 버전을 업그레이드할 수 있음 |
이 접근법은 진정으로 장수명 공개 API(Stripe는 10년 전 버전도 유지)에 확장 가능한 유일한 방법입니다. 내부 복잡성(§4의 변환기 패턴)이 필요하지만, 고객에게 영원한 /v1/ → /v2/ 마이그레이션 주기를 강요하지 않습니다.
2. 버전 전략 비교
| 전략 | 버전 위치 | 장점 | 단점 | 최적 대상 |
|---|---|---|---|---|
URI 세그먼트 (/v1/, /v2/) | 경로 | 캐시 친화적(다른 URL = 다른 캐시 항목); 클라이언트 설정 불필요 | 작은 브레이크 불가; 트리 분기 강제; URL이 "안정적인 리소스 식별자"가 아님(Fielding 주장) | 내부 API, 드물게 변경되는 공개 API |
커스텀 헤더 (Stripe-Version: 2024-04-10) | 요청 헤더 | 저렴하게 많은 버전 제공; 요청별 세분화 | URL에 버전이 포함되지 않아 공유 곤란; 브라우저 주소창에서 테스트 어려움 | 규모가 큰 공개 API |
Accept 미디어 타입 (Accept: application/vnd.foo.v2+json) | 표준 Accept 헤더 | HTTP "스펙에 올바름"; 콘텐츠 협상 메커니즘 재사용 | 설정이 번거로움; 도구 지원 다양; curl 디버깅이 장황 | 하이퍼미디어 API, 깊은 RESTful 설계 |
쿼리 파라미터 (?version=2) | URL | 테스트 용이; 로그에 표시 | URL 오염; 의미상 잘못됨 (버전은 리소스 속성이 아님) | 빠른 프로토타이핑 전용 |
| 버전 없음, 추가만 | 해당 없음 | 오버헤드 없음; 클라이언트 조정 불필요 | 새 엔드포인트 없이는 브레이킹 체인지 불가 | 공유 배포가 가능한 내부 마이크로서비스 |
제약 조건에 따라 선택:
- "작은 브레이크를 자주 수행한다" → 데이트 기반 헤더 (Stripe)
- "5년에 한 번 대규모 재작성한다" → URI 세그먼트 (
/v1/,/v2/) - "절대 깨지 않고 추가만 한다" → 버전 없음 + 추가적 진화
3. Deprecation/Sunset 헤더 쌍
두 개의 IETF RFC가 수명주기 신호를 다룹니다:
Sunset 헤더 — RFC 8594
Sunset 값은 [RFC7231]의 섹션 7.1.1.1에 정의된 HTTP-date 타임스탬프이며, 미래의 타임스탬프여야 합니다(SHOULD).
Sunset: Sat, 31 Dec 2026 23:59:59 GMT
"리소스가 미래의 특정 시점에 응답하지 않을 것으로 예상됨"을 나타냅니다. 클라이언트는 이 타임스탬프를 힌트로 처리해야 하며(SHOULD), 엄격한 계약으로 보지 않아야 합니다.
Deprecation 헤더 — RFC 9745
Deprecation: @1735689599
Sunset: Sun, 31 Dec 2026 23:59:59 GMT
Link: <https://api.example.com/docs/migrate-v1-to-v2>; rel="deprecation"
Deprecation 값은 Unix 타임스탬프(초, Structured Fields에 따라 @ 접두사 사용)입니다. 과거("이미 폐기됨") 또는 미래("폐기 예정")일 수 있습니다. 준수해야 할 사항(MUST):
- RFC 9651에 따른 structured-field 날짜 형식 사용(MUST)
Sunset은Deprecation보다 이르면 안 됨(MUST NOT) — 스펙이 명시적임; 시간 순서 제약 조건- 마이그레이션 문서를 가리키는
rel="deprecation"포함Link를 포함해야 함(SHOULD)
Deprecation을 보내는 행위는 리소스의 동작을 변경하지 않습니다 — 이는 신호일 뿐, 성능 저하가 아닙니다. 서버는 (긴급 상황이 아닌 한) Sunset 날짜까지 리소스를 기능적으로 유지해야 합니다(SHOULD).
권장 타임라인
T+0 첫 Deprecation: 프로덕션에서 헤더 전송
T+30d 공개 공지 (체인지로그, 이메일, 문서)
T+90d 폐기 경로에 대한 응답 로그/메트릭 추가
T+180d Sunset 날짜를 90일 후로 설정
T+270d Sunset 날짜 도래 — 엔드포인트가 마이그레이션 링크와 함께 410 Gone 반환
메이저 버전 고정 API의 사소한 브레이킹 체인지의 경우: 중간값 6개월. 전체 v1 → v2 sunset의 경우: 12-24개월.
4. 버전 변환기 패턴
Stripe의 내부 아키텍처는 "프로덕션에 50개 버전" 문제를 해결합니다:
[이 시스템은] 현재 API 응답 구조를 정의하는 API 리소스 클래스와, 이전 버전과 호환되지 않는 변환을 캡슐화하는 버전 변경 모듈을 결합합니다. 응답을 처리할 때, 시스템은 시간을 거슬러 올라가면서 대상 버전에 도달할 때까지 각 버전 변경 모듈을 적용합니다.
형태:
// resource: 표준(최신) 표현
const charge = {
id: 'ch_123',
amount: 1000,
payment_method_details: { card: { brand: 'visa', last4: '4242' } },
}
// 각 브레이킹 체인지는 하나의 변환기 모듈:
const v_2024_03_01 = {
// payment_method_details 아래 'card' 중첩 제거, 필드 평탄화
apply(resource) {
return {
...resource,
card_brand: resource.payment_method_details?.card?.brand,
card_last4: resource.payment_method_details?.card?.last4,
}
},
}
const v_2023_10_15 = {
// 'amount'를 'amount_cents'로 이름 변경
apply(resource) {
const { amount, ...rest } = resource
return { ...rest, amount_cents: amount }
},
}
// 파이프라인은 역시간순으로 변환기를 적용
// 요청된 버전에 도달할 때까지
function transform(resource, requestedVersion) {
const chain = transformers.filter(t => t.date > requestedVersion)
return chain.reduceRight((r, t) => t.apply(r), resource)
}
이점:
- 핵심 코드는 최신 상태 유지 — 모든 엔드포인트는 최신 스키마에 대해 작성; 비즈니스 로직에
if (version < ...)가 흩어지지 않음. - 각 브레이크는 하나의 파일 — 검토, 격리 테스트, 마지막 사용자가 통과한 후 삭제가 용이.
- 텔레메트리 — 일별로 어떤 변환기가 실행되는지 세어 실제 사용량 기반 폐기 계획 수립 가능.
비용: 변환기 프레임워크 구축. 연간 3개 이상의 브레이킹 체인지와 1만 명 이상의 API 소비자가 있다면 가치 있음; 50명 미만 고객의 내부 API에는 과잉.
5. 내부 vs 공개 API
비대칭성이 중요합니다:
| 내부 | 공개 | |
|---|---|---|
| 조정된 배포 가능? | 예 — 원자적 교체 | 아니오 — 클라이언트가 독립적으로 배포 |
| 스키마 진화 | 추가적 + 슬랙 메시지 | 엄격한 버전 관리 + 이메일 + 대시보드 + 블로그 포스트 |
| 종료 기간 | 수일 ~ 수주 | 6-24개월 |
| 버전 관리 필요성 | 종종 불필요 — Deprecation 헤더만 | 필수 |
| 최적 전략 | 추가적 진화; 재작성 시에만 URI 버전 | 데이트 기반 헤더(규모가 허락한다면), 대규모 재작성 시 URI |
함정: 모든 API를 "공개"로 취급하여 3개의 내부 서비스만 사용하는 API를 위해 무거운 버전 관리 인프라를 구축합니다. 반대 함정: 외부로 유출된 내부 API에 외부 소비자가 생겨 실제로 깨뜨릴 수 없게 됩니다.
방어: 서비스에 "공개"라는 이진 태그를 만듭니다. 공개 서비스는 전체 버전 관리 의식을 따르고, 내부 서비스는 따르지 않지만 누가 호출하는지에 대한 적극적인 모니터링을 받습니다.
안티 패턴
| 안티 패턴 | 문제점 | 수정 |
|---|---|---|
하나의 브레이킹 체인지를 위해 /v1/을 /v2/로 올리기 | 하나의 필드 때문에 모든 것을 마이그레이션해야 함 | 데이트 기반 버전 또는 추가적 진화 |
Sunset 헤더 없이 엔드포인트 제거 | 경고 없이 클라이언트가 깨짐; 지원 티켓 폭증 | 항상 Deprecation과 함께 제거, 90일 이상 전에 |
Deprecation 전송하지만 Link 없음 | 클라이언트는 폐기되었지만 어떻게 해야 할지 모름 | 항상 Link: <migration-doc>; rel="deprecation" 포함 |
| 고객별 하드코딩된 버전 예외 | 유지 불가능; 동작 추론 불가 | 변환기 패턴 사용; "예외"는 버전 핀이 됨 |
| 내부 API를 공개 API처럼 버전 관리 | 성과 없는 프로세스 오버헤드 | 추가적 진화 + 원자적 배포 + 슬랙 |
REST API에 semver (v1.2.3) 사용 | 적용되지 않는 의미 부여 (JSON 스키마에서 "패치"란?) | 데이트 기반 이름 또는 메이저만 (v1) |
| 강제 없는 Sunset 날짜 | 엔드포인트가 영원히 살아있고 코드가 썩음 | 캘린더 알림; sunset 7일 전에 실패하는 CI 테스트로 강제 조치 |
초보자 / 전문가 / 타임라인
| 초보자 | 전문가 | |
|---|---|---|
| 첫 브레이킹 체인지 | /v1을 /v2로 올리고 전체 트리 복사 | 데이트 기반 핀 고려, 변경이 실제로 브레이킹인지 평가 |
| 클라이언트에게 폐기 알리기 | 이메일 폭탄 | Deprecation + Sunset 헤더 + Link + 이메일 + 대시보드 배너 |
| 5개 버전 유지 | 모든 컨트롤러에 5개의 if/else 분기 | 변환기 모듈, 최신 코어 코드만 유지 |
| 종료 강제 | "나중에 하지" | CI에 Sunset 날짜 포함; 해당 날짜에 엔드포인트가 410 Gone 반환 |
| 내부 API 변경 | 공개와 동일한 의식 — 느림 | 추가적 진화 + 원자적 배포; 무거운 프로세스는 공개용으로 예약 |
타임라인 테스트: "필드 X를 제거하고 싶다"에서 "필드 X가 프로덕션에서 사라졌다"까지 얼마나 걸리나요? 전문가 조직은 단일 반복 가능한 답변(예: 90일)을 갖고 있습니다. 초보자 조직은 프로세스가 없어 답변이 없습니다.
품질 게이트
버전 관리 변경이 출시되려면 다음 조건을 충족해야 합니다:
- 테스트: 외부에서 볼 수 있는 필드를 제거하거나 이름 변경하려면 최소 N일(문서화된 종료 기간) 전에 폐기 공지가 있어야 합니다.
- 테스트: 폐기된 모든 엔드포인트는
Deprecation: @<unix-ts>및Sunset: <HTTP-date>헤더를 전송하며, 통합 테스트에서 확인됩니다. - 테스트:
Sunset값은Deprecation값보다 절대 이르지 않습니다 (RFC 9745 MUST 기준). - 테스트: 모든 폐기 공지에는
Link: <docs-url>; rel="deprecation"헤더와 해당 URL에 존재하는 공개 마이그레이션 문서가 포함되며 (CI에서 HEAD 검사), 확인됩니다. - 테스트: 버전별 사용량 텔레메트리가 존재합니다 — v2를 삭제하기 전에 "어제 v2 경로에 몇 개의 요청이 있었나?"에 답할 수 있어야 합니다.
- 테스트 (데이트 핀 전용): 새 API 키는 최신 버전으로 기본 설정되어야 하며, 기존 키는 배포 간에 핀을 유지해야 합니다.
- 수동: 종료 강제 계획: 캘린더 알림, CI 검사, 또는 자동화된 410 Gone — 날짜가 도착하면 무언가가 실행되어야 합니다.
이 스킬의 범위 밖
- GraphQL 스키마 진화 (
graphql-schema-evolution사용) - gRPC / Protobuf 와이어 호환성 (
protobuf-evolution-rules사용) - 데이터베이스 스키마 버전 관리 (
zero-downtime-database-migration사용) - 라이브러리 / SDK semver (
npm-package-versioning사용) - 이벤트 스트림 스키마 진화 (
kafka-schema-registry-design사용)
출처
- Stripe: API versioning at Stripe — 고정 데이트 기반 버전, 변환기 패턴
- Stripe API: Idempotent requests / versioning policy
- IETF: RFC 8594 — The Sunset HTTP Header Field
- IETF: RFC 9745 — The Deprecation HTTP Header Field
- IETF: RFC 9651 — Structured Field Values for HTTP (Deprecation 헤더의 날짜 형식)
- Roy Fielding: REST APIs must be hypertext-driven — URI 버전 관리에 대한 표준적 반론