본문 바로가기

버전 관리와 API 진화 전략

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

앞의 두 글에서는 DTO로 입력을 검사하고, Swagger로 API를 문서화했습니다. 5장의 마지막 글인 이 글에서는 API를 오래 안정적으로 유지하기 위한 버전 관리(versioning)와 API 진화 전략을 다룹니다.

API는 웹, 모바일 앱, 다른 서비스 같은 클라이언트와 맺은 약속입니다. 한 번 배포한 API를 바꾸면 기존 클라이언트의 동작이 달라질 수 있습니다. 그래서 변경이 불가피할 때 기존 클라이언트와의 호환성을 지키면서 새 기능을 도입할 분명한 방법이 필요하며, 이것이 API 버전 관리의 목적입니다.

Nest API 변경 분류와 버전 수명주기

호환 변경/파괴 변경 판단·같은 버전 진화/새 계약·병행 지원·종료에서 지원 계약 상태가 달라진다.

Nest API · 변경과 버전 운영

계약 변경을 분류하고 라우팅·캐시·종료 조건을 맞춥니다

새 버전의 필요성은 지원 중인 client의 계약이 깨지는지로 판단합니다. 버전 추출 위치와 배포 방식은 각각 별도로 정하며, 문서화부터 지원 종료까지 운영합니다.

계약 변경 분류와 버전 운영 경계 기존지원client계약유지면같은버전진화,파괴면새계약이전. 선택한버전추출방식과cachekey를검토하고필요한병행지원·종료정책으로이어간다. 같은버전변경마다새추출방식또는새구버전종료를만드는뜻은아니다. 예 아니요 지원 client 계약 기존 요청 의미 유지? 같은 버전에서 진화 호환 계약 검수 새 계약으로 분리 이전 기간 · 계획 버전 추출·캐시 검토 URI / Header / Accept Custom 입력 병행 지원 → 종료 관측 · 공지한 기준
계약 호환 판단과 버전 추출·이전 운영을 구분한다 지원client계약유지여부를먼저판단한다. 비파괴변경은같은버전검수,파괴변경은새계약과이전계획으로분리한다. 이미운영중인버전추출방식과cachekey를검토하고병행지원/관측뒤공지한종료조건에따라구버전을제거한다. 유지분기도매변경때새추출방식을설정해야한다는뜻은아니다. 예 아니요 지원 client 계약 기존 요청 의미 유지? 유지 같은 버전 호환 검수 파괴 새 계약 이전 계획 버전 추출 검토 URI / Header / Accept Custom 입력 라우팅 · 캐시 버전별 key · Vary 병행 지원 명세 · 관측 · 이전 구 버전 종료 공지한 기간 · 기준 충족
같은 버전에서 진화

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

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

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

새 계약으로 분리

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

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

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

URI

요청과 Nest 설정:
GET /api/v1/users
type: URI · 기본 prefix v
routing·cache 운영 경계: target URI 자체가 기본 cache key를 나눕니다.

Header

요청과 Nest 설정:
X-API-Version: 1
type: HEADER
header: 'X-API-Version'
routing·cache 운영 경계: Vary에 버전 header를 명시하고 gateway·CDN key도 같은 값으로 나눕니다.

Media Type

요청과 Nest 설정:
Accept:
application/json;v=1
type: MEDIA_TYPE
key: 'v='
routing·cache 운영 경계: Vary: Accept를 보내고 gateway·CDN key도 Accept 값으로 나눕니다.

Custom

요청과 Nest 설정:
extractor(request)
type: CUSTOM
string | string[]

배열의 다중 버전 매칭은 Fastify에서 지원합니다. Express는 단일 버전을 추출합니다.

routing·cache 운영 경계: Header 입력은 Vary와 gateway key에 반영합니다. 다른 추출 입력도 cache key로 구분할 수 없으면 캐시하지 않습니다.
Controller 전체

