NestJS Swagger 데코레이터 심화

NestJS Swagger 통합이란?

NestJS는 @nestjs/swagger 패키지로 OpenAPI 3.0 문서를 자동 생성합니다. 하지만 기본 설정만으로는 불완전한 문서가 만들어집니다. 데코레이터를 정밀하게 활용해야 프론트엔드 개발자가 바로 사용할 수 있는 API 문서가 됩니다. 이 글에서는 @ApiProperty부터 제네릭 응답 래퍼, 파일 업로드, 인증 헤더, CLI 플러그인까지 심화 패턴을 다룹니다.

기본 설정과 SwaggerModule

먼저 Swagger UI를 부트스트랩에 등록하는 기본 설정입니다.

// main.ts
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(
      { type: 'http', scheme: 'bearer', bearerFormat: 'JWT' },
      'access-token',
    )
    .addApiKey(
      { type: 'apiKey', in: 'header', name: 'X-API-KEY' },
      'api-key',
    )
    .addServer('https://api.example.com', 'Production')
    .addServer('http://localhost:3000', 'Local')
    .build();

  const document = SwaggerModule.createDocument(app, config);
  SwaggerModule.setup('docs', app, document, {
    swaggerOptions: {
      persistAuthorization: true,  // 새로고침 시 토큰 유지
      tagsSorter: 'alpha',
      operationsSorter: 'alpha',
    },
  });

  await app.listen(3000);
}

DTO 데코레이터 정밀 설정

DTO의 @ApiProperty는 Swagger 문서의 핵심입니다. 타입, 예시, 제약 조건을 명확히 정의해야 합니다.

import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsEmail, IsEnum, IsInt, Min, Max, IsOptional } from 'class-validator';

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

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

  @ApiProperty({
    description: '사용자 이름',
    example: '홍길동',
    minLength: 2,
    maxLength: 50,
  })
  name: string;

  @ApiProperty({
    description: '사용자 나이',
    example: 25,
    minimum: 1,
    maximum: 150,
    type: Number,
  })
  @IsInt()
  @Min(1)
  @Max(150)
  age: number;

  @ApiProperty({
    description: '사용자 역할',
    enum: UserRole,
    enumName: 'UserRole',  // 스키마에 이름 부여
    default: UserRole.USER,
  })
  @IsEnum(UserRole)
  role: UserRole;

  @ApiPropertyOptional({
    description: '프로필 이미지 URL',
    example: 'https://cdn.example.com/avatar.jpg',
    nullable: true,
  })
  @IsOptional()
  avatarUrl?: string | null;

  @ApiProperty({
    description: '관심 태그 목록',
    example: ['nestjs', 'typescript'],
    type: [String],
    isArray: true,
  })
  tags: string[];
}

중첩 객체와 배열 타입

중첩 DTO는 Swagger가 자동 인식하지 못하는 경우가 많습니다. type 속성을 명시해야 합니다. NestJS DI Scope 설계와 마찬가지로, 타입 명확성이 핵심입니다.

// 중첩 DTO
export class AddressDto {
  @ApiProperty({ example: '서울시 강남구' })
  street: string;

  @ApiProperty({ example: '06000' })
  zipCode: string;
}

export class CreateCompanyDto {
  @ApiProperty({ example: 'ACME Corp' })
  name: string;

  // 단일 중첩 객체
  @ApiProperty({ type: () => AddressDto })
  headquarters: AddressDto;

  // 중첩 객체 배열
  @ApiProperty({ type: () => [AddressDto], isArray: true })
  branches: AddressDto[];

  // Map/Record 타입
  @ApiProperty({
    type: 'object',
    additionalProperties: { type: 'string' },
    example: { ko: '에이씨엠이', en: 'ACME' },
  })
  localizedNames: Record<string, string>;
}

응답 타입 데코레이터

컨트롤러 메서드에 응답 타입을 명시하면 Swagger가 정확한 응답 스키마를 생성합니다.

import {
  ApiTags, ApiOperation, ApiResponse, ApiParam,
  ApiQuery, ApiBearerAuth, ApiBody, ApiConsumes,
} from '@nestjs/swagger';

@ApiTags('Users')
@ApiBearerAuth('access-token')
@Controller('users')
export class UserController {

