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
를 추가해야 에러가 나지 않는다.