NestJS API Versioning 운영 가이드

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개로 제한하라.

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