[es2015+] constructor 응용하기

개요top

자바스크립트에서 생성자는 객체를 생성할 때 최초로 호출되는 함수를 의미합니다. 자바스크립트 버전에 따라 생성자 시스템은 많은 변화를 겪었습니다. 이에 대해서는 이미 다른 포스트에서 굉장히 상세히 다뤘습니다.

  1. ES3.1의 생성자와 그 의미 – 자바스크립트와 클래스
  2. ES5의 생성자와 그 의미- Object 1. 새로운 방식의 객체 생성
  3. ES6+의 클래스 시스템과 생성자 – [es2015+] class문은 특별할까?

위의 포스팅에서 충분히 자세하게 버전별 생성자에 대해 다루고 있습니다만 마지막 ES6+의 경우는 클래스 시스템을 다루는데 더 중점을 뒀기 때문에 이번 포스팅에서는 오롯히 ES6+의 생성자에만 집중해보죠.

프로토타입에서의 생성자 시스템top

부모 클래스가 Parent고 자식 클래스가 Child인 경우를 상정하여 글을 전개해 가겠습니다.

ES5까지 new키워드를 사용한 객체 생성은 내부에 프로토타입 체인을 만들어주는 일종의 매크로에 가까웠습니다.
하지만 프로토타입 체인 부분을 좀 배제하고 new로 만들어지는 객체에 대해서 집중해볼 필요가 있습니다.

var Parent = function(){};
var Child = function(){};
Child.prototype = new Parent;

var child = new Child;

위의 전형적인 프로토타입체인을 이용한 상속시스템에서 만들어진 child의 정체는 뭘까요?

  1. Object의 인스턴스이면서
  2. [[Prototype]]속성이 Parent.prototype인 객체입니다.

즉 ES5까지는 new를 사용해서 객체를 만들면 항상 Object의 인스턴스를 만드는 셈입니다. 단지 연결되는 [[Prototype]]만 바뀌는거죠.
뭘 어떻게 해도 이건 변하지 않는 구조였습니다.

그럼 약간 생각을 바꿔보죠. 대체 Object말고 어떤 인스턴스를 만들고 싶은지.

  • 만약 Function의 인스턴스를 만든다면 호출할 수 있게 될 것입니다.
  • 만약 Array의 인스턴스를 만든다면 자동으로 length관리가 될테죠.
  • 만약 Date라면 생성시점에 시간을 알고 있을 것입니다.

결국 자바스크립트 코어객체를 상속받는 인스턴스를 만들 수 있다면 처음부터 기본 객체의 혜택을 받을 수 있겠죠. 이 니즈는 꾸준히 재기되어 왔습니다. ES6부터는 이를 반영하여 완전히 새로운 방식으로 객체를 만들게 변경되었습니다.

ES6에 오면서는 Parent의 인스턴스를 만든 뒤 이 인스턴스가 Child로부터 비롯되었다는 것을 남겨두는 방식으로 변경됩니다(new.target과 homeObject의 개념을 사용합니다)

이건 쉬운 개념이니까 스펙 좀 보면 알 수 있는 내용이고 처음 언급한 마지막 링크에 상세히 설명되어있습니다만, 생성자의 또 다른 특성인 객체 반환에 대해서는 잘 알려져 있지 않습니다.

이전 ES5까지는 다음과 같이 생성자가 객체를 반환하는 경우 new의 결과가 그 객체로 반환되는 스펙이 존재했습니다.

var Test = function(){
  return [];
};

console.log((new Test) instanceof Array); //true

생성자가 객체타입을 반환하면 new연산자의 결과는 해당 생성자의 객체가 아니라 반환된 객체가 되어버리는 거죠.

super가 생성자에 주는 영향top

이 특성은 es6에서 한층 더 발전되어 부모계층에서 반환된 객체가 this를 변경할 수 있는 스펙으로 확장되었습니다

super keyword runtime semantics evaluation

이 스펙을 좀 자세히 보죠.

SuperCall:superArguments 슈퍼생성자가 호출되었을 때

  1. Let newTarget be GetNewTarget(). : newTarget을 얻고
  2. Assert: Type(newTarget) is Object. : newTarget이 Object냐
  3. Let func be ? GetSuperConstructor(). : super생성자가 함수냐
  4. Let argList be ArgumentListEvaluation of Arguments. : 갸에게 보낼 인자 정리
  5. ReturnIfAbrupt(argList). : 인자 확인
  6. Let result be ? Construct(func, argList, newTarget). : super생성자 호출결과
  7. Let thisER be GetThisEnvironment( ). : 현재 this를 읽어와서
  8. Return ? thisER.BindThisValue(result). : 6번 결과에 따라 this를 재바인딩

