버전 관리와 API 진화 전략
안녕하세요! 지난 절에서 DTO와 유효성 검사를 통해 견고한 API를 만들고, Swagger를 이용해 효율적으로 문서화하는 방법을 알아보았습니다. 이제 5장의 마지막 절로, 장기적으로 안정적이고 유지보수 가능한 API를 구축하기 위한 필수적인 전략인 버전 관리(Versioning) 와 API 진화 전략에 대해 논의하겠습니다.
API는 클라이언트(웹, 모바일 앱, 다른 서비스)와의 계약과 같습니다. 한 번 배포된 API를 변경하면 기존 클라이언트의 동작에 영향을 줄 수 있습니다. 따라서 API의 변경이 불가피할 때, 기존 클라이언트와의 호환성을 유지하면서 새로운 기능을 도입하거나 개선 사항을 적용할 수 있는 명확한 전략이 필요합니다. 이것이 바로 API 버전 관리의 목적입니다.
API 버전 관리의 중요성
API는 시간이 지남에 따라 필연적으로 변경됩니다. 요구사항이 바뀌고, 새로운 기능이 추가되며, 기존 기능이 개선되거나 제거될 수 있습니다. 이러한 변경 사항이 기존 클라이언트를 "깨뜨리지(break)" 않으면서 API를 발전시키기 위해 버전 관리는 필수적입니다.
API 변경의 유형
- 하위 호환성 유지 변경 (Non-breaking Change): 기존 클라이언트가 아무런 수정 없이 계속 작동할 수 있는 변경입니다.
- 새로운 엔드포인트 추가
- 기존 응답에 새로운 필드 추가 (클라이언트는 모르는 필드를 무시할 수 있어야 함)
- 새로운 요청 파라미터 추가 (선택적)
- 하위 호환성 파괴 변경 (Breaking Change): 기존 클라이언트가 더 이상 정상적으로 작동하지 못하게 하는 변경입니다.
- 기존 엔드포인트 제거 또는 경로 변경
- 기존 응답에서 필수 필드 제거 또는 이름 변경
- 기존 요청 파라미터 제거 또는 이름/타입 변경 (필수)
- 응답 데이터 구조의 대대적인 변경
하위 호환성 파괴 변경이 발생할 때, 버전 관리는 기존 클라이언트가 이전 버전의 API를 계속 사용할 수 있도록 하면서, 새로운 클라이언트가 새로운 버전의 API를 사용할 수 있도록 하는 메커니즘을 제공합니다.
API 버전 관리 전략
API 버전 관리를 위한 몇 가지 일반적인 전략이 있으며, 각각 장단점을 가집니다. NestJS는 모든 방식을 유연하게 지원합니다.
URI(URL) 기반 버전 관리
가장 흔하고 직관적인 방법입니다. API 경로에 버전 번호를 포함시킵니다.
예시
GET /api/v1/users
GET /api/v2/users
장점
- 가장 명확하고 이해하기 쉽습니다.
- 라우팅이 간단하고 RESTful 원칙에 부합합니다.
- 브라우저에서 직접 테스트하기 쉽습니다.
단점
- URI 자체가 변경되므로, 클라이언트 측에서 버전 변경 시 모든 URI를 수정해야 합니다.
- 같은 리소스에 대해 버전별로 다른 URI를 가지게 됩니다.
NestJS 구현
NestJS에서는 @Version()
데코레이터를 사용하여 쉽게 구현할 수 있습니다. 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 기반)
app.enableVersioning({
type: VersioningType.URI, // URI 기반 버전 관리 사용
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', in: 'header', name: 'JWT' },
'access-token'
)
.build();
const document = SwaggerModule.createDocument(app, config);
SwaggerModule.setup('api', app, document);
await app.listen(3000);
}
bootstrap();
컨트롤러에서 @Version()
데코레이터를 사용하여 버전을 지정합니다.
// src/users/users.controller.ts (업데이트 - v1, v2)
import { Controller, Get, Post, Body, Param, Patch, Delete, HttpCode, HttpStatus, NotFoundException, Version } from '@nestjs/common';
import { UsersService, User } from './users.service';
import { CreateUserDto } from './dto/create-user.dto';
import { UpdateUserDto } from './dto/update-user.dto';
import { ApiTags, ApiOperation, ApiResponse, ApiParam, ApiBody, ApiSecurity } from '@nestjs/swagger';
// Version 1 Controller
@ApiTags('users - v1') // Swagger 태그도 버전별로 분리하여 명확하게 합니다.
@Controller('users')
@Version('1') // 이 컨트롤러의 모든 라우트는 v1 API로 동작합니다. (예: /v1/users)
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 Controller (가상의 변경: email 필드 제거, phoneNumber 필드 추가 등)
// 실제 User 인터페이스나 DTO도 v2에 맞게 변경되어야 합니다.
export interface UserV2 {
id: number;
name: string;
phoneNumber?: string; // v2에서 추가될 필드
}
@ApiTags('users - v2')
@Controller('users')
@Version('2') // 이 컨트롤러의 모든 라우트는 v2 API로 동작합니다. (예: /v2/users)
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/v1/users
또는 http://localhost:3000/v2/users
로 요청하여 원하는 버전의 API에 접근할 수 있습니다.
헤더(Header) 기반 버전 관리
요청 헤더에 커스텀 헤더 필드를 추가하여 버전을 명시하는 방법입니다.
예시
GET /api/users
- Header:
X-API-Version: 1
- Header:
X-API-Version: 2
장점
- URI가 깔끔하고 리소스 중심적입니다.
- 캐싱에 유리할 수 있습니다 (같은 URI지만 헤더가 다름).
단점
- 브라우저에서 직접 테스트하기 어렵습니다 (별도의 도구 필요).
- HTTP 표준 헤더가 아니므로 클라이언트에서 추가적인 설정이 필요합니다.
NestJS 구현
main.ts
에서 VersioningType.HEADER
를 사용하고, header
옵션으로 헤더 이름을 지정합니다.
// main.ts
app.enableVersioning({
type: VersioningType.HEADER,
header: 'X-API-Version', // 사용할 헤더 이름
defaultVersion: '1',
});
컨트롤러 코드는 URI 기반과 동일하게 @Version('1')
, @Version('2')
를 사용합니다.
미디어 타입(Media Type) 기반 버전 관리
Accept
헤더에 커스텀 미디어 타입을 포함하여 버전을 명시하는 방법입니다. vnd.mycompany.v1+json
과 같은 형식을 사용합니다.
예시
GET /api/users
- Header:
Accept: application/vnd.company.app-v1+json
- Header:
Accept: application/vnd.company.app-v2+json
장점
- RESTful 원칙에 가장 부합하는 방식이라고 알려져 있습니다 (Content Negotiation).
- URI가 변경되지 않습니다.
단점
- 구현이 복잡하고, 클라이언트와 서버 모두에서 미디어 타입 처리가 필요합니다.
- 널리 사용되지 않아 익숙하지 않을 수 있습니다.
NestJS 구현
main.ts
에서 VersioningType.MEDIA_TYPE
을 사용하고, key
옵션으로 미디어 타입의 키를 지정합니다.
// main.ts
app.enableVersioning({
type: VersioningType.MEDIA_TYPE,
key: 'v', // Accept 헤더에서 'v=' 뒤에 오는 값을 버전으로 인식합니다.
// 예: Accept: application/json;v=1
defaultVersion: '1',
});
컨트롤러 코드는 동일하게 @Version('1')
, @Version('2')
를 사용합니다.
API 진화 전략 및 고려사항
버전 관리 방식 외에도, API를 장기적으로 성공적으로 유지하기 위한 전략들이 있습니다.
- 비파괴적 변경 우선: 가능한 한 하위 호환성을 유지하는 방식으로 API를 변경합니다. 새로운 필드를 추가하거나, 선택적 파라미터를 추가하는 등 기존 클라이언트를 깨뜨리지 않는 변경을 선호합니다.
- 충분한 통지: 하위 호환성을 파괴하는 변경(Breaking Change)이 발생할 경우, 클라이언트 개발자들에게 충분한 시간을 가지고 미리 통지해야 합니다. (예: 이메일, 개발자 포털 공지)
- 구 버전 지원 기간 명시: 구 버전 API를 언제까지 지원할 것인지 명확히 공지하고, 해당 기간이 지나면 구 버전 API를 비활성화하거나 제거합니다. 이 기간 동안 클라이언트는 새 버전으로 마이그레이션할 시간을 가집니다.
- 문서화의 중요성: 모든 API 버전 변경 사항은 Swagger와 같은 도구를 통해 명확하게 문서화되어야 합니다. 어떤 필드가 추가/제거되었는지, 어떤 동작이 변경되었는지 등을 자세히 기록합니다.
- 마이그레이션 가이드 제공: 클라이언트 개발자가 구 버전에서 신 버전으로 쉽게 전환할 수 있도록 자세한 마이그레이션 가이드를 제공하는 것이 좋습니다.
- API Gateway 활용: 복잡한 마이크로서비스 아키텍처에서는 API Gateway를 사용하여 버전 라우팅, 구 버전 API의 트래픽 제어 등을 유연하게 관리할 수 있습니다.
- 점진적 배포 (Canary Deployment): 새 버전 API를 소수의 사용자에게 먼저 배포하여 문제가 없는지 확인한 후, 점진적으로 모든 사용자에게 확대 배포하는 전략을 고려할 수 있습니다.
API 버전 관리는 단순히 기술적인 선택을 넘어, 클라이언트와의 관계, 서비스의 안정성, 그리고 장기적인 유지보수 전략과 밀접하게 연결되어 있습니다. 프로젝트의 규모, 클라이언트의 다양성, 변경 빈도 등을 고려하여 가장 적합한 버전 관리 전략을 선택하고, 일관되게 적용하는 것이 중요합니다.
이것으로 5장 "REST API 개발"을 모두 마칩니다. 이제 여러분은 RESTful API의 설계 원칙부터 구현, DTO와 유효성 검사, 자동 문서화, 그리고 버전 관리 전략까지, 안정적이고 효율적인 API를 구축하는 데 필요한 핵심 지식을 갖추게 되었습니다.
다음 장에서는 NestJS 애플리케이션의 성능을 최적화하고 확장성을 고려한 아키텍처를 설계하는 방법에 대해 알아보겠습니다.