Typescript Decorator - 타입스크립트 데코레이터

2023. 3. 22. 15:35코딩

TypeScript Decorator는 TypeScript의 핵심 기능 중 하나로, 클래스, 메서드, 프로퍼티 등에 메타데이터를 추가하고 동적으로 동작을 변경하는 방법을 제공합니다. Decorator는 앵귤러 프레임워크의 핵심 개념 중 하나이기도 하며, TypeScript를 사용하는 개발자들에게 꼭 알아둬야 할 중요한 개념입니다.

이번 블로그 글에서는 TypeScript Decorator가 무엇인지, 어떻게 사용하는지, 그리고 어떤 실제적인 예시들이 있는지 등에 대해 알아보도록 하겠습니다. 또한, Decorator를 사용하는 이점과 함께 어떻게 좀 더 유연하게 코드를 작성할 수 있는지 등을 다룰 예정입니다. 따라서 TypeScript를 사용하는 개발자라면 Decorator를 알아두면 매우 유용할 것입니다.

  • 데코레이터
  • 데코레이터 팩토리
  • 데코레이터 합성
  • 데코레이터 평가
  • 클래스 데코레이터
  • 메서드 데코레이터
  • 접근자 데코레이터
  • 프로퍼티 데코레이터
  • 매개변수 데코레이터
  • 메타데이터

데코레이터

데코레이터는 클래스 선언, Method, Accessor, property, parameter에
첨부할 수 있는 특수한 종류의 선언입니다.

  • @Expression 형식 [(at icon) + Function name]을 사용합니다.

데코레이터 팩토리

데코레이터가 선언에 적용되는 방식을 원하는대로 바꾸고 싶을 때..
그럴 때 작성하는 것으로 런타임에 호출할 표현식을 반환하는 함수 입니다.

function Apple(value: string) { //데코레이터 팩토리
  return function (target) { //데코레이터
    // 위에서 가져온 value와 target으로 어떠한 작업을 수행
  }
}

데코레이터 합성

  • 단일행 :
      @A @B function
  • 다중행 :
      @A
      @B
      function

위에서 선언한 @A @B function은 A(B(function))과 같습니다.

  1. 위에서 아래로 평가하며,
     A call
     B call
  2. 아래에서 위로 함수를 호출합니다.
     B Complate
     A Complate

클래스 데코레이터 (Class Decorator)

클래스 선언 직전에 선언 합니다.
클래스 정의를 감시, 수정, 교체 하는데 사용 가능합니다.

  • 선언 파일이나 주변 컨텍스트에서 사용할 수 없습니다.
    • 컨텍스트 (Context) : 예) 선언 클래스

클래스 데코레이터는 적용하는 클래스의 생성자를 유일한 인수로 받습니다.

  • constructor

클래스 데코레이터가 값을 반환하면 생성자 함수로 컨버팅 됩니다.

예제 Class

@classDecorator
class sealed {
  data: string;
  constructor(message : string) {
    this.data = message;
  }

  method() {
    return "Hello, " + this.data;
  }
}

예제 Decorator

function classDecorator(constructor: Function) {
  Object.seal(constructor);
  Object.seal(constructor.prototype);
}
  • 예제에서는 Object.seal() 기능을 이용해서 객체를 밀봉하였습니다.
    • Object.seal() : 객체 밀봉
      • 새로운 속성 추가 x
      • 모든 속성을 설정 불가능 상태로 변경
      • [Object.freeze와 다른 점] 쓰기 가능한 속성의 값은 밀봉 후에도 변경 가능

메서드 데코레이터 (Method decorator)

메서드 선언 직전에 선언합니다.
메서드 정의를 감시, 수정, 교체 하는데 사용 가능합니다.

  • 선언 파일이나 주변 컨텍스트에서 사용할 수 없습니다.

클래스 데코레이터와 달리 런타임에 세 개의 인수와 함께 함수로 호출됩니다.

  1. 정적 멤버에 대한 클래스의 생성자 함수 또는 인스턴스 멤버에 대한 클래스의 프로토타입
  2. 멤버의 이름
  3. 멤버의 프로퍼티 설명자 (property descriptor)

메서드 데코레이터가 값을 반환하면, 메서드의 프로퍼티 설명자로 사용됩니다.

예제 Class

