API 버전 관리에 URL과 헤더를 사용하는 방법

API 버전 관리에 URL과 헤더를 사용하는 방법

한눈에 보기

URL 버전은 라우팅과 관찰이 쉽고 헤더 버전은 URL을 깨끗하게 유지한다. 중요한 것은 호환성 정책과 종료 일정을 명시하는 것이다.

목차

문제가 되는 상황

웹 프론트엔드는 서버와 동시에 배포할 수 있지만 앱스토어의 모바일 앱, 파트너 연동, 고객이 직접 설치한 SDK는 즉시 업데이트되지 않는다. 서버 응답 필드의 의미를 바꾸면 몇 달 전 앱이 같은 URL에서 새 응답을 받아 갑자기 깨질 수 있다.

버전 관리는 URL에 v1을 붙이는 규칙 하나가 아니다. 어떤 변경을 호환 가능하다고 볼지, 구버전을 얼마나 병행할지, 내부 코드에서 버전 차이를 어디에 둘지, 남은 소비자를 어떻게 찾고 종료할지를 포함하는 생명주기 관리다.

이 글의 예제에 관하여

주문 API, 버전 날짜와 종료 일정은 설명을 위한 가상 값이다. 실제 서비스 버전 정책이나 고객 사용량을 사용하지 않았다.

버전이 필요한 변경부터 구분한다

모든 필드 변경에 새 major version이 필요한 것은 아니다. client 계약을 깨뜨리는지를 기준으로 본다.

변경 일반적인 판단 주의점
선택적 응답 필드 추가 호환 가능할 수 있음 client가 unknown field를 거부하면 깨짐
필수 요청 필드 추가 breaking 구 client가 값을 보낼 수 없음
필드 삭제·이름 변경 breaking 역직렬화 실패 가능
문자열 enum 값 추가 breaking일 수 있음 exhaustive switch client가 깨짐
숫자 단위 변경 breaking 타입은 같아도 의미가 바뀜
정렬 기본값 변경 제품 계약에 따라 breaking 페이지 중복·UI 순서 변화
오류 코드 세분화 client 분기 방식에 따라 다름 fallback 정책 필요

다음 변경은 JSON 모양은 같지만 의미가 깨진다.

{ "amount": 32000 }

amount가 원 단위 정수에서 소수 통화 단위로 바뀌거나 세전 금액에서 최종 결제 금액으로 바뀌면 타입 검사로 발견할 수 없다. 스키마뿐 아니라 의미와 단위를 API 설명에 고정한다.

호환 가능한 변경의 기준도 client 계약에 포함한다. “client는 모르는 응답 필드를 무시해야 한다”, “모르는 enum은 unknown fallback으로 처리한다” 같은 forward compatibility 원칙이 있어야 필드 추가를 안전하다고 말할 수 있다.

URL 버전의 장단점

가장 눈에 보이는 방식은 경로에 major version을 넣는 것이다.

GET /v1/orders/42 HTTP/1.1
GET /v2/orders/42 HTTP/1.1

장점은 라우팅, access log, CDN cache key, 문서 주소에서 버전이 명확하다는 것이다. 브라우저와 일반 HTTP 도구로도 쉽게 호출할 수 있다. 단점은 URL이 바뀌므로 같은 도메인 리소스가 버전마다 다른 URI를 가지며, route와 링크 관리가 늘어난다는 점이다.

URL 버전을 사용한다고 controller 전체를 복사할 필요는 없다.

router.get("/v1/orders/:id", async (request, response) => {
  const order = await orderService.getById(request.params.id);
  return response.json(presentOrderV1(order));
});

router.get("/v2/orders/:id", async (request, response) => {
  const order = await orderService.getById(request.params.id);
  return response.json(presentOrderV2(order));
});

업무 모델은 공유하고 버전별 request parser와 presenter를 경계에 둔다. V1 요구 때문에 내부 도메인 전체가 구 필드명에 묶이지 않게 한다.

헤더와 미디어 타입 버전의 장단점

경로를 유지하면서 custom header 또는 Accept media type parameter로 표현을 선택할 수 있다.

