API 버저닝이 필요한 이유
운영 중인 API의 응답 구조를 바꾸면 기존 클라이언트가 깨진다. 모바일 앱은 강제 업데이트까지 구버전이 남아있고, 외부 파트너 연동은 마이그레이션에 몇 달이 걸린다. API Versioning은 구버전과 신버전을 동시에 운영하여 Breaking Change를 안전하게 도입하는 메커니즘이다.
NestJS는 v8부터 프레임워크 레벨에서 버저닝을 지원한다. URI, Header, Media Type, Custom 네 가지 전략을 제공하며, Guard·Interceptor와 완전히 호환된다. 이 글에서는 전략별 설정, 컨트롤러·라우트 레벨 버전 지정, VERSION_NEUTRAL, 다중 버전 동시 지원, 그리고 점진적 마이그레이션 패턴까지 정리한다.
1. 버저닝 활성화 — 4가지 전략
1-1. URI Versioning (가장 일반적)
URL 경로에 버전을 포함한다: /v1/orders, /v2/orders. 브라우저에서 직접 테스트 가능하고, 프록시·로드밸런서에서 라우팅이 쉽다.
// main.ts
import { VersioningType } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableVersioning({
type: VersioningType.URI,
defaultVersion: '1', // 버전 미지정 시 기본값
prefix: 'v', // /v1, /v2 (기본값: 'v')
});
await app.listen(3000);
}
// 결과: GET /v1/orders, GET /v2/orders
1-2. Header Versioning
커스텀 헤더로 버전을 지정한다. URL이 깔끔하게 유지되지만 브라우저 테스트가 어렵다.
app.enableVersioning({
type: VersioningType.HEADER,
header: 'X-API-Version', // 커스텀 헤더명
defaultVersion: '1',
});
// 사용: GET /orders + X-API-Version: 2
1-3. Media Type Versioning
Accept 헤더에 버전을 포함한다. GitHub API가 이 방식을 쓴다.
app.enableVersioning({
type: VersioningType.MEDIA_TYPE,
key: 'v=', // Accept: application/json;v=2
defaultVersion: '1',
});
// 사용: GET /orders + Accept: application/json;v=2
1-4. Custom Versioning
쿼리 파라미터, 쿠키 등 어떤 기준으로든 버전을 추출할 수 있다.
app.enableVersioning({
type: VersioningType.CUSTOM,
extractor: (request: Request) => {
// 쿼리 파라미터에서 버전 추출: /orders?version=2
const version = request.query['version'] as string;
return version || '1';
},
});
| 전략 | URL 예시 | 장점 | 단점 |
|---|---|---|---|
| URI | /v1/orders | 직관적, 캐싱 친화적 | URL이 길어짐 |
| Header | /orders + 헤더 | URL 깔끔 | 브라우저 테스트 어려움 |
| Media Type | /orders + Accept | REST 순수주의 | 복잡, 디버깅 어려움 |
| Custom | /orders?v=2 | 완전한 자유도 | 표준 없음 |
2. 컨트롤러·라우트 레벨 버전 지정
2-1. 컨트롤러 레벨 — 전체 엔드포인트에 적용
// v1 컨트롤러
@Controller('orders')
@Version('1')
export class OrdersV1Controller {
@Get()
findAll() {
// GET /v1/orders
return this.orderService.findAllV1();
}
@Get(':id')
findOne(@Param('id') id: string) {
// GET /v1/orders/:id
return this.orderService.findOneV1(id);
}
}
// v2 컨트롤러 — 별도 클래스
@Controller('orders')
@Version('2')
export class OrdersV2Controller {
@Get()
findAll(@Query() query: PaginationDto) {
// GET /v2/orders — 페이지네이션 추가
return this.orderService.findAllV2(query);
}
@Get(':id')
findOne(@Param('id') id: string) {
// GET /v2/orders/:id — 응답 구조 변경
return this.orderService.findOneV2(id);
}
}
2-2. 라우트 레벨 — 특정 엔드포인트만 버전 분기
@Controller('orders')
export class OrdersController {
// v1과 v2 모두에서 동일하게 동작
@Get()
@Version(['1', '2'])
findAll() {
return this.orderService.findAll();
}
// v1 전용 — 기존 응답 구조
@Get(':id')
@Version('1')
findOneV1(@Param('id') id: string) {
return this.orderService.findOneV1(id);
}
// v2 전용 — 새로운 응답 구조
@Get(':id')
@Version('2')
findOneV2(@Param('id') id: string) {
return this.orderService.findOneV2(id);
}
}
2-3. 다중 버전 지정 — 하나의 핸들러가 여러 버전 처리
// 배열로 다중 버전 지정
@Get('health')
@Version(['1', '2', '3'])
healthCheck() {
return { status: 'ok' };
}
3. VERSION_NEUTRAL — 버전 무관 엔드포인트
헬스체크, 인증, 공통 유틸리티 등 버전과 무관한 엔드포인트는 VERSION_NEUTRAL로 지정한다. 어떤 버전으로 요청해도 매칭된다.
import { VERSION_NEUTRAL } from '@nestjs/common';
@Controller('health')
@Version(VERSION_NEUTRAL) // /v1/health, /v2/health, /health 모두 매칭
export class HealthController {
@Get()
check() {
return { status: 'ok', timestamp: new Date().toISOString() };
}
}
// 라우트 레벨에서도 사용 가능
@Controller('auth')
export class AuthController {
@Post('login')
@Version(VERSION_NEUTRAL) // 로그인은 버전 무관
login(@Body() dto: LoginDto) {
return this.authService.login(dto);
}
@Get('profile')
@Version('2') // 프로필 조회는 v2 전용
getProfile(@CurrentUser() user: User) {
return this.userService.getProfileV2(user.id);
}
}
4. 모듈 구조 설계 — 버전별 분리 패턴
패턴 A: 같은 모듈, 컨트롤러만 분리
// 변경이 적을 때 — 컨트롤러만 버전별 생성
src/
├── orders/
│ ├── orders.module.ts
│ ├── orders.service.ts // 공유 서비스
│ ├── controllers/
│ │ ├── orders-v1.controller.ts
│ │ └── orders-v2.controller.ts
│ ├── dto/
│ │ ├── v1/
│ │ │ └── order-response.dto.ts
│ │ └── v2/
│ │ └── order-response.dto.ts
│ └── orders.repository.ts // 공유 리포지토리
// orders.module.ts
@Module({
controllers: [OrdersV1Controller, OrdersV2Controller],
providers: [OrdersService, OrdersRepository],
})
export class OrdersModule {}
패턴 B: 모듈 자체를 버전별 분리
// 변경이 많을 때 — 모듈 단위 분리
src/
├── v1/
│ └── orders/
│ ├── orders-v1.module.ts
│ ├── orders-v1.controller.ts
│ └── orders-v1.service.ts
├── v2/
│ └── orders/
│ ├── orders-v2.module.ts
│ ├── orders-v2.controller.ts
│ └── orders-v2.service.ts
└── shared/
├── orders.repository.ts // 공유 계층
└── entities/
└── order.entity.ts
// app.module.ts
@Module({
imports: [
SharedModule,
OrdersV1Module,
OrdersV2Module,
],
})
export class AppModule {}
패턴 C: 서비스 내부에서 버전 분기 (비권장)
// ❌ 서비스에 버전 로직이 침투 — 복잡도 증가
@Injectable()
export class OrdersService {
findOne(id: string, version: number) {
const order = await this.repo.findOne(id);
if (version === 1) return this.toV1Response(order);
if (version === 2) return this.toV2Response(order);
}
}
// ✅ 컨트롤러에서 분기, 서비스는 버전 무관
@Injectable()
export class OrdersService {
findOne(id: string) {
return this.repo.findOneOrFail(id);
}
}
// V1 컨트롤러가 V1 DTO로 변환
// V2 컨트롤러가 V2 DTO로 변환
5. DTO 변환과 호환성 유지
// V1 응답 — 기존 구조
export class OrderResponseV1 {
id: string;
customerName: string; // 단일 필드
total: number;
status: string;
}
// V2 응답 — 구조 변경 (Breaking Change)
export class OrderResponseV2 {
id: string;
customer: { // 객체로 확장
id: string;
name: string;
email: string;
};
pricing: { // 가격 구조 분리
subtotal: number;
tax: number;
total: number;
currency: string;
};
status: OrderStatus; // string → enum
createdAt: string; // 신규 필드
}
// 컨트롤러에서 변환
@Controller('orders')
@Version('1')
export class OrdersV1Controller {
constructor(private readonly ordersService: OrdersService) {}
@Get(':id')
async findOne(@Param('id') id: string): Promise<OrderResponseV1> {
const order = await this.ordersService.findOne(id);
return {
id: order.id,
customerName: order.customer.name,
total: order.pricing.total,
status: order.status,
};
}
}
@Controller('orders')
@Version('2')
export class OrdersV2Controller {
constructor(private readonly ordersService: OrdersService) {}
@Get(':id')
async findOne(@Param('id') id: string): Promise<OrderResponseV2> {
const order = await this.ordersService.findOne(id);
return {
id: order.id,
customer: order.customer,
pricing: order.pricing,
status: order.status,
createdAt: order.createdAt.toISOString(),
};
}
}
6. 버전 폐기(Deprecation) 전략
// 커스텀 Interceptor로 Deprecation 경고 헤더 추가
@Injectable()
export class DeprecationInterceptor implements NestInterceptor {
constructor(
private readonly deprecatedVersions: Set<string>,
private readonly sunsetDate: string,
) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
const request = context.switchToHttp().getRequest();
const response = context.switchToHttp().getResponse();
// 요청된 버전 확인 (URI에서 추출)
const version = request.url.match(//v(d+)//)?.[1];
if (version && this.deprecatedVersions.has(version)) {
response.setHeader('Deprecation', 'true');
response.setHeader('Sunset', this.sunsetDate);
response.setHeader('Link',
'<https://docs.example.com/migration/v2>; rel="deprecation"');
}
return next.handle();
}
}
// 모듈에서 적용
@Module({
providers: [
{
provide: APP_INTERCEPTOR,
useFactory: () => new DeprecationInterceptor(
new Set(['1']), // v1을 deprecated로 지정
'Mon, 01 Jun 2026 00:00:00 GMT', // 폐기 예정일
),
},
],
})
export class AppModule {}
// 클라이언트가 받는 응답 헤더:
// Deprecation: true
// Sunset: Mon, 01 Jun 2026 00:00:00 GMT
// Link: <https://docs.example.com/migration/v2>; rel="deprecation"
7. 테스트 — 버전별 엔드포인트 검증
describe('Orders Versioning (e2e)', () => {
let app: INestApplication;
beforeAll(async () => {
const module = await Test.createTestingModule({
imports: [AppModule],
}).compile();
app = module.createNestApplication();
app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' });
await app.init();
});
it('GET /v1/orders/:id — V1 응답 구조', () => {
return request(app.getHttpServer())
.get('/v1/orders/ord_123')
.expect(200)
.expect((res) => {
expect(res.body).toHaveProperty('customerName'); // V1: 단일 필드
expect(res.body).toHaveProperty('total');
expect(res.body).not.toHaveProperty('customer'); // V2 필드 없음
expect(res.body).not.toHaveProperty('pricing');
});
});
it('GET /v2/orders/:id — V2 응답 구조', () => {
return request(app.getHttpServer())
.get('/v2/orders/ord_123')
.expect(200)
.expect((res) => {
expect(res.body).toHaveProperty('customer.id'); // V2: 객체
expect(res.body).toHaveProperty('customer.name');
expect(res.body).toHaveProperty('pricing.total');
expect(res.body).toHaveProperty('createdAt');
expect(res.body).not.toHaveProperty('customerName');
});
});
it('VERSION_NEUTRAL 엔드포인트는 모든 버전에서 접근 가능', () => {
return Promise.all([
request(app.getHttpServer()).get('/v1/health').expect(200),
request(app.getHttpServer()).get('/v2/health').expect(200),
]);
});
it('V1 deprecated 경고 헤더 포함', () => {
return request(app.getHttpServer())
.get('/v1/orders')
.expect(200)
.expect('Deprecation', 'true')
.expect('Sunset', /2026/);
});
});
8. 운영 체크리스트
| 항목 | 권장 사항 | 위반 시 증상 |
|---|---|---|
| 전략 선택 | URI Versioning (가장 범용적) | 클라이언트 혼란, 디버깅 어려움 |
| 서비스 계층 | 버전 로직은 컨트롤러/DTO에만 | 서비스에 버전 분기 코드 누적 |
| 공통 엔드포인트 | VERSION_NEUTRAL 사용 | 헬스체크·인증을 버전마다 중복 구현 |
| 폐기 알림 | Deprecation + Sunset 헤더 | 클라이언트가 폐기 예정을 모름 |
| 동시 운영 버전 수 | 최대 2~3개, 오래된 버전은 폐기 | 유지보수 비용 폭증 |
| 버전별 테스트 | E2E 테스트에서 모든 버전 검증 | V1 수정 시 V2도 깨지는 회귀 버그 |
마무리 — 버저닝은 운영의 안전장치다
API 버저닝은 기술적으로 복잡하지 않다. enableVersioning() 한 줄과 @Version() 데코레이터면 된다. 어려운 것은 얼마나 오래 구버전을 유지할 것인가, 어디서 버전을 분기할 것인가의 설계 결정이다.
핵심 원칙은 세 가지다. 첫째, 버전 로직은 컨트롤러와 DTO에만 두고 서비스 계층은 깨끗하게 유지하라. 둘째, VERSION_NEUTRAL로 공통 엔드포인트의 중복을 방지하라. 셋째, Deprecation·Sunset 헤더로 클라이언트에게 폐기 일정을 알리고, 동시 운영 버전을 최대 2~3개로 제한하라.