Logo

NestJS로 REST API 찍어내기

분산 시스템 환경에서 가장 흔하게 접할 수 있는 백엔드(backend) 애플리케이션은 아마도 특정한 도메인의 데이터를 관리해주는 REST API일 텐데요. 이번 포스팅에서는 NestJS를 이용하면 얼마나 효과적으로 이러한 전형적인 REST API를 개발할 수 있는지 알아보겠습니다.

실습 프로젝트 구성

먼저 간단한 실습을 위해서 NestJS 프로젝트가 하나 필요할 것 같은데요. 터미널에서 NestJS CLI 도구의 nest new 명령어를 실행하여 새로운 프로젝트를 구성하도록 하겠습니다.

$ nest new our-nestjs
⚡  We will scaffold your app in a few seconds..

? Which package manager would you ❤️  to use? (Use arrow keys)npm
  yarn
  pnpm

NestJS CLI를 설치하고 NestJS 프로젝트를 구성하는 기본적인 방법은 관련 포스팅을 참고 바랍니다.

코드 자동 생성

REST API는 대부분의 경우 유지보수가 용이하도록 여러 레이어(layer)로 나누어서 설계하지요?

관례적으로 컨트롤러(controller) 클래스는 HTTP 요청을 받아서 응답하고, 서비스(service) 클래스는 비지니스 로직을 처리합니다. 또한 엔티티(entity) 클래스는 해당 애플리케이션에서 관리되는 하나의 데이터를 나타내기 위해서 사용되며, DTO(entity) 클래스는 외부로 부터 유입되는 데이터를 나타내기 위해서 사용됩니다.

이러한 클래스들을 하나씩 손수 일일이 생성하는 것은 상당히 지루어하고 번거로운 작업이 될 수 있는데요. 다행히도 NestJS CLI는 REST API를 개발하는데 필요한 클래스를 일괄적으로 자동 생성해주는 nest generate resource라는 명령어를 제공하고 있습니다.

실습 프로젝트에서는 유저 정보를 관리하기 위한 REST API를 개발하려고 하는데요. 따라서 nest generate resource 명령어의 인자로 users를 넘겨서 실행하겠습니다.

$ nest generate resource users
? What transport layer do you use? REST API
? Would you like to generate CRUD entry points? Yes
CREATE src/users/users.controller.spec.ts (566 bytes)
CREATE src/users/users.controller.ts (894 bytes)
CREATE src/users/users.module.ts (247 bytes)
CREATE src/users/users.service.spec.ts (453 bytes)
CREATE src/users/users.service.ts (609 bytes)
CREATE src/users/dto/create-user.dto.ts (30 bytes)
CREATE src/users/dto/update-user.dto.ts (169 bytes)
CREATE src/users/entities/user.entity.ts (21 bytes)
UPDATE package.json (2004 bytes)
UPDATE src/app.module.ts (312 bytes)
✔ Packages installed successfully.

그러면 src/users/ 디렉토리 안에 총 8개의 파일이 생성되고, app.module.ts 파일에서 추가된 모듈을 불러오도록 수정되는 것을 볼 수 있으실 겁니다.

Entity

많은 REST API가 뒷 단에 데이터베이스를 두고 요청받은 HTTP 메서드(POST PATCH GET DELETE)에 따라 소위 CRUD(Create Update Read Delete) 작업을 처리하게 되는데요.

NestJS에서는 엔티티(entity) 클래스를 통해서 REST API에서 관리하는 데이터를 모델링(modeling)합니다.

예를 들어서, 엔티티 클래스의 인스턴스(instance)는 관계형 데이터베이스를 사용하다면 어떤 테이블의 하나의 레코드(record)가 될 것이고, NoSQL 데이터베이스를 사용한다면 어떤 컬렉션(collection)의 하나의 아이템(item) 또는 문서(document)가 될 것입니다.

실습 프로젝트에서 src/users/entities/ 디렉토리 안에 있는 user.entity.ts 파일을 열어보면 비어있는 User 클래스가 있을텐데요. 우리는 이 클래스 안에 REST API가 관리해야하는 속성들을 나열해줘야 합니다.

우선 각 유저를 유일하게 식별하기 위한 id 속성이 필요할 것 같고요. 유저의 이름과 이메일, 전화번호를 저장하기 위한 name, email, phone 속성도 추가하겠습니다. 그리고 각 유저 데이터가 언제 생성되었고 수정되었는지를 추적하기 위한 createdAt, updatedAt 속성을 추가하도록 하겠습니다.

user.entity.ts
export class User {
  id: number;
  name: string;
  email: string;
  phone?: string;
  createdAt: Date;
  updatedAt?: Date;
}

DTO

REST API에서는 일반적으로 POST 방식의 엔드포인트(endpoint)를 통해 생성할 데이터가 들어오고 PATCH 방식의 엔드포인트를 통해서 수정할 데이터가 들어오는데요.

