[모던JS: Core] Proxy와 Reflect

KG·2021년 6월 3일
4

모던JS

목록 보기
25/47
post-thumbnail

Intro

본 포스팅은 여기에 올라온 게시글을 바탕으로 작성되었습니다.
파트와 카테고리 동일한 순서로 모든 내용을 소개하는 것이 아닌, 몰랐거나 새로운 내용 위주로 다시 정리하여 개인공부 목적으로 작성합니다.
중간중간 개인 판단 하에 필요하다고 생각될 시, 기존 내용에 추가로 보충되는 내용이 있을 수 있습니다.

Proxy

Proxy는 ES6(ES2015)에 도입된 문법이다. 구버전의 브라우저에서도 호환되도록 트랜스컴파일러(transcompiler)의 역할을 하는 바벨(Babel)에서 지원하지 않기 때문에 인터넷 익스플로러에서는 사용할 수 없다. 그라나 최신 브라우저의 경우에는 모두 지원하고 있다.

Proxy의 의미는 위임, 대리인 등의 뜻을 갖고 있는데, 용어의 뜻에 걸맞게 컴퓨터 분야에서 다양한 의미로 사용되고 있다. 대표적으로 웹 서버에서 사용되는 Forward Proxy 또는 Reverse Proxy 개념 부터 프론트엔드 React에서도 Webpack 번들러를 통해 개발서버에서 Proxy를 설정할 수 있다. 각각 세부적인 역할은 당연히 모두 다르겠지만, 초점은 무언가를 대리하는 것에 맞춰져 있다.

자바스크립트에서의 Proxy 역시 이와 유사하게, 특정 객체를 감싸 프로퍼티 읽기 및 쓰기와 같은 객체에 가해지는 작업을 중간에 가로채는 객체로 사용한다. 이렇게 가로챈 작업은 Proxy 객체에서 처리되거나, 아니면 원래 객체가 처리하도록 그대로 전달되기도 한다. Proxy는 어떻게 사용하고, 이러한 처리가 실무에서 어떻게 적용되는지 살펴보도록 하자.

1) 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.testproxy 객체가 가지고 있는 프로퍼티에 접근하는 것이 아닌 target이 가지고 있는 프로퍼티에 접근하는 것이다.

이번엔 트랩을 활성화 해보자. 먼저 Proxy 에서 트랩을 사용해 가로챌 수 있는 작업이 어떤 것들이 있는지 파악해야 한다.

보통 객체에 어떤 작업을 수행하면 자바스크립트 명세서의 정의된 내부 메서드(Internal Method)가 깊숙한 곳에서 관여하게 된다. 객체 내부 프로퍼티를 읽을 땐 [[Get]] 이라는 내부 메서드가, 프로퍼티에 값을 쓸 땐 [[Set]] 이라는 내부 메서드가 관여하게 된다. 이런 내부 메서드들은 명세서에만 정의되어 있기 때문에 개발자가 직접 코드를 사용해 호출할 수는 없다.

프록시의 트랩은 이러한 내부 메서드의 호출을 가로챌 수 있다. 프록시가 가로챌 수 있는 내부 메서드의 리스트는 아래 표와 같다. 모든 내부 메서드에는 대응하는 트랩이 있다.

내부 메서드핸들러 메서드작동 시점
[[Get]]get프로퍼티를 읽을 때
[[Set]]set프로퍼티에 값을 쓸 때
[[HasProperty]]hasin 연산자가 동작할 때
[[Delete]]deletePropertydelete 연산자가 동작할 때
[[Call]]apply함수를 호출할 때
[[Construct]]constructnew 연산자가 동작할 때
[[GetPrototypeOf]]getPrototypeOfObject.getPrototypeOf
[[SetPrototypeOf]]setPrototypeOfObject.setPrototypeOf
[[IsExtensible]]isExtensibleObject.isExtensible
[[PreventExtensions]]preventExtensionsObject.preventExtensions
[[DefineOwnProperty]]definePropertyObject.defineProperty, Object.defineProperties
[[GetOwnProperty]]getOwnPropertyDescriptorObject.getOwnPropertyDescriptor, for...in, Ojbect.keys/values/entries
[[OwnPropertyKeys]]ownKeysObject.getOwnPropertyNames, Object.getOwnPropertySymbols, for...in, Object/keys/values/entries

대부분의 내장 메서드들은 이전 챕터에서 가볍게라도 훑고 넘어간 경우가 많다. 다음 메서드들 중 일부만 사용예시를 살펴보며 Proxy의 내부 작동 방식을 조금 더 살펴보도록 하자.

