NestJS

8. NestJS 파이프(Pipes): 입력 유효성 검사와 변환

오늘의하늘은 2025. 3. 31. 00:01

웹 애플리케이션을 개발할 때 클라이언트로부터 받은 데이터를 검증하고 필요한 형식으로 변환하는 것은 매우 중요합니다. NestJS는 이러한 작업을 처리하기 위한 '파이프(Pipes)'라는 강력한 메커니즘을 제공합니다. 파이프는 요청 데이터가 컨트롤러의 라우트 핸들러에 도달하기 전에 중간에서 데이터를 가공하거나 검증하는 역할을 합니다.

이 글에서는 NestJS의 파이프 시스템의 동작 방식, 내장 파이프 사용법, 그리고 커스텀 파이프를 만들어 애플리케이션의 요구사항에 맞는 데이터 검증 및 변환 로직을 구현하는 방법을 자세히 살펴보겠습니다.

목차

  1. 파이프의 개념과 역할
  2. NestJS의 내장 파이프
  3. 유효성 검사 파이프
  4. Class-validator를 이용한 객체 유효성 검사
  5. 커스텀 파이프 만들기
  6. 전역 파이프 등록하기
  7. 파이프 조합하기
  8. 비동기 파이프
  9. 성능 고려사항
  10. 모범 사례와 패턴

1. 파이프의 개념과 역할

NestJS에서 파이프는 PipeTransform 인터페이스를 구현하는 클래스로, 주로 다음 두 가지 용도로 사용됩니다:

  1. 변환(Transformation): 입력 데이터를 원하는 형식으로 변환합니다. 예를 들어, 문자열을 숫자로 변환하거나 날짜 문자열을 Date 객체로 변환할 수 있습니다.
  2. 유효성 검사(Validation): 입력 데이터가 특정 조건을 만족하는지 검사하고, 그렇지 않으면 예외를 발생시킵니다.

파이프는 컨트롤러 라우트 핸들러가 처리하기 전에 요청 데이터에 접근할 수 있어, 데이터가 비즈니스 로직에 도달하기 전에 적절한 형식과 유효성을 갖추도록 보장합니다.

2. NestJS의 내장 파이프

NestJS는 일반적인 사용 사례를 위한 여러 내장 파이프를 제공합니다:

  1. ValidationPipe: 객체의 속성을 검증합니다. class-validator와 함께 사용하면 강력한 유효성 검사 기능을 제공합니다.
  2. ParseIntPipe: 문자열을 정수로 변환합니다.
  3. ParseFloatPipe: 문자열을 부동 소수점 숫자로 변환합니다.
  4. ParseBoolPipe: 문자열을 불리언(boolean) 값으로 변환합니다.
  5. ParseArrayPipe: 값을 배열로 변환합니다.
  6. ParseUUIDPipe: 문자열이 UUID인지 확인합니다.
  7. ParseEnumPipe: 값이 열거형(enum)에 정의된 값인지 확인합니다.
  8. DefaultValuePipe: 값이 undefined인 경우 기본값을 제공합니다.

내장 파이프 사용 예시

import { 
  Controller, 
  Get, 
  Param, 
  Query, 
  ParseIntPipe, 
  ParseBoolPipe, 
  DefaultValuePipe
} from '@nestjs/common';

@Controller('products')
export class ProductsController {
  @Get(':id')
  findOne(@Param('id', ParseIntPipe) id: number) {
    // id는 이미 숫자로 변환되었습니다.
    return `Product with id: ${id}`;
  }

  @Get()
  findAll(
    @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
    @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number,
    @Query('includeOutOfStock', new DefaultValuePipe(false), ParseBoolPipe) includeOutOfStock: boolean,
  ) {
    return `Products page: ${page}, limit: ${limit}, includeOutOfStock: ${includeOutOfStock}`;
  }
}

