@데코레이터 뽀개기 !!! (feat _IoC, TS)

DatQueue·2022년 9월 27일
7
post-thumbnail

지난 포스팅에서 우린 “객체지향과 의존성”을 주제로, 세부 중요 키워드 두 가지 “의존성 주입(Dependency Injection _DI)”과 “의존 역전 원칙(Dependency Inversion Principle _DIP)”에 관해

알아보았다.

이번 포스팅에선 지난 포스팅의 “의존성 주입”을 조금 더 실용적이고 효율적으로 쓰는 방법을 알아보고자 한다. 타이틀에서도 알 수 있듯이 바로 “제어권의 역전(Inversion of Control)”이다.

( 정말 긴 내용이 담길 예정입니다. 하지만 큰 틀은 타입스크립트 공식문서에 의존하니 참고바랍니다. 꼭 도움이 되시길 바랍니다. )


제어권의 역전(Inversion of Control _IoC)


지난 코드 다시 보기


이전 포스팅을 안보셨다면 !!
⬇⬇⬇
이전 포스팅


제어권의 역전에 들어가기 앞서 이전 포스팅에서 “의존성 주입(DI)”를 시행했던 코드를 살펴보자.

class SportsWear {
  public putOn() {
    console.log('put on SportsWear!');
  }
}

class Nike extends SportsWear {
  public putOn(): void {
    console.log('put on Nike!')
  }
}

class Player {
  private sportsWear: SportsWear;

  constructor(sportsWear : SportsWear) {
    this.sportsWear = sportsWear;
  }

  public putOn() {
    this.sportsWear.putOn();
  }
}

const player: Player = new Player(new Nike());
player.putOn(); // put on Nike!

해당 코드는 “의존성 주입”을 함으로써 Player 내부에서 변경을 수정하지 않고 외부에서 수정함으로써 조금 더 유연한 코드를 만들 수 있었다.

하지만 위와 같은 방법또한 문제가 드러난다. 즉, “단점”이 존재한다는 말이다.

의존성 주입만 사용하게 되면 우리가 직접 의존성을 관리해야 한다. 만약 의존성을 띄는 클래스 Nike 하나가 아닌 Adidas, Puma, Umbro … 굉장히 많다고 가정해보자.


const player: Player = new Player(new Nike());
player.putOn(); 
const player: Player = new Player(new Adidas());
player.putOn(); 
const player: Player = new Player(new Puma());
player.putOn(); 
const player: Player = new Player(new Umbro());
player.putOn();

그럼 위와 같이 직접 의존성의 인스턴스를 일일이 생성해야한다. 한 두개가 아닌 여러개가 될 경우 굉장히 귀찮아지고 코드가 충분히 더러워질 수 있다. 실수또한 발생할 수 있다.

우리는 “클린 소프트웨어”를 구현하는데에 초점을 맞춰야하므로 이러한 방식은 좋지 못하다 할 수 있다.

지금부터, 우린 제어권을 Player에서 다른 방식으로 “역전”할 것이다.


제어의 역전에 대해


제어의 역전은 소프트웨어 아키텍쳐를 구성하는데에 있어, 하나의 “설계 원칙”이다.

개발자가 작성한 객체나 메서드의 제어를 개발자가 아니라 “외부에 위임”하는 설계원칙을 우린 “제어의 역전”이라 한다. 우린 “프레임워크”를 사용할 때 보통 이러한 경험을 많이 하게 된다. 우리가 코드를 일일이 작성하지 않음에도 불구하고 이미 그 기능을 외부로부터 받아와 구현할 수 있는 것이다.

이번에 설명하게 될 “Decorator”또한 타입스크립트로 짜여진 백엔드 프레임워크인 “Nest”에서 typeDI로써 유연하게 사용된다. Nest와 같은 프레임워크를 접해보기 전 꼭 알고 가면 좋을 개념이기도 하다.

당장 백엔드적인 코드로썬 알아볼 수 없지만 “제어의 역전”을 통해 애플리케이션의 제어 책임이 프로그래머에서 프레임워크로 위임되므로, 개발자는 핵심 비즈니스 로직에 조금 더 집중할 수 있게된다.


TypeScript Decorator


Angular, Nest와 같은 TS기반 프레임워크에서 제어 관계 역전을 통해 “의존성 주입”으로 활용되는 “데코레이터”를 알아보자.

데코레이터를 작성해보기에 앞서 추가해주어야할 속성이 있다.

우린 tsconfig.json 파일에서 experimentalDecorators 값을 true로 추가해주어야한다.

만약 해당 속성을 추가해주지 않는다면

코드에서 에러를 발생시키며 위와 같은 문구를 접하게 될 것이다. 해석하면 다음과 같다.

“데코레이터는 실험적 기능이며 릴리즈에 의해 변경될 수 있다. ‘tsconfig’ 또는 ‘jsconfig’ 옵션 세팅을 통해 ‘experimentalDecorators’ 를 설정하여 경고 문구를 제거하도록 해라.

자 그럼, 우린 데코레이터를 사용할 준비가 되었다. 지금은 백엔드 서버가 아닌 그냥 브라우저에서 데코레이터를 작성할 것이므로 따로 npm 모듈 설치는 필요없다.

데코레이터 (Decorators)


데코레이터 는 클래스 선언, 메서드, 접근자, 프로퍼티 또는 매개 변수에 첨부할 수 있는 특수 종류의 선언이다. 형식은 @expression 과 같다. 여기서 expression은 데코레이팅 된 선언에 대한 정보와 함께 런타임에 호출되는 함수여야 한다.


