API를 수정해야 하는데 기존 클라이언트가 깨지면 안 된다면, 어떻게 해야 할까요?

API 버저닝은 서비스가 성장하면서 반드시 마주하는 문제입니다. 공부하다 보니 버저닝 방식마다 장단점이 명확해서, 상황에 맞는 선택이 중요하다는 걸 느꼈습니다. 각 방식을 비교하고 실무 권장 전략을 정리해보겠습니다.

왜 버저닝이 필요한가

API는 서버와 클라이언트 간의 계약(Contract) 입니다.

JSON
// v1: 이름이 하나의 필드
{ "name": "홍길동" }

// v2: 이름을 성/이름으로 분리 (Breaking Change!)
{ "first_name": "길동", "last_name": "홍" }

이런 변경을 버저닝 없이 하면:

  • 기존 클라이언트가 name 필드를 찾다가 에러
  • 모바일 앱은 업데이트 배포까지 시간이 걸림
  • 외부 파트너 API라면 즉시 장애 발생

하위 호환성이 깨지는 변경(Breaking Change)이 있을 때 새 버전이 필요합니다.

Breaking Change 예시:

  • 필드 이름 변경 또는 삭제
  • 응답 구조 변경
  • 필수 파라미터 추가
  • HTTP 메서드 변경

Non-Breaking Change (버전 변경 불필요):

  • 새 필드 추가 (기존 필드 유지)
  • 새 엔드포인트 추가
  • 선택적 파라미터 추가

URI Path 방식

가장 직관적이고 널리 사용되는 방식입니다. AWS, Google, Twitter 등이 사용합니다.

PLAINTEXT
GET /v1/users
GET /v2/users
JAVA
// 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 방식

PLAINTEXT
GET /users?version=1
GET /users?version=2

** 장점:**

  • URL 경로는 유지, 파라미터로 분기
  • 기본값 설정 가능 (version 생략 시 최신 버전)

** 단점:**

  • 파라미터 누락 시 의도치 않은 버전 호출 가능
  • 캐싱이 복잡해짐 (쿼리 파라미터 포함 여부에 따라)
  • 실무에서 잘 사용하지 않는 방식

Custom Header 방식

HTTP 커스텀 헤더로 버전을 지정합니다. Stripe가 대표적입니다.

PLAINTEXT
GET /users
X-API-Version: 1

GET /users
X-API-Version: 2
JAVA
// 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가 사용합니다.

PLAINTEXT
GET /users
Accept: application/vnd.mycompany.v1+json

GET /users
Accept: application/vnd.mycompany.v2+json
JAVA
// 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 PathQuery ParamCustom HeaderContent Negotiation
가독성높음보통낮음낮음
URL 깔끔함낮음보통높음높음
캐싱 용이성높음보통낮음낮음
구현 복잡도낮음낮음보통높음
테스트 편의성높음높음낮음낮음
대표 사용처AWS, Google-StripeGitHub

시맨틱 버저닝과의 관계

시맨틱 버저닝(SemVer)은 MAJOR.MINOR.PATCH 형식입니다.

  • MAJOR — 하위 호환성이 깨지는 변경 (1.x → 2.x)
  • MINOR — 하위 호환성 유지하며 기능 추가 (1.1 → 1.2)
  • PATCH — 버그 수정 (1.1.1 → 1.1.2)

API 버저닝에서는 보통 MAJOR 버전만 URL에 반영 합니다:

PLAINTEXT
/v1/users  ← MAJOR 버전
/v2/users  ← Breaking Change가 있을 때만 올림

MINOR, PATCH 변경은 같은 버전 안에서 하위 호환성을 유지하며 반영합니다.

버전 폐기(Deprecation) 전략

새 버전을 출시한 후 이전 버전을 무한정 유지할 수는 없습니다.

단계별 폐기 프로세스

PLAINTEXT
1. 공지 (6~12개월 전)
  - API 응답에 Deprecation 헤더 추가
  - 문서에 폐기 일정 공지

2. 경고 기간
  - 이전 버전 호출 시 Sunset 헤더 포함
  - 사용량 모니터링

3. 폐기
  - 410 Gone 또는 301 Redirect 응답
JAVA
// 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 버저닝을 내장 지원합니다.

JAVA
// Spring Framework 7에서의 버저닝 (빌트인 지원)
@RestController
@RequestMapping("/users")
@ApiVersion("1")
public class UserV1Controller {
    // /v1/users 로 자동 매핑
}

@RestController
@RequestMapping("/users")
@ApiVersion("2")
public class UserV2Controller {
    // /v2/users 로 자동 매핑
}

기존에는 커스텀 RequestMappingHandlerMapping을 만들어야 했지만, 이제 프레임워크 레벨에서 지원하므로 보일러플레이트가 크게 줄어듭니다.

실무 권장 전략

대부분의 팀에게 가장 현실적인 전략입니다:

  1. URI Path 방식으로 시작 — 가독성, 디버깅, 캐싱 모두 유리
  2. MAJOR 버전만 URL에 반영/v1/, /v2/
  3. Non-Breaking Change는 같은 버전 내에서 처리 — 새 필드 추가, 선택적 파라미터
  4. ** 최대 2개 버전 유지** — 현재 + 이전
  5. ** 마이그레이션 기간 6~12개월** — Deprecation 헤더로 사전 공지
  6. API 변경 로그 관리 — 클라이언트가 무엇이 바뀌었는지 파악 가능하게

버저닝 방식보다 더 중요한 것은 "하위 호환성을 최대한 유지하는 설계"입니다. Breaking Change를 줄이면 버전을 올릴 일 자체가 줄어듭니다.

정리

  • API 버저닝은 Breaking Change가 있을 때 기존 클라이언트를 보호하기 위한 전략
  • URI Path 방식 이 가장 실용적 — 가독성, 캐싱, 테스트 편의성 모두 우수
  • Header 방식은 URL을 깔끔하게 유지하고 싶을 때, Content Negotiation은 세밀한 제어가 필요할 때
  • 최대 2개 버전 유지 + 6~12개월 마이그레이션 기간이 실무 표준
  • Spring Framework 7부터 빌트인 버저닝 지원으로 구현이 간편해짐
  • 가장 좋은 버저닝 전략은 "Breaking Change를 만들지 않는 것"
댓글 로딩 중...