타입스크립트 꽝꽝

[TypeScript] Decorator에 관하여

bimppap 2022. 2. 10. 18:02

 

서론

스프링에서 어노테이션을 통해 AOP를 적용했던 기억이 있어 타입스크립트에서도 비슷하게 적용할 수 있는지 궁금해졌다.

사실 타입스크립트로 로또 뽑기를 구현하다가 try-catch가 너무 반복되는게 신경 쓰여서 AOP를 쓰려고 한건데,

결론적으로 적용은 못하고(ㅋㅋ) 공부만 했다. 나중에 써먹을 데가 있겠지...🥲

 

각설하고 AOP를 타입스크립트에 적용하기 위해 찾아서 알게 된 건 Decorator 였다.

타입 스크립트 공식 문서에선 Decorator를 이렇게 소개한다.

 

데코레이터는...

- 클래스 선언과 멤버에 어노테이션과 메타-프로그래밍 구문을 추가할 수 있는 방법을 제공한다.

- 클래스 선언, 메서드, 접근자, 프로퍼티 또는 매개 변수에 첨부할 수 있는 특수한 종류의 선언이다.

- @expression 형식을 사용하며, 여기서 expression은 데코레이팅 된 선언에 대한 정보와 함께 런타임에 호출되는 함수여야 한다.

 

쉽게 말하자면 아래처럼 쓸 수 있는 거다.

// 데코레이터들은 클래스와 클래스 내 메서드, 프로퍼티, 접근자, 매개 변수에 한해 적용할 수 있다고 생각하면 편하다

@classDecorator
class DecoratedClass {
  @propertyDeorator
  private _decoratedProperty: string;
  
  @methodDecorator
  decoratedMethod(@parameterDecorator param: string) {
    // do something
  }
  
  @accessorDecorator
  get decoratedProperty() {
    return this._decoratedProperty;
  }
}

스프링 프레임워크를 사용해봤다면 이 코드가 좀 더 와닿을 수도 있겠다.

@Controller("api")
export class UsersController {
  @Get("users")
  getAllUsers(): User[] {
    // get all user
  }

  @Get("users/:id")
  getUser(@Param("id") id: string): User {
    // get sepcific user by id
  }

  @Post("users")
  createUser(@Body() body: CreateUserInput): User {
    // make user with user info input
  }
}

 

데코레이터는 아직 실험적으로 지원 중인 거라 따로 커맨드 라인을 쓰거나 컴파일러 옵션을 활성화해야 한다.

compilerOptions-target은 ES5 이상이어야 한다.

나중에 다시 설명하겠지만, 데코레이터를 만들 때 인자로 쓰이는 PropertyDescriptor는 ES5부터 생겨났기 때문이다.

(이전 버전에서는 PropertyDescriptor를 타입으로 가진 변수는 undefined가 뜬다.)

 

- 커맨드 라인

tsc --target ES5 --experimetalDecorators

- tsconfig.json:

{
  "compilerOptions": {
    "target": "ES5",
    "experimentalDecorators": true
  }
}

 

본론

처음엔 대충 훑어보고 아 그렇구나~😮 하고 바로 적용해보려고 했다.

하지만 대충해서 잘 되는 코딩은 늘 그렇듯이... 없었다 😇

그래서 기왕 하는 거 좀 더 파헤쳐보기로 했다!

 

데코레이터 시그니쳐

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: Function, propertyKey: string | symbol, parameterIndex: number) => void;
// 접근자 데코레이터의 경우는 메서드 데코레이터와 시그니쳐가 같다 :3

참고로 eslint를 쓰고 있다면(prettier 컨벤션 기준) 데코레이터를 만들 경우 매개 변수 타입 단에서 아래와 같은 경고를 마주할 수 있다.

데코레이터 사용이 불가피하다면 suppress를 하자.

 

데코레이터 합성

아래 예제와 같이 하나의 선언에 여러 데코레이터를 적용할 수 있다.

// 단일 행
@f @g x: string

// 다수의 행
@f
@g
x: string

 수학의 합성 함수 (f(g(x))와 유사하게 표현은 f -> g 순으로 되어있지만, 호출은 g -> f 순으로 간다.

 

데코레이터 팩토리

데코레이터가 선언에 적용되는 방식, 즉 런타임에 호출할 표현식을 바꿀 때 사용한다.

아래 예시를 보자.

// 데코레이터 팩토리를 사용하면 아래 두 메서드처럼 데코레이터 표현식을 바꿀 수 있다.
function decorateFunctionWithFactory() {
  return function (target: Object, methodName: string, propertyDescriptor: PropertyDescriptor) {
    console.log(`${methodName} is decorated!!`);
  };
}

function decorateFunctionWithFactory2(value: string) {
  return function (target: Object, methodName: string, propertyDescriptor: PropertyDescriptor) {
    console.log(`${methodName} is decorated!!`);
    console.log(`given value is ${value}!!`);
  };
}

function decorateFunction(target: Object, methodName: string, propertyDescriptor: PropertyDescriptor) {
  console.log(`${methodName} is decorated!!`);
}

// 데코레이터 사용하기
@decorateFunctionWithFactory
function doSomething() {
  // code here
}

@decorateFunctionWithFactory2('hello')
function doSomethingFun() {
  // code here
}

// 이 경우 시그니쳐 성립이 안 된다는 경고가 뜬다
@decorateFunction()
function doSomethingStupid() {
  // code here
}

 

클래스 데코레이터

// 클래스 데코레이터
function decorateClass(target: Function) {
  Object.seal(target);
  console.log(`${target.name} is sealed!!`);
}

@decorateClass
class SomeClass {
 // some code blah blah
}

// console
// > SomeCalss is sealed!!

 

매개 변수 target은 Function 타입으로, 새로운 클래스의 생성자가 인자로 들어온다. 클래스 데코레이터는 자신이 적용되는 클래스를 extend하여 로깅 등의 원하는 기능을 추가할 수 있다.

Function 타입 변수에서 제공하는 메소드와 프로퍼티들

예시로 쓰인 Object.seal은 해당 객체를 봉인하여 새 프로퍼티를 추가하거나 기존 프로퍼티를 변경/삭제하는 걸 방지한다.

 

메서드 데코레이터

// 메서드 데코레이터(feat.데코레이터 합성)
function decorate() {
  return function decorateFunction(target: any, methodName: string, propertyDescriptor: PropertyDescriptor) {
    console.log(`${methodName} is decorated!!`);
  }
}

class SomeClass {
  // ...
  
  @decorate()
  function someMethod() {
    // some code
  }
}

// console
// >someMethod is decorated!!

메서드 데코레이터는 아래 세 개를 매개 변수로 받는다.

- target: static 메서드면 클래스 생성자(Function), 인스턴스 메서드면 클래스의 prototype 객체(Object)

- 메서드명(string)

- propertyDescriptor(PropertyDescriptor)

 

propertyDescriptor는 ES5에서 처음 생긴 스펙으로 , 메서드 데코레이터는 자신이 적용되는 메서드의 property Descriptor를 수정해 메서드를 확장해나간다.

 

개인적으로 메서드 데코레이터를 만들려고 했을 때 난관을 많이 겪었다.

MS의 타입스크립트 깃헙 #2249 이슈에서는 ES7부터 메서드 데코레이터를 못 쓰게 한다고 했다.

하지만 난 es2017(ES8)를 쓰는 중인데 잘 됐다...😮

특정 구간에서만 못 쓰게 하는 건가 싶기도 하다. 공식 문서를 보면 메서드 데코레이터는 선언 파일, 오버로드 또는 기타 주변 건텍스트(예: 선언 클래스)에서 사용할 수 없다고 뜨기 때문이다. 사실 이것만 읽고 무슨 소린가 싶어서(;;) 여기저기 다 넣어본 결과 클래스 내에 있는 메소드면 데코레이터를 붙일 수 있다는 걸 알았다. (틀렸다면 지적 부탁드립니다...)

 

(+) 클래스 내 익명 메서드에도 넣을 수 있다! 다만 이 경우 프로퍼티 데코레이터 시그니쳐를 써야 하기에 데코레이터를 익명과 네임드 메서드에 골고루 적용해야 한다면 데코레이터 함수를 아래와 같이 변경하면 된다.  여길 참고했다.

function decorateFunction() {        
    return (target: Object, propertyKey: string, descriptor?: TypedPropertyDescriptor<any>) => {        
      // do something
    }
}

 

접근자 데코레이터

// 접근자 데코레이터
function decorateAccessor() {
  return function(target: any, propertyName: string, propertyDescriptor: PropertyDescriptor) {
    console.log(`${propertyName} is decorated!!`);
  }
}

class SomeClass {
  private readOnly _someProperty: string;
  
  // ...
  
  @decorateAccessor()
  get someProperty() {
    return this._someProperty;
  }
}

// console
// > _someProperty is decorated!!

접근자 데코레이터는 메서드 데코레이터와 같은 시그니쳐를 사용한다. getter와 setter에 적용할 수 있다.

다만 제약이 조금 있다. 하나의 데코레이터를 getter와 setter에 동시에 적용할 수 없다.

Given the fact that Property Descriptors contains both the setter and getter for a particular member, accessor decorators can only be applied to either the setter or the getter of a single member, not to both.
- How to Use Decorators in TypeScript

설명하자면, 데코레이터에 인자로 들어오는 Property Descriptors는 getter setter 둘 다 포함하고 있지만 각각의 접근자에 대한 Property Descriptors는 없다. 때문에 코드 순서상 먼저 오는 접근자에 한해서만 적용이 된다.

하지만 접근자에 데코레이터를 쓸 일이 생길까...? 🤔

 

프로퍼티 데코레이터

// 프로퍼티 데코레이터
function decorateProperty() {
  return function (target: any, propertyKey: string) {
    console.log(`${propertyKey} is decorated!!`);
  };
}

class SomeClass {
  @decorateProperty()
  private readOnly _someProperty: number;
}

// console
// > _someProperty is decorated!!

프로퍼티 데코레이터는 두 가지 인자를 받는다.

- target: static 메서드면 클래스 생성자(Function), 인스턴스 메서드면 클래스의 prototype 객체(Object)

- 프로퍼티명(string)

 

메서드 혹은 접근자 데코레이터와 다르게 PropertyDescriptor를 받지 않는다. 대신 Property Descriptor 형식의 객체를 return 하여 프로퍼티의 설정을 변경할 수 있다.

매개변수 데코레이터

// 매개 변수 데코레이터
function decorateParameter() {
  return function (target: Function, propertyName: string, parameterIndex: number) {
    console.log(`${propertyName} is decorated!! index is ${parameterIndex}`);
  };
}

class SomeClass {
  function(@decorateParameter someParameter: string) {
    // some code
  }
}

// console
// > someParmater is decorated!! index is 0

매개 변수 데코레이터는 아래의 세 개의 인자를 받는다.

- target: static 메서드면 클래스 생성자(Function), 인스턴스 메서드면 클래스의 prototype 객체(Object)

- 매개 변수명(string)

- 매개 변수 인덱스(number)

 

매개 변수 데코레이터의 경우, 메서드 데코레이터와 함께 사용되거나 metadata를 활용하여 커스텀하는 편이라고 한다.

메서드 데코레이터와 함께 사용하는 경우는 이 링크의 예시가 좋으니 참고하자.

메타데이터

메타데이터란 쉽게 말하자면 데이터에 대한 데이터다. 이렇게 말하면 뭔가 싶을 거다. 아래 예시를 보자.

function hello() {
  console.log("hello!");
}

이 hello 메소드에선 무엇을 얻을 수 있을까?

이렇게 콘솔에 찍힌 hello와 0은 메서드 hello의 속성 정보, 즉 메타데이터라고 볼 수 있다.

 

타입스크립트에서 메타데이터를 활용하려면 reflect-metadata 라이브러리를 추가하고 tsconfig.json에서 emitDecoratorMetadata 옵션을 설정해야 한다. reflect-metadata도 ES5 이후로 실험적으로 제공하는 API라 차후 변동이 생길 수도 있다 😔

// install reflect-metada library
npm i reflect-metadata --save

// add compiler option
tsc --target ES5 --emitDecoratorMetadata
{
  "compilerOptions": {
    "target": "ES5",
    "experimentalDecorators": true,
    "emitDecoratorMetadata": true
  }
}

메타데이터를 활용한 데코레이터는 이 링크나 여기를 참고하면 좋다.

 

여담

지금까지 타입스크립트에서 제공하는 Decorator의 다양한 적용 방법에 대해 알아보았다.

다 쓰고 보니 스프링 쓸 때도 커스텀 어노테이션을 많이 쓰지 않았는데 뻘하게 많이 찾아봤나 싶기도 하고...

그래도 Decorator는 TypeORM과 NestJS 등에서 많이 쓰인다고 하니 조만간 접하게 될 때 금방 이해할 수 있을 것 같다.

 

Decorator 외에도 타입스크립트에서의 AOP에 대해 좀 더 찾아보니 ts-aspect, AspectTS 같은 라이브러리도 있는 것 같다.

개인 취향에 맞게 가져다 쓰면 좋을 듯 하다!!

각 데코레이터들을 어떻게 적용하는지 간단하게 적어보았는데, 기회가 된다면 유용한 커스텀 데코레이터도 만들어서 소개해보도록 하겠다 :>

 

참고자료

 

타입스크립트의 Decorator 살펴보기 · Devlog

타입스크립트의 데코레이터를 사용하면 클래스, 프로퍼티, 메서드 등에 이전에는 제공하지 않던 방법으로 새로운 기능을 추가할 수 있습니다. 사실 데코레이터라는 문법은 이미 자바스크립트

haeguri.github.io

 

How To Use Decorators in TypeScript | DigitalOcean

 

www.digitalocean.com

 

 

TypeScript Decorator 직접 만들어보자

TypeScript로 만들어진 library를 보면 decorator를 여기저기서 많이 사용하는 것을 볼 수 있다. Decorator가 무엇이며 어떻게 만드는지 궁금해서 공식 documentation 및 여러 글을 살펴보았다. 이번 글은 decorat

dparkjm.com

 

 

Documentation - Decorators

TypeScript Decorators overview

www.typescriptlang.org