본문 바로가기

javascript

[nestjs] nestjs swagger

Swagger

https://swagger.io/

오픈소스 기반 문서 자동화 도구로, 바로 테스팅해볼 수 있는 환경도 함께 지원한다.

Controller이 자동 문서화 된 모습

 졸업 프로젝트에서 프론트엔드 분과 협업할 필요가 있다. 이전에 진행했던 프로젝트에서는 API 요청 및 응답에 대한 문서화가 제대로 되지 않아 API 구조에 대한 의견을 주고 받다가 시간을 많이 낭비한 경험이 있다. 작성해 둔 API 관련 정보가 개발 과정에서 변화를 많이 겪었지만 이에 대한 문서는 최신화하지 않아 발생한 문제다.

따라서 이번 프로젝트에서는 swagger을 도입하여 이러한 시행착오를 줄이고자 한다.


@nestjs/swagger

https://docs.nestjs.com/openapi/introduction

nestjs는 추가 모듈 설치를 통해 swagger을 쉽게 사용할 수 있다.

npm install @nestjs/swagger

설정도 엄청 간편하다.

// swagger.ts 파일. 이걸 bootstrap 안에서 실행
import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger';
import { INestApplication } from '@nestjs/common';

export function swaggerSetup(app: INestApplication) {
  const config = new DocumentBuilder()
    .setTitle('팀바나나 API 목록')
    .setDescription('팀 바나나에서 사용하는 API입니다')
    .setVersion('1.0')
    .build();
  const document = SwaggerModule.createDocument(app, config);
  SwaggerModule.setup('api', app, document);
}

// main.ts 파일
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  swaggerSetup(app);
  // .. 이외 많은 use / 설정들
  await app.listen(3000);
}

여기까지 진행하면 swagger 모듈이 우리 환경에 연결된 상태이다. swagger은 2가지 방법으로 접근 가능하다.

  • http://내-주소/api: 웹 기반 사이트로 이동
  • http://내-주소/api-json: 현재 작성된 swagger 문서에 대한 json 형식 제공

간단한 사용법

  • 따로 설정하지 않으면 .dto.ts 또는 .entity.ts로 끝나는 파일을 스키마로 취급한다.
  • 따로 설정하지 않으면 .controller.ts로 끝나는 파일에서만 API 정보를 가져온다.
  • 주석을 문서로 변경하는 옵션은 nest-cli.json 옵션에서 따로 켜줘야 활성화된다.
{
  "$schema": "https://json.schemastore.org/nest-cli",
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "deleteOutDir": true,
    "plugins": [{
      "name":"@nestjs/swagger",
      "options": {
        "introspectComments": true,
        "controllerKeyOfComment": "summary"
        }
      }]
  }
}
  • name: @nestjs/swagger
  • options:
    • introspectComments: true
    • controllerKeyOfComment: "summary"

위 설정에서 introspectComments을 활성화하면 작성한 주석의 내용을 Swagger에 반영할 수 있게 된다. controllerKeyOfCommentsummary로 지정하면 주석에 작성한 내용이 API 제목 부분에 반영된다.

  @ApiOperation({
    summary: '이건 요약',
    description: '이건 묘사',
  })
  @Post()
  @ApiResponse({
    description: '생성한 댓글',
    status: 201,
    type: () => OutCommentDto,
  })
  async createComment(@Body() dto: createCommentDto) {
    const comment = await this.commentService.create(dto);
    return comment;
  }

summary와 description의 위치

요약 부분은 Swagger UI를 처음 봤을 때 각 API에 대해 최초로 이해하는데 큰 역할을 수행한다. 반면 description의 경우 각 API를 눌러야만 드러나므로, 각 API를 좀 더 자세하게 이해할 때 필요하다. 현재 프로젝트의 경우 API가 그렇게 복잡하지 않기 때문에 description까지 작성할 필요가 없다고 생각해서, 주석과 요약을 통일하기 위해 위처럼 설정했다.

 주석 옵션을 반드시 켤 필요는 없다. 나는 나중에 각 프로퍼티나 메서드의 역할을 상기할 수 있도록 간단하게 주석을 추가하는 것을 선호한다. 그런데 swagger을 사용할 때는 @ApiProperty 또는 @ApiOperation에 주석에 들어갈 내용을 작성하기 때문에 정작 내가 개발할 때 참고하기는 어려워지는 불편함이 있어 주석 옵션을 켜는 것이 더 좋다.

 주석 옵션을 사용하지 않더라도 플러그인 자체는 추가하는 것이 좋다. 플러그인이 없다면 @ApiProperty가 적용되지 않은 프로퍼티가 Swagger UI에 노출되지 않는다. dto를 만들 때마다 쓸모없는 프로퍼티를 붙일 생각이 아니라면 최소한 플러그인은 추가하자.

