본문 바로가기

DTO와 유효성 검사

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

앞 글에서는 URI와 HTTP 메서드의 의미를 정하고, controller와 service가 각각 무엇을 맡는지 나누었습니다. 이 글에서는 controller로 들어오는 값을 DTO(Data Transfer Object)로 선언하고, ValidationPipe로 런타임에 검사하는 방법을 다룹니다.

HTTP 요청으로 받은 JSON은 아직 믿을 수 없는 JavaScript 값입니다. TypeScript의 타입 표기는 컴파일한 뒤 지워지므로 @Body() dto: CreateUserDto라고 적는 것만으로는 요청이 검사되지 않습니다. Nest가 런타임에 참조할 수 있는 실제 DTO 클래스, 그 클래스에 붙은 class-validator 데코레이터, 그리고 이 둘을 실행하는 pipe가 함께 있어야 입력 규칙이 실제로 적용됩니다.


DTO는 요청 표현의 규칙입니다

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

DTO를 쓰면 다음과 같은 이점이 있습니다.

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

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


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

먼저 Nest의 ValidationPipe가 사용하는 두 패키지를 설치합니다. NestJS 12와 함께 쓰는 현재 버전은 class-validator 0.15, class-transformer 0.5입니다.

npm install class-validator class-transformer

아래 DTO는 age 검증을 덧붙여 입력 검사를 설명하기 위한 발췌입니다. 앞 글의 사용자 API를 이어서 만든다면 기존 name·email의 trim·길이 규칙은 그대로 유지하세요. 앞 글의 UserRecord, Alice/Bob 초기 데이터, UserResponseDto는 id·name·email만 정의합니다. DTO에 age를 추가한다고 해서 저장·조회·응답까지 나이를 다루게 되지는 않으므로, 나이를 실제 자원 필드로 제공하려면 그 부분도 함께 설계해야 합니다.

생성 요청 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()을 쓸 수 있습니다. 이 함수는 @nestjs/mapped-types 패키지에 있으며, 현재 12.0 버전이 NestJS 12를 지원합니다.

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도 검사를 건너뜁니다. 위 예제는 이 값을 false로 바꾸어 빠진 필드만 건너뛰고, 명시적으로 보낸 null에는 상속한 validator를 실행합니다. null이 “값 제거”를 뜻해야 한다면, 그 의미와 바꿀 수 없는 필드를 별도 DTO나 도메인 규칙으로 정해 둡니다.

Swagger나 GraphQL의 mapped type을 쓸 때는 해당 통합 패키지가 제공하는 PartialType을 가져와야 schema metadata가 함께 유지됩니다. 다음 글에서 Swagger를 연결할 때 이 import 위치를 다시 확인합니다.


전역 ValidationPipe 설정

여러 controller에 같은 입력 정책을 적용하려면 bootstrap 단계에서 전역 pipe를 등록합니다. 앞 글부터 이어서 만든다면 아래 useGlobalPipes() 옵션을 기존 main.ts에 합치고, URI 버전 설정은 그대로 둡니다.

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(process.env.PORT ?? 3000);
}

void bootstrap();

네 옵션은 각각 다른 정책을 정합니다.

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

transform: true가 DTO 속성의 모든 문자열을 TypeScript 표기대로 자동 변환한다는 뜻은 아닙니다. 위 DTO에 JSON body로 "age": "25"를 보내면, 속성 변환 규칙을 따로 선언하지 않았으므로 @IsInt()가 이 값을 거부합니다. body의 문자열 숫자를 받아 주는 것이 API의 약속이라면 @Type()이나 @Transform()을 명시하고 경곗값을 테스트하세요. path와 query처럼 문자열로 들어오는 단일 값에는 ParseIntPipe, ParseBoolPipe, ParseUUIDPipe를 붙이면 변환 규칙과 실패 조건이 코드에 더 분명하게 드러납니다.

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

아래 흐름은 요청 값과 DTO 클래스 metadata가 ValidationPipe에서 만나, controller 실행과 오류 응답 중 한쪽으로 나뉘는 과정을 보여 줍니다.

Nest DTO 런타임 검증 경계

pipe가 plain 요청과 runtime class metatype에 변환/whitelist/검증을 적용해 handler 실행 또는 400 응답으로 이어지게 한다.

Nest DTO · 런타임 입력 계약

실제 DTO class와 validation metadata가 입력을 검사합니다

요청 값과 class metatype을 ValidationPipe에 함께 전달합니다. 본문의 transform·whitelist·forbidNonWhitelisted 설정에서는 허용하지 않는 입력을 거부하고 통과한 값만 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 미실행
값과 런타임 DTO 규칙이 pipe에서 만난다 요청plain값과DTOclass/metatype/decorator정보는서로다른입력으로ValidationPipe에모인다. 변환/허용필드/검증규칙을적용하고통과해야controller로전달한다. 실패는기본400이며handler/service를실행하지않는다. DTOclass에서요청값을만들거나검증통과가domain고유성을보장한다는뜻은아니다. 예 아니요 요청 plain 값 클라이언트 JSON DTO class 런타임 metatype validation metadata ValidationPipe 변환 · whitelist decorator 규칙 입력 계약 판정 필드 · 타입 · 범위 통과 Controller 검증 DTO 거부 기본 400 handler 중단

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 불변식까지 증명하지는 않습니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 클라이언트의 plain JavaScript 요청 값과 CreateUserDto class의 runtime metatype 및 validation decorator가 ValidationPipe에 입력된다. Pipe는 설정에 따라 class instance 변환, whitelist, decorator 검증을 적용하고, 계약을 통과하면 controller를 실행하며 실패하면 handler를 실행하지 않고 기본 400 Bad Request로 끝낸다.
  • 요청의 plain 값과 DTO class/metatype/decorator 정보는 서로 다른 입력으로 ValidationPipe에 모인다. 변환/허용 필드/검증 규칙을 적용하고 통과한 값만 controller로 전달한다. 실패는 기본 400이며 handler/service를 실행하지 않는다. DTO class에서 요청 값을 만들거나 검증 통과가 domain 고유성을 보장한다는 뜻은 아니다.
관계도의 보충 설명
원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1Nest DTO 런타임 검증 경계
24요청의 plain 값과 DTO class/metatype/decorator 정보는 서로 다른 입력으로 ValidationPipe에 모인다. 변환/허용 필드/검증 규칙을 적용하고 통과한 값만 controller로 전달한다. 실패는 기본 400이며 handler/service를 실행하지 않는다. DTO class에서 요청 값을 만들거나 검증 통과가 domain 고유성을 보장한다는 뜻은 아니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
plain 값과 metatype

plain 요청 값과 DTO class·decorator를 Pipe에 전달합니다.

값: plain JavaScript · metatype: DTO classruntime 검증에 필요한 두 입력이 모입니다.
설정된 Pipe 처리

설정에 따라 instance 변환·whitelist·검증을 합니다.

작업: 설정된 변환·검증 · interface: runtime 대체 아님원래 입력이 handler 계약으로 좁혀집니다.
handler 또는 400

통과하면 controller, 실패하면 기본 400입니다.

성공: controller · 실패: 400·handler 없음검증 결과가 실제 진입을 결정합니다.

Nest DTO 런타임 검증 경계

pipe가 plain 요청과 runtime class metatype에 변환/whitelist/검증을 적용해 handler 실행 또는 400 응답으로 이어지게 한다.

Nest DTO · 런타임 입력 계약

실제 DTO class와 validation metadata가 입력을 검사합니다

요청 값과 class metatype을 ValidationPipe에 함께 전달합니다. 본문의 transform·whitelist·forbidNonWhitelisted 설정에서는 허용하지 않는 입력을 거부하고 통과한 값만 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 미실행
값과 런타임 DTO 규칙이 pipe에서 만난다 요청plain값과DTOclass/metatype/decorator정보는서로다른입력으로ValidationPipe에모인다. 변환/허용필드/검증규칙을적용하고통과해야controller로전달한다. 실패는기본400이며handler/service를실행하지않는다. DTOclass에서요청값을만들거나검증통과가domain고유성을보장한다는뜻은아니다. 예 아니요 요청 plain 값 클라이언트 JSON DTO class 런타임 metatype validation metadata ValidationPipe 변환 · whitelist decorator 규칙 입력 계약 판정 필드 · 타입 · 범위 통과 Controller 검증 DTO 거부 기본 400 handler 중단

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 불변식까지 증명하지는 않습니다.

