본문 바로가기

Backend/NestJS

NestJS - 대충 서비스 만들어보기 (5)

이번엔 광고 생성 또는 수정, 조회할 때 요청으로 들어오는 파라미터들에 대한 유효성 체크를 해보도록 하겠습니다.

그러기 위해선 먼저 파이프에 대해 알아야 합니다.

 

- Pipe (파이프)

 

 

NestJS 공식 문서 중 파이프에 대한 설명으로 제일 먼저 나와있는 그림입니다. 어느정도 공식 문서의 문구를 설명하는데 사용하겠습니다.

 

파이프는 Injectable 데코레이터가 붙어있는 클래스고, PipeTransform 인터페이스를 구현합니다.

파이프는 요청이 라우터 핸들러로 전달되기 전에 요청 객체를 변환할 수 있는 기회를 제공합니다. 지금까지 작성한 코드 기준으로 보자면

컨트롤러가 받고 있는 DTO 가 되겠죠.

 

보통 파이프는 일반적으로 두 가지의 사용법이 있습니다.

 

  • transformation
    • 입력 데이터를 원하는 포맷으로 변경합니다 (예를 들면 string 에서 integer)
  • validation
    • 입력 데이터가 유효한지 평가하고, 유효하다면 변화 없이 넘기거나 유효하지 않다면 에러를 던집니다

파이프는 위의 두 케이스 모두 컨트롤러 라우트 핸들러가 처리하는 arguments 에 대해 동작합니다.

NestJS 는 메소드가 실행되기 전에 파이프를 끼워넣고, 파이프는 원래 메소드가 받는 arguments 를 받아서 작동합니다.

 

이 부분이 Express 에 익숙하다면 어느정도 알고 있는 미들웨어와 비교되는 부분인 것 같습니다.

NestJS 에서의 미들웨어도 Express 미들웨어와 동일하고, 요청과 응답 객체에 접근 가능하면서 next 에 의해 컨트롤됩니다.

 

How about creating validation middleware?
This could work, but unfortunately it's not possible to create generic middleware which can be used across all contexts across the whole application.
This is because middleware is unaware of the execution context, including the handler that will be called and any of its parameters.


NestJS 의 파이프 관련 문서에 보면 위와 같은 내용이 있습니다.

파이프가 validation 을 해주니 그냥 validation 미들웨어를 만들어도 되지 않나? 싶을텐데

그것도 아마 동작은 하겠지만, NestJS 의 모든 컨텍스트에서 사용할 수 있는 일반적인 미들웨어를 만드는것은 불가능합니다.

왜냐면 미들웨어는 들어온 요청이 어떤 핸들러에 의해 호출될지와 해당 매개변수를 포함한 실행 컨텍스트에 대해 알지 못하기 때문입니다.

미들웨어에 대해선 이정도로만 보고 나중에 다른 포스팅에서 다뤄보도록 하겠습니다.

 

다시 파이프로 돌아와서, 파이프는 exception zone 안에서 실행됩니다. 이 말은 즉 파이프에서 던지는 에러는 exception layer 에서 처리되고, 이로 인해 우리는 파이프에서 에러가 던져지면 컨트롤러의 메서드 또한 호출되지 않는 다는 것도 명확히 알 수 있습니다.

 

- 파싱 및 유효성 검사

 

이제 파이프를 사용해서 컨트롤러가 받는 DTO 에 대해 파싱 및 유효성 검사를 해보도록 하겠습니다.

먼저 간단하게 볼 것은 ParseIntPipe 입니다. 이 파이프는 이미 @nestjs/common 에 구현되어있는 내장 파이프입니다.

 

먼저 단일 id 로 조회 부분 기존 코드를 보겠습니다.

 

// ads.controller.ts
export class AdsController {
  ...
  @Get(':id')
  findOne(@Param('id') id: string) {
    return this.adsService.findOne(+id);
  }
  ...
}

// ads.service.ts
export class AdsService {
  ...
  findOne(id: number) {
    return `This action returns a #${id} ad`;
  }
  ...
}

 

현재는 컨트롤러의 findOne 메서드가 받는 id 의 타입은 string 으로 되어있고, +id 를 통해 number 형변환을 해서 AdsService 의 findOne 메서드를 호출하고 있습니다. query params 나 querystring 으로 받는 파라미터들은 다 일단 string 으로 들어오기때문에

