NestJS - Pipe, Validation
이 포스트는 NestJS 의 Pipe 를 통한 유효성 검사에 대해 알아본다.
소스는 example, user-service 에 있습니다.
1. Pipe
Pipe 는 요청이 라우터 핸들러로 전달되기 전에 요청 객체를 변환하거나 검사할 수 있도록 한다.
미들웨어와 비슷하지만 미들웨어를 현재 요청이 어떤 핸들러에서 수행되고 어떤 매개변수를 갖는지에 대한 실행 context 를 알지 못하므로 모든 context 에서 사용이 불가하다.
라우트 핸들러
요청을 처리하는 엔드포인트마다 동작을 수행하는 컴포넌트
라우트 핸들러가 요청 경로와 컨트롤러 매핑
Pipe 는 보통 아래 두 가지 목적으로 사용된다.
- transformation
- 입력 데이터를 원하는 형식으로 변환
- ex) user/1 경로 매개변수 문자열 1을 정수로 변환
- validation
- 입력 데이터가 유효하지 않은 경우 예외 처리
@nest/common
패키지에 아래와 같은 Pipe 가 있다.
ParseIntPipe
- 전달된 인수 타입 검사
ParseBoolPipe
- 전달된 인수 타입 검사
ParseArrayPipe
- 전달된 인수 타입 검사
ParseUUIDPipe
- 전달된 인수 타입 검사
DefaultValuePipe
- 인수에 기본값 설정
ValidationPipe
- 2. Pipe 내부 구현 에서 확인
예를 들어 /user/:id 에서 경로 매개변수 id 는 문자열 타입이고, 내부에선 정수로 사용할 때 이를 매번 정수형으로 변경해서 사용하는 것은 코드 중복이다. 이럴 때 @Param
데커레이터의 두 번째 인수로 Pipe 를 넘겨 현재 ExecutionContext 에 바인딩할 수 있다.
@Get(':id')
findOne(@Param('id', ParseIntPipe) id: number) {
return id + 2;
}
$ nest new ch07
...
$ curl --location 'http://localhost:3000/2aa' | jq
{
"statusCode": 400,
"message": "Validation failed (numeric string is expected)",
"error": "Bad Request"
}
유효성 검사 에러가 발생하며 요청이 컨트롤러까지 전달되지 않는다.
클래스를 전달하는 것이 아니라 Pipe 객체를 직접 생성해서 전달할 수도 있는데 생성할 Pipe 객체의 동작을 원하는대로 변경하고자 할 때 사용한다.
아래는 에러 상태 코드를 변경하는 예시이다.
@Get(':id')
findOne(
@Param('id', new ParseIntPipe({ errorHttpStatusCode: HttpStatus.NOT_ACCEPTABLE })) id: number) {
return id + 2;
}
$ curl --location 'http://localhost:3000/2aa' | jq
{
"statusCode": 406,
"message": "Validation failed (numeric string is expected)",
"error": "Not Acceptable"
}
DefaultValuePipe
는 아래와 같이 사용한다.
@Get('user/all')
findAll(
@Query('offset', new DefaultValuePipe(0), ParseIntPipe) offset: number,
@Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number,
) {
console.log(offset, limit);
return offset;
}
$ curl --location 'http://localhost:3000/user/all'
0%
$ curl --location 'http://localhost:3000/user/all?offset=2'
2%
2. Pipe 내부 구현
2. Pipe 내부 구현 와 3. 유효성 검사 Pipe 만들기 에서 ValidationPipe 를 직접 만들어보긴 하지만 원리만 파악하고 실제로는 Nest 가 제공하는 ValidationPipe 를 쓰는 것이 좋다.
4. 유저 서비스 유효성 검사 적용 에서 Nest 가 제공하는 ValidationPipe 를 사용하는 예시가 있습니다.
@nest/common
패키지에 있는 Pipe 중 ValidationPipe 가 있는데 Nest 가 이미 제공하는 것을 활용하여 직접 ValidationPipe 를 만들어본다. (=Custom Pipe)
Custom Pipe 는 PipeTransform
인터페이스를 상속받은 클래스에 @Injectable()
데커레이션을 붙여주면 된다.
validation.pipe.ts
import { ArgumentMetadata, Injectable, PipeTransform } from '@nestjs/common';
@Injectable()
export class ValidationPipe implements PipeTransform {
transform(value: any, metadata: ArgumentMetadata): any {
return undefined;
}
}
PipeTransform
의 시그니처는 아래와 같다.
export interface PipeTransform<T = any, R = any> {
/**
* Method to implement a custom pipe. Called with two parameters
*
* @param value argument before it is received by route handler method
* @param metadata contains metadata about the value
*/
transform(value: T, metadata: ArgumentMetadata): R;
}
- value
- 현재 pipe 에 전달된 인수
- metadata
- 현재 pipe 에 전달된 인수의 메타데이터
ArgumentMetadata
의 시그니처는 아래와 같다.
export interface ArgumentMetadata {
/**
* Indicates whether argument is a body, query, param, or custom parameter
*/
readonly type: Paramtype;
/**
* Underlying base type (e.g., `String`) of the parameter, based on the type
* definition in the route handler.
*/
readonly metatype?: Type<any> | undefined;
/**
* String passed as an argument to the decorator.
* Example: `@Body('userId')` would yield `userId`
*/
readonly data?: string | undefined;
}
- type
- pipe 에 전달된 인수가 본문인지 쿼리인지 경로 매개변수인지 커스텀 매개변수인지 여부
- metatype
- 라우트 핸들러에 정의된 인수의 타입
- 생략하거나 바닐라 자바스크립트 사용 시 undefined 가 됨
- data
- 데커레이터에 전달된 문자열 (=매개변수명)
예를 들어 아래와 같은 라우트 핸들러가 있을 때 value 와 metadata 는 아래와 같다.
@Get(':id')
findOne(@Param('id', ValidationPipe) id: number) {
return id;
}
$ curl --location 'http://localhost:3000/1'
value: 1
metadata: { metatype: [Function: Number], type: 'param', data: 'id' }
3. 유효성 검사 Pipe 만들기
NestJS 공식 문서에는 @UsePipes
데커레이터와 joi
라이브러리를 이용해서 Custom Pipe 를 바인딩하는 방법을 설명하고 있다.
joi
는 Schema 라고 부르는 유효성 검사 규칙을 가진 객체를 만든 후 이 Schema 에 검사하려는 객체를 전달하여 validate 하는 방식인데 joi
는 class-validator
(바로 뒤에 나옴) 와 비교했을 때 Schema 를 적용하는 문법이 불편하다.
하여 여기선 class-validator
를 이용하여 유효성 검사를 진행해 볼 예정이다.
$ npm i class-validator class-transformer
$ nest g resource Users
? What transport layer do you use? REST API
? Would you like to generate CRUD entry points? Yes
CREATE src/users/users.controller.spec.ts (566 bytes)
CREATE src/users/users.controller.ts (894 bytes)
CREATE src/users/users.module.ts (247 bytes)
CREATE src/users/users.service.spec.ts (453 bytes)
CREATE src/users/users.service.ts (609 bytes)
CREATE src/users/dto/create-user.dto.ts (30 bytes)
CREATE src/users/dto/update-user.dto.ts (169 bytes)
CREATE src/users/entities/user.entity.ts (21 bytes)
UPDATE package.json (2037 bytes)
UPDATE src/app.module.ts (312 bytes)
그럼 이제 신규 유저 생성 시 body 유효성 검사를 해본다.
/users/dto/create-user.dto.ts
import { IsEmail, IsString, MaxLength, MinLength } from 'class-validator';
export class CreateUserDto {
@IsString()
@MinLength(1)
@MaxLength(20)
readonly name: string;
@IsEmail()
email: string;
}
class-validator
가 지원하는 데커레이터들은 class-validator 공식 문서 에서 확인하세요.
위에서 정의한 것과 같은 dto 객체를 받아 유효성 검사를 하는 Pipe(ValidationPipe) 를 직접 구현해본다.
validation.pipe.ts
import {
ArgumentMetadata,
BadRequestException,
Injectable,
PipeTransform,
} from '@nestjs/common';
import { plainToClass } from 'class-transformer';
import { validate } from 'class-validator';
@Injectable()
export class ValidationPipe implements PipeTransform {
// transform(value: any, metadata: ArgumentMetadata): any {
// console.log('value: ', value);
// console.log('metadata: ', metadata);
// return undefined;
// }
async transform(value: any, { metatype }: ArgumentMetadata) {
// metatype 이 Pipe 가 지원하는 타입인지 검사
if (!metatype || !this.toValidate(metatype)) {
return value;
}
// 순수 자바스크립트 객체를 클래스 객체로 변경
// (네트워크를 통해 들어온 데이터는 역직렬화 과정에서 body 의 객체가 아무런 타입 정보도 없기 때문에 타입을 지정하는 변환 과정)
const object = plainToClass(metatype, value);
const errors = await validate(object);
if (errors.length > 0) {
throw new BadRequestException('Validation failed~');
}
return value; // 유효성 검사 통과했다면 원래의 값 그대로 전달
}
// eslint-disable-next-line @typescript-eslint/ban-types
private toValidate(metatype: Function): boolean {
// eslint-disable-next-line @typescript-eslint/ban-types
const types: Function[] = [String, Boolean, Number, Array, Object];
return !types.includes(metatype);
}
}
이제 위에서 만든 ValidationPipe 를 적용해본다.
users.controller.ts
import { ValidationPipe } from '../validation.pipe';
...
@Controller('users')
export class UsersController {
@Post()
create(@Body(ValidationPipe) createUserDto: CreateUserDto) {
return this.usersService.create(createUserDto);
}
}
$ curl --location 'http://localhost:3000/users' \
--header 'Content-Type: application/json' \
--data '{
"name": "ss",
"email": "testtest.com"
}' | jq
{
"statusCode": 400,
"message": "Validation failed~",
"error": "Bad Request"
}
ValidationPipe 를 핸들러마다 지정하는 것이 아니라 전역으로 설정하려면 부트스트랩 과정에서 적용하면 된다.
main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from './validation.pipe';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe());
await app.listen(3000);
}
bootstrap();
4. 유저 서비스 유효성 검사 적용
Nest 에서 제공하는 class-validator
를 적용하고, class-validator
에서 제공하지 않는 유효성 검사 기능을 직접 만들어보도록 한다.
4.1. 유저 생성 body 유효성 검사
$ npm i class-validator class-transformer
Nest 에서 제공하는 ValidationPipe
를 전역으로 적용하고, class-transformer
도 적용한다. (=transform 속성 true)
main.ts
import { NestFactory } from '@nestjs/core';
import { AppModule } from './app.module';
import { ValidationPipe } from '@nestjs/common';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(
new ValidationPipe({
transform: true,
}),
);
await app.listen(3000);
}
bootstrap();
기존 CreateUserDto 는 아래와 같은데 여기에 class-validator
를 이용하여 몇 가지 규칙을 적용해본다.
users/dto/create-user.dto.ts
export class CreateUserDto {
readonly name: string;
readonly email: string;
readonly password: string;
}
- 이름은 2자 이상, 30자 이하 문자열
- 이메일은 60자 이하 문자열, 이메일 주소 형식에 부합
- 패스워드는 영문 대소문자와 숫자 또는 특수문자 (!, @, #, $, %, ^, &, *, (, )) 로 이루어진 8자 이상, 30자 이하 문자열
users/dto/create-user.dto.ts
import {
IsEmail,
IsString,
Matches,
MaxLength,
MinLength,
} from 'class-validator';
export class CreateUserDto {
@IsString()
@MinLength(2)
@MaxLength(30)
readonly name: string;
@IsString()
@IsEmail()
@MaxLength(60)
readonly email: string;
@IsString()
@Matches(/^[A-Za-z\d!@#$%^&*()]{8,30}$/)
readonly password: string;
}
4.2. class-transformer
활용: @Transform
class-transformer
에서 @Transform
데커레이터가 가장 많이 쓰이는데 시그니처는 아래와 같다.
@Transform
데커레이터의 시그니처
export declare function Transform(transformFn: (params: TransformFnParams) => any, options?: TransformOptions): PropertyDecorator;
export interface TransformFnParams {
value: any;
key: string;
obj: any;
type: TransformationType;
options: ClassTransformOptions;
}
@Transform
데커레이터는 TransformFnParams
타입인 transformFn 을 인수로 받는데 transformFn 은 이 데커레이터가 적용되는 속성의 값(value) 와 그 속성이 속해있는 객체(obj) 등을 인수로 받아 속성을 변형한 후 리턴하는 함수이다.
name 에 @Transform
데커레이터를 적용해서 TransformFnParams
으로 어떤 값들이 전달되는지 확인해본다.
@IsString()
@MinLength(2)
@MaxLength(30)
@Transform((params) => {
console.log('param: ', params);
return params.value; // 속성 변형을 하지 않고 그대로 리턴
})
readonly name: string;
$ curl --location 'http://localhost:3000/users' \
--header 'Content-Type: application/json' \
--data-raw '{
"name": "ss",
"email": "test@test.com",
"password": "testtest"
}' | jq
param: {
value: 'ss',
key: 'name',
obj: { name: 'ss', email: 'test@test.com', password: 'testtest' },
type: 0,
options: {
enableCircularCheck: false,
enableImplicitConversion: false,
excludeExtraneousValues: false,
excludePrefixes: undefined,
exposeDefaultValues: false,
exposeUnsetFields: true,
groups: undefined,
ignoreDecorators: false,
strategy: undefined,
targetMaps: undefined,
version: undefined
}
}
만일 name 앞뒤에 공백을 제거한다면 아래와 같이 하면 된다.
@IsString()
@MinLength(2)
@MaxLength(30)
@Transform((params) => params.value.trim())
readonly name: string;
obj 를 이용하여 name 이 포함된 password 는 설정 불가하게 하려면 아래와 같이 하면 된다.
@IsString()
@Matches(/^[A-Za-z\d!@#$%^&*()]{8,30}$/)
@Transform(({ value, obj }) => {
if (obj.password.includes(obj.name.trim())) {
throw new BadRequestException('password 에 name 과 같은 문자열 포함');
}
return value.trim();
})
readonly password: string;
$ curl --location 'http://localhost:3000/users' \
--header 'Content-Type: application/json' \
--data-raw '{
"name": "test",
"email": "test@test.com",
"password": "testtest"
}' | jq
{
"statusCode": 400,
"message": "password 에 name 과 같은 문자열 포함",
"error": "Bad Request"
}
아래는 유효성에 부합하지 않을 경우 메시지 확인이다.
$ curl --location 'http://localhost:3000/users' \
--header 'Content-Type: application/json' \
--data '{
"name": "testds",
"email": "testtest.com",
"password": "testtest"
}' | jq
{
"statusCode": 400,
"message": [
"email must be an email"
],
"error": "Bad Request"
}
$ curl --location 'http://localhost:3000/users' \
--header 'Content-Type: application/json' \
--data '{
"name": "testds",
"email": "testtest.com",
"password": "t"
}' | jq
{
"statusCode": 400,
"message": [
"email must be an email",
"password must match /^[A-Za-z\\d!@#$%^&*()]{8,30}$/ regular expression"
],
"error": "Bad Request"
}
$ curl --location 'http://localhost:3000/users' \
--header 'Content-Type: application/json' \
--data '{
"name": "test",
"email": "testtest.com",
"password": "testtest"
}' | jq
{
"statusCode": 400,
"message": "password 에 name 과 같은 문자열 포함",
"error": "Bad Request"
}
4.3. Custom 유효성 검사기 직접 생성 (데커레이터 생성)
위에서 @Transform
데커레이터 내에서 예외를 던졌는데 그렇게 하면 코드가 복잡해지니 이럴 땐 직접 유효성 검사를 수행하는 데커레이터를 만들어서 사용하면 된다.
@IsString()
@Matches(/^[A-Za-z\d!@#$%^&*()]{8,30}$/)
@Transform(({ value, obj }) => {
if (obj.password.includes(obj.name.trim())) {
throw new BadRequestException('password 에 name 과 같은 문자열 포함');
}
return value.trim();
})
readonly password: string;
/src/utils/decorators/not-in.ts
import {
registerDecorator,
ValidationArguments,
ValidationOptions,
} from 'class-validator';
export function NotIn(property: string, validationOptions?: ValidationOptions) { // 데커레이터의 인수는 객체에서 참조하려고 하는 다른 속성의 이름과 ValidationOptions 을 받음
// eslint-disable-next-line @typescript-eslint/ban-types
return (object: Object, propertyName: string) => { // registerDecorator 를 호출하는 함수 리턴, 이 함수의 인수로 데커레이터가 선언될 객체와 속성 이름 받음
registerDecorator({ // registerDecorator 는 ValidationDecoratorOptions 객체를 인수로 받음
name: 'NotIn', // 데커레이터 이름
target: object.constructor, // 이 데커레이터는 객체가 생성될 때 적용됨.
propertyName,
options: validationOptions, // 유효성 옵션은 데커레이터의 인수로 전달받은 것을 사용
constraints: [property], // 이 데커레이터는 속성에 적용되도록 제약을 줌
validator: { // validator 속성 안에 유효성 검사 규칙 기술, 이는 ValidatorConstraint Interface 를 구현한 함수
validate(
value: any,
validationArguments?: ValidationArguments,
): Promise<boolean> | boolean {
const [relatedPropertyName] = validationArguments.constraints;
const relatedValue = (validationArguments.object as any)[
relatedPropertyName
];
return (
typeof value === 'string' &&
typeof relatedValue === 'string' &&
!relatedValue.includes(value)
);
},
},
});
};
}
이제 Custom 데커레이터를 속성에 적용해본다.
@IsString()
@MinLength(2)
@MaxLength(30)
@Transform((params) => params.value.trim())
@NotIn('password', { message: 'password 는 name 과 같은 문자 포함 불가' })
readonly name: string;
$ curl --location 'http://localhost:3000/users' \
--header 'Content-Type: application/json' \
--data-raw '{
"name": " test ",
"email": "test@test.com",
"password": "testtest"
}' | jq
{
"statusCode": 400,
"message": [
"password 는 name 과 같은 문자 포함 불가"
],
"error": "Bad Request"
}
curl --location 'http://localhost:3000/users' \
--header 'Content-Type: application/json' \
--data-raw '{
"name": "test",
"email": "test@test.com",
"password": "test"
}' | jq
{
"statusCode": 400,
"message": [
"password 는 name 과 같은 문자 포함 불가",
"password must match /^[A-Za-z\\d!@#$%^&*()]{8,30}$/ regular expression"
],
"error": "Bad Request"
}
참고 사이트 & 함께 보면 좋은 사이트
본 포스트는 한용재 저자의 NestJS로 배우는 백엔드 프로그래밍을 기반으로 스터디하며 정리한 내용들입니다.
- NestJS로 배우는 백엔드 프로그래밍
- NestJS로 배우는 백엔드 프로그래밍 - Github
- NestJS 공식문서
- NestJS docs
- Nest.js Github
- NestJS 공식 예제 Starter 프로젝트 Github
- class-validator 공식 문서