@Controller options의 path와 version

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과 별도로 관리합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 기존에 지원하는 client 계약을 유지하면 같은 버전에서 진화하고, 계약을 깨뜨리면 새 계약으로 이전한다. 선택한 버전 추출 방식과 cache key를 검토하고 필요한 병행 지원·종료 정책으로 이어간다. 같은 버전을 변경할 때마다 추출 방식을 새로 설정하거나 구 버전의 지원을 종료한다는 뜻은 아니다.
  • 지원하는 client 계약의 유지 여부를 먼저 판단한다. 비파괴 변경은 같은 버전에서 검수하고, 파괴 변경은 새 계약과 이전 계획으로 분리한다. 이미 운영 중인 버전 추출 방식과 cache key를 검토하고 병행 지원/관측 뒤 공지한 종료 조건에 따라 구 버전을 제거한다. 계약을 유지하는 분기도 매번 변경할 때마다 새 추출 방식을 설정해야 한다는 뜻은 아니다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1Nest API 변경 분류와 버전 수명주기
6기존에 지원하는 client 계약을 유지하면 같은 버전에서 진화하고, 계약을 깨뜨리면 새 계약으로 이전한다. 선택한 버전 추출 방식과 cache key를 검토하고 필요한 병행 지원·종료 정책으로 이어간다. 같은 버전을 변경할 때마다 추출 방식을 새로 설정하거나 구 버전의 지원을 종료한다는 뜻은 아니다.
21지원하는 client 계약의 유지 여부를 먼저 판단한다. 비파괴 변경은 같은 버전에서 검수하고, 파괴 변경은 새 계약과 이전 계획으로 분리한다. 이미 운영 중인 버전 추출 방식과 cache key를 검토하고 병행 지원/관측 뒤 공지한 종료 조건에 따라 구 버전을 제거한다. 계약을 유지하는 분기도 매번 변경할 때마다 새 추출 방식을 설정해야 한다는 뜻은 아니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
호환성 분류

기존 지원 client 계약을 유지하는지 판정합니다.

호환: 같은 버전 진화 · 파괴: 새 계약변경 종류가 지원 전략을 정합니다.
추출·cache 경계

새 계약이 필요할 때 선택한 version 추출과 cache key를 확인합니다.

검토: 추출·cache key · 모든 변경: 새 추출 아님계약 구분이 전송·캐시 입력에 반영됩니다.
병행 지원과 종료

필요한 병행 지원·종료 정책을 적용합니다.

상태: 지원 계약 · 종료: 정책에 따라client 이전 근거로 지원 범위를 바꿉니다.

Nest API 변경 분류와 버전 수명주기

호환 변경/파괴 변경 판단·같은 버전 진화/새 계약·병행 지원·종료에서 지원 계약 상태가 달라진다.

Nest API · 변경과 버전 운영

계약 변경을 분류하고 라우팅·캐시·종료 조건을 맞춥니다

새 버전의 필요성은 지원 중인 client의 계약이 깨지는지로 판단합니다. 버전 추출 위치와 배포 방식은 각각 별도로 정하며, 문서화부터 지원 종료까지 운영합니다.

계약 변경 분류와 버전 운영 경계 기존지원client계약유지면같은버전진화,파괴면새계약이전. 선택한버전추출방식과cachekey를검토하고필요한병행지원·종료정책으로이어간다. 같은버전변경마다새추출방식또는새구버전종료를만드는뜻은아니다. 예 아니요 지원 client 계약 기존 요청 의미 유지? 같은 버전에서 진화 호환 계약 검수 새 계약으로 분리 이전 기간 · 계획 버전 추출·캐시 검토 URI / Header / Accept Custom 입력 병행 지원 → 종료 관측 · 공지한 기준
계약 호환 판단과 버전 추출·이전 운영을 구분한다 지원client계약유지여부를먼저판단한다. 비파괴변경은같은버전검수,파괴변경은새계약과이전계획으로분리한다. 이미운영중인버전추출방식과cachekey를검토하고병행지원/관측뒤공지한종료조건에따라구버전을제거한다. 유지분기도매변경때새추출방식을설정해야한다는뜻은아니다. 예 아니요 지원 client 계약 기존 요청 의미 유지? 유지 같은 버전 호환 검수 파괴 새 계약 이전 계획 버전 추출 검토 URI / Header / Accept Custom 입력 라우팅 · 캐시 버전별 key · Vary 병행 지원 명세 · 관측 · 이전 구 버전 종료 공지한 기간 · 기준 충족
같은 버전에서 진화

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

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

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

새 계약으로 분리

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

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

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

URI

요청과 Nest 설정:
GET /api/v1/users
type: URI · 기본 prefix v
routing·cache 운영 경계: target URI 자체가 기본 cache key를 나눕니다.

Header

요청과 Nest 설정:
X-API-Version: 1
type: HEADER
header: 'X-API-Version'
routing·cache 운영 경계: Vary에 버전 header를 명시하고 gateway·CDN key도 같은 값으로 나눕니다.

Media Type

요청과 Nest 설정:
Accept:
application/json;v=1
type: MEDIA_TYPE
key: 'v='
routing·cache 운영 경계: Vary: Accept를 보내고 gateway·CDN key도 Accept 값으로 나눕니다.

Custom

요청과 Nest 설정:
extractor(request)
type: CUSTOM
string | string[]

배열의 다중 버전 매칭은 Fastify에서 지원합니다. Express는 단일 버전을 추출합니다.

routing·cache 운영 경계: Header 입력은 Vary와 gateway key에 반영합니다. 다른 추출 입력도 cache key로 구분할 수 없으면 캐시하지 않습니다.
Controller 전체

@Controller options의 path와 version

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과 별도로 관리합니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 기존에 지원하는 client 계약을 유지하면 같은 버전에서 진화하고, 계약을 깨뜨리면 새 계약으로 이전한다. 선택한 버전 추출 방식과 cache key를 검토하고 필요한 병행 지원·종료 정책으로 이어간다. 같은 버전을 변경할 때마다 추출 방식을 새로 설정하거나 구 버전의 지원을 종료한다는 뜻은 아니다.
  • 지원하는 client 계약의 유지 여부를 먼저 판단한다. 비파괴 변경은 같은 버전에서 검수하고, 파괴 변경은 새 계약과 이전 계획으로 분리한다. 이미 운영 중인 버전 추출 방식과 cache key를 검토하고 병행 지원/관측 뒤 공지한 종료 조건에 따라 구 버전을 제거한다. 계약을 유지하는 분기도 매번 변경할 때마다 새 추출 방식을 설정해야 한다는 뜻은 아니다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1Nest API 변경 분류와 버전 수명주기
6기존에 지원하는 client 계약을 유지하면 같은 버전에서 진화하고, 계약을 깨뜨리면 새 계약으로 이전한다. 선택한 버전 추출 방식과 cache key를 검토하고 필요한 병행 지원·종료 정책으로 이어간다. 같은 버전을 변경할 때마다 추출 방식을 새로 설정하거나 구 버전의 지원을 종료한다는 뜻은 아니다.
21지원하는 client 계약의 유지 여부를 먼저 판단한다. 비파괴 변경은 같은 버전에서 검수하고, 파괴 변경은 새 계약과 이전 계획으로 분리한다. 이미 운영 중인 버전 추출 방식과 cache key를 검토하고 병행 지원/관측 뒤 공지한 종료 조건에 따라 구 버전을 제거한다. 계약을 유지하는 분기도 매번 변경할 때마다 새 추출 방식을 설정해야 한다는 뜻은 아니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
호환성 분류

기존 지원 client 계약을 유지하는지 판정합니다.

호환: 같은 버전 진화 · 파괴: 새 계약변경 종류가 지원 전략을 정합니다.
추출·cache 경계

새 계약이 필요할 때 선택한 version 추출과 cache key를 확인합니다.

검토: 추출·cache key · 모든 변경: 새 추출 아님계약 구분이 전송·캐시 입력에 반영됩니다.
병행 지원과 종료

필요한 병행 지원·종료 정책을 적용합니다.

상태: 지원 계약 · 종료: 정책에 따라client 이전 근거로 지원 범위를 바꿉니다.

