
API 버저닝 전략: URL vs Header vs Content Negotiation
API는 한번 공개하면 마음대로 바꾸지 못한다. 클라이언트를 깨트리지 않으면서 API를 진화시키는 버저닝 전략 4가지를 비교하고, GitHub·Stripe·Twilio의 실제 선택을 분석한다.

API는 한번 공개하면 마음대로 바꾸지 못한다. 클라이언트를 깨트리지 않으면서 API를 진화시키는 버저닝 전략 4가지를 비교하고, GitHub·Stripe·Twilio의 실제 선택을 분석한다.
프링글스 통(Stack)과 맛집 대기 줄(Queue). 가장 기초적인 자료구조지만, 이걸 모르면 재귀 함수도 메시지 큐도 이해할 수 없습니다.

페이스북은 왜 REST API를 버렸을까? 원하는 데이터만 쏙쏙 골라 담는 GraphQL의 매력과 치명적인 단점 (캐싱, N+1 문제) 분석.

로버트 C. 마틴(Uncle Bob)이 제안한 클린 아키텍처의 핵심은 무엇일까요? 양파 껍질 같은 계층 구조와 의존성 규칙(Dependency Rule)을 통해 프레임워크와 UI로부터 독립적인 소프트웨어를 만드는 방법을 정리합니다.

단순히 해시(Hash)만 하면 1초 만에 뚫립니다. 레인보우 테이블 공격을 막기 위해 소금(Salt)과 후추(Pepper)를 치는 원리.

