파이프는 요청이 라이터 핸들러로 전달되기 전에 요청 객체를 반환할 수 있는 기회를 제공합니다.
미들웨어와 비슷한 역할이라고 생각하면 됩니다.
하지만, 미들웨어는 애플리케이션의 모든 콘텍스트에서 사용할 수 없습니다.

미들웨어는 현재 요청이 어떤 핸들러에서 수행 되는지 어떤 매개 변수를 가지고 있는지에 대한 실행 콘텍스트를 알지 못하기 때문입니다.

파이프의 사용 목적

  1. 변환
  • 입력 데이터를 원하는 형식으로 변환
  • 예를 들면, /users/user/1 내의 경로 매개변수 문자열 1을 정수로 변환
  1. 유효성 검사
  • 입력 데이터가 사용자가 정한 기준에 유호하지 않는 경우 예외 처리

@nest/common 패키지에는 여러 내장 파이프가 마련되어 있습니다.

ParseIntPipe, ParseBoolPipe, ParseArrayPipe, ParseUUIDPipe는 전달된 인수의 타입을 검사하는 용도입니다.
/users/user/:id 엔드포인트에 전달된 경로 매개변수 id는 타입이 string 입니다.
이를 내부에서는 int로 사용하고 있다고 한다면,
컨트롤러에서는 id 매번 정수형으로 변환해서 쓰는 것은 불필요한 중복 코드를 양산하게 됩니다.
아래 코드와 같이 @Param 데커레이터의 두번째 인수로 파이프를 넘겨 현재 실행 콘텍스트에 바인딩 할 수 있습니다.

@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number ){
	return this.usersService.findOne(id)
}

어제 id에 정수로 파싱 가능하지 않는 문자를 전달한다면, 유효성 검사 에러가 발생하면서 에러 응답을 돌려줍니다.
요청이 컨트롤러에 전달되지 않는 것을 알 수 있습니다.

Class를 전달하지 않고, 파이프 객체를 직접 생성하여 전달 할 수 도 있습니다.
이 경우는 생성과 파이프 객체의 동작을 원하는 대로 바꾸고자 할 때 사용합니다.
예를 들어 앞의 에러에서 상태 코드를 406 Not Acceptable로 변경해서 내보내고 싶다면,

@Get(':id')
findOne(@Param('id', new ParseIntPipe({  errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE })) id: number ){
	return this.usersService.findOne(id)
}

DefaultValuePipe는 인수의 값에 기본 값을 설정할 때 사용합니다.
쿼리 매개변수가 생략된 경우 유용하게 사용할 수 있습니다.
유저 목록을 조회할 때 오프셋 기반 페이징을 사용하고 있다고 합시다.
쿼리 매개변수로 offsetlimit을 받습니다.

@Get()
findAll(
	@Query('offset', new DefaultValuePipe(0), ParseIntPipe) offset: number,
	@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number,
){
	console.log(offset, limit);

	return this.usersService.findAll();
}

두 매개변수를 생략하고 호출하고 잘 동작 하는지 확인하면

curl http://localhost:3000/users

콘솔창에 0과 10이 출력되는가?
매개 변수를 생략하지 않고, null 또는 undefined를 전달하면 예외가 발생한 것입니다.

파이프 내부 구현 이해하기

이제 ValidationPipe를 어떻게 활용하는지 알아봅니다.
이미 Nest는 ValidationPipe를 제공하지만 직접 만들 수도 있습니다.
이 과정을 통해서 추후에 커스텀 파이프가 필요할 때 어떻게 만들면 될지를 배울 수 있습니다.

validation.pipe.ts

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

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

PipeTransform의 원형은 다음처럼 정의 되어 있습니다.

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

구현해야 하는 transform 함수는 2개의 매개 변수를 가지고 있습니다.

export interface ArgumentMetadata{
	readonly type: Paramtype;
	readonly metadata?: Type<any> | undefined;
	readonly data?: string | undefined;
}

export declare type Paramtype = 'body' | 'query' | 'param' | 'custom';

type

@Get(':id')
findOne(@Param('id', ValidationPipe) id: number){
	return this.usersService.findOne(id)
}

