Typescript의 클래스 데코레이터에서 프로퍼티의 런타임 타입 검증하기

Jaden Kim·2024년 6월 1일
0
post-thumbnail

데코레이터를 클래스에 적용하면 클래스에 Reflect 메타 데이터를 지정하거나, prototype에 메서드를 추가로 삽입하여 클래스의 기능을 확장할 수 있다.
이 때 기능을 확장하는 과정에서 클래스의 인스턴스가 특정 타입의 멤버 변수를 가져야 하는 제약이 있을 수 있다.

검증하는 방법(결론)

이 경우에는 데코레이터가 반환하는 확장된 클래스 내에서 검증을 수행하도록 구성하면 된다.
아래 예제에서는 target의 멤버 변수인 name의 런타임 타입이 string 으로 제한된다.

export const Validated =
  () =>
  <T extends { new (...args: any[]): any }>(target: T, _) => {
    return class extends target {
      constructor(...args: any[]) {
        super(...args);
        if (typeof this.name !== "string")
          throw new Error("name must be a string");
      }
    };
  };

@Validated()
class MeetingRoom {
  name: any;

  constructor(name: any) {
    this.name = name;
  }
}

new MeetingRoom(1); // 예외 발생

코드의 동작 방식 살펴보기

위 코드의 동작 방식을 하나씩 살펴보면서, 클래스 데코레이터의 동작 방식을 이해해보자.

커링 메서드

먼저 위 코드에서 데코레이터 Validated는 함수를 반환하는 커링 함수로 구현되었다.
보통 데코레이터는 커링을 사용하여 구현되어, 데코레이터를 적용하는 시점에 원하는 옵션을 전달할 수 있게 한다.
정확하게 말하자면 Validated는 데코레이터를 반환하는 커링 함수이고, 여기서 반환한 메서드가 실질적으로 클래스를 확장하는 데코레이터 역할을 수행한다.
원하는 위치에서 해당 커링 메서드 앞에 @를 붙이고 호출하여, 반환된 데코레이터가 클래스에 적용되도록 한다.

다음 예제의 데코레이터는 옵션으로 name을 받아서, 클래스가 프로퍼티로 해당 name 값을 가지도록 제한한다.
클래스 정의의 바로 위에서 @Validated({ name: "Jaden" }) 로 데코레이터를 적용하여, name이 Jaden이 되도록 제한했다.

export const Validated =
  (options: { name: string }) =>
  <T extends { new (...args: any[]): any }>(target: T, _) => {
    return class extends target {
      constructor(...args: any[]) {
        super(...args);
        if (this.name !== options.name)
          throw new Error(`name must be ${options.name}`);
      }
    };
  };

@Validated({ name: "Jaden" })
class Student {
  name: string;

  constructor(name: string) {
    this.name = name;
  }
}

new Student("Tom"); // Error: name must be Jaden

데코레이터의 target, 제네릭 한정자를 통한 target의 타입 제한

이제 본격적으로 데코레이터를 살펴보자.
데코레이터는 첫번째 매개변수로 데코레이터를 적용하는 대상인 target을 받는다.
이 때 데코레이터는 클래스, 메서드, 프로퍼티, 매개변수 등 다양한 위치에 사용될 수 있는데, 적용 가능한 대상을 제한하기 위해서는 제네릭 한정자를 통해 타입을 제한해야 한다.

데코레이터를 클래스에만 사용할 수 있도록 제한하기 위해서는, 데코레이터의 제네릭 한정자를 <T extends { new (...args: any[]): any }> 로 지정하면 된다.
이는 T가 생성자 키워드를 가져야 함을 제약하여, 클래스에만 데코레이터를 적용할 수 있도록 한다.
실제로 위에서 정의한 Validated를 프로퍼티에 적용하면 type error가 발생한다.

class Student {
  @Validated({ name: "Jaden" }) // Argument of type 'undefined' is not assignable to parameter of type 'new (...args: any[]) => any'.
  name: string;

  constructor(name: string) {
    this.name = name;
  }
}

데코레이터 로직의 실행 순서

이제 데코레이터 로직이 실행되는 순서를 살펴보자.
위와 같이 커링 형태로 데코레이터를 구현한 경우, 먼저 전체 선언부가 읽히는 과정에서 커링 메서드가 실행되어 데코레이터가 반환된다.
그리고 데코레이터를 적용한 target 클래스가 정의될 때 해당 데코레이터 함수가 실행된다.
이 때 데코레이터 함수는 target의 확장된 클래스를 반환하여, 해당 클래스가 Student 클래스를 대체하게 된다.
즉 new 키워드를 통해 Student 클래스의 생성자를 호출하면, 데코레이터가 반환한 클래스의 생성자가 대신 호출된다.

코드 상에서 순서를 확인하기 위한 로그를 추가하고 결과를 살펴보자.
먼저 커링 메서드가 호출되고, 클래스 선언 시점에 데코레이터가 호출되며, 인스턴스 생성 시점에 확장된 클래스의 생성자가 호출된다.

export const Validated =
  () =>
  <T extends { new (...args: any[]): any }>(target: T, _) => {
    console.log("[2] return extended class");
    return class extends target {
      constructor(...args: any[]) {
        console.log("[4] extended class constructor called");
        super(...args);
      }
    };
  };

console.log("[1] currying method called");
@Validated()
class Student {
  constructor() {
    console.log("[5] target class constructor(super) called");
  }
}

console.log("[3] instance create(construrtor called)");
new Student();
출력 결과
[1] currying method called
[2] return extended class
[3] instance create(construrtor called)
[4] extended class constructor called
[5] target class constructor(super) called

주의할 점

클래스 데코레이터를 사용할 때, 데코레이터는 해당 클래스의 선언을 대체하는 역할로 실행된다는 점을 기억해야 한다.
선언 시점에 실행되는 것이기 때문에, 클래스 인스턴스의 필드에 접근하여 런타임 타입을 검증하는 등의 작업은 불가능하다.

export const Validated = () => (target: any, _) => {
  console.log(target.name);
  // target.name은 클래스의 이름인 'Student', 검증 통과
  if (typeof target.name !== "string") {
    throw new Error("name must be a string");
  }
};

@Validated()
class Student {
  name: any;

  constructor(name: any) {
    this.name = name;
  }
}

new Student(123); // 에러를 발생시키지 않음

따라서 런타임 타입을 검증하기 위해서는 데코레이터에서 반환하는 확장된 클래스의 생성자 내에서 검증을 수행해야 한다.
이 점이 반영되어 최종적인 클래스 검증 데코레이터가 완성되었다.

export const Validated =
  () =>
  <T extends { new (...args: any[]): any }>(target: T, _) => {
    return class extends target {
      constructor(...args: any[]) {
        super(...args);
        if (typeof this.name !== "string")
          throw new Error("name must be a string");
      }
    };
  };

참고

관련해서 작업했던 오픈소스 PR

0개의 댓글