본문으로 건너뛰기

안동민 개발노트

본문 시작

버전 관리와 API 진화 전략

호환 변경과 파괴적 변경을 구분하고 URI·헤더·미디어 타입·Custom 버전 추출과 이전 정책으로 기존 클라이언트를 보호합니다.

지난 절에서 DTO와 유효성 검사를 통해 견고한 API를 만들고, Swagger로 효율적으로 문서화하는 방법을 알아보았습니다.

이제 5장의 마지막 절에서 장기적으로 안정적이고 유지보수 가능한 API를 구축하기 위한 핵심 전략인 버전 관리(Versioning)API 진화 전략을 다룹니다.

API는 클라이언트(웹, 모바일 앱, 다른 서비스)와의 계약과 같습니다.

한 번 배포된 API를 변경하면 기존 클라이언트의 동작에 영향을 줄 수 있습니다.

따라서 API의 변경이 불가피할 때, 기존 클라이언트와의 호환성을 유지하면서 새로운 기능을 도입하거나 개선 사항을 적용할 수 있는 명확한 전략이 필요합니다.

이것이 바로 API 버전 관리의 목적입니다.

API 변경을 호환 변경과 파괴 변경으로 분류하고 URI, Header, Media Type, Custom 방식으로 버전을 추출한 뒤 문서화, 병행 지원, 사용량 관측, 지원 종료로 이어지는 Nest API 수명주기

Nest · API Evolution

변경을 분류하고, 버전을 추출하고, 종료까지 운영한다

버전 위치는 routing 설정이고, 새 버전이 필요한지는 지원 중인 클라이언트 계약이 깨지는지로 판단합니다.

같은 버전에서 진화

기존 요청과 응답 의미가 유지된다

생략 시 기존 동작을 유지하는 선택 입력, 알 수 없는 필드를 허용하는 계약 아래의 응답 필드, 충돌 없는 새 endpoint가 후보입니다.

지원 중인 client와 contract test로 호환성을 확인합니다.

새 계약으로 분리

기존 client가 실패하거나 의미가 달라진다

필드 제거·이름·타입, 새 필수 입력, 허용 값 축소, 인증 요구, status와 body 의미 변경은 새 버전과 이전 기간이 필요합니다.

배포 방식과 API contract version은 서로 다른 결정입니다.

버전 추출 방식과 routing·cache 운영 계약
방식 요청과 Nest 설정 routing·cache 운영 경계
URI
GET /api/v1/users
type: URI · 기본 prefix v
target URI 자체가 기본 cache key를 나눕니다.
Header
X-API-Version: 1
type: HEADER
header: 'X-API-Version'
Vary에 버전 header를 명시하고 gateway·CDN key도 같은 값으로 나눕니다.
Media Type
Accept:
application/json;v=1
type: MEDIA_TYPE
key: 'v='
Vary: Accept를 보내고 gateway·CDN key도 Accept 값으로 나눕니다.
Custom
extractor(request)
type: CUSTOM
string | string[]
추출 입력을 Vary와 cache key에 표현할 수 없으면 캐시하지 않습니다.
URI · path segment
GET /api/v1/users, type: URI, 기본 prefix v
서로 다른 target URI가 기본 cache key를 나눕니다.
Header · custom request header
X-API-Version: 1
type: HEADER
Vary에 버전 header를 명시하고 gateway·CDN key도 같은 값으로 나눕니다.
Media Type · Accept parameter
Accept:
application/json;v=1
key: 'v='
Vary: Accept를 보내고 gateway·CDN key도 Accept 값으로 나눕니다.
Custom · application extractor
extractor(request)
string | string[]
추출 입력을 Vary와 cache key에 표현할 수 없으면 캐시하지 않습니다.
Controller 전체

@Controller options의 pathversion

version: '1'을 지정하면 그 controller의 handler가 기본적으로 v1 route가 됩니다.

Handler 하나

method의 @Version('2')

