자바스크립트 데코레이터 이해하기


원글: Mike Green - Understanding JavaScript Decorators

얼마 전 상태 관리를 위해 MobX를 사용하여 React 앱을 개발했다. 때론 흥미롭기도 하고 가끔은 혼란스럽기도 했지만, 바로 글을 쓰고 싶을 만큼 즐거운 경험이었다. MobX 개발에서 가장 흥미로웠던 점은 클래스의 속성을 작성하기 위해 데코레이터를 사용했다는 것이다. 실제로 사용해보지 못했을때는 깨닫지 못했지만, MobX개발에서 몇 번 작성해본 후에는 데코레이터가 엄청난 잠재력을 가진 기능이라고 생각하게 되었다.

데코레이터는 아직 자바스크립트의 정식 기능이 아니다. 아직 ECMA TC39의 표준화 절차를 진행하고 있다. 그렇다고 해서 우리가 사용조차 못 한다는 것은 아니다. 가까운 시일 내에 노드(Node)와 브라우저에서 데코레이터를 지원할 것이다. 그리고 그때까지는 Babel을 사용하면 된다.

데코레이터란?

데코레이터는 "데코레이터 함수"(또는 메서드)의 약자이다. 새 함수를 반환하여 전달 된 함수 또는 메서드의 동작을 수정하는 함수이다. 조금 전 “함수”라는 말을 많이 썼는데, 이는 우리가 고차(high-order)함수에 대해 얘기 할 때 생긴 직업병이다.

함수를 일급 시민으로서의 기능을 지원하는 모든 언어는 데코레이터를 구현할 수 있다(예를 들어, 자바스크립트는 함수를 변수에 할당하거나 다른 함수에 인자로 전달할 수 있다). 이런 언어 중 일부에는 데코레이터를 정의하고 사용하기 위한 특수한 구문(Syntactic sugar)이 있다. 파이선도 그런 언어 중 하나다.

def cashify(fn):
    def wrap():
        print("$$$$")
        fn()
        print("$$$$")
    return wrap

@cashify
def sayHello():
    print("hello!")

sayHello()

# $$$$
# hello!
# $$$$

위 코드에서 무슨 일이 일어나고 있는지 보자. 우리의 cashify 함수는 데코레이터이다. 데코레이터는 함수를 인자로 하며 반환 값도 함수이다. 우리는 파이선의 파이(@) 구문을 사용하여 데코레이터를 sayHello 함수에 적용했다. 데코레이터를 적용한 sayHello는 아래 정의한 sayHello와 본질적으로 같다.

def sayHello():
    print("hello!")

sayHello = cashify(sayHello)

최종 결과는 우리가 데코레이터를 적용한 함수로 출력하려고 하는 문자 앞, 뒤에 달러 기호를 출력한 모습이다.

어째서 필자가 파이선 예제를 사용하여 ECMAScript 데코레이터를 설명했는지 궁금한가? 물어봐 줘서 고맙다!

  • 파이선의 데코레이터가 JS에서 데코레이터가 작동하는 방식보다 더 단순하기 때문에, 데코레이터를 설명하기 좋다.
  • JS와 TypeScript는 파이선의 "파이 구문"을 사용하여 데코레이터를 클래스의 메서드와 속성에 적용하므로 시각적으로나, 구문적으로 비슷해 보이기 때문이다.

그렇다면, 파이선의 데코레이터가 JS 데코레이터와 다른 점은 무엇일까?

자바스크립트 데코레이터와 속성 설명자

파이선의 데코레이터는 장식하는 함수의 모든 인자를 전달받지만, 자바스크립트의 데코레이터는 자바스크립트 객체의 작동 방식 때문에 정보가 조금 더 필요하다.

자바스크립트의 객체는 속성이 있고, 각 속성은 값을 가지고 있다.

const oatmeal = {
  viscosity: 20,
  flavor: 'Brown Sugar Cinnamon',
};

그러나 각 속성은 값 외에도 화면 밖에 숨겨진 정보들이 있는데, 이런 정보들이 각 속성이 어떻게 작동할지를 정의한다. 이것을 속성 설명자라고 한다.

console.log(Object.getOwnPropertyDescriptor(oatmeal, 'viscosity'));

/*
{
  configurable: true,
  enumerable: true,
  value: 20,
  writable: true
}
*/

