NestJS API Versioning 마이그레이션

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 심화도 함께 참고하세요.

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux