코딩일상

[nest.js] nest.js 뿌수기 공식 docs 모조리 파헤치기[Pipes] 본문

개발 공부/nest.js

[nest.js] nest.js 뿌수기 공식 docs 모조리 파헤치기[Pipes]

solutionMan 2025. 12. 23. 20:41
반응형

1. Pipes의 정의와 역할

Pipes는 @Injectable() 데코레이터가 붙은 클래스로, PipeTransform 인터페이스를 구현합니다. 컨트롤러 라우트 핸들러가 실행되기 전에 메서드의 인자를 가로채서 처리하는 역할을 합니다.

두 가지 주요 사용 사례:

  • Transformation(변환): 입력 데이터를 원하는 형태로 변환 (예: string → integer)
  • Validation(검증): 입력 데이터 검증 후 통과 또는 예외 발생

중요한 특징:

  • Pipes는 예외 처리 영역(exceptions zone) 내에서 실행됩니다
  • Pipe에서 예외가 발생하면 전역 예외 필터나 해당 컨텍스트의 예외 필터가 처리합니다
  • Pipe에서 예외가 발생하면 컨트롤러 메서드는 실행되지 않습니다
  • 외부 소스에서 들어오는 데이터를 시스템 경계에서 검증하는 베스트 프랙티스를 제공합니다

2. Built-in Pipes (내장 파이프)

NestJS는 다음과 같은 내장 Pipes를 제공합니다 (@nestjs/common 패키지):

// 변환(Transformation) Pipes
ParseIntPipe       // string → integer
ParseFloatPipe     // string → float
ParseBoolPipe      // string → boolean
ParseArrayPipe     // string → array
ParseUUIDPipe      // string 검증 및 UUID로 변환
ParseEnumPipe      // enum 값 검증 및 변환
ParseDatePipe      // string → Date
DefaultValuePipe   // 기본값 제공
ParseFilePipe      // 파일 검증

// 검증(Validation) Pipe
ValidationPipe     // 객체 검증

3. Pipe Binding (파이프 바인딩)

Pipes는 여러 레벨에서 바인딩할 수 있습니다:

3.1 Parameter-scoped (파라미터 레벨)

// 기본 사용 - 클래스 전달 (DI 활용)
@Get(':id')
async findOne(@Param('id', ParseIntPipe) id: number) {
  return this.catsService.findOne(id);
}

// 인스턴스 전달 - 옵션 커스터마이징
@Get(':id')
async findOne(
  @Param('id', new ParseIntPipe({ 
    errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE 
  }))
  id: number,
) {
  return this.catsService.findOne(id);
}

// Query 파라미터에 적용
@Get()
async findOne(@Query('id', ParseIntPipe) id: number) {
  return this.catsService.findOne(id);
}

// UUID 검증
@Get(':uuid')
async findOne(@Param('uuid', new ParseUUIDPipe()) uuid: string) {
  return this.catsService.findOne(uuid);
}

3.2 Method-scoped (메서드 레벨)

@Post()
@UsePipes(new ZodValidationPipe(createCatSchema))
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

3.3 Global-scoped (전역 레벨)

// main.ts
async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  app.useGlobalPipes(new ValidationPipe());
  await app.listen(3000);
}

// 또는 모듈에서 DI 활용
@Module({
  providers: [
    {
      provide: APP_PIPE,
      useClass: ValidationPipe,
    },
  ],
})
export class AppModule {}

4. Custom Pipes 만들기

4.1 기본 구조

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

@Injectable()
export class ValidationPipe implements PipeTransform {
  transform(value: any, metadata: ArgumentMetadata) {
    return value;
  }
}

PipeTransform 인터페이스:

interface PipeTransform<T = any, R = any> {
  transform(value: T, metadata: ArgumentMetadata): R;
}

ArgumentMetadata 구조:

interface ArgumentMetadata {
  type: 'body' | 'query' | 'param' | 'custom';
  metatype?: Type<unknown>;  // String, Number 등의 타입
  data?: string;              // 데코레이터에 전달된 문자열
}

4.2 Transformation Pipe 예제

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

@Injectable()
export class ParseIntPipe implements PipeTransform<string, number> {
  transform(value: string, metadata: ArgumentMetadata): number {
    const val = parseInt(value, 10);
    if (isNaN(val)) {
      throw new BadRequestException('Validation failed');
    }
    return val;
  }
}

