NestJS Pipe란
Pipe는 NestJS 요청 파이프라인에서 유효성 검증(Validation)과 데이터 변환(Transformation)을 담당하는 계층이다. 컨트롤러 핸들러에 도달하기 직전에 실행되며, 입력 데이터가 올바른지 검증하고 필요한 형태로 변환한다. Pipe에서 예외를 던지면 핸들러는 실행되지 않고 즉시 에러 응답이 반환된다.
NestJS는 9개의 내장 Pipe를 제공하며, 커스텀 Pipe를 만들어 도메인 특화 검증 로직을 구현할 수 있다. class-validator + class-transformer와 결합한 ValidationPipe가 가장 핵심적이다.
내장 Pipe 9종
| Pipe | 역할 | 예시 |
|---|---|---|
| ValidationPipe | DTO class-validator 검증 | @Body(ValidationPipe) |
| ParseIntPipe | 문자열 → 정수 변환 | @Param(‘id’, ParseIntPipe) |
| ParseFloatPipe | 문자열 → 실수 변환 | @Query(‘price’, ParseFloatPipe) |
| ParseBoolPipe | 문자열 → boolean 변환 | @Query(‘active’, ParseBoolPipe) |
| ParseUUIDPipe | UUID 형식 검증 | @Param(‘id’, ParseUUIDPipe) |
| ParseEnumPipe | Enum 값 검증 | @Param(‘role’, new ParseEnumPipe(Role)) |
| ParseArrayPipe | 배열 파싱 + 검증 | @Body(new ParseArrayPipe({items: CreateDto})) |
| DefaultValuePipe | 기본값 설정 | @Query(‘page’, new DefaultValuePipe(1)) |
| ParseFilePipe | 파일 유효성 검증 | 파일 크기, MIME 타입 체크 |
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, // DTO에 없는 프로퍼티 전송 시 400 에러
transform: true, // 파라미터 자동 타입 변환
transformOptions: {
enableImplicitConversion: true, // @Type 없이도 변환
},
stopAtFirstError: false, // 모든 에러를 한 번에 반환
exceptionFactory: (errors) => {
const messages = errors.map((err) => ({
field: err.property,
errors: Object.values(err.constraints || {}),
}));
return new BadRequestException({
statusCode: 400,
message: 'Validation failed',
errors: messages,
});
},
}),
);
await app.listen(3000);
}
DTO 유효성 검증 패턴
import {
IsString, IsEmail, IsInt, IsEnum, IsOptional,
Min, Max, MinLength, MaxLength, IsArray,
ValidateNested, IsDateString, Matches,
ArrayMinSize, IsNotEmpty,
} from 'class-validator';
import { Type, Transform } from 'class-transformer';
export class CreateOrderDto {
@IsNotEmpty({ message: '배송 주소는 필수입니다' })
@IsString()
@MinLength(5)
@MaxLength(200)
shippingAddress: string;
@IsOptional()
@IsString()
@MaxLength(500)
note?: string;
@IsArray()
@ArrayMinSize(1, { message: '최소 1개 이상의 상품이 필요합니다' })
@ValidateNested({ each: true })
@Type(() => OrderItemDto)
items: OrderItemDto[];
}
export class OrderItemDto {
@IsString()
@IsNotEmpty()
productId: string;
@IsInt()
@Min(1)
@Max(999)
quantity: number;
}
// 쿼리 파라미터 DTO
export class OrderListQueryDto {
@IsOptional()
@IsEnum(OrderStatus)
status?: OrderStatus;
@IsOptional()
@IsInt()
@Min(1)
@Transform(({ value }) => parseInt(value, 10))
page?: number = 1;
@IsOptional()
@IsInt()
@Min(1)
@Max(100)
@Transform(({ value }) => parseInt(value, 10))
limit?: number = 20;
@IsOptional()
@IsDateString()
startDate?: string;
@IsOptional()
@IsDateString()
endDate?: string;
@IsOptional()
@IsString()
@Matches(/^(createdAt|total|status)$/)
sortBy?: string = 'createdAt';
@IsOptional()
@IsEnum(['asc', 'desc'])
sortOrder?: 'asc' | 'desc' = 'desc';
}
컨트롤러에서 Pipe 사용
@Controller('orders')
export class OrderController {
constructor(private readonly orderService: OrderService) {}
// ValidationPipe가 글로벌이면 @Body()만으로 자동 검증
@Post()
create(
@CurrentUser() user: UserPayload,
@Body() dto: CreateOrderDto,
) {
return this.orderService.create(user.id, dto);
}
// ParseUUIDPipe: UUID 형식이 아니면 400 에러
@Get(':id')
findOne(
@Param('id', ParseUUIDPipe) id: string,
) {
return this.orderService.findOne(id);
}
// 여러 Pipe 체이닝: 기본값 → 정수 변환
@Get()
findAll(
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number,
@Query() query: OrderListQueryDto,
) {
return this.orderService.findAll({ ...query, page, limit });
}
// ParseEnumPipe: 유효한 Enum 값인지 검증
@Get('status/:status')
findByStatus(
@Param('status', new ParseEnumPipe(OrderStatus)) status: OrderStatus,
) {
return this.orderService.findByStatus(status);
}
// ParseArrayPipe: 배열 요청 본문 검증
@Post('bulk')
createBulk(
@Body(new ParseArrayPipe({
items: CreateOrderDto,
whitelist: true,
}))
dtos: CreateOrderDto[],
) {
return this.orderService.createBulk(dtos);
}
}
커스텀 Pipe: 도메인 검증
// 1. 엔티티 존재 여부 검증 Pipe
@Injectable()
export class ParseOrderPipe implements PipeTransform {
constructor(private readonly orderRepository: OrderRepository) {}
async transform(value: string, metadata: ArgumentMetadata) {
if (!isUUID(value)) {
throw new BadRequestException('유효한 주문 ID가 아닙니다');
}
const order = await this.orderRepository.findById(value);
if (!order) {
throw new NotFoundException(`주문을 찾을 수 없습니다: ${value}`);
}
return order; // 문자열 ID → Order 엔티티로 변환
}
}
// 사용: 핸들러에서 바로 Order 객체를 받음
@Get(':id')
findOne(@Param('id', ParseOrderPipe) order: Order) {
return order; // 이미 DB에서 조회된 엔티티
}
@Patch(':id')
update(
@Param('id', ParseOrderPipe) order: Order,
@Body() dto: UpdateOrderDto,
) {
return this.orderService.update(order, dto);
}
// 2. 파일 검증 Pipe
@Injectable()
export class FileValidationPipe implements PipeTransform {
private readonly allowedMimeTypes = [
'image/jpeg', 'image/png', 'image/webp',
];
private readonly maxSize = 5 * 1024 * 1024; // 5MB
transform(file: Express.Multer.File) {
if (!file) {
throw new BadRequestException('파일이 필요합니다');
}
if (!this.allowedMimeTypes.includes(file.mimetype)) {
throw new BadRequestException(
`허용된 파일 형식: ${this.allowedMimeTypes.join(', ')}`,
);
}
if (file.size > this.maxSize) {
throw new BadRequestException(
`파일 크기는 ${this.maxSize / 1024 / 1024}MB 이하여야 합니다`,
);
}
return file;
}
}
// 사용
@Post('avatar')
@UseInterceptors(FileInterceptor('file'))
uploadAvatar(
@UploadedFile(FileValidationPipe) file: Express.Multer.File,
) {
return this.userService.updateAvatar(file);
}
// 3. 문자열 정규화 Pipe (trim + lowercase)
@Injectable()
export class NormalizeStringPipe implements PipeTransform {
transform(value: any) {
if (typeof value === 'string') {
return value.trim().toLowerCase();
}
return value;
}
}
// 검색 키워드 정규화
@Get('search')
search(
@Query('q', NormalizeStringPipe) keyword: string,
) {
return this.productService.search(keyword);
}
커스텀 Validator: class-validator 확장
// 커스텀 데코레이터: DB 유니크 검증
@ValidatorConstraint({ async: true })
@Injectable()
export class IsEmailUniqueConstraint
implements ValidatorConstraintInterface
{
constructor(private readonly userRepository: UserRepository) {}
async validate(email: string) {
const user = await this.userRepository.findByEmail(email);
return !user; // 존재하지 않으면 유효
}
defaultMessage() {
return '이미 등록된 이메일입니다';
}
}
export function IsEmailUnique(options?: ValidationOptions) {
return (object: object, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName,
options,
constraints: [],
validator: IsEmailUniqueConstraint,
});
};
}
// DTO에서 사용
export class CreateUserDto {
@IsEmail()
@IsEmailUnique()
email: string;
@IsString()
@MinLength(2)
name: string;
}
// useContainer 설정 (main.ts)
import { useContainer } from 'class-validator';
useContainer(app.select(AppModule), { fallbackOnErrors: true });
PartialType과 IntersectionType
NestJS @nestjs/mapped-types로 DTO를 조합하여 중복을 제거한다.
import { PartialType, PickType, OmitType, IntersectionType } from '@nestjs/mapped-types';
// 모든 필드가 Optional인 Update DTO
export class UpdateOrderDto extends PartialType(CreateOrderDto) {}
// 특정 필드만 추출
export class LoginDto extends PickType(CreateUserDto, ['email', 'password']) {}
// 특정 필드 제외
export class UserResponseDto extends OmitType(CreateUserDto, ['password']) {}
// 두 DTO 교차
export class CreateUserWithProfileDto extends IntersectionType(
CreateUserDto,
CreateProfileDto,
) {}
Pipe vs Guard vs Interceptor 선택
| 목적 | 선택 |
|---|---|
| 입력 데이터 형식 검증 | Pipe (ValidationPipe) |
| 파라미터 타입 변환 | Pipe (ParseIntPipe 등) |
| 인증/인가 확인 | Guard |
| 응답 변환/로깅 | Interceptor |
| 에러 포맷팅 | Exception Filter |
NestJS Interceptor 6가지 패턴의 응답 변환과 Pipe의 입력 변환을 결합하면 요청-응답 전체 흐름을 깔끔하게 제어할 수 있다. NestJS Custom Decorators 가이드의 커스텀 파라미터 데코레이터와 Pipe를 결합하면 더 강력한 패턴을 구현할 수 있다.
정리: Pipe 설계 체크리스트
- 글로벌 ValidationPipe: whitelist + transform + forbidNonWhitelisted 필수
- DTO에 집중: 모든 검증 규칙은 DTO 클래스에 선언적으로 정의
- ParseXxxPipe 활용: 파라미터 타입 변환은 내장 Pipe로 처리
- 커스텀 Pipe: 엔티티 조회, 파일 검증 등 도메인 특화 변환에 사용
- mapped-types: PartialType/PickType으로 DTO 중복 제거
- 커스텀 Validator: DB 유니크 체크 등 비동기 검증은 ValidatorConstraint으로
- exceptionFactory: 검증 에러 응답 포맷을 API 스펙에 맞게 커스터마이징