이 예의 조건

  • 두 박스는 같은 실행의 진행 정보와 자료·결과를 관찰하는 고정 역할입니다. 박스의 좌우·위아래 위치를 별도 호출 순서로 해석하지 않습니다.
  • 조건별 종료는 대안입니다. 실패 뒤 정상 후속 단계를 실행하거나 독립 비교 사례를 같은 요청으로 연결하지 않습니다.
  • 클라이언트의 plain JavaScript 요청 값과 CreateUserDto class의 runtime metatype 및 validation decorator가 ValidationPipe에 입력된다. Pipe는 설정에 따라 class instance 변환, whitelist, decorator 검증을 적용하고, 계약을 통과하면 controller를 실행하며 실패하면 handler를 실행하지 않고 기본 400 Bad Request로 끝낸다.
  • 요청의 plain 값과 DTO class/metatype/decorator 정보는 서로 다른 입력으로 ValidationPipe에 모인다. 변환/허용 필드/검증 규칙을 적용하고 통과한 값만 controller로 전달한다. 실패는 기본 400이며 handler/service를 실행하지 않는다. DTO class에서 요청 값을 만들거나 검증 통과가 domain 고유성을 보장한다는 뜻은 아니다.

관계도의 보충 설명

원래 전체 그림의 표시 문구·조건·주석
순번원문 표시
1Nest DTO 런타임 검증 경계
24요청의 plain 값과 DTO class/metatype/decorator 정보는 서로 다른 입력으로 ValidationPipe에 모인다. 변환/허용 필드/검증 규칙을 적용하고 통과한 값만 controller로 전달한다. 실패는 기본 400이며 handler/service를 실행하지 않는다. DTO class에서 요청 값을 만들거나 검증 통과가 domain 고유성을 보장한다는 뜻은 아니다.
단계별 상태·값과 전환 이유
장면관찰 값전환 이유
plain 값과 metatype

plain 요청 값과 DTO class·decorator를 Pipe에 전달합니다.

값: plain JavaScript · metatype: DTO classruntime 검증에 필요한 두 입력이 모입니다.
설정된 Pipe 처리

설정에 따라 instance 변환·whitelist·검증을 합니다.

작업: 설정된 변환·검증 · interface: runtime 대체 아님원래 입력이 handler 계약으로 좁혀집니다.
handler 또는 400

통과하면 controller, 실패하면 기본 400입니다.

성공: controller · 실패: 400·handler 없음검증 결과가 실제 진입을 결정합니다.

위 그림에서 클라이언트가 보낸 plain 값과 CreateUserDto 클래스의 metatype·데코레이터는 서로 다른 입력으로 ValidationPipe에 모입니다. pipe는 설정에 따라 인스턴스 변환, whitelist, 데코레이터 검증을 차례로 적용하고, 모두 통과한 값만 controller에 넘깁니다. 실패하면 handler와 service를 실행하지 않고 기본 400으로 끝나며, 검증을 통과했다고 해서 이메일 고유성 같은 도메인 규칙까지 확인된 것은 아니라는 점도 그림 아래쪽에 적혀 있습니다.

더 깊이: ValidationPipe가 조용히 검사를 건너뛰는 경우

ValidationPipe는 모든 인자를 검사하지 않습니다. 소스 코드의 toValidate()는 매개변수의 metatype이 없거나 String, Boolean, Number, Array, Object, Buffer, Date 가운데 하나이면 검증 단계를 건너뛰고, transform: true일 때 path·query의 원시 값 변환만 시도합니다. 그런데 TypeScript 컴파일러는 인라인 객체 타입이나 인터페이스로 적은 매개변수의 타입을 메타데이터에 Object로 기록합니다. 그래서 @Body() body: { name: string } 같은 매개변수는 전역 pipe가 있어도 검사되지 않고, 오류도 나지 않습니다. import type으로 가져온 DTO도 메타데이터에 DTO 클래스가 남지 않아(tsc는 그 자리에 Function을 기록합니다) DTO에 붙인 검증 규칙이 적용되지 않으므로, 테스트는 잘못된 입력이 실제로 400이 되는지로 확인해야 합니다.

