필자는 Vue 3 Reactivity Proxy
의 트랩 내에서 Reflect
가 사용된 이유가 궁금했었다. 구글링을 해봐도 Proxy
의 핸들러나 여러 사용법 외엔 쉽게 찾기가 어려워 이번 기회에 알아보았다. 본 글에서는 Proxy
와 Reflect
의 간단한 개념과 함께 Proxy
와 Reflect
를 함께 사용하게 된 이유가 무엇인지 정리하고자 한다.
본격적으로 Proxy
를 알아보기에 앞서 한 가지 개념을 소개하려 한다. Proxy
또한 프로그래밍 언어의 어떤 기능 또는 개념을 지원하기 위해 나온 문법이라고 볼 수 있다. 바로 메타프로그래밍(Metaprogramming) 이다.
메타프로그래밍이란 하나의 컴퓨터 프로그램이 다른 프로그램을 데이터로 취급하는 것을 말한다. 즉, 어떤 프로그램이 다른 프로그램을 읽고, 분석하거나 변환하도록 설계된 것이다. 메타프로그래밍에 이용되는 언어를 메타 언어라고 하고, 조작 대상이 되는 언어를 대상 언어라고 한다. 메타 언어와 대상 언어는 같을 수도 있고, 다를 수도 있다.
아래 예제는 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>");
아래 예제는 메타 언어와 대상 언어를 둘 다 JavaScript로 작성하기 위해 eval()
을 사용했다. eval
은 런타임 중에 매개변수를 평가한다. 이렇게 한 프로그래밍 언어가 자기 자신의 메타 언어가 되는 것을 반영(Reflection) 이라고 하며, 이는 런타임 시점에 자신의 구조와 행위를 관리하고 수정하는 것을 의미한다.
> eval('1 + 1')
2
그럼 메타프로그래밍의 한 갈래인 반영에 대해 알아보도록 하겠다.
우리가 알아볼 Proxy
와 Reflect
는 반영을 구현한 것이고, 여기에는 또다시 세 가지의 종류가 있다.
Object.keys()
가 있다.[]
표기법을 사용하고, delete
연산자로 제거하는 것 등이 있다.Proxy
가 만들어졌다. (2ality의 운영자인 Axel Rauschmayer의 표현(링크 참조)인데, 사실 Object.defineProperty()
같은 메서드들이 이미 구현이 되어 있어 기존에 없었다고 보기에는 좀 애매하지만, Intercession의 개념이 대상에 변이를 일으키지 않는 것에 초점이 맞춰진다면 맞는 말일 수도 있겠다.)Proxy
란프록시 객체(Proxy object)는 대상 객체(Target object) 대신 사용된다. 대상 객체를 직접 사용하는 대신, 프록시 객체가 사용되며 각 작업을 대상 객체로 전달하고 결과를 다시 코드로 돌려준다.
이러한 방식을 통해 프록시 객체는 JavaScript의 기본적인 명령에 대한 동작을 사용자 정의가 가능하도록 한다. 객체 자체가 처리하는 특정 명령을 재정의할 수 있게 되는 것이다. 이런 명령의 종류는 속성 검색, 접근, 할당, 열거, 함수 호출 등이 대표적이다.
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]]
: 폐기 여부를 뜻함이는 기본적인 프록시 객체 생성 방법이며, 주의할 점은 한 번 세팅된 프록시 객체의 대상 객체를 변경할 수 없다는 점이다.
프록시 객체는 대상 객체의 기본 명령을 재정의하기 위한 함수인 트랩(Trap)을 통해 동작하는 매커니즘을 가진다. 모든 트랩은 취사선택이 가능하며, 트랩이 없다면 프록시 객체가 처리하는 별개의 동작이 없다.
기본적인 트랩 추가 방법은 아래와 같으며, 같은 트랩이 중복 선언되면 마지막에 선언된 트랩만 적용된다.
const target = { name: 'target' };
const p = new Proxy(target, {
get: () => console.log('access')
})
한 번 Proxy가 생성되면 트랩을 추가하거나 지울 수 없으며, 변경이 필요하다면 새로운 Proxy를 만들어야 한다. 자세한 트랩의 종류와 설명은 MDN을 통해 확인할 수 있다.
생성자로 만들어진 프록시 객체는 지워지거나(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에 의해 수거된다.
Reflect
란Reflect
는 Proxy
와 같이 JavaScript 명령을 가로챌 수 있는 메서드를 제공하는 내장 객체이다.
Reflect
의 특징Reflect
는 함수 객체가 아닌 일반 객체이다.[[Prototype]]
내부 슬롯을 가지며 그 값은 Object.prototype
이다. (Reflect.__proto__ === Object.prototype
)[[Construct]]
내부 슬롯이 존재하지 않아 new
연산자를 통해 호출될 수 없다.[[Call]]
내부 슬롯이 존재하지 않아 함수로 호출될 수 없다.Proxy
의 모든 트랩을 Reflect
의 내장 메서드가 동일한 인터페이스로 지원한다.Reflect
의 유용성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
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
Reflect.get
과 Reflect.set
이제 Reflect.get
과 Reflect.set
을 간단히 살펴보도록 하겠다.
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
Reflect.set(target, propertyKey, V [, receiver])
Reflect.set
은 Reflect.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
receiver
다음으로 설명할 Reflect
의 get
/set
과 receiver
의 이해를 돕기 위해, ECMAScript의 객체 속성 탐색과 관련된 Receiver
를 조금 이야기 해보겠다.
먼저 ECMAScript의 속성 탐색 과정을 살펴보겠다. (이해를 돕기 위한 파트이므로 Ordinary Object의 흐름만 간단하게 설명한다.)
const obj = { prop: 1 };
const propertyKey = 'prop';
console.log(obj.prop); // 1
console.log(obj[propertyKey]); // 1
ECMAScript의 스펙은 객체 속성 접근을 다음과 같이 해석한다.
MemberExpression.IdentifireName
과 MemberExpression.[Expression]
형태로 해석한다.GetValue(V)
를 호출하고, 내부 슬롯 메서드인 [[Get]](P, Receiver)
을 호출한다.그럼 다음 코드를 보도록 하자.
const child = { age: 0 };
const parent = { age: 40, job: 'programmer' };
child.__proto__ = parent;
console.log(child.job); // programmer
처리 과정은:
GetValue(V)
의 호출에서 P
는 탐색하고자 하는 속성명으로 job
이고, Receiver
는 GetThisValue(V)
의 결괏값으로 this
컨텍스트 값 child
가 된다.[[Get]](P, Receiver)
가 호출되면, OrdinaryGet(O, P, Receiver)
를 호출한다. O
는 child
, P
는 job
, Receiver
는 child
이다.child
에는 job
이 없기 때문에, 프로토타입 체이닝을 통해, parent.[[Get]](P, Receiver)
를 재귀적으로 호출한다. 여기에서 Receiver
는 그대로 전달된다.OrdinaryGet(O, P, Receiver)
가 호출되고, O
는 parent
가 된다. 이후 여기에서 job
을 찾고 'programmer'
를 반환하게 된다.자세한 스펙은 링크를 참조하되 중요한 건 프로토타입 체이닝에 의해 프로토타입 객체로 탐색을 이어가더라도 Receiver
가 유지된다는 점이다.
Receiver
는 언제 사용될까Receiver
는 OrdinaryGet(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년 기준)
위 코드의 흐름은 다음과 같다.
child.[[Get]](P, Receiver)
가 호출되고, Receiver
는 child
가 된다.parent.[[Get]](P, Receiver)
가 재귀적으로 호출되고 age
가 getter
로 존재하며 이를 실행할 때 Receiver
가 this
로 사용된다.결국, Receiver
는 말 그대로 프로토타입 체이닝 속에서, 최초로 작업 요청을 받은 객체가 무엇인지 알 수 있게 해준다. 지금은 [[Get]]
으로만 예제를 보여줬지만, [[Set]]
도 완전히는 아니지만 비슷한 과정(링크)을 거쳐, setter
의 this
가 child
로 잡힌다.
Reflect.get
과 Reflect.set
의 receiver
Receiver
는 앞서 설명한 것처럼 이름 그대로 작업 요청을 직접 받은 객체이다. Reflect.get
과 Reflect.set
의 receiver
는 target[propertyKey]
가 getter
나 setter
일 때 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
에 담아 유지하고 있으며, Reflect
의 get
/set
트랩에서는 receiver
매개변수를 통해 이를 컨트롤할 수 있게 된다.
Proxy
에서 Reflect
를 사용하게된 이유이제 진짜 Proxy
에 Reflect
를 같이 사용하게 된 이유를 알아보도록 하겠다. Vue 개발자인 Evan You는 온라인 강의를 통해 Proxy
의 트랩 내 Reflect
를 언급하며, "주제에서 벗어나기 때문에 자세히 말할 수는 없지만, 프로토타입에 대한 사이드 이펙트를 처리하기 위해 사용했다" 라고 했다. 이 멘트에 집중하여 프록시 객체인 반응형 객체를 프로토타입으로 사용할 경우 어떤 일이 일어나는지 보도록 하겠다.
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
트랩:
child
에서 age
를 조회하면 프로토타입 체인을 통해 프록시 객체로 탐색을 이어간다. (참조 - step 3)parent
의 [[Get]]
이 호출되면, Proxy
의 get
트랩이 트리거 되고, 트랩 내 target
은 parent
이기 때문에 target[key]
를 조회하게 되면, 단순히 parent.age
의 평가와 똑같아지므로 this
는 parent
가 된다.set
트랩:
child
에서 job
이란 속성에 'unemployed'
를 할당하면, 프로토타입 체인을 통해 프록시 객체로 탐색을 이어간다. (참조 - step 2)parent
의 [[Set]]
이 호출되면, Proxy
의 set
트랩이 트리거 되고, target[key]
는 결국 parent['job']
이기 때문에 parent
에 job
속성이 추가되고 값이 할당되게 된다.> child.age; // (2021년 기준)
40
> child.job = 'unemployed';
> child.hasOwnProperty('job');
false
> child.job;
'unemployed'
> reactivityParent.hasOwnProperty('job');
true
> reactivityParent.job;
'unemployed'
child
의 나이가 40
이 되고, 직업은 reactivityParent
에 세팅되어 있는 문제가 발생한다.
Reflect
를 통해 receiver
를 이용이제 Proxy
의 get
/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
트랩:
Proxy
의 get
트랩이 트리거 된다.Reflect.get
을 호출하는데 실제 수행 코드는 Reflect.get(parent, 'age', child)
가 된다.parent
의 get age()
가 실행될 때 this
의 바인딩이 child
가 되면서 child
에 대한 age
연산이 수행된다.set
트랩:
Proxy
의 set
트랩이 트리거 된다.Reflect.set
을 호출하는데 실제 수행 코드는 Reflect.set(parent, 'age', 'unemployed', child)
가 된다.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'
로 할당이 된다!
Proxy
에 Reflect
를 곁들이게 된 이유가 무엇인가를 주제로 시작했지만 정리하다 보니 receiver
의 이해가 어느새 주연 자리를 꿰찬 느낌이다. 결국 복잡한 JavaScript를 이해하기 위해선, ECMAScript 스펙을 이해해야 한다는 걸 다시금 깨닫게 되었다. (하지만 보기만 해도 멀미가 난다...)
그럼 길고 복잡한 글을 읽어준 분들께 감사를 전하며 마무리하겠다. 끝!
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