[js] 코틀린 delegated Property 흉내내기

개요top

코틀린에서는 위임된 속성(delegated property)이란게 있습니다. 이는 getter와 setter를 가진 객체에게 속성을 대신해서 작동하도록 연결해주는 일종의 매크로 기능입니다.
이를 좀 자세히 분석해보고 자바스크립트에 일반적으로 적용할 수 있는 패턴으로 바꿔보도록 하죠.
짜피 코틀린이 익숙치 않은 분들에겐 위임 속성을 길게 설명해도 소용없으므로 코드로 전개하면서 장점을 설명해가겠습니다.

자바스크립트에 코틀린을 왜 응용해요?
코틀린에 재밌는 아이디어가 많다능

속성이란 무엇일까?top

이는 클래스 내부와 외부에 따라 다르게 해석될 수 있습니다.
클래스 내부에서는 은닉하고 있는 상태라고 할 수 있지만, 클래스 외부에서는 캡슐화된 특정 연산에 가깝습니다. 여기서 말하는 연산이란 당연히 getter, setter입니다.
예를 들어 다음과 같은 코드는 내부에 은닉된 속성과 외부에 노출된 속성이 어떻게 다른지 보여줍니다.

class Test{
  #map = new Map;
  get name(){
    return this.#map.get("name") ?? "no name";
  }
  set name(v){
    this.#map.set("name", v);
  }
}

const test = new Test;

console.log(test.name); //no name

test.name = "hika";

console.log(test.name); //hika

여기서 은닉된 내부의 상태는 #map에 있는 Map객체가 될 것이지만, 외부에 노출된 속성은 캡슐화된 연산으로서의 getter, setter가 됩니다.

만약 위 클래스에서 더 많은 속성을 외부에 노출하면 어떻게 될까요?

class Test{
  #map = new Map;
  get name(){
    return this.#map.get("name") ?? "no name";
  }
  set name(v){
    this.#map.set("name", v);
  }
  get company(){
    return this.#map.get("company") ?? "no company";
  }
  set company(v){
    this.#map.set("name", v);
  }
}

얼추 이런 코드가 될텐데, 여기서 name과 company가 거의 유사한 코드를 갖고 있음을 알 수 있습니다. 즉 외부에 노출되는 속성이 비슷한 연산을 갖고 있어 코드의 중복을 유발한다는 점입니다.

이걸 어떻게 추상화할까요?

간단한 방법은 클래스 내에 private함수를 만드는 것입니다.

class Test{
  #map = new Map;
  _get(k){
    return this.#map.get(k) ?? `no ${k}`;
  }
  _set(k, v){
    this.#map.set(k, v);
  }
  get name(){
    return this._get("name");
  }
  set name(v){
    this._set("name", v);
  }
  get company(){
    return this._get("company");
  }
  set company(v){
    this._set("name", v);
  }
}

아니 뭐 좀 나아지긴 했죠. 하지만 정말 조금 나아졌을 뿐입니다. 보다 본격적이고 과감하게 그리고 일반화된 방법은 없을까요?

그럼 외부에 getName, setName메소드를 노출해도 동일한건가요?
메소드와 속성을 쓸 때 기분이 다르다능, 기분이!

코틀린의 방식top

코틀린 이러한 상황에서 아예 getter, setter를 객체에게 위임할 수 있는 편의 문법을 제공합니다. 그게 바로

https://kotlinlang.org/docs/reference/delegated-properties.html

요런 넘입니다. 코틀린코드로 표현하면 위 상황을 다음과 같이 적을 수 있습니다.

class Delegated(private val map:MutableMap<String, String>){
  operator fun getValue(ref:Any?, prop: KProperty<*>) = map[prop.name] ?: "no ${prop.name}"
  operator fun setValue(ref:Any?, prop: KProperty<*>, v:Any){
    map[prop.name] = "$v"
  }
}
class Test{
  private val map = mutableMapOf<String, String>()
  var name by Delegated(map)
  var company by Delegated(map)
}

val test = Test()
test.name = "hika"
test.company = "bsidesoft"

코틀린이라 코드가 잘 안보이시겠지만 대략 해석하자면 속성만 처리하는 Delegated라는 클래스를 만들고 거기서 getValue, setValue가 직접 다 처리해주는 식입니다.
이 객체의 인스턴스를 각 속성마다 하나씩 생성해주는데 이때 핵심적인 by라는 키워드가 등장합니다.

