Proxy

Proxy

Proxy 객체를 사용하면 다른 객체에 대한 프록시를 만들 수 있으며, 이를 통해 그 객체의 기본적인 연산을 가로채고 재정의할 수 있다.

출처: Proxy - JavaScript | MDN

본문

개요

Proxy 객체는 원본 객체 대신 사용할 수 있는 객체를 만들 수 있게 해 주며, 속성 가져오기(getting), 설정(setting), 정의(defining) 같은 기본적인 Object 연산을 재정의할 수 있다. Proxy 객체는 주로 속성 접근 로깅, 입력 검증·형식화·정화, 그 밖의 용도로 흔히 사용된다.

Proxy는 두 개의 파라미터로 생성한다:

  • target: 프록시하려는 원본 객체
  • handler: 어떤 연산이 가로채질지, 가로채진 연산을 어떻게 재정의할지를 정의하는 객체

예를 들어 다음 코드는 target 객체에 대한 프록시를 만든다:

const target = {
  message1: "hello",
  message2: "everyone",
};

const handler1 = {};

const proxy1 = new Proxy(target, handler1);

handler가 비어 있으므로 이 프록시는 원본 target과 똑같이 동작한다:

console.log(proxy1.message1); // hello
console.log(proxy1.message2); // everyone

프록시를 커스터마이즈하려면 handler 객체에 함수를 정의한다:

const target = {
  message1: "hello",
  message2: "everyone",
};

const handler2 = {
  get(target, prop, receiver) {
    return "world";
  },
};

const proxy2 = new Proxy(target, handler2);

여기서는 target의 속성 접근을 가로채는 get() 핸들러의 구현을 제공했다.

handler 함수는 때때로 trap(트랩)이라고 불린다. 아마 target 객체에 대한 호출을 트랩하기 때문일 것이다. 위 handler2의 trap은 모든 속성 접근자를 재정의한다:

console.log(proxy2.message1); // world
console.log(proxy2.message2); // world

Proxy는 종종 Reflect 객체와 함께 사용된다. ReflectProxy trap과 같은 이름의 메서드를 제공하며, 해당 객체 내부 메서드를 호출하는 반영적(reflective) 의미론을 제공한다. 예를 들어 객체의 동작을 재정의하고 싶지 않다면 Reflect.get을 호출할 수 있다:

const target = {
  message1: "hello",
  message2: "everyone",
};

const handler3 = {
  get(target, prop, receiver) {
    if (prop === "message2") {
      return "world";
    }
    return Reflect.get(...arguments);
  },
};

const proxy3 = new Proxy(target, handler3);

console.log(proxy3.message1); // hello
console.log(proxy3.message2); // world

Reflect 메서드는 여전히 객체 내부 메서드를 통해 객체와 상호작용한다. 프록시에서 호출되더라도 프록시를 "de-proxify"하지 않는다. 프록시 trap 안에서 Reflect 메서드를 사용하는데 그 Reflect 메서드 호출이 다시 trap에 가로채지면 무한 재귀가 발생할 수 있다.

용어(Terminology)

프록시의 기능에 대해 이야기할 때 다음 용어가 사용된다.

  • handlerProxy 생성자의 두 번째 인자로 전달되는 객체. 프록시의 동작을 정의하는 trap들을 담고 있다.
  • trap — 해당 객체 내부 메서드에 대한 동작을 정의하는 함수. (운영체제의 trap 개념과 유사하다.)
  • target — 프록시가 가상화하는 객체. 종종 프록시의 저장 백엔드로 사용된다. 객체의 비확장성(non-extensibility)이나 비구성(non-configurable) 속성에 관한 불변식(invariants, 변하지 않는 의미론)이 target에 대해 검증된다.
  • invariants — 사용자 정의 연산을 구현할 때도 변하지 않는 의미론. trap 구현이 handler의 불변식을 위반하면 TypeError가 발생한다.

객체 내부 메서드(Object internal methods)