NestJS에서는 DTO(data transfer object) 클래스를 통해서 이렇게 외부로 부터 유입되는 데이터를 모델링합니다. 따라서 생성할 유저를 나타낼 DTO 클래스와 수정할 유저 데이터를 나타낼 DTO 클래스가 필요할 것 같은데요.

실습 프로젝트에서 src/users/dto 디렉토리에 들어가보면 create-user.dto.ts 파일과 update-user.dto.ts 파일이 보일텐데요. 이 파일을 각각 열어보면 NestJS CLI가 이미 만들어 놓은 CreateUserDto 클래스와 UpdateUserDto 클래스가 확인될 것입니다.

먼저 create-user.dto.ts 파일을 열고 CreateUserDto 클래스에 name, email, phone 속성을 추가합니다. 위에서 엔티티 클래스에 추가했던 id, createdAt, updatedAt 속성을 DTO 클래스에 제외하는 이유는 이러한 속성은 애플리케이션 내부적으로 결정되므로 DTO 클래스를 통해서 외부로 부터 받을 필요가 없기 때문입니다.

create-user.dto.ts
export class CreateUserDto {
  name: string;
  email: string;
  phone?: string;
}

update-user.dto.ts 파일에 있는 UpdateUserDto 클래스의 경우 별도로 수정해줄 부분없이 그대로 사용할 수 있는데요. 대부분의 REST API에서 데이터 생성용 DTO 클래스와 데이터 수정용 DTO 클래스의 차이는 단지 속성을 필수적으로 받아야하는지 말아야하는지 밖에 없습니다.

UpdateUserDto 클래스를 보면 NestJS에서 제공하는 유틸리티 타입인 PartialType을 통해서 CreateUserDto 클래스를 확장하되 모든 속성을 선택적으로 입력받도록 하고 있습니다.

import { PartialType } from "@nestjs/mapped-types";
import { CreateUserDto } from "./create-user.dto";

export class UpdateUserDto extends PartialType(CreateUserDto) {}

Service

이제 실제 비지니스 로직을 담당하는 서비스 클래스를 구현할 차례인데요. 실제 프로젝트에서는 데이터베이스를 사용하여 데이터를 저장하겠지만 실습 프로젝트에서는 최대한 간단한 코드를 위해서 배열에 데이터를 저장하겠습니다.

실습 프로젝트에서 src/users/ 디렉토리 안에 있는 users.service.ts 파일을 열어보면 5개의 메서드로 구성된 UsersService 클래스가 있을텐데요. 우리는 이제부터 각 메서드가 배열에 접근하여 데이터를 조회, 추가, 수정, 삭제하도록 코드를 작성해주기면 하면 됩니다.

users.service.ts
import { Injectable, NotFoundException } from "@nestjs/common";
import { CreateUserDto } from "./dto/create-user.dto";
import { UpdateUserDto } from "./dto/update-user.dto";
import { User } from "./entities/user.entity";

@Injectable()
export class UsersService {
  private users: Array<User> = [];
  private id = 0;

  create(createUserDto: CreateUserDto) {
    this.users.push({ id: ++this.id, ...createUserDto, createdAt: new Date() });
  }

  findAll() {
    return [...this.users];
  }

  findOne(id: number) {
    const found = this.users.find((u) => u.id === id);
    if (!found) throw new NotFoundException();
    return found;
  }

  update(id: number, updateUserDto: UpdateUserDto) {
    const found = this.findOne(id);
    this.remove(id);
    this.users.push({ ...found, ...updateUserDto, updatedAt: new Date() });
  }

  remove(id: number) {
    this.findOne(id);
    this.users = this.users.filter((u) => u.id !== id);
  }
}

여기서 주의깊게 볼 부분은 findOne() 메서드는 주어진 id에 부합하는 데이터가 없을 경우에 NotFoundException 예외를 던진다는 것입니다. NotFoundException 예외는 NestJS에서 잡아서 404 Not Found로 응답하기 때문에 호출자에게 존재하지 않는 데이터를 요청했다고 명시적으로 알려줄 수 있습니다.

id를 인자로 받는 update() 메서드와 remove() 메서드도 내부적으로 findOne() 메서드를 호출하기 때문에 호출자에게 동일한 피드백을 줄 수 있습니다.

Controller

마지막으로 실제로 HTTP 요청을 받아서 응답해주는 컨트롤러(controller) 클래스를 살펴보도록 하겠습니다.

실습 프로젝트에서 src/users/ 디렉토리 안에 있는 users.controller.ts 파일을 열어보면 이미 UsersController 클래스 안에 5개 엔드포인트를 처리할 수 있는 메서드가 구현되어 있는 것을 볼 수 있습니다.