자바스크립트는 속성에 대한 몇 가지를 추적하고 관리한다.

  • 구성 가능(configurable)은 속성 유형을 변경하거나, 객체에서 속성을 삭제할 수 있는지를 결정한다.
  • 열거 가능(enumerable)Object.keys(oatmeal)를 호출하거나 for 루프에서 사용할 때처럼 객체의 속성을 열거할 때 속성을 표시할지 여부를 제어한다.
  • 쓰기 가능(writable)은 할당 연산자 =를 통해 속성값을 변경할 수 있는지를 제어한다.
  • 값(value)은 접근할 때 표시되는 속성의 정적 값이다. 속성 설명자 중에 유일하게 쉽게 볼 수 있고, 주로 우리가 관심을 두고 보는 부분이다. 함수를 포함한 모든 자바스크립트의 값이 올 수 있으며, 이 속성은 속성을 자신이 속한 객체의 메소드로 만든다.

속성 설명자에는 다른 두 속성이 더 있다. 그 두 가지 속성은 흔히 getter와 setter로 알고 있는 그 접근자 설명자이다.

  • get은 정적인 value 대신 반환 값을 전달하는 함수이다.
  • set은 속성에 값을 할당할 때, 등호 오른쪽에 넣는 모든 것을 인자로 전달하는 특수 함수이다.

장식 없이 꾸며보기

자바스크립트에는 ES5부터 속성 설명자를 다루기 위한 API Object.getOwnPropertyDescriptorObject.defineProperty가 추가되었다. 예를 들어, 오트밀의 두께를 그대로 유지하려면 아래처럼 API를 사용해서 읽기 전용 속성으로 만들 수 있다.

Object.defineProperty(oatmeal, 'viscosity', {
  writable: false,
  value: 20,
});

// `oatmeal.viscosity`를 다른 값으로 설정하면 조용히 실패하게 될 것이다.
oatmeal.viscosity = 30;
console.log(oatmeal.viscosity);
// => 20

객체의 특정 속성의 설명자를 직접 수정하는 범용 decorate함수를 작성할 수도 있다.

function decorate(obj, property, callback) {
  var descriptor = Object.getOwnPropertyDescriptor(obj, property);
  Object.defineProperty(obj, property, callback(descriptor));
}

decorate(oatmeal, 'viscosity', function(desc) {
  desc.configurable = false;
  desc.writable = false;
  desc.value = 20;
  return desc;
});

판자 위에 크라운 몰딩 추가하기

데코레이터 제안과 앞서 살펴본 데코레이터의 첫 번째 다른 점은 데코레이터 제안은 일반 객체가 아니라 ECMAScript 클래스에만 관심이 있다는 것이다. 우리가 실제로 원하는 걸 보여주려면 쓸데없이 거창한 아침 식사를 만들어 먹어야 하므로, 포리지(porridge) 같은 간단한 클래스를 작성해보자.

class Porridge {
  constructor(viscosity = 10) {
    this.viscosity = viscosity;
  }

  stir() {
    if (this.viscosity > 15) {
      console.log('This is pretty thick stuff.');
    } else {
      console.log('Spoon goes round and round.');
    }
  }
}

class Oatmeal extends Porridge {
  viscosity = 20;

  constructor(flavor) {
    super();
    this.flavor = flavor;
  }
}

더욱 일반적인 Porridge 클래스를 상속받아서 오트밀을 표현했다. Oatmeal 클래스의 기본 viscosityPorridge의 기본값보다 높게 설정하고 새로운 flavor 특성을 추가했다. 또한 viscosity 값을 재정의하기 위해 다른 ECMAScript 제안인 클래스 필드(Class field)를 사용했다.

우리는 다음과 같이 기존의 오트밀 한 접시를 다시 만들 수 있다.

const oatmeal = new Oatmeal('Brown Sugar Cinnamon');

/*
Oatmeal {
  flavor: 'Brown Sugar Cinnamon',
  viscosity: 20
}
*/

이제 ES6로 만든 오트밀이 완성되었다. 지금부터 데코레이터를 작성해보자!

데코레이터 작성하는 법

JS 데코레이터 함수에는 세 가지 인자가 전달된다.

  1. target은 현재 인스턴스 객체의 클래스이다.
  2. key는 데코레이터를 적용할 속성 이름이다(문자열).
  3. descriptor는 해당 속성의 설명자 객체이다.