5. Schema-based Validation

5.1 Zod를 사용한 검증

// 설치
// npm install --save zod

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

export class ZodValidationPipe implements PipeTransform {
  constructor(private schema: ZodSchema) {}

  transform(value: unknown, metadata: ArgumentMetadata) {
    try {
      const parsedValue = this.schema.parse(value);
      return parsedValue;
    } catch (error) {
      throw new BadRequestException('Validation failed');
    }
  }
}

// Schema 정의
import { z } from 'zod';

export const createCatSchema = z
  .object({
    name: z.string(),
    age: z.number(),
    breed: z.string(),
  })
  .required();

export type CreateCatDto = z.infer<typeof createCatSchema>;

// 사용
@Post()
@UsePipes(new ZodValidationPipe(createCatSchema))
async create(@Body() createCatDto: CreateCatDto) {
  this.catsService.create(createCatDto);
}

주의: Zod는 tsconfig.json에서 strictNullChecks 설정이 필요합니다.

5.2 class-validator를 사용한 검증

// 설치
// npm i --save class-validator class-transformer

// DTO 정의
import { IsString, IsInt } from 'class-validator';

export class CreateCatDto {
  @IsString()
  name: string;

  @IsInt()
  age: number;

  @IsString()
  breed: string;
}

// ValidationPipe 구현
import { 
  PipeTransform, 
  Injectable, 
  ArgumentMetadata, 
  BadRequestException 
} from '@nestjs/common';
import { validate } from 'class-validator';
import { plainToInstance } from 'class-transformer';

@Injectable()
export class ValidationPipe implements PipeTransform<any> {
  async transform(value: any, { metatype }: ArgumentMetadata) {
    // 검증이 필요 없는 타입 체크
    if (!metatype || !this.toValidate(metatype)) {
      return value;
    }
    
    // plain object → typed object 변환
    const object = plainToInstance(metatype, value);
    
    // 검증 실행
    const errors = await validate(object);
    if (errors.length > 0) {
      throw new BadRequestException('Validation failed');
    }
    
    return value;
  }

  private toValidate(metatype: Function): boolean {
    const types: Function[] = [String, Boolean, Number, Array, Object];
    return !types.includes(metatype);
  }
}

// 사용
@Post()
async create(
  @Body(new ValidationPipe()) createCatDto: CreateCatDto,
) {
  this.catsService.create(createCatDto);
}

class-validator를 사용하는 이유:

  • DTO 클래스가 데이터 구조와 검증 로직의 단일 소스(Single Source of Truth)가 됩니다
  • 데코레이터 기반으로 깔끔하고 선언적입니다
  • metatype 정보를 활용하여 런타임에 타입 검증이 가능합니다

plainToInstance가 필요한 이유: 네트워크 요청에서 역직렬화된 body 객체는 타입 정보가 없는 plain JavaScript 객체입니다. class-validator가 DTO에 정의된 데코레이터를 사용하려면 적절한 타입 정보를 가진 객체로 변환해야 합니다.

 


이 코드의 흐름을 단계별로 완벽 설명

1. 요청이 들어왔을 때의 데이터 상태

// 클라이언트가 POST /cats 요청
// Body: { "name": "Kitty", "age": 3, "breed": "Persian" }

// Express/Fastify가 받은 데이터 (JSON.parse 후)
const incomingData = {
  name: "Kitty",
  age: 3,
  breed: "Persian"
};

// 이 시점의 문제점:
console.log(incomingData instanceof CreateCatDto);  // false
console.log(typeof incomingData);                   // "object"
// 그냥 plain JavaScript 객체일 뿐, CreateCatDto 클래스의 인스턴스가 아님!

2. ValidationPipe의 transform 메서드 실행 순서

