자바스크립트의 Proxy를 알아보자

Gyeop
22 min readFeb 8, 2022

--

Proxy라는 단어를 어디서 한 번 쯤은 들어봤을 것이다. 물론 자바스크립트 언어가 아니라 그냥 컴퓨터를 사용하다보면 한 번 쯤 들어봤을만한 단어이다. 사전적인 의미로는 대리라는 정도의 의미를 가지고 있는데, 분명 컴퓨터공학에서 서버/네트워크 공부를 하다보면 한 번 쯤 접하게된다. 사전적의미와 마찬가지로 컴퓨터 공학에서도 특정 자원의 요청에 대해 중계해주는 역할을 하는 서버 정도로 표현하는데, 이러한 비슷한 역할을 하는 것이 자바스크립트의 언어 내부에도 존재한다.

갑자기 프록시?

사실 프록시를 한 번도 접해본 것은 아니었지만, 대략적인 이론만 읽고 넘어갔었다. 머릿속에는 간단하게 자바스크립트에서 객체에 대한 특정한 행위를 할 때, 중개자 역할을 하는 것으로, 일반적으로 객체의 프로퍼티에 접근할 때에 실행시킬 수 있는 함수를 따로 지정해줄 수 있는 정도였다. 비즈니스 로직에서도 라이브러리를 통한 간접적인 사용을 제외하고는 크게 접할 일은 없었고, 프록시를 사용할 정도의 복잡한 인터페이스를 사용할 일은 없었는데, React-native의 헤르메스 엔진에 대해 읽어보다가 궁금증이 생기게 되었다.

Proxy and Reflect were originally excluded from Hermes because Facebook does not use them. We were also concerned that adding Proxy would hurt property lookup performance even when Proxy is not used. But Proxy quickly become the most requested feature of Hermes due to popular libraries such as MobX and Immer.

표현만 보면 헤르메스에서 프록시를 내장하도록 하면, 일반적으로 객체에 접근할 때에도 성능이 저하될 수 있어 구현하지 않았다고 했다. 아마 내부적으로 구현할 때 프록시를 거쳐 객체에 접근하지 않더라도, 기본이 되는 프록시 인터페이스를 거치도록 구현이 되기 때문이라고 생각할 수 있을 것 같다. 물론 인기있는 라이브러리에서 프록시를 사용하는 경우가 꽤 있어서 추가하기로 결정했다고는 한다. (RN버전 0.7에서 정식으로 활성화 된다고 한다. 그전에는 따로 hermes-engine의 RC버전을 사용해서 활성화 시킬 수 있다.) 실제로 mobx 라이브러리를 확인해보면 Proxy를 이용해서 배열과 객체에 옵저버를 구현해놓음을 볼 수 있고, immer에서는 Reflect를 이용하고, Proxy.revocable를 이용해 생성한 프록시를 제거하는 부분을 구현했음을 볼 수 있다.

Proxy!

위에서 설명했듯이, 프록시 객체는 기본적인 동작의 새로운 행동을 정의할 때 사용한다. 여기서 자바스크립트에서는 프로토타입이라는 개념을 이용해 일부 메서드나 프로퍼티를 위임(Behavior Delegation)하는데, 이는 우리가 프로토타입을 수정하거나, Shadowing을 이용해서 가려줄 수 있지만, 가장 저수준의 작업(ex Dot notation이나 bracket notation을 이용해 객체의 키에 접근하는 방식 등)은 이를 이용해서도 수정할 수 없다. 프록시는 바로 저수준 작업을 변경할 수 있는 래퍼를 제공해주는 역할을 한다.

const object = {};// proxy 생성자 함수는 target(object)과 ProxyHandler를 인자로 받는다.
const proxy = new Proxy(object, {});
// `set`동작을 하는 트랩이 존재하지 않으므로, target에 작업을 그대로 전달한다.
proxy["foo"] = "bar";
console.log(proxy); // { foo: 'bar' }