forbidUnknownValues의 기본값도 두 라이브러리에서 다릅니다. class-validator는 0.14.0부터 이 옵션을 기본으로 켜서, 검증 메타데이터가 없는 클래스의 인스턴스를 검증하려 하면 실패로 처리합니다. 반면 Nest의 ValidationPipe는 생성자에서 forbidUnknownValues: false를 기본값으로 넣으므로, 직접 옵션을 주지 않으면 이 검사가 꺼진 채로 동작합니다. 같은 DTO라도 서비스에서 class-validator의 validate()를 직접 호출한 결과와 pipe를 거친 결과가 다를 수 있다는 뜻입니다.


Controller에서 검증된 DTO 사용하기

전역 pipe가 등록되어 있으면 controller는 handler 매개변수의 런타임 metatype으로 DTO 클래스를 제공하기만 하면 됩니다. DTO를 type-only import로 가져오지 않는 이유가 여기에 있습니다.

아래 코드는 검증된 DTO가 service로 넘어가는 위치를 보여 주는 발췌입니다. 앞 글의 UsersController를 계속 쓴다면 버전 1 설정, 출력 DTO, 생성 시 Location, 404·409 변환은 그대로 두고 입력 처리 부분만 비교합니다. 이어지는 요청 예시는 설명을 위해 버전 접두사 없이 /users로 적었으며, 앞 글의 API를 이어서 쓴다면 /v1/users로 요청합니다.

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나 매개변수 pipe가 실패하면 handler와 service는 실행되지 않고 기본적으로 400 Bad Request가 반환됩니다. errorHttpStatusCode나 exceptionFactory를 설정하면 상태 코드와 응답 형식을 바꿀 수 있으므로, 클라이언트가 의존할 오류 형식은 테스트로 고정해 두어야 합니다.

controller에 도착한 DTO는 전송 형식과 선언한 validation 규칙을 통과한 값입니다. 이메일 중복, 수정 권한, 존재하지 않는 사용자, 트랜잭션 같은 판단은 여전히 service와 저장소가 맡습니다.


유효성 검사 테스트

애플리케이션을 실행하고 HTTP 요청을 보내 네 가지 경우를 확인합니다.

첫째, 유효한 생성 요청입니다. POST http://localhost:3000/users에 {"name":"David","email":"[email protected]","age":30}을 보내면 handler와 service가 실행되고 기본 201 Created 응답이 돌아옵니다.

둘째, 데코레이터 규칙을 어긴 요청입니다. {"email":"invalid-email","age":200}을 보내면 name 누락, 이메일 형식, 나이 상한 오류가 400 Bad Request 응답의 message 배열에 담깁니다. 오류 메시지의 순서와 최종 JSON 형식은 Nest 버전과 exceptionFactory 설정에 따라 달라질 수 있으므로, 테스트는 상태 코드와 필요한 필드·메시지가 있는지를 기준으로 작성합니다.

셋째, 알 수 없는 필드가 섞인 요청입니다. {"name":"Eve","email":"[email protected]","age":25,"role":"admin"}을 보내면 whitelist: true와 forbidNonWhitelisted: true 조합에서는 handler 전에 400 Bad Request로 거부됩니다. forbidNonWhitelisted를 false로 두면 role만 제거한 뒤 handler에 전달합니다.

넷째, body의 자동 형변환을 가정한 요청입니다. {"name":"Eve","email":"[email protected]","age":"25"}를 보내면, 위 DTO처럼 속성 변환 규칙을 선언하지 않은 상태에서는 @IsInt()가 문자열을 거부합니다. 문자열 숫자를 받아야 한다면 변환 규칙을 명시하고 빈 문자열, NaN, 범위 밖 값에 대한 테스트를 함께 둡니다.