class example {
    a: string = "Hello";
    get b(): string {
        return `${this.a} World`;
    }
    @decorator
    method(c: string): void {
        console.log(c);
    }
}

예제 Decorator

function decorator() {
    return function (
        target: any,
        propertyKey: string, 
        descriptor: propertyDescriptor
    ) {
        console.log(target); // {b: [Getter], method: [Function (anonymous)]}
        console.log(propertyKey); // d
        console.log(descriptor); 
        /**
         * {
         *  value: [Function (anonymous)]
         *  writable: true,
         *  enumerable: true,
         *  configurable: true
         * }
         */ 
    }
}

@decorator 가 실행되면서 예제 코드의 console 결과가 나오는 것입니다.

@decorator를 좀 바꿔본다면

function decorator ( target: any, propertyKey: string, descriptor: PropertyDescriptor): void {
    const method = descriptor.value;
    descriptor.value = function () {
        try {
            method();   
        } catch (error) {
            console.log("error 핸들링 로직");
        }
    }
}

이렇게 바꾸고 반영해서 실행한다면!!

예제 class 중

...
@decorator
method(c: string): void {
    console.log(c);
    throw new Error();
}
...
//데코레이터
...
new example().method("오..");

console

오..
error 핸들링 로직

이라는 결과가 나온다.


접근자 데코레이터 (Accessor Decorator)

접근자 선언 직전에 선언
접근자 정의를 감시, 수정, 교체 하는데 사용 가능합니다.

  • 문서 순서대로 지정한 첫 번째 접근자에 적용해야 합니다.
    • 각각의 선언이 아닌 결합한 property descriptor에 적용되기 때문입니다.

메서드 데코레이터와 같이 세개의 인수와 함께 함수로 호출합니다.
접근자 데코레이터가 값을 반환하면 프로퍼티 설명자로 사용됩니다.

예제 class (typescript handbook 中)

class Point {
    private _x: number;
    private _y: number;

    constructor(x: number, y: number, public size: string = "100") {
        this._x = x;
        this._y = y;
    }

    @configurable(false)
    get x() { return this._x }

    @configurable(false)
    get y() { return this._x }
}

예제 decorator

function configurable(value: boolean) {
    return function (target: any, propertyKey: string, descriptor: PropertyDescriptor) {
        descriptor.configurable = value;
    }
}
// point 객체 인스턴스 생성
const point = new Point(100, 150);

// 속성 제거 가능
delete point.size;

// [오류] delete 연산자의 피연산자는 읽기 전용 속성일 수 없습니다.
delete point.x;

이 코드에서 생성된 객체 인스턴스의 접근 제어자 속성을 제거하려 시도하면 오류가 발생합니다.

메서드 데코레이터와 동일하나 decription의 configurable 속성을 바꿉니다.


프로퍼티 데코레이터 (Property decorator)

프로퍼티 선언 직전에 선언합니다.

앞선 메서드 & 접근자 데코레이터와 달리 두 개의 인수와 함께 함수로 호출합니다.

  1. 정적 멤버에 대한 클래스의 생성자 함수 또는 인스턴스 멤버에 대한 클래스의 프로토타입
  2. 멤버의 이름

예제 class

class Car {
    private name: string;
    private price: number;
    private type: string;

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

    public toString() {
        return `${this.name}, ${this.type}, ${this.price}`
    }
}

객체를 주입할 때 사용할 간단한 컨테이너를 정의하고 객체를 넣어둡니다.

class Container {
    private static map: {[key: string]: any} = {};

    static add(key: string, value: string) {
        Container.map[key] = value;
    }

    static get(key: string): string {
        return Container.map[key];
    }
}

Container.add('myType', 'Classic');
console.log(Container.get('myType')); // 'Classic'

이제 예제에서 사용할 데코레이터를 정의합니다.
예제 decorator

function Inject(param: string) {
    return function (target: any, propertyKey: string) {
        console.log(target); // {toString: f, constructor: f}
        console.log(propertyKey); // type
        target[propertyKey] = Container.get(param); //target 오브젝트에 property 값 할당
    }
}

이렇게 정의한 데코레이터는 클래스를 정의할 때 사용할 수 있습니다.

class Car {
    private name: string;
    private price: number;
    @Inject('myType') //값 주입, 위에서 이미 myType에 Classic이라는 값을 넣어놨음
    private type: string;
}

let myCar = new Car('AMG GT', 15000); // type말고 나머지 name과 price에 값 할당
console.log(myCar.toString()); // AMG GT, Classic, 15000

여기서 클래스가 정의될 때 데코레이터 함수가 실행되어 클래스의 프로퍼티 값이 지정되기 때문에 클래스를 위와 같이 정의하면 컨테이너의 값을 수정해도 처음 값이 그대로 출력됩니다.

Container.add('myType', 'Custom');
let myCar = new Car('918 Spider', 150000);
console.log(myCar.toString()); // 918Spider, Classic, 150000

그래서 Car 클래스의 Type property가 값이 아닌 함수를 가지고 있게 수정 하고, 값이 아닌 함수를 return 하게 Inject function을 수정하게 된다면 수정된 컨테이너의 값이 반영 됩니다.

function Inject(param: string) {
    ...
    tartget[propertyKey] = () => Container.get(param); //값이 아닌 함수를 리턴
}
class Car {
    ...
    @Inject('myType')
    private type: Function; //'type'은 값이 아니라 함수
    ...
}
Container.add('myType', 'Custom');
let myCar = new Car('918 Spider', 150000);
console.log(myCar.toString()); // 918Spider, Custom, 150000

매개변수 데코레이터 (Parameter decorator)

매개 변수 선언 직전에 선언 합니다.
세 개의 인수와 함께 함수로 호출 합니다.

  1. 정적 멤버에 대한 클래스의 생성자 함수 또는 인스턴스 멤버에 대한 클래스의 프로토타입
  2. 멤버의 이름
  3. 함수 매개 변수 목록에 있는 서수 색인 (Ordinal index)

매개 변수 데코레이터의 반환값은 무시 됩니다.

정리하면 파라미터 데코레이터는 옵저빙(감시), 값 변경이 안되기에 metadata를 정의할 때 사용합니다. 그렇기에 reflect metadata랑 같이 사용합니다.

데코레이터 호출 순

function cd() {
    console.log('class');
    ...
}

function md() {
    console.log('method');
    ...
}

function prod() {
    console.log('property');
    ...
}

function paramd() {
    console.log(parameter);
    ...
}

@cd()
class example {
    @prod()
    property = "property";

    @md()
    test(
        @paramd() param: string
    ) {
        console.log('test');
    }
}

결과는

property
method
parameter
class

순으로 데코레이터 호출 순서는

Property decorator =>
Method decorator =>
Parameter decorator =>
Class decorator 입니다.


Annotation vs Decorator

자바 어노테이션과 다른 점

문법적으로 봤을 때 java의 annotation과 큰 차이는 없습니다. 다만 차이점이 있다면
decorator는 runtime에서만 역할을 한다는 점 입니다.

java의 annotation은 Retention이라는 게 있어 compiler에게만 보이고, runtime에는 없어지게 하거나 runtime에도 살아남아서 jvm에 의해 참조할 수 있게 할수도 있습니다.

  • @Retention : 어느 시점까지 어노테이션의 메모리를 가져갈 지 설정합니다.
    • 어노테이션의 라이프 사이클
  • Jvm (Java Virtual Machine) : Java 컴파일러가 bytecode로 변환한 것을 os가 이해해줄 수 있도록 해석해주는 것

typescript에서는 정적인 타입이 없기 때문에 애초에 compile time에 기능을 할 수 없습니다. 이런 측면에서 runtime에서만 활용할 수 있는 annotation이라고 볼 수 있습니다.

  • Compile Time : 개발자에 의해 작성된 소스코드를 컴퓨터가 인식할 수 있는 기계어 코드로 변환되어 실행 가능한 프로그램이 되는 과정을 의미
  • Run Time : 컴파일 과정을 마친 응용 프로그램이 사용자에 의해서 실행되는 때를 의미

메타데이터 Metadata

  • 데이터의 데이터

Reflection

동일한 시스템(또는 그 자체)의 다른 코드를 검사할 수 있는 코드를 설명하는데 사용됩니다.

Typescript에서의 reflection API

Typescript에서는 reflect-metadata 패키지를 사용하여 메타데이터 리플렉션 API를 사용할 수 있습니다.

npm install reflect-metadata

Typescript의 tsconfig.json 파일의 emitDecoratorMetadat를 true로 설정해야 합니다.
이제 프로퍼티 데코레이터로 한번 적용 해본다면..

예제 decorator

function logtype(target: any, key: string) {
    let t = Reflect.getMetadata("design:type", target, key);
    console.log(`${key} type : ${t.name}`);
}

그리고 클래스에 적용한다면

class izone {
    @logType
    public wizone: string;
}

이제 이렇게 작성한 예제 코드를 실행 한다면

// console
wizone type: String

파라미터 데코레이터에 적용 해봅시당

function logParamTypes(target: any, key: string) {
    let types = Reflect.getMetadata("design:paramtypes", target, key);
    let s = types.map(a => a.name).join();
    console.log(`${key} param types: ${s}`)
}

이제 클래스의 메서드 중 하나에 이 함수를 적용해보면

class Ive {}
interface Dive {}

class Lovedive{
  @logParamTypes
  narcissistic(
    yujin: string,
    gaeul: number,
    rei: Dive,
    wonyoung: Ive,
    liz: Function,
    leeseo: { baby: string },
  ): number {
    return 1
  }
}

이제 위 예제를 실행하게 되면

//console
narcissistic param types : string, number, Object, Ive, Function, Object

design: returntype 이라는 메타 데이터 키를 사용하여 메서드의 반환 유형에 대한 정보를 얻을 수도 있습니다.

메타데이터 활용 예제

  • 메소드, 파라미터 데코레이터에서 메타데이터를 이용한 valdation 예제

    메서드 데코레이터

    1. 받은 정보들을 토대로 메타데이터에서 가져옵니다.
    1. 가져온 데이터가 있다면, 해당 아이템의 유형에 따라 정규식 변경
    1. 정규식 test를 통해 성공 여부를 아이템의 status에 저장
function validateDecorator(
  target: any,
  propertyName: string,
  descriptor: TypedPropertyDescriptor<any>
) {
  let method = descriptor.value;
  descriptor.value = function () {
    let capitalParameters: number[] = Reflect.getOwnMetadata(
      checkCapitalMetadataKey,
      target,
      propertyName
    ); //metadata에서
    // CheckValidate Symbol metadata 키를 가지고,
    // 멤버에 대한 생성자 함수, 프로토타입 타겟에
    // checkValidate라는 property 키를 가진 애를 가져오기~
    if (capitalParameters) {
      // metadata에 저장이 되어있으면 ?
      for (let parameterIndex of capitalParameters) {
        //루프를 돌려~
        let regex_text = ""; // 정규식 문자 text
        switch (arguments[parameterIndex].type) {
          case validType.English:
            regex_text = regex.English;
            break;
          case validType.Korean:
            regex_text = regex.Korean;
            break;
          case validType.Number:
            regex_text = regex.Number;
            break;
          case validType.Email:
            regex_text = regex.Email;
            break;
          case validType.Password:
            regex_text = regex.Password;
            break;
          default:
            break;
        }
        const reg = new RegExp(regex_text); // 정규식 test용 객체 생성
        arguments[parameterIndex].status = reg.test(
          arguments[parameterIndex].value
        ); //정규식 성공 여부를 해당 아이템 status에 설정
      }
    }
    return method!.apply(this, arguments); //함수에 단일 배열 전달~
  };
}

파라미터 데코레이터

    1. 파라미터에 연결함
    1. existingCapitalParameters에
    • 메타데이터에 배열이 존재한다면 해당 배열
    • 없다면 빈 배열
    1. 위에서 가져온 배열에 매개변수 인덱스 담아주기
    1. metadata 정의
function parameterDecorator(
  target: any,
  propertyKey: string | symbol,
  parameterIndex: number
) {
  let existingCapitalParameters: number[] =
    Reflect.getOwnMetadata(checkCapitalMetadataKey, target, propertyKey) || [];
  existingCapitalParameters.push(parameterIndex); //metadata에서 가져온 배열에 해당 매개변수 인덱스를 담아주기
  Reflect.defineMetadata(
    checkCapitalMetadataKey,
    existingCapitalParameters,
    target,
    propertyKey
  );
  // 데코레이터 내부에 metadata 정의
}

적용 부분

  @validateDecorator
  checkValidate(@parameterDecorator item: validItem): void {
    console.log(item);
    // {
    //     "id": 4,
    //     "type": "Email",
    //     "value": "fdsafd@fdsafd.com",
    //     "status": true,
    //     "errMsg": "이메일 형식 (ex : email@email.com)"
    // }
  }