데코레이터의 목적에따라 내부의 동작이 결정된다. 객체의 메서드나 속성을 꾸미려면 새로운 속성 설명자를 반환해야한다. 속성을 읽기 전용으로 만드는 데코레이터를 작성하는 방법은 다음과 같다.

function readOnly(target, key, descriptor) {
  return {
    ...descriptor,
    writable: false,
  };
}

우리가 만들어놓은 Oatmeal 클래스도 조금만 수정하면 가능하다.

class Oatmeal extends Porridge {
  @readOnly viscosity = 20;
  // (@readOnly를 속성 바로 윗 줄에 적어도 된다.)

  constructor(flavor) {
    super();
    this.flavor = flavor;
  }
}

이제 오트밀의 접착제와 같은 점성은 변경에 영향을 받지 않는다.

실제로 유용한 것을 하고 싶다면 뭘 해야 할까? 실제로 필자는 최근에 프로젝트를 진행하면서 데코레이터를 통해 타이핑 및 유지 관리 오버헤드를 줄일 수 있었다.

API 오류 처리하기

글 초반에 언급했던 MobX / React 앱에는 데이터 저장소 역할을 하는 몇 종류의 클래스가 있다. 이 클래스들은 사용자가 상호 작용하는 서로 다른 것들의 모음을 나타내며, 서로 다른 서버에서 데이터를 가져오기 위해 API 엔드 포인트와 통신한다. API 오류를 처리하기 위해 네트워크를 통해 통신할 때 각 저장소의 프로토콜을 따르도록 만들었다.

  1. UI 스토어의 networkStatus 속성을 "loading"으로 설정한다.
  2. API로 요청을 보낸다.
  3. 결과 처리하기

    • 요청이 성공하면 응답으로 로컬 상태를 업데이트하기.
    • 문제가 발생하면 UI 저장소의 apiError 속성을 수신한 오류로 설정하기.
  4. UI 스토어의 networkStatus 속성을 "idle"로 설정하기.

아래와 같은 안티패턴의 코드를 계속 만들어내다 보니, 문득 뭔가 잘못되었다는 생각이 들었다.

class WidgetStore {
  async getWidget(id) {
    this.setNetworkStatus('loading');

    try {
      const { widget } = await api.getWidget(id);
      // 응답이 오면 로컬 상태를 수정하는 일을 수행할 부분
      this.addWidget(widget);
    } catch (err) {
      this.setApiError(err);
    } finally {
      this.setNetworkStatus('idle');
    }
  }
}

이 패턴은 많은 오류를 다룰 수 있는 보일러플레이트(boilerplate)다. 필자는 이미 MobX의 @action 데코레이터를 사용하고 있었고, 관찰 가능한 모든 속성을 업데이트하는 모든 메서드에 데코레이터를 사용하기로 결정했다(단순하게 표현하기 위해 속성들을 다 적지는 않았다.). 그냥 데코레이터 한개를 더 추가함으로써, 필자의 오류 처리 코드를 재사용할 수 있게 되었다. 데코레이터를 추가한 코드는 다음과 같다.

function apiRequest(target, key, descriptor) {
  const apiAction = async function(...args) {
    const original = descriptor.value || descriptor.initializer.call(this);

    this.setNetworkStatus('loading');

    try {
      const result = await original(...args);
      return result;
    } catch (e) {
      this.setApiError(e);
    } finally {
      this.setNetworkStatus('idle');
    }
  };

  return {
    ...descriptor,
    value: apiAction,
    initializer: undefined,
  };
}

모든 API 요청 부분에 사용하던 에러 처리 보일러플레이트를 아래와 같이 바꿀 수 있었다.

class WidgetStore {
  @apiRequest
  async getWidget(id) {
    const { widget } = await api.getWidget(id);
    this.addWidget(widget);
    return widget;
  }
}

내 오류 처리 코드는 여전히 존재하지만, 이제는 한 번만 작성하면 된다. 그리고 데코레이터를 사용하기 전에 각 클래스에 setNetworkStatussetApiError 메소드가 있는지 확인해야 한다.

Babel을 위한 방식

그런데, original을 얻기 위해 descriptor.value를 직접 사용하거나 descriptor.initializer를 호출 고민하는 코드는 왜 들어간 걸까? 그 코드는 바로, 바벨 때문에 추가되었다. 자바스크립트가 데코레이터를 정식 지원할 때는 그런 식으로 동작하지 않을 것이다. 하지만, 바벨이 클래스 속성으로 정의된 화살표 함수를 처리하는 방식 때문에 지금은 저런 코드가 필요하다.