프록시는 위와 같이 생성할 수 있다. 프록시 래퍼를 생성하는 것이지만, 트랩이 정의되지 않은 경우에는 기본 동작을 사용하게 된다. 그렇다면 ProxyHandler에 포함될 수 있는 트랩을 하나씩 파헤쳐보자.

*예제코드에 일부 권장하지 않는 문법이 존재하는데, 이는 proxy가 해당 부분까지 작동함을 보여주기 위한 예제일 뿐, 실제로 사용하는 것을 권장하진 않는다 (__proto__ 등). 참고로만 보길 바란다.

get

target key receiver 총 3개의 인자를 받는다. 아래의 예제는 값을 찾지 못하면 undefined 대신 false를 리턴하도록 작성한 코드이다.

const object = { foo: "bar" };
const proxy = new Proxy(object, {
// target은 프록시의 대상
// key는 객체를 접근하기 위한 키
// receiver는 작업이 발생한 오브젝트 (this이지만 일반적으로 notation을 이용한 접근이므로 this는 proxy가 된다)
get(target, key, receiver) {
if (key in receiver) {
return target[key];
} else {
return false;
}
},
});
console.log(proxy['foo']); // bar
console.log(proxy['123']); // false

has

has트랩은 자바스크립트에서 in 연산자의 트랩 역할을 한다. 특정 key에 접근하여 값을 확인할 수 없도록 코드를 작성해보자.

const object = { foo: "1", bar: "2" };
const proxy = new Proxy(object, {
has(target, key) {
if (key === "foo") return false;
// return key in target; -> 대신에 Reflect의 메서드를 사용하자.
return Reflect.has(...arguments);
},
});
console.log("foo" in proxy); // false
console.log("bar" in proxy); // true
console.log(Reflect.has(proxy, "foo")); // false
console.log(Reflect.has(proxy, "bar")); // true
console.log(proxy.hasOwnProperty("foo")); // true
console.log(proxy.hasOwnProperty("bar")); // true

foo 라는 키를 가진 경우, false 를 리턴하도록 코드를 구현했다. 여기서 has트랩은 in 연산자의 트랩역할이라고 말했는데, 프록시를 이용해 in을 대체하는 코드의 내부에서 in을 다시 사용하는 것이 어색하게 느껴져서, Reflect로 대신하여 처리했다. 물론 Relfect.hasin 은 같은 역할을 한다. Reflect에 대해 조금 더 설명하자면, 프록시가 트랩을 이용해 역할을 재정의하는 저수준의 작업들에 대해 기본 동작을 제공하는 메서드 모음이라고 보면 된다. + 외부에서 Reflect.has 를 이용해 프록시에 접근할 경우, 마찬가지로 has 트랩에 걸리기 때문에 같은 값을 반환한다. (Reflect를 필요로하는 이유가 따로 존재하긴 하는데, 이 글은 프록시를 위한 글이므로 넘어간다.)

추가적으로 in과 비슷한 행위를 하지만 같은 레벨의 객체에서만 키를 탐색하는 Object.prototype.hasOwnProperty는 프록시의 트랩과는 다른 방식으로 작동하기 때문에, 트랩에 걸리지 않는다. (hasOwnProperty는 in이 아닌, 다른 네이티브 코드로 구현되어있는 코드이기 때문으로 보인다.)

set

set은 값을 설정하는 기본 동작을을 재정의하는 트랩의 역할을 한다. 아래는 기존에 존재하는 key가 있는 경우, 값을 할당하지 않는 코드의 예제이다.

"use strict";const object = { foo: "1" };
const proxy = new Proxy(object, {
set(target, key, value, receiver) {
if (target.hasOwnProperty(key)) {
console.log("이미 존재하는 값이에요!");
return false;
}
return Reflect.set(...arguments);
},
});
proxy.foo = 3; // "이미 존재하는 값이에요!" / 3
console.log(object); // {foo: '1'}

참고로 Strict mode에서는 값의 할당에 성공했으면 true를 반환해주어야 한다. falsy한 값을 반환하면 에러가 발생한다.

deleteProperty

delete 연산자를 이용해 객체의 속성을 제거할 때의 트랩 역할을 한다.

