junyeokk
Blog
NestJS·2025. 11. 15

NestJS Swagger / OpenAPI

API를 만들면 문서화가 필요하다. 프론트엔드 개발자가 "이 엔드포인트 파라미터가 뭐야?", "응답 형식이 어떻게 돼?" 하고 물어볼 때마다 슬랙에서 답하고 있으면 생산성이 바닥을 친다. Postman 컬렉션을 공유하거나 Notion에 수동으로 정리하는 방법도 있지만, 코드가 바뀔 때마다 문서를 따로 업데이트해야 하니 결국 문서와 실제 API가 어긋나게 된다.

OpenAPI(구 Swagger) 스펙은 이 문제를 해결하기 위한 표준이다. API의 엔드포인트, 파라미터, 요청/응답 스키마를 JSON 또는 YAML로 기술하는 명세인데, 이 명세를 기반으로 Swagger UI라는 인터랙티브 문서를 자동 생성할 수 있다. 브라우저에서 바로 API를 테스트할 수도 있어서 Postman 없이도 빠르게 확인이 가능하다.

NestJS는 @nestjs/swagger 패키지를 통해 데코레이터 기반으로 OpenAPI 명세를 코드에서 직접 생성한다. 컨트롤러와 DTO에 데코레이터를 붙이면 코드가 곧 문서가 되는 구조다. 코드를 수정하면 문서도 자동으로 바뀌니 불일치 문제가 원천적으로 사라진다.


설치와 기본 설정

bash
npm install @nestjs/swagger

main.ts에서 Swagger 모듈을 초기화한다.

typescript
import { NestFactory } from '@nestjs/core';
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { AppModule } from './app.module';

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

  const config = new DocumentBuilder()
    .setTitle('My API')
    .setDescription('API 설명')
    .setVersion('1.0')
    .addBearerAuth()
    .build();

  const document = SwaggerModule.createDocument(app, config);
  SwaggerModule.setup('api-docs', app, document);

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

DocumentBuilder는 빌더 패턴으로 OpenAPI 문서의 메타정보를 설정한다. SwaggerModule.createDocument()가 앱에 등록된 모든 컨트롤러를 스캔해서 OpenAPI 명세 객체를 생성하고, SwaggerModule.setup()이 그 명세를 기반으로 Swagger UI를 지정한 경로에 마운트한다. 이 경우 http://localhost:3000/api-docs에서 문서를 볼 수 있다.

DocumentBuilder에서 자주 쓰는 메서드들:

메서드역할
setTitle()API 제목
setDescription()API 설명
setVersion()API 버전
addBearerAuth()JWT Bearer 인증 스키마 추가
addApiKey()API Key 인증 스키마 추가
addCookieAuth()쿠키 기반 인증 스키마 추가
addTag()태그 추가 (엔드포인트 그룹핑용)
addServer()서버 URL 추가 (스테이지별 분리)

DTO 문서화: @ApiProperty

SwaggerModule이 컨트롤러의 @Body(), @Query(), @Param() 데코레이터는 자동으로 감지하지만, DTO 내부의 각 필드가 무엇인지는 알 수 없다. TypeScript의 타입 정보는 런타임에 사라지기 때문이다. 그래서 DTO 프로퍼티에 @ApiProperty()를 붙여서 명시적으로 스키마를 알려줘야 한다.

typescript
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';

export class CreateUserDto {
  @ApiProperty({
    description: '사용자 이메일',
    example: 'user@example.com',
  })
  email: string;

  @ApiProperty({
    description: '비밀번호 (8자 이상)',
    minLength: 8,
    example: 'securePass123',
  })
  password: string;

  @ApiPropertyOptional({
    description: '닉네임',
    example: '홍길동',
  })
  nickname?: string;
}

@ApiProperty()에 전달할 수 있는 주요 옵션:

옵션설명
description필드 설명
example예시 값 (Swagger UI의 "Try it out"에 자동 입력됨)
required필수 여부 (기본값 true)
default기본값
enum열거형 값 목록
type타입 명시 (보통 자동 추론되지만, 배열이나 중첩 객체에서 필요)
isArray배열 여부
minimum / maximum숫자 범위
minLength / maxLength문자열 길이 범위