@Injectable()
export class ValidationPipe implements PipeTransform<any> {
  async transform(value: any, { metatype }: ArgumentMetadata) {
    // 📍 STEP 1: 입력값 분석
    console.log('value:', value);
    // { name: "Kitty", age: 3, breed: "Persian" }
    
    console.log('metatype:', metatype);
    // [Function: CreateCatDto] - 실제 CreateCatDto 클래스 자체
    
    // 📍 STEP 2: 검증이 필요한지 체크
    if (!metatype || !this.toValidate(metatype)) {
      return value;
    }
    
    // 📍 STEP 3: 변환 (핵심!)
    const object = plainToInstance(metatype, value);
    
    // 📍 STEP 4: 검증
    const errors = await validate(object);
    if (errors.length > 0) {
      throw new BadRequestException('Validation failed');
    }
    
    // 📍 STEP 5: 원본 또는 변환된 값 반환
    return value;
  }
  
  private toValidate(metatype: Function): boolean {
    const types: Function[] = [String, Boolean, Number, Array, Object];
    return !types.includes(metatype);
  }
}

3. 각 단계 상세 설명

STEP 1: ArgumentMetadata 이해

// @Body() 데코레이터가 제공하는 메타데이터
const metadata: ArgumentMetadata = {
  type: 'body',              // 어디서 온 데이터인가? (body, query, param, custom)
  metatype: CreateCatDto,    // 어떤 타입이어야 하는가?
  data: undefined            // 데코레이터에 전달된 키 (예: @Body('user')면 'user')
};

// 왜 metatype이 중요한가?
// TypeScript 컴파일 후에도 런타임에서 CreateCatDto 클래스 정보를 얻을 수 있음!

STEP 2: toValidate() 메서드 - 왜 필요한가?

private toValidate(metatype: Function): boolean {
  const types: Function[] = [String, Boolean, Number, Array, Object];
  return !types.includes(metatype);
}

// 사용 예시:
toValidate(String);      // false - 검증 불필요 (기본 타입)
toValidate(Number);      // false - 검증 불필요
toValidate(CreateCatDto); // true  - 검증 필요! (커스텀 클래스)

// 이유:
// @Query('page') page: number 같은 경우
// - metatype은 Number (기본 JavaScript 타입)
// - 기본 타입에는 class-validator 데코레이터를 붙일 수 없음
// - 검증할 필요가 없음 (이미 타입 변환만 필요)

STEP 3: plainToInstance() - 가장 중요한 부분!

// 변환 전
const plainObject = { name: "Kitty", age: 3, breed: "Persian" };
console.log(plainObject instanceof CreateCatDto);  // false
console.log(plainObject.constructor.name);          // "Object"

// plainToInstance 실행
const object = plainToInstance(CreateCatDto, plainObject);

// 변환 후
console.log(object instanceof CreateCatDto);        // true
console.log(object.constructor.name);               // "CreateCatDto"

// 내부적으로 일어나는 일:
// 1. new CreateCatDto() 인스턴스 생성
// 2. plainObject의 속성들을 새 인스턴스에 복사
// 3. 클래스 메타데이터 보존 (데코레이터 정보 포함!)

왜 이 변환이 필요한가?

export class CreateCatDto {
  @IsString()  // 👈 이 메타데이터는 CreateCatDto 클래스에 저장됨
  name: string;
  
  @IsInt()     // 👈 이것도 CreateCatDto 클래스에 저장됨
  age: number;
  
  @IsString()
  breed: string;
}

// plain object는 이 데코레이터 정보를 가지고 있지 않음!
const plain = { name: "Kitty", age: 3, breed: "Persian" };
// plain 객체에는 @IsString(), @IsInt() 같은 메타데이터가 없음

// class-validator의 validate() 함수는
// 클래스 인스턴스의 메타데이터를 읽어서 검증을 수행함
// 따라서 반드시 CreateCatDto 인스턴스여야 함!

STEP 4: validate() 실행

import { validate } from 'class-validator';

const object = plainToInstance(CreateCatDto, value);
const errors = await validate(object);

// validate()가 하는 일:
// 1. object의 클래스(CreateCatDto)에서 메타데이터 읽기
// 2. 각 속성에 붙은 데코레이터 확인
// 3. 실제 값이 규칙을 만족하는지 검사

// 예시:
const invalidData = { name: 123, age: "not a number", breed: "Persian" };
const obj = plainToInstance(CreateCatDto, invalidData);
const errors = await validate(obj);

// errors 배열:
[
  {
    property: 'name',
    constraints: { isString: 'name must be a string' }
  },
  {
    property: 'age',
    constraints: { isInt: 'age must be an integer number' }
  }
]

