개발/Today I Learned

타입스크립트 데코레이터를 활용하여 유효성 검사(Validation) 하기

devmomori 2022. 4. 29. 21:54

타입스크립트의 데코레이터는 도대체 뭘까?

 

class-validator를 프로젝트에서 사용해보았다면, 아래와 같이 데코레이터를 사용해 간단히 유효성 검사를 위한 Class를 만들 수 있다.

import {
  IsEmail,
  MinLength,
  MaxLength,
} from 'class-validator';

export class UserDto {
  @MaxLength(15)
  name: string;
  
  @IsEmail()
  email:string;

  @MinLength(8)
  password: string;
}
DTO를 위해 class-validator를 사용한 예시

 

이외에도 Nest.js를 사용해보았다면, Module, Provider, Controller를 만들 때도 데코레이터를 활용한다.

 

그래서, 데코레이터는 무엇일까?

여러 기술 아티클과 공식문서를 참고하며 실습을 해보았다. 말 그대로, '함수를 꾸며주는 역할을 한다'. 데코레이터는 클래스(class), 메서드(method), 접근자(accessor), 속성(property), 매개변수(parameter)에 적용이 가능하다.

 

각각의 타입은 다음과 같다.

declare type ClassDecorator = <TFunction extends Function>(target: TFunction) => TFunction | void;

declare type PropertyDecorator = (target: Object, propertyKey: string | symbol) => void;

declare type MethodDecorator = <T>(target: Object, propertyKey: string | symbol, descriptor: TypedPropertyDescriptor<T>) => TypedPropertyDescriptor<T> | void;

declare type ParameterDecorator = (target: Object, propertyKey: string | symbol, parameterIndex: number) => void;

 

공식문서의 예시처럼 보통 다음과 같이 사용하며, 데코레이터 팩토리의 형태로도 활용할 수 있다.

function color(value: string) {
  // 데코레이터 함수를 반환하는 color 데코레이터 팩토리
  return function (target, propertyKey, ...) {
    // 데코레이터 함수: target과 value를 사용하여 원하는 동작을 수행한다.
  };
}

 

공식 문서에 들어가도 꽤 많은 예시들이 있지만 굉장히 짧아서 어디서 어떻게 사용해야 할지 잘 모르겠다.

 


직접 class-validator를 (최대한 비슷하게) 구현해보자

실습 깃헙 레포: https://github.com/somedaycode/decorator-validation

- 필요하다면 위의 레포를 참고해서 환경설정을 하자!

 

1. 프로젝트 환경설정

먼저 타입스크립트 프로젝트를 만들고 prettier, eslint 등 필요한 환경설정을 추가해준다.

// package.json
{
  "name": "decorator-validation",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "scripts": {
    "start": "tsc-watch --onSuccess \"node ./dist/index.js\""
  },
  "devDependencies": {
    "@typescript-eslint/eslint-plugin": "^5.21.0",
    "@typescript-eslint/parser": "^5.21.0",
    "eslint": "^8.14.0",
    "eslint-config-prettier": "^8.5.0",
    "eslint-plugin-prettier": "^4.0.0",
    "prettier": "^2.6.2",
    "ts-node": "^10.7.0",
    "tsc-watch": "^5.0.3",
    "typescript": "^4.6.3"
  }
}

 

2. tsconfig 설정에서 잊지 말아 할 것이 있는데, 데코레이터는 아직 실험단계이기 때문에 다음과 같이 설정해주어야 한다.

// tsconfig.json
"experimentalDecorators": true,

 

만들고자 하는 Validation Decorator는 다음과 같다.

// index.ts
class Person {
  @MinLength(2) // name은 길이가 2이상 이어야함
  public name: string;
}

 

3. @MinLength 데코레이터를 만들어보자

// 데코레이터 팩토리로부터 들어오는 min 값을 value과 비교한다
export function isMinLength(value: unknown, min: number): boolean {
  return typeof value === 'string' && value.length >= min;
}

// 데코레이터 팩토리 및 데코레이터 함수
export function MinLength(min: number) {
  return function (target: any, propertyKey: string): any {
    let value = target[propertyKey];

    function validate() {
      if (!isMinLength(value, min)) {
        throw Error()
      }
      return value;
    }

    function setter(newVal: number) {
      value = newVal;
    }

    // getter 및 setter를 추가해준다
    return {
      get: validate,
      set: setter,
    };
  };
}

 

인스턴스 생성 후, 이름을 두 글자보다 낮게 할당하고 접근하면 에러가 난다.

// index.ts
const main = () => {
  const somedaycode = new Person();
  somedaycode.name = 'Q';

  // 접근
  console.log(somedaycode.name); // fail
};

main();

-----------------

Error: 에러 발생!
    at Person.validate [as name] (../dist/decorator/string/minLength.js:13:23)

 

뭔가 조금 아쉽다.

접근하는 함수를 Validator라는 이름으로 만들어본다.

// 말 그대로 해당 클래스의 속성에 접근하기만 한다.
export const validator = (target: Record<string, any>) => {
  for (const key in target) {
    target[key];
  }
};

 

추가적으로 @IsMinLength 데코레이터를 만든 것처럼 @Max도 만들어 주었다.

https://github.com/somedaycode/decorator-validation/blob/main/src/decorator/number/max.ts

 

이어서 방금 만든 validator를 index.ts에 추가한다.

class Person {
  @MinLength(2)
  public name: string;

  @Max(10)
  public age: number;
}

const main = () => {
  const somedaycode = new Person();
  somedaycode.name = 'Q';
  somedaycode.age = 14;

  validator(somedaycode); // fail: name에 접근시 error를 던진다

};

main();

---

Error: 에러 발생!
    at Person.validate [as name] (../dist/decorator/string/minLength.js:13:23)

 

여기서 아쉬운 점 : 에러가 somedaycode.name에 접근하는 순간 에러가 발생하기 때문에 그 뒤에 있는 age는 에러를 던지는지 판단할 수 없다.

 

여기서 어떻게 하면 1개 이상의 에러들을 모두 관리하고 한 번에 볼 수 있을까 고민했다.

 

Error를 관리하는 Store를 만들어줘서 Error 로그들을 담고 있는 store를 throw 하기로 결정했다.

log만 찍게 되면 traspile 단계에서는 error를 잡지 못하고 runtime까지 오게 된다.

export class ErrorLogStore {
  private store: any[] = [];

  // Error log가 길어서 앞의 두 줄만 뽑아쓴다.
  private static parseError(errLog: string) {
    const error = new Error(errLog);
    const [message, path]: string[] = error.stack?.split('\n', 2) || [];
    if (message && path) return { message, path };
  }

  // error가 담긴 store를 throw
  public throwIfHasErrorLogs() {
    if (this.store.length > 0) throw this.store;
    return console.log('No Errors found');
  }

  public addErrorLog(errLog: string) {
    this.store.push(ErrorLogStore.parseError(errLog));
  }
}

 

데코레이터 함수 내부의 getter를 대신하는 validate 함수도 ErrorLogStore를 활용하여 수정해준다.

function validate() {
  if (!isMinLength(value, min)) {
    // log를 담는다.
    return errorLogStore.addErrorLog(
      `failed minLength Validation : ${min}`
    );
  }
  return value;
}

 

 

 

아주 간단하게 실습한 최종 코드

https://github.com/somedaycode/decorator-validation

class Person {
  @MinLength(2)
  public name: string;

  @Max(10)
  public age: number;
}

export const errorLogStore = new ErrorLogStore();

const main = () => {
  const somedaycode = new Person();
  somedaycode.name = 'Q';
  somedaycode.age = 14;

  validator(somedaycode);

  errorLogStore.throwIfHasErrorLogs(); // throw errors

  console.log('출력이 되나요?'); // 출력 안됨 (throw 하지 않고 log만 찍었다면 출력 됨)
};

main();

-----

../dist/errorStore/index.js:17
            throw this.store;
            ^
[
  {
    message: 'Error: failed minLength Validation : 2',
    path: '    at Function.parseError (../dist/errorStore/index.js:10:23)'
  },
  {
    message: 'Error: failed Max Validation : 10',
    path: '    at Function.parseError (../dist/errorStore/index.js:10:23)'
  }
]

 

마무리

class-validator와 Nest.js의 데코레이터 동작 방식이 궁금해서 어찌어찌 직접 구현해보겠다고 학습하게 됐다. class-validator의 source도 열심히 뜯어보고 이해하기 위해 노력했는데, 데코레이터 팩토리를 이용한 데코레이터가 4번 이상이 중첩되어 있다 보니 이해하는 것이 쉽지 않았다.

 

또, 런타임에 데코레이터가 호출된 이후, getter, setter에 새로운 함수를 만들어서 새로운 값을 할당하고 접근하고 있는데 class-validator는 reflect-metadata라는 것을 활용하는 것으로 보인다.

 

나도 transpile 된 이후의 index.js에서 볼 수 있는 metadata를 활용해서 데코레이터가 가지는 값들을 MetadataStore에 저장해서 유효성 검사(validation)를 하려 했지만 잘 안되어서 기존에 원하는 방식과는 다르게 구현하게 되었다.

 

시간 날 때, class-validator 코드랑 reflect-metadata를 활용하는 방법을 좀 찾아봐야겠다.

 

좋은 참고자료나 더 쉽게 구현할 수 있는 방법이 있다면 알려주시면 감사하겠습니다!

 


 

참고자료: