[es6] Proxy #2

개요

이미 한번 Proxy의 기초를 다뤘으므로 보다 본격적인 활용을 해보죠. 사실 별로 매력적이지 않았던 Proxy가 최근 업데이트에서 대부분의 브라우저가 지원함에 따라 본격적인 활용이 가능해졌습니다. Proxy는 다른 es6특징에 비해 더욱 언어를 확장하는 효과가 있습니다. 즉 js의 언어적인 한계라고 여겨진 것을 대부분 커버할 수 있게 됩니다. 이번 포스팅에서는 이러한 언어개조로서 subscript에 Proxy를 활용하는 방법을 집중적으로 다뤄보겠습니다. 본 포스팅은 Proxy와 Symbol에 대한 기본적인 이해를 요구하므로 다른 글을 읽으시면 도움이 됩니다.

subscript의 개념

Subscript는 인스턴스에 동적인 속성을 추가하거나 조회하는 기능입니다. 언틋 생각하면 js의 모든 객체는 해시맵이므로 당연히 되는거잖아? 라는 느낌입니다.
물론 됩니다. 아래처럼..

class Test{}
const t = new Test();
t[0] = 3;
t['aaa'] = 5;

하지만 이게 되기는 하지만 모던 언어들이 갖고 있는 기능을 제공해주는 것은 아닙니다.

  1. 무엇보다 정적으로 형의 정의해서 쓰는 의미가 없어질뿐만 아니라
  2. 저렇게 설정되는 속성의 get이나 set시점에 아무것도 관여할 수 없게 됩니다.

이와 비교하여 Subscript을 지원하는 Swift와 c#의 예를 보겠습니다.
안보시던 분들에겐 좀 생소할 수 있는데, 언어의 디테일보다는 선언되는 큰 그림을 보시길 바랍니다(es6도 제법 모던언어의 형태를 갖추고 있는지라 그렇게까지 생소하시진 않을 것…이라고 말하면 뻥이고..^^)

간단히 설명하자면 여러가지 Item을 담는 Bag을 만들게 됩니다. 객체를 나타내기 위해 일부러 Id라는 인스턴스로 키를 지정했습니다. Id의 인스턴스를 키로 해서 Item객체를 저장하는 예를 Swift부터 살펴보죠.

[swift]
class Id{
private let key:String
Id(key:String){
self.key = key;
}
}
class Item{}
class PinItem:Item{}

class Bag{

private items = NSMutableDictionary()

subscript(itemName: Id) -> Item?{
get{return items[itemName] as? Item}
set{items[itemName] = newValue}
}
}

let mybag = Bag()
let id = Id("pin")
mybag[id] = PinItem()
print("\(mybag[id])");
[/swift]

위에서 Id, Item, PinItem을 정의했고 이어서 Bag내부에 딕셔너리를 하나 잡아두었습니다. subscript라는 미리 약속된 키워드로 메소드를 정의하면 swift에서는 이를 외부에 대괄호 속성을 지정할 수 있는 인터페이스로 노출하게 됩니다. Id를 키로 받아 Item을 반환하는 구조를 정의한뒤 get과 set을 이용해 각각을 정의합니다.
(set의 newValue는 swift에서 미리 지정되어있는 set용 인자이름입니다)
c#은 더욱 직관적인 시그니처로 되어있습니다. 메소드의 정의 시 아예 대괄호를 명시하는 스타일입니다.

class Id{
  private String key;
  public Id(String key){
    this.key = key;
  }
}
class Item{}
class PinItem:Item{}

class Bag{ 

  Dictionary<Id, Item> items = new Dictionary<Id, Item>();

  public Item this[Id key] {
    get{return items[key];}
    set{items[key] = value;}
  }
}

Bag mybag = new Bag();
Id id = new Id("pin")
mybag[id] = new PinItem();
System.Console.WriteLine("{0}", mybag[id]);