@ApiPropertyOptional()@ApiProperty({ required: false })의 단축형이다. 선택적 필드에 사용하면 코드가 더 읽기 좋다.

중첩 객체와 배열

DTO가 다른 DTO를 포함하는 경우 type을 명시해야 Swagger가 정확한 스키마를 생성한다.

typescript
export class CreateOrderDto {
  @ApiProperty({
    description: '주문 항목 목록',
    type: [OrderItemDto],
  })
  items: OrderItemDto[];

  @ApiProperty({
    description: '배송 주소',
    type: AddressDto,
  })
  address: AddressDto;
}

배열의 경우 type: [OrderItemDto] 또는 type: OrderItemDto, isArray: true 둘 다 가능하다.

enum 타입

TypeScript enum을 그대로 전달하면 Swagger UI에서 드롭다운으로 표시된다.

typescript
export enum UserRole {
  ADMIN = 'admin',
  USER = 'user',
  GUEST = 'guest',
}

export class UpdateRoleDto {
  @ApiProperty({
    enum: UserRole,
    description: '변경할 역할',
  })
  role: UserRole;
}

CLI 플러그인: 데코레이터 자동 생성

매번 모든 DTO 필드에 @ApiProperty()를 붙이는 건 번거롭다. @nestjs/swaggerCLI 플러그인을 제공해서 빌드 타임에 TypeScript AST를 분석하고 데코레이터를 자동 삽입한다.

nest-cli.json에서 플러그인을 활성화한다:

json
{
  "compilerOptions": {
    "plugins": [
      {
        "name": "@nestjs/swagger",
        "options": {
          "classValidatorShim": true,
          "introspectComments": true,
          "dtoFileNameSuffix": [".dto.ts", ".entity.ts"]
        }
      }
    ]
  }
}

주요 옵션:

  • classValidatorShim: class-validator 데코레이터(@IsString(), @IsOptional() 등)에서 타입과 required 정보를 자동 추론한다. @IsOptional()이 붙으면 required: false로 처리.
  • introspectComments: JSDoc 주석을 description으로 자동 변환한다.
  • dtoFileNameSuffix: 플러그인이 처리할 파일 이름 패턴. 기본값은 .dto.ts.entity.ts.

플러그인이 활성화되면 이런 코드가:

typescript
export class CreateUserDto {
  /** 사용자 이메일 */
  @IsEmail()
  email: string;

  /** 비밀번호 (8자 이상) */
  @IsString()
  @MinLength(8)
  password: string;

  /** 닉네임 */
  @IsOptional()
  @IsString()
  nickname?: string;
}

빌드 시 자동으로 @ApiProperty() 데코레이터가 붙은 것처럼 동작한다. JSDoc 주석이 description이 되고, @IsOptional()?가 required: false로 변환된다. 다만 example 같은 옵션은 자동 추론이 안 되니 필요하면 수동으로 추가해야 한다.

주의점: CLI 플러그인은 NestJS CLI(nest build, nest start)로 빌드할 때만 동작한다. tsc로 직접 컴파일하면 플러그인이 적용되지 않는다. SWC 컴파일러를 사용하는 경우에도 플러그인이 지원되지만, nest-cli.json"builder": "swc" 설정이 필요하다.


컨트롤러 데코레이터

컨트롤러와 각 엔드포인트에도 데코레이터를 붙여서 문서를 풍부하게 만들 수 있다.

@ApiTags

컨트롤러 단위로 태그를 붙여서 Swagger UI에서 그룹핑한다.

typescript
@ApiTags('users')
@Controller('users')
export class UsersController {
  // 이 컨트롤러의 모든 엔드포인트가 'users' 태그 아래 그룹핑됨
}

@ApiOperation

각 엔드포인트의 설명을 추가한다.

typescript
@ApiOperation({
  summary: '사용자 생성',
  description: '새로운 사용자를 생성하고 생성된 사용자 정보를 반환합니다.',
})
@Post()
create(@Body() dto: CreateUserDto) {
  return this.usersService.create(dto);
}

