[자바스크립트] proxy객체의 유연함-심화편(feat, Reflective-Programming)

Date:     Updated:

카테고리:

태그:


🚀깊이있게 이해하는 자바스크립트 Reflect 객체와 리플렉티브 프로그래밍

자바스크립트는 이미 광범위하게 사용되고 있는 언어임에도 불구하고, 항상 새로운 개념과 기능이 도입되고 있다. 사람들이 많이 익숙해진 ES6, 사실 내가 코딩을 시작하기 한~~참 전부터 나왔던 ES6이지만 잘 모르는 고~급 기능 중 하나인 Reflect 객체와 그것을 이용한 리플렉티브 프로그래밍에 대해 알아보겠다. 이 글은 저번글(프록시객체-기초편) 에 이어서 작성하는 내용이다. 오늘 설명할 reflect객체는 결국 proxy객체와 함께 쓰여야 유의미한 결과를 보여주기 때문이다.


❓Reflective Programming

Reflective Programming이 뭘까? 내가 이해한 바는 아래와 같다.

런타임에서 바뀐것들을 반영 해 주는 것

결론적으로 간단한 바는 위의 내용으로 귀결되었다. Reflective Programming에 대해 공부하며, 일반적으로 사람들이 알려주는 내용은 아래와 같다.

메타프로그래밍에서 manipulate하는 언어와 metaData가 같은 언어일 때 리플렉티브 프로그래밍이라고 한다

처음에는 정말 무슨 소리인지 잘 모르겠었다. 또한 이러한 프로그래밍의 필요성에 대해서 알려주는 글들을 찾기 너무 어려웠었다. 오늘 주제인 Reflective프로그래밍의 정의를 알기위해 메타프로그래밍에 대해서도 간단히 알아보고. 이와같은 프로그래밍의 필요성에 대해서도 알아보도록 하겠다.


❓Meta Programming

메타프로그래밍은 무엇일까? 메타프로그래밍은 프로그램을 조작하는 프로그래밍이다. 즉, 프로그래밍 언어를 이용해서 다른 프로그래밍 언어를 조작하는 것이다. 말장난같지만… 진짜 그렇다. 왜 이와같은 이름이 붙었을까? 바로 Meta Data를 조작하는 것과 같은 의미이기 때문이다. 자바스크립트에서는 이러한 메타프로그래밍을 할 수 있는 기능이 내장되어 있다. 바로 Reflect 객체와 Proxy 객체이다. 이 두가지를 이용해서 메타프로그래밍을 할 수 있다.

무지개반사

그렇다면 Reflect Programming은 무엇일까? 메타프로그래밍이지만, (조작당하는 녀석이 반사(reflection)을 했으므로)스스로를 조작당한다는 그런 의미인 것이다. 초창기 메타프로그래밍 개념도입으로써 가장 자주언급되는 Brian Cantwell박사의 “Smith Reflection and Semantics in a Procedural Language”라는 논문에서 초기 메타프로그래밍의 개념을 소개하고 있다.그리고 이 개념이 발전해나가면서 현재의 “코딩”에서의 메타프로로그래밍 개념까지 발전된 것이다.

Reflection은 초등학생때 “응~ 무지개반사 반사” 할때 쓰는 그 반사인 것이다. 이러한 반사를 이용해서 메타프로그래밍을 할 수 있다. Meta 데이터를 조작하려고 했지만, 조작되는건 자기 자신이였다… 뭐 이런의미이다. 어떤 논문에서도 Reflective Metaprogramming이라는 말을 사용한다. 한국어로 종종 표현되는 “반사적 프로그래밍”보다, 영어가 오히려 그 개념을 잘 설명해주는 것 같다.

그럼 이제, 메타프로그래밍(reflection)의 필요성에 대해서 알아보겠다.


🚀Reflection의 필요성1: 데이터바인딩

메타프로그래밍의 필요성에 대해서는 다양한 의견이 있다. 하지만, 내가 생각하는 메타프로그래밍의 필요성은 아래와 같다.

  1. 코드의 재사용성을 높이기 위해서
  2. 코드의 가독성을 높이기 위해서
  3. 코드의 유지보수를 쉽게 하기 위해서

한번 그 예시를 보도록 하자

<p id="myText"> hi </p>

위 텍스트를 업데이트 하려면 어떻게 할까?

const myText = document.getElementById("myText");
myText.innerText = "안녕 바보들";

이렇게 바꾸면 된다. 하지만 자동으로 업데이트되도록 데이터 바인딩을 하려면 어떻게 해야할까?

let data = {
  _text: "",
  get text() {
    return this._text;
  },
  set text(value) {
    this._text = value;
    let element = document.getElementById("myText");
    if (element) {
      element.textContent = value;
    }
  }
};

data.text = "안녕 바보들";

이처럼 바꾸면 된다. 뭔가 코드가 길어졌다. 이러한 코드를 메타프로그래밍을 이용해서 간단하게 바꿀 수 있다.

function bindData(data, elementId) {
  let handler = {
    set(target, property, value) {
      if (Reflect.set(target, property, value)) {
        let element = document.getElementById(elementId);
        if (element) {
          element.textContent = value;
        }
        return true;
      }
      return false;
    }
  };
  
  return new Proxy(data, handler);
}

let data = { text: "" };
let proxyData = bindData(data, "myText");

proxyData.text = "안녕 바보들";

굳이 getter와 setter를 사용하지 않고, 코드가 조금 더 가독성이 좋아진 기분이다. 이처럼 메타프로그래밍을 이용하면 코드의 재사용성을 높일 수 있고, 가독성을 높일 수 있으며, 유지보수를 쉽게 할 수 있다. 라고들 하지만… 과연 그럴까 싶기도 하다. 재사용할 필요가 없거나, 추후 따로 신경쓸 필요가 없는 상황에서는 위와같은 방식이 오히려 팀원들이 코드를 이해하기 어렵게 만들수도 있다.

Reflect와 Proxy객체에 대해 팀원들의 이해도가 있거나, 추후 팀원들에게 설명해주는게 아니면 위와같은 방식은 오히려 팀원들이 이해하기 어려울 수도 있다. 즉, 다른사람들이 편히 사용할 수 있는 도구를 만드는 상황에서 이러한 프로그래밍이 필요하다고 생각한다. 상용 프레임워크 혹은 라이브러리를 사용해서 개발을 할 때에는, 이를 이용하여 다른 사람들이 원하는 화면과 데이터를 보여주는 작업 자체에는 이러한 개념이 오히려 혼란을 줄 수 있다고 생각한다.(개인생각임) 하지만, 팀원들을 위한 전반적인 코드구조 개선 및 기존 데이터타입을 헤치지 않고 추가적인 기능을 구현할 때에는 이러한 개념이 필요하다고 생각한다.

그래서 주로 디버깅, 테스팅, 플러그인 등에 위 개념들이 사용되는것이 아닌가 싶다.

두번째 예시로, Vue개발자가 메타프로그래밍을 사용한 예시를 자.세.히 알아보고 글을 마치도록 하겠다.


🚀Vue.js의 메타프로그래밍

사람들이 Vue쓰는 회사 가기 싫다고 하지만, 사실 리액트나 별반 차이 없는데 왜 은근히 미워하는지 모르겠다. 무튼 Vue개발자가 reflect를 이용하여 reactive()를 구현한 이유는, 프로토타입에 대한 사이드 이펙트를 처리하기 위해 사용했다고 한다.

엄청나게 많은 예제를 차근차근 보여주겠다. 내가 이해한 바를 결론부터 말하자면

애매모호한 리시버를 바로잡기 위해 reflect를 사용했다.

라고 이해하게 되었다.

리시버가 뭐냐? 하면 그냥 쩜 앞에 있는객체이다. 메서드를 실행하는 녀석인 것이다. 다음 7가지 엄청쉬운 예제를 순서대로 보고나면 reflect 객체의 필요성을 단번에 이해할 수 있다.

예제1: 리시버
예제2: 프로토타입
예제3: 리시버 * 프로토타입
예제4: Reflect
예제5: Reflect * 리시버
예제6(중요): 리시버 * 프로토타입 * 프록시 <=🚨문제발생
예제7(중요): 리시버 * 프로토타입 * 프록시 * Reflect <=💡문제안발생


🚀Reflection의 필요성: with 7개의 예죄

seven_examples

⭐️예제1 리시버

const obj = { prop: 1 };
const propertyKey = 'prop';

console.log(obj.prop); // 1
console.log(obj[propertyKey]); // 1

여기서 obj가 바로 리시버이다. 개인적인 이상한 이해로는… 조져짐을 당하는(?) 객체로 이해했다. 조져짐 당하는 객체가, 메소드를 “받기” 때문에 리시버라는 이름이 붙었다.

⭐️예제2: 프로토타입

const child = { age: 0 };
const parent = { age: 40, job: 'programmer' };

child.__proto__ = parent;

console.log(child.job); // programmer

프로토타입이다. proxy나 reflect에 관심이 있어 여기까지 읽고있다면 이미 알고있는 사실일테니 패스하겠다.

⭐️예제3: 리시버 * 프로토타입

리시버와 프로토타입 이용한 코드보기(정상작동코드)
const child = {
  birthYear: 2020
};
const parent = {
  birthYear: 1980,
  get age() {
    return new Date().getFullYear() - this.birthYear;
  },
  set birthYear(year) {
    this.birthYear = year;
  }
};

child.__proto__ = parent;
console.log(child.age); // 3

child.birthYear = 2022;
console.log(child.age); // 1
  1. child.[[Get]](P, Receiver)가 호출되고, Receiverchild가 된다.
  2. 프로토타입 체이닝에 의해parent.[[Get]](P, Receiver)가 재귀적으로 호출되고agegetter로 존재하며 이를 실행할 때Receiverthis로 사용된다.

정리하자면… 리시버는 그대로 child이지만, 부모꺼를 잘 가져와서 쓴 예제인 것이다. 아래의 예제 4, 5도 계속 정상작동하는 코드이다. 글이 너무 길어지는 것 같으면 예제6과 예제 7만 보면 된다

⭐️예제4: Reflect

reflect 사용한 코드보기(정상작동)
const obj = {
  a: 1,
  b: 2,
  get sum() {
    return this.a + this.b;
  }
};

const receiverObj = { a: 2, b: 3 };
> Reflect.get(obj, 'sum', obj);
3
> Reflect.get(obj, 'sum', receiverObj);
5

reflect를 잘 사용했다. obj에 달려있는 “sum”스킬을 사용하고 싶다면, 리시버오브젝트가 저렇게 쓰는 것이다.

⭐️예제5: Reflect * 리시버

reflect와 리시버를 함께 사용한 코드 보기(정상작동)
const obj = {
  prop: 1,
  set setProp(value) {
    return this.prop = value;
  }
};

const receiverObj = {
  prop: 0
};
> Reflect.set(obj, 'setProp', 2, obj);
true
> obj.prop;
2

---
> Reflect.set(obj, 'setProp', 1, receiverObj);
true
> obj.prop;
2
> receiverObj.prop;
1

⭐️예제6(중요): 리시버 * 프로토타입 * 프록시

앞의 내용은 사실 다 아는 이야기일 것이다. 모르는 개념이 아니였다면, 굳이 깊게 안들여봤기를 바란다.

function reactive(target) {
  const proxy = new Proxy(
    target,
    {
      get(target, key, receiver) {
        const res = target[key]; // 변경
        return res;
      },
      set(target, key, value, receiver) {
        const oldValue = target[key];

        target[key] = value; // 변경

        if (oldValue !== value) {
          // trigger(target, key, value, oldValue);
        }
        return value;
      }
    }
  )

  return proxy;
}

위처럼 프록시객체를 정의하고 아래처럼 프로토타입을 연결시켜준다.

const child = {
  birthYear: 2020
};

const parent = {
  birthYear: 1983,
  get age() {
    return new Date().getFullYear() - this.birthYear;
  }
};

const reactivityParent = reactive(parent);
child.__proto__ = reactivityParent;

그리고… 실행시키면!! 아래와 같이 이상하게 작동한다. child는 40살이 되어버리고, child에게 백수라는 job을 주었지만 부모님도 백수가 되어버린다.

> child.age //40
> child.job = 'unemployed'
> child.hasOwnProperty('job') //false
> child.job // 'unemployed'
> reactiveParent.hasOwnProperty('job') //true
> reactivityParent.job // 'unemployed'

get에서도 this가 parent가 되어버리고, set에서도 this가 parent가 되어버린다..!! 분명히 child에다가 요청했는데 프록시객체의 get트랩이 트리거되면서 target[key]에 부모가 바인딩 되어버린 것이다!!

이처럼 프로토타입 체인을 타고, 타고, 올라가서 child.age를 요청했건만 parent.age를 호출한 꼴이 되는 것이다. 즉, 리시버가 child가 아닌 parent가 되어버린 것이다.

⭐️예제7(중요): 리시버 * 프로토타입 * 프록시 * Reflect

그렇다면 reflect를 곁들이 proxy객체는 어떻게 작동할까? 모든 문제를 고칠 수 있다.

function reactive(target) {
  const proxy = new Proxy(
    target,
    {
      get(target, key, receiver) {
        const res = Reflect.get(target, key, receiver);
        // track(target, key);

        return res;
      },
      set(target, key, value, receiver) {
        const oldValue = target[key];
        const res = Reflect.set(target, key, value, receiver);

        if (oldValue !== res) {
          // trigger(target, key, value, oldValue);
        }
        return res;
      }
    }
  )

  return proxy;
}

const child = {
  birthYear: 2020
};

const parent = {
  birthYear: 1983,
  get age() {
    return new Date().getFullYear() - this.birthYear;
  }
};

const reactivityParent = reactive(parent);
child.__proto__ = reactivityParent;

이번에는 프록시 객체를 위처럼 정의하고, 아까와 예제6과 똑같이 프로토타입체이닝을 연결해준다(스킵) 그리고 실행시키면…!! 아주 자알~ 나온다

> child.age; // (2023년 기준)
3
> child.job = 'unemployed';
> child.hasOwnProperty('job');
true
> reactivityParent.hasOwnProperty('job');
false
> child.job;
'unemployed'
> reactivityParent.job;

중요한 차이점이 뭐였을까? 예제6은 target[key]를 바로 때려버렸고, 예제7은 Reflect.get(target, key, receiver)를 사용했다는 것이다.

이처럼 proxy객체의 트랩에 바로 target[key]를 사용하지 않고, Reflect.get(target, key, receiver)를 사용하면 리시버에 예상치 못하는 값이 오는것을 막을 수 있는 것이다.

다시한번 정리 하자면, 프로토타입을 타고 타고 타고 올라와서 proxy트랩을 실행시키면 리시버가 부모가 되어버린다. 하지만, 프로토타입을 타고타고타고 올라와서 proxy트랩에서 reflect객체를 이용해서 실행시키게 된다면, 우리가 처음 요청했던 그 child가 조져짐을 당하는, 그 리시버가 되는 것이다.


💡마무리

결국 이번 포스팅에서는 자바스크립트의 Reflect 객체와 리플렉티브 프로그래밍에 대해 살펴보았다. Reflective Programming은 런타임에서 바뀐 것들을 반영해 주는 개념으로, 메타조작을 스스로에게 reflection시키는 것이였다.

첫번째 예시에서는 데이터바인딩을 쉽게 할 수 있기에 그 필요성을 알 수 있었으며, 두번째 예시에서는 조져짐을 당하는 녀석, 즉 리시버가 되는 녀석을 확실히 할 수 있다는 것이였다.

메타프로그래밍은 코드의 재사용성을 높이고, 가독성을 높이며, 코드의 유지보수를 쉽게하기 위해 필요하다는 말들이 있는데, 솔직히 아직 와닿지는 않는다. 이러한 형태의 고~급 프로그래밍을 적절히 적용할만한 상황을 만나지도 못했을뿐더러 팀원들의 이해도가 받혀주지 않는다면, 나만아는 레거시가 되어버리지는 않을까 걱정도 된다.

메타프로그래밍 개념도 결국 나의 프로그래밍 시야가 조금 더 넓어지고, 더 많은 경험을 쌓고나면 그 활용방안이 잘 보이지 않을까 싶다. 깃헙의 수많은 프로젝트에서 ‘gosu’개발자들이 잘 활용하고 있는 모습이 이를 증명하지 않나 싶다.

디버깅, 테스팅, 플러그인 등에 잘 활용되고 있다는 메타프로그래밍과 자바스크립트에서 이를 쉽게 활용할 수 있도록 도와주는 reflection을 곁들인 proxy객체! 아직은 이해가 잘 되지 않지만, 열일하다보면 또 자연스럽게 와닿게 되는 날이 오리라 믿는다. 생각보다 빨리 올수도


맨 위로 이동하기

javascript 카테고리 내 다른 글 보러가기

댓글 남기기