Javascript

[Javascript] ES6 프록시(Proxy)와 핸들러(Handler) 기초 - 프록시, 핸들러, 리플렉트, 리시버의 개념 알기

apost 2023. 2. 1. 10:45

프록시(Proxy)

 

프록시는 특정 객체를 감싸서 객체에 적용되는 동작을 가로채 특정 작업을 수행하거나, 동작을 다시 객체에게 전달하는 역할을 하는 객체입니다. 쉽게 중간 다리 역할을 하는 중계상? 같은 객체입니다.

 

프록시 객체는 3가지 중요한 필수 요소가 있습니다.

 

타겟: 프록시는 먼저 감쌀 객체가 필요합니다. 이 대상 객체를 타겟이라고 합니다.

 

핸들러: 프록시가 타겟 객체에 어떤 동작을 할지를 기술해놓은 객체를 핸들러라고 합니다. 핸들러 객체 안에는 타겟 객체를 다루는 메서드(들)를 정의하며, 이 메서드를 트랩이라고 합니다. 핸들러는 트랩 메서드의 모음 객체를 말합니다.

 

트랩: 객체에 접근하는 방법을 구현한 메서드를 트랩(Trap)이라고 합니다.

 

대상 객체의 특정 동작을 대신하는 프록시 객체는 트랩(Trap) 메서드로 필요한 작업을 구현합니다. 따라서, 트랩 메서드를 작성하는 방법이 프록시 객체를 구현하고 사용하는 핵심입니다.

 

빈 프록시 객체를 한 개 선언해 보겠습니다.

타겟 객체가 비어있고, 프록시 핸들러 객체도 아무것도 정의하지 않은 빈 프록시입니다.

객체에서 발생하는 동작은 프록시를 그냥 통과해서 원래 객체의 해당되는 동작을 실행합니다.

 

let target = {};
let proxy = new Proxy(target, {});

 

프록시 선언 기본 문법은 다음과 같습니다.

 

let proxy = new Proxy(target, handler)

 

앞서 생성한 핸들러가 없는 빈 프록시를 사용해서 객체에 속성을 추가해 보겠습니다.

 

proxy.name = '라이언';
console.log(target.name); // 라이언
console.log(proxy.name); // 라이언

 

프록시에 핸들러가 없으므로 타겟 객체의 게터/세터 가 실행되어 객체에 속성이 추가되고, 또 속성의 값을 읽은 것입니다. 프록시로 접근해도 타겟 객체로 접근해도 속성의 값을 읽고 쓸 수 있습니다.

 

 

 

 

프록시 핸들러 정의

핸들러는 객체입니다.

따라서 핸들러는 객체 표시 괄호 "{}"로 감싸서 표시를 합니다. 객체 안에는 트랩 메서드들을 나열해서 작성하면 됩니다.

트랩 메서드 이름은 반드시 객체의 내장 메서드와 같아야 합니다. 임의의 이름을 사용할 수 없습니다.

 

객체의 속성 값을 읽는 게터(get) 트랩 메서드를 하나 만들어보겠습니다.

작성한 트랩은 객체의 게터를 대신해서 동작하게 됩니다.

 

let target = {name: '라이언', age: 5, gender:'male'};
let proxy = new Proxy(target, {
    get(target, prop) {
        if (prop in target) {
            return target[prop];
        } else {
            return 'no Property!';
        }
    }        
});
console.log(proxy.name) // 라이언

 

프록시의 속성 읽기 트랩 메서드를 보면 알겠지만, 객체에 해당 속성이 없을 경우 "no Property!"라는 메시지를 반환하도록 해서 속성이 없는 경우에도 에러가 발생하지 않도록 막을 수 있습니다.

프록시는 이렇게 객체의 내장 속성을 대체해서 다양한 동작을 구현할 수 있습니다.

 

프록시에서 중간에 가로채서 재정의할 수 있는 속성과 메서드들은 다음과 같습니다.

 

Property

  • get
  • set
  • has
  • deleteProperty

Method

  • apply
  • construct

Object

  • getPrototypeOf
  • setPrototypeOf
  • isExtensible
  • preventExtensions
  • getOwnPropertyDescriptor
  • defineProperty
  • ownKeys

 

 

 

속성에 적용할 값을 검증하는 set 트랩 매써드 작성

 