위 예시에서 ParseIntPipe는 경로 매개변수와 쿼리 매개변수를 숫자로 변환합니다. 변환할 수 없는 값이 제공되면 파이프는 BadRequestException을 발생시킵니다. DefaultValuePipe는 값이 제공되지 않았을 때 기본값을 설정합니다.

3. 유효성 검사 파이프

NestJS의 ValidationPipe는 데이터 유효성 검사를 위한 강력한 도구입니다. 주로 DTO(Data Transfer Object)와 함께 사용되어 클라이언트 요청 데이터의 유효성을 검사합니다.

ValidationPipe 설정하기

import { ValidationPipe } from '@nestjs/common';
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  
  app.useGlobalPipes(
    new ValidationPipe({
      whitelist: true, // DTO에 정의되지 않은 속성을 자동으로 제거합니다.
      forbidNonWhitelisted: true, // DTO에 정의되지 않은 속성이 있으면 요청을 거부합니다.
      transform: true, // 요청 페이로드를 DTO 클래스의 인스턴스로 자동 변환합니다.
      transformOptions: {
        enableImplicitConversion: true, // 암시적 타입 변환을 활성화합니다.
      },
    }),
  );
  
  await app.listen(3000);
}
bootstrap();

ValidationPipe 옵션 설명

  1. whitelist (boolean): DTO 클래스에 데코레이터가 없는 모든 속성을 자동으로 제거합니다.
  2. forbidNonWhitelisted (boolean): DTO에 정의되지 않은 속성이 요청에 포함된 경우 요청 자체를 거부합니다.
  3. transform (boolean): 요청 페이로드를 DTO 클래스의 인스턴스로 자동 변환합니다.
  4. transformOptions (object): 변환 관련 추가 옵션을 설정합니다.
    • enableImplicitConversion: 암시적 타입 변환을 활성화합니다. 예를 들어, 쿼리 매개변수로 받은 문자열 "1"을 자동으로 숫자 1로 변환합니다.
  5. skipMissingProperties (boolean): 누락된 속성을 무시합니다.
  6. disableErrorMessages (boolean): 클라이언트에 오류 메시지를 반환하지 않습니다. 프로덕션 환경에서 유용합니다.
  7. exceptionFactory (function): 유효성 검사 오류가 발생했을 때 호출될 사용자 정의 함수입니다.

4. Class-validator를 이용한 객체 유효성 검사

NestJS는 class-validator 라이브러리를 활용하여 DTO 클래스에서 데코레이터 기반 유효성 검사를 지원합니다. 이를 사용하려면 먼저 필요한 패키지를 설치해야 합니다:

npm install class-validator class-transformer

DTO 정의 및 유효성 검사 데코레이터 사용하기

// create-user.dto.ts
import { 
  IsEmail, 
  IsNotEmpty, 
  IsString, 
  MinLength, 
  MaxLength,
  IsOptional,
  Matches,
  IsNumber,
  Min,
  Max
} from 'class-validator';

export class CreateUserDto {
  @IsNotEmpty()
  @IsString()
  @MinLength(2)
  @MaxLength(50)
  name: string;

  @IsNotEmpty()
  @IsEmail()
  email: string;

  @IsNotEmpty()
  @IsString()
  @MinLength(8)
  @MaxLength(30)
  @Matches(/((?=.*\d)|(?=.*\W+))(?![.\n])(?=.*[A-Z])(?=.*[a-z]).*$/, {
    message: '비밀번호는 최소 1개의 대문자, 소문자, 숫자 또는 특수 문자를 포함해야 합니다.',
  })
  password: string;

  @IsOptional()
  @IsNumber()
  @Min(0)
  @Max(150)
  age?: number;
}

컨트롤러에서 DTO 사용하기

// users.controller.ts
import { Body, Controller, Post, ValidationPipe } from '@nestjs/common';
import { CreateUserDto } from './dto/create-user.dto';
import { UsersService } from './users.service';

@Controller('users')
export class UsersController {
  constructor(private readonly usersService: UsersService) {}