summary는 엔드포인트 목록에서 한 줄로 표시되고, description은 펼쳤을 때 상세 설명으로 보인다.

@ApiResponse

응답 스키마를 문서화한다. 하나의 엔드포인트에 여러 응답 코드를 정의할 수 있다.

typescript
@ApiResponse({
  status: 201,
  description: '사용자 생성 성공',
  type: UserResponseDto,
})
@ApiResponse({
  status: 409,
  description: '이미 존재하는 이메일',
})
@Post()
create(@Body() dto: CreateUserDto): Promise<UserResponseDto> {
  return this.usersService.create(dto);
}

자주 쓰는 상태 코드별 단축 데코레이터도 있다:

typescript
@ApiOkResponse({ type: UserResponseDto })           // 200
@ApiCreatedResponse({ type: UserResponseDto })       // 201
@ApiBadRequestResponse({ description: '잘못된 입력' }) // 400
@ApiUnauthorizedResponse({ description: '인증 필요' }) // 401
@ApiNotFoundResponse({ description: '사용자 없음' })   // 404
@ApiConflictResponse({ description: '이메일 중복' })    // 409

@ApiParam과 @ApiQuery

경로 파라미터와 쿼리 파라미터를 문서화한다. NestJS가 @Param()@Query()에서 기본 정보는 자동 추출하지만, 설명이나 예시를 추가하려면 이 데코레이터를 사용한다.

typescript
@ApiParam({
  name: 'id',
  description: '사용자 ID',
  example: 'abc-123',
})
@ApiQuery({
  name: 'role',
  enum: UserRole,
  required: false,
  description: '역할 필터',
})
@Get(':id')
findOne(
  @Param('id') id: string,
  @Query('role') role?: UserRole,
) {
  return this.usersService.findOne(id, role);
}

인증 문서화

DocumentBuilder에서 인증 스키마를 추가했으면, 컨트롤러에서 어떤 엔드포인트가 인증을 필요로 하는지 표시해야 한다.

typescript
// main.ts에서
const config = new DocumentBuilder()
  .addBearerAuth()  // 기본 이름: 'bearer'
  .build();

// 컨트롤러에서
@ApiBearerAuth()  // 이 엔드포인트는 JWT 필요
@UseGuards(JwtAuthGuard)
@Get('profile')
getProfile() { ... }

Swagger UI에서 상단의 "Authorize" 버튼을 클릭하면 토큰을 입력할 수 있고, 이후 "Try it out"으로 요청을 보낼 때 자동으로 Authorization: Bearer <token> 헤더가 추가된다.

커스텀 이름을 사용할 수도 있다:

typescript
// 인증 스키마에 이름 지정
const config = new DocumentBuilder()
  .addBearerAuth(
    { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
    'access-token',  // 스키마 이름
  )
  .build();

// 해당 이름으로 참조
@ApiBearerAuth('access-token')

API Key 인증의 경우:

typescript
const config = new DocumentBuilder()
  .addApiKey(
    { type: 'apiKey', name: 'X-API-KEY', in: 'header' },
    'api-key',
  )
  .build();

// 컨트롤러
@ApiSecurity('api-key')

Mapped Types: DTO 재사용

실제 프로젝트에서는 Create, Update, Response DTO가 비슷한 필드를 공유한다. @nestjs/swagger@nestjs/mapped-types와 동일한 유틸리티 타입을 제공하는데, Swagger 메타데이터까지 함께 매핑한다는 점이 다르다.

typescript
// ⚠️ @nestjs/mapped-types가 아닌 @nestjs/swagger에서 import해야
// Swagger 메타데이터가 유지됨
import { PartialType, PickType, OmitType, IntersectionType } from '@nestjs/swagger';

PartialType

모든 필드를 optional로 만든다. Update DTO에서 가장 많이 쓴다.

typescript
export class UpdateUserDto extends PartialType(CreateUserDto) {}

CreateUserDto의 모든 필드가 required: false가 되고, @ApiProperty() 메타데이터도 그대로 유지된다.

PickType

특정 필드만 선택한다.

typescript
export class LoginDto extends PickType(CreateUserDto, ['email', 'password']) {}

OmitType

특정 필드를 제외한다.

typescript
export class UserResponseDto extends OmitType(CreateUserDto, ['password']) {}

IntersectionType

두 DTO를 합친다.

typescript
export class CreateUserWithProfileDto extends IntersectionType(
  CreateUserDto,
  CreateProfileDto,
) {}

이 유틸리티들을 조합할 수도 있다:

typescript
// CreateUserDto에서 password 제외하고, 나머지를 optional로
export class UpdateUserDto extends PartialType(
  OmitType(CreateUserDto, ['password']),
) {}

핵심 포인트: 반드시 @nestjs/swagger에서 import해야 한다. @nestjs/mapped-types에서 import하면 런타임 타입은 올바르게 매핑되지만 Swagger 메타데이터가 누락되어 문서에 빈 스키마가 표시된다.


고급 설정

문서 분리 (멀티 Swagger)

관리자용 API와 일반 사용자용 API를 별도 문서로 분리할 수 있다.

typescript
// 일반 사용자 API 문서
const userConfig = new DocumentBuilder()
  .setTitle('User API')
  .build();
const userDocument = SwaggerModule.createDocument(app, userConfig, {
  include: [UsersModule, OrdersModule],
});
SwaggerModule.setup('api-docs/user', app, userDocument);

// 관리자 API 문서
const adminConfig = new DocumentBuilder()
  .setTitle('Admin API')
  .build();
const adminDocument = SwaggerModule.createDocument(app, adminConfig, {
  include: [AdminModule],
});
SwaggerModule.setup('api-docs/admin', app, adminDocument);

createDocument()의 세 번째 인자로 include 배열을 전달하면 해당 모듈에 속한 컨트롤러만 문서에 포함된다.

JSON/YAML 엔드포인트

Swagger UI 외에 원본 OpenAPI 명세를 JSON이나 YAML로 제공할 수 있다. 프론트엔드 팀에서 swagger-typescript-api 같은 도구로 타입을 자동 생성할 때 유용하다.

typescript
SwaggerModule.setup('api-docs', app, document, {
  jsonDocumentUrl: 'api-docs/json',
  yamlDocumentUrl: 'api-docs/yaml',
});

이렇게 설정하면 /api-docs/json에서 OpenAPI JSON을, /api-docs/yaml에서 YAML 형식을 받을 수 있다.

Swagger UI 커스터마이징

setup()의 네 번째 인자로 UI 옵션을 전달할 수 있다.

typescript
SwaggerModule.setup('api-docs', app, document, {
  swaggerOptions: {
    persistAuthorization: true,  // 새로고침해도 인증 토큰 유지
    tagsSorter: 'alpha',         // 태그 알파벳 순 정렬
    operationsSorter: 'alpha',   // 엔드포인트 알파벳 순 정렬
    docExpansion: 'none',        // 초기에 모든 태그 접힌 상태
  },
  customCss: '.swagger-ui .topbar { display: none }',  // 상단바 숨기기
  customSiteTitle: 'My API Docs',
});

persistAuthorization: true는 특히 유용하다. 기본적으로 Swagger UI를 새로고침하면 입력한 토큰이 사라지는데, 이 옵션을 켜면 localStorage에 저장되어 유지된다.

프로덕션에서 비활성화

API 문서를 프로덕션 환경에서 노출하면 보안 위험이 있다. 환경 변수로 조건부 활성화하는 게 일반적이다.

typescript
if (process.env.NODE_ENV !== 'production') {
  const config = new DocumentBuilder().setTitle('My API').build();
  const document = SwaggerModule.createDocument(app, config);
  SwaggerModule.setup('api-docs', app, document);
}

또는 프로덕션에서도 문서가 필요하다면 Basic Auth 미들웨어를 앞에 두는 방법도 있다.


@ApiExtraModels와 제네릭 응답

NestJS Swagger는 DTO가 컨트롤러에서 직접 참조되지 않으면 스키마에 포함하지 않는다. 제네릭 응답 래퍼를 사용할 때 이 문제가 발생한다.

typescript
// 공통 응답 래퍼
export class ApiResponseWrapper<T> {
  success: boolean;
  data: T;
  message?: string;
}

TypeScript 제네릭은 런타임에 사라지므로 Swagger가 T가 무엇인지 알 수 없다. @ApiExtraModels()로 스키마를 등록하고, getSchemaPath()$ref 참조를 사용해야 한다.

typescript
@ApiExtraModels(ApiResponseWrapper, UserResponseDto)
@ApiOkResponse({
  schema: {
    allOf: [
      { $ref: getSchemaPath(ApiResponseWrapper) },
      {
        properties: {
          data: { $ref: getSchemaPath(UserResponseDto) },
        },
      },
    ],
  },
})
@Get()
findAll() { ... }

이 패턴이 매번 반복되면 헬퍼 함수로 추출하면 편하다:

typescript
import { applyDecorators, Type } from '@nestjs/common';
import { ApiExtraModels, ApiOkResponse, getSchemaPath } from '@nestjs/swagger';

export const ApiWrappedResponse = <T extends Type<any>>(dataDto: T) =>
  applyDecorators(
    ApiExtraModels(ApiResponseWrapper, dataDto),
    ApiOkResponse({
      schema: {
        allOf: [
          { $ref: getSchemaPath(ApiResponseWrapper) },
          {
            properties: {
              data: { $ref: getSchemaPath(dataDto) },
            },
          },
        ],
      },
    }),
  );

// 사용
@ApiWrappedResponse(UserResponseDto)
@Get()
findAll() { ... }

applyDecorators()는 NestJS의 유틸리티로, 여러 데코레이터를 하나로 합성한다.


파일 업로드 문서화

multipart/form-data 파일 업로드도 Swagger에서 문서화할 수 있다.

typescript
@ApiConsumes('multipart/form-data')
@ApiBody({
  schema: {
    type: 'object',
    properties: {
      file: {
        type: 'string',
        format: 'binary',
      },
      description: {
        type: 'string',
      },
    },
  },
})
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
upload(
  @UploadedFile() file: Express.Multer.File,
  @Body('description') description: string,
) { ... }

@ApiConsumes('multipart/form-data')가 없으면 Swagger UI에서 파일 업로드 UI가 표시되지 않는다. format: 'binary'로 지정해야 파일 선택 버튼이 나타난다.


실전 패턴 정리

실제 프로젝트에서 Swagger를 효과적으로 사용하는 패턴을 정리하면:

  1. CLI 플러그인을 기본으로 쓰고, 부족한 부분만 수동 데코레이터를 추가한다. 플러그인이 타입과 required를 자동 처리하니 example이나 description만 보강하면 된다.

  2. Mapped Types는 반드시 @nestjs/swagger에서 import한다. 이건 실수하기 쉬운 부분이라 프로젝트 초반에 린트 규칙으로 잡는 게 좋다.

  3. @ApiTags로 모듈별 그룹핑을 확실히 한다. 태그 없이 엔드포인트가 30개 넘어가면 문서를 읽을 수가 없다.

  4. 에러 응답도 문서화한다. 성공 응답만 문서화하면 프론트엔드에서 에러 핸들링을 구현할 때 또 물어봐야 한다. 주요 에러 케이스(400, 401, 404, 409 등)의 응답 형식을 명시하자.

  5. 프로덕션에서는 숨기거나 인증을 건다. OpenAPI 명세에는 API의 전체 구조가 담겨 있어서, 외부에 노출되면 공격 표면이 넓어진다.


정리

  • CLI 플러그인으로 DTO 데코레이터를 자동 생성하고, example이나 description만 수동 보강하면 코드-문서 불일치를 원천 차단할 수 있다
  • Mapped Types(PartialType, PickType 등)는 반드시 @nestjs/swagger에서 import해야 Swagger 메타데이터가 유지된다
  • 프로덕션에서는 Swagger UI를 비활성화하거나 인증을 걸어서 API 구조 노출을 방지해야 한다

관련 문서