그외의 코드는 대동소이하므로 설명을 생략합니다. 이렇듯 언어차원에서 보다 명시적인 subscript을 지원하는 경우 형일치를 검사할 수 있을 뿐만 아니라 get, set시점에 개입하여 내부 정책을 가져가면서도 캡슐화가 보장되므로 외부의 대괄호를 사용하는 호스트코드 변화없이 내부를 뜯어고칠 수 있습니다.
또한 이들의 subscript은 override가 가능하므로 키의 형에 따라 다른 정책을 세우는 것도 가능합니다. 에를들어 Bag에 키가 Id가 아니라 String에 들어온다면 다음과 같이 대응할 수 있을 것입니다.

[swift]
class Bag{

private items = NSMutableDictionary()

subscript(itemName: Id) -> Item?{
get{return items[itemName] as? Item}
set{items[itemNAme] = newValue}
}
subscript(itemName: String) -> Item){
get{return items[Id(itemName)] as? Item}
set{items[Id(itemName)] = newValue}
}
}

Bag mybag = new Bag();
//Stirng이 온경우도 처리된다!
mybag["pin"] = new PinItem();
[/swift]

대략 여기까지 따라오시는데 성공하셨으면…(죄송^^) 이제 이를 es6에서 어떻게 할 것인가를 생각해볼 차례입니다.
물론 기존의 js에서 이렇게 언어차원에서 제공하는 기능을 만들 방법은 없습니다. 하지만 es6의 Proxy는 get, set 트랩이 준비되어있습니다.

Proxy를 이용한 subscript구현

우선 subscript을 구현하는 전략을 다음과 같이 세워보죠.

  1. Symbol private패턴으로 내부에 subscript용 저장소를 Map으로 생성한다.
  2. 생성자가 Proxy를 통해 사전에 정의된 trap에서 get, set을 처리하도록 변경한 객체를 반환한다.

우선 언어의 세부사항을 무시한체 위의 c#코드를 es6로 번역해보면 다음과 같을 것입니다.

const Item = class{};
const PinItem = class extends Item{};

const Bag = (()=>{
  const prop = Symbol(); //private을 위한 심볼
  
  //해당 객체의 prop속성에 할당된 Map에 get, set을 한다.
  const trap = {
    get(target, key){
      //Symbol만 받아준다.
      if(typeof key == 'symbol') return target[prop].get(key);
      throw "invalid type of key";
    },
    set(target, key, value){
      //symbol과 Item체크
      if(typeof key == 'symbol' && value instanceof Item) target[prop].set(key, value);
      throw "invalid type of key or value";
    }
  };
  return class{
    constructor(){
      //get, set용 Map을 prop속성에 넣어준다.
      this[prop] = new Map();
      //Proxy객체를 반환한다.
      return new Proxy(this, trap);
    }
  };
})();

const mybag = new Bag();
const id = Symbol();
mybag[id] = new PinItem();
console.log(mybag[id]);

안타깝게도 키에 들어갈 Id클래스는 es6의 Proxy한계로 번역되지 못했습니다. get, set트랩은 오직 key에 대해 String과 Symbol만 허용합니다. 따라서 문자열이 아닌 키를 쓰고 싶다면 Symbol만 허용됩니다. 게다가 Symbol은 상속되지 않고 속성을 갖을 수도 없으므로 Symbol을 확장한 객체를 만드는 것도 불가능합니다. 사실 여기에 대응하는 아주 나쁜 방법이 하나 있긴합니다. 일명 Symbol트릭이라는 방법입니다.

Symbol trick을 이용한 Id클래스의 구현

es6에서는 toString이나 valueof를 대체할 새로운 기본형대응 메소드가 정의되었는데 객체 내부에 [Symbol.toPrimitive]메소드를 구상하면 됩니다. 말보단 MDN의 코드를 소개하죠.

MDN Symbol.toPrimitive

여기에 등장하는 코드를 간단히 발췌하면 다음과 같습니다.

var obj2 = {
  [Symbol.toPrimitive](hint) {
    if (hint == "number") {
      return 10;
    }
    if (hint == "string") {
      return "hello";
    }
    return true;
  }
};
console.log(+obj2);     // 10      -- hint is "number"
console.log(`${obj2}`); // "hello" -- hint is "string"
console.log(obj2 + ""); // "true"  -- hint is "default"