  @Post()
  // 여기에서 ValidationPipe를 별도로 설정할 수도 있습니다.
  create(@Body(ValidationPipe) createUserDto: CreateUserDto) {
    return this.usersService.create(createUserDto);
  }
}

전역적으로 ValidationPipe를 설정했다면, 각 핸들러에서 별도로 지정할 필요가 없습니다. 그러나 특정 엔드포인트에 대해 다른 설정을 적용하려면 위와 같이 데코레이터에 파이프를 명시적으로 추가할 수 있습니다.

주요 class-validator 데코레이터

문자열 유효성 검사

  • @IsString(): 값이 문자열인지 확인합니다.
  • @IsNotEmpty(): 값이 비어있지 않은지 확인합니다.
  • @MinLength(min): 문자열의 최소 길이를 검사합니다.
  • @MaxLength(max): 문자열의 최대 길이를 검사합니다.
  • @Matches(pattern): 문자열이 정규식 패턴과 일치하는지 확인합니다.
  • @IsEmail(): 이메일 형식인지 확인합니다.
  • @IsUrl(): URL 형식인지 확인합니다.

숫자 유효성 검사

  • @IsNumber(): 값이 숫자인지 확인합니다.
  • @Min(min): 숫자의 최솟값을 검사합니다.
  • @Max(max): 숫자의 최댓값을 검사합니다.
  • @IsPositive(): 숫자가 양수인지 확인합니다.
  • @IsNegative(): 숫자가 음수인지 확인합니다.

날짜 유효성 검사

  • @IsDate(): 값이 유효한 날짜인지 확인합니다.
  • @MinDate(date): 날짜가 지정된 날짜 이후인지 확인합니다.
  • @MaxDate(date): 날짜가 지정된 날짜 이전인지 확인합니다.

배열 유효성 검사

  • @IsArray(): 값이 배열인지 확인합니다.
  • @ArrayMinSize(min): 배열의 최소 길이를 검사합니다.
  • @ArrayMaxSize(max): 배열의 최대 길이를 검사합니다.
  • @ArrayUnique(): 배열의 모든 요소가 고유한지 확인합니다.

불리언 및 기타 유효성 검사

  • @IsBoolean(): 값이 불리언인지 확인합니다.
  • @IsEnum(entity): 값이 지정된 열거형에 포함되는지 확인합니다.
  • @IsOptional(): 값이 undefined이거나 null이면 다른 유효성 검사를 건너뜁니다.
  • @ValidateNested(): 중첩된 객체의 유효성을 검사합니다. @Type() 데코레이터와 함께 사용해야 합니다.

5. 커스텀 파이프 만들기

기본 제공되는 파이프만으로는 충분하지 않은 경우, 특정 요구사항에 맞는 커스텀 파이프를 만들 수 있습니다. 커스텀 파이프는 PipeTransform 인터페이스를 구현해야 합니다.

간단한 커스텀 변환 파이프 예시

여기서는 쉼표로 구분된 문자열을 숫자 배열로 변환하는 파이프를 만들어 보겠습니다:

// parse-int-array.pipe.ts
import { PipeTransform, Injectable, ArgumentMetadata, BadRequestException } from '@nestjs/common';

@Injectable()
export class ParseIntArrayPipe implements PipeTransform {
  transform(value: any, metadata: ArgumentMetadata) {
    if (!value) {
      return [];
    }
    
    // 이미 배열인 경우 각 요소를 숫자로 변환
    if (Array.isArray(value)) {
      return value.map(item => this.parseInteger(item));
    }
    
    // 쉼표로 구분된 문자열을 분할하고 각 부분을 숫자로 변환
    return value.toString().split(',').map(item => this.parseInteger(item.trim()));
  }

  private parseInteger(value: string): number {
    const val = parseInt(value, 10);
    if (isNaN(val)) {
      throw new BadRequestException(`${value}는 유효한 숫자가 아닙니다.`);
    }
    return val;
  }
}

커스텀 파이프 사용하기