이 자체가 이상한건 아니지만, 한번 id 에 number 로 형변환이 되지 않는 값을 넣고 요청을 해보겠습니다.

 

 

abc 라는 문자열은 number 로 형변환을 하면 NaN 이 되기 때문에 응답에 그대로 NaN 이 나온것을 볼 수 있습니다.

 

// ads.controller.ts
export class AdsController {
  ...
  @Get(':id')
  findOne(@Param('id') id: number) {
    return this.adsService.findOne(id);
  }
  ...
}

 

그럼 컨트롤러에서 findOne 메서드가 받는 id 파라미터의 타입을 number 로만 바꿔주면 해결이 될까요?

 

 

사실 뭐 저기서 number 타입으로 선언하는건 컴파일 단계에서나 효과가 있는 것이라.. 실제 런타임에 들어온 string 은 그대로 전달됩니다.

우리는 컨트롤러에서 id 에 number 타입이 아닌 파라미터가 들어왔을 때 아예 잘못된 요청이라는 에러를 떨구고 싶습니다.

이럴 때 아까 위에서 얘기한 내장 파이프를 사용할 수 있습니다.

 

// ads.controller.ts
import {
  ...,
  ParseIntPipe
} from '@nestjs/common';

export class AdsController {
  ...
  @Get(':id')
  findOne(@Param('id', ParseIntPipe) id: number) {
    return this.adsService.findOne(id);
  }
  ...
}

 

ParseIntPipe 의 내장 파이프의 사용은 위와 같습니다. 이렇게 추가하면 해당 파이프를 현재의 실행 컨텍스트에 바인딩할 수 있고,

이름 그대로 전달된 인자를 파싱해 변환하면서 그 유효성까지 검사해주는 파이프입니다.

이제 id 에 number 로 형변환이 되지 않는 값을 넣어 다시 요청을 보내보겠습니다.

 

 

유효성 검사에서 에러가 발생해 400 에러까지 떨궈주는 걸 확인할 수 있습니다. 따라서 컨트롤러 메서드 호출로 이어지지 않은 것이죠.

기본적인 타입에 대한 파싱은 이외에도 ParseBoolPipe, ParseArrayPipe 등의 내장 파이프를 충분히 사용할 수 있어보입니다.

저는 일단 현재의 코드에선 id 를 받는 모든 컨트롤러의 메서드에 ParseIntPipe 파이프를 추가해주었습니다.

 

- DefaultValuePipe

 

이 파이프 또한 @nestjs/common 에 구현되어 있는 내장 파이프인데, 전달받는 파라미터의 default value 를 넣어줄 때 유용하게

사용할 수 있습니다. 이건 당장 지금 구현하는 컨트롤러에 필요하진 않지만, 그냥 간단히 보고 넘어가겠습니다.

 

export class AdsController {
  ...
  @Get()
  findAll(@Query('limit', ParseIntPipe) limit: number) {
    console.info(limit);
    return this.adsService.findAll();
  }
  ...
}

 

원래 없지만, findAll 메서드에 limit 라는 파라미터를 받을 수 있도록 위처럼 설정하고 요청을 보내보면 파라미터에 limit 10 등을 넣었을 땐 응답이 정상이지만 limit 을 넣지 않았을 땐 400 에러를 받게 됩니다. 우리는 limit 를 매 요청마다 넣을수도 있지만 없을 경우엔 default value 로 동작하도록 만들고 싶습니다. 이럴 때 DefaultValuePipe 를 사용할 수 있습니다.

 

import {
  ...,
  DefaultValuePipe
} from '@nestjs/common';

export class AdsController {
  ...
  @Get()
  findAll(
    @Query('limit', new DefaultValuePipe(10), ParseIntPipe) limit: number,
  ) {
    console.info(limit);
    return this.adsService.findAll();
  }
  ...
}

 

위처럼 파이프를 넣고 쿼리 파라미터에 limit 없이 요청을 보내보면 limit 가 10 으로 동작하는 것을 볼 수 있습니다.

아직은 이게 필요한 부분은 없으니 이 예시는 코드에서 제거하겠습니다.

 