  @Get()
  @ApiOperation({ summary: '사용자 목록 조회' })
  @ApiQuery({ name: 'page', required: false, type: Number, example: 1 })
  @ApiQuery({ name: 'limit', required: false, type: Number, example: 20 })
  @ApiQuery({
    name: 'role',
    required: false,
    enum: UserRole,
    description: '역할로 필터링',
  })
  @ApiResponse({
    status: 200,
    description: '사용자 목록',
    type: [UserResponseDto],
  })
  findAll(
    @Query('page') page?: number,
    @Query('limit') limit?: number,
    @Query('role') role?: UserRole,
  ): Promise<UserResponseDto[]> {
    return this.userService.findAll({ page, limit, role });
  }

  @Get(':id')
  @ApiOperation({ summary: '사용자 상세 조회' })
  @ApiParam({ name: 'id', type: Number, description: '사용자 ID' })
  @ApiResponse({ status: 200, type: UserResponseDto })
  @ApiResponse({ status: 404, description: '사용자를 찾을 수 없음' })
  findOne(@Param('id', ParseIntPipe) id: number): Promise<UserResponseDto> {
    return this.userService.findOne(id);
  }

  @Post()
  @ApiOperation({ summary: '사용자 생성' })
  @ApiResponse({ status: 201, type: UserResponseDto })
  @ApiResponse({ status: 409, description: '이메일 중복' })
  create(@Body() dto: CreateUserDto): Promise<UserResponseDto> {
    return this.userService.create(dto);
  }
}

제네릭 응답 래퍼 패턴

실전 API는 { data, meta, message } 같은 공통 응답 래퍼를 사용합니다. Swagger에서 제네릭 래퍼를 표현하는 패턴입니다.

// 페이지네이션 메타
export class PaginationMeta {
  @ApiProperty({ example: 1 })
  page: number;

  @ApiProperty({ example: 20 })
  limit: number;

  @ApiProperty({ example: 150 })
  total: number;

  @ApiProperty({ example: 8 })
  totalPages: number;
}

// 제네릭 래퍼를 위한 팩토리 함수
export function ApiPaginatedResponse<T extends Function>(itemType: T) {
  return applyDecorators(
    ApiExtraModels(PaginationMeta, itemType as any),
    ApiResponse({
      status: 200,
      schema: {
        allOf: [
          {
            properties: {
              data: {
                type: 'array',
                items: { $ref: getSchemaPath(itemType as any) },
              },
              meta: { $ref: getSchemaPath(PaginationMeta) },
              message: { type: 'string', example: 'OK' },
            },
          },
        ],
      },
    }),
  );
}

// 사용
@Get()
@ApiPaginatedResponse(UserResponseDto)
findAll(@Query() query: PaginationQueryDto) {
  return this.userService.findAll(query);
}

파일 업로드 문서화

NestJS 파일 업로드 엔드포인트를 Swagger에 정확히 표현하는 방법입니다.

// 단일 파일 업로드
@Post('avatar')
@ApiOperation({ summary: '프로필 이미지 업로드' })
@ApiConsumes('multipart/form-data')
@ApiBody({
  schema: {
    type: 'object',
    properties: {
      file: {
        type: 'string',
        format: 'binary',
        description: '이미지 파일 (JPG, PNG, max 5MB)',
      },
      description: {
        type: 'string',
        example: '프로필 사진',
      },
    },
    required: ['file'],
  },
})
@UseInterceptors(FileInterceptor('file'))
uploadAvatar(
  @UploadedFile() file: Express.Multer.File,
  @Body('description') description?: string,
) {
  return this.uploadService.uploadAvatar(file, description);
}

// 다중 파일 업로드
@Post('gallery')
@ApiConsumes('multipart/form-data')
@ApiBody({
  schema: {
    type: 'object',
    properties: {
      files: {
        type: 'array',
        items: { type: 'string', format: 'binary' },
        description: '최대 10개 이미지',
      },
    },
  },
})
@UseInterceptors(FilesInterceptor('files', 10))
uploadGallery(@UploadedFiles() files: Express.Multer.File[]) {
  return this.uploadService.uploadGallery(files);
}

커스텀 데코레이터 조합

반복되는 Swagger 데코레이터를 커스텀 데코레이터로 묶으면 코드가 깔끔해집니다.

import { applyDecorators } from '@nestjs/common';