위 그림은 API 변경을 다루는 순서를 세 단계로 보여 줍니다. 먼저 지원 중인 클라이언트가 기존 요청을 같은 의미로 계속 쓸 수 있는지 판단하고, 그렇다면 같은 버전 안에서 진화시킵니다. 기존 요청이 실패하거나 의미가 달라진다면 새 버전으로 분리하고, 이미 운영 중인 버전 추출 방식과 캐시 키를 점검합니다. 마지막으로 두 버전을 함께 운영하며 사용량을 관측하고, 공지한 종료 조건을 채운 뒤 구 버전을 제거합니다. 변경할 때마다 새 추출 방식을 고르거나 구 버전을 종료해야 한다는 뜻은 아니라는 점도 그림에 함께 적혀 있습니다.


호환되는 변경과 깨지는 변경

API는 시간이 지나면 바뀔 수밖에 없습니다. 요구사항이 바뀌고, 기능이 추가되며, 기존 기능이 개선되거나 제거됩니다. 이런 변경이 기존 클라이언트를 깨뜨리지(break) 않으면서 API를 발전시키려면 먼저 변경의 종류를 구분해야 합니다.

하위 호환 변경(non-breaking change)은 지원 중인 기존 클라이언트가 수정 없이 같은 의미로 계속 동작하는 변경입니다.

  • 기존 경로와 충돌하지 않는 새 엔드포인트 추가
  • 알 수 없는 필드를 무시하는 클라이언트만 있다는 것이 확인된 경우의 응답 필드 추가
  • 생략했을 때 기존 동작과 기본값이 유지되는 선택 요청 매개변수 추가

하위 호환을 깨는 변경(breaking change)은 기존 요청이 실패하거나 같은 요청의 응답 의미가 달라지는 변경입니다.

  • 기존 엔드포인트 제거나 경로 변경
  • 응답 필드의 제거·이름 변경·타입 변경, 또는 상태 코드와 본문 의미 변경
  • 새 필수 입력 추가, 기존 입력의 제거·이름 변경·타입 변경, 허용 값 범위 축소
  • 인증·인가 요구나 기본 동작 변경

깨지는 변경이 필요할 때 버전 관리는 기존 클라이언트가 이전 버전을 계속 쓰게 하면서, 새 클라이언트는 새 버전을 쓰게 하는 방법을 제공합니다.


Nest의 네 가지 버전 추출 방식

Nest는 URI, 사용자 지정 헤더, Accept 미디어 타입 매개변수, 사용자 정의 extractor라는 네 가지 방식으로 요청에서 버전을 꺼냅니다. 애플리케이션은 이 가운데 하나를 enableVersioning()으로 명시적으로 고르고, controller나 handler 메타데이터에 지원 버전을 적습니다.

URI 기반 버전 관리

가장 흔하고 직관적인 방법으로, API 경로에 GET /api/v1/users, GET /api/v2/users처럼 버전 번호를 넣습니다. 선택된 버전이 일반 HTTP 라우팅과 접근 로그에 그대로 드러나고, 브라우저에서 직접 테스트하기도 쉽습니다. 반면 URI 자체가 바뀌므로 클라이언트가 버전을 올릴 때 모든 URI를 고쳐야 하고, 같은 자원이 버전마다 다른 URI를 갖게 됩니다.

main.ts에서 전역 버전 관리를 켭니다. 이 예제는 global prefix api도 함께 설정합니다.

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(process.env.PORT ?? 3000);
}
void bootstrap();

controller 전체의 버전은 @Controller()의 version 옵션으로 지정하고, @Version()은 개별 handler에만 붙입니다.

5장 첫 글의 페이지 서비스를 그대로 쓰면서, v2에서는 목록 항목의 표현만 바꿉니다. v1의 UsersController는 이름과 파일을 그대로 두고, 클래스의 @ApiTags()를 'users - v1'로 바꾼 뒤 기존 목록 메서드만 아래 내용으로 갱신합니다. 단일 조회·생성·PATCH·삭제와 오류 변환은 기존 구현을 유지합니다. 앞 글의 문서 모델을 다시 쓰므로, UsersService에서 존재하지 않는 User 클래스를 가져오지 않습니다.