import { Controller, Get, Query } from '@nestjs/common';
import { ParseIntArrayPipe } from './pipes/parse-int-array.pipe';

@Controller('products')
export class ProductsController {
  @Get('filter')
  filterByIds(@Query('ids', new ParseIntArrayPipe()) ids: number[]) {
    return `Filtering products by IDs: ${ids.join(', ')}`;
  }
}

이제 /products/filter?ids=1,2,3 요청을 보내면, ids 매개변수는 [1, 2, 3] 숫자 배열로 변환됩니다.

커스텀 유효성 검사 파이프 예시

이번에는 특정 엔티티가 존재하는지 확인하는 파이프를 만들어 보겠습니다:

// entity-exists.pipe.ts
import { PipeTransform, Injectable, ArgumentMetadata, NotFoundException } from '@nestjs/common';
import { Repository } from 'typeorm';
import { InjectRepository } from '@nestjs/typeorm';
import { User } from '../entities/user.entity';

@Injectable()
export class UserExistsPipe implements PipeTransform {
  constructor(
    @InjectRepository(User)
    private readonly userRepository: Repository<User>,
  ) {}

  async transform(value: any, metadata: ArgumentMetadata) {
    if (!value) {
      throw new NotFoundException('사용자 ID가 제공되지 않았습니다.');
    }

    const id = parseInt(value, 10);
    if (isNaN(id)) {
      throw new NotFoundException('유효하지 않은 사용자 ID입니다.');
    }

    const user = await this.userRepository.findOneBy({ id });
    if (!user) {
      throw new NotFoundException(`ID가 ${id}인 사용자를 찾을 수 없습니다.`);
    }

    return user; // 값을 반환하여 실제 엔티티로 교체
  }
}

사용 예시

import { Controller, Get, Param } from '@nestjs/common';
import { User } from '../entities/user.entity';
import { UserExistsPipe } from './pipes/user-exists.pipe';

@Controller('users')
export class UsersController {
  @Get(':id')
  findOne(@Param('id', UserExistsPipe) user: User) {
    // 파이프가 값을 User 엔티티로 변환했기 때문에
    // user 매개변수는 이미 데이터베이스에서 가져온 사용자 객체입니다.
    return user;
  }
}

이 접근 방식의 장점은 컨트롤러에서 별도의 존재 여부 확인 로직을 작성할 필요가 없다는 것입니다. 파이프가 존재하지 않는 엔티티에 대한 요청을 자동으로 처리합니다.

6. 전역 파이프 등록하기

특정 파이프를 애플리케이션 전체에 적용하려면 전역으로 등록할 수 있습니다. 이미 앞에서 ValidationPipe를 전역으로 등록하는 방법을 살펴봤습니다. 다른 파이프도 유사한 방식으로 등록할 수 있습니다.

// main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
import { ParseIntArrayPipe } from './pipes/parse-int-array.pipe';

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  
  // 여러 전역 파이프 등록
  app.useGlobalPipes(
    new ValidationPipe({
      transform: true,
      whitelist: true,
    }),
    new ParseIntArrayPipe(),
  );
  
  await app.listen(3000);
}
bootstrap();

하지만 의존성 주입을 사용하는 파이프는 useGlobalPipes()를 통해 등록할 때 의존성 주입이 작동하지 않습니다. 이 경우 모듈 컨텍스트에서 파이프를 제공해야 합니다:

// app.module.ts
import { Module } from '@nestjs/common';
import { APP_PIPE } from '@nestjs/core';
import { TypeOrmModule } from '@nestjs/typeorm';
import { UserExistsPipe } from './pipes/user-exists.pipe';
import { User } from './entities/user.entity';

@Module({
  imports: [
    TypeOrmModule.forRoot({
      // 데이터베이스 연결 설정
    }),
    TypeOrmModule.forFeature([User]), // UserExistsPipe가 User 리포지토리에 액세스할 수 있도록 함
  ],
  providers: [
    {
      provide: APP_PIPE,
      useClass: UserExistsPipe,
    },
  ],
})
export class AppModule {}