즉, @expression을 코드에 사용하였다면

function expression(target) {
	// 'target' ~~
}

위와 같이 expression을 함수명으로 가지는 함수를 작성함으로써 구현할 수 있다는 것이다.


데코레이터 팩토리 (Decorator Factories)


데코레이터가 선언에 적용되는 방식을 원하는 대로 바꾸고 싶다면 “데코레이터 팩토리”를 작성할 수 있다. “데코레이터 팩토리”는 단순히 데코레이터가 런타임에 호출할 표현식을 반환하는 함수이다.


작성법은 다음과 같다.

function factory(value: string) {  //데코레이터 팩토리
  return function(target: string) {  //데코레이터
    // target , value ~~
  }
}

우리가 일전에 “GoF의 디자인 패턴”에서도 익혔듯이 어떠한 함수를 감싸고, 그 함수를 반환하는 구조를 띄는 함수를 “팩토리 함수”라고 한다.


데코레이터 합성 (Decorator Composition)


“하나”의 선언(단일 선언)에 여러 데코레이터를 적용할 수 있다. 예를 들면 아래와 같이 말이다.

class Composition {
	@f
	@g
	x
}
// 단일 행으로 작성할 경우 { @f @g x }

여러 개의 데코레이터가 단일 선언에 적용되는 경우는 수학의 합성 함수와 유사하다.

수학의 합성 함수는 알다시피 f(g(x)) 꼴이다. 일반적으로 g(x)의 결과를 먼저 구하고, 그 다음에 해당 결과를 f 함수의 매개변수에 대입하게 된다. 이 원리가 데코레터의 합성의 원리와 유사하다 보면 된다.


TypeScript의 단일 선언에서 데코레이터를 사용할 때 다음 단계가 수행된다.

  1. 각 데코레이터의 표현은 위에서 아래로 평가된다.
  2. 그런 다음 결과는 아래에서 위로 호출된다.

코드를 통해 확인해보자.

function first() {
  console.log("first(): factory evaluated");
  return function(target: any, propertyKey: string, desc: PropertyDescriptor) {
    console.log("first(): called");
  };
}

function second() {
  console.log("second(): factory evaluated");
  return function(target: any, propertyKey: string, desc: PropertyDescriptor) {
    console.log("second(): called");
  };
}

class ExampleClass {
  @first()
  @second()
  method() {}
}

결과는 다음과 같다.

위에서 언급한대로 데코레이터의 표현은 위에서 아래로 이동하므로 first()함수에서 작성한 문구가 먼저 콘솔에 찍히고, 그 다음으로 second()함수에서 작성한 문구가 찍힌다.

하지만, 결과에 해당하는 각 함수의 리턴문 안의 문구는 반대로(위에서 아래로) 프린트되는 것을 확인할 수 있다.

이렇게 단일 선언에서 데코레이터의 호출은 어떤 순서로 진행되는지 확인을 해보았다. 그런데 조금 다른 부분에서 궁금함이 생길 것이다. 위의 코드를 실행하는데 전혀 쓰이지도 않은 “매개변수”들은 도대체 무엇일까?

아래의 매개변수들(target, propertyKey, desc) 말이다.

 return function(target: any, propertyKey: string, desc: PropertyDescriptor) {}

아래 세부 타이틀을 통해 알아보자.


데코레이터의 쓰임


바로 위에서 언급한 “3”개의 매개변수에 관해 알아보자. 우리는 앞선 코드에서 사용하지도 않는 매개변수 3가지를 작성함으로써 코드를 실행시켰다.

만약 해당 매개변수를 지우면 어떻게 될까?

보다시피 데코레이터로 주입한 @first@second에서 에러를 발생시킨 것을 확인할 수 있다.

에러 문구를 확인해보면

다음과 같이 “데코레이터를 사용하는데에 있어 인자(arguments)들이 너무 적다”고 한다. 즉, 우리가 앞서 작성하였던 데코레이터 함수의 매개변수들은 위의 코드를 사용하는데있어 꼭 필요한 매개변수라고 할 수 있다.

지금부터 해당 매개변수들의 쓰임과 그것이 여러 데코레이터에서 어떻게 작용하는지에 대해 알아보고자 한다.


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


“메서드 데코레이터”는 메서드 선언 직전에 선언된다. 데코레이터는 메서드의 프로퍼티 설명자(Property Descriptor)에 적용되며 메서드 정의를 관찰, 수정 또는 대체하는 데 사용할 수 있다.


메서드 데코레이터의 표현식은 런타임에 다음 세 개의 인수와 함께 함수로 호출된다.

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

우리가 앞서 보았던 그 “3”개의 파라미터들이 바로 위의 인수들이다.


아래 예제를 보자.

class Greeter {
  greeting: string;
  constructor(message: string) {
    this.greeting = message;
  }

  @enumerable(false)
  greet() {
    return "Hello, " + this.greeting;
  }
}

function enumerable(value: boolean) {
  return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    descriptor.enumerable = value;
  }
}

const print = new Greeter("World!").greet();
console.log(print);    // Hello, World!

@enumerable이란 데코레이터를 greet()메서드 직전에 선언함과 동시에 enumerable함수로써 데코레이터 팩토리를 구현해주었다.