앞에서 속성 값을 읽는 get 트랩 메서드를 어떻게 작성하는지를 알았으므로 조금 더 나아가서 속성에 값을 적용할 때 인자로 받은 값이 올바른 타입의 데이터인지 확인한 후, 적용 가능한 타입이면 속성에 값을 적용하는 set 트랩 메서드를 작성해 보겠습니다.

 

let target = {start:3, end:10};
let proxy = new Proxy(target, {
    set(target, prop, val) {
        if (typeof val == 'number') {
            target[prop] = val;
        }
    }            
});
proxy.start = 5
console.log(proxy.start)  // 5

 

 

 

 

데이터 객체와 UI의 데이터 바인딩

 

기초적인 프록시의 사용법을 알아봤으므로, 실제로 어떻게 프록시를 사용하는지 알아보겠습니다. 화면 UI 폼 필드와 데이터를 유지하고 관리하는 객체를 프록시로 바인딩(Binding)해서 상호 연동을 시켜보겠습니다.

 

먼저 UI에 해당하는 로그인 폼을 하나 만들어보겠습니다.

아이디와 패스워드 입력 필드만 있는 간단한 입력 폼입니다.

 

    <form name="loginForm">
        <input type="text" name="userid" id="userid" value="">
        <input type="text" name="password" id="password" value="">
    </form>

 

loginInfo는 데이터를 유지하고 관리하는 객체입니다.

그리고 폼 필드 요소를 쿼리 선택자로 선택해서 변수에 저장해 놓습니다.

 

let loginInfo = {
    userid: '',
    password: ''
}
const userid = document.querySelector('#userid')
const password = document.querySelector('#password')

 

프록시를 작성합니다. 타겟 객체는 데이터를 보관하고 관리하는 객체인 loginInfo가 됩니다.

이 프록시의 핵심은 객체 속성에 데이터를 저장하는 set 트랩 메서드입니다.

속성이 타겟 객체에 있으면 속성에 값을 저장하고, UI의 폼 요소에도 값을 반영합니다.

그 밑줄에 콘솔로 객체의 속성 값과 UI의 폼 필드 값을 출력하므로 콘솔에서 바로 변경된 값들을 확인할 수 있습니다.

 

let proxy = new Proxy(loginInfo, {
    get(target, prop) {
        if (prop in target) {
            return target[prop];
        } else {
            return 'Error!';
        }
    },     
    set(target, prop, val) {
        if(prop in target){
            target[prop] = val; // 객체 데이터 구조 업데이트
            document.querySelector(`#${prop}`).value = val; //입력 필드 UI 업데이트
            console.log(target[prop], document.querySelector(`#${prop}`).value) // 데이터 구조와 UI 양쪽의 값 확인
            return true
        }
        return false
    }   

});

 

프록시로 속성 값을 적용해서 데이터 객체와 폼 UI가 연동이 되는지 확인합니다.

 

proxy.userid = '어피치'
proxy.password = 'djvlcl'

 

자바스크립트 코드로 데이터 객체를 핸들링하면 화면 UI에 값이 적용되는 것은 확인했습니다.

이제 반대로 UI에 변경이 생겼을 때 데이터 객체가 변경되도록 해야 합니다.

UI 입력 필드에 change 이벤트 핸들러를 추가합니다.

 

<form name="loginForm">
    <input type="text" name="userid" id="userid" value="" onchange="proxy.userid=this.value">
    <input type="text" name="password" id="password" value="" onchange="proxy.password=this.value">
</form>

 

화면 UI의 입력 필드에 새로운 값을 입력하면 데이터 객체에도 변경된 값이 적용됩니다.

이런 방식으로 프록시를 사용해서 UI와 데이터 객체를 바인딩하게 됩니다.

원시적이고 간단한 코드지만 이런 방식으로 SPA(Single Page Application) 웹 페이지나, 프런트엔드 프레임워크를 만들게 됩니다.

 

 

 

 

캐싱과 데이터 업데이트 관리

 

데이터와 UI의 바인딩을 알아봤으므로 조금 더 고급 사용 방법을 알아보겠습니다.

서버 통신을 통해 클라이언트가 관리하는 데이터를 최신의 데이터로 유지하도록 하면서, 필요한 최소한의 데이터만 서버에 요청함으로써 데이터 트래픽을 줄이고 더 빠른 실행 속도를 유지하는 방법입니다.

 

일단위, 월단위로 한번 변경되는 데이터를 매번 서버에서 받아서 scores 객체에 업데이트를 하면 지속적인 트래픽이 발생하고 부하를 주게 됩니다. 데이터 크기가 크거나 업데이트가 빈번하다면 꽤 큰 부담을 주게 됩니다.

 