내부적으로 위에서 작성한 UsersService의 메서드를 호출하도록 각 요청 핸들러 메서드가 잘 구현이 되어 있기 때문에 추가로 수정해줘야 할 부분은 크게 없습니다.

users.controller.ts
import {
  Controller,
  Get,
  Post,
  Body,
  Patch,
  Param,
  Delete,
} from "@nestjs/common";
import { UsersService } from "./users.service";
import { CreateUserDto } from "./dto/create-user.dto";
import { UpdateUserDto } from "./dto/update-user.dto";

@Controller("users")
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post()
  create(@Body() createUserDto: CreateUserDto) {
    return this.usersService.create(createUserDto);
  }

  @Get()
  findAll() {
    return this.usersService.findAll();
  }

  @Get(":id")
  findOne(@Param("id") id: string) {
    return this.usersService.findOne(+id);
  }

  @Patch(":id")
  update(@Param("id") id: string, @Body() updateUserDto: UpdateUserDto) {
    return this.usersService.update(+id, updateUserDto);
  }

  @Delete(":id")
  remove(@Param("id") id: string) {
    return this.usersService.remove(+id);
  }
}

여기까지 하면 기본적인 CRUD 기능을 수행을 하는 REST API가 완성이 되는데요. 당연히 실제 프로젝트에서는 제대로 된 데이터베이스를 사용하여 데이터를 영속적으로 저장을 해야겠고요. 미들웨어(middleware), 파이프(pipe), 가드(guard)와 같은 NestJS에서 제공하는 부가 기능도 활용할 수 있겠습니다.

자동 문서화

REST API를 개발할 때 문서화도 중요하지만 간과하기 쉬운 부분인데요.

NestJS는 REST API의 실제 코드에서 Open API 규격에 맞는 문서를 자동으로 추출할 수 있는 Swagger 통합도 지원하는데요. 이렇게 만든 문서 페이지에서는 실제 REST API 호출도 가능하기 때문에 테스트 측면에서도 매우 유용하게 활용할 수 있습니다.

그러면 실습 프로젝트에 Swagger를 설정하기 위해서 먼저 @nestjs/swagger라는 npm 패키지를 프로젝트에 설치합니다.

$ npm i @nestjs/swagger

warn preInstall No repository field
┌ [1/4] 🔍  Resolving dependencies
└ Completed in 7.524s
┌ [2/4] 🚚  Fetching dependencies
│ info pruneDeps Excluding 1 dependency. For more information use `--verbose`.
└ Completed in 3.728s
┌ [3/4] 🔗  Linking dependencies
└ Completed in 40.713s
info security We found `install` scripts which turbo skips for security reasons. For
more information see https://turbo.sh/install-scripts.
└─ @nestjs/core@9.2.1

success Saved lockfile "package-lock.json"
success Updated "package.json"

success Install finished in 52.061s

그 다음 src/ 디렉토리에 있는 main.ts 파일을 열어서, NestJS 앱이 구동될 때 Swagger도 셋업이 되도록 수정해줍니다.

main.ts
import { NestFactory } from "@nestjs/core";
import { DocumentBuilder, SwaggerModule } from "@nestjs/swagger";
import { AppModule } from "./app.module";

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  const config = new DocumentBuilder()    .setTitle("Users API")    .setDescription("The is a sample REST API")    .build();  const document = SwaggerModule.createDocument(app, config);  SwaggerModule.setup("api", app, document);  await app.listen(3000);
}
bootstrap();

마지막으로 프로젝트 최상위 경로에 있는 nest-cli.json 파일에 @nestjs/swagger를 플러그인으로 설정해줍니다. 이렇게 해주면 굳이 엔티티 클래스와와 DTO 클래스에 일일이 Swagger 관련 데코레이터를 추가해주지 않아도 Swagger에게 스키마로 인식되게 됩니다.

{
  "$schema": "https://json.schemastore.org/nest-cli",
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "deleteOutDir": true,
    "plugins": [
      {
        "name": "@nestjs/swagger",
        "options": {
          "introspectComments": true
        }
      }
    ]
  }
}

전체 코드

실습 프로젝트의 코드는 아래에서 직접 확인하고 실행해볼 수 있습니다.

마치면서

이상으로 NestJS를 이용하여 유저 정보를 관리해주는 간단한 REST API를 개발해보았습니다. 만약에 이러한 REST API를 기존에 백엔드 애플리케이션 개발에 많이 사용되던 Express를 사용해서 구현했다면 NestJS 대비 얼마나 많은 노력이 들어갔을까요? 본 포스팅을 통해서 NestJS가 개발 생산성에 얼마나 큰 도움이 될 수 있는지 느낄 수 있으셨으면 좋겠습니다.

NestJS에 관련된 다른 포스팅은 관련 태그를 참고 바라겠습니다.