들어가며: “어? 이 필드가 왜 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)에 따른 실행 순서:
- Middleware
- Guard
- Interceptor (before)
- Pipe ← 여기서 유효성 검증/변환
- Controller 핸들러
- Interceptor (after)
- 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. 실전 체크리스트
- 글로벌 ValidationPipe 등록:
whitelist: true,forbidNonWhitelisted: true,transform: true를 기본으로 설정했는가? - 중첩 객체 @Type():
@ValidateNested()사용 시@Type(() => NestedDto)를 빠뜨리지 않았는가? - enableImplicitConversion: Query parameter의 문자열→숫자 자동 변환이 필요하면
transformOptions.enableImplicitConversion: true를 설정했는가? - 프로덕션 에러 메시지:
disableErrorMessages: true로 상세 검증 에러를 숨기거나,exceptionFactory로 민감 정보를 필터링했는가? - Partial/Pick/Omit 활용: Update DTO를 수동으로 만들지 않고 mapped-types를 사용하여 DRY를 지켰는가?
- 커스텀 밸리데이터: 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는 컨트롤러 핸들러에 도달하기 전에 데이터를 검증·변환하는 관문이다. 글로벌 ValidationPipe에 whitelist·transform·forbidNonWhitelisted를 설정하고, DTO에 class-validator 데코레이터를 선언하면, 런타임 타입 안전성과 보안을 동시에 확보할 수 있다. 중첩 객체의 @Type(), 조건부 검증의 @ValidateIf(), 파생 DTO의 PartialType/PickType까지 조합하면 대부분의 입력 검증 요구사항을 커버할 수 있다.