hint라는 인자에 친절하게도 상황에 맞춰 “number”, “string”, “default”가 들어옴으로 여기에 맞춰 적절한 값을 반환할 수 있게 합니다. 말이 toPrimitive지 이 메소드에서 뭘 반환하든 맘대로입니다. 즉 문자열을 요구받는 상황에서도 객체나 숫자를 반환해도 된다는 것입니다(물론 런타임에러를 일으키겠지만요 ^^)
어떤 객체의 키로서 대괄호 안에 해당 객체가 놓여지는 상황은 일반적으로 toString상황입니다. 즉 hint에 “string”이 들어오는 상황이죠. 하지만 귀찮으니까 hint를 무시하고 전부 Symbol을 반환하도록 Id클래스를 작성해보죠.

const Id = (()=>{
  //private용 symbol
  const symbol = Symbol();

  return class{
    constructor(key){
      this.key = key;
      //primitive용 Symbol
      this[symbol] = Symbol();
    }
    [Symbol.toPrimitive](hint){
      //무조건 심볼을 반환하자.
      return this.symbol;
    }
  };
})();

어떤가요. 기존의 uuid같은 문자열로 고유키를 만드는 것보다는 훨씬 깔끔하고 안전한 고유값 객체가 됩니다. 이를 문자열에 넣거나 연산시에 사용하면 예외가 발생되니 조심해야합니다.
(Symbol.toPrimitive는 toString이나 valueof보다 우선순위가 높아서 전부 무시되고 오직 toPrimitive만 작동하게 됩니다)

Bag의 get, set트랩입장에서는 여전히 Symbol이지만 외부에서는 Id클래스를 사용하고 key속성도 갖을 수 있게 되었습니다. 수정된 전체 코드를 보죠.

const Id = (()=>{
  const symbol = Symbol();
  return class{
    constructor(key){
      this.key = key;
      this[symbol] = Symbol();
    }
    [Symbol.toPrimitive](hint){
       return this.symbol;
    }
  };
})();
const Item = class{};
const PinItem = class extends Item{};

const Bag = (()=>{
  const prop = Symbol();
  const trap = {
    get(target, key){
      if(typeof key == 'symbol') return target[prop].get(key);
      throw "invalid type of key";
    },
    set(target, key, value){
      if(typeof key == 'symbol' && value instanceof Item) target[prop].set(key, value);
      throw "invalid type of key or value";
    }
  };
  return class{
    constructor(){
      this[prop] = new Map();
      return new Proxy(this, trap);
    }
  };
})();

const mybag = new Bag();
const id = new Id("pin");
mybag[id] = new PinItem();
console.log(mybag[id]);

인스턴스별로 Symbol을 생성하니 고유값을 보장하고 해당 인스턴스가 아니면 그 Symbol을 얻을 수 없으니 다른 방법으로 해당 값을 찾을 수도 없게 됩니다.

크로스브라우저 style처리기

get, set 트랩을 활용하여 타언어에 있는 subscript을 구현해봤습니다. 언어에 대한 구현이라 너무 딱딱한 면이 있으니 실무적인 예제를 하나 다뤄보죠.
어떤 DOM객체에 스타일을 적용하려고 하면 해당 속성이 표준대로 작동하는지 아니면 접두어를 붙여야하는지 항상 고민스럽습니다. 뿐만 아니라 지원되지 않는 속성도 있기 마련인데 이때는 부드럽게 무시하고 싶죠. 이를 간단히 처리할 수 있는 get, set 트랩을 구현해보죠.

  1. 우선 어떤 style의 key가 들어왔을 때 해당 key가 실제로 존재하는지 확인할 방법이 필요합니다. 이를 위해 body엘리먼트를 하나 만들고 그 style에 in을 통해 조회하는 수법을 사용하기로 합니다.
  2. 없는 경우 prefix를 붙인 속성을 검색해봅니다.
  3. 만약 prefix를 붙인 속성도 존재하지 않는다면 무시하고
  4. 문제없다면 해당 속성에 값을 지정해주도록 하죠.
  5. 값이 숫자인데 ‘px’를 빠트린 거라면 자동으로 px를 붙여주는 기능도 set에 추가합니다.
  6. 이를 위해 숫자인 경우만 분리하여 body의 스타일에 px를 붙인 값을 넣어보고 실제 px가 남아있는지를 확인하는 식으로 구현합니다.