내부 메서드나 트랩을 쓸 땐 자바스크립트에서 정한 몇 가지 규칙을 반드시 따라야 정상적으로 작동함을 보장할 수 있다. 대부분의 규칙은 반환 값과 관련되어 있다. 예를 들어 다음과 같은 규칙을 준수하도록 하자.

  • 값을 쓰는게 성공적으로 처리되었다면 [[Set]]은 반드시 true를 반환. 그렇지 않은 경우에는 false를 반환
  • 값을 지우는 게 성공적으로 처리되었다면 [[Delete]]는 반드시 true를 반환. 그렇지 않은 경우에는 false를 반환
  • ...

2) get 트랩

가장 흔히 볼 수 있는 트랩은 프로퍼티를 읽거나 쓸 때 사용되는 트랩이다. 프로퍼티 읽기를 프록시에서 가로채기 위해서는 handlerget(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를 덮어쓰고 있다는 점에 주의하자. 객체를 프록시로 감싼 이후엔 타겟 객체를 참조하는 코드가 없도록 구성하는 것이 좋다. 그렇지 않은 경우엔 예상치 못한 결과가 나올 수 있기 때문이다.

3) set 트랩

객체 프로퍼티에 값을 쓰거나 갱신하는 경우엔 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의 값을 삽입하려 할 때 에러가 발생하는 것을 확인할 수 있다.

pushunshift와 같이 배열에 값을 추가하는 내장 메서드들은 내부에서 [[Set]]을 사용하고 있기 때문에 별도로 메서드 오버라이딩을 하지 않더라도 set 트랩이 정삭적으로 이를 가로챌 수 있다.

또한 배열 관련 기능들은 여전히 사용할 수 있다는 점도 주목하자. numbers.length를 출력해보면 프록시를 통하여 값을 추가하고 있지만 이를 잘 반영하고 있다는 것을 확인할 수 있다. 프록시를 사용하더라도 기존에 있던 기능은 절대 손상되지 않는다.

4) ownKeys와 getOwnPropertyDescriptor를 통한 반복작업

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

5) deleteProperty와 프로퍼티 보호

앞서 언급했듯이 _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 타입으로 인식되는 경우엔 원본 객체인 targetbind 시켜주어 this의 정보를 제대로 전달해주고 있는 과정이라고 볼 수 있다.

이러한 방법은 대부분의 경우 잘 작동하기는 하지만 메서드가 어딘가에서 프록시로 감싸지 않은 객체를 넘기게 되면 내부 흐름이 엉망진창이 되어버리는 경우가 있기 때문에 이상적인 방법은 아니다. 기존 객체와 프록시로 감싼 객체가 어디에 있는지 제대로 파악할 수 없기 때문이다.

한 객체를 여러 번 프록시로 감쌀 경우엔 각 프록시마다 객체에 가하는 수정이 다를 수 있다는 점 역시 문제로 작용한다. 이 역시 프록시로 감싸지 않은 객체를 메서드에 넘기는 경우처럼 예상치 못한 결과가 나타날 수 있다.

모던 자바스크립트에서는 # 키워드를 통해 private 접근제어자를 구현할 수 있다. 따라서 프록시 없이도 외부에서 보호되는 프로퍼티를 구현할 수 있다. 다만 클래스 챕터에서 살펴본 것과 같이 private 프로퍼티는 상속이 불가하다는 단점도 존재한다.

6) has 트랩으로 범위 내 여부 확인

다음과 같이 범위를 담고 있는 객체가 있다고 가정해보자.

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

7) apply 트랩으로 함수 감싸기

함수 역시 프록시로 감쌀 수 있다. 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

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를 사용함에 있어 중요한 차이가 존재한다.

1) getter 프록시

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 트랩이 트리거 된다. 이 시점에서 thisuser 객체를 참조하고 있다. 트리거 된 지점이 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);
}

2) 프록시 한계 (Proxy Limitations)

프록시를 통해 가장 낮은 수준에서 기존 객체의 동작을 변경하거나 수정하는 등의 개입이 가능하지만, 여기에는 다음과 같은 제약이 존재한다.

내장 객체: 내부 슬롯(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와 같이 많은 메서드를 가로챌 수 있다. 그러나 동등 비교인 === 연산자의 경우에는 가로챌 수 있는 방법이 없다. 객체는 오직 자기 자신과만 동등비교 시 일치하기 때문이다. 따라서 동일성을 체크하는 모든 연산자와 내장 객체 및 클래스의 경우는 객체와 프록시를 구분한다.

3) Proxy.revocable

Proxy.revocabletarget을 참조하는 프록시 객체를 해제할 수 있는 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

References

  1. https://ko.javascript.info/proxy
  2. https://www.zerocho.com/category/ECMAScript/post/57ca5f053316f61500c4f902
  3. https://pks2974.medium.com/javascript-proxy-%EC%99%80-reflect-%EA%B0%84%EB%8B%A8-%EC%A0%95%EB%A6%AC%ED%95%98%EA%B8%B0-5f1ccaa51b2e
  4. https://infoscis.github.io/2018/02/27/ecmascript-6-proxies-and-the-reflection-api/
profile
개발잘하고싶다

0개의 댓글