이 코드에서 우린 Greeter란 클래스를 인스턴스화해 greet메서드를 호출함으로써 “Hello, World!”라는 문구를 콘솔에서 프린트할 수 있었다.

그런데 데코레이터구문이 해당 “Hello, World!”를 출력하는데 어떤 영향을 미쳤을까?

정답은 “Hello, World!”를 호출하는데는 아무런 영향도 미치지 않았다.

보다시피 에러를 발생시키지도 않았다.


사실, 이러한 생각을 하는 본인이 이상한건지 모르겠지만 이러한 생각을해야 “메서드 데코레이터”에 관해 더 확실히 이해할 것 같았다.

그럼, 도데체 메서드 데코레이터는 어떤 역할을 하는지 알아보자.


“””

그전에, 꼭 알고 넘어가야할 개념들이 있다. 바로 위의 코드에서도 보이는 PropertyDescriptor이다.

해당 “PropertyDescriptor(속성 설명자)”에 관해선 꼭 알아야 앞으로 내용을 이해할 수 있다. 이것에 대한 내용을 이번 포스팅에 자세히 다루기엔 내용이 길어지므로 좋은 블로그를 링크로 걸어두려 한다.
⬇⬇⬇

객체에 대하여.JS #2 속성 설명자

“””


그럼 다시 코드로 넘어와 enumerable함수를 살펴보자.

먼저 데코레이터 함수의 첫 번째 매개변수인 target에 관해 알아볼 것이므로 콘솔에 출력시켜본다.

다음과 같이 constructorgreet메서드가 포함된 Greeter클래스가 오브젝트로 출력되었다.

그럼 마찬가지 방법으로 두 번째 매개변수인 propertyKey를 출력해보자.

greet메서드가 출력되었다.

마지막 세 번째 매개변수인 descriptor는 어떨까? 이 부분이 굉장히 중요하고 위에 링크를 걸어둔 블로그 혹은 구글링을 통해 “프로퍼티 설명자”에 관해 먼저 학습하고 와야 할 이유이다.

사실, descriptor는 PropertyDescriptor로 이미 선언하였고 vsCode의 정의 미리보기 기능을 통하여 확인해보면

위와 같이 인터페이스로 여러 속성들과 타입을 가진 채 나열된 것을 확인할 수 있을 것이다.

콘솔창에 출력하게 된다면 아래와 같은 결과를 얻을 수 있다.

보다시피 현재 enumerable: false인 것을 확인할 수 있을 것이다.

우리가 작성한 코드를 보면

@enumerable을 선언할 때 enumerable함수의 매개변수 value의 값으로 false를 지정해주었기 때문이다. 만약, 아래와 같이 true로 바꾸게 된다면

enumerable함수에서 descriptor를 호출할 시

다음과 같이 true로 바뀐 값을 가지게 된다.

function enumerable(value: boolean) {
  return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    descriptor.enumerable = value;
    console.log(descriptor)
  }
}

enumerable데코레이터 팩토리 함수의 매개변수로 value를 받아와주고 데코레이터를 선언해주기에 앞서, 데코레이터 함수 내부에서 value를 받아와 **descriptor: PropertyDescriptor**를 통해 enumerable의 속성을 변경시켰다.

(데코레이터 이름 enumerable은 단지 가독성을 위한 함수명일뿐 착각하지마세요.)


사실 해당 예제를 통해 별 감흥이 없는 것은, 눈에 보이는 “무언가”가 없기 때문이다. 메서드 데코레이터와 프로퍼티 설명자를 이용하는, 조금 더 와닿는 코드를 만들어보자.

class Greeter {
  greeting: string;
  constructor(message: string) {
    this.greeting = message;
  }

  @enumerable(false)
  greet() {
    return "Hello, " + this.greeting;
  }
}

function enumerable(value: boolean) {
  return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    descriptor.enumerable = value;

    descriptor.value = () => {
      return "Bye, World! ";
    }
  }
}

const print = new Greeter("World!").greet();
console.log(print);    // Bye World!

우리는 위 코드에서 분명 인스턴스과정을 통해 Greeter클래스를 생성하면서 동시에 greet()메서드를 호출하였다. 앞전코드와 같이 당연히 greet()내부 리턴값을 작성한 “Hello, “ + this.greeting; 이 출력되길 예상했지만 그렇지 않았다.

enumerable 데코레이터 함수 내부에서 작성한 “Bye, World!”가 출력된다. (직접 콘솔로 확인하길 바람) 어떻게 된 것일까?

우리가 앞서 세 번째 매개변수인 descriptor의 타입 값으로 지정해 준 PropertyDescriptor를 인터페이스로써 알아보았을때 아래와 같은 속성들이 있다는 것을 알 수 있었다.

여기에 value? : any, 즉, value 속성이 보일 것이다. ( 참고로 우리가 앞서 enumerable의 매개변수로 받아온 value와는 다르다는 것을 미리 말한다. 해깔림에 주의하자)

한번 value 값을 콘솔창에 띄워보자. 간단히 아래와 같이 작성하면 된다.

console.log(descriptor.value)

위와 같은 값을 얻게 되었다. 즉 , greet()메서드의 본체인 것이다.


function enumerable(value: boolean) {
  return function(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
    descriptor.enumerable = value;

    descriptor.value = () => {
      return "Bye, World! ";
    }
  }
}

다시 해당 부분을 보자.

우리가 가장 처음에 메서드 데코레이터를 정의할 때 뭐라고 하였는가?

