본문 바로가기

Backend/NestJS

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

이번엔 저번 포스팅에서 하지 못했던 커스텀 validator 를 생성해 컨트롤러가 받는 DTO 의 일부 필드에 적용해보도록 하겠습니다

 

- Custom Validator

 

validator 를 커스텀하게 만들려면 class-validator 에서 제공하는 기능을 사용하면 됩니다. 이번 예시에서도 공식 내용을 따라가면서

만들어보도록 하겠습니다.

 

import {
  registerDecorator,
  ValidationOptions,
  ValidationArguments,
} from 'class-validator';

export function IsXXX(property: string, validationOptions?: ValidationOptions) {
  return (object: object, propertyName: string) => {
    registerDecorator({
      name: 'IsXXX',
      target: object.constructor,
      propertyName,
      options: validationOptions,
      constraints: [property],
      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;
        },
      },
    });
  };
}

 

일단 긁어온 예시입니다. 꼭 정해진건 아니지만 대체로 유효성을 체크하는 데코레이터이기 때문에 앞에 prefix 로 Is 를 쓰게 되었습니다.

먼저 볼 것은 class-validator 에서 import 하는 함수 및 인터페이스들입니다.

 

registerDecorator 는 네이밍도 그렇고 위 코드에서 보이는 것처럼 해당 데코레이터를 등록하는 역할을 합니다.

이 registerDecorator 는 인자로 ValidationDecoratorOptions 타입을 받는데 그 타입에 들어가는 속성들은 코드에 나와있습니다.

 

ValidationOptions 는 위에서도 옵셔널로 선언한 것 처럼 별로 중요한 역할은 아니지만 해당 validator 가 실패했을 때 나오는

메세지등을 해당 인자에 커스텀하게 집어 넣을 수 있습니다.

 

ValidationArguments 는 저것만 봐선 뭐가 뭔지 알기는 어렵고 validator, contraints 등을 같이 봐야합니다.

먼저 validator 는 말 그대로 커스텀한 검사 규칙 코드를 갖고 있는 속성이고, 이는 ValidatorContraint interface 를 구현한 함수라서

그 내부에선 validate 함수를 구현해야 합니다.

validate 함수는 boolean 을 리턴하도록 작성하면 되는데 true 면 유효성 체크를 통과한것이고, false 면 유효성 체크를 통과하지 못한 것입니다.

 

class XXXDto {
  readonly a: string;

  @IsXXX('a', { message: 'blah blah blah' })
  readonly b: string;
}

 

아무래도 예시를 드는게 편할 듯 해서..

위에서 작성한 IsXXX 데코레이터를 XXXDto 의 b 속성에 유효성 validator 로 사용하기 위해 위와 같이 추가했습니다.

이러면 property 는 'a' 가 되고 validationOptions 엔 message 프로퍼티를 가진 리터럴 객체가 들어가는데 뭐 이건 이미 얘기한것처럼

만약 validator 가 유효성 검사에 실패했을 경우 메세지로 'blah blah blah' 가 뜨는 정도의 역할입니다.

 

property 로 'a' 가 들어온 상태에서 registerDecorator 를 호출하는 함수를 반환하는 함수의 인자인 object 와 propertyName 은

각각 XXXDto 와 'b' 가 됩니다. 즉 object 는 이 데코레이터가 선언될 객체, propertyName 은 그 속성 이름이 됩니다.

그 밑에 constraints 를 보면 [property] 가 되어있는데 이걸 통해서 우리는 이 데코레이터의 제약조건으로 'a' 를 사용할 것이라고 선언할 수 있습니다. 당연히 이 'a' 는 XXXDto 에 선언되어있는 프로퍼티를 말합니다.

 

constraints 에 선언했기 때문에 우리는 validator 의 validate 함수에서 'a' 프로퍼티에 접근할 수 있습니다.

코드를 그대로 해석하자면 relatedPropertyName 은 'a' 가 되고, relatedValue 는 유효성 검사를 할 때 a 에 들어온 실제 값이 되며

a 와 b 가 둘 다 string 이고 b 의 길이가 a 보다 길어야 통과한다 라는 validator 가 되는 것입니다.

 

여기까지 예시 코드 해석이 되었으니.. 저는 제가 만들고 싶은 validator 를 만들도록 하겠습니다.

 

 

먼저 저는 src 하위에 decorators/index.ts 와 types/country.ts 파일을 생성했습니다.

 

export enum CountryCode {
  KR = 'KR',
  US = 'US',
}

 

types/country.ts 에는 최대한 간략히 넣어봤습니다.

당장 지원하는 국가는 KR 과 US 로 가정하고 두 국가의 2자리 국가코드만 enum 에 넣습니다.

 

import { registerDecorator, ValidationOptions } from 'class-validator';
import { CountryCode } from '../types/country';

export function IsAlpha2Country(validationOptions?: ValidationOptions) {
  return (object: object, propertyName: string) => {
    registerDecorator({
      name: 'IsAlpha2Country',
      target: object.constructor,
      propertyName,
      options: validationOptions,
      validator: {
        validate(value: any) {
          return Object.values(CountryCode).includes(value);
        },
      },
    });
  };
}

 

decorators/index.ts 는 예시에 비하면 비교적 심플한데 저는 광고 생성시 입력받는 국가의 값을 제공하는 enum 이 아닌건 400 에러로 처리하는 validator 를 만들고자 했습니다.

