최근 실무에서 Express ⇒ Nest.js 마이그레이션을 요하는 의견이 있었어서 Nest 강의를 듣고 있다. Nest 강의를 듣다가 클라이언트에서 보낸 데이터의 유효성을 체크하기 위해 class-validation 패키지에서 제공하는 ValidationPipe를 사용해보았다. ValidationPipe에는 다양한 옵션이 있었는데, 그 중 대표적인 3가지 옵션에 집중해보려 한다. 개인적인 공부를 위해 들여다보는 것이지만 Nest를 모른거나, Nest를 써보고 싶은 사람들 혹은 Nest에 막 입문한 분들께도 도움이 되었으면 좋겠다. *Nest의 공식 문서 내용이 포함되어있다.
Nest.js Validation
웹 애플리케이션으로 전송되는 모든 데이터의 정확성을 검증하는 것이 좋다. 들어오는 요청의 유효성을 자동으로 검사하기 위해 Nest는 즉시 사용할 수 있는 여러 파이프를 제공한다.
- ValidationPipe ⇒ 이 글에서 포커싱할 파이프
- ParseIntPipe
- ParseBoolPipe
- ParseArrayPipe
- ParseUUIDPipe
ValidationPipe는 NestJS 프레임워크에서 전역 파이프(Global Pipe)를 사용해, 강력한 class-validator 패키지와 선언적 유효성 검사 Decorator를 사용한다. 모든 클라이언트 Payload에 대해 유효성 검사 규칙을 적용하는 방식을 제공하며, 특정 규칙은 각 모듈의 로컬 Class/DTO에서 간단한 어노테이션으로 선언된다.
async function bootstrap() {
const app = await NestFactory.create(AppModule);
app.useGlobalPipes(new ValidationPipe());
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
- app.useGlobalPipes()
전역 파이프를 설정하는 NestJS의 메서드 이 메서드는 애플리케이션 전역에서 모든 요청에 대해 파이프를 사용하도록 설정한다.
미들웨어랑..비슷?.. 같은 것 같음 - new ValidationPipe()
생성자 함수를 호출해 ValidationPipe를 생성한다. 이 파이프는 들어오는 요청의 데이터를 검증하기 위해 사용된다.
ValidationPipe는 class-validator, class-transformer 라이브러리를 사용한다. transform을 통해 클라이언트에서 전달된 데이터를 원하는 타입으로 반환할 수 있는 등 다양한 옵션을 제공한다.
테스트를 위해 먼저 *DTO 파일을 하나 생성해주는데, 이때 class-validator 패키지에서 제공하는 데코레이터를 사용할 수 있다. CreateMovieDto를 사용하는 모든 라우터는 이 검증 규칙을 자동으로 적용하게 된다. 매우 간편..!
*DTO Data Transfer Object 계층 간 데이터 전송을 위해 도메인 모델 대신 사용되는 객체. 여기서 계층이란 Controller, Service, DAO 등을 의미한다.
// create-movie.dto.ts
import { IsNumber, IsString } from 'class-validator';
export class CreateMovieDto {
@IsString()
readonly title: string;
@IsNumber()
readonly year: number;
@IsString({ each: true })
readonly genres: string[];
}
만약 이 규칙이 적용되고 Request Body에 잘못된 속성이 포함된 요청이 들어오게 되면 자동으로 400 Bad Request로 응답한다.
// movies.controller.ts
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { MoviesService } from './movies.service';
import { Movie } from './entities/movie.entity';
import { CreateMovieDto } from './dto/create-movie.dto';
@Controller('movies')
export class MoviesController {
constructor(readonly moviesService: MoviesService) {}
@Get()
getAll(): Movie[] {
return this.moviesService.getAll();
}
@Get('/:id')
getOne(@Param('id') movieId: number): Movie {
return this.moviesService.getOne(movieId);
}
@Post()
create(@Body() movieData: CreateMovieDto) {
return this.moviesService.create(movieData);
}
@Delete('/:id')
remove(@Param('id') movieId: number) {
return this.moviesService.deleteOne(movieId);
}
@Patch('/:id')
patch(@Param('id') movieId: number, @Body() updateData) {
return this.moviesService.update(movieId, updateData);
}
}
// movie.service.ts
import { Injectable, NotFoundException } from '@nestjs/common';
import { Movie } from './entities/movie.entity';
import { CreateMovieDto } from './dto/create-movie.dto';
@Injectable()
export class MoviesService {
private movies: Movie[] = [];
getAll(): Movie[] {
return this.movies;
}
getOne(id: number): Movie {
const movie = this.movies.find((movie) => movie.id === id);
if (!movie) {
throw new NotFoundException(`Movie with ID ${id} not found.`);
}
return movie;
}
deleteOne(id: number) {
this.getOne(id);
this.movies = this.movies.filter((movie) => movie.id !== id);
}
create(movieData: CreateMovieDto) {
this.movies.push({
id: this.movies.length + 1,
...movieData,
});
}
update(id: number, updateData) {
const movie = this.getOne(id);
this.deleteOne(id);
this.movies.push({ ...movie, ...updateData });
}
}
=> CreateMovieDto를 타입으로 갖고 있는 라우터는 전부 해당 Dto와 비교하여 검증된다.
ValidationPipe - `whitelist`, `forbidNonWhitelisted`, `transform`
네트워크를 통해 들어오는 Payload 값은 일반 JS 객체이다. ValidationPipe는 class-transformer를 사용하여 페이로드를 Dto 클래스에 따라 타입이 지정된 객체로 자동 변환할 수 있다. 자동 변환을 활성화 하려면 transform = true 로 설정해주면 된다.
나는 아래와 같이 main.ts에서 ValidationPipe를 옵션과 함께 적용했다.
// 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({
whitelist: true,
forbidNonWhitelisted: true,
transform: true,
}),
);
await app.listen(process.env.PORT ?? 3000);
}
bootstrap();
실제로 이렇게 되면 id를 string으로 요청하더라도 DTO에 설정해놓은 덕에 number로 저장된다.
class-validator는 검증을 수행할 때 대상 객체에 검증 규칙이 정의되어있지 않은 프로퍼티가 있더라도 오류를 내지 않고 그대로 통과시켜주는데, whitelist 옵션을 적용할 경우 대상 객체에서 검증 규칙이 정의되어있지 않은 프로퍼티를 모두 제거해주는 것이다.
이때 forbidNonWhitelisted를 true로 같이 적용할 경우, whitelist에 없는 프로퍼티를 제거하는 대신 validator가 exception을 throw한다.
요약하자면
- whitelist<boolean> true로 설정시,
어떠한 검증 데코레이터도 사용하지 않는 속성은 whitelist에 등록되지 않은 것으로 간주하여 검증 대상으로부터 제거된다.
만약 어떠한 데코레이터도 적합하지 않아 붙일 것이 없는 속성에는 @Allow()라도 붙여주면 된다. - forbidNonWhitelisted<boolean> true로 설정시,
whitelist에 없는 속성을 제거하는 대신 validator가 예외를 던진다.
예를 들어 아래처럼 요청할 경우 whitelist true옵션의 경우 해당하지 않는 “hacked”는 제거된다.
이제 3가지 옵션이 실제 동작하는 내부 코드를 들여다보자
// class-validator/src/validation/ValidationExecutor.ts
export class ValidationExecutor {
... // 중간 생략
whitelist(
object: any,
groupedMetadatas: { [propertyName: string]: ValidationMetadata[] },
validationErrors: ValidationError[]
): void {
const notAllowedProperties: string[] = [];
Object.keys(object).forEach(propertyName => {
// 1. does this property have no metadata?
if (!groupedMetadatas[propertyName] || groupedMetadatas[propertyName].length === 0)
notAllowedProperties.push(propertyName);
});
if (notAllowedProperties.length > 0) {
if (this.validatorOptions && this.validatorOptions.forbidNonWhitelisted) {
// 2. throw errors
notAllowedProperties.forEach(property => {
const validationError: ValidationError = this.generateValidationError(object, object[property], property);
validationError.constraints = { [ValidationTypes.WHITELIST]: `property ${property} should not exist` };
validationError.children = undefined;
validationErrors.push(validationError);
});
} else {
// 3. strip non allowed properties
notAllowedProperties.forEach(property => delete object[property]);
}
}
}
- notAllowedProperties를 선언하고, 먼저 DTO에 정의되지 않은 속성을 찾아서 push한다.
- notAllowedProperties가 존재할 경우 ⇒ 그리고 forbidNonWhitelisted가 활성화된 경우, 예외를 발생시킨다. (validationError가 forbidNonWhitelisted 부분이라 보면 된다)
- 그렇지 않은 경우 whitelist true옵션대로 해당 속성을 객체에서 제거한다.
export class ValidationPipe implements PipeTransform<any> {
// ... 생략
this.isTransformEnabled = !!transform;
// 1. transform 옵션이 true라면 isTransformEnabled가 활성화된다.
// ... 생략
public async transform(value: any, metadata: ArgumentMetadata) {
if (this.expectedType) {
metadata = { ...metadata, metatype: this.expectedType };
}
const metatype = metadata.metatype;
if (!metatype || !this.toValidate(metadata)) {
return this.isTransformEnabled
// 2. 변환 가능한 타입인지 확인하고, 메타타입이 없거나 검증 대상이 아닌 타입이라면 원시 값 변환(transformPrimitive)을 수행한다.
? this.transformPrimitive(value, metadata)
: value;
}
...
// ... 생략
// 3. transformPrimitive
protected transformPrimitive(value: any, metadata: ArgumentMetadata) {
if (!metadata.data) {
return value;
}
const { type, metatype } = metadata;
if (type !== 'param' && type !== 'query') {
return value; // param, query에 해당해야 변환이 진행된다. 아닐 경우 return
if (metatype === Boolean) {
if (isUndefined(value)) {
return undefined; // 값이 없으면 undefined로 반환
}
return value === true || value === 'true'; // 문자열 'true'를 Boolean으로 변환
}
if (metatype === Number) {
return +value; // 숫자로 변환
}
if (metatype === String && !isUndefined(value)) {
return String(value); // 문자열로 변환
}
return value; // 변환되지 않는 경우 return
}
특이한 점은 transform의 경우엔 Nest.js에 자체 구현되어있었다. class-transformer 내부에 있을 것 같았는데 아니었다.
라이브러리 내부에 의존했다면 Pipe 단계에서 데이터를 타입에 맞게 변환하고 비즈니스 로직으로 전달하기가 어려웠을 것이라 추정.. (조금 더 정확하게 아시는 분 계시다면 피드백 부탁드립니다 (_ _))
Nest 공부를 하며 express를 쓰던 때보다 확실히 개발자 친화적이고 체계가 잘 잡혀있어서 더욱 빨리 익히고 싶었다. 공부하면서 또 다뤄볼 것들이 생기면 포스팅해보려한다. 마이그레이션 또한 잘 해낼 수 있도록....
https://docs.nestjs.com/techniques/validation
https://www.prisma.io/blog/nestjs-prisma-validation-7D056s1kOla1
https://hudi.blog/data-transfer-object/
https://velog.io/@cheesechoux/Nest.js-ValidationPipe-whitelist-에러를-잡아보아요
https://cdragon.tistory.com/entry/NestJS-Validation과-Transformation-검증과-변환-feat-Serialization-직렬화
'JavaScript > Node.js' 카테고리의 다른 글
JWT란? NestJS로 풀어보는 토큰 기반 인증과 인가 (2) | 2025.01.30 |
---|---|
Node.js는 싱글 스레드일까? 멀티 스레드일까? Worker thread에 대하여. (6) | 2024.10.27 |