NestJS Pipe·Validation

들어가며: “어? 이 필드가 왜 string이죠?”

NestJS 컨트롤러에서 DTO를 받았는데, 숫자여야 할 price가 문자열로 들어온다. 또는 존재하지 않는 필드가 그대로 통과해서 DB까지 도달한다. TypeScript의 타입 시스템은 런타임에 사라지기 때문에, 런타임 유효성 검증은 별도로 구현해야 한다.

NestJS는 이 문제를 Pipe로 해결한다. 이 글에서는 NestJS 공식 문서(Pipes, Validation)와 class-validator/class-transformer 공식 문서를 근거로, ValidationPipe의 옵션 설계부터 커스텀 Pipe 작성까지 실무 관점에서 다룬다.

1. Pipe의 역할과 실행 시점

1-1. 두 가지 용도

NestJS 공식 문서에 따르면, Pipe는 두 가지 용도로 사용된다:

  • 변환(Transformation): 입력 데이터를 원하는 형태로 변환 (예: 문자열 → 숫자)
  • 유효성 검증(Validation): 입력 데이터가 올바른지 검증하고, 올바르지 않으면 예외를 던짐

1-2. 요청 라이프사이클에서의 위치

NestJS 공식 문서(Request lifecycle)에 따른 실행 순서:

  1. Middleware
  2. Guard
  3. Interceptor (before)
  4. Pipe ← 여기서 유효성 검증/변환
  5. Controller 핸들러
  6. Interceptor (after)
  7. Exception Filter

Pipe에서 예외가 발생하면 컨트롤러 핸들러는 실행되지 않는다. 유효하지 않은 데이터가 비즈니스 로직에 도달하는 것을 원천 차단한다.

2. 빌트인 Pipe 6종

Pipe 용도 예시
ValidationPipe DTO 유효성 검증 (class-validator 기반) @Body() dto: CreateUserDto
ParseIntPipe 문자열 → 정수 변환 @Param('id', ParseIntPipe) id: number
ParseFloatPipe 문자열 → 실수 변환 @Query('lat', ParseFloatPipe) lat: number
ParseBoolPipe 문자열 → boolean 변환 @Query('active', ParseBoolPipe) active: boolean
ParseUUIDPipe UUID 형식 검증 @Param('id', ParseUUIDPipe) id: string
ParseEnumPipe enum 값 검증 @Query('role', new ParseEnumPipe(Role)) role: Role

변환에 실패하면 자동으로 BadRequestException(400)을 던진다.

3. ValidationPipe: 가장 중요한 Pipe

3-1. 설치와 기본 설정

npm install class-validator class-transformer

글로벌 ValidationPipe 등록:

// main.ts
import { ValidationPipe } from '@nestjs/common';

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

  app.useGlobalPipes(new ValidationPipe({
    whitelist: true,           // DTO에 정의되지 않은 속성 제거
    forbidNonWhitelisted: true, // 정의되지 않은 속성이 있으면 400 에러
    transform: true,           // 자동 타입 변환 활성화
    transformOptions: {
      enableImplicitConversion: true, // @Type() 없이도 타입 변환
    },
  }));

  await app.listen(3000);
}

3-2. 핵심 옵션 상세

옵션 기본값 설명 권장
whitelist false DTO 데코레이터가 없는 속성을 자동 제거 true — Mass Assignment 공격 방지
forbidNonWhitelisted false 알 수 없는 속성이 있으면 400 에러 true — 클라이언트에 명확한 에러 반환
transform false plain object를 DTO 클래스 인스턴스로 변환 true — DTO 메서드 사용 가능, 타입 변환
disableErrorMessages false 에러 메시지 비활성화 프로덕션에서 true 고려 (보안)
exceptionFactory 기본 팩토리 커스텀 에러 응답 포맷 팀 표준 에러 포맷에 맞게 커스텀

3-3. whitelist가 꺼져 있으면 생기는 일

// DTO 정의
export class CreateUserDto {
  @IsString()
  name: string;