그래서 데코레이터 이름은 IsAlpha2Country 라고 지었고, 다른 프로퍼티와 비교할건 아니라서 인자에서 property 는 제거했습니다.

validator 의 validate 구현부는 value 가 enum 에 속하는지를 보는 정도로 간단합니다.

 

...
import { IsAlpha2Country } from '../../decorators';
import { CountryCode } from '../../types/country';

export class CreateAdDto {
  ...

  @IsAlpha2Country({
    message: '지원하는 국가의 2자리 국가코드를 입력해야 합니다',
  })
  readonly country: CountryCode;
}

 

CreateAdDto 의 변경된 부분만 보자면 위와 같습니다. 기존 class-validator 가 제공하는 데코레이터 사용하는 것과 똑같이 사용하면

되는데 validationOptions 에 메세지만 조금 추가를 해봤습니다. enum 체크를 할거라 기존에 있었던 @IsString, @Length(2, 2) 는

불필요하기에 제거했구요. 이제 한번 테스트를 해보겠습니다.

 

 

이런식으로 enum 에서 제공하지 않는 국가 코드 또는 KOR 등의 3자리 국가코드를 넣게되면 custom validator 에 의한 유효성 검사가

실패하면서 message 도 제가 넣었던 내용으로 뜨는 것을 볼 수 있습니다. KR 또는 US 로 넣으면 정상적으로 처리되는 것 또한 확인이

가능합니다.

 

음 근데 이렇게 적고나서 class-validator 를 좀 보다보니 이미 @IsEnum 이라는 데코레이터를 지원하는군요.

 

...
import { IsEnum } from 'class-validator';
import { CountryCode } from '../../types/country';

export class CreateAdDto {
  ...
  @IsEnum(CountryCode, {
    message: '지원하는 국가의 2자리 국가코드를 입력해야 합니다',
  })
  readonly country: CountryCode;
}

 

제공하는 @IsEnum 데코레이터를 사용해서도 동일한 처리가 가능합니다.

커스텀 validator 를 작성하기에 앞서 필요한 데코레이터를 제공하는지 먼저 확인하는 것이 중요하겠습니다. (링크)

 

- Use Validator by class

 

위에서 validator 는 ValidatorContraint interface 를 구현한 함수라고 했는데, 말 그대로 ValidatorContraintInterface 를 구현하는

클래스를 별도로 만들어서 validator 에 넣는 것 또한 가능합니다. 여기엔 옵션을 추가해서 비동기 작업도 쉽게 넣을수가 있구요.

 

import {
  registerDecorator,
  ValidationOptions,
  ValidatorConstraint,
  ValidatorConstraintInterface,
} from 'class-validator';

function findAdType(adType: string): Promise<boolean> {
  return Promise.resolve(adType !== '1');
}

@ValidatorConstraint({ async: true })
export class IsAdTypeAlreadyExistConstraint
  implements ValidatorConstraintInterface
{
  validate(adType: any) {
    return findAdType(adType).then((isExist) => {
      return isExist;
    });
  }
}

export function IsAdTypeAlreadyExist(validationOptions?: ValidationOptions) {
  return function (object: object, propertyName: string) {
    registerDecorator({
      target: object.constructor,
      propertyName: propertyName,
      options: validationOptions,
      constraints: [],
      validator: IsAdTypeAlreadyExistConstraint,
    });
  };
}

 

이건 정말 단순 예시로만 봐주시면 좋을 것 같습니다. 그래서 억지로 끼워넣긴했고.. 예시로만 보이고 다시 코드에선 제거할겁니다.

AdType 이 '1' 로 들어오면 유효성 체크를 실패로 처리하기 위한 데코레이터인데, 위에서 얘기한대로 ValidatorConstraintInterface 를 구현하는 클래스를 validator 에 넣었고 validate 는 Promise 를 반환하고 있지만 ValidatorConstraint 에 async 옵션을 줘서 별다른

컨트롤 없이 사용이 가능합니다.

 

import { IsAdTypeAlreadyExist } from '../../decorators';

export class CreateAdDto {
  @IsString()
  @IsAdTypeAlreadyExist({ message: 'AdType $value already exists'})
  readonly adType: string;
  
  ...
}

 

CreateAdDto 의 adType 프로퍼티에 위와 같이 데코레이터를 추가했습니다. 이번에 보면서 알게된건데 템플릿 리터럴을 쓰듯이 저렇게 표현하는 것도 상당히 편했습니다. 이제 adType 에 '1' 을 넣고 요청을 보내보겠습니다.

 

 

의도한대로 '1' 을 넣었을 때 400 에러가 떨어지면서 에러에 대한 정보 메세지도 잘 나오는 것을 확인할 수 있습니다.

npm class-validator 의 예시 코드에선 validate 할 때 repository 까지 접근해서 중복 체크를 하는데 그게 파이프의 역할로 볼 수

있을지는 저는 조금 의문이라 일단 예시만 작성해보고 넘어갑니다.

 

실제 여러 비즈니스 요구사항을 구현하는 API 서버를 작업하다보면 요청 페이로드에는 다양한 프로퍼티가 들어오게되는데,

class-validator 가 이미 제공하는 많은 데코레이터로도 충분히 커버가 가능하지만 특수한 경우에는 위처럼 별도의 커스텀 validator 를

생성해 사용한다면 코드가 훨씬 관심사별로 깔끔하게 분리도 되면서 비즈니스 로직 구현에 더 집중할 수 있는 환경이 될 수 있어보입니다.