[NestJS] Validation(검증) 심화버전
2024.01.25- -
이번 포스팅에서는 NestJS에서 데이터베이스를 사용하는 경우 custom validation을 어떤 방식으로 할 수 있는지 단위 테스트(Unit testing)과 함께 알아보도록 하겠습니다. 추가적으로 Error Handling은 또 어떤 식으로 할 수 있는지 알아보도록 합시다.
1. Class Validation
여기서 사용된 기술 스택은 다음과 같습니다.
- 데이터베이스: MySQL
- ORM 라이브러리: TypeORM
- validation 라이브러리: class-validator
요청과 함께 날라온 데이터를 validate(검증)하는 방식은 여러 가지가 존재합니다. 우리는 더 가독성 있는 코드를 만들면서 컨트롤러와 데이터 검증을 분리할 수 있도록 하는 NestJS의 class-validator를 사용할 수 있습니다.
class-validator는 IsInt(), IsEmail(), Contains('example') 등과 같은 다양한 데코레이터들로 검증 단계를 단순화합니다.
Class validator
이번에는 검증 기법에 대해 살펴보겠습니다. 검증을 위해선 class-validator 라이브러리가 굉장히 많은 도움을 줄 수 있습니다. 데코레이터 기반의 검증이 Nest의 Pipe와 결합하여 더 효율적인 validation을 할 수 있게 됩니다.
다음 명령어를 통해 패키지를 설치해줍니다.
$ npm i --save class-validator class-transformer
우리가 보통 요청 메소드의 인자를 각 계층과 응답 사이에서 주고 받을 때는 DTO를 사용한다고 했습니다. class validator는 이러한 DTO 객체에 대한 validation을 데코레이터로 가능하게 해줍니다.
// create-cat.dto.ts
import { IsString, IsInt } from 'class-validator';
export class CreateCatDto {
@IsString()
name: string;
@IsInt()
age: number;
@IsString()
breed: string;
}
그 다음 ValidationPipe라는 Pipe를 만들어줍니다.
// validation.pipe.ts
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;
}
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);
}
}
validation의 경우에는 Nest에서 바로 사용할 수 있는 ValidationPipe가 제공되기 때문에 굳이 custom validation을 직접 만들지 않아도 됩니다.
코드를 보게되면 transform() 메소드에 async 키워크가 붙은 것을 확인할 수 있는데, 이는 Nest가 동기와 비동기 파이프를 모두 지원하기 때문에 가능한 것입니다. async를 붙임으로써 class-validator의 validation이 비동기로 이루어질 수 있도록 할 수 있습니다.(Promises가 활용됨)
또한 metatype(ArgumentMetadata) 필드를 우리의 metatype 매개변수로 추출하기 위해 destructuring(구조 분해 할당)을 사용합니다. 이를 통해 손쉽게 ArgumentMetadata 전체를 얻을 수 있고 metatype 변수를 할당하기 위해 추가적인 구문을 가지기도 쉽습니다.
그리고 아래에 보시면 toValidate라는 helper 함수가 존재합니다. 이는 넘겨져 오는 가장 최근 인자가 네이티브 자바스크립트 타입(Native JavaScript type)일 때 validation 단계를 우회하는 역할을 합니다.
- 그러한 경우에는 validation 데코레이터를 붙일 수 없기 때문에 validation 단계를 거칠 이유가 없습니다.
다시 돌아오면 class-transformer의 plainToInstance() 메소드가 validation을 할 수 있도록 우리의 plain JavaScript 인자 객체를 typed 객체로 변환하는데 사용되는 것을 볼 수 있습니다.
이러한 과정을 거쳐야 하는 이유는 들어오는 post body 객체가 네트워크 요청으로 인해 역직렬화 될 때, 어떠한 타입 정보도 갖지 않게되기 때문입니다.
class-validator는 우리가 앞서 DTO에 대해 정의했던 validation 데코레이터를 사용할 필요가 있었기에, 이러한 변환(tranformation)이 단지 plain vanilla object가 아니라 적절히 데코레이팅 된 객체로 들어오는 body에 대해 처리하도록 수행해야 할 필요가 있습니다.
그래서, 앞서 언급했듯이 이게 validation pipe이기 때문에 값을 변경하지 않고 반환하거나 예외를 반환합니다.
최종적으로 ValidationPipe를 바인딩 하는 작업만 해주면 됩니다. Pipe는 parameter-scoped, method-scoped, controller-scoped, 혹은 global-scope가 될 수 있습니다. 다음 코드는 우리가 만든 pipe가 post body를 검증하기 위해 호출되도록 pipe 인스턴스를 라우트 핸들러 @Body 데코레이터로 바인딩 시키는 예시입니다.
// cats.controller.ts
@Post()
async create(
@Body(new ValidationPipe()) createCatDto: CreateCatDto,
) {
this.catsService.create(createCatDto);
}
Global scoped pipes
ValidationPIpe는 가능한 광범위하도록 만들어졌기 때문에, 애플리케이션 전체의 모든 라우트 핸들러에 적용되도록 global-scoped로 설정하여 유용하게 사용할 수 있습니다.
// main.ts
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe());
await app.listen(3000);
}
bootstrap();
Global Pipe는 모든 컨트롤러와 모든 라우트 핸들러에 대해 애플리케이션 전역에서 사용됩니다.
의존성 주입 관점에서 보면, 바인딩이 어떠한 모듈이든 해당 모듈의 컨텍스트 외부에서 행해지기 때문에 모듈의 외부로부터 등록된 global pipe는 의존성을 주입할 수 없다는 것을 알아야 합니다. 이를 해결하기 위해서 아래와 같이 코드를 작성하면 global pipe를 모든 모듈에서 직접 설정할 수 있습니다.
// app.module.ts
import { Module } from '@nestjs/common';
import { APP_PIPE } from '@nestjs/core';
@Module({
providers: [
{
provide: APP_PIPE,
useClass: ValidationPipe,
},
],
})
export class AppModule {}
다만 이 접근 방식을 사용하여 파이프에 대한 의존성 주입을 수행할 때, 이 구조가 사실은 사용되는 모듈과는 관계없이 파이프는 실제로 전역이라는 점에 유의해야 합니다. 파이프가 정의된 모듈(위에서는 ValidationPipe)를 선택합니다. 또한 useClass만이 custom provider 등록을 처리하는 유일한 방법은 아닙니다.
자세한 validation을 위해 공식문서를 참고하세요.
1-1. Custom Validation 만들기
만약 위에서 말한 이메일 검증이라던가, 형태 검증과 같은 단순한 검증이 아닌 우리 입맛에 맞는 더 복잡한 검증을 하고 싶다면 어떻게 해야 할까요? 예를 들어, 어떠한 새로운 사용자를 추가하는 함수가 있다고 합시다. 하지만 우리는 등록이 허용되지 않은(a.k.a. 블랙리스트) 이메일 목록이 있습니다. 아래 user.validator.ts 파일처럼 코드를 작성해봅시다.
import { Injectable } from '@nestjs/common';
import {
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
registerDecorator,
} from 'class-validator';
@ValidatorConstraint({ name: 'AllowedEmail', async: true })
@Injectable()
export class AllowedEmailValidator implements ValidatorConstraintInterface {
constructor(@Inject(IUserModel) private readonly userModel: IUserModel) {}
async validate(value: string): Promise<boolean> {
try {
const blackLists = [
'bad.attitude@email.com',
'not.allowed@email.com',
'competitor@email.com',
];
if (blackLists.includes(value)) return false;
return true;
} catch (e) {
return false;
}
}
defaultMessage(): string {
return 'Email not allowed';
}
}
이 때, 우리는 user.validator.ts 파일에서 새로운 함수를 만들어 우리만의 데코레이터를 만들고 class-validator를 통해 registerDecorator() 함수를 호출할 수 있습니다.
export const IsAllowedEmail = (validationOptions?: ValidationOptions) => {
return (object: unknown, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
validator: AllowedEmailValidator,
});
};
};
그리고 user.dto.ts 파일에서는 단순히 데코레이터로 IsAllowedEmail 함수를 사용합니다.
import { IsEmail } from 'class-validator';
/** commons */
import { IsAllowedEmail } from '../commons/validators/test.validtator';
export class CreateUserDto {
@IsEmail()
@IsAllowedEmail()
email: string;
}
1-2. 데이터베이스 연결하여 Custom Validation 만들기
그렇다면 데이터베이스에 있는 데이터를 체크해야 할 필요가 있는 경우는 어떨까요? 예를 들어, 한 사용자를 등록하는 함수가 있다고 합시다. 그러면 데이터베이스에서 등록된 이메일로 새로운 사용자를 검증해야 합니다.
다시 말해, 데이터베이스 연결을 구축하기 위해 의존성을 주입해야 한다는 의미입니다. 이 경우에, 주입된 의존성은 UserModel 클래스이며, 이 클래스는 데이터베이스 상에서 'users' 테이블과 상호작용하는 함수들의 목록을 포함합니다.
아래 코드는 UserModel 클래스를 포함하는 user.model.ts 파일입니다.
import { Injectable } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
/** commons */
import { UserEntity } from '../../../entities/user.entity';
/** DTOs */
import { CreateUserDto } from '../dtos/user.dto';
import { IUserModel } from '../interfaces/user.model.interface';
@Injectable()
export class UserModel implements IUserModel {
private entityManager: EntityManager;
constructor(private readonly dataSource: DataSource) {}
async checkEmailExist(email: string): Promise<boolean> {
try {
this.entityManager = this.dataSource.manager;
const result = await this.entityManager
.getRepository(UserEntity)
.query(`SELECT id FROM users WHERE email = ? LIMIT 1`, [email]);
if (result.length) return true;
return false;
} catch (error) {
return Promise.reject(error);
}
}
/** other functions */
}
이 파일에서, 해당 이메일이 이미 등록되었는지를 검증하는 함수를 구현하였습니다.
- 만약 그 이메일이 이미 등록되었다면, "이미 존재하는 이메일입니다."와 같은 에러 메세지를 반환합니다.
또한 데코레이터로 DTO에서 호출되는 함수를 만들어줍니다.
import { Inject, Injectable } from '@nestjs/common';
import {
registerDecorator,
ValidationOptions,
ValidatorConstraint,
ValidatorConstraintInterface,
} from 'class-validator';
/** interfaces */
import { IUserModel } from '../../interfaces/user.model.interface';
@ValidatorConstraint({ name: 'EmailExist', async: true })
@Injectable()
export class EmailExistValidator implements ValidatorConstraintInterface {
constructor(@Inject(IUserModel) private readonly userModel: IUserModel) {}
async validate(value: string): Promise<boolean> {
try {
const isEmailExist = await this.userModel.checkEmailExist(value);
if (isEmailExist) return false;
return true;
} catch (e) {
return false;
}
}
defaultMessage(): string {
return 'Email already exists';
}
}
export const IsUserExist = (validationOptions?: ValidationOptions) => {
return (object: unknown, propertyName: string) => {
registerDecorator({
target: object.constructor,
propertyName: propertyName,
options: validationOptions,
validator: EmailExistValidator,
});
};
};
그러면, 해당 데이터를 검증하는 DTO 내의 IsUserExists 함수를 사용합니다.
import { IsEmail } from 'class-validator';
/** commons */
import { IsUserExist } from '../commons/validators/user.validator';
export class CreateUserDto {
@IsEmail()
@IsUserExist()
email: string;
}
다음으로는 API 엔드포인트로 접근될 수 있도록 service와 controller를 완성시켜줍니다.
아래는 user.service.ts 파일입니다.
import { Inject, Injectable } from '@nestjs/common';
/** DTOs */
import { CreateUserDto } from '../dtos/user.dto';
/** interfaces */
import { IUserModel } from '../interfaces/user.model.interface';
import { IUserService } from '../interfaces/user.service.interface';
@Injectable()
export class UserService implements IUserService {
constructor(@Inject(IUserModel) private readonly userModel: IUserModel) {}
async create(body: CreateUserDto): Promise<any> {
try {
await this.userModel.create(body);
return { status: true };
} catch (error) {
return Promise.reject(error);
}
}
}
그리고 다음은 user.controller.ts 파일입니다.
import {
Body,
Controller,
Inject,
Post,
UsePipes,
ValidationPipe,
} from '@nestjs/common';
/** DTOs */
import { CreateUserDto } from '../dtos/user.dto';
/** interfaces */
import { IUserService } from '../interfaces/user.service.interface';
@Controller('user')
export class UserController {
constructor(
@Inject(IUserService)
private readonly userService: IUserService,
) {}
@Post('')
@UsePipes(new ValidationPipe({ transform: true }))
async create(@Body() body: CreateUserDto): Promise<any> {
return await this.userService.create(body);
}
}
그럼 이제 앞서 만든 custom validation 함수를 테스트 해볼 수 있는 단위 테스트를 작성해 봅시다.
이 단위 테스트에서는 UserService와 UserModel의 함수들을 감시합니다. 그렇게 함으로써 그러한 실행 중인 함수들을 시뮬레이션 할 수 있습니다.
import { Test, TestingModule } from '@nestjs/testing';
import { INestApplication, ValidationPipe } from '@nestjs/common';
import { useContainer } from 'class-validator';
import * as request from 'supertest';
import { AppModule } from '../../app.module';
/** Models */
import { IUserModel } from '../../modules/user/interfaces/user.model.interface';
/** Services */
import { IUserService } from '../../modules/user/interfaces/user.service.interface';
describe('UserController (e2e)', () => {
let app: INestApplication;
let userService: IUserService;
let userModel: IUserModel;
beforeAll(async () => {
const moduleFixture: TestingModule = await Test.createTestingModule({
imports: [AppModule],
providers: [
{
provide: IUserService,
useValue: {
create: jest.fn(),
},
},
{
provide: IUserModel,
useValue: {
checkEmailExist: jest.fn(),
},
},
],
}).compile();
app = moduleFixture.createNestApplication();
app.enableCors({
origin: true,
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS',
credentials: true,
});
app.useGlobalPipes(
new ValidationPipe({
transform: true,
transformOptions: { enableImplicitConversion: true },
}),
);
useContainer(app.select(AppModule), { fallbackOnErrors: true });
await app.init();
userService = moduleFixture.get<IUserService>(IUserService);
userModel = moduleFixture.get<IUserModel>(IUserModel);
});
describe('/user (POST)', () => {
const body = { email: 'example@email.com' };
const expectedResult = { status: true };
it('it should be success return true', async () => {
const resMock = { status: true };
jest.spyOn(userModel, 'checkEmailExist').mockResolvedValueOnce(false);
jest.spyOn(userService, 'create').mockResolvedValueOnce(resMock);
const res = await request(app.getHttpServer()).post('/user').send(body);
expect(res.body).toEqual(expectedResult);
});
it('it should be failed if email is exist', async () => {
jest.spyOn(userModel, 'checkEmailExist').mockResolvedValueOnce(true);
const res = await request(app.getHttpServer()).post('/user').send(body);
expect(res.body).toHaveProperty('error');
});
it('it should be failed with unknown error', async () => {
jest
.spyOn(userModel, 'checkEmailExist')
.mockRejectedValueOnce(new Error('Internal Server Error'));
const res = await request(app.getHttpServer()).post('/user').send(body);
expect(res.body).toHaveProperty('error');
});
});
});
마지막으로 class-validator의 useContainer를 사용합니다. useContainer는 class-validator에 의해 사용 중인 컨테이너를 설정할 수 있도록 합니다. 이를 main.ts에 구현합니다.
import { NestFactory } from '@nestjs/core';
import { Logger, ValidationPipe } from '@nestjs/common';
import { useContainer } from 'class-validator';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.enableCors({
origin: true,
methods: 'GET,HEAD,PUT,PATCH,POST,DELETE,OPTIONS',
credentials: true,
});
app.useGlobalPipes(
new ValidationPipe({
transform: true,
transformOptions: { enableImplicitConversion: true },
}),
);
// define useContainer
useContainer(app.select(AppModule), { fallbackOnErrors: true });
await app.listen(process.env.PORT || 3000);
}
bootstrap().then(() => Logger.log(`App Started`, process.env.PORT || 3006));
1-3. 결과 확인(by Postman)
우리가 만든 custom validator가 제대로 동작하는지 postman을 통해 결과를 확인해 봅시다.
처음 요청하는 이메일에 대해 사용자를 성공적으로 생성하였습니다.
이미 등록된 이메일을 가진 사용자를 생성하는 데 실패하였습니다.
user 모듈의 모든 코드가 단위 테스트를 통과한 모습입니다.
'Back-end > NestJS' 카테고리의 다른 글
[NestJS] NestJS 구조 살펴보기 (2) | 2024.01.29 |
---|---|
[NestJS] NestJS에서 로깅(Logging)하기 - 1 (전문적으로 로깅하기) (0) | 2024.01.26 |
[NestJS] NestJS CLI로 REST API를 사용한 CRUD 기능 만들기(5분버전 vs. 심화버전) with TypeORM & MySQL (0) | 2024.01.24 |
[NestJS] NestJS에서 Swagger 사용법 (feat. API Documentation) (2) | 2024.01.23 |
[NestJS] NestJS 시작 (설치 & 구성요소 맛보기) (3) | 2024.01.22 |
소중한 공감 감사합니다