src/users/users.controller.ts (v1 목록 메서드 발췌)
import { Controller, Get, Query } from '@nestjs/common';
import { ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { ListUsersQueryDto } from './dto/list-users-query.dto';
import { UserResponseDto, type UsersPageResponse } from './dto/user-response.dto';
import { UsersPageResponseModel } from './dto/users-response.model';
import { UsersService } from './users.service';

@ApiTags('users - v1')
@Controller({ path: 'users', version: '1' })
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Get()
  @ApiOperation({ summary: '[v1] 사용자 목록 조회' })
  @ApiQuery({ name: 'page', required: false, schema: { type: 'integer', minimum: 1, default: 1 } })
  @ApiQuery({ name: 'limit', required: false, schema: { type: 'integer', minimum: 1, maximum: 100, default: 20 } })
  @ApiResponse({ status: 200, type: UsersPageResponseModel })
  findAll(@Query() query: ListUsersQueryDto): UsersPageResponse {
    const result = this.usersService.findAll(query.page, query.limit);

    return {
      items: result.items.map(UserResponseDto.from),
      page: query.page,
      limit: query.limit,
      total: result.total,
    };
  }

  // 5-1의 나머지 CRUD 메서드와 rethrowConflict는 유지합니다.
}

v2에서는 항목에서 email을 빼는 깨지는 변경을 예로 듭니다. phoneNumber는 나중에 전화번호를 따로 제공하게 될 때 쓸 선택 필드입니다. 지금의 메모리 저장 모델에는 전화번호가 없으므로 아래 응답 변환은 이 필드를 생략하며, 가짜 전화번호를 조회 결과처럼 채우지 않습니다.

src/users/dto/users-v2-response.model.ts
import { ApiProperty } from '@nestjs/swagger';

export class UserV2 {
  @ApiProperty({ example: 1 })
  id!: number;

  @ApiProperty({ example: 'Alice' })
  name!: string;

  @ApiProperty({ required: false, description: '전화번호를 별도로 저장·제공할 때 사용하는 선택 필드' })
  phoneNumber?: string;
}

export class UsersV2PageResponseModel {
  @ApiProperty({ type: [UserV2] })
  items!: UserV2[];

  @ApiProperty({ example: 1, minimum: 1 })
  page!: number;

  @ApiProperty({ example: 20, minimum: 1, maximum: 100 })
  limit!: number;

  @ApiProperty({ example: 2, minimum: 0 })
  total!: number;
}

항목 타입이 달라졌으므로 v1 페이지 모델을 상속해 items 타입만 덮어쓰지 않고, 별도의 페이지 모델을 선언했습니다. 두 버전 모두 페이지 바깥 구조는 { items, page, limit, total }로 같습니다.

src/users/users-v2.controller.ts (목록 예제)
import { Controller, Get, Query } from '@nestjs/common';
import { ApiOperation, ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { ListUsersQueryDto } from './dto/list-users-query.dto';
import { UsersV2PageResponseModel } from './dto/users-v2-response.model';
import { UsersService } from './users.service';

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

  @Get()
  @ApiOperation({ summary: '[v2] 사용자 목록 조회', description: '항목의 email을 제외하고 페이지 구조를 유지합니다.' })
  @ApiQuery({ name: 'page', required: false, schema: { type: 'integer', minimum: 1, default: 1 } })
  @ApiQuery({ name: 'limit', required: false, schema: { type: 'integer', minimum: 1, maximum: 100, default: 20 } })
  @ApiResponse({ status: 200, type: UsersV2PageResponseModel })
  findAll(@Query() query: ListUsersQueryDto): UsersV2PageResponseModel {
    const result = this.usersService.findAll(query.page, query.limit);

    return {
      items: result.items.map((user) => ({ id: user.id, name: user.name })),
      page: query.page,
      limit: query.limit,
      total: result.total,
    };
  }
}

이 v2 예제는 목록 조회만 제공합니다. v2의 단일 조회·생성·수정·삭제는 요청·응답·오류 형식을 따로 정한 뒤 구현할 범위입니다.

users.module.ts에는 기존 v1 controller와 새 v2 controller를 등록합니다. 기존 UsersController와 별도의 UsersV1Controller를 함께 등록하면 같은 버전 1 목록 route가 중복되므로, 아래 module은 기존 v1 CRUD를 담은 UsersController 하나와 새 파일의 UsersV2Controller만 씁니다.