이 스펙을 읽어보면 부모생성자가 호출되면 this바인딩을 바꿀 수 있다는 사실을 알게 됩니다. 즉 상속층에서는 부모의 생성자가 객체를 반환하는 경우 super호출 이후의 자식 생성자 내부에서 this가 바뀌어 있을 수 있다는 의미입니다.

const Parent = class{
  constructor(){
    return []; //부모 생성자가 배열 반환
  }
};

const Child = class extends Parent{
  constructor(){
    super();
    console.log(Array.isArray(this)); //true!!
    console.log(this.methodA); //undefined
  }
  methodA(){}
};

new Child;
  1. Child의 생성자에서 분명 처음 this는 Child의 인스턴스였을 것입니다.
  2. 하지만 super를 호출하는 순간 Parent의 생성자가 호출되었겠죠.
  3. Parent의 생성자는 []로 배열의 인스턴스를 반환하고 있습니다.
  4. 이 순간 스펙에 따라 따라서 자식쪽 생성자에서 super()를 호출한 뒤의 this를 조사해보면 이미 배열객체로 this가 변경되어있음을 알 수 있습니다.
  5. 또한 배열객체가 되어버렸으니 Child의 methodA따위는 이미 this에 존재하지 않게 됩니다.

이 모든 장치는 원래 이런 목적은 아니고 부모쪽 객체가 실제 this로 환원되게 하는 일종의 newTarget체인 시스템의 일부입니다만 덕분에 위와 같은 일이 가능해지는 거죠.

이렇게 부모에서 this를 변경할 수 있는 특성을 이용하면 다양한 응용이 가능해집니다.

WeakMap을 이용한 Singletontop

클래스별로 유일한 인스턴스를 갖게 하고 싶다면 개별로 정의할 수도 있겠지만 간단히 Singleton이란 외부 객체를 사용하는 트릭을 써볼 수도 있습니다.

아이디어는

  1. WeakMap을 통해 키를 생성자함수로, 값을 유일한 싱글톤 객체로 잡아준다.
  2. 이후 싱글톤 객체만 반환하는 getInstance라는 메소드를 제공한다.

요렇게 간단합니다. 실제로 사용하는 쪽에서는 getInstance에 this같은 객체를 보내기 때문에 this.constructor속성으로 생성자를 얻을 수 있습니다.
하지만 constructor는 변조되기도 쉽고 위험하니 직접 생성자를 new.target으로 받는 인터페이스를 구축하는 편이 좋겠죠.

우선 이런 아이디어를 바탕으로 싱글톤을 만들어주는 클래스를 만들어보죠.

const Singleton = class extends WeakMap{
  has(){throw '';}
  set(){throw '';}
  get(){throw '';}
  getInstance(constructor){ 
    //아직 없다면 클래스별로 싱글톤 객체를 하나 만든다.
    if(!super.has(constructor)) super.set(constructor, new constructor);

    //싱글톤 객체를 반환한다.
    return super.get(constructor);
  }
};
  1. 우선 간단히 WeakMap을 상속하여 맵의 키에 생성자를 받을 수 있게 합니다.
  2. Singleton을 상속한 클래스가 WeakMap으로 동작하기를 바라지 않기 때문에 has, set, get을 예외로 몰아버리고 super의 메서드를 사용합니다.

일단 이렇게 만들었다면 이걸 이용하는 클래스를 만들 수 있겠죠.

//싱글톤처리기
const singleton = new Singleton;

const Parent = class{
  constructor(){
    //싱글톤을 반환한다.
    return singleton.getInstance(new.target);
  }
};

//new.target으로부터 만들으니 Parent의 인스턴스다.
console.log(new Test instanceof Parent); //true

//헌데 싱글톤이므로 아무리 new를 때려도 같은 객체다.
console.log(new Parent === new Parent); //true

이건 어찌보면 ES5까지의 지식 수준에서도 동일한 결과를 얻을 수 있습니다.
하지만 이제 ES6부터는 상속으로도 이걸 처리할 수 있으므로 부모측에서 싱글톤 여부를 감추고 자식에서는 선택적으로 싱글톤이나 개별 객체를 얻을 수 있게 됩니다.

const SingletonParent =(_=>{
  const singleton = new Singleton;
  return class{
    constructor(){
      return singleton.getInstance(new.target);
    }
  };
})();

const Child = class extends SingletonParent{
  constructor(){
    super();
  }
};

//이제 Child는 무조건 싱글톤이다.
console.log(new Child === new Child);

더 나아가 일반적인 MV계열의 프레임웍이나 DI시스템에서 객체의 생명주기를 Application(혹은 Singleton), Session, Request 등으로 잡는 것처럼 얼마나 길게 생명유지를 할 것이냐를 결정하죠. 그대로 흉내내서 생성 시의 인자로 각 생명 주기에 맞는 객체를 반환하도록 추상화 할 수 있습니다. 아래 예제에서는 구상 Model을 생성하는 시점에서 어떤 라이프사이클로 객체를 얻을지 인자에 적당한 값을 넘겨 처리하도록 하고 있습니다.

  • Application – 가장 길게 유지함
  • Session – 개별 세션이 유효할 때까지 유지함
  • Request – 개별 요청이 유효할 때까지 유지함
  • class – 개별 클래스별로 하나씩 유지함

우선 생명주기 객체부터 간단히 정의해볼까요.

const Application =(_=>{
  const singleton = new Singleton;
  let application;
  return class{
    constructor(key){
      return application || (application = new Application);
    }
  };
})();
const Session = class{
  constructor(key){
    this.key = key;
  }
};
const Request = class{};

Application은 유일한 객체가 나와하니 마찬가지 요령으로 싱글톤을 처리하고 있고 그 외엔 평범한 클래스들입니다.
Model측에서 받아들일 수 있는 형이 위에 정의된 것들이라 검사하기 귀찮으니 간단한 도우미 함수를 작성합니다.

//형검사 모두 통과
const is =(target, ...cls)=>cls.some(cls=>target instanceof cls);

is함수는 대상에 대해 여러 클래스 중 하나이길 원합니다. 이제 추상 모델을 간단히 작성할 수 있습니다.

const Model =(_=>{
  const singleton = new Singleton;
  return class{
    constructor(v = new.target){ //특별한 지시가 없으면 생성자로 처리

      //지정된 타입이거나 new.target만 허용
      if(!is(v, Application, Session, Request) && v !== new.target) throw '';

      return singleton.getInstance(v);
    }
  };
})();

싱글톤의 키만 조정해주면 되는 문제니 기존의 생성자 외에도 라이프사이클 객체를 받아주도록 개조합니다.
WeakMap은 특성 상 키가 되는 객체가 GC되는 시점에 알아서 값도 제거되기 때문에 정확히 우리의 의도에 일치하여 Session이나 Request가 제거될 때 같이 정리될 것입니다(weakmap-objects)

이제 Model을 상속받는 ListModel 구상클래스를 만들어보죠.

const ListModel = class extends Model{
  constructor(target){
    super(target);
  }
};

타겟을 넘길 뿐 거의 모든 처리는 추상 클래스에서 하게 되었습니다. 이제 차근차근 사용해보겠습니다.

//ListModel레벨의 싱글톤 == 정적속성레벨
console.log(new ListModel === new ListModel); //true

생성자에 인자가 없는 경우는 new.target이 키로 지정되므로 클래스 레벨에서 동일한 인스턴스가 반환됩니다.
이건 보통 static속성에 인스턴스를 잡아주던 전통적인 싱글톤 모델에 가깝죠.

//application레벨의 싱글톤
const app = new Application;
console.log(new ListModel(app) === new ListModel(app)); //true

이에 반해 싱글톤으로 반환되는 Application 객체를 인자로 넘겼을 때 ListModel은 동일한 싱글톤을 반환합니다. 마찬가지로 Session이나 Request는 각 인스턴스별로 싱글톤이 제공될 것입니다.

//session별 싱글톤
const s0 = new Session('hika');
const s1 = new Session('maeng');

//같은 세션이면 같은 객체
console.log(new ListModel(s0) == new ListModel(s0)); //true

//다른 세션끼리는 다른 객체임
console.log(new ListModel(s0) == new ListModel(s1)); //false

간단한 싱글톤에서부터 DI레벨의 싱글톤까지 전부 일관된 new문법으로 해결할 수 있습니다. 기존에는 라이프사이클을 기준으로 객체를 생성하는 스타일이 많았는데 super의 this전이를 이용하면 오히려 구상측의 생성 문법은 그대로 new로 유지하고 인자를 통해 싱글톤의 종류를 결정할 수 있는 거죠.

프록시 추상화top

