[Javascript] Decorator & Descriptor

kelly woo
11 min readJun 7, 2020

--

Photo by Markus Spiske on Unsplash

decorator pattern은 자바스크립트 개발자들에게는 이미 많이 익숙해진 스펙이다.

es5 부터 지원하지만 babel을 통해 대부분의 web framework(library)가 지원하고 있으며 Angular의 경우 기본 concept의 정의를 모두 데코레이터로 통일하고 있다.

// angular
@Component({
selector: 'app-heroes',
templateUrl: './heroes.component.html',
styleUrls: ['./heroes.component.css']
})
export class HeroesComponent implements OnInit {
...
}
// react
const mapStateToProps = (state) => ({...})
@connect(mapStateToProps)
export class App extends React.PureComponent{
...
}

언뜻보면 ‘@ ‘ 하나로 뭔가 마법같은 일을 하는 이 문법을 파헤쳐 보면 실상 decorator는 argument가 정해진 클래스 선언 단계에서 호출되는 함수에 지나지 않는다.

이제 그 하나하나를 살펴보자.

(*참고로 decorator 사용을 위해서는 babel이나 typescript 설정이 필요하다. 설정은 글의 마지막을 참고하자.)

PropertyDescriptor

decorator pattern을 이해하기 위해서는 우선적으로 propertyDescriptor(이하 Descriptor)객체에 대한 이해가 선행되어야 한다.

Descriptor를 Object.defineProperty에 넘겨 객체의 property를 선언할 수 있다. 아래의 예제코드를 살펴보자.

const target = {}Object.defineProperty(target, 'pr1', {
value: 42,
configurable: false,
writable: false,
enumerable: true,
});

위에서 사용된 value, configurable, writable, enumerable은 Descriptor가 가지는 속성으로 이 외에도 set과 get을 제공하며 각 속성이 나타내는 값은 다음과 같다 .

  • value: property 의 값
  • configurable: property 삭제 가능 여부
  • writable: 새로운 값 할당 가능 여부
  • enumerable: iterate 시 키를 보여줄 것인지 여부
  • get: getter 함수(value, writable과 함께 선언할 수 없다.)
  • set: setter 함수(value, writable과 함께 선언할 수 없다.)

구성 이름에서도 알 수 있듯이 Descriptor를 이용하면 객체 property의 값과 속성을 바꾸는 것이 가능해진다.
특히 set과 get으로 넘기는 setter와 getter 함수는 내부의 this가 실제 생성된 객체에 바인딩되기 때문에 런타임에서 변경될 값에 접근 및 수정이 가능하다.

이것이 우리가 decorator를 이해하기 위해 descriptor를 이해해야하는 이유이다. 앞서 말한 것처럼 decorator는 특정 인자를 받아서 변경된 값을 반환하는 함수이고 그를 위해 decorator는 descriptor를 인자로 받을 수 있다.

Decorator Arguments

decorator는 특정한 상황에서 쓰이기 때문에 이미 arguments가 정해져 있다.
이 arguments 를 확인하기 위해 간단한 log란는 decorator를 만들었다.

다음 코드와 결과값을 확인해 보자.

function log(type: string) {  // function을 return하는 이유는 
// 우리가 추가로 보내는인자를 closure로 잡기 위함이다.
// 만약 따로 보내는 인자가 필요하지 않다면 wrapper 함수를 벗겨내도 된다.
return function(
target: new () => any,
prop: string,
desc: PropertyDescriptor
) {
console.log(type, '::', target, prop, desc);
return desc;
};}@log("class")
class A {
@log("staticProp")
static staticProp = "1";
@log("staticFC")
static staticFC() {
console.log("static");
}
@log("variable")
c = 1;
@log("method")
hello() {
console.log("hello");
}
}

위의 결과값을 확인하면 decorator의 대상에 따라 arguments 가 바뀐다는 것을 확인할 수 있다. (class A 가 function A로 쓰인 이유는 es5로 compile 되었기 때문이다.)

arguments의 경우 첫번째 인자부터 class constructor, property key, 그리고 descriptor에 대응한다. 모두 다 valid한 값이 들어오는 것은 아니고 해당 상황에 따라 empty 값이 들어오기도 한다. (class decorator의 경우 property key와 descriptor가 undefined로, method, variable은 class constructor가 빈 object로 들어온다.)

자 그럼 이제 실제로 변경을 해보자.

Decorator Return

Decorator의 반환값은 실제 class나 해당 property의 성격을 변경한다.

function log(type) {
...

if (type === "class") {
// class인 경우 target class를 상속한 B class 를 반환한다.
return class B extend target {
hello() {
console.log("hello from B");
}
};
}
...
}
@log('class')
class A {
hello(){
console.log('hello from A');
}
}
const a = new A();a.hello();
// hello from B

위의 코드에서 type이 ‘class’일 경우 B class 를 반환하고 있는데 이로 인해 A class로 객체를 만들었음에도 결과값은 B 의 객체를 받게된다. 그러면 member variable이나 method는 어떤 값을 반환하면 될까?

바로 위에서 이야기했던 Descriptor를 반환하면 된다.
예제의 코드에 주석으로 되어있던 코드를 활성화 시키면 아래와 같은 결과가 나온다.

function log(type){
...
if (type === "bind") {
let originValue = desc.value;
let bound: Function | null = null;
return {
get() {
if (bound) {
return bound;
}
bound = originValue.bind(this);
return bound;
},
set(v: Function) {
originValue = v;
bound = null;
}
};
}...
}
class A {
...
name = 'kelly'
@log("bind")
getName () {
console.log(this.name);
}
...
}
const a = new A();
const getName = a.getName;
getName(); // 'kelly'

getter에서 처음 정의된 값을 originValue 라는 이름의 closure 로 만들어두고 해당값이 요청될때마다 bind 된 값을 반환하는 것이다.
물론 여기서 writable이나 set을 제거함으로 해서 readOnly 등의 처리도 가능하다.

Binding

위의 예제에서 주석을 풀고 확인해 보았다면 마지막에 선언한 객체 a, b의 getName이 둘다 ‘kelly’를 출력한다는 것을 알 수 있다. 코드를 보면 b의 name을 cathy로 변경했으므로 b의 getName은 cathy를 출력해야 하는데 왜 이런 일이 일어날까?

그것은 우리가 모든 instance에 대해 mutated를 일괄적용했기 때문이다. getter에서 this의 context와 관계없이 하나의 bound 값을 확인해서 나누어쓰고 있다. 이는 물론 말이 안된다. 즉 각 객체마다 따로 자신의 this context 내부에 저 bound 된 값을 각각 가질 필요가 있다.
때문에 이를 생성된 자신의 this에 bind 하기 위한 코드를 추가한다.

function log(type){
...
if (type === "bind") {
let
originValue = desc.value;
return {
get() {
if (typeof org !== 'function' || this === target.prototype){
return originValue;
}
let bound = originValue.bind(this);
// this에 각자 binding을 시키기 위해 override 한다.
Object.defineProperty(this, key, {
enumerable: false,
get() {
return bound;
},
set(v) {
if (typeof org !== 'function') {
bound = v;
} else {
bound = v.bind(this);
}
},
});
return bound;
},
set(v: Function) {
originValue = v;
}
};
}...
}

이를 이용하면 component method 중 throttle이나 debounce 가 처리되어야 한는 경우 constructor에서 다시 할당해주던 로직을 깨끗하게 decorator로 처리가 가능하다. (react-redux의 connect 역시 decorator로 사용이 가능하다.)

추가로 아래는 lodash의 throttle 와 debounce를 이용해 decorator를 만든 예제이다.

이 글의 내용은 decorator를 쉽게 사용할 수 있는 방법 중 하나를 설명한 것이고 더 깊은 이해를 원한다면 아래의 사이트를 참고하자.

환경설정

decorator는 es5 에서 도입된 개념이므로 일반 브라우저에서 바로 사용이 되지 않는다. babel과 typescript에 추가 작업이 필요하다.

babel
@babel/plugin-proposal-decorators 를 package에 추가하고 해당 라이브러리를 babel config plugins에 추가한다.

{ "plugins": [ ["@babel/plugin-proposal-decorators", { "legacy": true }] ] }

typescript의 경우 tsconfig 에서 compilerOptions에

experimentalDecorators: true 를 추가해야 에러가 나지 않는다.

--

--

No responses yet