- class-validator, class-transformer

 

이제 컨트롤러에서 생성하는 메서드가 받는 DTO 의 유효성 검사를 추가해보겠습니다. 외부 라이브러리의 도움을 약간 받아야 합니다.

 

> npm install class-validator class-transformer

 

class-validator, class-trnasformer 두 라이브러리를 설치해줍니다.

처음엔 transform 기능은 당장 쓸 생각이 없어서 class-validator 만 설치하고 설정 코드를 추가 후 실행해봤는데

두개가 세트인지 transformer 설치 안하면 NestJS 앱 실행할 때 에러가 나더라구요.

 

...
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();

 

main.ts 는 위와 같이 수정해줍니다. ValidationPipe 에 대해선 좀 이따 자세히 보도록 하고, NestJS 앱에서 ValidationPipe 를

전역으로 사용할 수 있도록 useGlobalPipes 를 사용해 적용합니다. class-transformer 를 사용하려면 transform 속성을 true 로

전달해줘야 하는데 안쓸거라면 빼도 상관없습니다.

 

import { IsString, Matches, Length } from 'class-validator';

export class CreateAdDto {
  @IsString()
  @Length(2)
  readonly adType: string;

  @IsString()
  @Length(10)
  readonly title: string;

  @IsString()
  @Matches(/^(https:\/\/|http:\/\/)[a-zA-Z0-9]{2,}/)
  readonly thumbnail: string;

  @IsString()
  @Length(20)
  readonly content: string;

  @IsString()
  @Length(2, 2)
  readonly country: string;
}

 

class-validator 를 사용해 CreateAdDto 의 파라미터에 여러 규칙을 적용해봤습니다.

class-validator 에는 IsString, Matches, Length 외에도 다양한 데코레이터를 선언할 수 있도록 구현되어 있습니다.

이름도 직관적입니다. IsString 은 string 인지를 검사하고, Length 는 min max 값을 넣어서 해당 string 의 길이를 제한할 수 있습니다.

Matches 는 정규표현식을 넣어 입력 string 이 의도한 형식을 따르는지 검사합니다.

 

 

제대로 작동하는지 보기 위해 thumbnail 에는 지정한 형식과 다른 값을 넣고, country 에는 3글자 국가코드를 넣은 후 요청을 보내면

위 처럼 400 에러가 발생하면서 메세지에 어떤 규칙에 위반했는지도 알려줍니다.

thumbnail 입력값에 http:// 를 붙이고 country 를 KR 과 같이 2글자로 수정해 다시 요청을 보내보면 201 응답을 확인할 수 있습니다.

 

class-transform 에서 사용할만한 데코레이터는 @Transform 입니다.

위에 우리는 class-validator 를 사용해 각 파라미터에 대한 규칙을 정했지만 예를 들어 title 에 앞뒤로 띄어쓰기만 넣어버린다면

그게 전부 문자열로 인식되어 길이를 만족하게 되는 불상사가 일어납니다. 이는 우리가 의도한 바가 아니겠죠.

이럴 경우 @Transform 을 사용할 수 있습니다.

 

import { IsString, Matches, Length } from 'class-validator';
import { Transform } from 'class-transformer';

export class CreateAdDto {
  ...

  @Transform((params) => params.value.trim())
  @IsString()
  @Length(10)
  readonly title: string;

  ...
}

 

@Transform 가 받는 params 인자는 TransformFnParams 라는 타입을 갖고 있고 이 타입 안에는 value, key, obj, type, options 

5개의 프로퍼티가 있는데 아마 주로 사용할 프로퍼티는 value, key 입니다. 위의 경우엔 key 가 title 이 되고, value 는 실제로 title 에

들어오는 입력값이 됩니다. 저는 일단 단순하게 value 를 trim 해서 앞 뒤 띄어쓰기 공백을 제거하는 형태로 넣어봤습니다.

 

 

이제 다시 공백을 추가한 title 을 요청으로 보내보면, trim 에 의해 공백은 제거되고 test 4글자만 남기 때문에 @Length(10) 제한에 의해

400 에러가 떨어지는 것을 확인할 수 있습니다.

 

- ValidationPipe

 