“데코레이터는 메서드의 프로퍼티 설명자(Property Descriptor)에 적용되며 메서드 정의를 관찰, 수정 또는 대체하는 데 사용할 수 있다.”

이제 위의 문구가 이해가 갈 것이라 생각한다.

@enumerablegreet()메서드의 프로퍼티 설명자 PropertyDescriptor에 적용이되고, 해당 greet()메서드를 수정할 수 있었다.

인스턴스의 추가 및 변경없이, 또한 greet()메서드 내부의 변경없이 데코레이터만으로 @enumerable의 주입만으로 우린 메서드를 수정할 수 있는 것이다. 우린 이렇게 데코레이터의 사용 이유까지 별 것 없는 코드를 통해서도 느껴볼 수 있다.


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


앞선 메서드 데코레이터(Method Decorators)에서 굉장히 세세히 풀어서 설명하였으므로 중복된 내용은 빠르게 지나가도록 하겠다.

“접근자 데코레이터”는 접근자 선언 바로 전에 선언한다. 접근자 데코레이터는 접근자의 프로퍼티 설명자(PropertyDescriptor)에 적용되며 접근자의 정의를 관찰, 수정 또는 교체하는 데 사용할 수 있다. 접근자 데코레이터는 선언 파일(d.ts file)이나 다른 주변 컨텍스트(ex: declare class)에서는 사용 불가하다.


선언 파일 이란? ⬇⬇⬇

d.ts 파일 | 타입스크립트 핸드북


“접근자 데코레이터”를 알아보기전에 먼저 “접근자(Accessor)”에 대한 지식이 필요할 것이다. 객체 지향을 바탕으로하는 Class에서 해당 “접근자”는 굉장히 중요한 내용이다.


(내용이 길어질 것을 고려해 따로 포스팅을 작성해두었으니 아래 작성글에서 먼저 참고하시고 오면 좋을 것입니다.) ⬇⬇⬇

접근 제어자 Getter & Setter __velog


접근자 데코레이터의 표현 식은 런타임에 다음 세 가지 인수와 함께 함수로 호출된다.

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

아래는 User클래스의 멤버에 적용되는 접근자 데코레이터(@configurable)의 예이다.

class User {
  private _age: number = 24;

  @configurable(false)
  get age() {
    console.log('Age getter called');
    return this._age;
  }

  //@configurable(false)
  set age(value: number) {
    console.log('Age setter called');
    this._age = value;
  }
}

function configurable(value: boolean) {
  return function(target: any, name: any, desc: PropertyDescriptor) {
    desc.configurable = value
  }
}

// Runtime Error!
Object.defineProperty(User.prototype, 'age', {
  get() {
    console.log("new getter called!");
    return this._age;
  }
});

데코레이터 @configurableget (게터) 직전에 선언하였다. 물론 set(세터)직전에 선언해도 상관은 없다. 하지만 두 개 모두에 선언하게 되면 먼저 작성된 부분(여기선 게터)에만 적용이된다. getset은 각각의 프로퍼티 설명자를 가지고 있지는 않다. 즉, 둘 각각의 데코레이터를 주입 받을 순 없는 것이다.

우린 위의 코드에서 PropertyDescriptor(프로퍼티 설명자 )를 이용하여, 속성 중 하나인 configurable의 불리언 값을 데코레이터 선언을 통해 매개변수로써 받아오게 하였다. 여기서 우린 해당 value값을 false로 지정해줌에따라, 해당 속성을 잠글 수 있게 되었다.

즉, 가장 아래서 Object.defineProperty()를 통해 객체의 속성을 재정의 하려고 할 시, 런타임에서 에러를 띄우게 된다.


해당 예제는 접근자 데코레이터(Property Decorator)의 대표적인 예이다. 하지만 데코레이터를 마주하는 입장으로써 뭔가 확(?) 와닿지 않을지도 모른다. 그렇다면 아래 예제 코드를 보자.


class User {
  private _age: number = 24;
	
	@changeAge(25)
  get age() {
    console.log('Age getter called');
    return this._age;
  }

  //@changeAge(25) --> 어짜피 get에만 적용되게 됨
  set age(value: number) {
    console.log('Age setter called')
    this._age = value;
  }
}

function changeAge(newAge: number) {
  return function(target: any, name: any, desc: PropertyDescriptor) {
    desc.get = () => {
      console.log("new age getter is called");
      return newAge;
    }
  }
}

const user = new User();
const newAge = user.age;
console.log(newAge)

결과는 아래와 같다. (브라우저 콘솔 창 출력)

클래스 외부에서 접근 제어자(set)를 이용한 instance.prop = value형식으로 age의 값을 수정해주지 않았음에도 불구하고, 데코레이터 @changeAge(25)를 접근자 직전에 선언해줌으로써 변경을 가능케 하였다.


원리는 다음과 같다.

접근자의 프로퍼티 설명자 (PropertyDescriptor)에는 일반 데이터의 프로퍼티 설명자와는 달리 valuewritable이 없는 대신에 getset이 존재한다. 즉, 데코레이터 팩토리에서 매개변수로 받은 newAge를 프로퍼티 설명자의 get속성으로 재정의 하고, 해당 newAge를 데코레이터 선언 시 값을 넣어줌으로써 age의 변경을 가능케 할 수 있었던 것이다.