src/users/users.module.ts
import { Module } from '@nestjs/common';
import { UsersService } from './users.service';
import { UsersController } from './users.controller';
import { UsersV2Controller } from './users-v2.controller';

@Module({
  controllers: [UsersController, UsersV2Controller],
  providers: [UsersService],
  exports: [UsersService],
})
export class UsersModule {}

이제 클라이언트는 http://localhost:3000/api/v1/users와 http://localhost:3000/api/v2/users에 같은 page·limit query를 보내, 페이지 구조는 같고 항목 모양만 다른지 비교할 수 있습니다. /api의 Swagger UI 경로와 실제 /api/v1/users route는 역할이 다릅니다.

global prefix를 추가했으므로 기존 v1 생성 메서드의 Location도 /api/v1/users/{id}에 맞춥니다. 5장 첫 글의 해당 줄을 response.setHeader('Location', '/api/v1/users/' + user.id)로 바꾸세요. 생성의 201, 잘못된 ID의 400, 없는 사용자의 404, 이메일 충돌의 409, 삭제의 본문 없는 204는 그대로 유지합니다.

@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()에 버전 메타데이터가 없는 route를 v1로 등록하는 기본값입니다. 버전 세그먼트가 없는 /api/users 요청을 /api/v1/users로 넘겨 주는 fallback이 아닙니다. 버전 없이도 접근해야 하는 controller나 handler는 VERSION_NEUTRAL로 명시합니다. URI 방식에서 버전 중립 자원의 URI에는 버전이 붙지 않으므로, 아래 controller는 /api/health에서 응답합니다.

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

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

헤더 기반 버전 관리

GET /api/users처럼 URI는 그대로 두고, X-API-Version: 1이나 X-API-Version: 2 같은 사용자 지정 헤더로 버전을 지정하는 방법입니다. URI가 자원 중심으로 깔끔하게 유지되고, URI를 바꾸지 않고도 클라이언트가 버전을 고를 수 있습니다.

대신 브라우저 주소창만으로는 테스트하기 어렵고, HTTP 표준 헤더가 아니므로 클라이언트에 추가 설정이 필요합니다. 또 같은 URI의 캐시 가능한 응답이 헤더 값에 따라 달라지므로 Vary: X-API-Version을 보내고, gateway와 CDN의 캐시 키도 이 헤더 값으로 나누어야 합니다. 두 조건을 보장할 수 없다면 이 응답은 캐시하지 않습니다. Vary가 캐시 키를 어떻게 나누는지는 네트워크 교재의 캐시와 CORS에서 다룹니다.

main.ts에서 VersioningType.HEADER를 쓰고, header 옵션에 헤더 이름을 적습니다.

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

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

미디어 타입 기반 버전 관리

Accept 헤더의 미디어 타입 매개변수에 버전을 적는 방법입니다. 요청은 GET /api/users에 Accept: application/json;v=1이나 Accept: application/json;v=2를 붙여 보냅니다. 표준 Accept 헤더의 콘텐츠 협상(content negotiation) 형태로 버전을 전달하고 URI도 바뀌지 않지만, 클라이언트와 서버 모두 미디어 타입을 처리해야 하고 널리 쓰이지 않아 낯설 수 있습니다.

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 값에 따라 응답 형식이 달라집니다. 캐시 가능한 응답에는 Vary: Accept를 보내고, gateway와 CDN의 캐시 키도 Accept 값으로 나눕니다. 두 조건을 보장할 수 없다면 이 응답은 캐시하지 않습니다.

사용자 정의 버전 관리

VersioningType.CUSTOM은 adapter의 request 객체에서 버전을 직접 꺼냅니다. extractor는 문자열 하나, 또는 높은 버전부터 낮은 버전 순으로 정렬한 문자열 배열을 반환합니다. 빈 문자열이나 빈 배열을 반환하면 버전이 지정된 route는 하나도 일치하지 않아 404가 됩니다. 이때도 VERSION_NEUTRAL route는 여전히 일치합니다.

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에서 이 선택이 안정적으로 동작하지 않는다고 안내하므로, Express에서는 단일 버전을 반환하는 구성이 안전합니다. Custom extractor가 헤더를 읽어 캐시 가능한 응답을 고른다면 그 헤더를 Vary에 명시하고, gateway와 CDN의 캐시 키도 같은 값으로 나눕니다. 헤더가 아닌 입력은 Vary로 표현할 수 없으므로, 그 입력을 캐시 키에 반영할 수 없다면 응답을 캐시하지 않습니다.

