파이프는 요청이 라이터 핸들러로 전달되기 전에 요청 객체를 반환할 수 있는 기회를 제공합니다.
미들웨어와 비슷한 역할이라고 생각하면 됩니다.
하지만, 미들웨어는 애플리케이션의 모든 콘텍스트에서 사용할 수 없습니다.
미들웨어는 현재 요청이 어떤 핸들러에서 수행 되는지 어떤 매개 변수를 가지고 있는지에 대한 실행 콘텍스트를 알지 못하기 때문입니다.
- 변환
- 입력 데이터를 원하는 형식으로 변환
- 예를 들면,
/users/user/1
내의 경로 매개변수 문자열 1을 정수로 변환
- 유효성 검사
- 입력 데이터가 사용자가 정한 기준에 유호하지 않는 경우 예외 처리
@nest/common
패키지에는 여러 내장 파이프가 마련되어 있습니다.
ValidationPipe
ParseIntPipe
ParseBoolPipe
ParseArrayPipe
ParseUUIDPipe
DefaultValuePipe
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
는 인수의 값에 기본 값을 설정할 때 사용합니다.
쿼리 매개변수가 생략된 경우 유용하게 사용할 수 있습니다.
유저 목록을 조회할 때 오프셋 기반 페이징을 사용하고 있다고 합시다.
쿼리 매개변수로 offset
과 limit
을 받습니다.
@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개의 매개 변수를 가지고 있습니다.
value
: 현재 파이프에 전달된 인수metadata
: 현재 파이프에 전달된 인수의 메타데이터
ArugmentMetadata
의 정의는 다음과 같습니다.
export interface ArgumentMetadata{
readonly type: Paramtype;
readonly metadata?: Type<any> | undefined;
readonly data?: string | undefined;
}
export declare type Paramtype = 'body' | 'query' | 'param' | 'custom';
type
- 파이프에 전달된 인수가 본문인지, 쿼리 매개변수인지, 매개변수인지 아니면 커스텀 매개변수인지를 나타냅니다.
metatype
- 라우트 핸들러에 정의된 인수의 타입을 알려 줍니다.
- 핸들러에서 타입을 생략하거나 바닐라 JS를 사용하면
undefined
가 됩니다.
data
- 데커레이터에 전달된 문자열. 즉, 매개변수의 이름입니다.
예를 들어 유저 정보를 가져오는 라우터 핸들러를 다음과 같이 구현 했다고 하면
@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-transformer
의 plainToClass
함수를 통해 순수(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;
}
이름, 이메일, 패스워드가 아무런 제약 없이 선언되어있습니다.
서비스는 다음과 같은 규칙을 가져야합니다.
- 사용자 이름은 2자 이상 30자 이하인 문자열이어야 한다.
- 사용자 이메일은 60자 이하의 문자열로서 이메일 주소 형식에 적합해야한다.
- 사용자 패스워드는 영문 대소문자와 숫자 또는 특수문자로 이뤄진 8자 이상 30자 이하의 문자열이어야 한다.
class-validator
를 이용하여 위 규칙을 적용해봤습니다.
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는 현재 속성이 속해있는 객체를 가르킨다고 했습니다.