객체는 속성의 모음이다. 그러나 언어는 객체에 저장된 데이터를 직접 조작하는 메커니즘을 제공하지 않는다. 대신 객체는 어떻게 상호작용할 수 있는지 지정하는 몇 가지 내부 메서드를 정의한다. 예를 들어 obj.x를 읽으면 다음이 일어날 것으로 기대할 수 있다:

  • x 속성이 발견될 때까지 프로토타입 체인 위로 검색된다.
  • x가 데이터 속성이면 속성 설명자의 value 속성이 반환된다.
  • x가 접근자(accessor) 속성이면 getter가 호출되고 getter의 반환 값이 반환된다.

이 과정에는 언어에서 특별한 것이 없다. 단지 일반 객체가 기본적으로 이 동작으로 정의된 [[Get]] 내부 메서드를 가지기 때문이다. obj.x 속성 접근 문법은 단순히 객체의 [[Get]] 메서드를 호출하며, 객체는 자체 내부 메서드 구현을 사용해 무엇을 반환할지 결정한다.

또 다른 예로, 배열은 일반 객체와 다르다. 배열은 수정될 때 자동으로 빈 슬롯을 할당하거나 요소를 제거하는 마법의 length 속성을 가지기 때문이다. 마찬가지로 배열 요소를 추가하면 자동으로 length 속성이 변경된다. 이는 배열이 정수 인덱스가 기록될 때 length를 갱신하거나, length가 기록될 때 배열 내용을 갱신하는 법을 아는 [[DefineOwnProperty]] 내부 메서드를 가지기 때문이다. 내부 메서드가 일반 객체와 다른 구현을 가진 그러한 객체를 이국적(exotic) 객체라고 부른다. Proxy는 개발자가 완전한 기능으로 자신만의 이국적 객체를 정의할 수 있게 해 준다.

모든 객체는 다음과 같은 내부 메서드를 가진다:

내부 메서드 해당 trap
[[GetPrototypeOf]] getPrototypeOf()
[[SetPrototypeOf]] setPrototypeOf()
[[IsExtensible]] isExtensible()
[[PreventExtensions]] preventExtensions()
[[GetOwnProperty]] getOwnPropertyDescriptor()
[[DefineOwnProperty]] defineProperty()
[[HasProperty]] has()
[[Get]] get()
[[Set]] set()
[[Delete]] deleteProperty()
[[OwnPropertyKeys]] ownKeys()

함수 객체는 또한 다음 내부 메서드를 가진다:

내부 메서드 해당 trap
[[Call]] apply()
[[Construct]] construct()

객체와의 모든 상호작용이 결국 이 내부 메서드 중 하나의 호출로 귀결되고, 모두 프록시를 통해 커스터마이즈 가능하다는 점을 인식하는 것이 중요하다. 이는 (특정 중요한 불변식을 제외하고는) 거의 어떤 동작도 언어에서 보장되지 않는다는 뜻이다. 모든 것이 객체 자신에 의해 정의된다. delete obj.x를 실행해도 이후에 "x" in objfalse를 반환한다는 보장이 없다. 그것은 객체의 [[Delete]][[HasProperty]] 구현에 달려 있다. delete obj.x는 콘솔에 무언가를 기록하거나, 전역 상태를 수정하거나, 기존 속성을 삭제하는 대신 새 속성을 정의할 수도 있다(다만 이런 의미론은 자신의 코드에서 피해야 한다).

모든 내부 메서드는 언어 자체에 의해 호출되며 JavaScript 코드에서 직접 접근할 수 없다. Reflect 네임스페이스는 일부 입력 정규화/검증 외에는 내부 메서드를 호출하는 것보다 조금 더 많은 기능을 제공하는 메서드를 제공한다. 각 trap의 페이지에 trap이 호출되는 몇 가지 전형적인 상황을 나열하지만, 이런 내부 메서드는 많은 곳에서 호출된다. 예를 들어 배열 메서드는 이런 내부 메서드를 통해 배열을 읽고 쓰므로, push() 같은 메서드도 get()set() trap을 호출한다.

대부분의 내부 메서드는 하는 일이 단순하다. 혼동될 수 있는 유일한 두 가지는 [[Set]][[DefineOwnProperty]]이다. 일반 객체의 경우 전자는 setter를 호출하고 후자는 호출하지 않는다. (그리고 [[Set]]은 기존 속성이 없거나 속성이 데이터 속성일 때 내부적으로 [[DefineOwnProperty]]를 호출한다.) obj.x = 1 문법이 [[Set]]을 사용하고 Object.defineProperty()[[DefineOwnProperty]]를 사용한다는 것은 알 수 있지만, 다른 내장 메서드와 문법이 어떤 의미론을 사용하는지는 즉시 명확하지 않다. 예를 들어 클래스 필드는 [[DefineOwnProperty]] 의미론을 사용하며, 그래서 파생 클래스에서 필드가 선언될 때 슈퍼클래스에 정의된 setter가 호출되지 않는다.

생성자(Constructor)

  • Proxy() — 새로운 Proxy 객체를 생성한다.

참고: Proxy.prototype 속성은 없으므로 Proxy 인스턴스는 특별한 속성이나 메서드를 갖지 않는다.

정적 메서드(Static methods)

  • Proxy.revocable() — 취소 가능한(revocable) Proxy 객체를 만든다.

예제

기본 예제

이 예제에서는 속성 이름이 객체에 없을 때 숫자 37이 기본 값으로 반환된다. get() 핸들러를 사용한다.

const handler = {
  get(obj, prop) {
    return prop in obj ? obj[prop] : 37;
  },
};

const p = new Proxy({}, handler);
p.a = 1;
p.b = undefined;

console.log(p.a, p.b); // 1, undefined

console.log("c" in p, p.c); // false, 37

No-op 전달 프록시(No-op forwarding proxy)

이 예제에서는 프록시가 적용되는 모든 연산을 전달할 네이티브 JavaScript 객체를 사용한다.

const target = {};
const p = new Proxy(target, {});

p.a = 37; // Operation forwarded to the target

console.log(target.a); // 37 (The operation has been properly forwarded!)

이 "no-op"이 일반 JavaScript 객체에는 동작하지만, DOM 요소, Map 객체, 내부 슬롯(internal slots)을 가진 어떤 것이든 네이티브 객체에는 동작하지 않는다는 점에 유의하라. private field 전달에 대한 자세한 내용은 아래를 참고하라.

private field 전달 없음(No private field forwarding)

프록시는 여전히 다른 정체성을 가진 또 다른 객체이다. 즉 래핑된 객체와 외부 사이에서 동작하는 프록시이다. 따라서 프록시는 원본 객체의 private 요소에 직접 접근할 수 없다.

class Secret {
  #secret;
  constructor(secret) {
    this.#secret = secret;
  }
  get secret() {
    return this.#secret.replace(/\d+/, "[REDACTED]");
  }
}

const secret = new Secret("123456");
console.log(secret.secret); // [REDACTED]
// Looks like a no-op forwarding...
const proxy = new Proxy(secret, {});
console.log(proxy.secret); // TypeError: Cannot read private member #secret from an object whose class did not declare it

이것은 프록시의 get trap이 호출될 때 this 값이 원본 secret이 아닌 proxy이기 때문에 #secret에 접근할 수 없기 때문이다. 이를 고치려면 원본 secretthis로 사용하라:

const proxy = new Proxy(secret, {
  get(target, prop, receiver) {
    // By default, it looks like Reflect.get(target, prop, receiver)
    // which has a different value of `this`
    return target[prop];
  },
});
console.log(proxy.secret);

메서드의 경우, 메서드의 this 값도 원본 객체로 리다이렉트해야 한다는 뜻이다:

class Secret {
  #x = 1;
  x() {
    return this.#x;
  }
}

const secret = new Secret();
const proxy = new Proxy(secret, {
  get(target, prop, receiver) {
    const value = target[prop];
    if (value instanceof Function) {
      return function (...args) {
        return value.apply(this === receiver ? target : this, args);
      };
    }
    return value;
  },
});
console.log(proxy.x());

일부 네이티브 JavaScript 객체는 JavaScript 코드에서 접근할 수 없는 internal slots라고 불리는 속성을 가진다. 예를 들어 Map 객체는 지도의 키-값 쌍을 저장하는 [[MapData]]라는 internal slot을 가진다. 따라서 지도에 대한 전달 프록시를 쉽게 만들 수 없다:

const proxy = new Proxy(new Map(), {});
console.log(proxy.size); // TypeError: get size method called on incompatible Proxy

이 문제를 해결하려면 위에 설명한 "this-recovering" 프록시를 사용해야 한다.

검증(Validation)

Proxy를 사용하면 객체에 전달되는 값을 쉽게 검증할 수 있다. 이 예제는 set() 핸들러를 사용한다.

const validator = {
  set(obj, prop, value) {
    if (prop === "age") {
      if (!Number.isInteger(value)) {
        throw new TypeError("The age is not an integer");
      }
      if (value > 200) {
        throw new RangeError("The age seems invalid");
      }
    }

    // The default behavior to store the value
    obj[prop] = value;

    // Indicate success
    return true;
  },
};

const person = new Proxy({}, validator);

person.age = 100;
console.log(person.age); // 100
person.age = "young"; // Throws an exception
person.age = 300; // Throws an exception

DOM 노드 조작하기(Manipulating DOM nodes)

이 예제에서는 Proxy를 사용해 두 개의 서로 다른 요소의 속성을 토글한다. 즉 한 요소의 속성을 설정하면 다른 요소의 속성이 해제된다.

selected 속성을 가진 객체의 프록시인 view 객체를 만든다. 프록시 handler는 set() 핸들러를 정의한다.

view.selected에 HTML 요소를 할당하면 그 요소의 'aria-selected' 속성이 true로 설정된다. 그런 다음 view.selected에 다른 요소를 할당하면 이 요소의 'aria-selected' 속성이 true로 설정되고 이전 요소의 'aria-selected' 속성은 자동으로 false로 설정된다.

const view = new Proxy(
  {
    selected: null,
  },
  {
    set(obj, prop, newVal) {
      const oldVal = obj[prop];

      if (prop === "selected") {
        if (oldVal) {
          oldVal.setAttribute("aria-selected", "false");
        }
        if (newVal) {
          newVal.setAttribute("aria-selected", "true");
        }
      }

      // The default behavior to store the value
      obj[prop] = newVal;

      // Indicate success
      return true;
    },
  },
);

const item1 = document.getElementById("item-1");
const item2 = document.getElementById("item-2");

// select item1:
view.selected = item1;

console.log(`item1: ${item1.getAttribute("aria-selected")}`);
// item1: true

// selecting item2 de-selects item1:
view.selected = item2;

console.log(`item1: ${item1.getAttribute("aria-selected")}`);
// item1: false

console.log(`item2: ${item2.getAttribute("aria-selected")}`);
// item2: true

값 보정과 추가 속성(Value correction and an extra property)

products 프록시 객체는 전달된 값을 평가하고 필요한 경우 배열로 변환한다. 객체는 또한 latestBrowser라는 추가 속성을 getter와 setter로 모두 지원한다.

const products = new Proxy(
  {
    browsers: ["Firefox", "Chrome"],
  },
  {
    get(obj, prop) {
      // An extra property
      if (prop === "latestBrowser") {
        return obj.browsers[obj.browsers.length - 1];
      }

      // The default behavior to return the value
      return obj[prop];
    },
    set(obj, prop, value) {
      // An extra property
      if (prop === "latestBrowser") {
        obj.browsers.push(value);
        return true;
      }

      // Convert the value if it is not an array
      if (typeof value === "string") {
        value = [value];
      }

      // The default behavior to store the value
      obj[prop] = value;

      // Indicate success
      return true;
    },
  },
);

console.log(products.browsers);
//  ['Firefox', 'Chrome']

products.browsers = "Safari";
//  pass a string (by mistake)

console.log(products.browsers);
//  ['Safari'] <- no problem, the value is an array

products.latestBrowser = "Edge";

console.log(products.browsers);
//  ['Safari', 'Edge']

console.log(products.latestBrowser);
//  'Edge'

명세(Specifications)

  • ECMAScript® 2027 Language Specification — sec-proxy-objects

브라우저 호환성

baseline 기준 2016년 9월부터 널리 사용 가능하다. 호환성 표는 JavaScript를 활성화해야 볼 수 있다.

참고 자료

  • Proxies are awesome — Brendan Eich의 JSConf 발표(2014)

더 알아보기