// 사용 된 Dto
class ArticleContentDto {
  @IsString()
  content: string;

  @IsNumber()
  score: number;
}

(좌) 플러그인 x (우) 플러그인 o

 @nestjs/swagger 플러그인을 사용하는 경우

 nest-cli가 다음 동작을 처리해준다고 한다.

  • @ApiHideProperty 로 지정되지 않은 모든 DTO의 프로퍼티에 @ApiProperty 데코레이터를 추가한다. 
  • hello?: string 처럼 ?가 달린 경우 required: false로 지정한다.
  • 타입에 기반하여 타입 / enum / array 등을 지정한다. (타입 알아서 지정해준다는 의미로 보임)
  • 프로퍼티에 기본 값이 지정되어 있다면, 해당 값을 default로 설정한다.
  • class-validator에 지정된 규칙을 추가한다.
  • 모든 엔드포인트에 대해 적절한 status, type을 지정한다. (response model)
  • 주석에 기반하여 description을 생성한다. (if introspectComments set to true)
  • 주석에 기반하여 example 값을 추가한다. (if introspectComments set to true)

많은 것들을 처리해주기 때문에 어지간한 기능은 주석 + 타입스크립트 문법 수준에서 처리되서 편하다.

export class createCommentDto {
  /**
   * 댓글 생성일
   * @example '2022-03-31'
   */
  @IsDateString()
  createdAt: Date;
  /**
   * 댓글 내용
   * @example '오늘은 날씨가 좋아요'
   */
  @IsString()
  content: string;

  /**
   * 공감수
   * @example 10597
   */
  @IsNumber()
  sympathy: number;

  /**
   * 비공감수
   * @example 10597
   */
  @IsNumber()
  antipathy: number;

  /**
   * 뉴스 링크
   * @example https://www.naver.com
   */
  @IsString()
  news_link: string;

  /**
   * 댓글의 대표 감정
   * @example happiness
   */
  @IsString()
  emotion: string;

  /**
   * 연관성 있는 기사 내 문장들
   */
  @ApiProperty({
    isArray: true,
    type: () => [ArticleContentDto],
  })
  @ValidateNested()
  @Type(() => ArticleContentDto) // nested 처리하기 위한 용도
  news_sentences: [ArticleContentDto];
}

class ArticleContentDto {
  /**
   * 기사 내 문장
   * @example 한편 김창섭 기획실장은 차기 디렉터로...
   */
  @IsString()
  content: string;

  /**
   * 댓글과 기사의 연관도. -1 ~ 1 사이의 값.
   * @example 0.7
   */
  @IsNumber()
  score: number;
}

위에서 유일하게 @ApiProperty를 지정해 둔 위치는 다른 객체 배열이 위치하는 부분이다. 저 부분은 데코레이터를 추가하지 않으면 그냥 문자열로 인식하기 때문에 isArray, type 옵션을 명시했다.

위 작성한 내용을 ApiProperty 기반으로 작성하면 다음과 같다.

export class createCommentDto {
  @ApiProperty({
    description: '댓글 생성일',
    example: '2022-03-31',
  })
  @IsDateString()
  createdAt: Date;

  @ApiProperty({
    description: '댓글 내용',
    example: '오늘은 날씨가 좋아요',
  })
  @IsString()
  content: string;

  @ApiProperty({
    description: '공감수',
    example: 10597,
  })
  @IsNumber()
  sympathy: number;

  @ApiProperty({
    description: '비공감수',
    example: 10597,
  })
  @IsNumber()
  antipathy: number;