GET /users/1 요청에 대해 transform 함수에 전달되는 인수를 출력해보면 value는 1이 되고, metadata는 다음과 같은 객체가 됩니다.

유효성 검사 파이프 라인

@UserPipes 데커레이터와 joi 라이브러리를 이요하여 커스텀 파이프를 바인딩 하는 방법을 설명하고 있습니다.
joi는 널리 사용되는 유효성 검사 라이브러리 입니다.
스키마라고 부르는 유효성 검사 규칙을 가진 객체를 만들고 이 스키마에 검사하고자하는 객첼르 전달하여 validate하는 방식입니다.

하지만 joi이후에 설명하는 class-validation와 비교하면 스키마를 적용하는 문법이 번거롭습니다.
아쉽게도 class-validator를 사용하는 방식은 바닐라 JS는 적용할 수 없기 때문에, 타입 스크립트로 애플리케이션을 작성하지 않거나, joi의 사용법에 익숙하신 분은 공식 문서를 참조하여 적용하는 것을 검토하면 됩니다.

npm i --save class-validator class-transformer

신규 유저를 생성할 때 본문이 유효성에 적합한지 검사하도록 합시다.

dto/create-user.dto

import { IsString, MinLength, MaxLength, IsEmail } from 'class-validator'

export class CreateUserDto{
	@IsString()
	@MinLength(1)
	@MaxLength(20)
	name: string;

	@IsEmail()
	email: string;
}

class-validator 를 사용하면 다양한 데커레이터를 선언하여 쓰기도 쉽고, 이해하기도 쉬운 코드를 작성할 수있습니다.
이 코드에서 CreateUserDto의 name 속성은 1글자 이상 20글자 이하인 문자열을 받도록 되어 있습니다.
email 속성은 이메일 형식을 따지는지 체크 합니다.

이제 위에서 정의한 것과 같은 dto 객체를 받아서 유효성 검사를 하는 파이프(validationPipe)를 직접 구현해 봅시다.

import { PipeTransform, Injectable, ArgumentMetadata, BadReqeustException } from '@nestjs/common';
import { validate } from 'class-validator';
import { plainToClass } from 'class-transformer'

@Injectable()
export class ValidationPipe implements PipeTransform<any>{
	async transform(value:any, { metatype } : ArgumentMetadata ){
		if(!metatype || !this.toValidate(metatype)){
			return value
		}
		const object = plainToClass(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);
	}
}

먼저 전달된 metatype이 파이프가 지원하는 타입인지 검사합니다.
그리고 class-transformerplainToClass함수를 통해 순수(plain 또는 literal) JS 객체를 클래스의 객체로 바꿔줍니다.
class-validator의 유효성 검사 데커레이터는 타입이 필요합니다.
네트워크 요청을 통해 들어오느 데이터는 역질렬화 과정에서 본문의 객체가 아무런 타입 정보도 가지고 있지 않기 때문에, 타입을 지정하는 변환 과정을 plainToClass로 수행하는 것입니다.

마지막으로 유효성 검사에 통과햇다면 원래의 값을 그대로 전달합니다ㅣ.
검사에 실패했다면, 400 Bad Request 에러를 던집니다.

이제 이 ValidationPipe를 적용해봅시다.

@Post()
create(@Body(ValidationPipe) createUserDto: CreateUserDto){
	return this.usersService.create(createUserDto);
}

잘못된 데이터를 전달하면 에러가 발생하는 것을 확인 할 수 있습니다.

ValidationPipe를 모든 핸들러에 일일이 지정하지 않고, 전역으로 설정하려면 부트스트랩 과정에 적용하며 됩니다.
이미 앞에서 다른 컴포넌트를 전역으로 지정할 때 봣던 것과 유사합니다.

import { ValidationPipe } from './validation.pipe'

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

ValidationPipe를 직접 만들어 사용해봤습니다.
하지만 이미 Nest가 마련해둔 ValidationPipe가 있기 때문에 굳이 따로 만들 필요는 없습니다.
동작 원리만 파악하고 Nest가 제공하는 ValidationPipe를 가져다 쓰면 됩니다.

유저 서비스에 유효성 검사 적용하기

서비스 운영 주에는 공개괸 호스트 도메인으로 해킹 등 끊임 없이 불필요한 요청이 들어옵니다.
불필요한 요청을 막기 위해 특정 IP에서 들어오는 요청을 일정 기간 무시하는 방법도 있습니다.

라우터 핸들러까지 요청이 들어왔을 때, 잘못된 요청을 걸러내는 것 또한 중요합니다.
클라이어트가 API를 주어진 스펙대로 호출하지 않거나 정확하게 구현했다 하더라도 변경된 API 스펙에 맞춰 수정되지 않는 경우도 있습니다.

앞서 사용자가 잘못된 요청을 보냈을 때 유효성 검사를 수행하는 파이프를 따로 둬 비즈니스 로직과 분리 되도록 하는 방법을 알아봤습니다.
이 장에서는 우리가 작성하고 있는 유저 서비스에 ValidationPipe를 적용해보겟습니다ㅣ.
이어서 class-validator에서 제공하지 않는 유효성 검사기를 직접 만들어보겠습니다.

유저 생성 본문의 유효성 검사

npm i --save calss-validator class-transformer

그리고 Nest에서 제공하는 ValidationPipe를 전역으로 적용합니다.
이때 뒤에서 사용할 class-transformer가 적용되게 하려면 transform 속성을 true로 주어야한다는 점을 주의해야합니다.

import { ValidationPipe } from './validation.pipe'

async function bootstrap(){
	const app = await NestFactory.create(AppModule);
	app.userGlobalPipes(new ValidationPipe({
		transform: true
	}))
	await app.listen(3000)
}
bootstrap();

이제 유저 생성 요청에 포함된 본문의 정의를 다시 보겠습니다.

export class CreateUserDto{
	readonly name: string
	readonly email: string
	readonly password: string;
}

이름, 이메일, 패스워드가 아무런 제약 없이 선언되어있습니다.
서비스는 다음과 같은 규칙을 가져야합니다.

import { IsString, MinLength, MaxLength, IsEmail } from 'class-validator'

export class CreateUserDto{
	@IsString()
	@MinLength(2)
	@MaxLength(30)
	readonly name:string

	@IsString()
	@MinLength(2)
	@MaxLength(30)
	readonly email: string;

	@IsString()
	@Matches(/^[A-Za-z\d!@#$%^&*()],{8,30}$/)
	readonly password: string;
}

class-transformer 활용

class-transformer에서 제공하는 기능 중에 많이 사용되는 @Trnsform 데커레이터의 정의를 살펴 보겠습니다.

export declare function Transform(transformFn: (params: TransformFnParams) => any, options?: TransformOptions): PropertryDecorator;

export interface TransformFnParams{
	value: any;
	key: string;
	obj: any;
	type: TransformationType;
	options: CalssTransformOptions;
}

Transform 데커레이터는 transformFn를 인수로 받습니다.
transfromFn은 이 데커레이터가 적용되는 속성값과 그 속성을 가지고 잇는 객체등을 인수로 받아 속성을 변형한 후 리턴하는 함수 입니다.

name 속성에 @Transform 데커레이터를 다음과 같이 적용해서 TransformFnParams에 어떤 값ㄷ르이 전달 되었는지 확인 해봅시다.

@Transform(params=>{
	console.log(params)
	return params.value
})

@IsString()
@MinLength(2)
@MaxLength(30)
readonly name: string;

유저 생성 본문 중에 name의 앞뒤에 공백이 포함되면 안된다고 합시다.
하지만 사용자는 충분히 공백을 넣고 요청을 할 수 있습니다.
이 공백을 제거하는 로직은 클라이언트에서 수행해도, 되지만 백엔드에 요청이 왔을 때 대해서도 방어 코드를 추가할 수 있습니다.
class-transformer를 이용하여 공백을 제거해봅스디ㅏ.

@Transform(params=>params.value.trin())
@IsString()
@MinLength()
@MaxLength()
readonly name:string

이제 name 앞뒤에 포함된 공백은 trim() 함수로 잘라내게 됩니다.
TransformFnParams 에는 obj 속성이 있습니다.
obj는 현재 속성이 속해있는 객체를 가르킨다고 했습니다.


#Back #NodeJS #NestJS