클래스의 속성을 정의하고 그 속성에 화살표 함수를 값으로 할당하면 바벨은 해당 함수를 클래스의 인스턴스에 직접 바인딩하고 올바른 this 값을 제공하기 위해 약간의 트릭을 수행한다. 이 트릭은 descriptor.initializer를 사용자가 작성한 함수를 반환하는 함수로 할당하고 스코프에서 this 값을 올바르게 설정하여 수행한다.

이를 명확하게 설명할 수 있는 예제를 가져왔다.

class Example {
  @myDecorator
  someMethod() {
    // 이 경우, 메서드는 descriptor.value를 참조할 것이다.
  }

  @myDecorator
  boundMethod = () => {
    // 여기서 descriptor.initializer는 함수 자신이 될것이다. 이 함수가 실행되면 `boundMethod`함수를 반환할 것이고 스코프가 올바르게 만들어져서 `this`는 실행 당시의의 Example의 인스턴스 일 것이다.
  };
}

클래스 장식하기

속성과 메서드를 장식하는 대신 전체 클래스를 장식할 수도 있다. 그렇게 하려면 데코레이터 함수의 첫 번째 인자로 전달할 target만 있으면 된다. 예를 들어, 어떤 요소를 자동으로 사용자 정의 HTML 요소로 HTML 문서에 등록하는 데코레이터예제를 만들어보자. 이 예제에서는 클로저를 사용해서 데코레이터가 요소 이름을 인자로 받을 수 있도록 할 것이다.

function customElement(name) {
  return function(target) {
    customElements.define(name, target);
  };
}

아마도 이렇게 사용할 수 있을 것이다.

@customElement('intro-message');
class IntroMessage extends HTMLElement {
  constructor() {
    super();

    const shadow = this.attachShadow({ mode: 'open' });
    this.wrapper = this.createElement('div', 'intro-message');
    this.header = this.createElement('h1', 'intro-message__title');
    this.content = this.createElement('div', 'intro-message__text');
    this.header.textContent = this.getAttribute('header');
    this.content.innerHTML = this.innerHTML;

    shadow.appendChild(this.wrapper);
    this.wrapper.appendChild(this.header);
    this.wrapper.appendChild(this.content);
  }

  createElement(tag, className) {
    const elem = document.createElement(tag);
    elem.classList.add(className);
    return elem;
  }
}

HTML에 불러오면 아래 HTML 코드가 만들어질 것이다.

<intro-message header="Welcome to Decorators">
  <p>Something something content...</p>
</intro-message>

그리고 브라우저에서는 아래처럼 보일 것이다.

result

정리

지금 바로 여러분의 프로젝트에서 데코레이터를 사용하려면 약간의 트랜스파일러 설정이 필요하다. 내가 본 가장 간단한 안내서는 MobX 문서이다. 그 문서에는 TypeScript 및 Babel의 두 가지 주요 버전에 대한 정보가 있다.

현시점의 데코레이터는 제안(proposal) 단계라서 구현과 명세가 계속해서 발전하고 있다. 만약 프로덕션 코드에서 제안 단계의 데코레이터를 사용하고 있다면, 추후에 데코레이터 코드에 약간의 조치가 필요할 수 있다. ECMAScript의 정식 명세에 데코레이터가 추가되면 데코레이터 관련 업데이트를 반영해서 코드를 수정하거나, Babel의 데코레이터 플러그인을 레거시 모드에서 계속 사용해야 할 것이다. 아직 Babel도 데코레이터를 제대로 지원하지 못하고 있는데, 최신 버전의 데코레이터 제안에는 이미 이전 버전의 데코레이터 코드와 호환되지 않는 큰 변경 사항이 있었기 때문이다.

데코레이터는 자바스크립트의 다른 최신 기능들처럼 사용하면 도움이 되는 도구 중 하나이다. 서로 관련이 없는 클래스들 사이의 동작 공유를 상당히 단순화 시킬 수 있다. 다른 새로운 기능들과 마찬가지로, 필자는 정식 출시가 되기 전에 적용해서 발생하는 비용은 어쩔 수 없다고 생각한다. 데코레이터를 사용하되, 각자의 코드 베이스의 구현을 명확히 파악해야 한다.

박정환2020.01.02
Back to list