개별 handler에만 다른 버전을 주며 controller 또는 global default 메타데이터를 덮어씁니다.

  1. 계약과 사용자를 먼저 조사한다

    현재 OpenAPI, contract test, client 목록과 version별 traffic을 기준선으로 고정합니다.

  2. 문서와 이전 계획을 함께 공개한다

    버전별 명세, 변경 이력, before/after, migration 절차, deprecated 상태와 지원 종료일을 제공합니다.

  3. 구·신 버전을 병행 운영한다

    routing, cache key, 인증, rate limit, log와 metric의 version 차원을 일치시키고 오류율과 잔여 사용량을 관측합니다.

  4. 공지한 종료 조건 뒤에 제거한다

    지원 기간, migration 완료율, rollback 조건을 충족한 뒤 구 버전을 비활성화합니다.

defaultVersion은 version 메타데이터가 없는 controller·handler의 기본값이며 버전 없는 요청 fallback이 아닙니다. versionless access는 VERSION_NEUTRAL로 선언하고, OpenAPI의 info.version도 route version과 별도로 관리합니다.


API 버전 관리의 중요성

API는 시간이 지남에 따라 필연적으로 변경됩니다.

요구사항이 바뀌고, 새로운 기능이 추가되며, 기존 기능이 개선되거나 제거될 수 있습니다.

이러한 변경 사항이 기존 클라이언트를 깨뜨리지(break) 않으면서 API를 발전시키기 위해 버전 관리는 필수적입니다.

API 변경의 유형
  • 하위 호환성 유지 변경 (Non-breaking Change): 지원 중인 기존 클라이언트가 수정 없이 같은 의미로 계속 동작하는 변경입니다.
    • 기존 경로와 충돌하지 않는 새로운 엔드포인트 추가
    • 알 수 없는 필드를 무시하는 클라이언트 계약이 확인된 경우 응답 필드 추가
    • 생략했을 때 기존 동작과 기본값이 유지되는 선택 요청 파라미터 추가
  • 하위 호환성 파괴 변경 (Breaking Change): 기존 요청이 실패하거나 같은 요청의 응답 의미가 달라지는 변경입니다.
    • 기존 엔드포인트 제거 또는 경로 변경
    • 응답 필드 제거·이름/타입 변경 또는 상태 코드와 본문 의미 변경
    • 새로운 필수 입력 추가, 기존 입력 제거·이름/타입 변경, 허용 값 범위 축소
    • 인증·인가 요구 또는 기본 동작 변경

하위 호환성 파괴 변경이 발생할 때, 버전 관리는 기존 클라이언트가 이전 버전의 API를 계속 사용할 수 있도록 하면서, 새로운 클라이언트가 새로운 버전의 API를 사용할 수 있도록 하는 메커니즘을 제공합니다.


API 버전 관리 전략

앞의 다이어그램은 변경을 분류한 뒤 Nest가 지원하는 네 가지 버전 추출 방식과 병행 지원·종료 정책까지 한 흐름으로 연결합니다.

Nest는 URI, 사용자 지정 헤더, Accept 미디어 타입 파라미터, 사용자 정의 extractor 방식을 지원합니다. 애플리케이션에서는 이 중 하나를 명시적으로 선택합니다.

URI(URL) 기반 버전 관리

가장 흔하고 직관적인 방법입니다.

API 경로에 버전 번호를 포함시킵니다.

예시
  • GET /api/v1/users
  • GET /api/v2/users
장점
  • 가장 명확하고 이해하기 쉽습니다.
  • 일반 HTTP routing과 access log에서 선택된 버전이 명시적으로 드러납니다.
  • 브라우저에서 직접 테스트하기 쉽습니다.
단점
  • URI 자체가 변경되므로, 클라이언트 측에서 버전 변경 시 모든 URI를 수정해야 합니다.
  • 같은 리소스에 대해 버전별로 다른 URI를 가지게 됩니다.
NestJS 구현

NestJS에서는 enableVersioning()으로 추출 방식을 고르고 controller 또는 handler 메타데이터에 지원 버전을 지정합니다.

