API 버저닝 — URI, Header, Content Negotiation 방식 비교
API를 수정해야 하는데 기존 클라이언트가 깨지면 안 된다면, 어떻게 해야 할까요?
API 버저닝은 서비스가 성장하면서 반드시 마주하는 문제입니다. 공부하다 보니 버저닝 방식마다 장단점이 명확해서, 상황에 맞는 선택이 중요하다는 걸 느꼈습니다. 각 방식을 비교하고 실무 권장 전략을 정리해보겠습니다.
왜 버저닝이 필요한가
API는 서버와 클라이언트 간의 계약(Contract) 입니다.
// v1: 이름이 하나의 필드
{ "name": "홍길동" }
// v2: 이름을 성/이름으로 분리 (Breaking Change!)
{ "first_name": "길동", "last_name": "홍" }
이런 변경을 버저닝 없이 하면:
- 기존 클라이언트가
name필드를 찾다가 에러 - 모바일 앱은 업데이트 배포까지 시간이 걸림
- 외부 파트너 API라면 즉시 장애 발생
하위 호환성이 깨지는 변경(Breaking Change)이 있을 때 새 버전이 필요합니다.
Breaking Change 예시:
- 필드 이름 변경 또는 삭제
- 응답 구조 변경
- 필수 파라미터 추가
- HTTP 메서드 변경
Non-Breaking Change (버전 변경 불필요):
- 새 필드 추가 (기존 필드 유지)
- 새 엔드포인트 추가
- 선택적 파라미터 추가
URI Path 방식
가장 직관적이고 널리 사용되는 방식입니다. AWS, Google, Twitter 등이 사용합니다.
GET /v1/users
GET /v2/users
// Spring에서 URI 버저닝
@RestController
@RequestMapping("/v1/users")
public class UserV1Controller {
@GetMapping("/{id}")
public UserV1Response getUser(@PathVariable Long id) {
// v1 응답: name 필드
return new UserV1Response(user.getName());
}
}
@RestController
@RequestMapping("/v2/users")
public class UserV2Controller {
@GetMapping("/{id}")
public UserV2Response getUser(@PathVariable Long id) {
// v2 응답: firstName, lastName 필드
return new UserV2Response(user.getFirstName(), user.getLastName());
}
}
** 장점:**
- 버전이 URL에 명시 → 가독성 높음
- 로그에서 어떤 버전인지 바로 파악
- HTTP 캐싱이 자연스럽게 동작 (URL이 다르니까)
- 브라우저에서 직접 테스트 가능
** 단점:**
- URL이 변경됨 → REST 원칙상 리소스 위치가 바뀌는 것
- 버전마다 라우팅 설정 필요
- 하나의 리소스에 여러 URL이 생김
Query Parameter 방식
GET /users?version=1
GET /users?version=2
** 장점:**
- URL 경로는 유지, 파라미터로 분기
- 기본값 설정 가능 (version 생략 시 최신 버전)
** 단점:**
- 파라미터 누락 시 의도치 않은 버전 호출 가능
- 캐싱이 복잡해짐 (쿼리 파라미터 포함 여부에 따라)
- 실무에서 잘 사용하지 않는 방식
Custom Header 방식
HTTP 커스텀 헤더로 버전을 지정합니다. Stripe가 대표적입니다.
GET /users
X-API-Version: 1
GET /users
X-API-Version: 2
// Spring에서 Header 기반 버저닝
@RestController
@RequestMapping("/users")
public class UserController {
@GetMapping(value = "/{id}", headers = "X-API-Version=1")
public UserV1Response getUserV1(@PathVariable Long id) {
return new UserV1Response(user.getName());
}
@GetMapping(value = "/{id}", headers = "X-API-Version=2")
public UserV2Response getUserV2(@PathVariable Long id) {
return new UserV2Response(user.getFirstName(), user.getLastName());
}
}
** 장점:**
- URL이 깔끔하게 유지
- 내부 마이크로서비스 간 통신에 적합
- 세밀한 버전 제어 가능 (Stripe는 날짜 기반 버전 사용)
** 단점:**
- 브라우저에서 직접 테스트 어려움 (헤더 설정 필요)
- API 문서를 잘 읽지 않으면 버전 지정을 놓칠 수 있음
- 로그에서 버전 확인이 번거로움
Content Negotiation 방식
HTTP Accept 헤더에 벤더 미디어 타입(Vendor Media Type)을 지정합니다. GitHub API가 사용합니다.
GET /users
Accept: application/vnd.mycompany.v1+json
GET /users
Accept: application/vnd.mycompany.v2+json
// Spring에서 Content Negotiation 버저닝
@RestController
@RequestMapping("/users")
public class UserController {
@GetMapping(value = "/{id}", produces = "application/vnd.mycompany.v1+json")
public UserV1Response getUserV1(@PathVariable Long id) {
return new UserV1Response(user.getName());
}
@GetMapping(value = "/{id}", produces = "application/vnd.mycompany.v2+json")
public UserV2Response getUserV2(@PathVariable Long id) {
return new UserV2Response(user.getFirstName(), user.getLastName());
}
}
** 장점:**
- HTTP 표준에 가장 부합
- URL 완전히 깔끔
- 가장 세밀한 버전 제어 (리소스 레벨까지)
** 단점:**
- 구현 복잡도가 높음
- 개발자 경험(DX)이 좋지 않음 — Accept 헤더를 정확히 설정해야 함
- 캐싱 설정이 복잡 (Vary 헤더 필요)
방식 비교 표
| 기준 | URI Path | Query Param | Custom Header | Content Negotiation |
|---|---|---|---|---|
| 가독성 | 높음 | 보통 | 낮음 | 낮음 |
| URL 깔끔함 | 낮음 | 보통 | 높음 | 높음 |
| 캐싱 용이성 | 높음 | 보통 | 낮음 | 낮음 |
| 구현 복잡도 | 낮음 | 낮음 | 보통 | 높음 |
| 테스트 편의성 | 높음 | 높음 | 낮음 | 낮음 |
| 대표 사용처 | AWS, Google | - | Stripe | GitHub |
시맨틱 버저닝과의 관계
시맨틱 버저닝(SemVer)은 MAJOR.MINOR.PATCH 형식입니다.
- MAJOR — 하위 호환성이 깨지는 변경 (1.x → 2.x)
- MINOR — 하위 호환성 유지하며 기능 추가 (1.1 → 1.2)
- PATCH — 버그 수정 (1.1.1 → 1.1.2)
API 버저닝에서는 보통 MAJOR 버전만 URL에 반영 합니다:
/v1/users ← MAJOR 버전
/v2/users ← Breaking Change가 있을 때만 올림
MINOR, PATCH 변경은 같은 버전 안에서 하위 호환성을 유지하며 반영합니다.
버전 폐기(Deprecation) 전략
새 버전을 출시한 후 이전 버전을 무한정 유지할 수는 없습니다.
단계별 폐기 프로세스
1. 공지 (6~12개월 전)
- API 응답에 Deprecation 헤더 추가
- 문서에 폐기 일정 공지
2. 경고 기간
- 이전 버전 호출 시 Sunset 헤더 포함
- 사용량 모니터링
3. 폐기
- 410 Gone 또는 301 Redirect 응답
// Deprecation 헤더 예시
@GetMapping("/v1/users/{id}")
public ResponseEntity<UserV1Response> getUserV1(@PathVariable Long id) {
UserV1Response response = userService.getUserV1(id);
return ResponseEntity.ok()
.header("Deprecation", "true")
.header("Sunset", "Sat, 01 Oct 2026 00:00:00 GMT")
.header("Link", "</v2/users/" + id + ">; rel=\"successor-version\"")
.body(response);
}
핵심 원칙: 최대 2개 버전(현재 + 이전)만 유지하고, 이전 버전 사용자에게 6~12개월의 마이그레이션 기간을 제공합니다.
Spring Framework 7의 API 버저닝 지원
Spring Framework 7부터 API 버저닝을 내장 지원합니다.
// Spring Framework 7에서의 버저닝 (빌트인 지원)
@RestController
@RequestMapping("/users")
@ApiVersion("1")
public class UserV1Controller {
// /v1/users 로 자동 매핑
}
@RestController
@RequestMapping("/users")
@ApiVersion("2")
public class UserV2Controller {
// /v2/users 로 자동 매핑
}
기존에는 커스텀 RequestMappingHandlerMapping을 만들어야 했지만, 이제 프레임워크 레벨에서 지원하므로 보일러플레이트가 크게 줄어듭니다.
실무 권장 전략
대부분의 팀에게 가장 현실적인 전략입니다:
- URI Path 방식으로 시작 — 가독성, 디버깅, 캐싱 모두 유리
- MAJOR 버전만 URL에 반영 —
/v1/,/v2/ - Non-Breaking Change는 같은 버전 내에서 처리 — 새 필드 추가, 선택적 파라미터
- ** 최대 2개 버전 유지** — 현재 + 이전
- ** 마이그레이션 기간 6~12개월** — Deprecation 헤더로 사전 공지
- API 변경 로그 관리 — 클라이언트가 무엇이 바뀌었는지 파악 가능하게
버저닝 방식보다 더 중요한 것은 "하위 호환성을 최대한 유지하는 설계"입니다. Breaking Change를 줄이면 버전을 올릴 일 자체가 줄어듭니다.
정리
- API 버저닝은 Breaking Change가 있을 때 기존 클라이언트를 보호하기 위한 전략
- URI Path 방식 이 가장 실용적 — 가독성, 캐싱, 테스트 편의성 모두 우수
- Header 방식은 URL을 깔끔하게 유지하고 싶을 때, Content Negotiation은 세밀한 제어가 필요할 때
- 최대 2개 버전 유지 + 6~12개월 마이그레이션 기간이 실무 표준
- Spring Framework 7부터 빌트인 버저닝 지원으로 구현이 간편해짐
- 가장 좋은 버저닝 전략은 "Breaking Change를 만들지 않는 것"