본 포스팅은 여기에 올라온 게시글을 바탕으로 작성되었습니다.
파트와 카테고리 동일한 순서로 모든 내용을 소개하는 것이 아닌, 몰랐거나 새로운 내용 위주로 다시 정리하여 개인공부 목적으로 작성합니다.
중간중간 개인 판단 하에 필요하다고 생각될 시, 기존 내용에 추가로 보충되는 내용이 있을 수 있습니다.
Proxy
는 ES6(ES2015)에 도입된 문법이다. 구버전의 브라우저에서도 호환되도록 트랜스컴파일러(transcompiler
)의 역할을 하는 바벨(Babel
)에서 지원하지 않기 때문에 인터넷 익스플로러에서는 사용할 수 없다. 그라나 최신 브라우저의 경우에는 모두 지원하고 있다.
Proxy
의 의미는 위임, 대리인
등의 뜻을 갖고 있는데, 용어의 뜻에 걸맞게 컴퓨터 분야에서 다양한 의미로 사용되고 있다. 대표적으로 웹 서버에서 사용되는 Forward Proxy
또는 Reverse Proxy
개념 부터 프론트엔드 React
에서도 Webpack
번들러를 통해 개발서버에서 Proxy
를 설정할 수 있다. 각각 세부적인 역할은 당연히 모두 다르겠지만, 초점은 무언가를 대리하는 것에 맞춰져 있다.
자바스크립트에서의 Proxy
역시 이와 유사하게, 특정 객체를 감싸 프로퍼티 읽기 및 쓰기와 같은 객체에 가해지는 작업을 중간에 가로채는 객체로 사용한다. 이렇게 가로챈 작업은 Proxy
객체에서 처리되거나, 아니면 원래 객체가 처리하도록 그대로 전달되기도 한다. Proxy
는 어떻게 사용하고, 이러한 처리가 실무에서 어떻게 적용되는지 살펴보도록 하자.
Proxy
는 다음과 같이 선언할 수 있다.
let proxy = new Proxy(target, handler);
target
: 감싸게 될 객체로, 함수를 포함한 모든 객체가 가능 (함수 역시 객체이므로)handler
: 동작을 가로채는 메서드 trap
이 담긴 객체로, 이곳에서 Proxy
의 동작을 처리Proxy
에 작업이 가해지고, handler
에 등록된 작업과 상응하는 트랩이 있다면 트랩이 실행되어 프록시가 이 작업을 처리하게 된다. 만약 트랩이 없다면 target
객체에 직접 작업이 수행된다. 먼저 트랩이 없는 프록시를 사용한 예시를 살펴보자.
let target = {};
let proxy = new Proxy(target, {}); // 빈 핸들러 전달
proxy.test = 5; // 프록시 객체에 값을 설정
console.log( proxy.test ); // 5
console.log( target.test ); // 5
// 두 객체에서 모두 동일한 값을 획득
for(let key in proxy)
console.log(key); // test => 정상적으로 프로퍼티로 등록
이 경우에는 프록시에 트랩이 전달되지 않았기 때문에, proxy
객체에 가해지는 모든 작업이 target
에게 전달된다. 따라서 이 경우에 proxy
객체는 target
을 그저 둘러싸고 있는 투명한 래퍼의 역할을 하는 것과 같다.
Proxy
는 일반 객체와는다른 행동 양상을 보이는 특수 객체(Exotic Object
)로 취급된다. 즉 자체적으로 가지는 프로퍼티가 없다. proxy.test
는 proxy
객체가 가지고 있는 프로퍼티에 접근하는 것이 아닌 target
이 가지고 있는 프로퍼티에 접근하는 것이다.
이번엔 트랩을 활성화 해보자. 먼저 Proxy
에서 트랩을 사용해 가로챌 수 있는 작업이 어떤 것들이 있는지 파악해야 한다.
보통 객체에 어떤 작업을 수행하면 자바스크립트 명세서의 정의된 내부 메서드(Internal Method
)가 깊숙한 곳에서 관여하게 된다. 객체 내부 프로퍼티를 읽을 땐 [[Get]]
이라는 내부 메서드가, 프로퍼티에 값을 쓸 땐 [[Set]]
이라는 내부 메서드가 관여하게 된다. 이런 내부 메서드들은 명세서에만 정의되어 있기 때문에 개발자가 직접 코드를 사용해 호출할 수는 없다.
프록시의 트랩은 이러한 내부 메서드의 호출을 가로챌 수 있다. 프록시가 가로챌 수 있는 내부 메서드의 리스트는 아래 표와 같다. 모든 내부 메서드에는 대응하는 트랩이 있다.
내부 메서드 | 핸들러 메서드 | 작동 시점 |
---|---|---|
[[Get]] | get | 프로퍼티를 읽을 때 |
[[Set]] | set | 프로퍼티에 값을 쓸 때 |
[[HasProperty]] | has | in 연산자가 동작할 때 |
[[Delete]] | deleteProperty | delete 연산자가 동작할 때 |
[[Call]] | apply | 함수를 호출할 때 |
[[Construct]] | construct | new 연산자가 동작할 때 |
[[GetPrototypeOf]] | getPrototypeOf | Object.getPrototypeOf |
[[SetPrototypeOf]] | setPrototypeOf | Object.setPrototypeOf |
[[IsExtensible]] | isExtensible | Object.isExtensible |
[[PreventExtensions]] | preventExtensions | Object.preventExtensions |
[[DefineOwnProperty]] | defineProperty | Object.defineProperty , Object.defineProperties |
[[GetOwnProperty]] | getOwnPropertyDescriptor | Object.getOwnPropertyDescriptor , for...in , Ojbect.keys/values/entries |
[[OwnPropertyKeys]] | ownKeys | Object.getOwnPropertyNames , Object.getOwnPropertySymbols , for...in , Object/keys/values/entries |
대부분의 내장 메서드들은 이전 챕터에서 가볍게라도 훑고 넘어간 경우가 많다. 다음 메서드들 중 일부만 사용예시를 살펴보며 Proxy
의 내부 작동 방식을 조금 더 살펴보도록 하자.
내부 메서드나 트랩을 쓸 땐 자바스크립트에서 정한 몇 가지 규칙을 반드시 따라야 정상적으로 작동함을 보장할 수 있다. 대부분의 규칙은 반환 값과 관련되어 있다. 예를 들어 다음과 같은 규칙을 준수하도록 하자.
- 값을 쓰는게 성공적으로 처리되었다면
[[Set]]
은 반드시true
를 반환. 그렇지 않은 경우에는false
를 반환- 값을 지우는 게 성공적으로 처리되었다면
[[Delete]]
는 반드시true
를 반환. 그렇지 않은 경우에는false
를 반환- ...
가장 흔히 볼 수 있는 트랩은 프로퍼티를 읽거나 쓸 때 사용되는 트랩이다. 프로퍼티 읽기를 프록시에서 가로채기 위해서는 handler
에 get(target, property, receiver)
메서드가 존재해야 한다.
target
: 동작을 전달할 객체로 new Proxy
의 첫 번째 인자property
: 프로퍼티 이름receiver
: 객체가 사용할 this
의 컨텍스트 정보 지정. 주로 Reflect
를 활용할 때 사용.get 트랩
을 사용하여 존재하지 않는 요소를 읽으려고 할 때 기본값 0
을 반환하는 배열을 만들어보자. 일반적인 경우 존재하지 않는 요소에 접근하면 undefined
를 반환한다. 이를 프록시로 감싸보자.
let numbers = [0, 1, 2];
numbers = new Proxy(numbers, {
get(target, prop) {
if (prop in target) {
return target[prop];
} else {
return 0;
}
}
});
console.log(numbers[1]); // 1
console.log(numbers[123]); // 0
예시를 보면 이제껏 이야기 하던 트랩의 역할을 손쉽게 이해할 수 있을 것이다. 이처럼 get
을 사용해 트랩을 만드는 것은 상당히 간단하다. 지금은 내부에서 직접 handler
를 정의하고 있으나, 이를 외부에 따로 구현하고 변수 형태로 넘겨주어도 상관이 없다.
프록시 객체가 처음에 선언한 배열 numbers
를 덮어쓰고 있다는 점에 주의하자. 객체를 프록시로 감싼 이후엔 타겟 객체를 참조하는 코드가 없도록 구성하는 것이 좋다. 그렇지 않은 경우엔 예상치 못한 결과가 나올 수 있기 때문이다.
객체 프로퍼티에 값을 쓰거나 갱신하는 경우엔 set
트랩을 사용하여 이를 가로챌 수 있다. 숫자만 저장할 수 있는 배열을 만들고 싶을 때 숫자형이 아닌 값을 저장하면 에러를 발생시키는 프록시를 구현해보자. set
메서드는 set(target, property, value, receiver)
의 형태를 가진다. value
는 프로퍼티에 쓸 값을 의미하고 나머지 인수는 위에서 언급한 get
메서드와 모두 일치한다.
let numbers = [];
numbers = new Proxy(numbers, {
set(target, prop, val) {
if (typeof val === 'number') {
target[prop] = val;
return true;
} else {
return false;
}
}
});
numbers.push(1);
numbers.push(2);
console.log( numbers.length ); // 2
numbers.push('Hello'); // Error: 'set' on proxy
set
메서드의 반환값은 성공여부에 따라 true/false
를 반환해야 한다는 점을 주의하자. push
를 사용해서 배열에 값을 삽입하고 있는데, 문자열인 Hello
의 값을 삽입하려 할 때 에러가 발생하는 것을 확인할 수 있다.
push
나 unshift
와 같이 배열에 값을 추가하는 내장 메서드들은 내부에서 [[Set]]
을 사용하고 있기 때문에 별도로 메서드 오버라이딩을 하지 않더라도 set 트랩
이 정삭적으로 이를 가로챌 수 있다.
또한 배열 관련 기능들은 여전히 사용할 수 있다는 점도 주목하자. numbers.length
를 출력해보면 프록시를 통하여 값을 추가하고 있지만 이를 잘 반영하고 있다는 것을 확인할 수 있다. 프록시를 사용하더라도 기존에 있던 기능은 절대 손상되지 않는다.
Object.keys
, for...in
반복문을 비롯한 프로퍼티 순환 관련 메서드 대다수는 내부 메서드 [[OwnPropertyKeys]]
를 사용하여 프로퍼티 목록을 얻는다. 이전 챕터에서 객체 관련 내장 메서드를 다룰 때 살펴본 바와 같이 각 메서드들의 세부 동작 방식엔 미세한 차이가 존재한다.
Object.getOwnPropertyNames(obj)
: 심볼형이 아닌 키만 반환Object.getOwnPropertySymbols(obj)
: 심볼형 키만 반환Object.keys/values()
: enumerable
플래그가 true
이면서 심볼형이 아닌 키 또는 값 반환for...in
: enumerable
플래그가 true
이면서 심볼형이 아닌 키, 프로토타입 키를 반환이처럼 메서드마다 반환하는 값에 차이가 존재하지만 내부적으로는 모두 [[OwnPropertyKeys]]
를 통해 프로퍼티 목록을 얻는다. 이를 프록시의 ownKeys
트랩을 사용하여 해당 작업을 가로챌 수 있다.
클래스 관련 챕터에서 문법적으로 강제되지는 않으나 관례적으로 언더바(_
)를 붙인 속성은 보통 protected
접근 지시자의 성격으로 사용하기에, 외부에서는 이 값에 접근하지 않는 것을 암시적으로 의미한다고 한 적이 있다. 이번에는 ownKeys
트랩을 사용해 _
로 시작하는 프로퍼티는 for...in
순회에서 제외하도록 구현해보자. ownKeys
트랩을 사용하기 때문에 Object.keys()
와 Object.values()
등 모두 동일한 로직이 적용된다.
let user = {
name: 'John',
age: 30,
_password: "********",
};
user = new Proxy(user, {
ownKeys(target) {
// _로 시작하는 프로퍼티명은 필터링하여 제외
return Object.keys(target)
.filter(key => !key.startsWith('_'));
}
});
for (let key in user)
console.log(key); // name, age
console.log( Object.keys(user) ); // name, age
console.log( Object.values(user) ); // Jonh, 30
콘솔창을 통해 의도한대로 잘 작동하는 것을 볼 수 있다. 그런데 만약 객체 내에 존재하지 않는 키를 반환하려고 하는 경우에는 Object.keys
가 이때의 키를 제대로 보여주지 않는다.
let user = { };
user = new Proxy(user, {
ownKeys(target) {
return ['a', 'b', 'c'];
}
});
console.log( Object.keys(user) ); // <빈 문자열>
그 이유는 간단하다. 위에서 살펴보았듯이 Object.keys()
메서드는 enumerable
플래그가 true
인 프로퍼티만 반환하기 때문이다. ['a', 'b', 'c']
의 경우는 배열이기 때문에 프로퍼티 설명자가 없고 따라서 enumerable
플래그 역시 존재하지 않기 때문에 순환 대상에서 자동으로 제외가 된다.
만약 이를 감지하도록 하려면 enumerable
플래그를 붙여 프로퍼티가 객체에 존재하도록 하거나 [[GetOwnProperty]]
가 호출될 때 이를 중간에서 가로채서 설명자 enumeralbe: true
를 반환하게 해주도록 하면 된다. 이런 경우에 getOwnPropertyDescriptor
트랩을 사용할 수 있다.
let user = { };
user = new Proxy(user, {
// 프로퍼티 리스트를 얻을 때 딱 한 번 호출
ownKeys(target) {
return ['a', 'b', 'c'];
},
// 모든 프로퍼티를 대상으로 호출
getOwnPropertyDescriptor(target, prop) {
return {
enumerable: true,
configurable: true,
// 그 외의 플래그도 설정할 수 있다
};
}
});
console.log( Object.keys(user) ); // a, b, c
앞서 언급했듯이 _property
는 내부용으로만 사용하고 외부에서는 접근할 수 없다는 것을 암시적으로 선언하는 컨벤션이다. 자바스크립트 자체에서는 이를 문법적으로 제공하지 않지만 기술적으로 구현은 가능하다. 앞서 반복작업 트랩을 설정하면서 그 일부를 살펴보았는데, _
로 시작하는 프로퍼티에는 어떠한 조작도 불가능하도록 프록시를 써서 구현해보자.
let user = {
name: 'John',
_password: '********',
};
user = new Proxy(user, {
get(target, prop) {
if (prop.startsWith('_')) {
throw new Error('접근 제한');
}
let value = target[prop];
return (typeof value === 'function')
? value.bind(target)
: value;
},
set(target, prop, val) {
if (prop.startsWith('_')) {
throw new Error('접근 제한');
} else {
target[prop] = val;
return true;
}
},
deleteProperty (target, prop) {
if (prop.startsWith('_')) {
throw new Error('접근 제한'));
} else {
delete target[prop];
return true;
}
},
ownKeys(target) {
return Object.keys(target)
.fillter(key => !key.startsWith('_'));
}
});
이처럼 읽기(get)
, 쓰기(set)
, 제거(deleteProperty)
그리고 순회(ownKeys)
와 관련해 모두 트랩을 작성해서 처리하도록 하면 기술적으로도 접근을 제한하는 것이 가능해진다.
이때 get 트랩
에서 function
타입을 따로 체크하여 반환값을 달리하고 있는 점에 주목해보자. 이는 만약 user
객체 내부에 checkPassword
와 같은 내장 메서드가 있을 시 정상적으로 _password
에 접근할 수 있도록 지원하기 위해서 위와 같은 처리를 하고 있다.
user.checkPassword()
를 호출하면 이때의 this
는 당연히 user
컨텍스트로 지정이 될 것이다. 이때 user
객체는 프록시에 래핑된 상태이기 때문에 기존의 user
와는 다른 환경을 가지고 있다. 처음에 이야기 한 바와 같이 프록시 객체는 내부 프로퍼티를 가지고 있지 않기 때문에, 프록시에서의 this._password
는 올바르지 않은 접근이 된다.
때문에 객체 메서드와 같이 function
타입으로 인식되는 경우엔 원본 객체인 target
을 bind
시켜주어 this
의 정보를 제대로 전달해주고 있는 과정이라고 볼 수 있다.
이러한 방법은 대부분의 경우 잘 작동하기는 하지만 메서드가 어딘가에서 프록시로 감싸지 않은 객체를 넘기게 되면 내부 흐름이 엉망진창이 되어버리는 경우가 있기 때문에 이상적인 방법은 아니다. 기존 객체와 프록시로 감싼 객체가 어디에 있는지 제대로 파악할 수 없기 때문이다.
한 객체를 여러 번 프록시로 감쌀 경우엔 각 프록시마다 객체에 가하는 수정이 다를 수 있다는 점 역시 문제로 작용한다. 이 역시 프록시로 감싸지 않은 객체를 메서드에 넘기는 경우처럼 예상치 못한 결과가 나타날 수 있다.
모던 자바스크립트에서는
#
키워드를 통해private
접근제어자를 구현할 수 있다. 따라서 프록시 없이도 외부에서 보호되는 프로퍼티를 구현할 수 있다. 다만 클래스 챕터에서 살펴본 것과 같이private
프로퍼티는 상속이 불가하다는 단점도 존재한다.
다음과 같이 범위를 담고 있는 객체가 있다고 가정해보자.
let range = {
start: 1,
end: 10
};
in
연산자를 사용해 특정 숫자가 range
범위 내에 존재하는지 검사할 수 있는 프록시를 만들어보자. in
연산자는 has 트랩
을 통해 가로챌 수 있다.
range = new Proxy(range, {
has(target, prop) {
return prop >= target.start && prop <= target.end;
}
});
console.log( 5 in range ); // true
console.log( 15 in range ); // false
함수 역시 프록시로 감쌀 수 있다. apply(target, thisArg, args)
트랩은 프록시를 함수처럼 호출하려고 할 때 동작한다.
target
: 타겟 객체 (함수 역시 객체)thisArg
: this
값args
: 인수 목록이전 함수 챕터에서 delay(f, ms)
데코레이터를 구현한 바 있다. 이때는 프록시를 이용하지 않고도 ms
밀리초 이후에 함수 f
가 호출되도록 구현했다. 이 코드를 다시 한 번 살펴보자.
function delay (f, ms) {
return function () {
setTimeout(() => f.apply(this, arguments), ms);
};
}
function sayHi (user) {
console.log(`Hello ${user}`);
}
sayHi = delay(sayHi, 3000);
sayHi('KG'); // Hello KG (3초 후 출력)
이는 앞서 살펴보았듯이 정상적으로 작동한다. 그러나 이 래퍼 함수는 프로퍼티 읽기/쓰기와 관련된 연산은 전달하지 못한다는 단점이 있다. delay
데코레이터 내부에서 래퍼 함수가 반환되기 때문에, 기존 함수의 프로퍼티인 name/length
등과 관련된 정보는 덮어씌어 사라지게 된다.
console.log( sayHi.length ); // 1
sayHi = delay(sayHi, 3000);
console.log( sayHi.length ); // 0
프록시를 사용하면 객체는 타겟 객체에기 모든 것을 전달해주기 때문에 훨씬 강력하다. 위의 데코레이터를 Proxy
를 사용하도록 바꾸어보자.
function delay (f, ms) {
return new Proxy(f, {
apply(target, thisArg, args) {
setTimeout(() => target.apply(thisArg, args), ms);
}
});
}
function sayHi (user) {
console.log(`Hello ${user}`);
}
console.log( sayHi.length ); // 1
sayHi = delay(sayHi, 3000);
console.log( sayHi.length ); // 1
sayHi('KG'); // Hello KG (3초 후 출력)
결과는 위와 동일하지만 프록시에서 가하는 모든 연산이 원본 함수에 전달되기 때문에 동일한 인수 목록의 크기를 출력하는 것을 확인할 수 있다. 따라서 조금 더 성능이 좋은 래퍼함수를 갖게 되었다고 볼 수 있다. 그 외 나머지 트랩들은 표에서 정리된 리스트 목록을 통해 확인해보자. 대부분의 구현은 지금까지의 방식과 다르지 않다.
Reflect
관련 문법은 사실 ES5(ES2014)에도 있었으나 ES6으로 넘어오면서 관련 기능을 조금 더 다듬고 개선이 이루어졌다. Reflect
의 사전적 의미는 반사/반영
정도의 뜻을 가지고 있다. 사전적 의미와 비슷하게 Reflect
는 프록시 생성을 단순화하는 내장 객체이다. 이를 이용하면 내장 메서드를 호출하는 방식의 일관된 방법으로 기존 메서드를 사용할 수 있다.
위에서 [[Get]]
과 [[Set]]
같은 내부 메서드는 개발자가 직접 호출할 수 없다고 설명했다. 그러나 Reflect
를 사용한다면 어느정도 이를 가능케 할 수 있다.
let user = { };
Reflect.set(user, 'name', 'KG');
console.log( user.name ); // KG
이처럼 내장 메서드 방식으로 일관되게 호출이 가능하다. 이전에 객체를 다룰때 객체와 관련된 메서드는 Object
내장 객체를 통해 접근하거나, 만들어진 객체를 통해 접근하는 등 두 가지 방식으로 사용했던 것을 살펴보았다. 이때 Reflect
를 통해 하나의 일관된 방식으로 객체 메서드를 사용할 수 있을 것이다.
특히 Reflect
는 프록시에서 제 기능을 십분 발휘한다. Reflect
는 프록시에서 트랩할 수 있는 모든 내부 메서드와 동일한 내장 메서드를 가지고 있다. 따라서 Reflect
를 사용하면 원래 객체에 그대로 작업을 전달할 때 별도의 사이드 이펙트 없이 전달 가능하다는 장점이 있다.
let user = {
name: 'KG',
};
user = new Proxy(user, {
get(target, prop, receiver) {
console.log(`GET ${prop}`);
return Reflect.get(target, prop, receiver);
},
set(target, prop, value, receiver) {
console.log(`SET ${prop}`);
return Reflect.set(target, prop, value, receiver);
}
});
let name = user.name; // GET name
user.name = "SJ"; // SET name
Reflect.get
은 객체 프로퍼티를 읽는다.Reflect.set
은 객체 프로퍼티에 값을 쓰고 성공 시 true
, 실패 시 false
를 반환한다.이처럼 Reflect
를 사용하면 간단하게 프록시 트랩 내부 동작을 처리할 수 있다. 트랩에서 기존 객체에 작업을 전달하려는 경우에는 Reflect
를 통해 동일 메서드를 동일 인수와 함께 호출할 수 있다.
위에서 이미 살펴본 바와 같이 사실 Reflect
없이도 이와 동일한 동작을 트랩에서 처리할 수 있다. 그러나 Reflect
를 사용함에 있어 중요한 차이가 존재한다.
Reflect.get
이 조금 더 나은 이유를 보여주는 예시를 살펴보자. 또한 위에서 언급만 하고 설명은 하지 않았던 receiver
인수를 어떻게 활용하는지 역시 함께 살펴보도록 하자. 다음과 같이 _name
프로퍼티와 이에 접근할 수 있는 getter
메서드가 있는 객체를 프록시 처리 해보자.
let user = {
_name: "KG",
get name() {
return this._name;
}
};
let userProxy = new Proxy(user, {
get(target, prop, receiver) {
return target[prop];
}
});
console.log( userProxy.name ); // KG
위 코드에서는 get 트랩
에서 Reflect
를 사용하지 않고 직접 값을 반환하도록 하고 있다. getter
를 통해 접근할 때 역시 [[Get]]
내부 메서드가 호출되기 때문에 이에 대한 값도 정상적으로 출력되고 있음을 확인할 수 있다. 하지만 이 프록시 객체를 상속받는 다른 객체가 생기는 경우엔 어떠한 일이 발생할까?
let admin = {
__proto__: userProxy, // userProxy 상속
_name: "SJ"
};
console.log( admin.name ); // KG (?!?)
위 결과를 확인하면 의도한 바와는 다르게 기존 user
객체의 _name
프로퍼티 값이 출력되고 있음을 확인할 수 있다. 프록시를 제거하고 기존 user
객체를 상속받게끔 수정한다면 정상적으로 동작한다. 이는 프록시를 통해 트랩을 처리하는 과정에서 무언가 예상치 못한 동작이 발생했다는 것을 의미한다.
이와 같은 문제가 발생하는 원인은 프록시에서 get 트랩
이 값을 반환하는 부분에 있다. admin.name
을 통해 접근하게 되면 admin
객체는 자체적으로 getter name
을 가지고 있지 않기 때문에 상위 프로토타입으로 이동하게 될 것이다. 그러나 admin
객체가 상속받은 객체는 userProxy
라는 프록시 객체이다.
해당 프록시 객체에서 다시 getter
에 접근하면 원본 객체인 user
에 접근을 하게 되고, 때문에 또 다시 get 트랩
이 트리거 된다. 이 시점에서 this
는 user
객체를 참조하고 있다. 트리거 된 지점이 user
객체에서 시작됐기 때문이다. 때문에 target
이 가리키는 대상은 user
가 되고 그런 까닭으로 user
의 _name
프로퍼티에 접근하여 원본 객체의 값을 출력하게 된 것이다.
이러한 경우를 대비하기 위해서 우리는 receiver
인수를 활용할 수 있다. receiver
를 통해 우리는 this
에 대한 컨텍스트를 유지할 수 있다. 그렇지만 어떻게 this
에 대한 정보를 추가적으로 전달할 수 있을까? 함수의 내장 메서드의 경우에는 call/apply
와 같은 메서드를 통해 전달이 가능했지만, getter
에 접근하는 것이기 때문에 함수를 호출하는 것이 아니다. 이때 우리는 Reflect
를 사용하여 원하는 동작을 처리해줄 수 있다.
let userProxy = new Proxy(user, {
get(target, prop, receiver) {
return Reflect.get(target, prop, receiver);
}
});
let admin = {
__proto__: userProxy,
_name: "SJ"
};
console.log( admin.name ); // SJ
이처럼 this
에 대한 올바른 참조 정보를 전달하는 경우에도 Reflect
를 사용하여 간단하게 처리할 수 있다. 또한 Reflect
를 호출하는 것은 트랩과 정확히 동일한 방식으로 명명되고 동일한 인수 역시 허용하기 때문에, 다음과 같은 방식으로 더 짧게 구현이 가능하다. 따라서 return Reflect...
를 통해 우리는 깊게 고민할 필요 없이 관련된 항목을 누락시키지 않고 간단하게 프록시 트랩 처리가 가능하다.
get(target, prop, receiver) {
return Reflect.get(...arguments);
}
프록시를 통해 가장 낮은 수준에서 기존 객체의 동작을 변경하거나 수정하는 등의 개입이 가능하지만, 여기에는 다음과 같은 제약이 존재한다.
내장 객체: 내부 슬롯(Internal Slots
)
Map
, Set
, Date
, Promise
와 같은 많은 내장 객체들은 이른바 내부 슬롯(Internal Slots)
이라는 공간을 사용한다.
이들은 프로퍼티와 유사하지만 내부적으로 각각 전용 목적을 가지고 있는 예약어와 같다. 예를 들어 Map
의 경우는 내부 슬롯 [[MapData]]
에 항목을 저장한다. 이때 내장 객체는 내부 메서드 [[Get]]/[[Set]]
을 사용하지 않고 [[MapData]]
에 직접 접근하게 되는데, 따라서 프록시는 이를 트랩으로 가로챌 수가 없다.
따라서 다음과 같이 내장 객체를 target
으로 하여 프록시 객체를 생성하는 경우, 프록시에서는 이러한 내부 슬롯을 가지고 있지 않기 때문에 기본적인 접근조차 차단된다.
let map = new Map();
let proxy = new Proxy(map, {}); // handler 없이 프록시 생성
proxy.set('test', 1); // Error
앞서 언급한 바와 같이 Map
은 모든 데이터를 [[MapData]]
내부 슬롯에 저장하게 된다. Map.get/Map.set
과 같은 연산은 따라서 해당 프록시 객체에서는 Map
에 접근하는 것이기 때문에 모두 get 트랩
으로 가로챌 수 있다. 그러나 프록시는 [[MapData]]
슬롯을 가지고 있지 않기 때문에 에러 메시지가 발생하는 것을 볼 수 있다.
이 역시 Reflect
를 이용하여 해결할 수 있다. Reflect.get(...arguments)
를 통해 proxy.set
이 들어오게 되면, 이 경우에는 map.set
과 같이 접근할 때 사용되는 네이티브 함수 형태가 반환되게 된다. 그리고 반환값이 함수이기 때문에 bind
를 통해 this
에 대한 정보를 target
과 연결시켜 주면 참조를 이어줄 수 있기 때문에 정삭적으로 프록시 내부에서도 [[MapData]]
속성에 접근할 수 있다. 프록시 내부에서는 더 이상 프록시 자체가 아닌 원래 맵을 컨텍스트로 가지고 접근하기 때문이다.
let map = new Map();
let proxy = new Proxy(map, {
get(target, prop, receiver) {
let value = Reflect.get(...arguments);
return typeof value == 'function'
? value.bind(target)
: value;
}
});
proxy.set('test', 1);
alert(proxy.get('test')); // 1
private fields
위와 유사한 이슈는 클래스에서 private
키워드를 사용할 때 동일하게 발생한다.
class User {
#name = "KG";
getName() {
return this.#name;
}
}
let user = new User();
user = new Proxy(user, {});
console.log( user.getName() ); // Error
이러한 에러가 발생하는 것 역시 유사한 이유인데, private
키워드를 통해 필드값을 선언할 경우엔 해당 필드가 내부 슬롯을 사용하여 구현되므로 [[Get]]/[[Set]]
내부 메서드로 값에 접근하지 않기 때문이다.
이 역시 위와 동일한 로직으로 해결할 수 있다.
...
user = new Proxy(user, {
get(target, prop, receiver) {
let value = Reflect.get(...arguments);
return typeof value === 'function'
? value.bind(target)
: value;
}
});
Proxy != target
프록시 객체가 원본 객체에 접근이 가능하기 때문에 흔히 프록시와 내부에서 선언되는 target
이 동일하다고 착각할 수 있다. 그러나 프록시는 새로 생성되는 객체이기 때문에 당연히 기존 객체와 다를 수 밖에 없다. 따라서 원래 객체를 키로 사용한 다음 프록시로 덮어쓰는 경우에는 기존 객체에 접근할 수 없다.
let allUsers = new Set();
class User = {
constructor(name) {
this.name = name;
allUsers.add(this);
}
}
let user = new User('KG');
console.log(allUsers.has(user)); // true
user = new Proxy(user, {});
console.log(allUsers.has(user)); // false
프록시는 위 표에서 확인할 수 있듯
new
,in
,delete
와 같이 많은 메서드를 가로챌 수 있다. 그러나 동등 비교인===
연산자의 경우에는 가로챌 수 있는 방법이 없다. 객체는 오직 자기 자신과만 동등비교 시 일치하기 때문이다. 따라서 동일성을 체크하는 모든 연산자와 내장 객체 및 클래스의 경우는 객체와 프록시를 구분한다.
Proxy.revocable
은 target
을 참조하는 프록시 객체를 해제할 수 있는 revoke
함수를 추가로 제공한다. 프록시 객체가 참조하고 있는 target
객체가 null
이 되어 참조가 끊기고 가비지 컬렉터에 의해 제거되더라도 이를 참조하고 있던 프록시 객체는 수집되지 않는다. 따라서 명시적으로 제거가 필요한데, 이때 revoke
메서드를 사용할 수 있다.
let { proxy, revoke } = Proxy.revocable(target, handler);
이러한 속성을 특히 WeakMap
과 연계하여 사용한다면 프록시 객체를 쉽게 찾음과 동시에 가비지 컬렉터에 의한 수집 역시 용이하게 설정할 수 있다. 위크맵의 특징은 오직 객체만 키 값으로 활용할 수 있고, 만일 키로 사용된 객체가 도중에 참조값이 사라지게 되면 가비지 컬렉터의 대상이 되어 메모리와 위크맵에서 지워진다는 점이다.
let revokes = new WeakMap();
let object = {
data: "Blah-Blah-Blah"
};
let { proxy, revoke } = Proxy.revocable(object, {});
revokes.set(proxy, revoke);
// ... 프록시를 활용한 작업 모두 처리
revoke = revokes.get(proxy);
revoke(); // 프록시 객체 제거
console.log(proxy.data); // Error