자바스크립트/모던 자바스크립트

모던 자바스크립트, 프록시 (Proxy)

Jake Seo 2023. 3. 29. 22:26

프록시 (Proxy) 객체란?

프로그래밍적 정의

  • 타겟 오브젝트를 정하고 타겟 오브젝트를 감싸는 객체이다.
    • 타겟 객체의 기본 작업 (fundamental operation)을 가로채 재정의하는 기능이 있다.
  • 프록시에 의해 해당 객체의 기본 작업 수행 시 트랩 (trap) 이라 불리는 핸들러 함수가 호출된다.

현실 세계와의 비유

프록시는 대리라는 뜻으로 현실에 비유하자면 연예인의 매니저 같은 것이다. 학교 축제에 블랙핑크의 제니를 섭외하고 싶다고 해서 제니의 연락처를 직접 알 수는 없다. 소속사를 통해 제니의 매니저와 통화를 먼저 거치고 가격, 시간 등에 대한 협의가 되어야 비로소 학교 축제에서 제니를 볼 수 있을 것이다.

기본 작업 (fundamental operation) 이란?

  • 기본 작업을 가로챈다고 했는데, 기본 작업이 무엇인지 용어만 듣고는 알기 힘들다.
    • 참고로 기본 작업 (fundamental operation) 이란 용어는 MDN 공식문서 에서 차용한 것이다.
  • 기본 작업의 예시는 아래와 같다.
    • 프로퍼티 값 접근 (access)
    • 프로퍼티 값 할당 (assign)
    • 객체 내부 메서드 호출 등 (function invocation)

트랩 (trap) 이란?

  • 기본 작업이 일어날 때 동작하는 함수를 말하는 것이다.

간단 예제 코드

const target = {
  message: "hello",
  message2: "whole new",
};

const trapHandler = {
  get(target, prop, receiver) {
    return Reflect.get(target, prop, receiver) + " world";
  },
};

const proxy = new Proxy(target, trapHandler);

console.log(proxy.message); // hello world
console.log(proxy.message2); // whole new world
console.log(target.message); // hello
console.log(target.message2); // whole new
  • 프록시 생성자인 new Proxy() 를 통해 새로운 프록시 객체를 생성할 수 있다.
    • 인자로는 차례로 target, trap handler 가 들어간다.
    • target 은 프록시를 적용할 원본 대상 객체를 말한다. trap handler 는 기본 작업 이전에 추가될 동작을 정의하는 객체이다.
  • 프록시 객체를 통해 프로퍼티에 접근하면 " world" 라는 문자열을 추가로 반환하게 한 예제이다.
  • Reflect 객체는 이전 포스팅에서 설명했듯이 프록시 트랩 핸들러에서 '기본 동작'을 그대로 제공한다.

프록시 트랩의 종류

  • 트랩은 여러가지 기본동작을 가로챌 수 있다.
    • 이전 간단 예제 코드에서는 프로퍼티 접근을 가로채는 get 을 살펴봤다.

모든 오브젝트에 해당하는 트랩

  • 모든 오브젝트에 있는 기본 작업들이다.

  • 왼쪽이 내부 메서드, 오른쪽이 그에 대응하는 트랩이다.

  • [[GetPrototypeOf]]: getPrototypeOf()

  • [[SetPrototypeOf]]: setPrototypeOf()

  • [[IsExtensible]]: isExtensible()

  • [[PreventExtensions]]: preventExtensions()

  • [[DefineOwnProperty]]: defineProperty()

  • [[HasProperty]]: has()

  • [[Get]]: get()

  • [[Set]]: set()

  • [[Delete]]: deleteProperty()

  • [[OwnPropertyKeys]]: ownKeys()

함수 오브젝트에 해당하는 트랩

  • 함수 오브젝트만 사용할 수 있는 트랩이다.

  • [[Call]]: apply()

  • [[Construct]]: construct()

프록시의 사용 사례

  • 객체에서 발생하는 작업 기록 (logging)
  • 제한된 속성만 읽게 하기 (권한 설정)
  • 속성에 제한된 값만 넣게 하기 (validation)
  • 두 코드 영역 사이 경계 제공 (ex. API와 컨슈머)
  • 객체 정보 숨기기
  • 객체가 실제보다 더 많은 정보를 가진 것처럼 보이게 하기