그리고 lastupdates 객체에 마지막 업데이트한 날짜만 기록해 놓은 후 정보 요청이 있으면 날짜 비교를 해서 날짜가 기준 날짜가 지났으면 서버에서 데이터를 가져와서 업데이트를 합니다.

 

let scores = {'라이언':[82,56,90], '어피치':[100,100,100]}
let lastupdates = {'라이언':'2023-01-01','어피치':'2023-02-01'}

let today = new Date()
today = new Date(today.getFullYear(), today.getMonth(), today.getDate())

let proxy = new Proxy(lastupdates, {
    get(target, prop) {
        if (prop in target) {
            const lastupdate = new Date(target[prop]+' 00:00:00.000')
            if(lastupdate < today){
                fetch('./getscore', {
                    method: "POST", body: new URLSearchParams({name: `${target[prop]}`})
                }).then(response => {
                    response.text().then(ret=> {
                        //scores 객체 업데이트
                    })
                }).catch(error => {
                    console.error('에러.')
                });
            }
        } else {
            return 'Error!';
        }
    } 
});

console.log(proxy['라이언'])

 

 

 

 

읽기 전용 뷰(View) 프록시

 

보안상 민감한 중요한 데이터들은 객체, 또는 데이터를 공동 작업을 하는 다른 작업자에게 노출하지 않는 것이 좋습니다.

모듈 단위로 나누어 개발을 하고 있을 경우, 다른 모듈에서 수정되면 안 되는 중요 정보를 임의로 수정하는 것을 막아야 합니다.

 

다음처럼 view 프록시만 노출해 주고 apiconfig 객체를 노출하지 않으면 데이터를 읽을 수만 있도록 막을 수 있습니다.

핸들러에서 get 트랩 메서드만 통과되어 객체의 get 메서드가 실행되고, 나머지 트랩 메서드는 "Readonly" 문자열이 반환되면서 타입 에러를 발생시킵니다.(문자열은 임의로 작성하면 됩니다.)

그렇게 아름다운 구현 방법은 아니지만 객체를 감출 필요가 있을 때 이렇게 프록시로 읽기 전용 뷰만 제공할 수도 있습니다.

 

const apiconfig = {
    name: 'API서버',
    server: {
        ip: '192.168.0.3',
        port: '8081',
        userid: 'apost',
        password: 'dpdlvldkdl',
        authkey: 'QK#ETRA1*AA',
    }
}

const handler = {
    set: 'Readonly',
    defineProperty: 'Readonly',
    deleteProperty: 'Readonly',
    preventExtensions: 'Readonly',
    setPrototypeOf: 'Readonly'
}

let view = new Proxy(apiconfig, handler);
console.log(view.name)
view.name = '테스트'

 

 

다만, 자바스크립트에서 freeze()라는 더 좋은 데이터 보안 수단을 제공하고 있기 때문에 프록시보다는 freeze(), seal() 메서드를 사용하는 것을 더 추천합니다.

 

> 객체의 불변성을 유지하는 방법(freeze, seal, preventExtensions의 차이)

 

 

 

리플렉트(Reflect)

한글로는 반사가 맞을 텐데, 대부분 리플렉트라고 합니다. 프록시를 조금 더 간단하게 생성/사용할 수 있도록 제공되는 내장 객체입니다.

 

리플렉트는 2가지 방법으로 사용할 수 있습니다.

 

첫 번째. 프록시 밖에서 단독으로 사용할 수 있습니다. 타겟 객체의 메서드와 이름이 같은 리플렉트 메서드가 모두 구현되어 있기 때문에 직접 호출할 수 없는 객체의 메서드를 직접 호출하는 것 과 같은 효과를 냅니다.

 

두 번째. 프록시가 가로챈 트랩 메서드를 실행한 후 타겟 객체의 메서드를 실행하고 싶을 때 리플렉트를 사용할 수 있습니다. 리플렉트는 반사, 즉 프록시로 넘어온 실행 제어를 처리한 후, 다시 타겟 객체로 제어를 반사시켜 돌려보내는 기능을 합니다. 

 

프록시 밖에서 사용하는 방법은 간단합니다.

리플렉트에 get/set 메서드가 구현되어 있기 때문에 타겟 객체의 속성에 메서드로 직접 접근할 수 있습니다.

 

const apiconfig = {
    name: 'API서버',
    server: {
        ip: '192.168.0.3',
        port: '8081',
        userid: 'apost',
        password: 'dpdlvldkdl',
        authkey: 'QK#ETRA1*AA',
    }
}
Reflect.get(apiconfig, server.ip)

 

프록시 안에서 사용할 때는 다음과 같이 사용합니다.

참고로 리플렉트로 트랩의 인자를 넘길 때는 트랩이 받은 인자를 그대로 리플렉트로 넘기기 때문에 펼침 연산자를 사용해 ...arguments 로 인자들을 대체할 수 있습니다.

 

let target = {name: '라이언', age: 5, gender:'male'};
let character = new Proxy(target, {
  get(target, prop, receiver) {
    return Reflect.get(target, prop, receiver);
  },
  set(target, prop, val, receiver) {
    return Reflect.set(...arguments);
  }
});

 

 

 

 

리시버(receiver) 파라미터

리시버는 프록시의 get/set 트랩 메서드의 마지막 파라미터입니다.

리시버는 get/set 트랩이 호출될 때의 객체 자신, 즉 this를 담고 있습니다. 프록시 트랩 메서드를 정의할 때 target과 receiver 객체가 같으면 receiver 파라미터는 필요가 없기 때문에 자주 생략합니다.

 

다음처럼 생성한 프록시를 추가의 다른 객체에 적용할 때 target 객체는 처음 프록시를 생성할 때 바인딩한 객체를 가리키고 있기 때문에 target 객체로 속성에 접근하면 처음 바인딩한 객체의 속성에 접근하게 됩니다.

다음처럼 추가로 생성한 객체에 이미 생성한 프록시를 바인딩하면 처음 프록시에 바인딩한 타겟 객체의 값만 출력됩니다.

 

let ryon = {_name: '라이언', get name(){return this._name}};
let proxy = new Proxy(ryon, {
  get(target, prop, receiver) {
    return target[prop]; // target은 프록시 객체를 생성할 때 인자로 넘긴 타겟 객체를 가리킴
  }
});
console.log(ryon.name) // 라이언
console.log(proxy.name) // 라이언
let apeach = {
    __proto__: proxy, //프록시 트랩을 가져옴
    _name: '어피치'
}
console.log(apeach.name) // 라이언

 

앞서 리플렉트를 설명할 때 리플렉트의 마지막 인자로 receiver를 넘기는 것은 프록시를 호출한 객체의 포인터(this)를 함께 넘겨서 프록시를 호출하는 실제 객체를 정확히 가리킬 수 있도록 하기 위해서입니다.

앞서의 문제는 다음처럼 리플렉트를 사용해서 프록시 게터를 호출한 객체를 가리키는 receiver 파라미터를 넘겨서 호출한 객체의 속성에 접근할 수 있도록 해야 합니다.

 

!중요합니다.

프록시 게터에 마지막 파라메터로 receiver 를 명시해야 하고, 리플렉트의 마지막 파라메터로 receiver를 반드시 넘겨야 합니다. 리시버(receiver)를 넘기지 않으면 target 이 가리키는 객체의 속성에 접근하게 됩니다.

 

let ryon = {_name: '라이언', get name(){return this._name}};
let proxy = new Proxy(ryon, {
  get(target, prop, receiver) {
    return Reflect.get(target, prop, receiver);//리플렉트에 receiver 객체를 반드시 파라메터로 넘겨야 함
  }
});
console.log(ryon.name)
console.log(proxy.name)
let apeach = {
    __proto__: proxy,
    _name: '어피치'
}
console.log(apeach.name) // 어피치

 

 

 

 

 

프록시 속도와 사용 제한

 

프록시는 객체를 감싸서 추가적인 기능을 구현하고, 데이터를 바인딩하기 때문에 필연적으로 속도가 느려집니다. 속도가 중요한 로직에서는 추천하지 않으며, 여러 객체와 UI를 연동해서 관리하는 복잡한 과정을 매끄럽고 단순하게 만들어주는데 적합한 기능입니다.

 

또한 Map, Set, Promise와 같은 객체들은 내부 슬롯(Internal slot) - 객체 내부에 정의된 전용의 속성과 메서드 - 이 존재하기 때문에 직접 프록시를 사용할 수 없습니다. 간접적인 방법으로 우회를 하는 방법이 있으나 권장하지 않으며, 이들 객체는 프록시를 사용하지 않는 것이 좋습니다.