오히려 데코레이터 함수를 정의함으로써 더 복잡히 작성한 것이 아니냐는 생각을 할 수도 있다. 하지만 클래스의 갯수가 많아짐에 따라 일일히 instance.prop = value형식으로 프라이빗 멤버 변수의 값을 변경하는 것 보단, 데코레이터 주입으로써 변경시키는 것이 가독성에도 좋고 훨씬 깔끔한 코드를 만들 수 있다고 생각한다. 또한, 데코레이터 팩토리 함수는 선언과 동시에 여러 비슷한 역할을 하는 접근자에 원하는 만큼 주입을 할 수 있으니, 더 유연한 코드 작성이라 할 수 있다.


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


“프로퍼티 데코레이터”는 프로퍼티 선언 바로 전에 선언된다. 프로퍼티 데코레이터 역시 선언 파일이나 다른 주변 컨텍스트에서 사용할 수 없다.

프로퍼티 데코레이터의 표현 식은 런타임에 다음 두 개의 인수와 함께 함수로 호출된다.

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

앞선 “메서드 데코레이터”, “접근자 데코레이터”와는 달리, 두 개의 인수와 함께 호출되고 세 번째 인수로 나왔던 “프로퍼티 설명자(PropertyDescriptor)”사용되지 않은 것을 알 수 있다.

타입스크립트에선 프로퍼티 데코레이터가 초기화되는 방식으로 인해 PropertyDescriptor가 프로퍼티 데코레이터에 대한 인수로 제공되지 않는다. 공식문서에 따르면 아직은 현재 프로토타입의 멤버를 정의할 때 인스턴스 프로퍼티(속성)를 설명하는 메커니즘이 없고, 프로퍼티의 초기화 과정을 관찰하거나 수정할 수 있는 방법이 없기 때문이라 한다. 즉, 데코레이터 선언 함수에서 반환하게 되는 리턴값 또한 무시된다.


하지만 우리는 ECMAScript5 defineProperty 혹은, 다른 방법들로 속성 선언을 확인, 수정, 교체할 수 있다.


아래 첫 번째 예시를 보자.

function format(formatString: string) {
  return function(target: any, propertyKey: string): any {
    console.log(propertyKey); //greeting
    let value = target[propertyKey];
    
    function getter() {
      return `${formatString} ${value}`
    }

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

    return {
      get: getter,
      set: setter,
    }
  }
}

class Greeter {
  @format("Hello")
  greeting!: string;
}

const t = new Greeter();
t.greeting = 'World';
console.log(t.greeting); // Hello World

먼저 콘솔창에서 출력값을 알아보면 Hello World를 띄운다.

우리는 class Greeter 내부에서 greeting에 대한 타입만 지정해주었지 값을 지정해주진 않았다. 하지만 우린 데코레이터 선언을 가능케 한 format 데코레이터 팩토리를 통해 greeting에 대한 값을 지정해줄 수 있다.

리턴을 통한 데코레이터 함수 내부에서 let value = target[propertyKey] 구문을 통해 greeting의 값을 받아와 줄 수 있다. target은 클래스 Greeter이고, propertyKey는 그에 속한 속성 greeting이 될 것이기 때문이다.

다음으론 해당 값들을 settergetter 함수를 통해 접근시켜 줄 수 있다. 물론 PropertyDescriptor 속성은 없지만 settergetter 함수를 선언 한 뒤, 최종적으로 데코레이터 함수 내에서 타입스크립트에서 제공하는 접근 제어자인 getset을 앞서 지정해 준 settergetter를 참조하도록 해주면 된다.

즉, 우린 외부 클래스 밖에서 클래스 인스턴스인 t를 이용해 데이터 프로퍼티와 같이 t.greeting형식으로 greeting 의 값을 수정시킬 수 있다.

여기서 주의 할 점은 t.greeting을 콘솔에 출력시키면 우리가 지정해 준 “World”만 나오는 것이 아니다. 우리는 게터 구문에서 (getter) 리턴 값으로 ${formatString} ${value}와 같이 받아주었기 때문에 최종 “Hello World”가 나오게 된다.


우리는 게터와 세터를 받아오는데 있어 위와 같이 간략히 표현했지만 아래와 같이

function format(formatString: string) {
  return function(target: any, propertyKey: string): any {
    console.log(propertyKey);
    let value = target[propertyKey];
    
    function getter() {
      return `${formatString} ${value}`
    }

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

    Object.defineProperty(target, propertyKey, {
      get: getter,
      set: setter,
    })
  }
}

자바스크립트에서 제공하는 Object.defineProperty()메서드를 사용해도 무관하다.


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


“매개변수 데코레이터”는 이름 그대로 클래스 생성자 또는 메서드의 파라미터에 선언되어 적용된다. 이것 역시 선언 파일, 선언 클래스에선 사용할 수 없다. 매개변수 데코레이터는 호출 될 때 3가지의 인자와 함께 호출된다. 리턴값은 무시된다.

또한, 매개변수 데코레이터는 매개변수가 “메서드”에서 선언되었음을 관찰하는 데만 사용할 수 있다.

  1. 정적 멤버가 속한 클래스의 생성자 함수이거나 인스턴스 멤버에 대한 클래스의 프로토타입
  2. 멤버의 이름
  3. 매개변수가 함수에서 몇 번째 위치에 선언되었는지를 나타내는 인덱스( _ordinal index)

아마 3번째가 기존의 “프로퍼티 설명자 ” 와 달라서 익숙치 않을 것이다. 차근차근 알아보자.


