NestJS Pipe란?
NestJS의 Pipe는 컨트롤러 핸들러에 도달하기 전 입력 데이터를 변환(transformation)하거나 검증(validation)하는 계층입니다. Express의 미들웨어와 달리, Pipe는 특정 파라미터에 바인딩되어 정확히 필요한 데이터만 처리합니다.
Pipe는 PipeTransform 인터페이스를 구현하며, transform(value, metadata) 메서드 하나만 정의하면 됩니다. 이 단순한 구조 안에 강력한 패턴들이 숨어 있습니다.
Built-in Pipe 완전 정복
NestJS는 9개의 내장 Pipe를 제공합니다. 실무에서 가장 많이 쓰이는 핵심 3가지를 깊이 살펴봅시다.
ValidationPipe — class-validator 통합
ValidationPipe는 class-validator와 class-transformer를 결합하여 DTO 기반 검증을 수행합니다.
// main.ts — 글로벌 설정
app.useGlobalPipes(new ValidationPipe({
whitelist: true, // DTO에 없는 속성 자동 제거
forbidNonWhitelisted: true, // 정의되지 않은 속성이 있으면 400 에러
transform: true, // 쿼리 파라미터 자동 타입 변환
transformOptions: {
enableImplicitConversion: true, // @Type() 없이도 타입 변환
},
}));
핵심 포인트: whitelist: true는 보안상 필수입니다. 공격자가 isAdmin: true 같은 필드를 주입하는 Mass Assignment 공격을 방지합니다.
ParseIntPipe — 타입 안전 변환
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
// id는 반드시 number 타입
// "abc" 입력 시 자동으로 400 Bad Request
}
ParseIntPipe에 옵션을 전달하면 에러 응답을 커스터마이징할 수 있습니다:
@Param('id', new ParseIntPipe({
errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE,
exceptionFactory: (error) =>
new NotAcceptableException(`ID must be numeric: ${error}`),
}))
DefaultValuePipe — 선택적 파라미터 기본값
@Get()
findAll(
@Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
@Query('limit', new DefaultValuePipe(20), ParseIntPipe) limit: number,
) {
// page, limit가 없으면 기본값 적용 후 숫자 변환
}
Pipe는 체이닝이 가능합니다. 왼쪽에서 오른쪽 순서로 실행되므로, DefaultValuePipe → ParseIntPipe 순서가 올바릅니다.
커스텀 Pipe 실전 패턴
패턴 1: 파일 검증 Pipe
파일 업로드 시 크기, MIME 타입, 매직 바이트까지 검증하는 Pipe입니다.
import { PipeTransform, Injectable, BadRequestException } from '@nestjs/common';
@Injectable()
export class FileValidationPipe implements PipeTransform {
constructor(
private readonly options: {
maxSize: number; // bytes
allowedMimes: string[];
},
) {}
transform(file: Express.Multer.File) {
if (!file) {
throw new BadRequestException('파일이 필요합니다');
}
if (file.size > this.options.maxSize) {
throw new BadRequestException(
`파일 크기 초과: ${(file.size / 1024 / 1024).toFixed(1)}MB / 최대 ${(this.options.maxSize / 1024 / 1024).toFixed(1)}MB`,
);
}
if (!this.options.allowedMimes.includes(file.mimetype)) {
throw new BadRequestException(
`허용되지 않는 파일 형식: ${file.mimetype}`,
);
}
// 매직 바이트 검증 (MIME spoofing 방지)
const magicBytes = file.buffer?.slice(0, 4);
if (magicBytes && !this.verifyMagicBytes(magicBytes, file.mimetype)) {
throw new BadRequestException('파일 내용이 확장자와 일치하지 않습니다');
}
return file;
}
private verifyMagicBytes(bytes: Buffer, mime: string): boolean {
const signatures: Record<string, number[]> = {
'image/png': [0x89, 0x50, 0x4e, 0x47],
'image/jpeg': [0xff, 0xd8, 0xff],
'application/pdf': [0x25, 0x50, 0x44, 0x46],
};
const expected = signatures[mime];
if (!expected) return true;
return expected.every((byte, i) => bytes[i] === byte);
}
}
// 사용
@Post('upload')
@UseInterceptors(FileInterceptor('file'))
uploadFile(
@UploadedFile(new FileValidationPipe({
maxSize: 5 * 1024 * 1024,
allowedMimes: ['image/png', 'image/jpeg'],
}))
file: Express.Multer.File,
) { ... }
패턴 2: 조건부 변환 Pipe
입력 형태에 따라 다른 변환 로직을 적용하는 Pipe입니다. ID 또는 slug를 모두 받아야 하는 API에서 유용합니다.
@Injectable()
export class ParseIdOrSlugPipe implements PipeTransform {
transform(value: string): { type: 'id'; value: number } | { type: 'slug'; value: string } {
const parsed = parseInt(value, 10);
if (!isNaN(parsed) && parsed > 0) {
return { type: 'id', value: parsed };
}
// slug 형식 검증: 영문 소문자, 숫자, 하이픈만
if (/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(value)) {
return { type: 'slug', value };
}
throw new BadRequestException('유효한 ID 또는 slug가 아닙니다');
}
}
// 사용
@Get(':identifier')
findOne(@Param('identifier', ParseIdOrSlugPipe) identifier) {
if (identifier.type === 'id') {
return this.service.findById(identifier.value);
}
return this.service.findBySlug(identifier.value);
}
패턴 3: 비동기 검증 Pipe (DB 조회)
데이터베이스에서 존재 여부를 확인하는 Pipe입니다. 존재하지 않는 리소스에 대한 요청을 컨트롤러 진입 전에 차단합니다.
@Injectable()
export class EntityExistsPipe implements PipeTransform {
constructor(
@InjectRepository(User)
private readonly userRepo: Repository<User>,
) {}
async transform(value: number): Promise<User> {
const user = await this.userRepo.findOne({ where: { id: value } });
if (!user) {
throw new NotFoundException(`User #${value} not found`);
}
return user; // 엔티티 객체를 직접 반환
}
}
// 사용 — 컨트롤러에서 별도 조회 불필요
@Get(':id')
getUser(@Param('id', ParseIntPipe, EntityExistsPipe) user: User) {
return user; // 이미 DB에서 조회된 엔티티
}
주의: DB 조회 Pipe는 편리하지만, 트랜잭션 컨텍스트 밖에서 실행됩니다. 트랜잭션이 필요한 경우 서비스 계층에서 처리하세요.
Pipe 바인딩 스코프와 실행 순서
Pipe는 4가지 레벨에서 바인딩할 수 있으며, 실행 순서가 다릅니다:
| 스코프 | 적용 방법 | 실행 순서 |
|---|---|---|
| Global | app.useGlobalPipes() |
1번째 |
| Controller | @UsePipes() on class |
2번째 |
| Method | @UsePipes() on method |
3번째 |
| Parameter | @Param('id', Pipe) |
4번째 |
중요: Global/Controller/Method 레벨 Pipe는 모든 파라미터에 대해 실행됩니다. Parameter 레벨 Pipe만 특정 파라미터에만 적용됩니다.
// Global Pipe가 ValidationPipe이고,
// Parameter Pipe가 ParseIntPipe일 때:
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
// 실행 순서:
// 1. ValidationPipe.transform(id값, {type:'param', metatype:Number, data:'id'})
// 2. ParseIntPipe.transform(id값, {type:'param', metatype:Number, data:'id'})
}
ArgumentMetadata 활용 고급 패턴
transform의 두 번째 인자인 ArgumentMetadata에는 세 가지 정보가 있습니다:
interface ArgumentMetadata {
type: 'body' | 'query' | 'param' | 'custom'; // 파라미터 종류
metatype?: Type; // TypeScript 타입 (Number, String, CreateDto 등)
data?: string; // 데코레이터에 전달된 키 ('id', 'name' 등)
}
이를 활용하면 하나의 Pipe로 여러 상황을 처리할 수 있습니다:
@Injectable()
export class SmartParsePipe implements PipeTransform {
transform(value: any, metadata: ArgumentMetadata) {
// body는 건드리지 않음 (ValidationPipe가 처리)
if (metadata.type === 'body') return value;
// query 파라미터는 metatype에 따라 자동 변환
if (metadata.type === 'query' && metadata.metatype === Number) {
const num = Number(value);
if (isNaN(num)) throw new BadRequestException(`${metadata.data}는 숫자여야 합니다`);
return num;
}
// boolean 변환
if (metadata.metatype === Boolean) {
return value === 'true' || value === '1';
}
return value;
}
}
테스트 전략
Pipe는 단순한 transform 함수이므로 단위 테스트가 매우 쉽습니다.
describe('FileValidationPipe', () => {
let pipe: FileValidationPipe;
beforeEach(() => {
pipe = new FileValidationPipe({
maxSize: 1024,
allowedMimes: ['image/png'],
});
});
it('파일이 없으면 BadRequestException', () => {
expect(() => pipe.transform(null)).toThrow(BadRequestException);
});
it('크기 초과 시 BadRequestException', () => {
const file = { size: 2048, mimetype: 'image/png' } as Express.Multer.File;
expect(() => pipe.transform(file)).toThrow('파일 크기 초과');
});
it('유효한 파일은 그대로 반환', () => {
const file = { size: 512, mimetype: 'image/png' } as Express.Multer.File;
expect(pipe.transform(file)).toBe(file);
});
});
// 비동기 Pipe 테스트
describe('EntityExistsPipe', () => {
it('존재하지 않는 엔티티는 NotFoundException', async () => {
const mockRepo = { findOne: jest.fn().mockResolvedValue(null) };
const pipe = new EntityExistsPipe(mockRepo as any);
await expect(pipe.transform(999)).rejects.toThrow(NotFoundException);
});
});
실전 팁: Pipe vs Guard vs Interceptor
| 계층 | 역할 | 사용 시점 |
|---|---|---|
| Guard | 인가(Authorization) | “이 사용자가 접근할 수 있는가?” |
| Pipe | 변환·검증 | “입력 데이터가 올바른가?” |
| Interceptor | 로깅·캐싱·응답 변환 | “요청/응답을 가공해야 하는가?” |
실행 순서: Guard → Interceptor(before) → Pipe → Handler → Interceptor(after)
Pipe에서 인가 로직을 넣거나, Guard에서 데이터 변환을 하지 마세요. 각 계층의 Guard와 Interceptor는 명확한 책임이 있습니다.
정리
NestJS Pipe의 핵심은 “입력을 믿지 말라”입니다. 모든 외부 입력은 Pipe를 통해 검증되고 변환되어야 합니다. ValidationPipe의 whitelist 옵션으로 Mass Assignment를 방지하고, 커스텀 Pipe로 파일 검증이나 엔티티 존재 확인까지 컨트롤러 진입 전에 처리하세요. Pipe의 단순한 인터페이스가 주는 테스트 용이성은 덤입니다.