NestJS API Versioning 전략

NestJS API Versioning이란?

API가 진화하면 기존 클라이언트와의 호환성을 유지하면서 새로운 기능을 제공해야 합니다. NestJS API Versioning은 프레임워크 레벨에서 버전 관리를 지원하여, URI 경로, 헤더, 미디어 타입, 커스텀 로직 등 다양한 전략으로 API 버전을 분리할 수 있습니다. v10+부터 내장 지원되며, 별도 라이브러리 없이 선언적으로 버전별 엔드포인트를 관리합니다.

버전 관리 전략 4가지

NestJS는 네 가지 버전 관리 전략을 제공합니다. main.ts에서 글로벌로 설정합니다.

import { VersioningType } from '@nestjs/common';

// 1. URI 방식 (가장 일반적): /v1/users, /v2/users
app.enableVersioning({
  type: VersioningType.URI,
  defaultVersion: '1',     // 기본 버전
  prefix: 'v',             // 접두사 (기본: 'v')
});

// 2. Header 방식: X-API-Version: 1
app.enableVersioning({
  type: VersioningType.HEADER,
  header: 'X-API-Version',
});

// 3. Media Type 방식: Accept: application/json;v=1
app.enableVersioning({
  type: VersioningType.MEDIA_TYPE,
  key: 'v=',
});

// 4. Custom 방식: 요청에서 버전을 추출하는 커스텀 로직
app.enableVersioning({
  type: VersioningType.CUSTOM,
  extractor: (request: Request) => {
    // 쿼리 파라미터에서 버전 추출: ?api-version=2
    const version = request.query['api-version'] as string;
    return version || '1';
  },
});
전략 요청 예시 장점 단점
URI GET /v2/users 직관적, 캐싱 용이 URL이 변경됨
Header X-API-Version: 2 URL 깔끔 브라우저 테스트 불편
Media Type Accept: app/json;v=2 REST 표준 준수 복잡한 Accept 파싱
Custom 자유 정의 완전한 유연성 직접 구현 필요

컨트롤러·메서드 레벨 버전 설정

@Version() 데코레이터로 컨트롤러 전체 또는 개별 메서드에 버전을 지정합니다.

// 컨트롤러 레벨 버전: 모든 엔드포인트가 v1
@Controller('users')
@Version('1')
export class UsersV1Controller {
  @Get()
  findAll() {
    return { version: 1, data: ['user1', 'user2'] };
  }

  @Get(':id')
  findOne(@Param('id') id: string) {
    return { version: 1, id, name: 'User V1' };
  }
}

// v2 컨트롤러: 응답 구조 변경
@Controller('users')
@Version('2')
export class UsersV2Controller {
  @Get()
  findAll() {
    return {
      version: 2,
      data: [{ id: 1, name: 'user1', email: 'user1@test.com' }],
      meta: { total: 1, page: 1 },
    };
  }
}

// 메서드 레벨 버전: 컨트롤러 내에서 혼합
@Controller('products')
export class ProductsController {
  // v1과 v2 모두 응답 (VERSION_NEUTRAL과 유사)
  @Get()
  @Version(['1', '2'])
  findAll() {
    return { products: [] };
  }

  // v2에서만 추가된 엔드포인트
  @Get('trending')
  @Version('2')
  getTrending() {
    return { trending: [] };
  }
}

VERSION_NEUTRAL: 버전 무관 엔드포인트

헬스체크, 인증 등 모든 버전에서 동일하게 동작해야 하는 엔드포인트는 VERSION_NEUTRAL을 사용합니다.

import { VERSION_NEUTRAL } from '@nestjs/common';

// 모든 버전에서 접근 가능
@Controller('health')
@Version(VERSION_NEUTRAL)
export class HealthController {
  @Get()
  check() {
    return { status: 'ok' };
  }
}

// /v1/health ✅
// /v2/health ✅
// /health    ✅ (URI 방식에서 prefix 없이도 접근)

// 인증 엔드포인트도 버전 중립
@Controller('auth')
@Version(VERSION_NEUTRAL)
export class AuthController {
  @Post('login')
  login(@Body() dto: LoginDto) { /* ... */ }

  @Post('refresh')
  refresh(@Body() dto: RefreshDto) { /* ... */ }
}

서비스 계층 버전 분리 패턴

컨트롤러뿐 아니라 서비스 계층도 버전별로 분리하면 비즈니스 로직 변경을 깔끔하게 관리할 수 있습니다. Dynamic Module 패턴과 조합하면 더 유연해집니다.

// 서비스 인터페이스
export interface UserService {
  findAll(): Promise<any>;
  findOne(id: number): Promise<any>;
}

// V1 서비스
@Injectable()
export class UserServiceV1 implements UserService {
  async findAll() {
    return this.userRepo.find();
  }