function Log(t:any, p:string, i:number) {
  console.log(t.name);
  console.log(`
    매개변수 순서: ${i}, 
    멤버 이름: ${p === undefined ? 'constructor' : p}
  `);
}

// Button 클래스
class Button {

  el:HTMLButtonElement;
  color:string;

  // 생성자 함수 내에 Log 데코레이터 사용
  constructor(
    @Log el:HTMLButtonElement, 
    @Log color:string = 'transparent'
  ) {
    this.el = el;
    this.color = color;
  }

  // 스태틱 메서드 내에 Log 데코레이터 사용
  static initialize(
    @Log els:NodeList, 
    @Log color:string = 'transparent'
  ){
    return [].slice.call(els).map(el => new Button(el, color));
  }
}

// Button.initialize() 스태틱 메서드 사용
const btns = Button.initialize( document.querySelectorAll('.button'), '#900' );

HTML 버튼을 제어하는 클래스이다. 너무 코드 해석으론 깊게 가지말고 “매개변수 데코레이터”가 어떻게 동작하는지를 알아보자.

보다시피 @Log로써 생성자 constructor 내에서 2번, 정적 메서드 initialize내에서 2번 데코레이터가 선언되었다. 즉, 우리가 데코레이터 함수 Log내부에서 작성한 출력문 또한, 4번 호출될 것이다.

그럼 먼저 콘솔창에서 결과를 확인해보자.

가만보니 클래스 내부에서 정의한 순서와 반대로 즉, “역순”으로 호출된 것을 확인할 수 있다. 또한 데코레이터 함수의 3번째 인자로 받아준 인덱스또한 “역순”으로 호출되었다.

여기서 또 한가지 알아볼 점은 위에서도 잠깐 언급하였지만 “매개변수 데코레이터”는 오로지 “메서드” 내부에서만 선언되었을 때 관찰이 가능하다.

즉, 우리가 아래와 같이 별도의 장치를 마련하지 않았다면

멤버 이름: ${p === undefined ? 'constructor' : p}

생성자 constructor 내부에서 작성된 매개변수 데코레이터는 멤버 이름을 undefined로 출력하였을 것이다.







우리는 이처럼 “매개변수 데코레이터”의 간단한 사용과 어떤 순으로 동작하는지 등에 관해 알아보았지만 뭔가 딱히 필요성 및 쓰임에 관해 느끼진 못하였다. 항상 언급하지만 공부를 하는 입장에선 이것이 어떻게 동작하는지를 아는 것도 중요하지만 “왜 필요한가” 를 깨닫는 것이 가장 중요하다 생각한다.


그럼 조금 더 실용성 있는 코드를 보자. 아래 코드는 사실 이해하는데 많은 시간이 걸렸다. 처음 데코레이터를 접한다면 아마 이해하는데 꽤 벅찰 수도 있지만 그래도 체험해보는 것은 전혀 나쁘지 않다.


function MinLength(min: number) {
  return function(target: any, propertyKey: string, parameterIndex: number) {
    target.validators = {
      minLength: function(args: string[]) {
        return args[parameterIndex].length >= min;
      }
    }
  }
}

function Validate(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const method = descriptor.value;

  descriptor.value = function(...args: []) {
    Object.keys(target.validators).forEach((key) => {
      if (!target.validators[key](args)) {
        console.log("throw new BadRequestException()");
      }else{
        console.log('OK!')
      }
    })
    method.apply(this, args);
  }
}

class User {
  private name!: string;

  @Validate
  setName(@MinLength(3) name: string) {
    this.name = name;
  }
}

const t = new User();
t.setName('Dexter');  // OK!
console.log('---------------');
t.setName('De');  // throw new BadRequestException();

앞서 해당 코드에 대한 설명부터 하자면 TypeScript 기반 백엔드 프레임워크인 Nest에서 API 요청 파라미터에 대해 유효성 검사를 하는 로직이다. 어렵게 생각할 것 없다. 그냥 파라미터로 사용할 수 있는지(유효한지) 데코레이터를 사용해서 검사를 하는 로직이라 생각하면 된다.

크게 바라보면 User클래스 내부에서 setName() 메서드 앞에 선언된 “메서드 데코레이터” @ValidatesetName() 파라미터 name 앞에 선언된 “매개변수 데코레이터” @MinLength가 위치해있다. 그리고 해당 데코레이터를 선언하기 위한 데코레이터 팩토리 MinLength()와 데코레이터 함수 Validate()가 선언되어 있다.


먼저 MinLength 부터 보자.

function MinLength(min: number) {
  return function(target: any, propertyKey: string, parameterIndex: number) {
    target.validators = {
      minLength: function(args: string[]) {
        return args[parameterIndex].length >= min;
      }
    }
  }
}

참고로 매개변수의 유효성 검사를 하는데 있어 “매개변수 길이제한”을 통해 시행할 것이다.


이제 더이상 말하면 입 아프지만, targetUser 클래스인 것이고 target.validators = {}를 통하여 우린 User클래스 내에 새로운 속성을 만들어 줄 수 있다. 그리고 해당 validators 객체 내에서 minLength라는 key와 function(args){~~~}라는 value를 정의하게 된다. 이 value 로써 정의한 함수의 리턴값 args[parameterIndex].length >= min;이 “매개변수 길이제한 판별식”이 되는 것이다.


잠깐 여기서 이런 생각이 들 수도 있다.


“유효성 검사를 하는 validators 속성을 애초에 User 클래스 내부에서 정의하면 되지 뭐하러 따로 만들어서 귀찮게 할까…”


이것에 대한 해답은 앞전 여러 예시에서도 언급하였지만 “데코레이터” 자체의 강점과 관련이 있다. 즉, 조금 더 나아가면 “의존성 주입(Depedency Injection)”과 관련 지을 수 있다.

물론, User 클래스 내부에서 정의할 수도 있다. 하지만, 만약 유효성 검사를 필요로하는 즉, MinLength의 동작을 필요로하는 클래스가 User하나만이 아닌 여러 개라면 어떨까? 일일이 내부에서 정의해줘야할까? 혹은 인스턴스로써 일일이 클래스 외부에서 정의해줘야 할까?


그렇게 한다면 코드는 복잡해 질 것이다. 불필요한 중복에 마주할 것이다. 또한, 거기까지 그럴 수 있다 해도, “유효성 검사”와 같은 특수한 로직을 User클래스 내부에 넣는 다는 것은 뭔가 User본체의 고유함을 흐린다고 할 수도 있다. 이게 뭔 이상한 소리냐 싶을 수도 있지만 User라는 클래스에는 일반적으로 고유한 나이, 주소와 같은 속성과 접근을 가능케하는 “세터”와 “게터” 등의 꼭 필요한 메서드만 있는 것이 효율적이다. 그런 의미에서 이 같이 어떠한 특수한 동작을 가능케하는 속성같은 경우엔 애초부터 클래스 내부에서 작성하는 것이 아니라 “데코레이터”를 통해 주입시켜주는 것이 효율적이다.


다음은 Validate데코레이터 함수이다.

function Validate(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
  const method = descriptor.value;

  descriptor.value = function(...args: []) {
    Object.keys(target.validators).forEach((key) => {
      if (!target.validators[key](args)) {
        console.log("throw new BadRequestException()");
      }else{
        console.log('OK!')
      }
    })
    method.apply(this, args);
  }
}

우린 해당 데코레이터를 이용하여 User 클래스 내의 name에 접근하는 메서드인 setName을 재정의 해주게 된다. 또 반복해서 말하지만, 왜 setName 내부를 직접 변경하는 것이 아니라 “메서드 데코레이터” 주입을 통해 변경하는지 꼭 먼저 생각해보는 것이 중요하다.


앞서 학습했듯이, “매개변수 데코레이터”와는 다르게 @Validate와 같은 “메서드 데코레이터”는 세 번째 인수로 “프로퍼티 설명자(PropertyDescriptor)”를 가지게 된다. 또한 해당 프로퍼티 설명자에 있는 value라는 속성을 이용하여 우린 setName메서드에 접근할 수 있게 된다. 직접 콘솔창에서 확인해보면 알겠지만 descriptor.value는 아시다시피 User클래스의 setName메서드를 가리킨다.


우린 이렇게 descriptor.value에 유효성 검사 로직이 추가된 함수를 할당하게 된다. 이 함수를 간단히 살펴보자면 다음 과정을 통해

Object.keys(target.validators).forEach((key) => {
  if (!target.validators[key](args)) {
    console.log("throw new BadRequestException()");
  }else{
    console.log('OK!')
  }
})

우리가 앞서 MinLengthtarget.validators에서 직접 선언하였던

target.validators = {
  minLength: function(args: string[]) {   
        return args[parameterIndex].length >= min;  // 유효성 검사 식
  }
}

해당 부분의 함수의 리턴값인 유효성 검사 식과 부합하는지를 알아보게 된다.


최종적으로 테스트를 해보자.

클래스 외부에서 아래와 같이 두 가지 경우로 테스트를 해 볼 수 있었다.

const t = new User();
t.setName('Dexter');
console.log('---------------');
t.setName('De');

그전에 먼저 우리는 @MinLength(3)을 통해 함수에서 정의해 준 매개변수 최소길이를 “3”으로 지정해주었다. 그 후 위의 setName의 인자로 둔 스트링 값 ‘Dexter’와 ‘De’ 두 가지 경우를 테스트 해보았다.


첫 번째 “Dexter”같은 경우는 길이가 5이고, validators의 유효성 검사 식을 통해 우리가 지정해 준 min값인 3을 넘었으므로 “OK!”메시지를 출력시킬 수 있었다.


반대로 “De”같은 경우는 스트링 값의 길이가 2이며 즉, 유효성 검사 식에 부합하지 않고, 이에 따라 Validate 함수의 descriptor.value 내부에서 받아준 해당 값 !target.validators[key](args)“false”를 가지게 된다. 결국 “throw new BadRequestException()”이라는 메시지를 출력시키는 것이다.

사실 직접 BadRequestException()이라는 클래스 모듈을 외부에서 불러오고 그에 따른 값을 확인하는 로직인데 그것까지 파헤쳐보기엔 아직 단계가 아니라 생각하여 콘솔을 통해 메시지만 띄우게 하였다. 혹시 해당 BadRequestException()이 궁금하다면 아래 링크를 통해 확인해보자.
⬇⬇⬇
BadRequestException | @nestjs/common


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


사실 어느 문서에서도 “클래스 데코레이터”에 관한 내용이 가장 먼저 나온다. 하지만 가장 끝에 작성한 이유는 나머지 데코레이터의 종류들과 가지고 있는 “인수”의 차이가 있기 때문이다. 딱히 특별한 이유는 아니지만 단지 큰 단락 “데코레이터 쓰임”의 시작을 “인수”의 해석 위주로 시작했기때문에 차이가 있는 “클래스 데코레이터”를 마지막에 정리하였다.


“클래스 데코레이터”는 클래스 선언 직전에 선언된다. 클래스 데코레이터는 클래스 생성자에 적용되며 클래스 정의를 관찰, 수정 또는 교체하는 데 사용할 수 있다. 이 역시 선언 파일이나 다른 주변 컨텍스트에서 사용할 수 없다.


클래스 데코레이터의 표현식은 데코레이팅된 클래스의 생성자를 유일한 인수로 런타임에 함수로 호출된다.

만약 클래스 데코레이터가 값을 반환한다면 클래스가 선언을 제공하는 생성자 함수로 바꾼다.

(새 생성자 함수를 반환하도록 선택한 경우 원래 프로토타입을 유지 관리해주어야 한다.)


첫 번째 아주 간단한 코드를 보자.

@sealed
class BugReport {
  type = "report";
  title: string;

  constructor(t: string) {
    this.title = t;
  }
}

function sealed(constructor: Function) {
  Object.seal(constructor);
  Object.seal(constructor.prototype);
}

@sealed가 실행이 되면 sealed함수 내에서 정의한 Object.seal()을 이용해 생성자와 프로토 타입을 모두 감사께 된다.

만약 생성자를 재정의 하고 싶다면 어떻게 할까? 다음 예제를 통해 확인해보자.


해당 코드는 클래스에 reportingURL속성을 추가하는 클래스 데코레이터의 예이다.

function reportableClassDecorator<T extends { new(...args: any[]): {} }>(constructor: T) {
  return class extends constructor {
    reportingURL = "http://www.example.com"
  }
}

@reportableClassDecorator
class BugReport {
  type = "report";
  title: string;

  constructor(t: string) {
    this.title = t;
  }
}

const bug = new BugReport("Needs dark mode");
console.log(bug);

결과는 다음과 같다.

BugReport클래스에 reportingURL이란 속성은 없었지만 클래스 데코레이터 @reportableClassDecorator를 주입함으로써 해당 속성을 추가할 수 있게 되었다.

하지만, 아래 Vscode의 기능을 사용해 bug인스턴스의 속성을 확인해보면

위와 같이 기존 클래스 내에서 선언되어있는 titletype만 존재하고 reportingURL은 나타나져있지 않다. 역시나 호출할 경우 에러를 발생시킨다.

이처럼 클래스에 속성을 추가시키는 것은 맞지만, 타입이 변경되는 것은 아니다. 타입 시스템은 reportingURL을 인식하지 못하기 때문에 bug.reportingURL과 같이 직접 사용은 불가하다.


하지만 강제로 타입시스템을 인식시켜 호출할 순 있다. as any를 사용하면 된다.

const bug = new BugReport("Needs dark mode");
console.log((bug as any).reportingURL); // http://www.example.com

위와 같이 작업하면 reportingURL 속성을 프로퍼티 호출 형태로 부를 수 있다.


사실 “클래스 데코레이터” 또한 여기서 끝내기는 아쉽다. 바로 “믹스인(MIXIN)”과 연관지어 생각해 볼 필요가 있기 때문이다. 하지만 이번 포스팅에서 언급하긴 상당히 루즈해질 것을 고려해 추후 따로 다루도록 하겠다. 혹시, 궁금하신분들은 꼭 “타입스크립트의 믹스인(MIXIN)”에 관해 찾아보고 “클래스 데코레이터”와 연관지어 보길 바란다.


생각정리


아마 본인이 작성한 포스팅 중 가장 긴 내용이 아니었나 싶다. 사실 공식 문서 위주로 정리해서 큰 틀은 공식 문서와 벗어나지 않지만, 도중 도중 평소 잘 접해보지 못했던 “프로퍼티 설명자”에 대해 정리해보고 또한 데코레이터가 로직에서 어떠한 기능과 이점을 가지는지에 대한 원천적인 궁금함에 대한 생각을 서술하느라 내용이 상당히 길어졌다.

사실 이렇게 길게 적을 생각은 없었는데 뭐 하나 빼먹고 포스팅하긴 아쉬웠다. 해당 데코레이터 문법은 “타입스크립트” 하나에 그치지 않고 조금 더 넘어가 “Nest”와 “Angular”같은 프레임워크에서도 굉장히 기본적으로 쓰이는 중요한 키워드이기 때문이다. 또한 우리가 이전 포스팅에서 다뤄보았던 “의존성 주입(Dependency Injection)”과 “의존 관계 역전 원칙(Dependencty inversion principle)”에 초점을 맞추어 작성할 필요 또한 있었기 때문이다.

항상 포스팅을 작성하면서 만약 누군가가 읽었을 때, 내가 공부한 정확한 자료를 제공해야겠다는 생각은 없다. 왜냐면 나 또한 공부중이고, 내가 적립한 지식 및 개념이 부정확할지도 모르기 때문이다. 사실 그런 이유로 블로그 포스팅을 반대하는 사람도 많다. 하지만, 난 코드 및 문법 속에 항상 내가 느끼고 깨달은 점들을 함께 서술한다. 그런 생각을 끊임없이 불특정 다수(포스팅을 읽으시는 분)와 공유하는 것이야말로 정말 도움이 되지 않을까 생각한다.

profile
You better cool it off before you burn it out / 티스토리(Kotlin, Android): https://nemoo-dev.tistory.com

0개의 댓글