// 공통 에러 응답 데코레이터
export function ApiCommonErrors() {
  return applyDecorators(
    ApiResponse({ status: 400, description: '잘못된 요청' }),
    ApiResponse({ status: 401, description: '인증 필요' }),
    ApiResponse({ status: 403, description: '권한 없음' }),
    ApiResponse({ status: 500, description: '서버 오류' }),
  );
}

// 인증 + 역할 데코레이터
export function ApiAuthEndpoint(summary: string) {
  return applyDecorators(
    ApiOperation({ summary }),
    ApiBearerAuth('access-token'),
    ApiCommonErrors(),
  );
}

// 사용 — 한 줄로 정리
@Get(':id')
@ApiAuthEndpoint('사용자 상세 조회')
@ApiResponse({ status: 200, type: UserResponseDto })
@ApiResponse({ status: 404, description: '사용자를 찾을 수 없음' })
findOne(@Param('id') id: number) {
  return this.userService.findOne(id);
}

DTO 상속과 PartialType/PickType

NestJS Swagger는 DTO 변환 유틸리티를 제공하여 중복 정의를 줄입니다.

import {
  PartialType, PickType, OmitType, IntersectionType,
} from '@nestjs/swagger';

// PartialType: 모든 필드를 optional로
export class UpdateUserDto extends PartialType(CreateUserDto) {}

// PickType: 특정 필드만 선택
export class LoginDto extends PickType(CreateUserDto, ['email'] as const) {
  @ApiProperty({ example: 'P@ssw0rd!' })
  password: string;
}

// OmitType: 특정 필드 제외
export class UserListItemDto extends OmitType(UserResponseDto, ['tags'] as const) {}

// IntersectionType: 두 DTO 합치기
export class CreateUserWithAddressDto extends IntersectionType(
  CreateUserDto,
  AddressDto,
) {}

CLI 플러그인으로 보일러플레이트 제거

Swagger CLI 플러그인을 활성화하면 @ApiProperty를 대부분 생략할 수 있습니다. TypeScript 타입에서 자동 추론합니다.

// nest-cli.json
{
  "compilerOptions": {
    "plugins": [
      {
        "name": "@nestjs/swagger",
        "options": {
          "classValidatorShim": true,   // class-validator → Swagger 매핑
          "introspectComments": true,    // JSDoc 주석 → description
          "dtoFileNameSuffix": [".dto.ts", ".entity.ts"]
        }
      }
    ]
  }
}

// 플러그인 활성화 후 — @ApiProperty 생략 가능
export class CreateUserDto {
  /** 사용자 이메일 주소 */
  @IsEmail()
  email: string;    // → 자동으로 type: string, description: '사용자 이메일 주소'

  /** 사용자 나이 */
  @IsInt()
  @Min(1)
  age: number;      // → 자동으로 type: number, minimum: 1

  /** 사용자 역할 */
  role: UserRole;   // → 자동으로 enum 매핑
}

API 그룹화와 태그 전략

대규모 API에서는 태그와 모듈 단위 문서 분리가 중요합니다.

// main.ts — 태그 설명 추가
const config = new DocumentBuilder()
  .setTitle('My API')
  .addTag('Users', '사용자 관리 API')
  .addTag('Auth', '인증·인가 API')
  .addTag('Products', '상품 관리 API')
  .addTag('Admin', '관리자 전용 API')
  .build();

// 모듈별 별도 문서 생성
const publicDoc = SwaggerModule.createDocument(app, config, {
  include: [UserModule, ProductModule, AuthModule],
  operationIdFactory: (controllerKey, methodKey) =>
    `${controllerKey}_${methodKey}`,
});
SwaggerModule.setup('docs/public', app, publicDoc);

const adminDoc = SwaggerModule.createDocument(app, config, {
  include: [AdminModule],
});
SwaggerModule.setup('docs/admin', app, adminDoc);

정리

NestJS Swagger는 단순한 문서 생성기가 아니라 API 계약(Contract)의 코드화입니다. @ApiProperty로 타입을 정밀하게 정의하고, 팩토리 함수로 제네릭 래퍼를 표현하며, CLI 플러그인으로 보일러플레이트를 제거하세요. 커스텀 데코레이터 조합으로 반복 코드를 줄이고, PartialType/PickType으로 DTO 변환을 활용하면 유지보수 가능한 API 문서가 완성됩니다.

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