  async findOne(id: number) {
    return this.userRepo.findOne({ where: { id } });
  }
}

// V2 서비스: 페이지네이션, 캐싱 추가
@Injectable()
export class UserServiceV2 implements UserService {
  async findAll() {
    const [data, total] = await this.userRepo.findAndCount();
    return { data, meta: { total } };
  }

  async findOne(id: number) {
    const cached = await this.cache.get(`user:${id}`);
    if (cached) return cached;
    const user = await this.userRepo.findOne({
      where: { id },
      relations: ['profile', 'roles'],
    });
    await this.cache.set(`user:${id}`, user, 300);
    return user;
  }
}

// 모듈에서 버전별 서비스 등록
@Module({
  controllers: [UsersV1Controller, UsersV2Controller],
  providers: [
    { provide: 'USER_SERVICE_V1', useClass: UserServiceV1 },
    { provide: 'USER_SERVICE_V2', useClass: UserServiceV2 },
  ],
})
export class UsersModule {}

// 컨트롤러에서 주입
@Controller('users')
@Version('2')
export class UsersV2Controller {
  constructor(
    @Inject('USER_SERVICE_V2')
    private readonly userService: UserService,
  ) {}

  @Get()
  findAll() {
    return this.userService.findAll();
  }
}

버전 Deprecation 전략

오래된 API 버전을 점진적으로 폐기하는 패턴입니다. Interceptor를 활용하여 deprecated 버전에 경고 헤더를 추가합니다.

// Deprecation 인터셉터
@Injectable()
export class DeprecationInterceptor implements NestInterceptor {
  private readonly deprecatedVersions: Map<string, string> = new Map([
    ['1', '2025-12-31'],  // v1은 2025년 말 종료
  ]);

  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const request = context.switchToHttp().getRequest();
    const response = context.switchToHttp().getResponse();

    // 요청된 API 버전 추출
    const version = request.version || request.headers['x-api-version'];

    if (version && this.deprecatedVersions.has(version)) {
      const sunset = this.deprecatedVersions.get(version);
      response.setHeader('Deprecation', 'true');
      response.setHeader('Sunset', sunset);
      response.setHeader(
        'Link',
        '</v2/docs>; rel="successor-version"',
      );
      response.setHeader(
        'X-Deprecation-Notice',
        `API v${version} is deprecated. Migrate to v2 before ${sunset}.`,
      );
    }

    return next.handle();
  }
}

// 글로벌 적용
@Module({
  providers: [
    { provide: APP_INTERCEPTOR, useClass: DeprecationInterceptor },
  ],
})
export class AppModule {}

// 응답 헤더 예시:
// Deprecation: true
// Sunset: 2025-12-31
// Link: </v2/docs>; rel="successor-version"
// X-Deprecation-Notice: API v1 is deprecated. Migrate to v2 before 2025-12-31.

Swagger 버전별 문서 분리

OpenAPI(Swagger) 문서를 버전별로 분리하면 클라이언트 개발자가 정확한 API 스펙을 확인할 수 있습니다.

// main.ts: 버전별 Swagger 문서 생성
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('API V1')
    .setVersion('1.0')
    .addTag('users-v1')
    .build();
  const v1Document = SwaggerModule.createDocument(app, v1Config, {
    include: [UsersV1Module, ProductsV1Module],
  });
  SwaggerModule.setup('docs/v1', app, v1Document);

  // V2 문서
  const v2Config = new DocumentBuilder()
    .setTitle('API V2')
    .setVersion('2.0')
    .addTag('users-v2')
    .build();
  const v2Document = SwaggerModule.createDocument(app, v2Config, {
    include: [UsersV2Module, ProductsV2Module],
  });
  SwaggerModule.setup('docs/v2', app, v2Document);

  await app.listen(3000);
}
// /docs/v1 → V1 API 문서
// /docs/v2 → V2 API 문서

운영 베스트 프랙티스

  • URI 방식 권장: 가장 직관적이고 캐싱/로깅/모니터링 도구와 호환성이 좋습니다
  • 최대 2~3 버전 유지: 동시 지원 버전이 많으면 유지보수 비용이 급증합니다
  • VERSION_NEUTRAL 활용: 인증, 헬스체크 등 공통 엔드포인트는 버전 중립으로 설정하세요
  • Deprecation 헤더 필수: 폐기 예정 버전에 Sunset 헤더를 추가하여 클라이언트에 사전 고지하세요
  • 서비스 계층 분리: 컨트롤러만 분리하면 서비스 코드가 분기문으로 복잡해집니다 — 서비스도 버전별로 분리하세요
  • 버전별 E2E 테스트: 각 버전의 엔드포인트가 독립적으로 동작하는지 E2E 테스트로 검증하세요
위로 스크롤
WordPress Appliance - Powered by TurnKey Linux