API 버저닝이 필요한 이유
운영 중인 API를 변경하면 기존 클라이언트가 깨집니다. API 버저닝은 하위 호환성을 유지하면서 새로운 기능을 추가할 수 있게 해주는 필수 전략입니다. NestJS는 4가지 버저닝 방식을 내장 지원하지만, 각각의 트레이드오프와 마이그레이션 전략을 이해해야 프로덕션에서 올바르게 운영할 수 있습니다.
이 글에서는 4가지 버저닝 타입별 동작 원리, 컨트롤러·라우트 레벨 버전 분리, Deprecated 엔드포인트 처리, Swagger 버전별 문서 분리, 그리고 점진적 마이그레이션 패턴까지 심층적으로 다룹니다.
4가지 버저닝 타입
NestJS는 @nestjs/core에서 4가지 버저닝 전략을 제공합니다:
// main.ts
import { VersioningType } from '@nestjs/common';
const app = await NestFactory.create(AppModule);
// 1) URI Versioning: /v1/users, /v2/users
app.enableVersioning({
type: VersioningType.URI,
defaultVersion: '1', // 버전 미지정 시 기본값
prefix: 'v', // 접두사 (기본: 'v')
});
// 2) Header Versioning: X-API-Version: 1
app.enableVersioning({
type: VersioningType.HEADER,
header: 'X-API-Version',
});
// 3) Media Type Versioning: Accept: application/json;v=1
app.enableVersioning({
type: VersioningType.MEDIA_TYPE,
key: 'v=',
});
// 4) Custom Versioning: 자유 로직
app.enableVersioning({
type: VersioningType.CUSTOM,
extractor: (request) => {
// 쿼리 파라미터, 쿠키, 도메인 등에서 버전 추출
return request.query?.version || request.headers['x-api-version'] || '1';
},
});
| 타입 | 장점 | 단점 |
|---|---|---|
| URI | 직관적, 캐싱 용이, 브라우저 테스트 쉬움 | URL 변경, 리소스 정체성 분리 |
| Header | URL 깔끔, RESTful 원칙 준수 | 브라우저 테스트 불편, 캐싱 복잡 |
| Media Type | HTTP 표준 준수, 세밀한 협상 | 클라이언트 구현 복잡 |
| Custom | 완전한 자유도 | 표준 없음, 문서화 필요 |
컨트롤러·라우트 레벨 버전 제어
버전을 컨트롤러 전체에 적용하거나, 개별 라우트에 세밀하게 지정할 수 있습니다:
// 컨트롤러 레벨: 모든 라우트에 v1 적용
@Controller('users')
@Version('1')
export class UsersV1Controller {
@Get()
findAll() {
return this.usersService.findAllV1(); // 기존 응답 형식
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.usersService.findOneV1(id);
}
}
// v2 컨트롤러: 응답 형식 변경
@Controller('users')
@Version('2')
export class UsersV2Controller {
@Get()
findAll(@Query() query: PaginationDto) {
// v2: 페이지네이션 메타데이터 포함
return this.usersService.findAllV2(query);
}
@Get(':id')
findOne(@Param('id') id: string) {
// v2: 관계 데이터 포함
return this.usersService.findOneV2(id);
}
}
// 라우트 레벨: 특정 엔드포인트만 다른 버전
@Controller('orders')
@Version('1') // 기본 v1
export class OrdersController {
@Get()
findAll() { /* v1 응답 */ }
@Post()
@Version('2') // 이 라우트만 v2
create(@Body() dto: CreateOrderV2Dto) { /* v2 요청 형식 */ }
@Get(':id')
@Version(['1', '2']) // v1, v2 모두에서 동일한 핸들러
findOne(@Param('id') id: string) { /* 공통 */ }
}
// VERSION_NEUTRAL: 버전 무관하게 항상 매칭
@Controller('health')
@Version(VERSION_NEUTRAL)
export class HealthController {
@Get()
check() { return { status: 'ok' }; }
}
서비스 레이어 버전 분리 패턴
컨트롤러만 분리하고 서비스는 공유하되, 버전별 변환 로직을 깔끔하게 관리합니다:
// 공통 서비스 (비즈니스 로직은 하나)
@Injectable()
export class UsersService {
async findAll(options?: { includeRelations?: boolean }) {
const qb = this.userRepo.createQueryBuilder('user');
if (options?.includeRelations) {
qb.leftJoinAndSelect('user.profile', 'profile');
}
return qb.getMany();
}
}
// v1 응답 변환기
@Injectable()
export class UsersV1Serializer {
serialize(users: User[]): UserV1Response[] {
return users.map(u => ({
id: u.id,
name: u.name,
email: u.email,
}));
}
}
// v2 응답 변환기 (확장된 필드)
@Injectable()
export class UsersV2Serializer {
serialize(users: User[], meta: PaginationMeta): UserV2Response {
return {
data: users.map(u => ({
id: u.id,
name: u.name,
email: u.email,
profile: u.profile, // v2에서 추가
createdAt: u.createdAt, // v2에서 추가
})),
meta, // v2에서 페이지네이션 메타 추가
};
}
}
// v1 컨트롤러
@Controller('users')
@Version('1')
export class UsersV1Controller {
constructor(
private readonly usersService: UsersService,
private readonly serializer: UsersV1Serializer,
) {}
@Get()
async findAll() {
const users = await this.usersService.findAll();
return this.serializer.serialize(users);
}
}
// v2 컨트롤러
@Controller('users')
@Version('2')
export class UsersV2Controller {
constructor(
private readonly usersService: UsersService,
private readonly serializer: UsersV2Serializer,
) {}
@Get()
async findAll(@Query() query: PaginationDto) {
const users = await this.usersService.findAll({ includeRelations: true });
return this.serializer.serialize(users, { page: query.page, total: users.length });
}
}
Deprecated 엔드포인트 처리
구버전 API를 즉시 제거하면 클라이언트가 깨집니다. Deprecation 헤더로 마이그레이션 유도 기간을 줍니다:
// Deprecation 인터셉터
@Injectable()
export class DeprecationInterceptor implements NestInterceptor {
intercept(context: ExecutionContext, next: CallHandler) {
const handler = context.getHandler();
const deprecation = Reflect.getMetadata('deprecated', handler);
if (deprecation) {
const res = context.switchToHttp().getResponse();
res.setHeader('Deprecation', deprecation.date);
res.setHeader('Sunset', deprecation.sunset);
res.setHeader('Link', `<${deprecation.successor}>; rel="successor-version"`);
// 로그로 사용량 추적
const req = context.switchToHttp().getRequest();
console.warn(
`Deprecated API called: ${req.method} ${req.url} by ${req.ip}`
);
}
return next.handle();
}
}
// 커스텀 데코레이터
export const Deprecated = (options: {
date: string;
sunset: string;
successor: string;
}) => SetMetadata('deprecated', options);
// 사용
@Controller('users')
@Version('1')
export class UsersV1Controller {
@Get()
@Deprecated({
date: '2026-01-01',
sunset: '2026-06-01', // 이 날짜에 완전 제거
successor: '/v2/users', // 대체 엔드포인트
})
findAll() {
return this.usersService.findAllV1();
}
}
// 응답 헤더:
// Deprecation: 2026-01-01
// Sunset: 2026-06-01
// Link: </v2/users>; rel="successor-version"
Swagger 버전별 문서 분리
API 버전별로 독립적인 Swagger 문서를 생성합니다:
// main.ts
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableVersioning({ type: VersioningType.URI, defaultVersion: '1' });
// v1 문서
const v1Config = new DocumentBuilder()
.setTitle('My API v1')
.setVersion('1.0')
.addBearerAuth()
.build();
const v1Document = SwaggerModule.createDocument(app, v1Config, {
include: [UsersV1Module, OrdersV1Module], // v1 모듈만 포함
});
SwaggerModule.setup('docs/v1', app, v1Document);
// v2 문서
const v2Config = new DocumentBuilder()
.setTitle('My API v2')
.setVersion('2.0')
.addBearerAuth()
.build();
const v2Document = SwaggerModule.createDocument(app, v2Config, {
include: [UsersV2Module, OrdersV2Module],
});
SwaggerModule.setup('docs/v2', app, v2Document);
// /docs/v1 → v1 전용 Swagger UI
// /docs/v2 → v2 전용 Swagger UI
await app.listen(3000);
}
점진적 마이그레이션 전략
버전 전환은 한 번에 하지 않고 4단계로 진행합니다:
// Phase 1: v2 출시 (v1 동시 운영)
// - v2 엔드포인트 추가
// - v1은 그대로 유지
// - 기간: 출시 즉시
// Phase 2: v1 Deprecation 공지 (v1에 경고 헤더)
// - Deprecation 헤더 추가
// - 클라이언트에 마이그레이션 가이드 제공
// - 기간: 1~3개월
// Phase 3: v1 사용량 모니터링
@Injectable()
export class VersionMetricsInterceptor implements NestInterceptor {
constructor(private readonly metricsService: MetricsService) {}
intercept(context: ExecutionContext, next: CallHandler) {
const req = context.switchToHttp().getRequest();
const version = req.version || 'neutral';
this.metricsService.increment('api.request', {
version,
path: req.route?.path,
client: req.headers['x-client-id'] || 'unknown',
});
return next.handle();
}
}
// → v1 트래픽이 5% 미만이면 Phase 4 진행
// Phase 4: v1 완전 제거
// - Sunset 날짜 이후 v1 라우트 제거
// - 410 Gone 응답으로 전환 (즉시 삭제 대신)
@Controller('users')
@Version('1')
export class UsersV1GoneController {
@All('*')
gone() {
throw new GoneException(
'API v1 has been retired. Please migrate to /v2/users'
);
}
}
마무리
NestJS API 버저닝은 클라이언트 호환성을 유지하면서 API를 진화시키는 핵심 전략입니다. URI 버저닝이 가장 직관적이며, 서비스 레이어는 공유하되 Serializer로 버전별 응답을 분리하면 코드 중복을 최소화할 수 있습니다. Deprecation 헤더와 사용량 모니터링으로 안전한 점진적 마이그레이션을 실현하세요.
관련 글로 NestJS Swagger 데코레이터 심화와 NestJS Interceptor RxJS 심화도 함께 참고하세요.