GET /orders/42 HTTP/1.1
API-Version: 2
GET /orders/42 HTTP/1.1
Accept: application/vnd.example.order+json; version=2

URI를 안정적으로 유지하고 같은 리소스의 표현 협상이라는 의미를 살릴 수 있다. 반면 브라우저 주소창에서 시험하기 어렵고, gateway·CDN·로그·문서 도구가 버전 헤더를 명시적으로 인식해야 한다. 응답이 버전 헤더에 따라 달라지면 cache key에 반영하고 적절한 Vary를 검토한다.

Vary: Accept

custom header를 cache key에 넣지 않은 CDN이 V1 응답을 V2 client에 제공하면 심각한 오류가 난다. 현재 인프라와 개발자 경험이 header 협상을 충분히 지원하는지 확인한다.

날짜 기반 버전과 클라이언트 협상

major 숫자 대신 계약 기준 날짜를 보낼 수도 있다.

API-Version: 2026-09-01

날짜는 client가 어느 시점의 동작을 기대하는지 명확하지만, 매 배포 날짜가 새 버전이 되어서는 안 된다. 호환성 경계가 생긴 날짜만 지원하고 허용 목록에 없는 값은 명확히 거부한다.

const supportedVersions = new Set(["2025-03-01", "2026-09-01"]);

function resolveApiVersion(request: Request): string {
  const requested = request.header("API-Version") ?? "2026-09-01";

  if (!supportedVersions.has(requested)) {
    throw new UnsupportedApiVersionError(requested);
  }

  return requested;
}

헤더가 없을 때 최신 버전을 자동 선택하면 오래된 client가 어느 날 새 계약을 받게 될 수 있다. 공개 API라면 명시적 버전을 요구하거나 안정적인 기본 버전을 오래 유지한다. SDK가 자동으로 버전을 보내도록 만들 수도 있다.

내부 모델과 버전별 표현을 분리한다

버전 분기를 service 곳곳에 넣으면 어느 코드가 V1 동작을 유지하는지 추적하기 어렵다.

// 피하고 싶은 형태
if (apiVersion === 1) {
  // service 중간의 구 동작
} else {
  // 새 동작
}

경계 adapter가 버전별 입력을 공통 command로 변환하고, 공통 result를 버전별 응답으로 바꾼다.

flowchart LR
    V1[V1 Request] --> P1[V1 Parser]
    V2[V2 Request] --> P2[V2 Parser]
    P1 --> C[Domain Command]
    P2 --> C
    C --> S[Shared Service]
    S --> R[Domain Result]
    R --> O1[V1 Presenter]
    R --> O2[V2 Presenter]

예를 들어 V1이 amount 하나를 받고 V2가 통화와 minor unit을 받는다면 parser에서 명시적으로 변환한다.

function parseCreateOrderV1(body: V1Body): CreateOrderCommand {
  return {
    amountMinor: body.amount,
    currency: "KRW",
  };
}

function parseCreateOrderV2(body: V2Body): CreateOrderCommand {
  return {
    amountMinor: body.money.minor,
    currency: body.money.currency,
  };
}

V1과 V2의 실제 업무 규칙이 근본적으로 다르면 억지로 하나의 service에 합치지 않고 정책 객체나 use case를 분리한다. 공유와 격리의 경계를 명확히 하는 것이 중요하다.

두 버전을 병행 운영하는 방법

새 버전을 한 번에 다시 작성하면 기존의 숨은 동작을 놓칠 수 있다. 다음 순서로 전환할 수 있다.

  1. V1 계약 테스트와 실제 사용량을 확보한다.
  2. 공통 도메인 로직과 V1 adapter를 분리한다.
  3. V2 adapter와 문서를 추가한다.
  4. 내부 client 또는 일부 파트너로 canary한다.
  5. 응답 차이와 오류율을 비교한다.
  6. V2를 신규 client 기본값으로 만든다.
  7. V1 deprecation과 종료 일정을 시작한다.

읽기 API는 같은 입력에 대해 V1/V2 presenter 결과를 비교하는 shadow 요청을 사용할 수 있다. 쓰기 API를 두 번 실행하면 중복 부수 효과가 생기므로 command를 한 번 처리한 결과만 두 표현으로 비교해야 한다.

Deprecation과 Sunset 전달하기

deprecated는 “더 이상 권장하지 않지만 아직 동작함”이고 sunset은 “이 시점 이후 응답하지 않을 예정”이라는 다른 단계다. 응답 header와 문서로 client에 알릴 수 있다.

HTTP/1.1 200 OK
Deprecation: @1790812800
Sunset: Tue, 01 Jun 2027 00:00:00 GMT
Link: <https://developer.example.test/migrations/orders-v2>;
      rel="deprecation"; type="text/html"

Deprecation은 structured date 형식이고 Sunset은 HTTP-date를 사용하므로 임의 문자열을 만들지 않는다. 종료 예정일은 deprecation 시점보다 이르면 안 된다. header만 추가하고 행동을 바꾸지 않으며, migration 문서에는 필드 매핑, SDK 버전, 샘플, 지원 채널을 제공한다.

앱 사용자에게 API header가 직접 보이지 않을 수 있으므로 개발자 이메일, dashboard 경고, SDK build warning 등 여러 채널을 함께 사용한다.

사용량을 확인하고 종료한다

구버전 트래픽이 0처럼 보여도 batch job이나 월말 작업이 남아 있을 수 있다. 호출량뿐 아니라 최근 마지막 호출, client ID, SDK 버전, endpoint별 사용을 본다.

{
  "event": "deprecated_api_call",
  "apiVersion": "v1",
  "clientId": "partner-example-7",
  "route": "GET /orders/:id",
  "userAgent": "example-sdk/2.4",
  "traceId": "trace-example-81"
}

토큰·개인정보를 로그에 넣지 않고 보존 기간과 접근 권한을 정한다. 종료 전에는 다음을 확인한다.

구버전을 제거한 뒤 route가 우연히 최신 handler로 fallback되지 않도록 명시적인 종료 응답을 두는 편이 안전하다.

계약 테스트와 문서 생성

각 버전의 OpenAPI 문서를 별도 artifact로 고정하고 schema diff로 breaking change 후보를 찾는다. 자동 도구는 의미 변경까지 알 수 없으므로 리뷰를 보조할 뿐이다.

it("V1 주문 응답은 기존 amount 필드를 유지한다", async () => {
  const response = await request(app)
    .get("/v1/orders/order-42")
    .expect(200);

  expect(response.body).toEqual({
    id: "order-42",
    amount: 32000,
    status: "paid",
  });
});

consumer-driven contract test를 사용한다면 실제 중요한 client 계약을 CI에서 확인할 수 있다. 하지만 등록되지 않은 client까지 자동으로 보호하지는 않으므로 공개 문서와 호환성 정책이 계속 필요하다. 스키마 운영은 OpenAPI 스키마를 계약으로 유지하는 방법에서 이어서 다룬다.

실전 점검 목록

API 버전 생명주기

  • breaking change의 기준이 필드 모양뿐 아니라 의미까지 정의되어 있는가?
  • URL 또는 헤더 방식이 gateway·cache·문서 도구에서 구분되는가?
  • 버전별 parser·presenter와 공통 도메인 로직이 분리되어 있는가?
  • 구버전 호출량을 client와 endpoint별로 측정하는가?
  • deprecation 날짜, sunset 날짜, migration 문서가 제공되는가?
  • 계약 테스트와 schema diff가 CI에 있는가?
  • 종료와 rollback runbook이 있는가?

URL 버전은 라우팅과 관찰이 쉽고 헤더 버전은 URL을 깨끗하게 유지한다. 중요한 것은 호환성 정책과 종료 일정을 명시하는 것이다.

결론

API 버전 관리는 breaking change를 격리하고 구 client가 이동할 시간을 제공하는 계약 생명주기다. URL 버전은 라우팅과 관찰이 쉽고 헤더·미디어 타입 버전은 URI를 유지하지만 cache와 도구 설정이 더 필요하다. 버전별 adapter, 계약 테스트, 사용량 측정, DeprecationSunset, migration 문서와 rollback 절차까지 있어야 안전하게 새 버전을 만들고 구버전을 종료할 수 있다.

관련 노트