TL;DR
- 버저닝 전략 4가지: URL Path (
/v1/), Header (API-Version: 2024-01-01), Content Negotiation (Accept: application/vnd.api+json;v=1), Query (?version=1) - Stripe 방식 = 날짜 기반:
2024-04-15처럼 날짜로 버전. 가장 우아한 접근 - GitHub 방식 = REST URL:
/v3/repos. 단순하지만 메이저 변경마다 마이그레이션 필요 - GraphQL = 버전 없음: 필드 단위 deprecation으로 진화. Twitter, GitHub, Shopify 채택
- Sunset 헤더: RFC 8594.
Sunset: Sat, 31 Dec 2025 23:59:59 GMT— 클라이언트에게 폐기 일정 알림
1. API 진화의 본질적 어려움
1.1 외부 API의 운명
"한 번 공개된 API는 영원히 살아있다." — Hyrum's Law
내부 코드는 마음대로 리팩토링할 수 있습니다. 외부 API는 다릅니다:
- 수천~수백만 클라이언트가 의존
- 모바일 앱은 사용자가 업데이트해야 새 버전
- 통합된 비즈니스 시스템은 변경에 인색
- "잠시 멈춤"이 매출 손실로 직결
1.2 변경의 종류
| 변경 | 영향 | 호환성 |
|---|---|---|
| 새 엔드포인트 추가 | 없음 | ✅ |
| 새 응답 필드 추가 | 없음 | ✅ |
| 선택 필드 추가 | 없음 | ✅ |
| 응답 필드 제거 | Breaking | ❌ |
| 필드 이름 변경 | Breaking | ❌ |
| 필드 타입 변경 | Breaking | ❌ |
| 필수 필드 추가 | Breaking | ❌ |
| 에러 코드 의미 변경 | Breaking | ❌ |
| 기본 동작 변경 | Breaking | ❌ |
규칙: 추가는 안전, 제거/변경은 위험.
1.3 Hyrum's Law의 잔혹함
"API에 충분한 사용자가 있으면, 어떤 관찰 가능한 동작도 누군가는 의존한다."
실제 사례:
- 응답 필드 순서를 가정하는 클라이언트
- 에러 메시지의 정확한 텍스트를 파싱하는 클라이언트
- 부수 효과(예: ID가 순차적인 것)에 의존하는 클라이언트
- 응답 시간이 일정하다는 가정
결론: "공식 문서에 없는 것은 바꿔도 된다"는 잘못입니다. 모든 관찰 가능한 동작이 API의 일부입니다.
2. 버저닝 전략 4가지
2.1 URL Path Versioning
GET /v1/users/123
GET /v2/users/123
장점:
- 가장 명확
- 디버깅 쉬움 (URL만 봐도 버전 보임)
- 캐시 친화적 (다른 URL = 다른 캐시)
단점:
- 메이저 변경마다 새 URL 필요
- 각 버전을 별도 코드베이스로 유지
- 클라이언트가 URL을 하드코딩
채택: GitHub (/v3/), Twitter, Stripe (URL은 /v1/이지만 실제는 헤더로)
2.2 Header Versioning
GET /users/123 HTTP/1.1
Stripe-Version: 2024-04-15
장점:
- URL이 깨끗
- 같은 리소스, 다른 표현
- 점진적 마이그레이션 쉬움
단점:
- 디버깅 어려움 (헤더 안 보이면)
- 캐시 처리 복잡 (Vary 헤더)
- 클라이언트 라이브러리가 헤더 자동 추가해야 함
채택: Stripe (가장 유명), Azure
2.3 Content Negotiation
GET /users/123 HTTP/1.1
Accept: application/vnd.example.user.v2+json
장점:
- HTTP 표준 (Accept 헤더 활용)
- 같은 URL로 다른 버전
- HATEOAS와 잘 맞음
단점:
- 복잡함
- 클라이언트가 정확한 MIME 타입 알아야 함
채택: GitHub (선택적, Accept: application/vnd.github.v3+json)
2.4 Query Parameter
GET /users/123?version=2
GET /users/123?api_version=2024-04-15
장점:
- 가장 단순
- URL에 보임 (디버깅 쉬움)
단점:
- "데이터의 일부"처럼 보임 (실제로는 메타데이터)
- 캐시 처리 복잡
채택: 일부 단순 API
2.5 비교표
| 방식 | URL 깨끗 | 디버깅 | 캐시 | 채택 |
|---|---|---|---|---|
| URL Path | ❌ | ⭐⭐⭐ | ⭐⭐⭐ | GitHub, Twitter |
| Header | ⭐⭐⭐ | ⭐ | ⭐⭐ | Stripe, Azure |
| Content Negotiation | ⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐ | GitHub (옵션) |
| Query Param | ⭐⭐ | ⭐⭐⭐ | ⭐⭐ | 단순 API |
3. Stripe의 천재적인 날짜 기반 버저닝
3.1 핵심 아이디어
Stripe는 버전을 날짜로 표현합니다:
2024-04-15(해당 날짜의 API 동작)2023-10-16(이전 동작)2020-08-27(5년 전 동작)
모든 변경은 날짜로 식별됩니다.
3.2 클라이언트 사용
import stripe
# 계정 기본 버전 사용
stripe.api_version = "2024-04-15"
# 또는 요청별
stripe.Charge.create(
amount=2000,
currency="usd",
api_version="2023-10-16" # 옛 동작
)
3.3 Stripe의 비결 — 변환 레이어
서버에는 단 하나의 코드베이스 (최신 버전).
각 요청에 대해:
- 클라이언트 버전 확인
- 요청을 최신 버전으로 변환 (forward transform)
- 처리
- 응답을 클라이언트 버전으로 변환 (backward transform)
클라이언트 (v2020) → [변환] → 최신 코드 → [변환] → 클라이언트 (v2020)
효과:
- 5년 전 클라이언트도 동작
- 코드는 최신 상태 유지
- 새 기능은 즉시 모든 사용자에게 (옵트인)
3.4 변환 예시
v2020에서 v2024 변경: 응답에 description 필드 추가.
# 변환 함수
def transform_to_v2020(response):
if "description" in response:
del response["description"] # v2020 클라이언트는 이 필드를 모름
return response
v2020에서 v2024 변경: amount가 정수에서 객체로 변경.
def transform_to_v2020(response):
if isinstance(response.get("amount"), dict):
response["amount"] = response["amount"]["value"]
return response
3.5 Stripe 변경 로그
각 버전 변경이 정확히 문서화됩니다:
2024-04-15
- Added
descriptionfield toChargeobjectamountfield type changed from integer to AmountObject- Default
currencyis now derived from account settingsMigration guide: ...
이 정도의 투명성이 신뢰의 핵심입니다.
4. GraphQL의 버전 없는 진화
4.1 핵심 철학
GraphQL은 버전을 사용하지 않습니다. 대신 필드 단위로 진화:
- 추가: 새 필드 추가 — 기존 클라이언트는 모르므로 영향 없음
- 삭제:
@deprecated마크 후 일정 시간 후 제거
type User {
id: ID!
name: String!
# Deprecated 필드
email: String @deprecated(reason: "Use 'emailAddress' instead. Will be removed 2025-12-31")
emailAddress: String!
}
4.2 클라이언트가 정확히 요청
query {
user(id: "123") {
id
name
emailAddress # 새 필드만 요청
}
}
기존 클라이언트는 email을 계속 요청 → 작동. 새 클라이언트는 emailAddress 사용.
Over-fetching이 없다 = 새 필드 추가가 무료.
4.3 Deprecation 추적
GraphQL 서버가 사용 통계를 수집:
- 어떤 클라이언트가
email을 사용 중인가? - 마지막 사용은 언제?
- 안전하게 제거 가능한가?
Apollo Studio, Hasura Cloud가 이 기능 제공.
4.4 GraphQL의 한계
- 모든 변경이 호환되는 건 아님 — 타입 변경, enum 값 제거 등은 여전히 breaking
- 클라이언트 코드 생성 — 새 스키마로 다시 빌드 필요
- 고급 도구 필요 — 사용 추적 등
채택: GitHub, Shopify, Twitter, Airbnb
5. Semantic Versioning과 API
5.1 SemVer 기본
MAJOR.MINOR.PATCH
v1.2.3
- MAJOR: 호환성 깨짐 (breaking)
- MINOR: 호환성 유지 + 새 기능
- PATCH: 호환성 유지 + 버그 수정
5.2 라이브러리 vs API
라이브러리: SemVer 자연스러움.
npm install foo@^1.0.0→ 1.x.x 자동 업데이트
Web API: 적용 어려움.
- 클라이언트가 자동 업데이트 못 함
- "v1.2"와 "v1.3"의 차이는 의미 있지만, "v1.2.3"과 "v1.2.4"는 거의 의미 없음
현실: Web API는 보통 메이저 버전만 표시 (/v1, /v2).
5.3 SemVer의 한계
v2.0.0이 나오면 모든 사용자가 마이그레이션 필요. 점진적 진화가 어려움.
→ Stripe 방식 (날짜 기반) 또는 **GraphQL 방식 (버전 없음)**이 더 우아.
6. Deprecation과 Sunset
6.1 Deprecation 단계
- Announce: 블로그, 이메일, 변경 로그
- Mark in API: 응답 헤더 또는 필드
- Monitor: 사용 추적
- Reminder: 사용 클라이언트에 직접 알림
- Sunset: 폐기 (HTTP 410 Gone)
6.2 Deprecation 헤더
RFC 8594: HTTP Sunset 헤더
HTTP/1.1 200 OK
Sunset: Sat, 31 Dec 2025 23:59:59 GMT
Deprecation: Sat, 31 Dec 2024 23:59:59 GMT
Link: <https://api.example.com/docs/migration>; rel="deprecation"
의미:
Deprecation: 이미 deprecated (아직 작동)Sunset: 폐기 일정Link: 마이그레이션 가이드
6.3 Deprecation 메시지 (응답 본문)
{
"data": {...},
"warnings": [
{
"code": "DEPRECATED_FIELD",
"message": "Field 'email' is deprecated. Use 'emailAddress' instead.",
"documentation_url": "https://api.example.com/docs/v2#email-deprecation",
"sunset_date": "2025-12-31"
}
]
}
6.4 사용자에게 알리기
기술적:
- 응답 헤더 (
Sunset,Deprecation) - 응답 본문의 warnings 필드
커뮤니케이션:
- 이메일 (등록된 개발자)
- 블로그 / 변경 로그
- 대시보드 공지
- 직접 연락 (큰 사용자)
Stripe: 사용 중인 deprecated API에 대해 자동 이메일 발송.
6.5 Sunset 정책 예시
| 사용자 | Sunset 기간 |
|---|---|
| 무료 사용자 | 6개월 |
| 유료 사용자 | 1년 |
| 엔터프라이즈 | 2년 |
큰 회사일수록 변경에 시간 필요.
7. Breaking Changes 회피 전략
7.1 Additive Changes 우선
잘못된 변경:
- "user_email"
+ "email"
올바른 변경:
+ "email" // 새 필드 추가
"user_email" // 옛 필드 유지 (deprecated)
두 필드를 같이 반환. 클라이언트가 마이그레이션할 시간 줌.
7.2 새 엔드포인트 vs 기존 변경
Bad: 기존 /users 응답 형식 변경.
Good: 새 /v2/users 엔드포인트 또는 /users?format=new.
7.3 Default 값 신중히
// v1
{ "page_size": 20 } // 기본값 20
// v2 — 50으로 변경?
{ "page_size": 50 } // ❌ Breaking! (페이지네이션 동작 변경)
기본값 변경은 종종 breaking입니다.
7.4 Optional → Required는 Breaking
- email: string? // optional
+ email: string // required
기존 클라이언트가 email을 보내지 않으면 실패. 추가하지 마세요.
7.5 Enum 값 추가는 안전?
enum Status {
ACTIVE,
INACTIVE,
+ PENDING_REVIEW // 새 값 추가
}
미묘함: 클라이언트가 enum을 switch로 처리하면 새 값에 대한 default 케이스가 필요. 있다면 안전, 없다면 미묘한 버그.
조언: enum 추가는 기술적으로 backward compatible이지만, 클라이언트 코드 검토 필요.
8. 실제 API의 진화 사례
8.1 Stripe — 날짜 기반의 우아함
- 10년+ API 진화
- 모든 변경에 정확한 날짜
- 클라이언트는 자기 페이스로 업그레이드
- 사용 통계 + 자동 알림
8.2 GitHub — REST에서 GraphQL로
- REST v3: 2014년부터 운영
- GraphQL v4: 2017년 출시
- 두 API 병행 운영
- 새 기능은 GraphQL 우선
교훈: 기존 API는 그대로 두고, 새 패러다임은 별도 시작.
8.3 Twilio — 메이저 버전 + 점진적 마이그레이션
/2008-08-01/,/2010-04-01/처럼 날짜 prefix- 하지만 메이저 변경은 새 prefix
- 옛 버전은 수년간 유지
8.4 Slack — 점진적 deprecation
- 빈번한 새 메서드 추가
- Deprecation은 6개월~1년 공지
- 사용자에게 직접 이메일
8.5 AWS — 거의 절대 깨지 않음
- 2006년 출시된 S3 API가 여전히 작동
- 새 기능은 추가만, 기존은 절대 변경 안 함
- 결과: API가 일관성 없고 복잡하지만 호환성 완벽
9. 모범 사례 체크리스트
9.1 설계 단계
- 버저닝 전략 결정 (URL/Header/날짜)
- 명확한 SLA (몇 년 지원할 것인가?)
- Deprecation 정책 문서화
- 변경 로그 자동화
9.2 변경 시
- Breaking change인가? (체크리스트로 확인)
- Additive로 만들 수 있나?
- 마이그레이션 가이드 작성
- Sunset 헤더 추가
- 사용자에게 공지
- 사용 통계 모니터링
9.3 Sunset 시
- 사용자 0명까지 대기
- 마지막 알림
- HTTP 410 Gone 응답
- 코드 제거 (시간이 지난 후)
10. API 진화의 미래
10.1 OpenAPI 3.1 + JSON Schema
스키마를 통한 자동 호환성 검사:
- API spec 변경 시 자동으로 breaking change 감지
- 클라이언트 코드 자동 생성
10.2 AI 기반 마이그레이션
- AI가 코드 분석 → 자동 마이그레이션 PR 생성
- 변경 영향도 자동 분석
10.3 contract testing 표준화
- Pact, Spring Cloud Contract 같은 도구
- API 제공자와 소비자 간 계약 강제
- CI에서 호환성 검증
퀴즈
1. 가장 흔한 Breaking Change는?
답: 응답 필드 제거 또는 이름 변경입니다. 클라이언트가 그 필드를 사용 중이라면 즉시 깨집니다. 안전한 대안: 새 필드를 추가하고 기존 필드를 deprecated로 표시 (둘 다 반환). 일정 기간 후 제거. 다른 흔한 breaking changes: 필드 타입 변경(string → object), 필수 필드 추가, 기본값 변경, enum 값 의미 변경.
2. Stripe의 날짜 기반 버저닝의 장점은?
답: (1) 점진적 마이그레이션 — 클라이언트가 자기 페이스로 새 버전 채택, (2) 단일 코드베이스 — 서버는 최신 버전만 유지, 변환 레이어로 옛 클라이언트 지원, (3) 명확한 변경 로그 — 각 날짜의 변경 사항이 정확히 문서화, (4) 테스트 용이 — 특정 날짜 버전을 명시적으로 테스트. 단점은 변환 레이어 구현이 복잡하다는 것.
3. GraphQL이 버전을 사용하지 않는 이유는?
답: GraphQL은 클라이언트가 정확히 필요한 필드만 요청하므로, 새 필드 추가가 기존 클라이언트에 영향 없습니다 — 그들은 그 필드를 요청하지 않으니까. 필드 제거는 @deprecated로 마크하고, 사용 통계를 추적하여 안전하게 제거 가능. 결과: 버전 없이 영원히 진화하는 API. 단점은 모든 변경이 호환되는 건 아니라는 것 (타입 변경, enum 값 제거 등).
4. Sunset 헤더의 역할은?
답: RFC 8594 표준 HTTP 헤더로, "이 리소스가 언제 폐기될 것인가"를 알려줍니다. Sunset: Sat, 31 Dec 2025 23:59:59 GMT. 클라이언트는 이 헤더를 보고 마이그레이션 일정을 자동으로 인식할 수 있습니다. Deprecation 헤더와 Link 헤더(마이그레이션 가이드)와 함께 사용. 자동화된 클라이언트가 안전하게 폐기 일정에 대응할 수 있게 합니다.
5. Hyrum's Law가 API 설계에 의미하는 것은?
답: "API에 충분한 사용자가 있으면, 어떤 관찰 가능한 동작도 누군가는 의존한다." 즉, 공식 문서에 없어도 변경하면 안 됩니다. 응답 필드 순서, 에러 메시지의 정확한 텍스트, 응답 시간, ID의 순차성 등 모두가 "API의 일부"가 됩니다. 결론: (1) 처음부터 신중하게 설계, (2) 변경 시 모든 관찰 가능한 동작을 고려, (3) 의도하지 않은 동작은 명시적으로 "이 동작에 의존하지 마세요"라고 문서화.
참고 자료
- Stripe API Versioning — 날짜 기반 접근 설명
- GitHub API v3 to v4 — GraphQL 마이그레이션
- RFC 8594 — Sunset Header
- Hyrum's Law
- OpenAPI 3.1 — API 명세 표준
- Pact — Contract testing
- GraphQL Best Practices
- API Stylebook — 회사별 API 가이드 모음
- Twilio API Versioning
- Building Evolvable Web APIs — Glenn Block et al.
- API Change Management at Stripe
현재 단락 (1/284)
- **버저닝 전략 4가지**: URL Path (`/v1/`), Header (`API-Version: 2024-01-01`), Content Negotiation (`Accep...