  @ApiProperty({
    description: '뉴스 링크',
    example: 'https://naver.com',
  })
  @IsString()
  news_link: string;

  @ApiProperty({
    description: '댓글의 대표 감정',
    example: 'happiness',
  })
  @IsString()
  emotion: string;

  @ApiProperty({
    description: '연관성 있는 기사 내 문장들',
    isArray: true,
    type: () => [ArticleContentDto],
  })
  @ValidateNested()
  @Type(() => ArticleContentDto) // nested 처리하기 위한 용도
  news_sentences: [ArticleContentDto];
}

class ArticleContentDto {
  @ApiProperty({
    description: '기사 내 문장',
    example: '한편 김창섭 기획실장은 차기 디렉터로...'
  })
  @IsString()
  content: string;

  @ApiProperty({
    description: '댓글과 기사의 연관도. -1 ~ 1 사이의 값',
    example: 0.7,
  })
  @IsNumber()
  score: number;
}

 데코레이터를 사용하는 것도 괜찮긴 한데, 이 경우 각 프로퍼티에 대한 설명이 주석이 없어서 개발하는 당사자 입장에서는 이게 뭔지 헷갈릴 수 있다.

import { Controller, Post, Get, Body, Query, Param } from '@nestjs/common';
import { ApiOperation, ApiParam, ApiResponse, ApiTags } from '@nestjs/swagger';

import { createCommentDto } from './dtos/create-comment.dto';
import { GetCommentsQueriesDto } from './dtos/get-comments-query.dto';
import { AnalysisCommentService } from './analysis-comment.service';
import { OutCommentDto } from './dtos/out-comment.dto';
import { ObjectId } from 'mongodb';

@ApiTags('Comment')
@Controller('comment')
export class AnalysisCommentController {
  constructor(private commentService: AnalysisCommentService) {}

  /**
   * 댓글 정보를 받아 DB에 저장, 생성된 댓글 반환
   */
  @Post()
  @ApiResponse({
    description: '생성한 댓글',
    status: 201,
    type: () => OutCommentDto,
  })
  async createComment(@Body() dto: createCommentDto) {
    const comment = await this.commentService.create(dto);
    return comment;
  }

  /**
   * 쿼리 정보 기반으로 댓글 정보 가져옴
   */
  @ApiOperation({
    description: '존재하는 쿼리 A, B, C를 지정하여 댓글을 가져올 수 있습니다',
  })
  @Get()
  @ApiResponse({
    description: '가져온 댓글 목록',
    status: 200,
    // type: () => [OutCommentDto],
  })
  async getComments(@Query() q: GetCommentsQueriesDto) {
    console.log(q.search, typeof q.search);
    console.log(q.pno, typeof q.pno);
    console.log(q.psize, typeof q.psize);
    return 'hello';
  }

  /**
   * summary: 'param을 id로 하는 댓글 가져옴',
   */
  @Get(':keyword')
  @ApiParam({
    name: 'keyword',
    description: '키워드',
    example: '윤석열',
  })
  async getCommentsById(@Param('keyword') id: ObjectId) {
    return id + 'hello';
  }
}

controller 부분도 크게 다르지 않다. @ApiOperation을 통해 각 API에 대한 설명을 추가할 수 있고, @ApiResponse을 통해 응답 값이나 형식에 대한 부분을 명시적으로 언급할 수 있다.

body, query 등 Dto에 의해 처리되는 경우 따로 명시하지 않아도 SwaggerUI 상의 적절한 위치에 삽입하고, 만약 개별적으로 명시하고 싶다면 @ApiBody, @ApiParam, @ApiQuery 데코레이터 등을 이용하여 설정할 수 있다.


자동 생성되는 Swagger UI

생성된 문서를 보고 나니까 고된 시간이 보답받는 느낌이다. Swagger 도입을 통해 팀원들과 명확하게 소통할 수 있기를 기대한다...

'javascript' 카테고리의 다른 글

[javascript] 호이스팅, var, let, const  (1) 2023.09.20
[자바스크립트] 시간 차이 측정하기  (0) 2023.01.21