ES2015 프록시 소개

아디 오스마니
애디 오스마니

ES2015 프록시 (Chrome 49 이상)는 JavaScript에 중간 API를 제공하여 대상 객체의 모든 작업을 트랩하거나 가로채고 이 대상이 작동하는 방식을 수정할 수 있습니다.

프록시는 다음을 비롯하여 다양한 용도로 사용됩니다.

  • 인터셉트
  • 객체 가상화
  • 리소스 관리
  • 디버깅을 위한 프로파일링 또는 로깅
  • 보안 및 액세스 제어
  • 객체 사용 계약

Proxy API에는 지정된 대상 객체와 핸들러 객체를 사용하는 프록시 생성자가 포함되어 있습니다.

var target = { /* some properties */ };
var handler = { /* trap functions */ };
var proxy = new Proxy(target, handler);

프록시의 동작은 핸들러가 제어하며, 핸들러는 target 객체의 원래 동작을 여러 가지 유용한 방식으로 수정할 수 있습니다. 핸들러에는 해당 작업이 프록시에서 수행될 때 호출되는 선택적 트랩 메서드 (예: .get(), .set(), .apply())가 포함됩니다.

인터셉트

먼저 일반 객체를 가져와서 Proxy API를 사용하여 여기에 가로채기 미들웨어를 추가해 보겠습니다. 생성자에 전달되는 첫 번째 매개변수는 대상 (프록시할 객체)이고 두 번째 매개변수는 핸들러 (프록시 자체)입니다. 여기에서 getter, setter 또는 기타 동작을 위한 후크를 추가할 수 있습니다.

var target = {};

var superhero = new Proxy(target, {
    get: function(target, name, receiver) {
        console.log('get was called for:', name);
        return target[name];
    }
});

superhero.power = 'Flight';
console.log(superhero.power);

위의 코드를 Chrome 49에서 실행하면 다음과 같은 결과를 얻게 됩니다.

get was called for: power  
"Flight"

실제로 볼 수 있듯이 프록시 객체에서 속성 get 또는 속성 설정을 올바르게 수행하면 핸들러의 해당 트랩에 대한 메타 수준의 호출이 발생합니다. 핸들러 작업에는 속성 읽기, 속성 할당, 함수 애플리케이션이 포함되며 모두 해당 트랩으로 전달됩니다.

trap 함수는 원하는 경우 작업을 임의로 구현할 수 있습니다 (예: 작업을 대상 객체로 전달). 이는 트랩이 지정되지 않으면 기본적으로 발생하는 일입니다. 예를 들어 다음은 이를 수행하는 노옵스(no-ops) 전달 프록시입니다.

var target = {};

var proxy = new Proxy(target, {});
    // operation forwarded to the target
proxy.paul = 'irish';
// 'irish'. The operation has been  forwarded
console.log(target.paul);

우리는 일반 객체를 프록시하는 방법을 살펴보았지만, 함수가 대상인 함수 객체를 프록시 처리하는 것과 마찬가지로 쉽게 프록시할 수 있습니다. 이번에는 handler.apply() 트랩을 사용합니다.

// Proxying a function object
function sum(a, b) {
    return a + b;
}

var handler = {
    apply: function(target, thisArg, argumentsList) {
        console.log(`Calculate sum: ${argumentsList}`);
        return target.apply(thisArg, argumentsList);
    }
};

var proxy = new Proxy(sum, handler);
proxy(1, 2);
// Calculate sum: 1, 2
// 3

프록시 식별

프록시의 아이덴티티는 자바스크립트 등호 연산자 (=====)를 사용하여 관찰할 수 있습니다. 아시다시피 두 객체에 이 연산자가 적용되면 객체 ID를 비교합니다. 다음 예는 이 동작을 보여줍니다. 서로 다른 두 프록시를 비교하면 기본 타겟이 동일하더라도 거짓이 반환됩니다. 비슷한 맥락에서 타겟 객체는 그 프록시와 다릅니다.

// Continuing previous example

var proxy2 = new Proxy (sum, handler);
(proxy==proxy2); // false
(proxy==sum); // false

프록시가 앱의 결과에 실제로 영향을 미치지 않도록 프록시를 비 프록시 객체와 구분할 수 없어야 합니다. 프록시 API에 객체가 프록시인지 확인하는 방법이 없고 객체에 대한 모든 작업에 트랩을 제공하지 않는 한 가지 이유가 있습니다.