main.ts에서 전역 버전 관리를 활성화해야 합니다.

src/main.ts (업데이트)
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe, VersioningType } from '@nestjs/common'; // VersioningType 임포트
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);

  // /api 뒤에 URI 버전 세그먼트가 붙습니다: /api/v1/users
  app.setGlobalPrefix('api');

  // API 버전 관리 활성화 (URI 기반)
  app.enableVersioning({
    type: VersioningType.URI, // URI 기반 버전 관리 사용
    // version 메타데이터가 없는 controller/handler를 v1로 등록
    defaultVersion: '1',
  });

  app.useGlobalPipes(new ValidationPipe({
    whitelist: true,
    forbidNonWhitelisted: true,
    transform: true,
    disableErrorMessages: process.env.NODE_ENV === 'production',
  }));

  const config = new DocumentBuilder()
    .setTitle('Users API Example')
    .setDescription('The Users API description with CRUD operations.')
    .setVersion('1.0') // Swagger 문서 자체의 버전, API 버전과 다름
    .addTag('users', 'User related endpoints')
    .addBearerAuth(
      { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
      'access-token'
    )
    .build();

  const documentFactory = () => SwaggerModule.createDocument(app, config);
  SwaggerModule.setup('api', app, documentFactory);

  await app.listen(3000);
}
bootstrap();

컨트롤러 전체 버전은 @Controller()version 옵션으로 지정합니다. @Version()은 개별 handler에만 적용합니다.

src/users/users.controller.ts (업데이트 - v1, v2)
import { Controller, Get } from '@nestjs/common';
import { UsersService, User } from './users.service';
import { ApiProperty, ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger';

// Version 1 Controller
@ApiTags('users - v1') // Swagger 태그도 버전별로 분리하여 명확하게 합니다.
@Controller({ path: 'users', version: '1' })
export class UsersV1Controller {
  constructor(private readonly usersService: UsersService) {}

  @Get()
  @ApiOperation({ summary: '[v1] 모든 사용자 조회', description: 'v1 API: 등록된 모든 사용자 목록을 반환합니다.' })
  @ApiResponse({ status: 200, description: '성공적으로 모든 사용자를 반환합니다.', type: [User] })
  findAll(): User[] {
    return this.usersService.findAll();
  }

  // ... (다른 CRUD 메서드도 동일하게 구현)
}

// Version 2 Response Model (가상의 변경: email 필드 제거, phoneNumber 필드 추가 등)
// Swagger 응답 스키마에 쓰려면 인터페이스가 아니라 클래스가 필요합니다.
export class UserV2 {
  @ApiProperty({ example: 1 })
  id!: number;

  @ApiProperty({ example: '홍길동' })
  name!: string;

  @ApiProperty({ example: '010-1234-5678', required: false })
  phoneNumber?: string; // v2에서 추가될 필드
}

@ApiTags('users - v2')
@Controller({ path: 'users', version: '2' })
export class UsersV2Controller {
  constructor(private readonly usersService: UsersService) {}

  @Get()
  @ApiOperation({ summary: '[v2] 모든 사용자 조회', description: 'v2 API: 등록된 모든 사용자 목록을 반환합니다. (Email 필드 제거, PhoneNumber 필드 추가)' })
  @ApiResponse({ status: 200, description: '성공적으로 모든 사용자를 반환합니다.', type: [UserV2] })
  findAll(): UserV2[] {
    // 실제 서비스 로직은 v2 응답 형태에 맞게 데이터를 변환해야 합니다.
    const v1Users = this.usersService.findAll();
    return v1Users.map(user => ({ id: user.id, name: user.name, phoneNumber: '010-XXXX-XXXX' }));
  }

  // ... (v2에 맞는 다른 CRUD 메서드 구현)
}
users.module.ts에도 두 컨트롤러를 모두 등록해야 합니다.
src/users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersV1Controller, UsersV2Controller } from './users.controller'; // 두 컨트롤러 임포트