버전을 어디에 둘지 고르는 일과 새 버전이 필요한지 판단하는 일은 서로 다른 문제입니다. URI 방식을 써도 호환되는 변경은 같은 버전 안에서 진화시킬 수 있고, 헤더 방식을 써도 깨지는 변경에는 새 버전과 이전 기간이 필요합니다.


API 진화 전략

버전 추출 방식 외에도, API를 오래 안정적으로 유지하려면 다음 운영 원칙을 함께 정합니다.

  • 호환되는 변경 우선: 추가한 입력을 생략했을 때의 동작, 알 수 없는 응답 필드 처리, 기존 상태 코드와 기본값을 자동 테스트로 확인합니다.
  • 버전별 문서: route 버전과 DocumentBuilder.setVersion()의 OpenAPI info.version은 별개입니다. 지원하는 API 버전마다 명세를 두거나, 버전이 분명히 구분된 통합 명세와 변경 이력을 유지합니다.
  • 폐기 예고: 구 버전을 즉시 제거하지 않고 deprecated 상태, 지원 종료일, 대체 버전과 문의 경로를 공지합니다.
  • 마이그레이션 가이드: 필드·인증·오류 형식의 변경 전후 예시, 전환 절차, 확인 방법과 롤백(rollback) 조건을 제공합니다.
  • 병행 운영과 관측: 구 버전과 새 버전을 함께 운영하며 실제 사용량과 오류율을 확인하고, 공지한 지원 기간과 종료 기준을 채운 뒤 구 버전을 제거합니다.
  • gateway와 캐시: 버전 라우팅뿐 아니라 헤더 정규화, Vary 전달, 캐시 키, rate limit, 관측 지표를 같은 버전 축으로 맞춥니다.
  • 점진적 배포: canary 배포는 새 구현의 배포 위험을 줄이는 전략이며, 기존 클라이언트와의 호환을 지키는 버전 관리를 대신하지 않습니다.

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

더 깊이: 폐기 예고를 응답 헤더로 알리는 표준

공지 페이지와 별개로, 폐기 예고를 응답 자체에 실어 클라이언트 코드가 알아차리게 하는 표준 헤더가 있습니다. 2025년 3월에 나온 RFC 9745는 Deprecation 응답 헤더를 정의합니다. 값은 구조화 필드(Structured Field)의 날짜라서 Deprecation: @1688169599처럼 @ 뒤에 유닉스 시각을 적으며, 미래 시각이면 그때 폐기될 예정이라는 뜻이고 과거 시각이면 이미 폐기되었다는 뜻입니다. 폐기 정책을 설명한 문서는 rel="deprecation" 링크로 함께 가리킬 수 있습니다.

응답을 언제 멈출지는 RFC 8594의 Sunset 헤더로 따로 알립니다. RFC 9745는 Sunset의 시각이 Deprecation의 시각보다 이르면 안 된다고 정하고, 역사적인 이유로 Sunset은 HTTP의 일반 날짜 형식을 써서 두 헤더의 날짜 표기가 다르다는 점도 짚습니다. 또 폐기 자체는 자원의 동작을 바꾸지 않으며 이 헤더는 힌트로 다뤄야 한다고 밝힙니다. 그래서 v1 controller의 응답에 두 헤더를 붙여도 응답의 의미는 그대로이고, 실제 제거는 병행 운영과 종료 기준에 따라 따로 결정합니다.