사용 사례

앞서 언급했듯이 프록시는 사용 사례가 매우 다양합니다. 액세스 제어, 프로파일링 등 위에 나와 있는 대부분의 API는 일반 래퍼, 즉 동일한 주소 '공간'에 다른 객체를 래핑하는 프록시에 속합니다. 가상화도 언급되었습니다. 가상 객체는 다른 객체가 동일한 주소 공간에 있을 필요 없이 다른 객체를 에뮬레이션하는 프록시입니다. 예를 들어 원격 객체 (다른 공간의 객체를 에뮬레이션하는 객체)와 투명한 future (아직 계산되지 않은 결과를 에뮬레이션하는 결과)가 있습니다.

핸들러로서의 프록시

프록시 핸들러의 일반적인 사용 사례는 래핑된 객체에서 작업을 수행하기 전에 유효성 검사 또는 액세스 제어 검사를 수행하는 것입니다. 검사에 성공한 경우에만 작업이 전달됩니다. 아래의 유효성 검사 예는 이를 보여줍니다.

var validator = {
    set: function(obj, prop, value) {
    if (prop === 'yearOfBirth') {
        if (!Number.isInteger(value)) {
        throw new TypeError('The yearOfBirth is not an integer');
        }

        if (value > 3000) {
        throw new RangeError('The yearOfBirth seems invalid');
        }
    }

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

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

person.yearOfBirth = 1986;
console.log(person.yearOfBirth); // 1986
person.yearOfBirth = 'eighties'; // Throws an exception
person.yearOfBirth = 3030; // Throws an exception

이 패턴의 더 복잡한 예에는 프록시 핸들러가 가로챌 수 있는 모든 다양한 작업 프록시가 고려될 수 있습니다. 구현 시 각 트랩에서 액세스 확인 및 작업 전달 패턴을 복제해야 한다고 생각할 수 있습니다.

각 op를 다르게 전달해야 할 수 있으므로 이를 추상화하기가 까다로울 수 있습니다. 완벽한 시나리오에서 모든 작업이 단 하나의 트랩을 통해 균일하게 유입경로될 수 있다면 핸들러는 단일 트랩에서 유효성 검사를 한 번만 실행하면 됩니다. 프록시 핸들러 자체를 프록시로 구현하면 됩니다. 이 내용은 이 도움말의 범위를 벗어납니다.

객체 확장 프로그램

프록시의 또 다른 일반적인 사용 사례는 객체에 대한 작업의 의미 체계를 확장하거나 재정의하는 것입니다. 예를 들어 핸들러가 작업을 기록하고, 관찰자에게 알리거나, 정의되지 않은 결과를 반환하는 대신 예외를 발생시키거나, 작업을 다른 저장 대상으로 리디렉션하도록 할 수 있습니다. 이러한 경우 프록시를 사용하면 대상 객체를 사용하는 것과 결과가 매우 다를 수 있습니다.

function extend(sup,base) {

    var descriptor = Object.getOwnPropertyDescriptor(base.prototype,"constructor");

    base.prototype = Object.create(sup.prototype);

    var handler = {
    construct: function(target, args) {
        var obj = Object.create(base.prototype);
        this.apply(target,obj, args);
        return obj;
    },

    apply: function(target, that, args) {
        sup.apply(that,args);
        base.apply(that,args);
    }
    };

    var proxy = new Proxy(base, handler);
    descriptor.value = proxy;
    Object.defineProperty(base.prototype, "constructor", descriptor);
    return proxy;
}

var Vehicle = function(name){
    this.name = name;
};

var Car = extend(Vehicle, function(name, year) {
    this.year = year;
});

Car.prototype.style = "Saloon";

var Tesla = new Car("Model S", 2016);

console.log(Tesla.style); // "Saloon"
console.log(Tesla.name); // "Model S"
console.log(Tesla.year);  // 2016

액세스 제어

액세스 제어는 프록시의 또 다른 좋은 사용 사례입니다. 신뢰할 수 없는 코드에 대상 객체를 전달하는 대신 일종의 보호막에 래핑된 프록시를 전달할 수 있습니다. 앱에서 신뢰할 수 없는 코드가 특정 작업을 완료했다고 판단하면 대상에서 프록시를 분리하는 참조를 취소할 수 있습니다. 멤브레인은 이 분리를 정의된 원래 대상에서 도달 가능한 모든 객체로 재귀적으로 확장합니다.

프록시와 함께 리플렉션 사용

Reflect는 가로채기 가능한 자바스크립트 작업을 위한 메서드를 제공하는 새로운 내장 객체로, 프록시 작업에 매우 유용합니다. 실제로 Reflect 메서드는 프록시 핸들러의 메서드와 동일합니다.

Python이나 C# 과 같이 정적으로 형식이 지정되는 언어는 오래전부터 Reflection API를 제공해 왔지만 JavaScript에는 동적 언어인 리플렉션 API가 실제로 필요하지 않았습니다. ES5에는 다른 언어에서 리플렉션으로 간주될 수 있는 Array.isArray() 또는 Object.getOwnPropertyDescriptor()와 같은 리플렉션 기능이 이미 꽤 많이 있다고 주장할 수 있습니다. ES2015는 이 카테고리의 향후 메서드를 더 쉽게 추론할 수 있도록 하는 Reflection API를 도입했습니다. 이는 객체가 리플렉션 메서드의 버킷이 아닌 기본 프로토타입이어야 하기 때문입니다.

Reflect를 사용하면 다음과 같이 get 및 set 트랩에서 필드 인터셉트를 적절하게 하기 위해 이전의 슈퍼히어로 예시를 개선할 수 있습니다.

// Field interception with Proxy and the Reflect API

var pioneer = new Proxy({}, {
    get: function(target, name, receiver) {
        console.log(`get called for field: ${name}`);
        return Reflect.get(target, name, receiver);
    },

    set: function(target, name, value, receiver) {
        console.log(`set called for field: ${name} and value: ${value}`);
        return Reflect.set(target, name, value, receiver);
    }
});

pioneer.firstName = 'Grace';
pioneer.secondName = 'Hopper';
// Grace
pioneer.firstName

출력은 다음과 같습니다.

set called for field: firstName and value: Grace
set called for field: secondName and value: Hopper
get called for field: firstName

또 다른 예는 다음과 같습니다.

  • 특정 로직으로 작업할 때마다 수동으로 새 프록시를 만들지 않도록 맞춤 생성자 내에 프록시 정의를 래핑합니다.

  • 변경사항을 '저장'하는 기능을 추가합니다. 단, 데이터가 실제로 수정된 경우에만 (저장 작업이 매우 많이 발생하므로 가설)

function Customer() {

    var proxy = new Proxy({
    save: function(){
        if (!this.dirty){
        return console.log('Not saving, object still clean');
        }
        console.log('Trying an expensive saving operation: ', this.changedProperties);
    },

    }, {

    set: function(target, name, value, receiver) {
        target.dirty = true;
        target.changedProperties = target.changedProperties || [];

        if(target.changedProperties.indexOf(name) == -1){
        target.changedProperties.push(name);
        }
        return Reflect.set(target, name, value, receiver);
    }

    });

    return proxy;
}


var customer = new Customer();

customer.name = 'seth';
customer.surname = 'thompson';
// Trying an expensive saving operation:  ["name", "surname"]
customer.save();

더 많은 Reflect API 예는 Tagtree의 ES6 프록시를 참고하세요.

Polyfilling Object.observe()

Object.observe()종료되지만 이제 ES2015 프록시를 사용하여 폴리필할 수 있습니다. 사이먼 블랙웰은 최근 살펴볼 만한 가치가 있는 프록시 기반 Object.observe() shim을 작성했습니다. 에릭 아르비드슨은 2012년까지 상당히 사양 완료 버전을 작성하기도 했습니다.

브라우저 지원

ES2015 프록시는 Chrome 49, Opera, Microsoft Edge, Firefox에서 지원됩니다. Safari의 경우 이 기능에 대한 대중의 신호도 섞여 있었지만 여전히 낙관적입니다. Reflect는 Chrome, Opera, Firefox에서 사용할 수 있으며 Microsoft Edge용으로 개발 중입니다.

Google에서는 프록시용 제한된 폴리필을 출시했습니다. 이는 프록시 생성 시점에 알려진 프록시 속성에만 사용할 수 있으므로 일반 래퍼에만 사용할 수 있습니다.

추가 자료