본문으로 건너뛰기

안동민 개발노트

본문 시작

DTO와 유효성 검사

DTO 클래스로 요청 계약을 선언하고 ValidationPipe로 변환·허용 필드·검증 오류를 제어합니다.

지난 절에서는 URI와 HTTP 메서드, controller와 service의 책임 경계를 정리했습니다.

이번 절에서는 그 경계로 들어오는 값을 DTO(Data Transfer Object)로 선언하고, ValidationPipe로 런타임에 검사하는 방법을 다룹니다.

HTTP 요청으로 받은 JSON은 신뢰할 수 없는 JavaScript 값입니다. TypeScript의 타입 표기는 컴파일 뒤 지워지므로 @Body() dto: CreateUserDto라는 표기만으로 요청이 검사되지는 않습니다. Nest가 런타임에 참조할 수 있는 구체적인 DTO 클래스, class-validator 데코레이터, 그리고 연결된 pipe가 함께 있어야 입력 계약이 실제로 강제됩니다.


DTO는 전송 경계의 계약입니다

DTO는 controller가 받을 요청 표현의 필드와 제약을 선언하는 클래스입니다. 데이터베이스 entity를 그대로 입력 DTO로 노출하지 않고, 생성·수정·응답에 필요한 계약을 각각 분리하면 클라이언트가 내부 필드나 서버가 관리하는 값을 덮어쓰는 일을 막기 쉽습니다.

DTO가 제공하는 이점은 다음과 같습니다.

  • 개발 시점 타입 정보: controller와 service 코드에서 자동 완성과 정적 검사를 제공합니다.
  • 런타임 입력 검증: DTO 클래스의 validation 데코레이터를 ValidationPipe가 실행합니다.
  • 허용 필드 경계: whitelist 정책으로 decorated field만 통과시키거나 알 수 없는 필드를 거부할 수 있습니다.
  • 문서화 기반: 다음 절의 Swagger 통합처럼 같은 클래스를 API schema 생성에 활용할 수 있습니다.

DTO 검증은 인증·인가, 데이터베이스 고유성, 재고나 상태 전이 같은 domain 규칙을 대신하지 않습니다. 이런 규칙은 guard와 service·저장소 경계에서 계속 강제해야 합니다.


DTO 클래스에 검증 규칙 선언하기

먼저 Nest의 ValidationPipe가 사용하는 패키지를 설치합니다.

npm install class-validator class-transformer

생성 요청 DTO를 구체적인 클래스로 정의하고 각 필드에 런타임 규칙을 붙입니다.

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

export class CreateUserDto {
  @IsString({ message: '이름은 문자열이어야 합니다.' })
  @IsNotEmpty({ message: '이름은 필수 항목입니다.' })
  name!: string;

  @IsEmail({}, { message: '유효한 이메일 형식이 아닙니다.' })
  @IsNotEmpty({ message: '이메일은 필수 항목입니다.' })
  email!: string;

  @IsInt({ message: '나이는 정수여야 합니다.' })
  @Min(0, { message: '나이는 0 이상이어야 합니다.' })
  @Max(150, { message: '나이는 150 이하여야 합니다.' })
  age!: number;
}

각 데코레이터는 서로 다른 제약을 표현합니다.

  • @IsString()@IsInt()는 런타임 값의 타입을 확인합니다.
  • @IsNotEmpty()undefined, null, 빈 문자열을 허용하지 않습니다.
  • @IsEmail()은 이메일 형식을 검사합니다.
  • @Min(0)@Max(150)은 두 끝값을 포함한 숫자 범위를 검사합니다.

필드의 string·number 표기는 TypeScript 개발 도구를 위한 정보입니다. 요청을 실제로 거부하는 것은 validation 데코레이터와 pipe입니다. 또한 DTO를 interface로 만들거나 import type { CreateUserDto }로 가져오면 런타임 클래스 참조가 사라져 ValidationPipe가 필요한 metatype을 사용할 수 없습니다.

PATCH용 DTO 파생하기

부분 수정에서는 생성 DTO의 규칙을 복사한 뒤 모든 필드를 선택적으로 만드는 PartialType()을 사용할 수 있습니다.

npm install @nestjs/mapped-types
src/users/dto/update-user.dto.ts
import { PartialType } from '@nestjs/mapped-types';
import { CreateUserDto } from './create-user.dto';

export class UpdateUserDto extends PartialType(CreateUserDto, {
  skipNullProperties: false,
}) {}

PartialType()은 생성 DTO의 validation metadata를 이어받고 누락된 필드를 선택적으로 다룹니다. 기본 skipNullProperties 값은 true라서 undefined뿐 아니라 null도 validation에서 건너뜁니다. 위 예제는 이를 false로 바꿔 누락된 필드만 선택적으로 두고, 전달된 null에는 상속한 validator를 실행합니다. null이 “값 제거”를 뜻해야 한다면 그 의미와 변경 불가 필드를 별도 DTO·domain 규칙으로 명시합니다.

Swagger나 GraphQL mapped type을 사용할 때는 해당 통합 패키지가 제공하는 PartialType을 사용해야 schema metadata가 함께 유지됩니다. 다음 절에서 Swagger를 연결할 때 이 import 경계도 다시 확인합니다.


전역 ValidationPipe 설정

여러 controller에 같은 입력 정책을 적용하려면 bootstrap에서 전역 pipe를 등록합니다.

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

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

  app.useGlobalPipes(
    new ValidationPipe({
      transform: true,
      whitelist: true,
      forbidNonWhitelisted: true,
      disableErrorMessages: process.env.NODE_ENV === 'production',
    }),
  );

  await app.listen(3000);
}

bootstrap();

옵션의 역할을 정확히 구분해야 합니다.

  • transform: true: plain payload를 handler의 DTO 클래스 인스턴스로 변환합니다. path·query에서 추출한 원시 값은 handler parameter metatype을 바탕으로 변환을 시도할 수 있습니다.
  • whitelist: true: DTO에 단순히 “선언된 필드”가 아니라 validation 데코레이터가 붙은 필드만 남깁니다. 검증 없이 허용할 필드는 @Allow()처럼 whitelist metadata를 명시해야 합니다.
  • forbidNonWhitelisted: true: whitelist와 함께 사용할 때 알 수 없는 필드를 제거하는 대신 기본 400 Bad Request로 거부합니다.
  • disableErrorMessages: 상세 validation message를 응답에서 숨깁니다. 공개할 오류 형식과 로그에 남길 진단 정보는 별도로 설계합니다.

transform: true가 DTO 속성의 모든 문자열을 TypeScript 표기대로 자동 변환한다는 뜻은 아닙니다. 위 DTO의 JSON body에 "age": "25"를 보내면 명시적인 property transformer가 없으므로 @IsInt()가 거부합니다. body coercion이 API 계약이라면 @Type()·@Transform() 등을 명시하고 경계값을 테스트하세요. path와 query처럼 문자열로 들어오는 단일 값은 ParseIntPipe, ParseBoolPipe, ParseUUIDPipe를 붙이면 변환 규칙과 실패 조건을 더 분명하게 드러낼 수 있습니다.

중첩 객체도 타입 표기만으로 재귀 검증되지 않습니다. 중첩 DTO에는 @ValidateNested()@Type(() => NestedDto) 같은 런타임 metadata가 필요합니다.

아래 흐름은 요청 값과 DTO class metadata가 ValidationPipe에서 만나 controller 실행 또는 오류 응답으로 나뉘는 경계를 보여줍니다.

클라이언트의 plain 요청 값과 런타임 DTO class metadata가 ValidationPipe에서 만나 변환, 허용 필드 정책, decorator 검증을 거친 뒤 controller 실행 또는 400 오류로 나뉘는 입력 경계

Nest · Runtime Input Contract

타입 표기는 개발자를 돕고, pipe가 런타임 입력을 막는다

요청 값만으로는 DTO가 되지 않습니다. 구체적인 class metatype과 validation metadata가 함께 전달되어야 ValidationPipe가 handler 전에 계약을 강제할 수 있습니다.

Nest DTO와 ValidationPipe의 요청 판정 흐름 클라이언트의 plain JavaScript 요청 값과 CreateUserDto class의 runtime metatype 및 validation decorator가 ValidationPipe에 입력된다. Pipe는 설정에 따라 class instance 변환, whitelist, decorator 검증을 적용하고, 계약을 통과하면 controller를 실행하며 실패하면 handler를 실행하지 않고 기본 400 Bad Request로 끝낸다. PASS FAIL 클라이언트 요청 값 plain JavaScript object CreateUserDto class metatype · validation decorators ValidationPipe plain → class instance 허용 필드 · decorator 규칙 입력 계약 통과? field · type · range Controller 실행 validated DTO 400 Bad Request handler · service 미실행

1 · 요청 값

JSON parser를 거친 body는 아직 신뢰할 수 없는 plain JavaScript 값입니다.

TypeScript property 표기만으로는 런타임 검사가 생기지 않습니다.

2 · 런타임 DTO 계약

CreateUserDto class의 metatype과 validation decorator가 허용 필드·타입·형식·범위를 선언합니다.

interface와 type-only import는 런타임 class 참조를 남기지 않습니다.

3 · ValidationPipe

transform은 plain 값을 DTO instance로 만들고, whitelist와 decorator 검증은 입력 정책을 적용합니다.

forbidNonWhitelisted를 함께 쓰면 알 수 없는 필드를 제거하지 않고 거부합니다.

PASS · Controller

선언한 계약을 통과한 DTO만 handler에 전달됩니다.

고유성·권한·상태 전이는 service와 저장소에서 계속 검사합니다.

FAIL · 400

필수 값, 타입, 형식, 범위 또는 허용 필드 정책을 위반하면 handler와 service를 실행하지 않습니다.

기본 상태는 400 Bad Request이며 옵션으로 오류 형식을 바꿀 수 있습니다.

whitelist의 기준은 TypeScript field 선언이 아니라 validation metadata입니다. DTO 검증은 transport 입력을 좁히지만 인증·인가와 domain·database 불변식까지 증명하지는 않습니다.


Controller에서 검증된 DTO 사용하기

전역 pipe가 등록되어 있으면 controller는 DTO 클래스를 handler parameter의 런타임 metatype으로 제공합니다. DTO를 type-only import하지 않는 이유도 여기에 있습니다.

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

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

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

  @Patch(':id')
  update(
    @Param('id', ParseIntPipe) id: number,
    @Body() updateUserDto: UpdateUserDto,
  ) {
    const updatedUser = this.usersService.update(id, updateUserDto);

    if (!updatedUser) {
      throw new NotFoundException(`User with ID ${id} not found`);
    }

    return updatedUser;
  }
}

POST handler의 기본 성공 상태는 201 Created입니다. DTO 또는 parameter pipe가 실패하면 handler와 service는 실행되지 않고 기본적으로 400 Bad Request가 반환됩니다. errorHttpStatusCodeexceptionFactory를 설정하면 상태와 응답 envelope를 바꿀 수 있으므로, 클라이언트가 의존할 오류 계약은 테스트로 고정해야 합니다.

Controller에 도달한 DTO는 transport 형식과 선언한 validation 규칙을 통과한 값입니다. 이메일 중복, 수정 권한, 존재하지 않는 사용자, 트랜잭션 같은 판단은 service와 저장소가 계속 담당합니다.


유효성 검사 테스트

애플리케이션을 실행하고 HTTP 요청으로 경계를 확인합니다.

유효한 생성 요청
  • POST http://localhost:3000/users
  • body: {"name":"David","email":"david@example.com","age":30}
  • 결과: handler와 service가 실행되고 기본 201 Created 응답을 반환합니다.
decorator 규칙 위반
  • body: {"email":"invalid-email","age":200}
  • 결과: name 누락, 이메일 형식, 나이 상한 오류가 기본 400 Bad Request의 message 목록에 포함됩니다.
  • 검증 오류의 순서와 최종 JSON envelope는 Nest 버전과 exceptionFactory 정책에 의존할 수 있으므로 상태와 필요한 field/message를 기준으로 검증합니다.
알 수 없는 필드
  • body: {"name":"Eve","email":"eve@example.com","age":25,"role":"admin"}
  • whitelist: true, forbidNonWhitelisted: true: handler 전에 400 Bad Request로 거부합니다.
  • whitelist: true, forbidNonWhitelisted: false: role을 제거한 뒤 handler에 전달합니다.
암묵적 body coercion을 가정하지 않기
  • body: {"name":"Eve","email":"eve@example.com","age":"25"}
  • 위 DTO처럼 property transformer를 선언하지 않았다면 @IsInt()가 문자열을 거부합니다.
  • 문자열 숫자를 지원해야 한다면 명시적인 변환 규칙과 빈 문자열·NaN·범위 밖 값 테스트를 함께 둡니다.

DTO 클래스는 TypeScript 개발 경험을 제공하고, validation 데코레이터와 ValidationPipe는 네트워크 경계의 런타임 계약을 강제합니다. transform, whitelist, forbidNonWhitelisted를 서로 다른 정책으로 이해하고, DTO 검증과 인증·domain·저장소 규칙을 분리해야 예측 가능한 API가 됩니다.

다음 절에서는 이 입력 계약을 Swagger schema와 예제로 노출하고 문서와 구현이 함께 변경되도록 구성합니다.