정리

  • TypeScript 타입 표기는 컴파일 뒤 사라지므로, 런타임 검사는 실제 DTO 클래스와 validation 데코레이터, ValidationPipe가 함께 있어야 동작합니다.
  • transform은 인스턴스 변환, whitelist는 데코레이터가 붙은 필드만 남기기, forbidNonWhitelisted는 알 수 없는 필드 거부로, 세 옵션은 서로 다른 정책입니다.
  • PartialType()은 생성 DTO의 규칙을 이어받아 모든 필드를 선택적으로 만들며, skipNullProperties로 null을 검사할지 정할 수 있습니다.
  • body의 문자열을 숫자로 바꾸는 일은 transform: true만으로 일어나지 않으므로, 필요하면 @Type()이나 @Transform()으로 명시합니다.
  • DTO 검증은 입력 형식만 좁힐 뿐이고, 인증·인가와 고유성 같은 도메인 규칙은 guard와 service, 저장소에서 따로 검사합니다.

연습 문제

1. 공백 이름과 문자열 나이

이 글의 trim 없는 CreateUserDto와 전역 pipe를 그대로 쓰고, 별도의 속성 변환 규칙은 없다고 가정합니다. 유효한 이메일과 함께 name: " ", age: 25를 보내면 이름 검증이 실패할까요? age를 문자열 "25"로 바꾸면 결과가 어떻게 달라지나요? 앞 글의 trim과 @Length(1, 80) 규칙을 유지했을 때의 이름 결과도 비교해 보세요.

해설 보기

@IsNotEmpty()는 빈 문자열을 거부하지만 공백 한 글자는 빈 문자열이 아니므로, 이 글의 DTO에서는 이름 검증을 통과합니다. 숫자 25는 정수이고 범위 안에 있으므로 통과하고, 문자열 "25"는 @IsInt()를 통과하지 못해 400이 됩니다. transform: true만으로 DTO 속성의 문자열 숫자가 바뀌지는 않습니다.

앞 글의 규칙에서는 trim이 공백 이름을 빈 문자열로 만들고, 그 결과가 최소 길이 1을 만족하지 못해 실패합니다. 이름에 공백만 있는 값을 허용할지는 입력 정규화 정책을 먼저 정한 뒤 DTO에 명시해야 합니다.

2. PATCH에 null을 보내면

이 글의 UpdateUserDto는 skipNullProperties: false로 만들었습니다. PATCH /users/1에 {"email": null}을 보내면 어떤 응답이 돌아올까요? 옵션을 빼고 기본값으로 만들었다면 결과가 어떻게 달라질까요?

해설 보기

skipNullProperties: false에서는 undefined인 필드만 검사를 건너뛰고, 명시적으로 보낸 null에는 상속한 @IsEmail()과 @IsNotEmpty()가 그대로 실행됩니다. 두 검사 모두 실패하므로 handler는 실행되지 않고 400 Bad Request가 돌아옵니다.

옵션을 빼면 기본값 true가 적용되어 null도 검사를 건너뜁니다. 요청은 검증을 통과하고 service는 email: null이 담긴 DTO를 받습니다. service가 이 값을 그대로 저장하면 이메일이 없는 사용자가 생길 수 있으므로, 기본값을 쓸 때는 null의 의미를 service에서 따로 처리해야 합니다.

3. 데코레이터 없는 필드

CreateUserDto에 데코레이터 없이 nickname?: string; 필드를 추가했습니다. 전역 pipe 설정은 이 글과 같습니다. {"name":"David","email":"[email protected]","age":30,"nickname":"dave"}를 보내면 어떻게 될까요?

해설 보기

whitelist는 클래스에 선언된 속성이 아니라 validation metadata가 있는 속성을 기준으로 판단합니다. nickname에는 데코레이터가 없으므로 알 수 없는 필드로 취급되고, forbidNonWhitelisted: true 설정 때문에 property nickname should not exist 같은 메시지와 함께 400 Bad Request가 돌아옵니다.

nickname을 받으려면 @IsOptional()과 @IsString()처럼 실제 규칙을 붙이거나, 검사 없이 허용할 이유가 분명할 때만 @Allow()를 붙입니다.

다음 글 Swagger를 이용한 API 문서화에서는 이 글에서 만든 DTO를 Swagger schema와 예제로 노출하고, 문서와 구현이 함께 바뀌도록 구성합니다.