@Module({
  controllers: [UsersV1Controller, UsersV2Controller], // 두 컨트롤러 모두 등록
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

이제 클라이언트는 http://localhost:3000/api/v1/users 또는 http://localhost:3000/api/v2/users로 요청하여 원하는 버전의 API에 접근할 수 있습니다.

@Version()은 아래처럼 개별 handler에 붙여 controller 기본 버전을 덮어쓸 때 사용합니다.

handler 단위 버전
import { Controller, Get, Version } from '@nestjs/common';

@Controller({ path: 'reports', version: '1' })
export class ReportsController {
  @Version('2')
  @Get()
  findAllV2() {
    return [];
  }
}

defaultVersion: '1'@Controller()@Version()에 version 메타데이터가 없는 route를 v1로 등록하는 기본값입니다. 버전 세그먼트가 없는 /api/users 요청을 /api/v1/users로 보내는 fallback이 아닙니다. 버전 없이도 접근해야 하는 controller나 handler는 VERSION_NEUTRAL로 명시합니다.

버전 중립 controller
import { Controller, Get, VERSION_NEUTRAL } from '@nestjs/common';

@Controller({ path: 'health', version: VERSION_NEUTRAL })
export class HealthController {
  @Get()
  check() {
    return { status: 'ok' };
  }
}

헤더(Header) 기반 버전 관리

요청 헤더에 커스텀 헤더 필드를 추가하여 버전을 명시하는 방법입니다.

예시
  • GET /api/users
  • Header: X-API-Version: 1
  • Header: X-API-Version: 2
장점
  • URI가 깔끔하고 리소스 중심적입니다.
  • URI를 바꾸지 않고 클라이언트가 버전을 선택할 수 있습니다.
단점
  • 브라우저에서 직접 테스트하기 어렵습니다 (별도의 도구 필요).
  • HTTP 표준 헤더가 아니므로 클라이언트에서 추가적인 설정이 필요합니다.
  • 같은 URI의 cacheable response가 헤더에 따라 달라지므로 Vary: X-API-Version을 보내고, gateway·CDN의 별도 cache key도 해당 헤더 값으로 나눕니다. 두 조건을 보장할 수 없다면 이 응답을 캐시하지 않습니다.
NestJS 구현

main.ts에서 VersioningType.HEADER를 사용하고, header 옵션으로 헤더 이름을 지정합니다.

main.ts
app.enableVersioning({
  type: VersioningType.HEADER,
  header: 'X-API-Version', // 사용할 헤더 이름
  defaultVersion: '1',
});

버전 메타데이터는 URI 방식과 마찬가지로 controller 전체라면 @Controller()version 옵션, 개별 handler라면 @Version()으로 지정합니다.

미디어 타입(Media Type) 기반 버전 관리

Accept 헤더의 미디어 타입 파라미터에 버전을 명시하는 방법입니다.

NestJS의 MEDIA_TYPE 방식은 아래처럼 v=1 값을 읽도록 설정할 수 있습니다.

예시
  • GET /api/users
  • Header: Accept: application/json;v=1
  • Header: Accept: application/json;v=2
장점
  • 표준 Accept 헤더의 content negotiation 형태로 버전을 전달합니다.
  • URI가 변경되지 않습니다.
단점
  • 구현이 복잡하고, 클라이언트와 서버 모두에서 미디어 타입 처리가 필요합니다.
  • 널리 사용되지 않아 익숙하지 않을 수 있습니다.
NestJS 구현

main.ts에서 VersioningType.MEDIA_TYPE을 사용하고, key 옵션으로 미디어 타입의 키를 지정합니다.

main.ts
app.enableVersioning({
  type: VersioningType.MEDIA_TYPE,
  key: 'v=', // key와 구분자까지 포함합니다.
            // 예: Accept: application/json;v=1
  defaultVersion: '1',
});

버전 메타데이터는 controller 전체와 개별 handler 범위에 맞춰 동일하게 지정합니다.

미디어 타입 방식도 같은 URI에서 Accept에 따라 응답 계약이 달라집니다. cacheable response에는 Vary: Accept를 보내고, gateway·CDN의 별도 cache key도 Accept 값으로 나눕니다. 두 조건을 보장할 수 없다면 이 응답을 캐시하지 않습니다.

사용자 정의(Custom) 버전 관리

VersioningType.CUSTOM은 adapter의 request 객체에서 버전을 직접 추출합니다. extractor는 단일 문자열 또는 높은 버전부터 낮은 버전 순으로 정렬한 문자열 배열을 반환합니다. 빈 문자열이나 빈 배열이면 일치하는 route가 없어 404가 됩니다.

main.ts
function extractVersion(request: unknown): string {
  const headers = (request as { headers?: Record<string, unknown> }).headers;
  const value = headers?.['x-api-version'];
  return typeof value === 'string' ? value : '';
}

app.enableVersioning({
  type: VersioningType.CUSTOM,
  extractor: extractVersion,
});

여러 후보 버전을 반환해 가장 높은 일치 버전을 고르는 동작은 Fastify adapter에서 지원됩니다. Express adapter에서는 이 선택이 안정적으로 동작하지 않으므로 단일 버전을 반환하는 구성이 안전합니다. Custom extractor가 header를 읽어 cacheable response를 선택한다면 그 header를 Vary에 명시하고 gateway·CDN의 별도 cache key도 같은 값으로 나눕니다. Header가 아닌 입력은 Vary로 표현할 수 없으므로 해당 입력을 cache key에 반영할 수 없다면 응답을 캐시하지 않습니다.


버전 위치를 고르는 것과 새 버전이 필요한지 판단하는 것은 다른 문제입니다. URI를 사용해도 비파괴 변경은 같은 버전에서 진화시킬 수 있고, Header를 사용해도 파괴 변경에는 별도 계약과 이전 기간이 필요합니다.


API 진화 전략 및 고려사항

버전 관리 방식 외에도, API를 장기적으로 안정적으로 유지하기 위한 전략들이 있습니다.

  • 비파괴적 변경 우선: 추가된 입력을 생략했을 때의 동작, 알 수 없는 응답 필드 처리, 기존 상태 코드와 기본값을 계약 테스트로 확인합니다.
  • 버전별 문서: route version과 DocumentBuilder.setVersion()의 OpenAPI info.version은 별개입니다. 지원하는 API 버전별 명세 또는 명확히 구분된 통합 명세와 변경 이력을 유지합니다.
  • 폐기 예고: 구 버전을 즉시 제거하지 않고 deprecated 상태, 지원 종료일, 대체 버전과 연락 경로를 공지합니다.
  • 마이그레이션 가이드: 필드·인증·오류 형식의 before/after, 전환 절차, 검증 방법과 rollback 조건을 제공합니다.
  • 병행 운영과 관측: 구 버전과 새 버전을 함께 운영하며 실제 사용량과 오류율을 확인하고, 공지한 지원 기간과 종료 기준을 충족한 뒤 구 버전을 제거합니다.
  • Gateway와 cache: version routing뿐 아니라 header normalization, Vary 전달, cache key, rate limit과 observability 차원을 같은 버전 축으로 맞춥니다.
  • 점진적 배포: Canary는 새 구현의 배포 위험을 줄이는 전략이지 기존 계약을 보존하는 버전 관리의 대체물이 아닙니다.

API 버전 관리는 URL 모양을 바꾸는 기능이 아니라 기존 클라이언트가 의존하는 계약을 언제, 어떻게 바꿀지 정하는 운영 정책입니다.

5장에서는 REST 리소스 설계, DTO 검증, Swagger 문서화, 버전 관리까지 API 계약을 유지하는 흐름을 연결했습니다.

다음 장에서는 NestJS 애플리케이션의 성능 최적화와 확장성 있는 구조를 다룹니다.