정리

  • 하위 호환 변경은 지원 중인 클라이언트가 수정 없이 같은 의미로 계속 동작하는 변경이고, 깨지는 변경은 기존 요청이 실패하거나 응답 의미가 달라지는 변경입니다.
  • Nest는 URI, 헤더, Accept 미디어 타입, 사용자 정의 extractor로 버전을 꺼내며, @Controller()의 version 옵션과 @Version()으로 지원 버전을 지정합니다.
  • defaultVersion은 버전 메타데이터가 없는 route의 기본값일 뿐, 버전 없는 요청의 fallback이 아닙니다. 버전 없이 접근할 route는 VERSION_NEUTRAL로 선언합니다.
  • 헤더나 미디어 타입으로 버전을 고르면 Vary와 캐시 키를 그 값으로 나누어야 하며, 보장할 수 없으면 캐시하지 않습니다.
  • 새 버전이 필요한지는 추출 방식이 아니라 클라이언트 호환성으로 판단하고, 문서·폐기 예고·병행 운영·종료 조건을 함께 계획합니다.

연습 문제

1. 응답 필드 추가는 항상 안전한가

기존 앱 A는 id·name·email만 읽고 모르는 JSON 필드는 무시합니다. 앱 B는 이 세 필드만 허용하는 엄격한 schema로 응답을 검증합니다. 서버가 선택 필드 phoneNumber를 추가하면 A와 B는 각각 어떻게 될까요? 또 요청의 선택 매개변수 limit의 기본값을 20에서 100으로 바꾸는 변경은 “필드를 추가하지 않았으니 안전하다”고 판단해도 될까요?

해설 보기

A는 새 필드를 무시하므로 계속 동작하지만, B는 알 수 없는 필드를 거부할 수 있습니다. 필드 추가가 호환되는 변경이라는 판단은 실제로 지원하는 클라이언트가 모르는 필드를 어떻게 처리하는지에 달려 있습니다.

limit을 생략한 기존 요청은 결과 크기와 처리 비용이 최대 다섯 배까지 늘어날 수 있으므로, 필드를 추가하지 않았더라도 기존 동작이 달라지는 변경입니다. 어떤 앱과 요청을 지원할지 확인한 뒤, 기존 기본값을 유지하거나 새 버전과 이전 계획으로 분리해야 합니다.

2. 버전 없는 요청

이 글의 URI 방식 설정(setGlobalPrefix('api'), defaultVersion: '1')에서 UsersController는 version: '1', HealthController는 VERSION_NEUTRAL입니다. GET /api/users, GET /api/v1/users, GET /api/health, GET /api/v1/health는 각각 어떤 결과가 될까요?

해설 보기

GET /api/v1/users만 v1 목록을 반환하고, GET /api/users는 버전 세그먼트가 없으므로 404 Not Found입니다. defaultVersion은 버전 없는 요청을 v1으로 보내 주지 않습니다.

VERSION_NEUTRAL controller는 URI 방식에서 경로에 버전이 붙지 않으므로 GET /api/health가 { status: 'ok' }를 반환하고, GET /api/v1/health는 일치하는 route가 없어 404입니다.

3. extractor가 빈 문자열을 반환하면

이 글의 사용자 정의 extractor를 쓰고 defaultVersion은 설정하지 않았습니다. UsersController는 version: '1', HealthController는 VERSION_NEUTRAL입니다. x-api-version 헤더 없이 GET /api/users와 GET /api/health를 보내면 각각 어떻게 될까요?

해설 보기

헤더가 없으므로 extractor는 빈 문자열을 반환합니다. 공식 문서에 따르면 이때는 버전이 지정된 route가 하나도 일치하지 않으므로 GET /api/users는 404가 됩니다.

VERSION_NEUTRAL route는 요청에 버전이 없어도 일치하므로 GET /api/health는 정상 응답합니다. 헤더 없는 요청을 v1으로 처리하고 싶다면 extractor가 '1'을 반환하도록 명시하는 편이 의도를 분명하게 드러냅니다.

이것으로 5장을 마칩니다. 5장에서는 자원 중심 URI 설계, DTO 검증, Swagger 문서화, 버전 관리까지 클라이언트와의 약속을 지키며 API를 바꾸어 가는 흐름을 이어 보았습니다. 다음 장의 오래 걸리는 요청과 작업 큐 분리부터는 요청 처리 안에서 끝내기 어려운 작업을 큐로 분리해 비동기로 처리하는 방법을 다룹니다.