  @IsEmail()
  email: string;
}

// 요청 body
{
  "name": "Theo",
  "email": "theo@example.com",
  "isAdmin": true    // ❌ DTO에 없는 필드
}

// whitelist: false → isAdmin이 그대로 통과
// whitelist: true → isAdmin이 자동 제거됨
// forbidNonWhitelisted: true → 400 에러 반환

이것은 Mass Assignment 취약점이다. whitelist: true는 반드시 켜야 한다.

4. DTO 설계: class-validator 데코레이터 실무 패턴

4-1. 기본 검증

import {
  IsString, IsEmail, IsInt, Min, Max,
  IsOptional, IsEnum, Length, IsNotEmpty,
} from 'class-validator';

export class CreateProductDto {
  @IsString()
  @IsNotEmpty()
  @Length(2, 100)
  name: string;

  @IsInt()
  @Min(0)
  @Max(99_999_999)
  price: number;

  @IsEnum(ProductCategory)
  category: ProductCategory;

  @IsOptional()
  @IsString()
  @Length(0, 500)
  description?: string;
}

4-2. 중첩 객체 검증

import { ValidateNested, IsArray, ArrayMinSize } from 'class-validator';
import { Type } from 'class-transformer';

export class CreateOrderDto {
  @IsString()
  @IsNotEmpty()
  customerId: string;

  @IsArray()
  @ArrayMinSize(1)
  @ValidateNested({ each: true })
  @Type(() => OrderItemDto)     // class-transformer가 변환할 타입 지정
  items: OrderItemDto[];
}

export class OrderItemDto {
  @IsString()
  productId: string;

  @IsInt()
  @Min(1)
  quantity: number;
}

주의: @ValidateNested()는 반드시 @Type()과 함께 사용해야 한다. @Type()이 없으면 중첩 객체가 plain object로 남아 검증이 동작하지 않는다. NestJS 공식 문서에 명시된 사항이다.

4-3. 조건부 검증

import { ValidateIf } from 'class-validator';

export class PaymentDto {
  @IsEnum(PaymentMethod)
  method: PaymentMethod;

  @ValidateIf((o) => o.method === PaymentMethod.CARD)
  @IsString()
  @Length(16, 16)
  cardNumber?: string;

  @ValidateIf((o) => o.method === PaymentMethod.BANK_TRANSFER)
  @IsString()
  bankAccountNumber?: string;
}

@ValidateIf()는 조건이 참일 때만 해당 필드의 데코레이터를 실행한다. 결제 수단에 따라 필수 필드가 달라지는 패턴에 적합하다.

5. 커스텀 Pipe 작성

5-1. 기본 구조

import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';

@Injectable()
export class ParseDatePipe implements PipeTransform<string, Date> {
  transform(value: string, metadata: ArgumentMetadata): Date {
    const date = new Date(value);
    if (isNaN(date.getTime())) {
      throw new BadRequestException(`"${value}"는 유효한 날짜가 아닙니다`);
    }
    return date;
  }
}

// 사용
@Get()
findByDate(@Query('date', ParseDatePipe) date: Date) {
  return this.service.findByDate(date);
}

5-2. 옵션을 받는 커스텀 Pipe

@Injectable()
export class ParseIntWithDefaultPipe implements PipeTransform {
  constructor(private readonly defaultValue: number) {}

  transform(value: string | undefined, metadata: ArgumentMetadata): number {
    if (value === undefined || value === '') {
      return this.defaultValue;
    }
    const parsed = parseInt(value, 10);
    if (isNaN(parsed)) {
      throw new BadRequestException(`"${value}"는 유효한 정수가 아닙니다`);
    }
    return parsed;
  }
}

// 사용: page가 없으면 기본값 1
@Get()
findAll(@Query('page', new ParseIntWithDefaultPipe(1)) page: number) {
  return this.service.findAll(page);
}

6. 커스텀 에러 응답: exceptionFactory

기본 ValidationPipe 에러 응답은 다음과 같다:

