04. 파이프를 사용한 유효성 검사
Pipes
NestJs에서 파이프는 데이터의 유효성 검사 및 변환을 위해 사용된다. 주로 요청(request) 데이터를 처리하는 데 사용되며, 컨트롤러에 도달하기 전에 데이터를 변환(transformation)하거나 유효성을 검증(validation)하는 역할을 한다.
커스텀 파이프를 생성할 수도 있지만, Nest에서 제공하는 9가지 빌트인 파이프를 가져다 쓸 수도 있다. 이 빌트인 파이프에는 타입을 변환하는 7개의 파이프와 타입을 검증하는 ValidationPipe, 그리고 기본 값을 부여하는 DefaultValuePipe가 있다.
데코레이터가 사용하는 위치에 따라 조금씩 다른 것처럼, 파이프도 사용하는 위치에 따라 글로벌∙핸들러∙파라미터 파이프로 구분된다.
// 글로벌 파이프 async function bootstrap() { const app = await NestFactory.create(AppModule); app.useGlobalPipes(new ValidationPipe({ stopAtFirstError: true })) await app.listen(3000); } // 핸들러 파이프 @Get() @UsePipes(customPipes) createBoard(@Body() createBoardDTO: CreateBoardDTO) {} // 파라미터 파이프 @Get() createBoard(@Body('title', DefaultValuePipe("제목 없음")) title: string) {}
Custom pipe
Nest에서 제공하는 빌트인 파이프에는 존재하지 않는 동작을 파이프로 구현하고 싶다면 커스텀 파이프를 만들 수 있다. 커스텀 파이프는 PipeTransform을 구현하며, 인자를 처리하기 위한 transform 메소드를 가지고 있어야 한다. 이 메소드는 파이프가 적용된 매개변수의 실제 값인 value와 함께 파이프가 호출된 곳에 대한 정보를 제공하는 metadata를 인자로 받는다.
- type : "body" | "query" | "param" | "custom" 중 하나의 값을 갖는다. 어떤 유형의 매개변수에 파이프가 적용되었는지를 나타낸다.
- metatype : 원본 매개변수의 타입.
- data : 매개변수의 데코레이터에 의해 전달된 추가 데이터. @Body('userTypes')와 같이 특정 필드를 지정한 경우 data는 userTypes가 된다.
export class CustomPipe implements PipeTransform { transform(value: any, metadata: ArgumentMetadata) { if (value === undefined) throw new BadRequestException("") else return value } }
다른 파이프들도 그렇지만, 커스텀 파이프를 핸들러 레벨에서 사용하면 각각의 인자를 차례로 검증하며, 파라미터 레벨에서는 단일 파라미터에 대해서만 검증한다.
DTO와 ValidationPipe
DTO(Data Transfer Object)는 인풋 검증 타입이라고도 불리며, 애플리케이션 내에서 데이터를 안정적으로 주고받기 위한 객체이다. Nest에서는 DTO를 사용하여 데이터의 구조를 정의하고, 이를 파이프와 결합하여 데이터 유효성 검사를 수행한다. 보통 이를 수행하는 데 class-validator와 class-transformer 라이브러리를 사용하곤 한다.
npm i class-validator class-transformer --save
class-validator는 타입 검증을 위한 데코레이터를 모아둔 라이브러리이다. 상상할 수 있는 ─ 그리고 일반적으로 필요하다고 여겨질만한 ─ 모든 검증 데코레이터를 다 구현해둔 것 같은 느낌이다. 깃허브 레포지토리에 공식 문서 수준의 README가 마련되어있어 특정 데코레이터가 필요할 때마다 가서 하나씩 찾아보는 편이다.
export class CreateBoardDTO { @IsNotEmpty() @IsString() title: string; @IsNotEmpty() @IsString() description: string; @IsOptional() @IsEnum(BoardStatus) status: BoardStatus; @IsIn(["맛집", "관광지", "숙소"], { each: true }) @IsArray() @IsOptional() tag: Array<Tag>; }
@Transform
앞서 살펴본 검증 데코레이터들이 단일 프로퍼티를 대상으로 한다면, class-transformer가 제공하는 @Transform 데코레이터는 객체 전체를 대상으로 커스텀한 검증을 처리할 수 있도록 한다.
@Transform 데코레이터는 콜백 함수를 인자로 받는데, 이 콜백 함수는 데코레이터가 적용되는 프로퍼티의 값과, 프로퍼티가 위치한 객체를 인수로 받는다. 이를 사용해 변환과 검증 로직을 주입하는 것이다.
// 공백 제거 @Transform(({ value }) => { return value.trim() }) // 비밀번호 일치 확인 @Transform(({ obj, value }) => { if (obj.password === obj.checkPassword) return value else throw new BadRequestException("비밀번호와 비밀번호 확인이 일치하지 않습니다") })
class-validator를 전역적으로 사용하고, class-transformer가 동작하도록 만들기 위해서는 ValidationPipe를 글로벌 파이프로 사용하고 transform 옵션을 켜주어야 한다.
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();
Custom validator decorators
@Transform 데코레이터가 이름과 맞지 않게 검증까지 하는 점이 꼴받을 수 있다. 그런 경우 registerDecorator를 호출하는 데코레이터 팩토리를 만들어 사용할 수 있다. 이를 커스텀 검증 데코레이터라고도 부른다. 아래의 예시 코드는 class-validator 깃허브 레포지토리에 있는 Custom validator decorators 예시와 동일하다.
import { registerDecorator, ValidationOptions, ValidationArguments } from 'class-validator'; export function IsLongerThan(property: string, validationOptions?: ValidationOptions) { return function (object: object, propertyName: string) { registerDecorator({ name: 'isLongerThan', target: object.constructor, propertyName: propertyName, constraints: [property], options: validationOptions, validator: { validate(value: any, args: ValidationArguments) { const [relatedPropertyName] = args.constraints; const relatedValue = (args.object as any)[relatedPropertyName]; return typeof value === 'string' && typeof relatedValue === 'string' && value.length > relatedValue.length; // you can return a Promise<boolean> here as well, if you want to make async validation }, }, }); }; }
import { IsLongerThan } from './IsLongerThan'; export class Post { title: string; @IsLongerThan('title', { /* you can also use additional validation options, like "groups" in your custom validation decorators. "each" is not supported */ message: 'Text must be longer than the title', }) text: string; }
Mapped types for DTO
ExpressJs에서는 superstruct를 사용해 유효성을 검증했다. 이 라이브러리는 다양한 유틸리티 타입을 지원해 하나의 타입으로부터 여러 종류의 struct를 만들 수 있도록 했다. Nest는 이와 비슷하게 하나의 DTO로부터 다양한 종류의 DTO를 만들 수 있도록 mapped-type라는 라이브러리를 제공하고 있다.
npm i @nestjs/mapped-types --save
이 라이브러리가 제공하는 유틸리티 클래스에는 PartialType, PickType, OmitType, IntersectionType 클래스가 있다. 이러한 유틸리티 클래스들은 모두 첫번째 인자로 DTO를 받아서 그 자체로 DTO가 된다는 공통점이 있다. 따라서 아래와 같이 유틸리티 클래스 여럿을 혼합하여 사용할 수도 있다.
export class UpdateTitleDTO extends PickType(PartialType(CreateBoardDTO), [ 'title', ] as const) {}
부록 : 잉여 속성 제거
유효성 검사와는 크게 관련이 없지만 pipe를 활용하는 또 다른 방법이 있어서 하나 소개해볼까 한다. 흔히 잉여 속성 제거라고 불리는 기능인데, NestJS의 글로벌 파이프 설정에서 whitelist: true 옵션을 통해 사용할 수 있으며, 클라이언트로부터 전달된 데이터 중 DTO에 정의되지 않은 속성을 자동으로 제거한다. 이를 통해 의도하지 않은 데이터가 애플리케이션 내부로 유입되는 것을 방지할 수 있어, 보안성과 코드의 명확성을 동시에 높일 수 있다.
const app = await NestFactory.create(AppModule); app.useGlobalPipes( new ValidationPipe({ whitelist: true, }), );
만약 DTO가 아래와 같은 상황에서 누군가 어드민 권한을 획득하기 위해 악의적으로 role을 추가하여 request를 보낸다면, whitelist 정책에 따라 명시적으로 허용되는 email만 컨트롤러에 넘어가고, role은 제거된다.
class CreateUserDto { @IsNotEmpty() @IsString() email: string; }
{ "email": "ayden@naver.com", "role": "admin" }
비슷하게 response를 검열하는 방법도 있는데, 바로 class-transformer가 제공하는 plainToInstance 함수를 사용하는 것이다. plainToInstance는 일반 JavaScript 객체를 특정 클래스의 인스턴스로 변환하는 역할을 한다. 아래 코드에서는 req.user 객체(세션에서 가져온 일반 객체)를 UserResDTO 타입의 클래스 인스턴스로 변환하고, 그 과정에서 @Expose()나 @Exclude() 같은 class-transformer 데코레이터를 활용해 멤버 변수를 필터링하고 있다. 결과적으로 password 같은 민감한 정보나 createdAt 같은 불필요한 정보 등이 적절히 걸러진 채 클라이언트로 보내지게 되는 것이다.
@Get('user') @UseGuards(AuthenticatedGuard) // 세션 기반 인증 가드 사용 getUser(@Req() req: Request): UserResDTO { return plainToInstance(UserResDTO, req.user); }
export class UserResDTO { @Expose() @IsUUID() @IsNotEmpty() id: string; @Expose() @IsString() @IsNotEmpty() email: string; @Expose() @IsNotEmpty() @IsString() nickname: string; @Exclude() password: string; @Exclude() createdAt?: Date; }
블로그의 정보
Ayden's journal
Beard Weard Ayden