API 버저닝 전략: URL vs Header vs Content Negotiation
1. 프롤로그 — "API를 바꿨더니 클라이언트가 다 터졌어요"
스타트업에서 자주 보는 장면이다.
백엔드 개발자가 응답 구조를 개선했다. 기존 { "user_name": "kim" } 대신 { "user": { "name": "kim", "id": 123 } } 형태로 바꿨다. 훨씬 깔끔하고 확장성 있는 구조다. 배포했다.
그런데 바로 그날 오후, iOS 앱이 터졌다. 모바일 개발자가 response.user_name으로 직접 접근하고 있었던 것이다. 안드로이드도 터졌다. 외부 파트너사 API 연동도 터졌다. 슬랙에 불이 났다.
이게 API 버저닝이 필요한 이유다.
API는 계약(Contract)이다. 한번 공개한 API는 마음대로 바꾸면 안 된다. 클라이언트가 의존하고 있기 때문이다. 근데 서비스는 계속 발전해야 한다. 이 두 요구사항 사이의 긴장을 해소하는 방법이 버저닝이다.
2. 언제 버전을 올려야 하나?
모든 변경이 버전 업그레이드를 요구하지는 않는다.
하위 호환 변경 (버전 올릴 필요 없음)
- 새 필드 추가
- 새 엔드포인트 추가
- 선택적 파라미터 추가
- 에러 메시지 텍스트 변경
파괴적 변경 (버전 올려야 함)
- 기존 필드 이름 변경 또는 삭제
- 응답 구조 변경 (nested → flat 등)
- 필드 타입 변경 (
string→number) - 엔드포인트 경로 변경
- 인증 방식 변경
- 기존 파라미터를 필수로 만들기
룰 오브 섬: 클라이언트 코드를 수정하지 않으면 기존처럼 동작해야 하면 버전 유지, 클라이언트 코드 수정 없이는 기존처럼 동작하지 않으면 새 버전이 필요하다.
3. 버저닝 전략 4가지
전략 1: URL Path 버저닝
가장 직관적이고 많이 사용되는 방법이다.
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이 공존
장점:
- 눈에 바로 보인다. URL만 봐도 어떤 버전인지 안다.
- 브라우저 북마크/히스토리가 버전별로 분리된다.
- 서버 라우팅이 단순하다. 버전별로 라우터를 분리하면 된다.
- 디버깅이 쉽다. 로그에서 버전이 바로 보인다.
- CDN/프록시 레이어에서 버전별 라우팅이 쉽다.
단점:
- URI는 리소스 식별자인데, 버전을 URI에 넣으면 "같은 리소스의 두 가지 표현"을 다른 URI로 만드는 것이다. REST 순수주의자들은 이걸 싫어한다.
- 기존 클라이언트를 마이그레이션시키기 어렵다. v1을 쓰는 클라이언트는 계속 v1 URL을 쓴다.
- 경로가 길어진다.
구현 예시 (Express):
// 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
전략 2: 쿼리 파라미터 버저닝
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
장점:
- 기본값 설정이 쉽다. 버전 파라미터가 없으면 최신(또는 v1)을 사용.
- 엔드포인트 경로 자체는 변하지 않는다.
- 선택적 적용이 쉽다. 특정 엔드포인트만 버전을 다르게 할 수 있다.
단점:
- 캐싱이 복잡해진다. 쿼리스트링이 다르면 CDN이 다른 리소스로 인식할 수도, 안 할 수도 있다.
- 필수 비즈니스 파라미터와 메타 파라미터(버전)가 같은 레벨에 섞인다.
- 버전 파라미터를 실수로 빠트리면 의도치 않은 버전을 사용하게 된다.
구현 예시:
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);
});
전략 3: Custom Request Header 버저닝
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보다 새 건지 헷갈린다"는 문제가 없다.
장점:
- URI가 깔끔하게 유지된다. REST 원칙에 더 가깝다.
- 라우팅 로직과 버전 로직이 분리된다.
- 버전별로 다른 미들웨어를 적용하기 쉽다.
단점:
- 브라우저에서 직접 테스트하기 어렵다 (curl이나 Postman 필요).
- 클라이언트가 헤더를 빠트리면 기본 버전으로 폴백하는 로직이 필요하다.
- 프록시/CDN이 헤더를 기반으로 라우팅하려면 설정이 복잡하다.
- 로그에서 버전을 확인하려면 헤더도 로깅해야 한다.
구현 예시:
// 버전 추출 미들웨어
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;
}
}
}
전략 4: Accept Header (Content Negotiation)
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다.
장점:
- HTTP 표준을 가장 충실하게 따른다. Roy Fielding(REST 창시자)이 권장하는 방식이다.
- 같은 URI가 다양한 표현(버전, 포맷)을 반환할 수 있다.
- 이론적으로 가장 "RESTful"하다.
단점:
- 개발자 경험(DX)이 나쁘다. 브라우저 URL 바에 붙여넣기가 불가능하다.
- 미디어 타입 문자열이 길고 타이핑하기 귀찮다.
- 테스트와 디버깅이 불편하다.
- 클라이언트 라이브러리 지원이 다양하지 않을 수 있다.
- 실무에서는 거의 안 쓴다 (이론적으로만 우월).
구현 예시:
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());
});
4. 전략 비교 테이블
| 기준 | URL Path | Query Param | Custom Header | Accept Header |
|---|---|---|---|---|
| 가시성 | 최상 | 좋음 | 낮음 | 낮음 |
| REST 순수성 | 낮음 | 낮음 | 중간 | 최상 |
| 개발자 경험 | 최상 | 좋음 | 보통 | 나쁨 |
| 캐싱 친화성 | 좋음 | 주의 필요 | 복잡 | 복잡 |
| 브라우저 테스트 | 가능 | 가능 | 불가 | 불가 |
| 실무 채택률 | 높음 | 중간 | 높음 | 낮음 |
| 대표 사용처 | GitHub REST, Twilio | AWS | Stripe | GitHub 일부 |
5. 실전: 주요 API의 선택
GitHub
URL Path (/v3/) + Accept Header 혼용. REST API는 /v3/, GraphQL API는 별도 엔드포인트(/graphql). Accept 헤더로 응답 포맷을 제어한다.
Stripe
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"
Twilio
URL Path 버저닝의 교과서적 사용. /2010-04-01/ 같이 날짜를 URL에 넣는 방식이다.
# Twilio — URL에 날짜가 버전
curl https://api.twilio.com/2010-04-01/Accounts/{AccountSid}/Messages \
-u AccountSid:AuthToken
Twitter/X API
URL Path 버저닝: /1.1/, /2/. v2가 완전히 재설계된 버전이라 major 번호 체계 사용.
6. 폐기 전략 (Deprecation)
버전을 올렸으면 언젠가 구 버전을 내려야 한다. 갑자기 내리면 클라이언트가 망한다.
Sunset 헤더
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();
});
Deprecation 경고 응답 바디
// 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',
},
});
현실적인 폐기 타임라인
- 발표 (Day 0): 새 버전 출시, 구 버전 폐기 예고 (최소 6개월, 가능하면 1년)
- 경고 (Day 0 ~ Sunset): 구 버전 사용 시 응답에 경고 헤더/메시지 포함
- 트래픽 모니터링: 구 버전 사용량이 충분히 줄었는지 확인
- 폐기 (Sunset Date): 404 또는 410 반환, 마이그레이션 가이드로 리다이렉트
// 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',
});
});
7. 버전 관리 베스트 프랙티스
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가 무난하다.
8. 결론
버저닝 전략에 정답은 없다. 상황에 맞는 선택이 있을 뿐이다.
- 공개 REST API / B2C: URL Path (
/v1/,/v2/) — 가시성이 최우선 - B2B SaaS / 파트너 API: Custom Header (Stripe 스타일) — 날짜 기반, 세밀한 변경 관리
- 내부 API / 마이크로서비스: URL Path 또는 Header — 팀 컨벤션을 따라라
- 그래픽스 API / 실험적 API: Query Parameter — 폴백이 쉬움
가장 중요한 건 전략을 일관되게 유지하고, 폐기 정책을 미리 정해두는 것이다. API를 배포한 순간부터 그 API는 계약이다.