06 [NestJS] DTO

06 [NestJS] DTO

NestJS로 코드를 짜면서 컨트롤러의 @Body() 데코레이터에 타입을 any로 두는 게 영 찜찜했다. Express 시절 req.body를 다룰 때처럼, 어떤 데이터가 들어올지 코드만 봐서는 전혀 알 수 없었다. req.body.userName인지 req.body.name인지 헷갈려서 항상 주의를 했었다.

NestJS에서는 DTO(Data Transfer Object) 라는 개념을 기본적으로 사용한다. DTO는 이 모든 문제를 해결해주는, NestJS 개발의 핵심적인 패턴이다.


DTO란 무엇인가?

DTO(Data Transfer Objec), 데이터 전송 객체는 이름 그대로 계창 간 데이터 전송에 사용되는 객체를 의미한다. 쉽게 말해, 클라이ㄴ트에서 우리 서버로 데이터가 넘어올 떄 , 그 데이터의 모양과 형태를 정의하는 약속이자 틀이다

예를 들어, 클라이언트가 유저를 생성하기 위해 nameemail을 보낸다면, DTO는 이렇게 생겼을 것이다.

{
  name: "홍길동",
  email: "gildong@example.com"
}

중요한 것은 DTO는 순수하게 데이터와 그 데이터의 형태 정의만 담고 있다는 점이다. 데이터를 담아 옮기는 상자일뿐, 메서드 같은 비즈니스 로직은 포함하지 않는다.


NestJS에서 DTO 만들기 (feat. Type Safety)

NestJS에서 DTO를 만드는 건 아주 간단하다. 그냥 TypeScript 클래스를 하나 만들면 그게 바로 DTO다.

src/users/dto/create-user.dto.ts

export class CreateUserDto {
  name: string;
  email: string;
  age: number;
}

그리고 이 DTO를 컨트롤러에서 타입으로 지정해주기만 하면 된다.
src/users/users.controller.ts

import { Body, Controller, Post } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UsersService } from './users.service';

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

  @Post()
  create(@Body() createUserDto: CreateUserDto) {
    // 이제 createUserDto는 name, email, age 프로퍼티를 가진
    // 타입이 명확한 객체다!
    console.log(createUserDto.name); // 자동 완성도 잘 되고, 오타 걱정도 없다.
    return this.usersService.create(createUserDto);
  }
}

이것만으로도 엄청난 이점을 얻는다. 바로 타입 안정성(Type Safety) 이다. 더이상 @Body()의 타입을 any로 두지 않아도 된다. TypeScript 컴파일러는 createUserDto가 어떤 프로퍼티를 가지고 있는지 정확히 알게 되고, 개발자는 자동 완성의 편리함과 컴파일 시점이ㅡ 에러 체크라는 혜택을 누릴 수 있다.


DTO와 ValidationPipe의 강력한 시너지

타입 안정성을 확보한 것만으로도 만족스러웠지만, DTO의 진짜 힘은 Validation과 함께 할때 발휘된다. 클라이언트가 약속된 DTO 모양을 어기고 엉뚱한 데이터를 보낼 수도 있지 않은가? 예를들어 age에 숫자가 아닌 문자열을 보낸다거나, 필수 값인 name을 뺴고 보낼 수도 있다.

Express를 쓸 때는 Joi같은 라이브러리를 써서 라우터마다 유효성 검사 미들웨어를 붙이곤 했다. 나름 괜찮은 방법이었지만, 유효성 검사 로직과 DTO의 타입 정의가 분리되어 있어 번거로울 때가 있었다.

그런데 NestJS에서는 class-validatorclass-transformer라는 라이브러리를 통해 DTO 클래스 자체에 유효성 검사 규칙을 정의할 수 있다.

1. 라이브러리 설치

npm install class-validator class-transformer

2. DTO에 유효성 검사 데코레이터 추가

// src/users/dto/create-user.dto.ts
import { IsString, IsEmail, IsNotEmpty, IsNumber } from 'class-validator';

export class CreateUserDto {
  @IsString()
  @IsNotEmpty()
  readonly name: string;

  @IsEmail() // 이메일 형식인지 검사
  @IsNotEmpty() // 빈 값이 아닌지 검사
  readonly email: string;

  @IsNumber() // 숫자인지 검사
  readonly age: number;
}

데이터의 모양과 규칙이 한 파일에 같이 있으니 코드를 이해하기가 훨씬 편해졌다.

3. 전역 파이프(Global Pipe) 설정

이제 이 규칙을 애플리케이션 전역에서 자동으로 적용하도록 main.tsValidationPipe를 추가한다.
src/main.ts

import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true, // DTO에 없는 프로퍼티는 거름
      forbidNonWhitelisted: true, // DTO에 없는 프로퍼티가 들어오면 에러 발생
      transform: true, // 요청 데이터를 DTO 타입으로 자동 변환 (e.g. string -> number)
    }),
  );
  await app.listen(3000);
}
bootstrap();

이제 끝났다. 만약 클라이언트가 CreateUserDto 규칙에 어긋나는 데이터를 보내면, 컨트롤러 로직이 실행되기도 전에 NestJS가 알아서 400 에러를 응답해준다.