{
  "statusCode": 400,
  "message": ["name must be a string", "price must be an integer"],
  "error": "Bad Request"
}

팀 표준 에러 포맷에 맞추려면 exceptionFactory를 커스텀한다:

app.useGlobalPipes(new ValidationPipe({
  whitelist: true,
  forbidNonWhitelisted: true,
  transform: true,
  exceptionFactory: (errors) => {
    const messages = errors.flatMap((err) =>
      Object.values(err.constraints ?? {}),
    );
    return new BadRequestException({
      code: 'VALIDATION_ERROR',
      message: '입력값이 올바르지 않습니다',
      details: messages,
    });
  },
}));

7. Update DTO 패턴: PartialType과 PickType

NestJS의 @nestjs/mapped-types(또는 @nestjs/swagger)는 기존 DTO를 기반으로 파생 DTO를 만드는 유틸리티를 제공한다:

import { PartialType, PickType, IntersectionType, OmitType } from '@nestjs/mapped-types';

// 모든 필드를 Optional로 → Update DTO
export class UpdateProductDto extends PartialType(CreateProductDto) {}

// 특정 필드만 선택
export class UpdatePriceDto extends PickType(CreateProductDto, ['price']) {}

// 특정 필드 제외
export class CreateProductPublicDto extends OmitType(CreateProductDto, ['internalCode']) {}

// 두 DTO 합치기
export class CreateFullProductDto extends IntersectionType(
  CreateProductDto,
  CreateProductMetaDto,
) {}

이 유틸리티는 class-validator 데코레이터를 그대로 상속한다. DTO를 중복 정의할 필요가 없다.

8. 실전 체크리스트

  1. 글로벌 ValidationPipe 등록: whitelist: true, forbidNonWhitelisted: true, transform: true를 기본으로 설정했는가?
  2. 중첩 객체 @Type(): @ValidateNested() 사용 시 @Type(() => NestedDto)를 빠뜨리지 않았는가?
  3. enableImplicitConversion: Query parameter의 문자열→숫자 자동 변환이 필요하면 transformOptions.enableImplicitConversion: true를 설정했는가?
  4. 프로덕션 에러 메시지: disableErrorMessages: true로 상세 검증 에러를 숨기거나, exceptionFactory로 민감 정보를 필터링했는가?
  5. Partial/Pick/Omit 활용: Update DTO를 수동으로 만들지 않고 mapped-types를 사용하여 DRY를 지켰는가?
  6. 커스텀 밸리데이터: class-validator 빌트인으로 부족한 경우 registerDecorator()로 커스텀 데코레이터를 만들어 재사용하는가?

9. 흔한 실수와 방지법

실수 증상 방지법
whitelist 미설정 DTO에 없는 필드가 그대로 통과 → Mass Assignment 취약점 whitelist: true + forbidNonWhitelisted: true 필수
@ValidateNested()@Type() 누락 중첩 객체 검증이 동작하지 않아 잘못된 데이터 통과 @Type(() => NestedDto) 반드시 함께 선언
Query parameter 타입 불일치 @IsInt()인데 문자열 “10”이 들어와서 검증 실패 transform: true + enableImplicitConversion: true 설정
Update DTO 수동 중복 작성 Create DTO 변경 시 Update DTO 동기화 누락 PartialType(CreateDto)로 자동 파생

정리

NestJS Pipe는 컨트롤러 핸들러에 도달하기 전에 데이터를 검증·변환하는 관문이다. 글로벌 ValidationPipewhitelist·transform·forbidNonWhitelisted를 설정하고, DTO에 class-validator 데코레이터를 선언하면, 런타임 타입 안전성과 보안을 동시에 확보할 수 있다. 중첩 객체의 @Type(), 조건부 검증의 @ValidateIf(), 파생 DTO의 PartialType/PickType까지 조합하면 대부분의 입력 검증 요구사항을 커버할 수 있다.

참고 자료

위로 스크롤
WordPress Appliance - Powered by TurnKey Linux