스타트업에서 자주 보는 장면이다.
백엔드 개발자가 응답 구조를 개선했다. 기존 { "user_name": "kim" } 대신 { "user": { "name": "kim", "id": 123 } } 형태로 바꿨다. 훨씬 깔끔하고 확장성 있는 구조다. 배포했다.
그런데 바로 그날 오후, iOS 앱이 터졌다. 모바일 개발자가 response.user_name으로 직접 접근하고 있었던 것이다. 안드로이드도 터졌다. 외부 파트너사 API 연동도 터졌다. 슬랙에 불이 났다.
이게 API 버저닝이 필요한 이유다.
API는 계약(Contract)이다. 한번 공개한 API는 마음대로 바꾸면 안 된다. 클라이언트가 의존하고 있기 때문이다. 근데 서비스는 계속 발전해야 한다. 이 두 요구사항 사이의 긴장을 해소하는 방법이 버저닝이다.
모든 변경이 버전 업그레이드를 요구하지는 않는다.
string → number)룰 오브 섬: 클라이언트 코드를 수정하지 않으면 기존처럼 동작해야 하면 버전 유지, 클라이언트 코드 수정 없이는 기존처럼 동작하지 않으면 새 버전이 필요하다.
가장 직관적이고 많이 사용되는 방법이다.
GET /api/v1/users
GET /api/v2/users
POST /api/v1/orders
POST /api/v2/orders
실제 예시 — GitHub REST API:
# GitHub API v3 (현재 버전)
curl https://api.github.com/repos/facebook/react
# GitHub 초기에는 v1, v2도 있었음
# 현재는 REST v3와 GraphQL이 공존
장점:
// src/routes/v1/users.ts
const v1Router = express.Router();
v1Router.get('/users', async (req, res) => {
const users = await getUsersV1();
res.json(users); // { user_name: string, user_email: string }
});
// src/routes/v2/users.ts
const v2Router = express.Router();
v2Router.get('/users', async (req, res) => {
const users = await getUsersV2();
res.json(users); // { user: { name: string, email: string, id: number } }
});
// app.ts
app.use('/api/v1', v1Router);
app.use('/api/v2', v2Router);
구현 예시 (Next.js App Router):
src/app/api/
├── v1/
│ ├── users/route.ts
│ └── orders/route.ts
└── v2/
├── users/route.ts
└── orders/route.ts
URL 경로가 아닌 쿼리스트링으로 버전을 지정한다.
GET /api/users?version=1
GET /api/users?version=2
GET /api/users?v=2
실제 예시 — Amazon AWS APIs:
# AWS API에서 날짜 기반 버전을 쿼리 파라미터로 지정하는 경우
https://ec2.amazonaws.com/?Action=DescribeInstances&Version=2016-11-15
장점:
app.get('/api/users', async (req, res) => {
const version = req.query.version ?? '1';
if (version === '2') {
const users = await getUsersV2();
return res.json(users);
}
// 기본값: v1
const users = await getUsersV1();
res.json(users);
});
HTTP 헤더에 버전 정보를 담는다.
GET /api/users
X-API-Version: 2
GET /api/users
API-Version: 2026-01-01
실제 예시 — Stripe:
# Stripe는 날짜 기반 버전을 헤더로 받는다
curl https://api.stripe.com/v1/charges \
-H "Stripe-Version: 2024-04-10" \
-u sk_test_xxx:
Stripe의 접근 방식이 특히 영리하다. 버전을 숫자가 아닌 날짜로 관리한다. 특정 날짜에 어떤 변경이 배포됐는지 타임라인이 명확하고, "v3가 v2보다 새 건지 헷갈린다"는 문제가 없다.
장점:// 버전 추출 미들웨어
const extractVersion = (req: Request, res: Response, next: NextFunction) => {
const version = req.headers['x-api-version']
?? req.headers['api-version']
?? '1';
req.apiVersion = String(version);
next();
};
app.use(extractVersion);
app.get('/api/users', async (req, res) => {
if (req.apiVersion === '2') {
return res.json(await getUsersV2());
}
res.json(await getUsersV1());
});
// TypeScript 타입 확장
declare global {
namespace Express {
interface Request {
apiVersion: string;
}
}
}
HTTP 표준인 Accept 헤더의 미디어 타입(MIME type)에 버전 정보를 담는다.
GET /api/users
Accept: application/vnd.myapp.v2+json
GET /api/users
Accept: application/vnd.github.v3+json
실제 예시 — GitHub:
# GitHub API는 Accept 헤더로 버전/형식을 지정
curl https://api.github.com/repos/facebook/react \
-H "Accept: application/vnd.github.v3+json"
# 다른 표현 요청
curl https://api.github.com/repos/facebook/react \
-H "Accept: application/vnd.github.raw+json"
vnd는 "vendor"의 줄임말이다. 회사나 서비스 특화 미디어 타입임을 나타내는 IANA 표준 prefix다.
app.get('/api/users', async (req, res) => {
const accept = req.headers['accept'] ?? '';
if (accept.includes('application/vnd.myapp.v2+json')) {
res.setHeader('Content-Type', 'application/vnd.myapp.v2+json');
return res.json(await getUsersV2());
}
// 기본값: v1
res.setHeader('Content-Type', 'application/vnd.myapp.v1+json');
res.json(await getUsersV1());
});
| 기준 | URL Path | Query Param | Custom Header | Accept Header |
|---|---|---|---|---|
| 가시성 | 최상 | 좋음 | 낮음 | 낮음 |
| REST 순수성 | 낮음 | 낮음 | 중간 | 최상 |
| 개발자 경험 | 최상 | 좋음 | 보통 | 나쁨 |
| 캐싱 친화성 | 좋음 | 주의 필요 | 복잡 | 복잡 |
| 브라우저 테스트 | 가능 | 가능 | 불가 | 불가 |
| 실무 채택률 | 높음 | 중간 | 높음 | 낮음 |
| 대표 사용처 | GitHub REST, Twilio | AWS | Stripe | GitHub 일부 |
URL Path (/v3/) + Accept Header 혼용. REST API는 /v3/, GraphQL API는 별도 엔드포인트(/graphql). Accept 헤더로 응답 포맷을 제어한다.
Custom Header(Stripe-Version) + URL Path 혼용. https://api.stripe.com/v1/ 처럼 URL에도 v1이 있지만, 실질적인 버저닝은 Stripe-Version 헤더의 날짜로 한다. 날짜 기반 버저닝의 모범 사례다.
# Stripe — 헤더 없이 요청하면 계정의 기본 버전 사용
curl https://api.stripe.com/v1/customers \
-u sk_test_xxx: \
-H "Stripe-Version: 2024-04-10"
URL Path 버저닝의 교과서적 사용. /2010-04-01/ 같이 날짜를 URL에 넣는 방식이다.
# Twilio — URL에 날짜가 버전
curl https://api.twilio.com/2010-04-01/Accounts/{AccountSid}/Messages \
-u AccountSid:AuthToken
URL Path 버저닝: /1.1/, /2/. v2가 완전히 재설계된 버전이라 major 번호 체계 사용.
버전을 올렸으면 언젠가 구 버전을 내려야 한다. 갑자기 내리면 클라이언트가 망한다.
IETF RFC 8594에 정의된 표준 방법이다. 응답에 헤더를 달아 "이 버전은 언제까지만 운영됩니다"를 알린다.
// v1 응답에 폐기 예고 헤더 추가
app.use('/api/v1', (req, res, next) => {
res.setHeader('Deprecation', 'true');
res.setHeader('Sunset', 'Sat, 31 Dec 2026 23:59:59 GMT');
res.setHeader(
'Link',
'<https://api.example.com/v2>; rel="successor-version"'
);
next();
});
// v1 응답에 경고 메시지 포함
interface V1Response<T> {
data: T;
_deprecation?: {
message: string;
sunset_date: string;
migration_guide: string;
};
}
const wrapV1Response = <T>(data: T): V1Response<T> => ({
data,
_deprecation: {
message: 'API v1 is deprecated. Please migrate to v2.',
sunset_date: '2026-12-31',
migration_guide: 'https://docs.example.com/migration/v1-to-v2',
},
});
// Sunset 이후 처리
app.use('/api/v1', (req, res) => {
res.status(410).json({
error: 'API v1 has been sunset',
message: 'Please upgrade to v2',
documentation: 'https://docs.example.com/v2',
migration_guide: 'https://docs.example.com/migration/v1-to-v2',
});
});
1. 버전은 메이저만 마이너 변경에 버전 번호를 올리지 마라. v1, v2, v3이면 충분하다. v1.1, v1.2 같은 방식은 관리가 복잡해진다.
2. 하위 호환성을 최대한 지켜라 새 버전이 필요한 경우를 최소화해야 클라이언트 부담이 줄어든다. 필드 추가/선택적 파라미터 추가는 버전 없이 가능하다.
3. 기본 버전을 명시하라 헤더 없이 요청이 오면 어느 버전으로 처리할지 문서에 명확히 적어라. 보통은 최신 안정 버전이 기본값이다.
4. 버전별 변경 로그를 유지하라## API Changelog
### v2 (2026-01-15)
**Breaking Changes:**
- `user_name` → `user.name` (nested object)
- `user_email` → `user.email`
**New Features:**
- `user.id` 필드 추가
- `/users/:id/preferences` 엔드포인트 추가
### v1 (2025-01-01)
- Initial release
- Sunset date: 2027-01-15
5. 팀의 현실을 반영하라 이론적으로 완벽한 방식보다 팀이 이해하고 유지보수할 수 있는 방식이 좋다. B2B SaaS라면 Stripe처럼 날짜 기반 헤더 버저닝, 공개 API라면 URL Path가 무난하다.
버저닝 전략에 정답은 없다. 상황에 맞는 선택이 있을 뿐이다.
/v1/, /v2/) — 가시성이 최우선가장 중요한 건 전략을 일관되게 유지하고, 폐기 정책을 미리 정해두는 것이다. API를 배포한 순간부터 그 API는 계약이다.