by는 컴파일러가 내부적으로 특수한 코드를 만들어내게 하는 일종의 매크로인데 그렇게 만들어지는 코드는 다음과 같습니다.

//by를 사용하는 원본 코드
class Test{
  private val map = mutableMapOf<String, String>()
  var name by Delegated(map)
}

//by가 번역된 코드
class Test{
  private val map = mutableMapOf<String, String>();

  //위임 객체를 내부에 생성하고
  private val nameProp = Delegated(map)

  //그 위임객체의 getValue, setValue를 getter, setter에 매핑한다.
  var name:String get() = nameProp.getValue(this, this::name)
      set(v:String) = nameProp.setValue(this, this::name, v)
}

이 방법은 이해하기 쉽습니다. 그리고 by라는 키워드를 통해 현격하게 클래스 내부의 코드를 줄이고 완전히 추상화할 수 있죠.

코틀린 코드라 하나도 모르겠어요.
사실은 코틀린 코드라 하나도 모르고 싶은거라능

defineProperty를 이용해 흉내내기top

by키워드의 핵심은 직접 getter, setter를 설정하지 않고 자동화한다는 점입니다. 따라서 이를 흉내내려면 defineProperty를 이용해 getter, setter를 생성해줄 수 밖에 없습니다.
어쩔 수 없이 의사결정을 해야하는데, 프로토타입 수준에서 설정할 것이냐, 아니면 인스턴스 수준에서 설정할 것이냐입니다.
우선은 인스턴스 수준으로 구현하고 너무 추상화하기 전에 Test전용으로 만들어보죠.

//속성처리 객체
class TestDelegate{
  #map;
  constructor(map){
    this.#map = map;
  }
  getValue(k){
    return this.#map.get(k) ?? `no ${k}`;
  }
  setValue(k, v){
    this.#map.set(k, v);
  }
}

//by로 속성 바인딩
const by = (target, k)=>{
  const delegate = new TestDelegate(target.map);
  Object.defineProperty(target, k, {
    get(){
      return delegate.getValue(k);
    },
    set(v){
      delegate.setValue(k, v);
    }
  });
};

//실제 인스턴스 속성 생성
class Test{
  map = new Map;
  constructor(){
    by(this, "name");
    by(this, "company");
    by(this, "other");
  }
}

const test = new Test;
test.name = "hika";
test.company = "bsidesoft";

console.log(test.name, test.company, test.other); 
//hika, bsidesoft, no other

이 간단한 장치가 Test클래스의 getter, setter를 엄청나게 단순화시킵니다.

get, set을 위해 속성마다 객체를 하나씩 만든다는 게 거부감이 있어요.
하지만 보통 위임 속성 객체는 메소드 중심이라 매우 가볍다능

by 함수의 일반화top

위에서 구현한 by함수는 내부에 TestDelegate 구상클래스를 알고 있으므로 일반화시킬 수 없습니다. 따라서 TestDelegate에 기대하고 있는 getValue, setValue만 인터페이스로 추출할 필요가 있습니다.

class Delegate{
  getValue(k){throw "override!"}
  setValue(k, v){throw "override!"}
}

이제 일반화된 인터페이스인 Delegate와 getValue, setValue를 추출했으므로 by함수도 보다 일반화된 형태로 기술됩니다.

const by = (target, k, factory)=>{
  const delegate = factory(target);
  Object.defineProperty(target, k, {
    get(){
      return delegate.getValue(k);
    },
    set(v){
      delegate.setValue(k, v);
    }
  });
};

일반화된 구현에서 by는 구상객체의 생성 지식이 없으므로 factory를 받아들이게 됩니다. 위의 사항을 받아들여 TestDelegate구상클래스를 작성하면 다음과 같아질 것입니다.

//Delegate 상속
class TestDelegate extends Delegate{

  //팩토리 함수
  static factory(target){
    return new TestDelegate(target.map);
  }

  #map;
  constructor(map){
    super();
    this.#map = map;
  }
  getValue(k){
    return this.#map.get(k) ?? `no ${k}`;
  }
  setValue(k, v){
    this.#map.set(k, v);
  }
}

이제 by가 일반화되었으므로 원하는 구상객체 팩토리를 넘기도록 Test클래스의 코드도 수정합니다.