작업 기록하기 (logging)

가장 간단한 예제

const obj = {
  testing: "abc",
};

const proxy = new Proxy(
  obj,
  // trap handler object
  {
    get(target, name, receiver) {
      console.log(`(getting property '${name}') ${new Date().toString()}`);
      return Reflect.get(target, name, receiver);
    },
  }
);

console.log("Getting 'testing' directly...");
console.log(`Got ${obj.testing}`);
console.log("Getting 'testing' via proxy...");
console.log(`Got ${proxy.testing}`);
console.log("Getting non-existent property 'foo' via proxy...");
console.log(`Got ${proxy.foo}`);

/*
출력 결과:
Getting 'testing' directly...
Got abc
Getting 'testing' via proxy...
(getting property 'testing') Wed Mar 29 2023 22:08:57 GMT+0900 (한국 표준시)
Got abc
Getting non-existent property 'foo' via proxy...
(getting property 'foo') Wed Mar 29 2023 22:08:57 GMT+0900 (한국 표준시)
Got undefined
*/
  • proxy.foo 의 결과는 존재하지 않지만, 역시 동일하게 트랩을 거친다.

거의 모든 트랩 로깅해보기

  • 트랩이 발생하는 조건을 생각해보면 모든 트랩을 로깅할 수 있다.

gettersetter 그리고 defineProperty()

// 각 객체가 가진 식별자를 저장하기 위한 용도
const names = new WeakMap();

/**
 * `log()` 함수는 객체를 받아 객체의 키와 키 내부에 있는 객체의 값을 반환해준다.
 */
const log = (label, params) => {
  console.log(
    `${label} : ${Object.getOwnPropertyNames(params)
      .map((key) => {
        const value = params[key];
        const name = names.get(value);
        const display = name ? name : JSON.stringify(value);
        return `${key} = ${display}`;
      })
      .join(", ")}`
  );
};

const example = { answer: 42 };
names.set(example, "example");
log("Testing 1 2 3", { value: example });

// 모든 기본행동에 `trap` 을 하는 핸들러이다.
const handlers = {
  apply(target, thisValue, args) {
    log("apply", { target, thisValue, args });
    return Reflect.apply(target, thisValue, args);
  },
  construct(target, args, newTarget) {
    log("construct", { target, args, newTarget });
    return Reflect.construct(target, args, newTarget);
  },
  defineProperty(target, propName, descriptor) {
    log("defineProperty", { target, propName, descriptor });
    return Reflect.defineProperty(target, propName, descriptor);
  },
  deleteProperty(target, propName) {
    log("deleteProperty", { target, propName });
    return Reflect.deleteProperty(target, propName);
  },
  get(target, propName, receiver) {
    log("get", { target, propName, receiver });
    return Reflect.get(target, propName, receiver);
  },
  getOwnPropertyDescriptor(target, propName) {
    log("getOwnPropertyDescriptor", { target, propName });
    return Reflect.getOwnPropertyDescriptor(target, propName);
  },
  getPrototypeOf(target) {
    log("getPrototypeOf", { target });
    return Reflect.getPrototypeOf(target);
  },
  has(target, propName) {
    log("has", { target, propName });
    return Reflect.has(target, propName);
  },
  isExtensible(target) {
    log("isExtensible", { target });
    return Reflect.isExtensible(target);
  },
  ownKeys(target) {
    log("ownKeys", { target });
    return Reflect.ownKeys(target);
  },
  preventExtensions(target) {
    log("preventExtensions", { target });
    return Reflect.preventExtensions(target);
  },
  set(target, propName, value, receiver) {
    log("set", { target, propName, value, receiver });
    return Reflect.set(target, propName, value, receiver);
  },
  setPrototypeOf(target, newProto) {
    log("setPrototypeOf", { target, newProto });
    return Reflect.setPrototypeOf(target, newProto);
  },
};

// 카운터 클래스 정의
class Counter {
  constructor(name) {
    this.value = 0;
    this.name = name;
  }

  increment() {
    return ++this.value;
  }
}

// 인스턴스 생성 및 `names` 맵에 저장하기
const counter = new Counter("counter");
const counterProxy = new Proxy(counter, handlers);
names.set(counter, "counter");
names.set(counterProxy, "counterProxy");

console.log("--- Getting counterProxy.value (before increment):");
console.log(`counterProxy.value (before) = ${counterProxy.value}`);
/*
출력결과:
--- Getting counterProxy.value (before increment):
get : target = counter, propName = "value", receiver = counterProxy
counterProxy.value (before) = 0
*/

console.log("--- Calling counterProxy.increment():");
counterProxy.increment();

/*
--- Calling counterProxy.increment():
get : target = counter, propName = "increment", receiver = counterProxy
get : target = counter, propName = "value", receiver = counterProxy
set : target = counter, propName = "value", value = 1, receiver = counterProxy
getOwnPropertyDescriptor : target = counter, propName = "value"
defineProperty : target = counter, propName = "value", descriptor = {"value":1}
*/
  • 처음엔 getter 밖에 실행하지 않아서 trap 에 걸린 것은 하나 뿐이다.
  • 두번째엔 increment() 함수가 실행되며 ++ 연산이 수행되었다.
    • 먼저 increment() 메서드를 가져오기 위해 [[Get]] 이 발생되었다.
    • ++ 연산의 경우엔 쪼개서 보면, 값 가져오기 ([[Get]]), 값 넣기 ([[Set]]) 로 이루어져있다.
    • 뒤의 getOwnPropertyDescriptordefineProperty 는 우리가 설정한 값이 데이터 속성 이기 때문이다.
    • 접근자setter 함수 가 호출되고 끝나지만, 데이터 속성 인 경우, [[GetOwnProperty]]descriptor 를 가져온 뒤, 값을 설정하고 [[DefineOwnProperty]] 를 통해 값을 업데이트한다.

[[DefineOwnProperty]] 에 트랩을 걸면, 모든 세터 함수에 관여할 수 있다.

ownKeys 트랩

console.log("--- Getting counterProxy's own enumerable string-named keys:");
console.log(Object.keys(counterProxy));

/*
ownKeys : target = counter
getOwnPropertyDescriptor : target = counter, propName = "value"
getOwnPropertyDescriptor : target = counter, propName = "name"
(2) ['value', 'name']
*/

deleteProperty 트랩

console.log("--- Deleting counterProxy.value:");
delete counterProxy.value;

/*
--- Deleting counterProxy.value:
deleteProperty : target = counter, propName = "value"
*/

has 트랩

console.log("--- Checking whether counterProxy has a 'value' property:");
console.log(`"value" in counterProxy? ${"value" in counterProxy}`);

/*
has : target = counter, propName = "value"
"value" in counterProxy? false
*/

프록시 트랩 예제 코드로 알아보기

  • 각 프록시 트랩을 몇가지 예제 코드로 알아보자.

defineProperty 트랩 예제: writablefalse 로 바꾸지 못하게 하기

const obj = {};

const p = new Proxy(obj, {
  defineProperty(target, propName, descriptor) {
    if ("writable" in descriptor && !descriptor.writable) {
      const currentDescriptor = Reflect.getOwnPropertyDescriptor(
        target,
        propName
      );
      if (currentDescriptor && currentDescriptor.writable) {
        return false;
      }
    }

    return Reflect.defineProperty(target, propName, descriptor);
  },
});

p.a = 1;

console.log(`p.a = ${p.a}`);
console.log("Trying to make p.a non-writable...");
console.log(
  `Result of defineProperty: ${Reflect.defineProperty(p, "a", {
    writable: false,
  })}`
);
console.log("Setting pa.a to 2...");

p.a = 2;

console.log(`p.a = ${p.a}`);

/*
출력결과:
p.a = 1
Trying to make p.a non-writable...
Result of defineProperty: false
Setting pa.a to 2...
p.a = 2
*/
  • descriptor 에 대한 이해가 선행되어야 이해되는 예제이다.
  • if ("writable" in descriptor && !descriptor.writable) 부분에서 descriptor.writablefalse 일 때 동작 자체를 막는다.
  • Reflect.defineProperty(p, "a", { writable: false }) 가 아니라 Object.defineProperty() 를 썼다면, 타입 에러가 던져진다.
    • Reflect 는 오류가 발생하면 false 를 반환한다.
  • defineProperty 트랩은 descriptor 인자로 온 객체를 직접 받는 것이 아니라, 유효한 propertyName 을 가져가서 특수한 객체로 변환한다.

deleteProperty 트랩 예제: 특정 프로퍼티의 삭제 막기

const obj = { value: 42 };

const p = new Proxy(obj, {
  deleteProperty(target, propName, descriptor) {
    if (propName === "value") {
      return false; // `value` 라는 이름의 프로퍼티를 삭제할 수 없게 막음
    }

    return Reflect.deleteProperty(target, propName, descriptor);
  },
});

console.log(`p.value = ${p.value}`);
console.log("deleting 'value' from p in loose mode:...");
console.log(delete p.value); // false
console.log(p.value); // 42

(() => {
  "use strict";
  console.log("deleting 'value' from p in strict mode:");
  try {
    delete p.value;
  } catch (error) {
    console.error(error);
  }
})();
  • value 프로퍼티에 대한 삭제를 시도했을 때 무조건 false 를 반환하게 하여 삭제를 하지 못하도록 했다.
  • 엄격모드에서는 삭제 실패가 에러를 던지기 때문에 에러가 던져진다.

preventExtensions 트랩 예제: 오브젝트 확장 못막게 하기

const obj = { value: 42 };

const p = new Proxy(obj, {
  // preventExtensions(target) {
  //   return false;
  // },
});

console.log(Reflect.isExtensible(p));
p.value2 = 30;
console.log(Reflect.preventExtensions(p));
p.value3 = 50;
console.log(Reflect.isExtensible(p));

console.log(p); // Proxy(Object) {value: 42, value2: 30}
  • Reflect.preventExtensions() 메서드는 오브젝트의 확장을 막는다.
  • 확장이 막혀 p.value3 = 50 을 통해 value3 프로퍼티를 추가했지만, 반응이 없다.
const obj = { value: 42 };

const p = new Proxy(obj, {
  preventExtensions(target) {
    return false;
  },
});

console.log(Reflect.isExtensible(p));
p.value2 = 30;
console.log(Reflect.preventExtensions(p));
p.value3 = 50;
console.log(Reflect.isExtensible(p));

console.log(p); // Proxy(Object) {value: 42, value2: 30, value3: 50}
  • Reflect.preventExtensions() 메서드의 결과가 핸들러에서 무조건 false 를 반환하게 만들어 확장을 못막게 한다.
  • 확장덕에 p.value3 = 50 을 통해 value3 프로퍼티를 추가가 그대로 적용된다.

취소 가능한 프록시 만들기 (Proxy.revocable())

  • 취소 가능한 프록시를 만들 수 있다.
  • 시간이 되면 프록시를 취소할 수 있다.
  • 프록시가 취소되면 프록시에 대한 모든 작업이 오류와 함께 실패한다.

예제 코드

const obj = { answer: 42 };
const { proxy, revoke } = Proxy.revocable(obj, {});
console.log(proxy.answer);
revoke();
console.log(proxy.answer);
console.log(obj.answer);
/*
출력결과:
42
Uncaught TypeError: Cannot perform 'get' on a proxy that has been revoked
    at <anonymous>:5:19
42
*/
  • 취소된 이후에는 프록시 객체가 동작하지 않는다.
    • 에러와 함께 실패한다.
  • 원본 객체에는 여전히 접근 가능하다.

프록시로 해결 가능한 문제

set 권한을 주지 않기 위해 get 을 별도로 만들지 말고 프록시 객체를 사용하자

  • 프로퍼티를 소비하는 API 를 따로 만들지 말고 프록시 객체를 이용하자.

구현 코드와 부가 관심사 코드를 분리하자

  • 객체 내부에 스며들어야 하는 로직이 아니면, 프록시로 분리된 레이어를 구성하여 관심사를 나누어 로직 작성이 가능하다.
  • 트랩 핸들러는 다른 객체에 재활용도 가능하니 재활용 할 수 있다면 재활용 하면 좋다.
반응형