"use strict";const object = { foo: "1", bar: "2" };
const proxy = new Proxy(object, {
deleteProperty(target, key) {
if (key === "foo") return false;
return Reflect.deleteProperty(...arguments);
},
});
delete proxy.bar; // true
delete proxy.foo; // error

마찬가지로 Strict mode에서 falsy한 값을 반환하면 에러가 발생한다.

getPrototypeOf

Object.getPrototypeOf 메서드나 특정 객체에 __proto__등의 접근방법을 통해 프로토타입 체인에 접근하려고 할 때에 트랩을 설정할 수 있다. 아래는 프록시 객체를 이용해 프로토타입 접근을 막는 코드의 예제이다.

const object = { foo: "1" };
const proxy = new Proxy(object, {
getPrototypeOf(target) {
return null;
},
});
proxy.__proto__; // null
Object.getPrototypeOf(proxy); // null

참고로 strict 모드가 아니어도, 무조건 object type을 반환해야한다. (8. If Type(handlerProto) is neither Object nor Null, throw a TypeError exception.)

setPrototypeOf

Object.setPrototypeOf 메서드나, 특정 객체에 __proto__를 이용해 새로운 프로토타입을 할당할 때에 트랩을 설정할 수 있다. 아래는 빈 객체나 null을 프로토타입에 할당하려고 하는 경우, 에러를 발생시키는 예제코드이다.

const isEmpty = (obj) => {
return !!(obj && Object.keys(obj).length === 0 && Object.getPrototypeOf(obj) === Object.prototype);
};
const object = { foo: "1" };const proxy = new Proxy(object, {
setPrototypeOf(target, proto) {
if (proto === null) return false;
if (isEmpty(proto)) return false;
return Reflect.setPrototypeOf(...arguments);
},
});
proxy.__proto__ = null; // error
proxy.__proto__ = {}; // error
Object.setPrototypeOf(proxy, null); // error
Object.setPrototypeOf(proxy, {}); // error
Object.setPrototypeOf(proxy, { a: 1 }); // Proxy {foo: '1'}

참고로 strict 모드가 아니어도, fasly한 값이 반환되면 에러가 발생한다.

preventExtensions, isExtensible

객체에 새로운 속성을 추가하는 것을 방지하는 preventExtensions, 그리고 이를 확인할 수 있는 isExtensible에 트랩을 설정할 수 있다.

const object = { foo: "1" };const proxy = new Proxy(object, {
isExtensible(target) {
return Reflect.isExtensible(...arguments);
},
preventExtensions(target) {
if (target.foo === "1") {
Reflect.preventExtensions(...arguments);
return true;
}
return Reflect.preventExtensions(...arguments);
},
});
console.log(Object.isExtensible(proxy)); // trueObject.preventExtensions(proxy);

먼저 preventExtensions의 트랩이 falsy한 값을 리턴하면 오류가 발생한다. 또한 true를 리턴했으나, 타겟이 여전히 확장가능한 상태에 있다면 에러를 발생시킨다. 재미있는 사실은 preventExtensions의 트랩은 해당 메서드에만 반응할 줄 알았는데, 이와 비슷한 메서드인 Object.freezeObject.seal도 트랩이 감지된다. 마찬가지로 isExtensibleObject.isFrozenObject.isSealed에 감지된다.

defineProperty, getOwnPropertyDescriptor

객체의 속성과 속성 설명자(Descriptor)를 정의해줄 수 있는 Object.defineProperty과 해당 속성의 속성 설명자를 반환하는 Object.getOwnPropertyDescriptor()에 트랩을 설정해줄 수 있다.

const object = { foo: "1" };const proxy = new Proxy(object, {
defineProperty(target, key, descriptor) {
// writable이 falsy하면 프로퍼티를 정의할 수 없다.
if (!descriptor.writable) return false;
return Reflect.defineProperty(...arguments);
},
getOwnPropertyDescriptor(target, key) {
return Reflect.getOwnPropertyDescriptor(...arguments);
},
});
Object.defineProperty(proxy, "bar", { value: "2", writable: true, enumerable: false, configurable: true }); // Proxy {foo: '1', bar: '2'}
Object.defineProperty(proxy, "zoo", { value: "3", writable: false }); // falsy값을 return하는 조건문에 걸려 에러가 발생한다.
Object.getOwnPropertyDescriptor(proxy, "bar");