class Test{
  map = new Map;
  constructor(){
    by(this, "name", TestDelegate.factory);
    by(this, "company", TestDelegate.factory);
    by(this, "other", TestDelegate.factory);
  }
}

이제 인스턴스 레벨의 속성위임이 일반화되었으므로 그 다음 단계로 나가보죠.

by안에서 factory로 생성 안하고, 직접 위임 속성 객체를 생성해서 넘기면 안되나요?
가능하다능. 단지 보다 선언적인 코드가 되기 때문에 factory로 짜봤다능

프로토타입레벨에 적용하기top

사실 인스턴스별로 getter, setter를 적용한다는건 클래스 일반화라 할 수는 없죠. 그래서 더 나아가 클래스 레벨에 적용하기 위해서는 프로토타입을 건드려야합니다.
일단 구현을 어떻게 할지 고민하기 전에 완성된다면 어떻게 사용하게 될까부터 궁리해보죠.

class Test{
  map = new Map;
}
by(Test.propotype, "name", TestDelegate.factory);
by(Test.propotype, "company", TestDelegate.factory);
by(Test.propotype, "other", TestDelegate.factory);

이렇게 할 수도 있고 속성을 묶어서 보낼 수도 있겠죠. 더 나아가 클래스를 그대로 보내도 될 것입니다.

class Test{
  map = new Map;
}
by(Test, {
 name:TestDelegate.factory,
 company:TestDelegate.factory,
 other:TestDelegate.factory
});

얼추 그럴 듯하게 나왔으니 이걸 받아줄 수 있게 구현해보죠. 우선 by함수를 이 인터페이스에 맞게 바꾸면 다음과 같이 될 것입니다.

const by = ({prototype}, props)=>{
  Object.entries(props).forEach(([k, factory])=>{
    const delegate = factory();
    Object.defineProperty(prototype, k, {
      get(){
        return delegate.getValue(this, k);
      },
      set(v){
        delegate.setValue(this, k, v);
      }
    });
  });
};
  1. 우선 첫번째인자인 클래스에서 부터 prototype을 추출합니다.
  2. props는 entries로 순회하면서 definedProperty를 적용해줍니다.

근데 factory에 더 이상 target을 보내지 않습니다. 대신 get, set에서 this를 인자로 보내고 있죠. 이제 Delegate는 특정 인스턴스가 아니라 this컨텍스트에 맞춰 작동해야하기 때문이죠.
이에 맞춰 Delegate와 TestDelegate를 리펙토링합시다.

//target을 받도록 수정됨
class Delegate{
  getValue(target, k){throw "override";}
  setValue(target, k, v){throw "override";}
}

class TestDelegate extends Delegate{

  //더이상 특정 인스턴스에 의존하지 않는 생성자
  static factory(){
    return new TestDelegate();
  }

  getValue(target, k){
    return target.map.get(k) ?? `no ${k}`;
  }
  setValue(target, k, v){
    target.map.set(k, v);
  }
}

위 구현은 이전과 비교해서 getValue, setValue가 매번 target에 반응하도록 변경되었습니다(좀 프록시훅에 가까워진 느낌이죠 ^^)
이제 부속이 완성되었으므로 그대로 전체 코드를 적용하면 됩니다.

//base
class Delegate{
  getValue(target, k){throw "override";}
  setValue(target, k, v){throw "override";}
}
const by = ({prototype}, props)=>{
  Object.entries(props).forEach(([k, factory])=>{
    const delegate = factory();
    Object.defineProperty(prototype, k, {
      get(){return delegate.getValue(this, k);},
      set(v){delegate.setValue(this, k, v);}
    });
  });
}
//구상 delegate
class TestDelegate extends Delegate{
  static factory(){return new TestDelegate();}
  getValue(target, k){return target.map.get(k) ?? `no ${k}`;}
  setValue(target, k, v){target.map.set(k, v);}
}
//사용 클래스
class Test{
  map = new Map;
}
//속성 바인딩
by(Test, {
 name:TestDelegate.factory,
 company:TestDelegate.factory,
 other:TestDelegate.factory
});

//실제 사용
const test = new Test;
test.name = "hika";
test.company = "bsidesoft";

console.log(test.name, test.company, test.other); 
//hika, bsidesoft, no other

제법 그럴듯해졌습니다. 몇가지만 더 보완해보죠.

클래스를 생성한 뒤 프로토타입을 건드리는게 맘에 들지 않아요.
오히려 프로토타입의 특성을 자바와 같은 구조로만 바라보는게 이상한거라능

클래스 내부에서 정의하기top

사실 거의 완성은 되었지만 클래스의 정의와 by함수의 사용이 나눠져 있어서 클래스 내부에 정의할 수 없다는 점이 단점입니다. 코드는 결국 응집성있게 몰려있는게 훨씬 낫기 때문이죠.
하지만 클래스 구문이 완결되기 전에는 prototype을 얻을 수 없으므로 특별한 프로토콜을 사용하지 않는 이상은 어쩔 수 없습니다. 대신 클래스 자체에 미리 훅이 될 속성을 정의하는 방법을 사용할 수는 있습니다.
다행히 factory가 특별한 인자를 받지 않기 때문에 미리 생성해둬도 아무런 문제가 없습니다.

class Test{
  static name = new TestDelegate;
  static company = new TestDelegate;
  static other = new TestDelegate;
  map = new Map;
}
by(Test);

이제 좀 더 그럴듯한 인터페이스가 되었습니다. 필요한 속성을 static으로 정의하고 여기에 위임속성 객체를 직접 생성해 넣었습니다. by는 이를 자동으로 감지해서 처리할 것입니다.

const by = (cls)=>{
  Object.entries(cls).forEach(([k, delegate])=>{

    //속성이 Delegate인 경우만 처리
    if(!(delegate instanceof Delegate)) return;

    delete cls[k]; //기존 속성은 제거함

    Object.defineProperty(cls.prototype, k, {
      get(){return delegate.getValue(this, k);},
      set(v){delegate.setValue(this, k, v);}
    });
  });
};

다시 한 번 모든 코드를 모아서 보죠. 달라진 점은 TestDelegate에 더 이상 factory가 필요없다는 점 정도입니다.

//기저층
class Delegate{
  getValue(target, k){throw "override";}
  setValue(target, k, v){throw "override";}
}
const by = (cls)=>{
  Object.entries(cls).forEach(([k, delegate])=>{
    if(!(delegate instanceof Delegate)) return;
    delete cls[k];
    Object.defineProperty(cls.prototype, k, {
      get(){return delegate.getValue(this, k);},
      set(v){delegate.setValue(this, k, v);}
    });
  });
};

//구상클래스
class TestDelegate extends Delegate{
  getValue(target, k){return target.map.get(k) ?? `no ${k}`;}
  setValue(target, k, v){target.map.set(k, v);}
}
class Test{
  static name = new TestDelegate;
  static company = new TestDelegate;
  static other = new TestDelegate;
  map = new Map;
}
by(Test);

//실제 사용
const test = new Test;
test.name = "hika";
test.company = "bsidesoft";

console.log(test.name, test.company, test.other); 
//hika, bsidesoft, no other
static중 어떤게 인스턴스의 위임속성이 될지 한 눈에 안들어와요.
특별한 속성이름규칙을 정하는 방법도 물론 있지만 뭐가 더 잘 보이는지는 알아서들 적용할 문제라능.

타입계층을 인터페이스로 바꾸기top

이 글에서는 이해를 돕기 위해 Delegate를 클래스 타입 계층 구조로 만들었습니다. 하지만 코틀린은 operator를 사용하는데 이는 클래스의 계층구조가 아니라 그저 그 메소드가 있는가를 감지하는 시스템입니다.
이에 따르면 딱히 Delegate추상층이 필요없습니다. 자바스크립트도 마찬가지 개념으로 인터페이스를 정의합니다. 대표적인 iterable, iterator 인터페이스는 객체가 특정 메소드를 소유하고 있는가로 타입적합성을 판정하는 덕타입 시스템입니다.
따라서 getValue, setValue가 존재하는 것으로 분기하게 by를 바꿀 수 있습니다.

const by = (cls)=>{
  Object.entries(cls).forEach(([k, delegate])=>{

    //인터페이스로 검사하자!
    if(typeof delegate.getValue != 'function' ||
       typeof delegate.setValue != 'function')) return;

    delete cls[k];
    Object.defineProperty(cls.prototype, k, {
      get(){return delegate.getValue(this, k);},
      set(v){delegate.setValue(this, k, v);}
    });
  });
};

이 구조에서는 할당된 객체가 특정 클래스인가를 평가하지 않고 내부에 getValue, setValue가 있는지를 평가합니다. 따라서 Delegate라는 추상클래스가 제거되어 보다 쉽게 구상 Delegate를 만들 수 있게 부모제약이 사라집니다.

const by = (cls)=>{
  Object.entries(cls).forEach(([k, delegate])=>{
    if(typeof delegate.getValue != 'function' ||
       typeof delegate.setValue != 'function') return;
    delete cls[k];
    Object.defineProperty(cls.prototype, k, {
      get(){return delegate.getValue(this, k);},
      set(v){delegate.setValue(this, k, v);}
    });
  });
};

class TestDelegate{
  getValue(target, k){return target.map.get(k) ?? `no ${k}`;}
  setValue(target, k, v){target.map.set(k, v);}
}
class Test{
  static name = new TestDelegate;
  static company = new TestDelegate;
  static other = new TestDelegate;
  map = new Map;
}
by(Test);

const test = new Test;
test.name = "hika";
test.company = "bsidesoft";

console.log(test.name, test.company, test.other); 

보다 기저층이 간결해지고 TestDelegate의 부모제약이 사라졌으므로 이쪽의 타입계층을 구성하기 용이해집니다. 하지만 대신 개발자가 getValue, setValue인터페이스를 암시적으로 인식하고 있어야하죠.
이런 경우 도움이 되는게 심볼입니다. 그저 메소드 이름으로는 제약이 약하므로 보다 명시적인 인터페이스를 확인하기 위해 심볼을 사용하는 거죠. Symbol.iterator 등도 같은 목적입니다.
by를 클래스화 시켜 속성으로 포함시킵니다.

class by{
  static getValue = Symbol();
  static setValue = Symbol();
  static set(cls){
    Object.entries(cls).forEach(([k, delegate])=>{
      if(typeof delegate[by.getValue] != 'function' ||
         typeof delegate[by.setValue] != 'function') return;
      delete cls[k];
      Object.defineProperty(cls.prototype, k, {
        get(){return delegate[by.getValue](this, k);},
        set(v){delegate[by.setValue](this, k, v);}
      });
    });
  }
}

이제 이 인터페이스를 이용하도록 전체적으로 코드를 변경합니다.

class TestDelegate{
  [by.getValue](target, k){return target.map.get(k) ?? `no ${k}`;}
  [by.setValue](target, k, v){target.map.set(k, v);}
}
class Test{
  static name = new TestDelegate;
  static company = new TestDelegate;
  static other = new TestDelegate;
  map = new Map;
}
by.set(Test);

const test = new Test;
test.name = "hika";
test.company = "bsidesoft";

console.log(test.name, test.company, test.other); 
get만 구현하고 싶을 때는 어떻게 해요?
by.setValue에 예외를 토하게 만들면 된다능. 원래 setter가 없는 경우에도 예외가 일어나는게 기본이라능

활용예 – Lazytop

코틀린에 내장된 기본 위임속성 객체 몇 가지를 구현해볼 건데 그 중에 첫 번째로 lazy를 해보죠. Lazy는 대표적인 위임속성의 사용처로 원하는 값을 즉시 만들지 않고 실제 사용시점에 활성화시키는 방법입니다.

  1. 값을 토하는 함수를 건내주고
  2. 최초 getter에서 함수를 호출하여 값으로 환원한 뒤
  3. 그 이후 환원된 값을 계속 사용하는

패턴입니다. 클래스 자체의 구현도 간단하고 팩토리 함수도 간단하니까 동시에 구현하죠.

const lazy =(_=>{
  class Lazy{
    static #EMPTY = Symbol();

    #f;

    //최초 값을 초기화함
    #v = Lazy.#EMPTY;

    constructor(f){
      this.#f = f;
    }

    [by.getValue](target, k){

      //한번도 f를 실행한 적 없으면 호출하여 값으로 환원
      if(this.#v === Lazy.#EMPTY) this.#v = this.#f(target);
      return this.#v;
    }

    [by.setValue](target, k, v){throw "read only";}
  }
  return f=>new Lazy(f);
})();

lazy함수 자체는 target을 인자로 받는 함수 자체를 받아들이는 함수입니다.
위임속성 객체 Lazy는 lazy함수만 아는 내부 객체로 최초 #v가 EMPTY였다가 f(target)을 호출한 값으로 getter환원되는 간단한 녀석입니다.
이제 이걸 적용해보겠습니다.

class Test{

  //target의 data로 부터 합계를 구한다.
  static sum = lazy(({data})=>data.reduce((acc,v)=>acc + v));

  data;
  constructor(...arg){
    this.data = [...arg];
  }
}
by.set(Test);

const test = new Test(1, 2, 3, 4, 5);
console.log(test.sum); //15
console.log(test.sum); //15

위 예에서 sum속성은 호출되기 전까지는 실행되지 않다가 최초 호출되면 함수가 실행되어 15가 반환되고 그 이후부터는 쭉 이미 계산된 15를 반환하게 됩니다.
따라서 무거운 계산을 getter시점까지 미룰 수 있을 뿐 아니라 그 이후 메모이제이션이 작동하게 되는 거죠. 위임속성 객체는 매우 가벼운 형태기 때문에 대부분 초기화 오버로딩이 더 쎈 속성의 경우나 무거운 객체를 속성을 잡을 때 유용합니다.

lazy하면서 비동기적이면 어떻게 해요?
프라미스를 반환하면 된다능

활용예 – Observertop

옵져버 패턴에 근거한 위임속성으로 미리 속성변화에 대한 훅을 걸어 매번 호출할 수 있게 해주는 객체입니다. 우선 옵져빙을 할 함수의 시그니처는 다음과 같습니다.

(target, propName, oldValue, newValue)=>{}


대상 객체, 속성명, 기존값, 새 값 이라는 형식이죠. 새 값이 설정되기 전후냐에 따라 before, after를 만들 수도 있긴한데, 이 예에서는 after만 만들어보겠습니다.


const observe =(_=>{
  class Observer{

    #value; //저장할 값
    #observer; //옵져버

    constructor(value, observer){
      this.#value = value;
      this.#observer = observer;
    }

    [by.getValue](target, k){
      return this.#value;
    }

    [by.setValue](target, k, v){
      const old = this.#value;
      this.#value = v;

      //여기서 호출한다!
      this.#observer(target, k, old, v);
    }
  }
  return (value, observer)=>new Observer(value, observer);
})();

observer함수는 초기값과 옵져버를 받아 생성됩니다. 이후 setValue시점마다 기존 값과 새 값에 대한 노티를 주죠. 마찬가지로 Test를 하나 만들어 실험합니다.

class Test{
  static name = observe('', (target, k, oldV, newV)=>console.log(oldV, 'to', newV));
}
by.set(Test);

const test = new Test;

test.name = 'hika'; // '' to 'hika'
test.name = 'maeng'; // 'hika' to 'maeng'

간단히 속성에 옵져버를 걸 수 있게 되었습니다. 일단 속성에 옵져버를 거는 게 손쉽기 때문에 양방향 바인딩이나 모델의 변화를 수신하는 객체 구조를 만들 때 굉장히 간단한 코드로 실현할 수 있게 됩니다.

여러 개의 옵져버를 등록하고 싶어요.
간단하니 한 번 수정해보라능

delegate를 선언적으로 바꾸기top

마지막으로 딱히 static시점에 delegate인스턴스를 만들 필요는 없습니다. 그러기 위한 클래스를 힌트로 주는 것으로 충분하기 때문이죠. 즉 다음과 같이 고쳐도 처리할 수 있습니다.

//기존
class Test{
  static name = new TestDelegate;
  static company = new TestDelegate;
  static other = new TestDelegate;
  map = new Map;
}

//개선 new가 필요없음
class Test{
  static name = TestDelegate;
  static company = TestDelegate;
  static other = TestDelegate;
  map = new Map;
}

개선된 구조가 훨씬 선언적입니다. 따라서 이에 반응하도록 by의 set을 수정합니다.

class by{
  static getValue = Symbol();
  static setValue = Symbol();
  static set(cls){
    Object.entries(cls).forEach(([k, dele])=>{

      //dele를 클래스로 보고 검사함
      if(!dele.prototype ||
         typeof dele.prototype[by.getValue] != 'function' ||
         typeof dele.prototype[by.setValue] != 'function') return;

      delete cls[k];

      //직접 delegate 생성
      const delegate = new dele;

      Object.defineProperty(cls.prototype, k, {
        get(){return delegate[by.getValue](this, k);},
        set(v){delegate[by.setValue](this, k, v);}
      });
    });
  }
}

바뀐 set은 내부에서 직접 인스턴스를 생성하여 사용하게 됩니다. 최종적으로 수정된 전체 코드는 다음과 같습니다.

class by{
  static getValue = Symbol();
  static setValue = Symbol();
  static set(cls){
    Object.entries(cls).forEach(([k, dele])=>{
      if(typeof dele.prototype[by.getValue] != 'function' ||
         typeof dele.prototype[by.setValue] != 'function') return;
      delete cls[k];
      const delegate = new dele;
      Object.defineProperty(cls.prototype, k, {
        get(){return delegate[by.getValue](this, k);},
        set(v){delegate[by.setValue](this, k, v);}
      });
    });
  }
}
class TestDelegate{
  [by.getValue](target, k){return target.map.get(k) ?? `no ${k}`;}
  [by.setValue](target, k, v){target.map.set(k, v);}
}
class Test{
  static name = TestDelegate;
  static company = TestDelegate;
  static other = TestDelegate;
  map = new Map;
}
by.set(Test);

const test = new Test;
test.name = "hika";
test.company = "bsidesoft";

console.log(test.name, test.company, test.other); 

하지만 선언적인 구조가 항상 좋은 것은 아닙니다. 특히 생성자에서 아무것도 할 수 없으므로 특별한 기능을 위해 인자를 갖는 위임객체를 만들 수 없으니까요.
위에서 예로 나왔던 observe나 lazy는 모두 위임객체에게 인자를 전달해야하기 때문에 이 구조로는 쓸 수 없습니다. 만약 둘 다 허용한다면? 약간의 개선으로 두 개다 허용할 수 있게 할 수 있으니 그렇게 하죠.

class by{
  static getValue = Symbol();
  static setValue = Symbol();
  static set(cls){
    Object.entries(cls).forEach(([k, dele])=>{

      let delegate, test;

      if(typeof dele == 'function'){ //클래스가 온 경우
        delegate = new dele;
        test = dele.prototype;
      }else{//인스턴스가 온 경우
        delegate = test = dele;
      }

      if(typeof test[by.getValue] != 'function' ||
         typeof test[by.setValue] != 'function') return;

      delete cls[k];

      Object.defineProperty(cls.prototype, k, {
        get(){return delegate[by.getValue](this, k);},
        set(v){delegate[by.setValue](this, k, v);}
      });
    });
  }
}

이제 자유롭게 모든 경우를 다룰 수 있게 되었습니다.

class TestDelegate{
  [by.getValue](target, k){return target.map.get(k) ?? `no ${k}`;}
  [by.setValue](target, k, v){target.map.set(k, v);}
}

class Test{

  static sum = lazy(({data})=>data.reduce((acc,v)=>acc + v));
  static name = observe('', (target, k, oldV, newV)=>console.log(oldV, 'to', newV));
  static company = TestDelegate;
  
  data;
  map = new Map;

  constructor(...arg){
    this.data = [...arg];
  }
}
by.set(Test);

const test = new Test(1, 2, 3, 4, 5);
console.log(test.sum);

test.name = 'hika'; // '' to 'hika'
test.name = 'maeng'; // 'hika' to 'maeng'

test.company = "bsidesoft";
console.log(test.company); //bsidesoft
결국 유용한 위임 속성 객체를 사전에 많이 만들어 둘 수 있네요?
외부에 노출된 속성에 대한 캡슐화라는게 이렇게 쓰다보면 패턴이 존재한다는 사실을 알게 된다능

결론top

코틀린은 by를 컴파일 타임에 getter, setter로 번역합니다. 이걸 통해 속성에 대한 처리를 완전히 객체에게 위임하게 되죠.
자바스크립트는 컴파일 타임이 없으므로 by를 런타임에 호출하는 것으로 갈음할 수 있습니다. 대신 동일하게 위임객체에 속성의 처리를 맡겨 코드를 깔끔하게 유지할 수 있습니다.

이 글과 관련된 저장소는 다음과 같습니다.

https://github.com/hikaMaeng/by