STEP 5: 왜 value를 반환하는가?

// 검증 통과 후
return value;  // ❓ object가 아니라 value를 반환?

// 이유 1: value와 object는 데이터가 동일함
// - object는 검증을 위해 생성한 인스턴스
// - value는 원본 plain object
// - 둘 다 같은 데이터를 담고 있음

// 이유 2: 컨트롤러에서 받는 타입
@Post()
async create(@Body(new ValidationPipe()) createCatDto: CreateCatDto) {
  // TypeScript는 여기서 createCatDto가 CreateCatDto 타입이라고 생각하지만
  // 실제로는 plain object일 수도 있음 (런타임에서는 차이 없음)
}

// 개선 방법: object를 반환하도록 수정
return object;  // 이렇게 하면 실제 인스턴스를 반환

4. 전체 실행 흐름 시각화

// 1️⃣ 클라이언트 요청
POST /cats
Body: { "name": "Kitty", "age": 3, "breed": "Persian" }

↓

// 2️⃣ NestJS가 요청 받음
const rawBody = { name: "Kitty", age: 3, breed: "Persian" };
// typeof rawBody: "object"
// rawBody instanceof CreateCatDto: false

↓

// 3️⃣ ValidationPipe.transform() 호출
transform(
  value: { name: "Kitty", age: 3, breed: "Persian" },
  metadata: { type: 'body', metatype: CreateCatDto, data: undefined }
)

↓

// 4️⃣ 검증 필요 여부 체크
if (!CreateCatDto || !toValidate(CreateCatDto)) // false, 계속 진행

↓

// 5️⃣ plain object → class instance 변환
const object = plainToInstance(CreateCatDto, value);
// object instanceof CreateCatDto: true ✅
// 이제 object에는 @IsString(), @IsInt() 메타데이터가 있음!

↓

// 6️⃣ 검증 실행
const errors = await validate(object);
// validate()가 메타데이터를 읽고 각 속성 검증
// errors.length === 0 이면 통과

↓

// 7️⃣ 검증 통과하면 반환
return value;

↓

// 8️⃣ 컨트롤러 메서드 실행
async create(createCatDto: CreateCatDto) {
  // createCatDto 사용 가능
}

5. 흔한 오해와 해결

오해 1: "왜 TypeScript 타입만으로는 안 되나요?"

// TypeScript 타입은 컴파일 타임에만 존재
interface CreateCatDto {
  name: string;
  age: number;
  breed: string;
}

// 컴파일 후 (JavaScript)
// interface는 완전히 사라짐! 런타임에 존재하지 않음

// 반면 클래스는 런타임에도 존재
class CreateCatDto {
  name: string;
  age: number;
  breed: string;
}

// 컴파일 후에도 클래스는 남아있음
// 따라서 metatype으로 CreateCatDto 클래스 정보를 받을 수 있음!

오해 2: "plainToInstance 없이 바로 validate 하면 안 되나요?"

// ❌ 안 됨!
const plainObject = { name: "Kitty", age: 3, breed: "Persian" };
const errors = await validate(plainObject);
// errors는 빈 배열! 검증이 안 됨!

// 이유: plainObject에는 데코레이터 메타데이터가 없음
// validate()는 메타데이터를 읽어야 하는데 읽을 게 없음

// ✅ 반드시 plainToInstance 필요
const object = plainToInstance(CreateCatDto, plainObject);
const errors = await validate(object);
// 이제 object에 메타데이터가 있어서 검증 가능!

6. 개선된 코드 (더 명확한 버전)

@Injectable()
export class ValidationPipe implements PipeTransform<any> {
  async transform(value: any, metadata: ArgumentMetadata) {
    const { metatype } = metadata;
    
    // 1. metatype이 없거나 기본 타입이면 검증 불필요
    if (!metatype || this.isNativeType(metatype)) {
      return value;
    }
    
    // 2. plain object를 class instance로 변환
    // 이 과정에서 데코레이터 메타데이터가 활성화됨
    const instance = plainToInstance(metatype, value);
    
    // 3. class-validator로 검증
    const errors = await validate(instance);
    
    // 4. 에러가 있으면 예외 발생
    if (errors.length > 0) {
      const messages = errors.map(error => 
        Object.values(error.constraints || {})
      ).flat();
      throw new BadRequestException({
        message: 'Validation failed',
        errors: messages
      });
    }
    
    // 5. 변환된 인스턴스 반환 (원본 value가 아닌 instance!)
    return instance;
  }
  
  private isNativeType(metatype: Function): boolean {
    const nativeTypes = [String, Boolean, Number, Array, Object];
    return nativeTypes.includes(metatype);
  }
}

7. 핵심 요약

  1. 클라이언트 데이터는 plain object: 클래스 정보와 데코레이터 메타데이터가 없음
  2. plainToInstance의 역할: plain object → class instance 변환으로 메타데이터 활성화
  3. validate()의 요구사항: 반드시 클래스 인스턴스여야 메타데이터를 읽을 수 있음
  4. toValidate()의 역할: 기본 타입은 검증 불필요, 커스텀 클래스만 검증
  5. metatype의 중요성: TypeScript 타입 정보를 런타임에서 사용 가능하게 함

 


 

6. Built-in ValidationPipe 활용

NestJS가 제공하는 내장 ValidationPipe는 더 많은 기능을 제공합니다:

// 전역 적용
app.useGlobalPipes(new ValidationPipe({
  whitelist: true,              // DTO에 없는 속성 제거
  forbidNonWhitelisted: true,   // DTO에 없는 속성 있으면 예외
  transform: true,              // 자동 타입 변환
  transformOptions: {
    enableImplicitConversion: true,
  },
  disableErrorMessages: false,  // 프로덕션에서 에러 메시지 숨김
}));

자세한 내용은 Validation 기법 문서를 참고하세요.

7. DefaultValuePipe 활용

쿼리 파라미터가 없을 때 기본값을 제공합니다:

@Get()
async findAll(
  @Query('activeOnly', new DefaultValuePipe(false), ParseBoolPipe) 
  activeOnly: boolean,
  
  @Query('page', new DefaultValuePipe(0), ParseIntPipe) 
  page: number,
) {
  return this.catsService.findAll({ activeOnly, page });
}

동작 순서:

  1. DefaultValuePipe가 먼저 실행되어 값이 없으면 기본값 제공
  2. ParseBoolPipe 또는 ParseIntPipe가 타입 변환 수행

8. 고급 사용 사례

8.1 Entity 변환 Pipe

@Injectable()
export class UserByIdPipe implements PipeTransform<string, Promise<UserEntity>> {
  constructor(private userService: UserService) {}

  async transform(id: string, metadata: ArgumentMetadata): Promise<UserEntity> {
    const user = await this.userService.findById(id);
    if (!user) {
      throw new NotFoundException('User not found');
    }
    return user;
  }
}

// 사용
@Get(':id')
findOne(@Param('id', UserByIdPipe) userEntity: UserEntity) {
  return userEntity;  // 이미 DB에서 조회된 entity
}

이렇게 하면 핸들러 코드가 선언적이고 보일러플레이트 코드를 줄일 수 있습니다.

9. 주요 차이점 정리

구분 class-validator Zod

스타일 데코레이터 기반 스키마 기반
타입 추론 별도 DTO 클래스 필요 z.infer로 타입 자동 추론
단일 소스 DTO 클래스 자체 스키마에서 타입 생성
의존성 class-transformer 필요 단독 사용 가능
TypeScript 필수 필수 (strictNullChecks)

10. 베스트 프랙티스

  1. 전역 ValidationPipe 사용: 애플리케이션 전체에 일관된 검증 적용
  2. DTO 재사용: 같은 DTO를 여러 곳에서 사용하여 DRY 원칙 준수
  3. 명확한 에러 메시지: 사용자가 이해하기 쉬운 에러 메시지 제공
  4. 적절한 스코프 선택: 필요한 레벨에서만 Pipe 적용
  5. 변환과 검증 분리: 각 Pipe는 단일 책임을 가지도록 설계
  6. 비동기 처리: 필요시 async/await 활용 (DB 조회 등)
  7. Native 타입 스킵: JavaScript 기본 타입은 검증 생략
  8. 의존성 주입 활용: 모듈 내에서 전역 Pipe 등록 시 DI 사용 가능
반응형
Comments