또 다른 재밌는 활용 방법은 프록시객체를 각 구상클래스에서 중복적으로 정의하지 않고 추상 클래스에서 정의해서 내릴 수 있다는 점입니다.
인스턴스별로 프록시가 되는 경우는 보통은 두 가지 케이스가 많습니다.

  1. callable 타입의 인스턴스를 정의한 경우
  2. getter를 일괄로 지정하고 싶을 때

callable 대응

우선 1번 케이스의 대표적인 형태를 살펴보죠.

const Test =(_=>{
  const trap = {apply(self, _, arg){
    self.a = arg[0];
  }};
  return class extends Function{
    constructor(){
      return new Proxy(this, trap);
    }
  };
})();

const test = new Test;
test(3);
console.log(test.a); //3

보통 호출 가능한(callable) 인스턴스를 만드려면 Function을 상속하면 되긴 하지만 Function을 상속한다 해도 호출 시점에 뭘 할 수 있는 것은 아닙니다. 유일한 방법은 super(인자, 함수몸체) 형태로 new Function과 비슷하게 만들어야 하지만 이렇게 하면 this와 연결할 방법이 없습니다(new Function은 항상 전역 컨텍스트가 되어버리기 때문이죠)

인스턴스로서 유지되면서 호출가능한 인스턴스가 되는 쉬운 방법이 apply트랩을 가진 프록시로 변환하는 것입니다. 이 경우 함수로서의 작동을 별도로 정의하고 추상층에서 프록시 관련 처리를 하도록 역할을 분리할 수 있습니다.

const Callable =(_=>{
  const trap = {apply(self, _, arg){
    return self.apply(...arg); //apply메소드에 위임
  }};
  return class extends Function{
    constructor(){
      super();
      return new Proxy(this, trap);
    }
    apply(...arg){
      throw 'override!';
    }
  };
})();

이제 구상 클래스에서는 apply를 오버라이드하면 함수로 호출될 때의 작동을 정의할 수 있습니다.

const Actor = class extends Callable{
  constructor(base){
    super();
    this._base = base;
  }
  apply(a){
    console.log(this._base, a); //속성과 인자 둘다 사용!
  }
};

new Actor(10)(5); //10, 5

호출 가능한 인스턴스 제작 시 구상 클래스를 손쉽게 만들 수 있게 되었습니다.

getter, setter타입 처리

이젠 대량속성이나 런타임에 정의되는 속성을 처리하기 위한 프록시를 추상 클래스에 위임해봅니다.
우선 get, set트랩에서 처리할 일을 재위임하는 구조는 위의 apply타입과 동일하겠죠. 이제 두 번째니 좀 간략하게 가겠습니다 ^^;

const CalcProp =(_=>{
  const trap = {
    get:(self, key)=>self.get(key),
    set:(self, key, val)=>self.set(key, val)
  };
  return class{
    constructor(){return new Proxy(this, trap);}
    get(key){throw 'override!';}
    set(key, val){throw 'override!';}
  };
})();

이제 구상 클래스의 get, set로 위임되었으니 알아서 구현하면 될 것입니다. 보통 private로 사용하는 _나 원래 자신의 속성을 보호하고 나머지 속성만 Map에 처리하되 get, set시에 console.log를 자동으로 뿌려주는 기능을 추가해보죠.

const LoggingData = class extends CalcProp{
  constructor(){
    super();
    this._map = new Map;//속성기록용 map
  }
  forEach(f){
    this._map.forEach(f);
  }
  get(key){
    //원래 속성과 _로 시작하는 속성을 보호
    if(key[0] == '_' || key in this) return this[key];

    //로그를 무조건 찍어주고
    console.log('get:', key);

    //내부 맵을 참조한다.
    this._map.get(key);
  }
  set(key, val){
    //get과 대동소이..
    if(key[0] == '_' || key in this) return this[key] = val;
    console.log('set:', key, val);
    this._map.set(key, val);
  }
};

이제 구상화된 클래스에서는 내부 map에 데이터를 저장하고 get, set에 반응합니다. forEach도 map에 위임하죠.
실제 사용하면 아래와 같이 될 것입니다.

const data = new LoggingData;
data.a = 3; //set: a 3
data.b = 5; //set: b 5
console.log(data.a); //get: a 나오고 3
data.forEach((val, key)=>console.log(key, val));
//a 3
//b 5

결론top

ES6의 생성자 스펙은 this를 바꿀 수 있는 강력한 기능을 포함합니다.
이에 더해 HomeObject를 통해 코어객체나 상위객체의 인스턴스를 만들게 되므로 이 둘을 한꺼번에 이용하여 다양한 기능을 구현할 수 있습니다.