7. 파이프 조합하기

NestJS에서는 여러 파이프를 함께 사용하여 복잡한 변환 및 유효성 검사 로직을 구축할 수 있습니다. 파이프는 지정된 순서대로 실행됩니다.

import { Controller, Get, Query, Param } from '@nestjs/common';
import { ParseIntPipe, DefaultValuePipe } from '@nestjs/common';
import { ParseIntArrayPipe } from './pipes/parse-int-array.pipe';

@Controller('products')
export class ProductsController {
  @Get()
  findAll(
    // 값이 없으면 기본값 1을 제공하고, 그 다음 정수로 변환
    @Query('page', new DefaultValuePipe(1), ParseIntPipe) page: number,
    @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number,
  ) {
    return `Page: ${page}, Limit: ${limit}`;
  }

  @Get('filter')
  // 기본값으로 비어있는 문자열을 제공하고, 그 다음 정수 배열로 변환
  filter(@Query('tags', new DefaultValuePipe(''), new ParseIntArrayPipe()) tags: number[]) {
    return `Filtering by tags: ${tags.join(', ')}`;
  }
}

파이프를 조합할 때 실행 순서는 왼쪽에서 오른쪽으로 진행됩니다. 위 예시에서 DefaultValuePipe가 먼저 실행되고, 그 다음 ParseIntPipe 또는 ParseIntArrayPipe가 실행됩니다.

8. 비동기 파이프

파이프는 비동기 작업을 수행할 수 있습니다. transform 메서드는 값 또는 Promise<값>을 반환할 수 있습니다. 비동기 파이프는 데이터베이스 조회나 외부 API 호출과 같은 작업에 유용합니다.

이전에 본 UserExistsPipe가 비동기 파이프의 좋은 예입니다. 비동기 파이프 동작은 NestJS가 자동으로 처리하므로 특별한 설정이 필요하지 않습니다.

9. 성능 고려사항

파이프는 모든 요청 데이터에 대해 실행되므로, 성능에 영향을 미칠 수 있습니다. 파이프를 구현할 때 몇 가지 고려해야 할 사항은 다음과 같습니다:

  1. 필요한 부분에만 파이프 적용하기: 애플리케이션 전체가 아닌 필요한 엔드포인트에만 특정 파이프를 적용하는 것이 좋습니다.
  2. 무거운 작업 최소화하기: 데이터베이스 쿼리와 같은 무거운 작업은 가능한 한 최소화하세요.
  3. 캐싱 활용하기: 동일한 입력에 대해 반복적으로 파이프를 실행하는 경우, 결과를 캐싱하는 것이 좋습니다.
  4. 조건부 수행: 모든 데이터가 아닌 특정 조건에서만 변환 또는 유효성 검사를 수행하는 것이 좋습니다.

10. 모범 사례와 패턴

명확한 DTO 설계

DTO는 데이터 구조를 명확하게 정의하고 유효성 검사 규칙을 집중화하는 데 중요합니다. 다음은 DTO 설계에 대한 몇 가지 모범 사례입니다:

// create-product.dto.ts
import { Type } from 'class-transformer';
import { 
  IsString, 
  IsNumber, 
  IsPositive, 
  IsArray, 
  ValidateNested, 
  ArrayMinSize,
  IsOptional,
  Min,
  Max
} from 'class-validator';

// 중첩된 DTO 클래스
class ProductVariantDto {
  @IsString()
  color: string;

  @IsString()
  size: string;

  @IsNumber()
  @IsPositive()
  additionalPrice: number;
}

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

  @IsNumber()
  @IsPositive()
  price: number;

  @IsOptional()
  @IsNumber()
  @Min(0)
  @Max(100)
  discountPercentage?: number;

  @IsArray()
  @ValidateNested({ each: true })
  @ArrayMinSize(1)
  @Type(() => ProductVariantDto) // 중요: 중첩된 유효성 검사를 위해 필요합니다.
  variants: ProductVariantDto[];
}