getOwnPropertyDescriptor에서 여러 에러가 발생할 수 있는 케이스가 존재하는데,

  1. objectundefined 가 아닌 다른 타입을 반환하는 경우
const object = { foo: "1" };const proxy = new Proxy(object, {
getOwnPropertyDescriptor(target, key) {
return 1;
});
Object.getOwnPropertyDescriptor(proxy, "foo"); // error

2. 속성 설명자가 가져선 안되는 프로퍼티를 가지는 경우

const object = { foo: "1" };const proxy = new Proxy(object, {
getOwnPropertyDescriptor(target, key) {
return { bar: "2" }; // bar라는 프로퍼티는 PropertyDescriptor에 포함되어선 안된다.
},
});
Object.getOwnPropertyDescriptor(proxy, "foo");

3. object를 확장할 수 없는 상태인 경우 (해당 프로퍼티의 descriptor가 아닌 다른 값을 반환할 수 없다 - freeze, seal, preventExtensions)

const object = {};
Object.freeze(object); // seal, preventExtensions 모두 포함됨
const proxy = new Proxy(object, {
getOwnPropertyDescriptor(target, key) {
return { value: 2, writable: true, enumerable: true, configurable: true };
},
});
Object.getOwnPropertyDescriptor(proxy, "foo"); // error

4. (3)과 마찬가지로 객체의 속성 자체가 구성할 수 없는 속성을 가지는 모순적인 경우 (configurable: false)

const object = {};const proxy = new Proxy(object, {
getOwnPropertyDescriptor(target, key) {
return { value: 2, writable: true, enumerable: true, configurable: false };
},
});
Object.getOwnPropertyDescriptor(proxy, "foo"); // error

ownKeys

객체의 프로퍼티를 반복문으로 탐색하여 프로퍼티 목록을 얻는 경우에 감지되는 트랩을 설정할 수 있다.

const object = { foo: "1", bar: "2" };const proxy = new Proxy(object, {
ownKeys(target) {
return Reflect.ownKeys(...arguments).filter((key) => key !== "foo");
},
});
Object.keys(proxy);
Object.values(proxy);
Object.entries(proxy);
Object.getOwnPropertyNames(proxy);
Object.getOwnPropertySymbols(proxy);
for (const key in proxy) {}

예제코드에 적힌 것 처럼, 아래의 코드를 실행할 때 트랩이 감지되는데, 이터레이터를 도는 만큼 실행하는게 아니라, 1회만 실행된다. Object.keys, Object.values, Object.entries는 속성 설명자의 enumerabletrue인 케이스에만 실행되므로, 이는 기존의 getOwnPropertyDescriptor트랩을 이용해 강제로 반환하게 만들 수 있다. getOwnPropertyDescriptor는 이터레이터를 반복하는 만큼 실행된다.

const object = {};const enumerableFalsyDescriptor = { value: "2", writable: true, enumerable: false, configurable: true };Object.defineProperty(object, "foo", enumerableFalsyDescriptor);
Object.defineProperty(object, "bar", enumerableFalsyDescriptor);
Object.defineProperty(object, "zoo", enumerableFalsyDescriptor);
const proxy = new Proxy(object, {
ownKeys(target) {
return Reflect.ownKeys(...arguments);
},
getOwnPropertyDescriptor(target, key) {
// 아래의 코드로 실행하면 enumerable이 모두 falsy하므로 빈 배열이 반환된다.
// return Reflect.getOwnPropertyDescriptor(...arguments);
// Object.keys는 내부적으로 [[GetOwnProperty]]를 호출하기 때문에,
// 중간에서 이를 가로채서 강제로 반환하게 만들어 줄 수 있다.
return { enumerable: true, configurable: true };
},
});
Object.keys(proxy); // ['foo', 'bar', 'zoo'];

construct

construct는 생성자 함수에서 new 연산자를 이용해 새로운 인스턴스를 생성할 때 감지되는 트랩이다. 지금까지 작성했던 트랩들과는 다르게 함수를 인자로 받는다.

function Target(defaultValue) {
this.value = defaultValue;
}
const proxy = new Proxy(Target, {
construct: function (target, argumentList) {
return Reflect.construct(...arguments);
},
});
const instance = new proxy(100); // construct가 감지console.log(typeof proxy); // function
console.log(instance instanceof proxy); // true
console.log(instance instanceof Target); // true

apply

construct가 생성자 함수에서 실행되었다면, apply는 일반적인 함수 호출인 경우에 감지되는 트랩이다.

function square(defaultValue) {
return defaultValue * defaultValue;
}
const proxy = new Proxy(square, {
apply: function (target, thisArg, argumentList) {
return Reflect.apply(
target,
thisArg,
argumentList.map((d) => d + 1)
);
},
});
proxy(3);

프록시를 비활성화 하는 방법

모든 트랩 종류를 하나씩 알아보았다. 그렇다면 생성했던 프록시 래퍼를 사용할 수 없도록 취소하는 방법은 없을까?

const object = { foo: "bar" };const { proxy, revoke } = Proxy.revocable(object, {
get(target, key, receiver) {
console.log("hi");
if (key in receiver) {
return target[key];
} else {
return false;
}
},
});
console.log(proxy["foo"]); // barrevoke();console.log(proxy["foo"]); // TypeError: Cannot perform 'get' on a proxy that has been revoked

Proxy.revocable를 이용해 프록시를 생성하는 경우에, 반환 값에 Revoke() 함수를 이용해 프록시의 접근을 차단할 수 있다. 기존의 프록시와 다르게 new 연산자를 사용하지 않음에 주의해야한다.

프록시에서 제공하는 트랩의 종류들에 대해 살펴보았다. 종류가 꽤 많고 복잡해보여도, 프록시 자체를 작성하는 것은 그렇게 복잡하지 않다. 다만 프록시를 작성할 때 주의할 점이 있는데, 위의 예제에서 일부 에러가 발생하는 케이스를 작성했지만 이외에 스펙을 꼼꼼히 읽어보고 작성하는 것이 좋다. 이는 JS 언어의 설계에 맞는 내부적인 규칙이 존재하는데, 모든 프록시의 구현은 기존의 코드를 대체하는 것이기 때문에, 기존에 우리가 언어를 사용하던 인터페이스와 다른 규칙을 적용하면 안된다고 한다(=불변성을 깨트리면 안된다). 그렇기에 의도적으로 에러를 발생시키는 것이다.

An implementation must not allow these invariants to be circumvented in any manner such as by providing alternative interfaces that implement the functionality of the essential internal methods without enforcing their invariants.

참고로 strict 모드의 유무에 따라 에러가 발생하는 케이스도 있어서, 이왕이면 strict모드를 사용하자.

추가로 프록시의 문제 중 하나는 IE를 완전히 지원하지 않는다는 것인데, pollyfill을 적용하더라도 get set apply construct 만 지원하고 있기 때문에, 프록시를 이용한 부분들은 IE를 지원하기 어려움이 있어보인다. 실제로 프록시를 이용하기 시작한 mobx 5버전부터는 IE지원을 하지않아서, IE나 RN을 위해 4버전을 이용하는 경우도 있고, 이를 위해 6버전부터 Proxy 사용 유무를 설정하는 useProxies가 생겼다. 지난번에 올렸던 AbortController도 IE를 지원하지 않아서 사용에 있어 굉장히 망설여졌는데, 프록시도..?

공부하다보니 프록시의 좀 더 상위 주제에 메타프로그래밍과 관련된 주제를 찾아볼 수 있었는데, 좀 더 큰 그림에 대한 공부도 필요할 것 같다는 생각이 든다.

참고자료

--

--