Claude Code로 안전한 API 버전 관리를 설계하는 실전 가이드
Claude Code로 OpenAPI, 호환성 테스트, 폐기 헤더, 롤아웃까지 안전한 API 버전 관리를 설계합니다.
API 버전 관리는 라우트에/v2를 붙이는 작업이 아닙니다. 이미 API를 사용하는 모바일 앱, 파트너 연동, 내부 서비스, Webhook 소비자, 배치 작업에 대한 호환성 약속입니다. 응답 필드 하나를 이름만 바꾸어도 새 API는 깔끔해 보이지만 오래된 클라이언트는 실패할 수 있습니다.
Claude Code 공식 문서에 설명된 것처럼 Claude Code는 코드베이스를 읽고 파일을 수정하며 명령을 실행할 수 있습니다. 그래서 API 개선에 유용하지만, “API를 현대화해 줘” 같은 모호한 요청을 주면 기존 소비자보다 새 구조를 우선할 수 있습니다. 안전하게 쓰려면 구현 전에 계약, 금지할 변경, 검증 명령, 배포 계획을 함께 전달해야 합니다.
이 글은 URL 경로, 요청 헤더, 미디어 타입 버전 관리의 장단점, OpenAPI 계약, 하위 호환 변환, Deprecation과Sunset 헤더, 변경 로그 정책, consumer test, 롤아웃과 폴백, 그리고 Claude Code가 breaking change를 만들지 않도록 하는 프롬프트를 다룹니다. 공식 근거는 OpenAPI Specification, RFC 9745 Deprecation Header, RFC 8594 Sunset Header를 기준으로 합니다.
구현 배경은 Claude Code API 개발, 리뷰 관점은 Claude Code 코드 리뷰, 릴리스 정책은 Changesets 버전 관리과 함께 보면 흐름이 자연스럽습니다.
먼저 호환성 계약을 고정한다
버전 관리의 목적은 오래된 코드를 영원히 유지하는 것이 아니라, 소비자가 예측 가능한 일정으로 이전할 수 있게 하는 것입니다. Masa가 작은 주문 API에서 테스트했을 때, 프롬프트가 “v2를 추가하고 customer 필드를 정리해 줘” 정도였을 때는 새 대시보드는 통과했지만 오래된 CSV 내보내기가 깨졌습니다. 빠진 것은 코드가 아니라 조건이었습니다. v1 응답 형상 유지, 폐기 날짜 공개, consumer test 추가, 마이그레이션 문서 갱신이 필요했습니다.
대표적인 사용 사례는 세 가지입니다.
| 사용 사례 | 중요한 제약 | 보통 적합한 방식 |
|---|---|---|
| 모바일 앱용 공개 REST API | 오래된 앱 버전이 몇 달간 남아 있음 | URL 경로 버전 |
| B2B SaaS 파트너 API | 고객사가 자기 일정으로 이전함 | URL 경로 또는 명시적 헤더 |
| 내부 마이크로서비스 | 클라이언트를 함께 업그레이드하기 쉬움 | 헤더 또는 미디어 타입 |
Claude Code에 구현을 맡기기 전에 현재 소비자, 최소 지원 기간, breaking change의 정의, 관찰할 지표를 적습니다. breaking change는 라우트 삭제만이 아닙니다. 응답 필드 이름 변경, 새 필수 요청 필드, 오류 포맷 변경, 기본 정렬 변경, 페이지네이션 구조 변경도 오래된 클라이언트를 깨뜨릴 수 있습니다.
URL, 헤더, 미디어 타입을 비교한다
버전을 어디에 둘지는 라우팅, 캐시, 문서, SDK 생성, 지원 업무에 영향을 줍니다. 대부분의 공개 API에서는 URL 경로 방식이 실용적인 기본값입니다. 로그에 잘 보이고, API Gateway 설정이 단순하며, 사람이curl로 테스트하기 쉽기 때문입니다. 단점은 URI에 제품 버전이 들어가서/api/v1/orders/123과/api/v2/orders/123이 별도 리소스처럼 보인다는 점입니다.
| 방식 | 예 | 장점 | 흔한 실패 |
|---|---|---|---|
| URL 경로 | /api/v1/orders | 라우팅, 문서, 디버깅이 명확함 | 오래된 경로가 남고 라우터 중복이 늘어남 |
| 커스텀 헤더 | API-Version: 2 | URL을 안정적으로 유지 | 헤더 누락이 쉽고 캐시는Vary: API-Version이 필요 |
| 미디어 타입 | Accept: application/vnd.acme.orders.v2+json | 표현 형식을 HTTP 협상에 맞춤 | OpenAPI, SDK 생성, 고객 지원이 복잡해짐 |
미디어 타입 방식을 쓰면 중간 캐시가 v1과 v2를 섞지 않도록Vary: Accept를 보내야 합니다. 커스텀 헤더 방식이면Vary: API-Version을 보냅니다. URL 방식이라도 응답 호환성이 바뀌면 OpenAPI에서 v1과 v2를 별도 계약으로 다루는 편이 안전합니다.
OpenAPI를 단일 기준으로 둔다
OpenAPI는 HTTP API의 경로, 메서드, 파라미터, 요청 본문, 응답, 인증을 기계가 읽을 수 있게 적는 형식입니다. 쉽게 말해 구현 전에 쓰는 API 약속입니다. openapi 필드는 OpenAPI 사양 버전이고, info.version은 팀의 API 문서 버전입니다. Claude Code에 이 둘을 혼동하지 말라고 명시합니다.
아래 예시는 v1을 문서에 남기고 deprecated로 표시하면서 v2를 추가합니다. 도구 호환성을 위해openapi: 3.1.0을 사용했지만, 최신 사양을 적용할지는 공식 OpenAPI 문서와 팀의 검증기 지원을 확인한 뒤 결정합니다.
openapi: 3.1.0
info:
title: Acme Orders API
version: 2.0.0
servers:
- url: https://api.example.com
paths:
/api/v1/orders/{orderId}:
get:
operationId: getOrderV1
summary: Get an order in the legacy v1 shape
deprecated: true
x-deprecated-at: "2026-03-31T00:00:00Z"
x-sunset-at: "2026-12-31T23:59:59Z"
parameters:
- name: orderId
in: path
required: true
schema:
type: string
responses:
"200":
description: Legacy order response
headers:
Deprecation:
schema:
type: string
description: RFC 9745 structured date, for example @1774915200
Sunset:
schema:
type: string
description: RFC 8594 HTTP-date when v1 may stop responding
content:
application/json:
schema:
$ref: "#/components/schemas/OrderV1Envelope"
/api/v2/orders/{orderId}:
get:
operationId: getOrderV2
summary: Get an order in the current v2 shape
parameters:
- name: orderId
in: path
required: true
schema:
type: string
responses:
"200":
description: Current order response
content:
application/json:
schema:
$ref: "#/components/schemas/OrderV2Envelope"
components:
schemas:
OrderV1Envelope:
type: object
required: [data]
properties:
data:
type: object
required: [id, customerName, totalCents, currency]
properties:
id:
type: string
customerName:
type: string
totalCents:
type: integer
currency:
type: string
OrderV2Envelope:
type: object
required: [data]
properties:
data:
type: object
required: [id, customer, amount, status]
properties:
id:
type: string
customer:
type: object
required: [displayName]
properties:
displayName:
type: string
amount:
type: object
required: [value, currency]
properties:
value:
type: integer
currency:
type: string
status:
type: string
enum: [paid, shipped]
이 YAML을 먼저 Claude Code에 읽히고 구현을 요청합니다. 지시는 구체적이어야 합니다. v1 필드를 삭제하지 말 것, v1 상태 코드를 바꾸지 말 것, 계약 변경이 있으면 테스트와 CHANGELOG를 함께 갱신할 것.
Node로 하위 호환 변환을 구현한다
다음 TypeScript 서버는 Node 내장 모듈만 사용합니다. api-versioning-demo.ts로 저장하면 URL 경로,API-Version 헤더,Accept 미디어 타입을 모두 시험할 수 있습니다. v1은 기존 응답을 유지하고, v2는 현재 응답을 반환하며, v1에는 폐기 관련 헤더를 추가합니다.
import { createServer } from "node:http";
import { parse } from "node:url";
type ApiVersion = "v1" | "v2";
type OrderRow = {
id: string;
customerName: string;
totalCents: number;
currency: "JPY" | "USD";
status: "paid" | "shipped";
createdAt: string;
};
const orders = new Map<string, OrderRow>([
[
"o_100",
{
id: "o_100",
customerName: "Masa Tanaka",
totalCents: 129800,
currency: "JPY",
status: "paid",
createdAt: "2026-06-02T09:00:00.000Z",
},
],
]);
function detectVersion(req: { headers: Record<string, string | string[] | undefined> }, pathname: string) {
const pathVersion = pathname.match(/^\/api\/(v[12])\//)?.[1] as ApiVersion | undefined;
if (pathVersion) return { version: pathVersion, source: "path" };
const header = req.headers["api-version"];
if (typeof header === "string") {
const normalized = header.startsWith("v") ? header : `v${header}`;
if (normalized === "v1" || normalized === "v2") {
return { version: normalized, source: "header" };
}
throw new Error(`Unsupported API-Version: ${header}`);
}
const accept = req.headers.accept;
if (typeof accept === "string") {
const mediaMatch = accept.match(/application\/vnd\.acme\.orders\.v([12])\+json/);
if (mediaMatch) {
return { version: `v${mediaMatch[1]}` as ApiVersion, source: "media-type" };
}
}
return { version: "v1" as ApiVersion, source: "default" };
}
function orderIdFrom(pathname: string) {
return pathname.match(/^\/api\/(?:v[12]\/)?orders\/([^/]+)$/)?.[1];
}
function toV1(row: OrderRow) {
return {
data: {
id: row.id,
customerName: row.customerName,
totalCents: row.totalCents,
currency: row.currency,
},
};
}
function toV2(row: OrderRow) {
return {
data: {
id: row.id,
customer: { displayName: row.customerName },
amount: { value: row.totalCents, currency: row.currency },
status: row.status,
createdAt: row.createdAt,
},
};
}
function addDeprecationHeaders(res: import("node:http").ServerResponse) {
const deprecatedAt = Math.floor(Date.parse("2026-03-31T00:00:00Z") / 1000);
res.setHeader("Deprecation", `@${deprecatedAt}`);
res.setHeader("Sunset", new Date("2026-12-31T23:59:59Z").toUTCString());
res.setHeader(
"Link",
[
'<https://docs.example.com/api/deprecations/v1-to-v2>; rel="deprecation"; type="text/html"',
'<https://docs.example.com/api/sunset-policy>; rel="sunset"; type="text/html"',
].join(", "),
);
}
function sendJson(res: import("node:http").ServerResponse, status: number, body: unknown) {
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
res.end(JSON.stringify(body, null, 2));
}
const server = createServer((req, res) => {
const pathname = parse(req.url ?? "/").pathname ?? "/";
const orderId = orderIdFrom(pathname);
if (!orderId) {
return sendJson(res, 404, { error: "not_found", message: "Route not found" });
}
let detected: ReturnType<typeof detectVersion>;
try {
detected = detectVersion(req, pathname);
} catch (error) {
return sendJson(res, 400, {
error: "unsupported_version",
message: error instanceof Error ? error.message : "Unsupported API version",
supportedVersions: ["v1", "v2"],
});
}
const row = orders.get(orderId);
if (!row) {
return sendJson(res, 404, { error: "order_not_found", orderId });
}
res.setHeader("Vary", "Accept, API-Version");
res.setHeader("X-API-Version", detected.version);
res.setHeader("X-API-Version-Source", detected.source);
if (detected.version === "v1") {
addDeprecationHeaders(res);
return sendJson(res, 200, toV1(row));
}
return sendJson(res, 200, toV2(row));
});
const port = Number(process.env.PORT ?? 18080);
server.listen(port, () => {
console.log(`API versioning demo: http://localhost:${port}`);
});
npm init -y
npm install -D tsx typescript @types/node
PORT=18080 npx tsx api-versioning-demo.ts &
SERVER_PID=$!
sleep 1
curl -i http://localhost:18080/api/v1/orders/o_100
curl -i -H "API-Version: 2" http://localhost:18080/api/orders/o_100
curl -i -H "Accept: application/vnd.acme.orders.v2+json" http://localhost:18080/api/orders/o_100
kill "$SERVER_PID"
핵심은 변환 계층입니다. v1이 v2 응답을 재사용하고 오래된 클라이언트가 버텨 주기를 기대해서는 안 됩니다. 내부 데이터에서 각 버전의 공개 응답으로 명확히 매핑해야 합니다.
폐기 헤더와 버전 정책을 공개한다
예전 샘플에는Deprecation: true가 많지만, 현재는 RFC 9745의 구조화 Date 값을 써야 합니다. 예를 들어@1774915200 같은 형식입니다. Sunset은 RFC 8594의 HTTP-date로, 리소스가 그 이후 응답하지 않을 수 있음을 알립니다. 헤더는 런타임 신호일 뿐, 마이그레이션 계획 전체를 대신하지 않습니다.
정책은 저장소에 둡니다.
currentApiVersion: v2
minimumSupportWindowMonths: 12
breakingChangeRequires:
- new-major-version
- migration-guide
- consumer-test
- owner-approval
deprecatedVersions:
- version: v1
deprecatedAt: "2026-03-31T00:00:00Z"
sunsetAt: "2026-12-31T23:59:59Z"
replacement: "/api/v2/orders/{orderId}"
migrationGuide: "https://docs.example.com/api/deprecations/v1-to-v2"
CHANGELOG에는 추가, 변경, 폐기, 삭제 예정 항목을 나눠 씁니다. 좋은 항목은 영향을 받는 대상, 바꿔야 할 필드, 대체 엔드포인트, 중단 가능 날짜를 포함합니다. “v1 deprecated” 한 줄은 파트너 팀의 일정 수립에 충분하지 않습니다.
Consumer test로 breaking change를 막는다
consumer test는 소비자가 여전히 필요로 하는 기대값을 표현합니다. Claude Code가 중복처럼 보이는 변환 코드를 정리하려 할 때 특히 유용합니다. 아래 테스트는 v1에customerName이 남아 있고 v2의customer 객체가 실수로 섞이지 않는지 확인합니다.
import assert from "node:assert/strict";
import test from "node:test";
const baseUrl = process.env.API_BASE_URL ?? "http://localhost:18080";
test("v1 keeps the legacy response shape", async () => {
const res = await fetch(`${baseUrl}/api/v1/orders/o_100`);
assert.equal(res.status, 200);
assert.match(res.headers.get("deprecation") ?? "", /^@\d+$/);
assert.match(res.headers.get("sunset") ?? "", /GMT$/);
const body = await res.json();
assert.equal(body.data.customerName, "Masa Tanaka");
assert.equal(body.data.customer, undefined);
});
test("v2 returns the current response shape", async () => {
const res = await fetch(`${baseUrl}/api/orders/o_100`, {
headers: { "API-Version": "2" },
});
assert.equal(res.status, 200);
const body = await res.json();
assert.equal(body.data.customer.displayName, "Masa Tanaka");
assert.equal(body.data.amount.currency, "JPY");
assert.equal(body.data.customerName, undefined);
});
PORT=18080 npx tsx api-versioning-demo.ts &
SERVER_PID=$!
sleep 1
API_BASE_URL=http://localhost:18080 node --test version-contract.test.mjs
kill "$SERVER_PID"
OpenAPI lint를 쓰는 프로젝트라면 같은 검증 흐름에 추가합니다.
npx @redocly/cli lint openapi.yaml
이 명령들이 프롬프트에 들어가면 Claude Code는 통과 기준을 알 수 있습니다. “하위 호환으로 해 줘”라는 말만으로는 기준을 추측해야 합니다.
단계적으로 배포하고 폴백을 준비한다
API 버전 관리 사고는 예측 가능합니다. 데이터베이스 스키마와 응답 형상을 같은 배포에서 바꿔 롤백이 어려워지거나, 실제 v1 트래픽을 보지 않고 sunset 날짜를 정하거나, SDK만 갱신하고 직접 HTTP를 호출하는 사용자를 잊거나, 문서에는 deprecated라고 쓰지만 지표와 알림에는 남은 소비자가 보이지 않는 경우입니다.
배포는 v2 추가, v1 폐기 헤더 추가, 버전별 사용량 측정, 마이그레이션 가이드 공개, SDK 갱신, 파트너 통지, Sunset 시행, v1 삭제 순서로 나눕니다. 폴백은 v2를 꺼도 v1이 동작하는지, 오래된 클라이언트가 새 필드를 무시하는지, DB 변경이 최소한 읽기 호환을 유지하는지 확인해야 합니다.
mkdir -p tmp/version-snapshots
BASE_URL=${BASE_URL:-http://localhost:18080}
for order_id in o_100 missing; do
curl -sS -D "tmp/version-snapshots/${order_id}.v1.headers" \
"$BASE_URL/api/v1/orders/$order_id" \
> "tmp/version-snapshots/${order_id}.v1.json" || true
curl -sS -D "tmp/version-snapshots/${order_id}.v2.headers" \
-H "API-Version: 2" \
"$BASE_URL/api/orders/$order_id" \
> "tmp/version-snapshots/${order_id}.v2.json" || true
done
이 스냅샷을 PR에 첨부하거나 Claude Code에 붙여 넣고 호환성 요약을 요청하면 리뷰어가 행동 차이를 빠르게 볼 수 있습니다.
breaking change를 막는 Claude Code 프롬프트
Claude Code에는 작업뿐 아니라 금지 사항과 검증 명령을 함께 전달합니다.
기존 API에 v2를 추가하세요. OpenAPI 파일을 단일 기준으로 취급하세요. v1 응답 형상, 상태 코드, 폐기 헤더를 변경하지 마세요.
수정 전에 다음을 나열하세요.
- breaking change가 될 수 있는 지점
- v1에서 유지해야 하는 필드
- v2에서 추가 또는 변경되는 필드
- 추가할 consumer test
수정 후 다음을 실행하세요.
- npm test
- npx @redocly/cli lint openapi.yaml
- curl로 v1과 v2 응답 비교
최종 응답에는 호환성 위험, 마이그레이션 가이드에 쓸 내용, 롤백 단계를 포함하세요.
머지 전에는 리뷰 전용 프롬프트를 사용합니다.
이 diff를 API 호환성 리뷰로 확인하세요.
점검 항목:
- v1 필수 응답 필드가 삭제, 이름 변경, 타입 변경되지 않았는가
- 오류 형식, HTTP 상태, 페이지네이션, 정렬 순서가 예기치 않게 바뀌지 않았는가
- Deprecation, Sunset, Link, Vary 헤더가 정책과 맞는가
- OpenAPI, 구현, 테스트, CHANGELOG가 서로 일치하는가
- 롤백이 v1 소비자를 깨뜨리지 않는가
문제가 있으면 파일명과 구체적인 수정안을 제시하세요.
이런 프롬프트는 Claude Code의 목표를 “코드를 더 깨끗하게”가 아니라 “공개 계약을 보호”로 바꿉니다. API에서는 그 차이가 버전 번호 위치보다 더 중요합니다.
마무리
안전한 API 버전 관리는 계약에서 시작합니다. 소비자와 인프라에 맞춰 URL, 헤더, 미디어 타입 중 하나를 고르고, OpenAPI에 v1과 v2를 모두 문서화하며, 명시적 변환 계층을 유지합니다. Deprecation과Sunset 신호, 실행 가능한 CHANGELOG, consumer test, 단계적 롤아웃이 함께 있어야 합니다.
팀에서 Claude Code를 API 개발 흐름에 넣고 싶다면 Claude Code consultation and training에서 API 계약, CI 게이트, 리뷰 프롬프트, 롤아웃 체크리스트를 실제 저장소에 맞게 정리할 수 있습니다. 먼저 개인적으로 시도한다면 무료 치트시트와 이 글의 프롬프트를 작은 API에 적용해 보세요.
위 Node 서버로 실제 확인한 결과, v1과 v2는 같은 내부 데이터를 공유하면서도 서로 다른 공개 응답을 유지할 수 있었습니다. consumer test는 필드 이름 변경을 즉시 잡아냈고, 가장 놓치기 쉬운 부분은 RFC 9745의Deprecation 날짜 형식, 헤더/미디어 타입 방식의Vary, 그리고 OpenAPI, 구현, 테스트, CHANGELOG를 함께 검토하는 절차였습니다.
무료 PDF: Claude Code 치트시트
이메일을 입력하면 명령, 리뷰 습관, 안전한 워크플로를 정리한 PDF를 받을 수 있습니다.
개인정보를 안전하게 관리하며 스팸을 보내지 않습니다.
작성자 소개
Masa
Claude Code 실무 워크플로와 팀 도입을 검증하는 엔지니어입니다.
관련 글
Claude Code Permission Receipt Pattern: 권한, 증거, 롤백을 남기는 운영
Claude Code 작업마다 허용 범위, 승인 경계, 검증 명령, 롤백 메모, Gumroad와 상담 CTA 확인을 남기는 permission receipt 패턴입니다.
Claude Code/Codex 안전 Agent Harness 설계: 권한, 검증, 롤백
Claude Code와 Codex를 안전하게 운영하기 위한 Agent Harness를 권한 정책, 실행 계획, 검증, 복구 계층으로 설계합니다.
Claude Code 서브에이전트 실전 가이드: 기사와 코드 작업을 안전하게 위임하기
Claude Code 서브에이전트로 기사와 코드 작업을 안전하게 나누는 방법. 위임 규칙, 프롬프트, 실패 사례를 정리합니다.