이상의 구현계획이 나왔으니 트랩을 차근차근 정의해가죠.

//각종 스타일을 테스트할 테스터를 생성한다.
const tester = document.createElement('body').style;

//style속성용 prefix
const prefix = ['webkit', 'moz', 'ms'];
//prefix를 포함한 키 존재 여부를 검사하는 함수
const styleKey = key=>{
   if(!(key in tester)){//해당키가 존재하지 않는경우
    //prefix조합이 존재하는지 차례로 검사
    const result = prfix.filter(v=>v + key in tester);
    if(result.length){ //존재하면 키를 변경
      key = result[0] + key;
    }else{//존재하지 않는 키의 처리
      return false;
    }
  }
  return key;
}
//Proxy용 trap의 정의
const trap = {
  get(target, key){
    //키검사
    key = styleKey(key);
    //키가 존재하면 해당 스타일값을, 없으면 빈문자열을 반환한다.
    return key ? target.style[key] : '';
  },
  set(target, key, value){
    //키검사
    key = styleKey(key);
    if(!key) return; //없는키 무시
    if(typeof value == 'number'){//숫자일때 px처리
      //우선 테스터에 px를 붙여서 넣어보자.
      tester[key] = value + 'px';
      //여전히 붙어있다면 이거슨 px붙이는거다.
      if(tester[key].indexOf('px') > -1){
        value = value + 'px';
      }
    }
    target.style[key] = value;
  }
};

//이제 trap을 붙여주는 간단한 dom래퍼를 만들면 된다.
const Dom = class{
  constructor(selector){
    return new Proxy(document.querySelector(selector), trap);
  }
};

const menu1 = new Dom('#menu1');
menu1.borderRadius = '5px';

결론

Proxy의 get, set은 복잡한 subscript정책을 가능하게 하는 굉장히 강력한 기능입니다.
원래 constructor에서 new Proxy를 반환하는 패턴으로 생성된 객체는 원본 클래스의 instanceof를 활용할 수 있습니다. 즉 아래와 같습니다.

console.log(new Proxy([]) instanceof Array); //true

하지만 위에서 다룬 Dom은 element를 Proxy의 인자로 보냈기 때문에 instanceof Dom이 성립하지 않습니다. 이를 개선하려면 직접 엘리먼트를 넘기는 것이 아니라 본인의 속성으로서 넘겨야합니다. 이 내용을 포함한 전체 코드는 다음과 같습니다.

const Dom = (()=>{
  const dom = Symbol();//private용 심볼

  const tester = document.createElement('body').style;
  const prefix = ['webkit', 'moz', 'ms'];
  const styleKey = key=>{
    if(!(key in tester)){
      const result = prfix.filter(v=>v + key in tester);
      if(result.length){
        key = result[0] + key;
      }else{
        return false;
      }
    }
    return key;
  };
  const trap = {
    get:(target, key)=>(key = styleKey(key)) ? target[dom].style[key] : '',
    set:(target, key, value)=>{
      if(!(key = styleKey(key))) return;
      if(typeof value == 'number'){
        tester[key] = value + 'px';
        if(tester[key].indexOf('px') > -1) value = value + 'px';
      }
      target[dom].style[key] = value;
    }
  };
  return class{
    constructor(selector){
      //private속성에 넣어주고
      this[dom] = document.querySelector(selector);
      //this를 보내 Dom의 인스턴스를 성립시킨다.
      return new Proxy(this, trap);
    }
  };
})();
const menu1 = new Dom('#menu1');
menu1.borderRadius = '5px';

console.log(menu1 instanceof Dom); //true
%d 블로거가 이것을 좋아합니다: