JavaScript Proxy. 근데 이제 Reflect를 곁들인


필자는 Vue 3 Reactivity Proxy의 트랩 내에서 Reflect가 사용된 이유가 궁금했었다. 구글링을 해봐도 Proxy의 핸들러나 여러 사용법 외엔 쉽게 찾기가 어려워 이번 기회에 알아보았다. 본 글에서는 Proxy와 Reflect의 간단한 개념과 함께 ProxyReflect를 함께 사용하게 된 이유가 무엇인지 정리하고자 한다.

1. 메타프로그래밍

본격적으로 Proxy를 알아보기에 앞서 한 가지 개념을 소개하려 한다. Proxy 또한 프로그래밍 언어의 어떤 기능 또는 개념을 지원하기 위해 나온 문법이라고 볼 수 있다. 바로 메타프로그래밍(Metaprogramming) 이다. 메타프로그래밍이란 하나의 컴퓨터 프로그램이 다른 프로그램을 데이터로 취급하는 것을 말한다. 즉, 어떤 프로그램이 다른 프로그램을 읽고, 분석하거나 변환하도록 설계된 것이다. 메타프로그래밍에 이용되는 언어를 메타 언어라고 하고, 조작 대상이 되는 언어를 대상 언어라고 한다. 메타 언어와 대상 언어는 같을 수도 있고, 다를 수도 있다.

1.1. 메타 언어와 대상 언어가 다른 경우

아래 예제는 JSP에서 HTML을 만들어내는 코드인데 메타 언어는 Java이고, 대상 언어는 HTML이 될 것이다. (나름대로 간단한 예제를 만들어본 건데 대상언어가 HTML이라 약간 애매하긴 하다. 하지만 이 안에서 <script>를 사용한다고 가정하면 좀 더 확실해지지 않을까 한다.)

out.println("<html><body>");

out.println("<h1>Hello World</h1>");
out.println("The current time is : " + new java.util.Date());
out.println("</body></html>");

1.2. 메타 언어와 대상 언어가 같은 경우

아래 예제는 메타 언어와 대상 언어를 둘 다 JavaScript로 작성하기 위해 eval()을 사용했다. eval은 런타임 중에 매개변수를 평가한다. 이렇게 한 프로그래밍 언어가 자기 자신의 메타 언어가 되는 것을 반영(Reflection) 이라고 하며, 이는 런타임 시점에 자신의 구조와 행위를 관리하고 수정하는 것을 의미한다.

> eval('1 + 1')
2

그럼 메타프로그래밍의 한 갈래인 반영에 대해 알아보도록 하겠다.

1.3. 반사형 프로그래밍(Reflective programming)

우리가 알아볼 ProxyReflect반영을 구현한 것이고, 여기에는 또다시 세 가지의 종류가 있다.

  • Type introspection 런타임에서 프로그램이 자신의 구조에 접근하여 타입이나 속성을 알아내는 능력을 뜻한다. 예시로 Object.keys()가 있다.
  • Self-modification 구조를 스스로 변경할 수 있다는 의미로, 예시로 어떤 속성에 접근하기 위해 [] 표기법을 사용하고, delete 연산자로 제거하는 것 등이 있다.
  • Intercession 말 그대로 어떤 것을 대신하여 개입하는 행위를 뜻하며, 언어가 수행되는 일부 의미를 재정의하는 것을 말한다. 이를 지원하기 위해 ES2015에서 Proxy가 만들어졌다. (2ality의 운영자인 Axel Rauschmayer의 표현(링크 참조)인데, 사실 Object.defineProperty() 같은 메서드들이 이미 구현이 되어 있어 기존에 없었다고 보기에는 좀 애매하지만, Intercession의 개념이 대상에 변이를 일으키지 않는 것에 초점이 맞춰진다면 맞는 말일 수도 있겠다.)

2. Proxy

프록시 객체(Proxy object)는 대상 객체(Target object) 대신 사용된다. 대상 객체를 직접 사용하는 대신, 프록시 객체가 사용되며 각 작업을 대상 객체로 전달하고 결과를 다시 코드로 돌려준다.

2.png

이러한 방식을 통해 프록시 객체는 JavaScript의 기본적인 명령에 대한 동작을 사용자 정의가 가능하도록 한다. 객체 자체가 처리하는 특정 명령을 재정의할 수 있게 되는 것이다. 이런 명령의 종류는 속성 검색, 접근, 할당, 열거, 함수 호출 등이 대표적이다.

2.1. 프록시 객체 생성

new Proxy(target, handler);

프록시 객체를 생성하기 위해 new를 붙여 생성자를 호출해야 한다. Proxy는 필수적으로 다음과 같은 2개의 인자를 받는다.

  • target : Intercession 처리를 해야 하는 대상 객체
  • handler : Intercession에 사용될 핸들러(트랩)를 추가할 때 사용되는 객체
const p = new Proxy({}, {});

위 코드는 별다른 기능이 없는 기본적인 프록시 객체다. 변수 p의 모양은 다음과 같다.

> p;
Proxy {}
  > [[Handler]]: Object
  > [[Target]]: Object
  > [[IsRevoked]]: false

[[]] 와 같은 슬롯은 JavaScript의 내부 슬롯(Internal Slot)이라 하며 이는 코드로 접근이 불가능하다.

  • [[Handler]] : 두 번째 인자로 전달한 객체를 매핑
  • [[Target]] : Proxy 처리가 될 대상을 뜻하며 첫 번째 인자로 전달한 객체
  • [[IsRevoked]] : 폐기 여부를 뜻함

이는 기본적인 프록시 객체 생성 방법이며, 주의할 점은 한 번 세팅된 프록시 객체의 대상 객체를 변경할 수 없다는 점이다.

2.2. 트랩

프록시 객체는 대상 객체의 기본 명령을 재정의하기 위한 함수인 트랩(Trap)을 통해 동작하는 매커니즘을 가진다. 모든 트랩은 취사선택이 가능하며, 트랩이 없다면 프록시 객체가 처리하는 별개의 동작이 없다.

기본적인 트랩 추가 방법은 아래와 같으며, 같은 트랩이 중복 선언되면 마지막에 선언된 트랩만 적용된다.

const target = { name: 'target' };
const p = new Proxy(target, {
  get: () => console.log('access')
})

한 번 Proxy가 생성되면 트랩을 추가하거나 지울 수 없으며, 변경이 필요하다면 새로운 Proxy를 만들어야 한다. 자세한 트랩의 종류와 설명은 MDN을 통해 확인할 수 있다.

2.3. 폐기 가능한 프록시 객체

생성자로 만들어진 프록시 객체는 지워지거나(garbage collection) 재사용이 불가능하다. 따라서 필요에 따라 폐기 가능한 프록시 객체(revocable proxy)를 만들 수도 있다.

const revocable = Proxy.revocable({}, {});

revocable() 메서드는 Proxy 객체와 revoke() 메서드가 담겨있는 객체를 반환한다.

> revocable;
{proxy: Proxy, revoke: f}
> revocable.proxy;
Proxy {}
  > [[Handler]]: Object
  > [[Target]]: Object
  > [[IsRevoked]]: false

revocable.proxy 객체는 앞서 설명한 생성자로 생성된 프록시 객체와 완전히 동일하다. revoke() 메서드를 한 번 호출하면 프록시 객체의 [[IsRevoked]] 슬롯의 값이 true로 변경된다. 이렇게 폐기된 프록시 객체의 트랩이 다시 트리거 되면 TypeError를 발생시키며, revoke()가 실행된 프록시 객체는 garbage collector에 의해 수거된다.

3. Reflect

ReflectProxy와 같이 JavaScript 명령을 가로챌 수 있는 메서드를 제공하는 내장 객체이다.

3.1. Reflect의 특징

  • Reflect는 함수 객체가 아닌 일반 객체이다.
  • [[Prototype]] 내부 슬롯을 가지며 그 값은 Object.prototype이다. (Reflect.__proto__ === Object.prototype)
  • [[Construct]] 내부 슬롯이 존재하지 않아 new 연산자를 통해 호출될 수 없다.
  • [[Call]] 내부 슬롯이 존재하지 않아 함수로 호출될 수 없다.
  • Proxy의 모든 트랩을 Reflect의 내장 메서드가 동일한 인터페이스로 지원한다.

3.2. Reflect의 유용성

  • 하나의 네임스페이스에 모인 API 이제 Object보다 자연스러운 Reflect라는 네임스페이스에서 원하는 반영 API를 사용할 수 있다.
  • 더 깔끔한 코드 Reflect를 통해 에러 핸들링이나 반영을 구현하면 더 깔끔하게 구현할 수 있다.
// 1. 에러 핸들링
const obj = {};
try {
  Object.defineProperty(obj, 'prop', { value: 1 });
  console.log('success');
} catch(e) {
  console.log(e);
}

const obj2 = {};
if (Reflect.defineProperty(obj2, 'prop', { value: 1 })) {
  console.log('success');
} else {
  console.log('problem creating prop');
}
// 2. 더 읽기 쉬운 코드
const obj = { prop: 1 };
console.log(Reflect.has(obj, 'prop') === ('prop' in obj)); // true
  • 안정적인 호출 엄격한 ESLint Rule Preset을 사용했던 개발자라면 no-prototype-builtins 규칙을 겪어 봤을 것이다. 기본 객체 내장 메서드명이 인스턴스 객체의 속성명일 경우 발생할 버그를 방어하기 위해 사용되는 규칙으로, Object.prototype의 메서드를 인스턴스에서 사용하지 못하게 한다. 그래서 obj.hasOwnProperty를 사용하지 못하고  Object.prototype.hasOwnProperty.call로 길게 작성해야 하는데, 여기에서도 Reflect를 사용하면 해당 Rule을 유지하면서 더 깔끔하게 코드를 작성할 수 있다.
const obj = { prop: 1, hasOwnProperty: 2 };
> obj.hasOwnProperty('prop');
Uncaught TypeError: obj.hasOwnProperty is not a function

> Object.prototype.hasOwnProperty.call(obj, 'prop');
true
> Reflect.has(obj, 'prop');
true

3.3. Reflect.getReflect.set

이제 Reflect.getReflect.set을 간단히 살펴보도록 하겠다.

3.3.1. Reflect.get(target, propertyKey [, receiver])

Reflect.get은 기본적으로 target[propertyKey] 값을 반환한다. 만약 target이 객체가 아닐 경우 TypeError을 발생시킨다. 이 TypeError도 기존 JavaScript의 모호한 점을 보완해 준다. 예를 들어, 'a'['prop']의 경우 undefined로 평가되지만 Reflect.get은 명확하게 에러를 발생시킨다는 점에서 차이가 있다.

const obj = { prop: 1 };
> Reflect.get(obj, 'prop');
1

> 'a'['prop'];
undefined
> Reflect.get('a', 'prop');
Uncaught TypeError: Reflect.get called on non-object

3.3.2. Reflect.set(target, propertyKey, V [, receiver])

Reflect.setReflect.get과 흡사하게 동작한다. 다만 set이란 메서드명에 맞게 할당할 V 인자가 추가된다.

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

> 'a'['prop'] = 1; // No Error
1
> Reflect.set('a', 'prop', 1);
Uncaught TypeError: Reflect.set called on non-object

3.4. 막간: receiver

다음으로 설명할 Reflectget/setreceiver의 이해를 돕기 위해, ECMAScript의 객체 속성 탐색과 관련된 Receiver를 조금 이야기 해보겠다.

3.4.1. ECMAScript의 속성 탐색 과정

먼저 ECMAScript의 속성 탐색 과정을 살펴보겠다. (이해를 돕기 위한 파트이므로 Ordinary Object의 흐름만 간단하게 설명한다.)

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

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

ECMAScript의 스펙은 객체 속성 접근을 다음과 같이 해석한다.

  1. MemberExpression.IdentifireNameMemberExpression.[Expression] 형태로 해석한다.
  2. 처리 과정을 거쳐 GetValue(V)를 호출하고, 내부 슬롯 메서드인 [[Get]](P, Receiver)을 호출한다.

그럼 다음 코드를 보도록 하자.

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

child.__proto__ = parent;

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

처리 과정은:

  1. GetValue(V)의 호출에서 P는 탐색하고자 하는 속성명으로 job이고, ReceiverGetThisValue(V)의 결괏값으로 this 컨텍스트 값 child가 된다.
  2. [[Get]](P, Receiver)가 호출되면, OrdinaryGet(O, P, Receiver)를 호출한다. Ochild, Pjob, Receiverchild이다.
  3. child에는 job이 없기 때문에, 프로토타입 체이닝을 통해, parent.[[Get]](P, Receiver)를 재귀적으로 호출한다. 여기에서 Receiver는 그대로 전달된다.
  4. 이후 OrdinaryGet(O, P, Receiver)가 호출되고, Oparent가 된다. 이후 여기에서 job을 찾고 'programmer'를 반환하게 된다.

자세한 스펙은 링크를 참조하되 중요한 건 프로토타입 체이닝에 의해 프로토타입 객체로 탐색을 이어가더라도 Receiver가 유지된다는 점이다.

3.4.2. Receiver는 언제 사용될까

ReceiverOrdinaryGet(O, P, Receiver)으로 찾은 속성이 getter인 경우에만 사용되며, getter 함수의 this 값으로 전달된다. 이제 아래 코드를 살펴보자.

const child = {
  birthYear: 2019
};
const parent = {
  birthYear: 1981,
  get age() {
    return new Date().getFullYear() - this.birthYear;
  },
  set birthYear(year) {
    this.birthYear = year;
  }
};

child.__proto__ = parent;
console.log(child.age); // 2 (2021년 기준)

child.birthYear = 2017;
console.log(child.age); // 4 (2021년 기준)

위 코드의 흐름은 다음과 같다.

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

결국, Receiver는 말 그대로 프로토타입 체이닝 속에서, 최초로 작업 요청을 받은 객체가 무엇인지 알 수 있게 해준다. 지금은 [[Get]]으로만 예제를 보여줬지만, [[Set]]도 완전히는 아니지만 비슷한 과정(링크)을 거쳐, setterthischild로 잡힌다.

3.5. Reflect.getReflect.setreceiver

Receiver는 앞서 설명한 것처럼 이름 그대로 작업 요청을 직접 받은 객체이다. Reflect.getReflect.setreceivertarget[propertyKey]gettersetter일 때 this의 컨텍스트로 동작하게 된다. 즉 receiver를 통해, this 바인딩을 조절할 수 있다는 의미이다.

아래 예제는 receiver를 다르게 적용하여 this 바인딩을 조작한 뒤 sum값을 다르게 가지고 오는 코드이다.

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.set에서 receiver를 이용하는 예제이다.

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

결론적으로 JavaScript는 getter/setter일 때 프로토타입 체이닝을 하더라도 최초 속성 접근 요청을 받은 객체를 Receiver에 담아 유지하고 있으며, Reflectget/set 트랩에서는 receiver 매개변수를 통해 이를 컨트롤할 수 있게 된다.

4. Proxy에서 Reflect를 사용하게된 이유

이제 진짜 ProxyReflect를 같이 사용하게 된 이유를 알아보도록 하겠다. Vue 개발자인 Evan You는 온라인 강의를 통해 Proxy의 트랩 내 Reflect를 언급하며, "주제에서 벗어나기 때문에 자세히 말할 수는 없지만, 프로토타입에 대한 사이드 이펙트를 처리하기 위해 사용했다" 라고 했다. 이 멘트에 집중하여 프록시 객체인 반응형 객체를 프로토타입으로 사용할 경우 어떤 일이 일어나는지 보도록 하겠다.

4.1. Reflect가 없다면

다음 코드는 필자가 이전에 작성했던 Vue 3 반응형 동작 원리 살펴보기(링크)에서 Proxy 부분만 발췌한 뒤 Reflect를 사용하지 않도록 바꾼 코드이다. Reflect를 사용하지 않고 일반적인 Proxy의 트랩 코드를 사용한다면, 현재 일어나는 탐색의 주체가 무엇인지 알 수가 없기 때문에 문제가 발생할 것이다.

function reactive(target) {
  const proxy = new Proxy(
    target,
    {
      get(target, key, receiver) {
        const res = target[key]; // 변경
        // track(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;
}

이제 위 코드를 기반으로 다음과 같이 코드를 작성해보겠다. 프록시 객체인 reactivityParent를 프로토타입으로 둔 child를 조작하는 예제이다.

const child = {
  birthYear: 2019
};

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

const reactivityParent = reactive(parent);
child.__proto__ = reactivityParent;
  • get 트랩:

    1. child에서 age를 조회하면 프로토타입 체인을 통해 프록시 객체로 탐색을 이어간다. (참조 - step 3)
    2. parent[[Get]]이 호출되면, Proxyget 트랩이 트리거 되고, 트랩 내 targetparent이기 때문에 target[key]를 조회하게 되면, 단순히 parent.age의 평가와 똑같아지므로 thisparent가 된다.
  • set 트랩:

    1. child에서 job 이란 속성에 'unemployed'를 할당하면, 프로토타입 체인을 통해 프록시 객체로 탐색을 이어간다. (참조 - step 2)
    2. parent[[Set]]이 호출되면, Proxyset 트랩이 트리거 되고, target[key]는 결국 parent['job']이기 때문에 parentjob 속성이 추가되고 값이 할당되게 된다.
> child.age; // (2021년 기준)
40
> child.job = 'unemployed';

> child.hasOwnProperty('job');
false
> child.job;
'unemployed'

> reactivityParent.hasOwnProperty('job');
true
> reactivityParent.job;
'unemployed'

child의 나이가 40이 되고, 직업은 reactivityParent에 세팅되어 있는 문제가 발생한다.

4.2. Reflect를 통해 receiver를 이용

이제 Proxyget/set 트랩 내 Reflect를 사용하고 receiver를 전달하여 실제 작업 요청받은 객체를 this 컨텍스트로 사용하여 사이드 이펙트를 없애보도록 하겠다.

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: 2019
};

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

const reactivityParent = reactive(parent);
child.__proto__ = reactivityParent;
  • get 트랩:

    1. 4.1의 코드와 마찬가지로 Proxyget 트랩이 트리거 된다.
    2. 값을 알아오기 위해 Reflect.get을 호출하는데 실제 수행 코드는 Reflect.get(parent, 'age', child)가 된다.
    3. parentget age()가 실행될 때 this의 바인딩이 child가 되면서 child에 대한 age 연산이 수행된다.
  • set 트랩:

    1. 4.1의 코드와 마찬가지로 Proxyset 트랩이 트리거 된다.
    2. 값을 세팅하기 위해 Reflect.set을 호출하는데 실제 수행 코드는 Reflect.set(parent, 'age', 'unemployed', child)가 된다.
    3. receiver를 전달하며 Reflect.set을 호출하였으므로 실제 작업의 대상은 child가 된다.
> child.age; // (2021년 기준)
2
> child.job = 'unemployed';
> child.hasOwnProperty('job');
true
> reactivityParent.hasOwnProperty('job');
false
> child.job;
'unemployed'
> reactivityParent.job;
undefined

이제 child의 나이도 제대로 나오고, 직업도 'unemployed'로 할당이 된다!


ProxyReflect를 곁들이게 된 이유가 무엇인가를 주제로 시작했지만 정리하다 보니 receiver의 이해가 어느새 주연 자리를 꿰찬 느낌이다. 결국 복잡한 JavaScript를 이해하기 위해선, ECMAScript 스펙을 이해해야 한다는 걸 다시금 깨닫게 되었다. (하지만 보기만 해도 멀미가 난다...) 그럼 길고 복잡한 글을 읽어준 분들께 감사를 전하며 마무리하겠다. 끝!


References

https://en.wikipedia.org/wiki/Metaprogramming
https://ko.wikipedia.org/wiki/메타프로그래밍
https://ko.wikipedia.org/wiki/반영_(컴퓨터_과학)
https://exploringjs.com/es6/ch_proxies.html
https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Reflect
https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Object
https://tc39.es/ecma262/#sec-reflection
https://tc39.es/ecma262/#sec-ordinaryget
https://tc39.es/ecma262/#sec-ordinaryset
https://github.com/tvcutsem/harmony-reflect/wiki
https://v8.dev/blog/understanding-ecmascript-part-2

이진우2021.04.13
Back to list