위에서 간략히 넘어갔었는데, 우리는 유효성 검사를 할 때 @nestjs/common 의 ValidationPipe 를 NestJS 의 globalPipe 에 적용하고 있습니다. 

 

export declare class ValidationPipe implements PipeTransform<any> {
    protected isTransformEnabled: boolean;
    protected isDetailedOutputDisabled?: boolean;
    protected validatorOptions: ValidatorOptions;
    protected transformOptions: ClassTransformOptions;
    protected errorHttpStatusCode: ErrorHttpStatusCode;
    protected expectedType: Type<any>;
    protected exceptionFactory: (errors: ValidationError[]) => any;
    protected validateCustomDecorators: boolean;
    constructor(options?: ValidationPipeOptions);
    protected loadValidator(validatorPackage?: ValidatorPackage): ValidatorPackage;
    protected loadTransformer(transformerPackage?: TransformerPackage): TransformerPackage;
    transform(value: any, metadata: ArgumentMetadata): Promise<any>;
    createExceptionFactory(): (validationErrors?: ValidationError[]) => unknown;
    protected toValidate(metadata: ArgumentMetadata): boolean;
    protected transformPrimitive(value: any, metadata: ArgumentMetadata): any;
    protected toEmptyIfNil<T = any, R = any>(value: T): R | {};
    protected stripProtoKeys(value: any): void;
    protected isPrimitive(value: unknown): boolean;
    protected validate(object: object, validatorOptions?: ValidatorOptions): Promise<ValidationError[]> | ValidationError[];
    protected flattenValidationErrors(validationErrors: ValidationError[]): string[];
    protected mapChildrenToValidationErrors(error: ValidationError, parentPath?: string): ValidationError[];
    protected prependConstraintsWithParentProp(parentPath: string, error: ValidationError): ValidationError;
}

 

ValidationPipe 선언을 보면 PipeTransform 이라는 인터페이스를 구현하게 되어 있으며, transform 과 validate 같은 변환 및 유효성 검사를 해줄 것 같은 메서드들이 보입니다.

 

public async transform(value: any, metadata: 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;
}

 

transform 은 value, metadata 두 인자를 받게 되어있고 ArgumentMetadata 는 type, metatype, data 로 정의되어 있습니다.

type 은 인자가 body, query, params, custom 중 어떤 타입인지를 나타내고 metatype 은 우리가 핸들러에 정의한 인자의 타입을

나타냅니다. data 는 파라미터의 이름이고, value 에 실제 값이 들어가있습니다.

이런 값들을 받아 메서드 명 그대로 값을 변환하는 일을 한다고 보시면 될 것 같습니다.

 

protected validate(
    object: object,
    validatorOptions?: ValidatorOptions,
  ): Promise<ValidationError[]> | ValidationError[] {
    return classValidator.validate(object, validatorOptions);
  }

 

이건 validate 메서드고.. 이미 그 구현 내용부터가 classValidator 의 validate 를 호출하고 있죠.

실제 문서에도 적혀있듯이 NestJS 의 ValidationPipe 는 class-validator 의 강력한 선언형 유효성 검사 데코레이터를 사용합니다.

class-validator 를 꼭 NestJS 가 아닌 Express 등에도 사용할 수 있지만 NestJS 의 ValidationPipe 자체가 class-validator 와

묶여있기 때문에 우리는 이미 위에서 봐왔던 것처럼 이를 이용해 상당히 편하게 유효성 검사를 할 수 있습니다.

 

app.useGlobalPipes(
  new ValidationPipe({
    disableErrorMessages: true,
  }),
);

 

이건 그냥 지나가는 팁 정도로.. 지금까지 작성한 파이프에 의해 유효하지 않을 경우엔 그 내용이 고스란히 응답의 메세지에 담겨왔었는데

실제 라이브 환경에선 이를 제거해야할 필요가 있고, 그럴 경우엔 위처럼 disableErrorMessage 옵션을 true 로 설정해 에러 메세지를

단순 400 상태 코드만 확인할 수 있도록 변경할 수 있습니다.

 

원래는 커스텀 validator 까지 담으려했으나 포스팅이 너무 길어져서 따로 남기거나, 추후 다른 기능을 붙일 때 필요한 부분이 있으면

그때 다뤄보도록 하겠습니다.