Spring Boot API Versioning 전략 — URI, 헤더, 미디어 타입 중 무엇을 골랐는가
Spring Boot 4.0의 공식 API Versioning 지원을 계기로, 실무에서 URI 방식과 헤더 방식 사이에서 고민한 과정과 기존 API를 점진적으로 버전 관리한 경험을 정리했습니다.

운영 중인 서비스에서 주문 API의 응답 구조를 바꿔야 할 일이 생겼습니다. 기존 클라이언트가 쓰고 있는 필드를 제거해야 했는데, 한 번에 바꾸면 앱이 터질 게 분명했습니다.
"버전을 나누면 되지"라고 가볍게 말했지만, 막상 어떻게 나눌지 기준이 없었습니다.
URL에 /v2를 붙일지, 헤더로 분리할지, 미디어 타입을 쓸지 — 검색하면 세 가지 방법이 전부 "정답"처럼 나왔기 때문입니다.
마침 Spring Boot 4.0에서 API Versioning을 공식 지원하기 시작했다는 소식을 접하면서, 이참에 제대로 정리해 보기로 했습니다.
Spring Boot 4.0이 Versioning을 공식 지원한 배경
Spring Boot는 오랫동안 API 버전 관리에 대해 별다른 공식 입장을 내놓지 않았습니다.
@RequestMapping의 headers나 produces 속성을 조합해서 직접 구현하거나, 커스텀 어노테이션을 만들어 쓰는 게 보통이었습니다.
문제는 팀마다 구현 방식이 달라서, 같은 Spring Boot 프로젝트인데도 버전 관리 코드가 전혀 다르게 생긴 경우가 흔했다는 점입니다.
Spring Boot 4.0에서는 이 문제를 인식하고 @ApiVersion 어노테이션과 ApiVersionConfigurer를 도입했습니다.
핵심은 버전 전략(URI, 헤더, 미디어 타입)을 설정에서 선택하고, 컨트롤러에서는 선언적으로 버전만 명시하는 구조입니다.
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void configureApiVersioning(ApiVersionConfigurer configurer) {
configurer
.useUriPath() // URI 방식: /api/v1/orders
.prefix("v")
.defaultVersion("1");
}
}설정 한 곳에서 전략을 바꾸면 컨트롤러 코드를 수정하지 않아도 되는 구조였기 때문에, 도입 자체는 꽤 깔끔하다고 느꼈습니다.
세 가지 전략을 직접 비교해 봤다
공식 지원이 나왔으니 세 가지 전략을 실제로 적용해 보면서 차이를 체감해 봤습니다.
URI Path 방식
가장 직관적인 방식입니다. URL만 보면 어떤 버전인지 바로 알 수 있습니다.
@RestController
@RequestMapping("/api/orders")
public class OrderController {
@GetMapping
@ApiVersion("1")
public ResponseEntity<List<OrderV1Response>> getOrdersV1() {
return ResponseEntity.ok(orderService.getOrdersV1());
}
@GetMapping
@ApiVersion("2")
public ResponseEntity<List<OrderV2Response>> getOrdersV2() {
return ResponseEntity.ok(orderService.getOrdersV2());
}
}호출은 /api/v1/orders, /api/v2/orders처럼 됩니다.
브라우저에서 테스트하기 쉽고, 로그에서도 어떤 버전이 호출됐는지 바로 보여서 운영 모니터링이 편했습니다.
Header 방식
URL은 깔끔하게 유지하고, X-API-Version 같은 커스텀 헤더로 버전을 구분하는 방식입니다.
configurer
.useHeader("X-API-Version")
.defaultVersion("1");REST 원칙 관점에서는 리소스 경로가 변하지 않으니 더 깔끔합니다. 하지만 브라우저에서 바로 테스트하기 어렵고, Swagger에서 헤더를 매번 넣어줘야 해서 프론트와 협업할 때 혼선이 생겼습니다.
미디어 타입(Content Negotiation) 방식
Accept: application/vnd.myapp.v2+json처럼 미디어 타입에 버전을 녹이는 방식입니다.
configurer
.useMediaType("vnd.myapp")
.defaultVersion("1");이론적으로는 가장 RESTful하다고 불리지만, 실무에서는 클라이언트가 Accept 헤더를 정확히 세팅해야 하고, CDN이나 프록시에서 캐시 키가 복잡해지는 문제가 있었습니다.
비교 정리
| 항목 | URI Path | Header | Media Type |
|---|---|---|---|
| 직관성 | 높음 (URL만 보면 됨) | 낮음 (헤더 확인 필요) | 낮음 (Accept 확인 필요) |
| 캐싱 | 쉬움 (URL이 다름) | 주의 필요 (Vary 헤더) | 주의 필요 (Vary 헤더) |
| 브라우저 테스트 | 바로 가능 | 불가 (도구 필요) | 불가 (도구 필요) |
| REST 원칙 준수 | 논란 있음 | 깔끔 | 가장 RESTful |
| 프론트 협업 | 쉬움 | 약간 번거로움 | 번거로움 |
| Swagger 지원 | 자연스러움 | 추가 설정 필요 | 추가 설정 필요 |
결국 URI Path 방식을 선택한 이유
처음에는 "REST 원칙을 제대로 지키자"는 생각에 헤더 방식을 시도했습니다. URL이 깔끔하게 유지되니 기분은 좋았지만, 실제 협업에서 문제가 꽤 빨리 드러났습니다.
프론트 개발자가 API를 테스트할 때마다 "이 헤더 뭐 넣어야 하죠?"라는 질문이 반복됐고, API 문서를 전달할 때도 헤더 규칙을 별도로 설명해야 했습니다. QA팀에서도 브라우저로 바로 확인할 수 없어서 불편하다는 피드백이 왔습니다.
결국 URI Path 방식이 이론적으로 완벽하지 않더라도, 팀 전체가 가장 빠르게 이해하고 사용할 수 있는 전략이라는 결론에 도달했습니다. GitHub, Stripe, Google Maps 같은 대형 서비스들도 URI 방식을 쓰고 있다는 점도 마음을 편하게 해줬습니다.
기존 API를 점진적으로 버전 관리한 과정
새로 만드는 API는 처음부터 @ApiVersion을 붙이면 되지만, 문제는 이미 운영 중인 수십 개의 API였습니다.
한 번에 전부 v1을 붙이면 기존 클라이언트가 모두 깨지기 때문에, 단계적으로 진행했습니다.
1단계: 기본 버전 설정으로 기존 API 보호
먼저 defaultVersion("1")을 설정해서, 버전을 명시하지 않은 모든 요청이 v1으로 라우팅되게 했습니다.
이렇게 하면 기존 클라이언트가 /api/orders로 호출해도 정상 동작합니다.
configurer
.useUriPath()
.prefix("v")
.defaultVersion("1")
.addFallbackMapping(true); // /api/orders → /api/v1/orders로 폴백2단계: 변경이 필요한 API부터 v2 추가
전체를 한꺼번에 건드리지 않고, 응답 구조가 바뀌어야 하는 API만 v2를 추가했습니다.
@GetMapping
@ApiVersion("1")
public ResponseEntity<OrderV1Response> getOrder(@PathVariable Long id) {
// 기존 응답 구조 유지
return ResponseEntity.ok(orderService.getOrderV1(id));
}
@GetMapping
@ApiVersion("2")
public ResponseEntity<OrderV2Response> getOrderV2(@PathVariable Long id) {
// 새 응답 구조 — 불필요한 필드 제거, 중첩 구조 정리
return ResponseEntity.ok(orderService.getOrderV2(id));
}이전에 응답 구조를 설계할 때 공통 래퍼를 만들어 뒀던 덕분에, v1과 v2의 차이를 응답 DTO 수준에서만 관리할 수 있었습니다.
3단계: 클라이언트 마이그레이션과 v1 사용량 모니터링
v2를 배포한 뒤에는 v1 호출량을 추적했습니다. Interceptor를 하나 두고 버전별 호출 횟수를 로그에 남겼습니다.
@Component
public class ApiVersionLoggingInterceptor implements HandlerInterceptor {
private static final Logger log = LoggerFactory.getLogger(ApiVersionLoggingInterceptor.class);
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response,
Object handler) {
String version = request.getAttribute("apiVersion") != null
? request.getAttribute("apiVersion").toString()
: "unknown";
String uri = request.getRequestURI();
log.info("[API Version] version={}, uri={}", version, uri);
return true;
}
}v1 호출량이 충분히 줄어든 시점에 Deprecated 응답 헤더를 추가하고, 일정 기간 후 v1을 제거하는 순서로 진행했습니다.
4단계: Deprecated 버전에 경고 헤더 추가
바로 제거하지 않고, 응답에 Sunset 헤더와 Deprecation 헤더를 추가해서 클라이언트에 사전 고지했습니다.
@Component
public class DeprecatedVersionFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
filterChain.doFilter(request, response);
String version = (String) request.getAttribute("apiVersion");
if ("1".equals(version)) {
response.setHeader("Deprecation", "true");
response.setHeader("Sunset", "2026-07-01");
response.setHeader("Link", "</api/v2/docs>; rel=\"successor-version\"");
}
}
}이렇게 하면 클라이언트 개발자가 응답 헤더를 통해 마이그레이션 시점을 인지할 수 있고, 갑자기 API가 사라지는 상황을 예방할 수 있었습니다.
버전 관리하면서 실수했던 것들
과정이 매끄럽기만 했던 건 아닙니다. 몇 가지 실수가 있었습니다.
첫 번째, 버전을 너무 자주 올릴 뻔했습니다. 필드 하나 추가할 때마다 v3, v4를 만들면 관리가 금방 지옥이 됩니다. 결국 "하위 호환성이 깨지는 변경(Breaking Change)만 새 버전"이라는 기준을 세웠습니다. 필드 추가처럼 기존 클라이언트에 영향이 없는 변경은 같은 버전 안에서 처리했습니다.
두 번째, 예외 응답 구조를 버전별로 다르게 내려보낸 적이 있습니다.
v1은 message 필드, v2는 error.detail 필드로 내려갔는데, 프론트에서 에러 처리 코드가 두 벌이 필요해지면서 오히려 더 복잡해졌습니다.
예외 응답만큼은 버전과 무관하게 통일하는 편이 낫다는 걸 그때 배웠습니다.
세 번째, 버전별 컨트롤러를 별도 클래스로 분리했다가 코드 중복이 심해졌습니다.
지금은 같은 컨트롤러 안에서 @ApiVersion으로 나누고, 비즈니스 로직은 서비스 계층에서 DTO 변환으로만 분기하는 구조를 선호합니다.
지금 쓰고 있는 버전 관리 규칙
여러 시행착오를 거쳐서 아래 규칙을 팀 내에서 정리했습니다.
| 항목 | 규칙 |
|---|---|
| 버전 올리는 기준 | Breaking Change가 있을 때만 |
| 전략 | URI Path (/api/v{n}/...) |
| 기본 버전 | defaultVersion("1") — 버전 미명시 요청 대응 |
| 예외 응답 | 버전 무관하게 통일 |
| Deprecated 고지 | Sunset, Deprecation 헤더 + 최소 3개월 유예 |
| 제거 시점 | v(n-1) 호출량이 전체의 1% 미만일 때 |
| 동시 유지 버전 수 | 최대 2개 (현재 + 이전) |
마무리
처음에는 API Versioning을 "URL에 숫자 붙이는 일" 정도로 가볍게 생각했습니다. 하지만 실제로 운영 중인 API에 버전을 도입하면서, 이건 기술 선택보다 팀과 클라이언트 사이의 약속을 설계하는 일에 더 가깝다는 걸 느꼈습니다.
어떤 전략이 이론적으로 우아한지보다, 팀원 모두가 혼란 없이 쓸 수 있는 방식이 결국 오래갑니다. Spring Boot 4.0이 공식 지원을 추가한 덕분에 보일러플레이트는 많이 줄었지만, "언제 버전을 올리고, 언제 제거하고, 어떻게 고지할 것인가"라는 운영 기준은 여전히 팀이 직접 정해야 할 몫이라는 걸 배웠습니다.
결국 좋은 API 버전 관리는 코드보다 커뮤니케이션이 더 중요했습니다.