VanilaJS의 Middleware. Proxy

expressJS에서 쓰이는 middleware. 바닐라JS에도 비슷한게 있다는걸 아셨나요? Observable을 위한 빌드업...

Featured image

들어가며

express.js로 서버 개발을 해봤다면 ‘미들웨어’ 라는 키워드를 들어 보셨을 겁니다. 중간에 값들을 가로채서 여러 처리들을 할 수 있는 함수 조각들을 의미하는것을 알고 계실겁니다. 그런데 바닐라 JS에서도 이와 비슷하게 연산 중간에 값을 ‘가로챌’ 수 있는 객체가 있다는 것을 알고 계셨나요? 이번 포스팅에서는 그 역할을 하는 Proxy 객체와 그 응용예시들을 살펴볼까 합니다. 생각보다 다양한 일을 할 수 있는것에 아마 깜짝 놀랄지도 모르겠습니다.

이 글은 https://ko.javascript.info/proxy의 내용을 참조하여 작성되었습니다.

Proxy 개요

영단어 ‘proxy’는 ‘대리자’를 뜻합니다. JS에서의 Proxy도 이 단어의 뜻과 비슷하게, 여러 연산을 ‘대신’ 수행하는 역할을 합니다. Proxy가 대신 할 수 있는 연산을 대략적으로 나열하면 아래와 같습니다.

모든 연산은 아니지만, JS에서 우리가 사용하는 여럿 연산에 대한 가로채기 연산을 지원해줌을 확인할 수 있습니다. 이 모든 연산을 가로채는 법과 그 실사용례를 다 적기엔 지면이 부족하므로, 혹시 관련 내용이 궁금하다면, mdn 독스에서 Proxyhandler의 메소드들을 확인해 보면 좋을듯 합니다.

Proxy 기본 문법

ProxyDate 처럼 전역으로 선언된 객체이며, new 연산자를 이용해서 인스턴스를 생성할 수 있습니다. 생성자 문법은 아래와 같습니다. 반드시 new를 통해서만 Proxy()를 호출 할 수 있습니다.

new Proxy(target, handler);

트랩의 이름은 언어 차원에서 정의되어 있으며, 이번 포스트 시리즈에서 다룰 트랩들의 이름과 역할에 대한 간단한 개요는 아래와 같습니다.


주의사항

Proxy에는 한계점이 있는데, 사실 정말 직관적인 한계점입니다. Proxy원본 객체와는 다른 객체입니다. 그렇기 때문에

일단 부딛혀 보는 사용예시

새로운 개념을 볼 때, 공식 docs 부터 해서 bottom-up으로 배우는 것은 나쁜 방법은 아니지만, 시간이 많이 걸리고, 내용이 좀 깊어질 경우 파고둘 엄두가 안나는 단점이 있습니다. 이 글에서는 최대한 부담을 덜기 위해서 필요한 만큼의 지식과, 흥미 유발을 위한 실용적인(적어도 실용적으로 보이기라도 하는) 예제들로 글을 구성을 해보겠습니다.

값 검증

Express.js 에서 미들웨어를 사용해서 값들을 검증 할 수 있듯, Proxy도 비슷한 역할을 할 수 있습니다. 우선 코드부터 보여드리겠습니다.

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");
      }
    }

    // 값을 저장하는 기본 동적
    obj[prop] = value;

    // 성공 표시
    return true;
  },
};

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

person.age = 100;
console.log(person.age); // 100
person.age = "young"; // 예외 발생
person.age = 300; // 예외 발생

나이를 나타내는 age라는 속성에 대해서 200 이하의 정수 만 들어갈 수 있도록 일종의 제약을 거는 코드라는 것을 대강 파악할 수 있을 것입니다. 이제 이 코드를 제대로 한번 파고 들어가봅시다.

쓰기 연산을 가로채는 handler.set()

handler는 위의 Proxy 명세에 간단히 지나가듯 적어놨듯, new Proxy()에 들어가는 두번째 인자인 객체를 의미합니다. 그리고 그 객체의 속성 이름이 set이라는 의미이지요. 앞으로 설명할 Proxyhandler에 대해서도 다음과 같은 형식으로 설명할 것 입니다. mdn에서도 이런 형식을 사용하기도 하고요.

서론이 길었습니다. handler.set()은 다음으로 정의됩니다.

new Proxy(target, {
  set(target, property, value, receiver) {
    // 여러분의 어썸한 로직
  },
});

매개변수

반환값

할당이 성공했을 때는 true, 실패했을때는 false를 반환해야 함. 엄격 모드에서 false가 반환되면 TypeError가 반환됨.


이제 다시 예제 코드의 set()을 사용하는 부분을 다시 봅시다.

  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");
      }
    }

변경하려는 속성 이름인 prop 변수를 활용해서 age를 수정하려 할 때, 조건을 검사하고, 조건이 맞지 않으면 에러를 throw하는 간단한 로직임을 알 수 있습니다.

handler.set[[Set]] 연산을 가로채는 것이기 때문에, 내부적으로 [[Set]] 연산을 사용하는 Array.prototype.push() 등 또한 정상적으로 가로챌 수 있다는 점도 알아두면 좋을것 같습니다.

객체의 속성 기본 값 설정하기

기본적으로 존재하지 않는 속성을 읽으면 undefined를 받는것이 JS 세계의 기본 상식입니다. 하지만, undefined보다 다른 값, 이를테면 0을 받고 싶다면 어떡해야 할까요? 아래 코드는 그 작업을 아무 문제없이 해내는 코드입니다.

let numbers = [0, 1, 2];

numbers = new Proxy(numbers, {
  get(target, prop) {
    if (prop in target) {
      return target[prop];
    } else {
      return 0; // 기본값
    }
  },
});

alert(numbers[1]); // 1
alert(numbers[123]); // 0 (해당하는 요소가 배열에 없으므로 0이 반환됨)

읽기 연산을 가로채는 handler.get()

긴 말 필요없습니다. 명세!

new Proxy(target, {
  get(target, property, receiver) {
    // 여러분의 어썸한 로직
  },
});

매개 변수

위의 set에서 다룬 그것과 사실 같습니다.

반환값

아무거나 정말 아무거나 반환할 수 있습니다. 여러분의 로직에 맞는 값을 반환해 주세요!


다시 한번 handler.get이 사용된 부분을 봅시다.

  get(target, prop) {
    if (prop in target) {
      return target[prop];
    } else {
      return 0; // 기본값
    }
  },

prop in target 으로 target안에 prop이 있으면, 있는 값을 반환해주고, 아니라면 기본값으로 0을 반환해주는 간단한 로직이었음을 알 수 있습니다.

사실 이 글을 작성한 이유. Observable

컴퓨터 공학의 디자인 패턴을 공부하신 분이라면, 옵저버 패턴(또는 발행/구독 패턴)을 들어 보셨을 겁니다. 간단히 말하자면, 객체의 상태 변화를 관찰하는 관찰자, 즉 옵저버를 등록하여, 해당 객체가 변경된 사실을 옵저버(구독자)들에게 통지하는 패턴입니다. 자세히 알고 싶으신 분을 위해서 위키백과 링크를 달아 두도록 하겠습니다.

이 글에서 다룬 Proxy를 통해서 이 옵저버 패턴을 구현할 수 있습니다. 우선 예제 코드부터 살펴 보겠습니다.

let handlers = Symbol("handlers");

function makeObservable(target) {
  // 1. 핸들러를 저장할 곳을 초기화합니다.
  target[handlers] = [];

  // 나중에 호출될 것을 대비하여 핸들러 함수를 배열에 저장합니다.
  target.observe = function (handler) {
    this[handlers].push(handler);
  };

  // 2. 변경을 처리할 Proxy를 만듭니다.
  return new Proxy(target, {
    set(target, property, value, receiver) {
      let success = Reflect.set(...arguments); // 동작을 객체에 전달합니다.
      if (success) {
        // 에러 없이 프로퍼티를 제대로 설정했으면
        // 모든 핸들러를 호출합니다.
        target[handlers].forEach((handler) => handler(property, value));
      }
      return success;
    },
  });
}

let user = {};

user = makeObservable(user);

user.observe((key, value) => {
  alert(`SET ${key}=${value}`);
});

user.name = "John";

임의의 객체 target을 넘겨받아서, 해당 객체를 observable로 만드는 코드입니다. 천천히 살펴봅시다.

중복을 방지하기 위해서 Symbol을 통해서 handler들을 담을 속성을 초기화 하고(target[handlers] = []), 핸들러 함수들을 등록하는 observe 메소드를 구현해줍니다.

이제 우리가 이 글의 많은 지면을 잡아먹으면서 다룬 Proxy가 사용되는 시점입니다. 해당 객체가 변경될 때 마다(수정 될 때 마다) handlers에 등록된 함수들을 호출할 수 있도록, handler.set 트랩을 작성해 줍시다.

let success = Reflect.set(...arguments); // 동작을 객체에 전달합니다.

라는 코드가 낯설어 보이는군요. Reflect.sethandler.set을 따로 떼놓은 것과 같다라고 생각하셔도 됩니다. Proxyhandler.set과 받는 매개변수가 같으며, 동작도 매우 비슷해서 지금 이 코드를 이해할 때에는 같다 라고 생각하셔도 됩니다.

두번째로는 argument인데, 이것은 함수 내에서 해당 함수의 인수의 배열이라고 생각하시면 됩니다. ... 스프레드 연산자를 통해서, Reflect.set에 넘겨주는것을 확인할 수 있지요. set연산이 성공적으로 되면 true를, 아니라면 false가 리턴 됩니다.

이제 이어지는 코드는 set 하는데에 문제가 없었다면, 등록된 콜백을 forEach로 실행해주는 로직임을 알 수 있습니다. 해당 객체의 변화를 구독하는 친구들에게 변화의 소식을 알려줄 수 있겠네요. 변경된 속성 이름(property)와 변경된 값(value)를 콜백에 넣어 줬으니까요.

Vanila JS에서 React의 Context API 처럼 전역 변수를 전달하고, 해당 변화를 구독할 수 있는 makeObservable 구현. Proxy만 제대로 이해한다면 그리 어려운 내용은 아니었습니다.

마치며

원래 처음에는 Proxy의 많은 내용들을 한 글에다가 담아보려고 했지만, 내용이 워낙에 방대하다 보니, 상태관리 라이브러리인 MobX 같은 곳에서 쓰이는 실전적인 예인 observable을 우선적으로 다루고, 향후의 글에서 조금 더 복잡한 사용 예들을 살펴볼까 합니다. 부스트캠프를 하면서 스스로 학습의 토끼굴에 빠져서 헤메고 있었는데, 이렇게 글을 하나라도 쓰면서 토끼굴을 조금 정리할 수 있어서 다행이었던것 같습니다. 글 끝까지 읽어주셔서 정말 감사합니다 :)

혹시라도 잘못된 정보나, 이해가 잘 되지 않는 부분이 있다면 코멘트 부탁드리겠습니다. 안녕~

참고 자료