DTO 버전 관리

API 버전이 변경됨에 따라 DTO도 진화할 수 있습니다. 다음은 DTO 버전을 관리하는 방법입니다:

// v1/create-user.dto.ts
export class CreateUserDtoV1 {
  @IsString()
  @IsNotEmpty()
  name: string;

  @IsEmail()
  email: string;

  @IsString()
  @MinLength(8)
  password: string;
}

// v2/create-user.dto.ts
export class CreateUserDtoV2 extends CreateUserDtoV1 {
  @IsOptional()
  @IsPhoneNumber()
  phoneNumber?: string;

  @IsOptional()
  @IsDateString()
  birthDate?: string;
}

파이프 조합 패턴

일반적으로 사용되는 파이프 조합을 재사용 가능한 함수로 추출할 수 있습니다:

// pipe-combinators.ts
import { ParseIntPipe, DefaultValuePipe, ParseUUIDPipe } from '@nestjs/common';

export const OptionalParseIntPipe = (defaultValue: number) => [
  new DefaultValuePipe(defaultValue),
  new ParseIntPipe(),
];

export const OptionalParseUUIDPipe = () => [
  new DefaultValuePipe(''),
  new ParseUUIDPipe({ version: '4', optional: true }),
];

사용 예시:

import { Controller, Get, Query } from '@nestjs/common';
import { OptionalParseIntPipe } from './pipe-combinators';

@Controller('products')
export class ProductsController {
  @Get()
  findAll(
    @Query('page', ...OptionalParseIntPipe(1)) page: number,
    @Query('limit', ...OptionalParseIntPipe(10)) limit: number,
  ) {
    return `Page: ${page}, Limit: ${limit}`;
  }
}

도메인별 파이프 구조 만들기

비즈니스 도메인에 특화된 파이프를 구성하여 코드의 가독성과 재사용성을 높일 수 있습니다:

// user/pipes/user-exists.pipe.ts
// product/pipes/product-exists.pipe.ts
// order/pipes/order-exists.pipe.ts
// 등등...

각 도메인 모듈은 자체 파이프 디렉토리를 가질 수 있으며, 해당 도메인의 특정 유효성 검사 및 변환 로직을 캡슐화합니다.

결론

NestJS의 파이프는 입력 데이터의 유효성 검사와 변환을 처리하는 강력한 메커니즘입니다. 내장 파이프와 함께 제공되는 기능만으로도 많은 일반적인 사용 사례를 처리할 수 있으며, 커스텀 파이프를 통해 애플리케이션에 특화된 요구사항을 충족시킬 수 있습니다.

파이프를 효과적으로 활용하면 다음과 같은 이점을 얻을 수 있습니다:

  1. 데이터 무결성 보장: 유효하지 않은 데이터가 애플리케이션에 진입하는 것을 방지하여 데이터 무결성을 유지합니다.
  2. 코드 중복 감소: 유효성 검사 및 변환 로직을 중앙화하여 여러 컨트롤러에서 반복하지 않아도 됩니다.
  3. 관심사 분리: 비즈니스 로직에서 입력 처리 로직을 분리하여 코드의 가독성과 유지보수성을 향상시킵니다.
  4. 자동화된 오류 처리: 유효성 검사에 실패할 경우 적절한 HTTP 응답을 자동으로 생성합니다.

NestJS의 파이프 시스템은 타입스크립트와 데코레이터를 활용하여 선언적이고 강력한 방식으로 데이터 유효성 검사와 변환을 처리할 수 있게 해줍니다. 이는 견고하고 안정적인 백엔드 애플리케이션을 개발하는 데 큰 도움이 됩니다.

다음 글에서는 NestJS의 가드(Guards)에 대해 알아보고, 인증과 권한 부여 시스템을 구현하는 방법에 대해 살펴보겠습니다.

반응형