본문 바로가기
👩‍💻 Programming/JavaScript

객체에 대한 액세스를 제어하자_2. 프록시(proxies) from Secrets of the JavaScript Ninja

by codingBear 2022. 4. 19.
728x90
반응형

이번 글은 'Secrets of the JavaScript Ninja'의 'Chapter 8. Controlling access to objects'를 바탕으로 작성하였습니다.


핵심 Concepts

  • 객체 프로퍼티에 대한 접근을 제어하는 게터와 세터의 활용법
  • 프록시(proxies)를 활용하여 객체에 대한 접근 제어하는 법
  • 횡단 관심사(cross-cutting concern)를 위한 프록시 활용법

사전 지식 체크!

  • 게터와 세터를 통해 프로퍼티의 값에 접근했을 때의 이점은 무엇인가
  • 프록시와 게터와 세터의 주된 차이점은 무엇인가
  • 프록시 트랩(proxy trap)은 무엇인가? 트랩의 세 가지 유형을 대보자

Using proxies to control access

 프록시는 다른 객체에 대한 액세스를 대신해서 제어해준다. 객체가 상호작용될 떄 실행될 임의의 행동을 정의 가능하게 해준다. 예를 들어 프로퍼티 값이 읽히거나 설정될 때, 혹은 메서드가 호출될 때와 같은 경우이다. 프록시를 게터와 세터를 일반화한 것으로 생각할 수 있다. 하지만 각 게터와 세터로는 단 하나의 객체 프로퍼티에 대한 접근만을 제어하는 반면 프록시는 일반적으로 객체와의 모든 상호작용을 메서드 호출을 포함하여 다룰 수 있게 해준다.

 프록시는 로그 기록, 데이터 검증, 프로퍼티 값 산출 등과 같은 전통적으로 게터와 세터를 사용할 때 쓸 수 있다. 허나 프록시는 보다 강력하다. 이를 통해 코드에 프로파일링 및 성능 측정 기능을 쉽게 추가할 수 있고 성가신 null 예외를 피하기 위해 객체 프로퍼티 자동 완성을 할 수 있고, 또한 브라우저 간 비호환성을 줄이기 위해 DOM과 같은 호스트 객체를 래핑(wrapping)할 수도 있다.

 JavaScript에서 Proxy 생성자를 사용하면 간단히 프록시를 만들 수 있다.

 

 

  우선 기본이 되는 emperor 객체를 생성하고 name 프로퍼티만을 부여한다. 그 다음 내부 Proxy 생성자를 사용하여 목표(target) 객체로 삼을 emperor 객체를 representative라는 프록시 객체로 감싼다. 프록시 생성 과정에서 두 번째 인수로 trap을 정의하는 객체를 보낸다. trap은 특정한 작업이 객체에서 수행될 때 호출되는 함수이다.

 이 예제에서는 두 가지 트랩을 정의했다. get 트랩은 프록시를 통해 프로퍼티의 값을 읽으려고 할 때마다 호출되며, set 트랩은 프록시를 통해 프로퍼티 값을 설정하려고 할 때마다 호출된다. get 트랩은 다음과 같은 작업을 수행한다. 목표 객체가 프로퍼티를 가졌을 경우 해당 프로퍼티를 반환하고 프로퍼티가 없다면 미리 지정한 메시지를 반환한다.

 그 다음으로 목표 emperor 객체 혹은 프록시 객체를 통해서 name 프로퍼티에 액세스 가능한지 테스트한다.

 emperor 객체로 name 프로퍼티에 직접 액세스 가능하다면 값 "Komei"가 반환된다. 허나 프록시 객체를 통해 액세스 했을 경우, get 트랩이 묵시적으로 호출된다. name 프로퍼티는 목표 emperor 객체 안에서 탐색되므로 역시 값 "Komei"는 호출된다.

 

프록시 트랩이 게터와 세터와 같은 방식으로 작동한다는 점이 중요하다. 프록시의 프로퍼티 값을 액세싱하는 것과 같은 작업을 수행하자마자 일치하는 트랩이 묵시적으로 호출되고 JavaScript 엔진은 명시적으로 함수를 호출했을 때와 비슷한 처리 과정을 수행한다.

 

 반면 목표 emperor 객체에 존재하지 않는 nickname 프로퍼티에 직접 액세스하려고 하면 undefined 값을 받는다. 하지만 프록시 객체를 통해 액세스한다면 get 핸들러가 활성화된다. 목표 emperor 객체는 nickname 프로퍼티를 가지지 않기 때문에 프록시의 get 트랩이 "Don't bother the emperor"라는 메시지를 반환하는 것이다.

 프록시 객체를 통해 새로운 프로퍼티 representative.nickname = "Tenno" 를 할당함으로써 예제 코드를 계속 실행해보자. 할당은 프록시를 통해 간접적으로 이루어지기 때문에 set 트랩이 활성화되어 메시지를 남기고 목표 emperor 객체에 프로퍼티를 할당한다.

 자연스럽게 새롭게 생성된 프로퍼티는 프록시 객체 혹은 목표 객체 어느 것을 통하여서든 액세스될 수 있다.

 이것이 바로 프록시 상용법의 요지이다. Proxy 생성자를 통해 프록시에서 직접 작업이 수행될 때마다 특정 트랩을 활성화하여 목표 객체에 대한 액세스를 제어하는 프록시 객체를 생성한다. 

 위 예제에서는 get과 set 트랩을 사용했는데 이 외에도 JavaScript에는 다양한 객체 액션을 위한 핸들러를 정의하는 데 쓰이는 내장 트랩이 많다. 몇 가지 예를 살펴보자.

 

  • apply 트랩: 함수를 호출할 때 활성화된다.
  • constructor 트랩: 새로운 연산자를 사용할 때 활성화된다.
  • get과 set 트랩: 프로퍼티를 읽거나 쓸 때 활성화된다.
  • enumerate 트랩: for-in 구문에서 활성화된다.
  • getPrototypeOf와 setPrototypeOf: 프로토타입 값을 얻거나 설정할 때 활성화된다.

 

 여기서 간과할 수 없는 몇 가지 작업에 주의를 기울여보자. 동치(equality, == or ===), instanceof, typeof 연산자이다.

 예를들어 표현식 x == y (혹은 x === y)는 x와 y가 똑같은 객체 혹은 값을 참조하는지 확인할 때 쓰인다. 이 같은 동치 연산자에 대해서는 몇 가지 의문이 있다. 예를 들어 두 객체를 비교하는 것은 항상 같은 두 객체에 대한 같은 값을 반환해야 한다. 허나 해당 값이 사용자 지정 함수에 의해 결정되는 경우 보장할 수 있는 사항이 아니다. 또한 두 객체를 비교하는 작업은 해당 객체 중 하나에 대한 액세스 권한을 부여해서는 안 된다. 이는 동치성이 트랩에 걸릴 수도 있는 경우일지 모른다. 비슷한 까닭으로 instanceof와 typeof 연산자는 트랩에 걸릴 수 없다.

 이제는 프록시의 실질적인 사용법에 대해 알아보자.


Using proxies for logging

 코드가 어떻게 작동하는지 확인할 때 혹은 버그의 원인을 찾고자 할 때 쓰이는 가장 유용한 수단은 바로 특정 순간에 유용하다고 생각되는 정보를 출력하는 행위인 로깅(logging)이다. 예를 들어 어느 함수가 호출되고 얼마 동안 실행되었으며 어느 프로퍼티가 읽히고 쓰이는지 알아내는 경우에 쓰이는 것이다. 

 앞서 봤던 예제를 통해 프록시에 대해 자세히 알아보자.

 

 

 우선 skillLevel 프로퍼티에 게터와 세터를 추가하는 Ninja 생성자 함수를 정의한다. 이 프로퍼티는 해당 프로퍼티에 대한 읽기와 쓰기가 일어날 때마다 조회 내역을 기록한다. 

 이는 이상적인 해결책이 아니다. 로깅 코드를 사용하여 객체 프로퍼티를 읽고 쓰는 작업을 처리하는 도메인 코드를 지저분하게 만들었기 때문이다. 

 만일 가까운 시일 내 ninja 객체에 더 많은 프로퍼티가 필요할 경우 각 새로운 프로퍼티에 추가적인 로깅 문구를 잊지 말고 추가해줘야 한다.

 하지만 프록시를 사용하면 프로퍼티를 읽고 쓸 때마다 로깅하는 코드를 간결히 작성 가능하다.

 

 

 여기서 target 객체를 받고 get과 set 트랩 핸들러를 가지는 새로운 Proxy를 반환하는 makeLoggable 함수를 정의한다. 이 트랩들은 프로퍼티에 대한 읽기와 쓰기 작업 외에도 프로퍼티가 읽히고 쓰이는지에 대한 정보를 기록한다. 

 그 다음 name 프로퍼티를 가지는 ninja 객체를 생성하고 새롭게 생성된 프록시에 대한 대상(target)으로 쓰일 makeLoggable 함수를 전달한다. 그러고 나서 ninja 식별자에 프록시를 재할당하여 덮어쓴다. 원래 있던 ninja 객체는 프록시의 대상 객체로서 계속 살아 있다.

 ninja.name과 같은 프로퍼티를 읽을 때마다 get 트랩이 호출되고 읽힌 프로퍼티에 대한 정보가 기록된다. ninja.weapon = "sword"로 프로퍼티에 값을 쓸 때 비슷한 일이 일어난다.

 기존의 게터와 세터를 사용한 방법에 비해 프록시를 쓰면 코드 작성이 매우 간단해진다는 것을 알 수 있다. 이로써 각 객체 프로퍼티마다 제가끔의 로깅을 추가할 필요가 없는 것이다. 대신 모든 프로퍼티는 프록시 객체 트랩 메서드를 통해 읽기와 쓰기 작업을 한다. 로깅은 한 곳에만 정의되어 필요한 만큼 재사용될 수 있다.


Using proxies for measuring performance

 프로퍼티에 대한 액세스를 기록하는 것 이외에 프록시는 함수 소스 코드를 수정하지 않고 함수 호출의 성능을 측정하는 데도 쓰인다. 다음 예제를 통해 숫자가 소수인지 계산하는 함수의 성능을 측정해보자.

 

 

 위 예제에서 간단한 isPrime 함수를 정의한다. 이제 isPrime 함수의 코드를 수정하는 일 없이 해당 함수의 성능을 측정할 필요가 있다고 상상해보자. 함수가 호출될 때마다 호출되는 트랩을 가진 프록시로 함수를 감쌀 수 있다.

 새롭게 생성된 프록시의 대상 객체로서 isPrime 함수를 쓰는 것이다. 또한 함수 호출 시 실행되는 apply 트랩을 가진 핸들러도 제공한다. 

 이전 예제와 비슷하게 isPrime 식별자에 새롭게 생성된 프록시를 할당한다. 이렇게 함으로써 우리가 측정하고자 하는 실행 시간을 가진 함수를 호출하는 코드의 어떤 부분도 건드리지 않는다. 나머지 프로그램 코드는 이러한 변화를 전혀 알아차리지 못한다.

 isPrime 함수가 호출될 때마다 해당 호출은 프록시의 apply 트랩으로 재할당되어 내장 메서드인 console.time으로써 스톱워치가 시작된다. 이후 원래 isPrime 함수를 호출하고, 지난 시간을 기록한 다음 isPrime 호출의 결과를 반환하는 것이다.


Using proxies to autopopulate properties

 로깅 작업을 단순화하는 것 외에도 프록시는 프로퍼티 자동 완성(autopopulating) 구현을 위해서도 쓰인다. 예를 들어 컴퓨터 폴더 구조를 설계한다고 가정해보자. 각 폴더 객체는 하나하나가 폴더인 프로퍼티를 갖는다. 이러한 과정을 코드로 나타내면 다음과 같다.

 

 

 기능을 구현하는 데 필요 이상으로 복잡해 보인다. 이때 프로퍼티 자동 완성 기능을 활용하는 것이다. 다음 예제를 살펴보자.

 

 

 다음 코드만을 보면 필시 예외가 발생하리라고 예상 가능하다.

 

 

 우선 rootFolder 객체의 아래에 있는 아직 정의되지 않은 프로퍼티인 ninjasDir 객체의 아래에 있는 firstNinjaDir에 액세스한다. 프록시를 활용하여 코드를 구현했기 때문에 모든 작업은 성공적으로 수행된다.

 프로퍼티에 접근할 때마다 프록시 get 트랩은 활성화된다. 폴더 객체가 이미 요청된 객체를 포함하고 있다면 객체의 값이 반환되고 포함하고 있지 않다면 새로운 폴더가 만들어지고 프로퍼티가 할당된다. 이러한 과정을 거쳐 ninjasDir와 firstNinjaDir 프로퍼티가 완성된다. 초기값이 설정되지 않은 프로퍼티의 값을 요청하는 행위는 해당 프로퍼티를 생성하게 된다.


Using proxies to implement negative array indexes

  Python, Ruby, Perl 같은 언어에서는 다음과 같이 배열 요소에 음수 인덱스(negative indexes)를 활용하여 접근 가능하다. 

 

 

  배열의 마지막 값에 접근하기 위한 일반적인 작성 방식인 ninjas[ninjas.length -1]과 비교하여 ninjas[-1]은 매우 간단함을 알 수 있다.

 하지만 JavaScript는 기본적으로 음수 배열 인덱스를 제공하지 않지만 프록시를 활용하여 이를 따라 만들 수 있다. 예제 코드를 살펴보자.

 

 

 우선 배열을 전달 받는 프록시를 생성하는 함수부터 정의한다. 프록시는 다른 형식의 객체를 받아서는 안 되기 때문에 인수가 배열이 아닐 시 예외가 발생하도록 코드를 짠다. 그리고 배열 요소를 읽을 때마다 활성화하는 get 트랩 및 배열 요소에 새 값을 쓸 때마다 활성화하는 set 트랩을 가지는 새로운 프록시를 생성하여 반환한다. 트랩 본문은 둘 다 유사하다. 우선 단항 더하기 연산자(index = +index)를 활용하여 프로퍼티를 숫자로 변환한다. 요청된 인덱스가 0보다 작으면 배열의 길이에 전달된 인덱스를 더하여 배열 요소를 뒤에서부터 액세스한다.(인수에 전달한 인덱스가 -1일 경우 배열 길이 3을 더하여 최종적으로 배열 인덱스 2가 되어 ninjas[2]의 값을 반환한다.) 만약 요청된 인덱스가 0과 같거나 클 경우에는 일반적인 방식으로 배열 요소에 액세스한다.

 이렇듯 프록시를 활용하면 음수 인덱스를 통해 배열 요소를 수정하거나 요소에 액세스 가능하다.

 다음으로는 프록시의 가장 큰 단점인 성능 문제에 대해 알아보겠다.


Performance costs of proxies

 앞서 살펴봤듯이 프록시를 활용하여 다양한 작업을 수행할 수 있다. 모든 작업이 프록시를 통해 전달되어야 한다는 사실은 이러한 기능들을 구현할 수 있도록 하는 간접 계층을 비롯해 성능에 영향을 미치는 상당한 양의 추가 작업을 초래한다. 

 아래 코드를 통해 일반적인 방식으로 배열 요소에 접근했을 때와 프록시를 활용하여 접근했을 때의 실행 시간을 비교해보자.

 

 

 코드 실행은 매우 순식간에 이루어지기 때문에 실행 시간을 측정하기 위해서는 여러 번 실행시켜야 한다. 여기서는 50만 번으로 설정했다.

 또한 실행 시간을 비교하기 위한 new Date().getTime()을 하나는 대상 코드 앞에, 또 하나는 뒤에 설정한다. 이 시간 차이를 통해 코드가 실행되는 데 얼마나 걸리는지 알아낼 수 있다. 마지막으로 measure 함수를 통해 일반적인 배열과 프록시를 활용한 배열을 만들어 실행 시간을 서로 비교한다. 결과값을 보면 프록시를 활용한 배열을 실행하는 데 더 많은 시간이 소요되었음을 알 수 있다.

 이를 통해 보듯 프록시는 주의하여 사용해야 한다. 프록시는 객체에 대한 액세스를 제어하는 데 유용하게 쓰이지만 작업양이 많아질수록 성능이 저하된다. 따라서 실행이 많이 이렁나는 코드에서는 되도록 사용하지 않는 편이 좋다.


요약 정리

  • 게터, 세터, 프록시를 통해 객체를 관찰 가능
  • 게터와 세터 같은 액세서 메서드를 활용하여 객체 프로퍼티에 대한 액세스를 제어 가능
    • 액세서 프로퍼티는 내장 메서드 Object.defineProperty나 객체 리터럴 혹은 ES6 클래스의 한 부분인 get/set 문법으로도 정의 가능
    • get 메서드는 읽기를 시도할 때 묵시적으로 호출되고 set 메서드는 일치하는 객체의 프로퍼티에 값을 할당할 때 호출된다.
    • 게터 메서드는 요청이 있을 때마다 값이 계산되는 산출된 프로퍼티에 쓰인다. 반면 세터 메서드는 데이터 검증 및 로깅 기능 구현에 쓰인다.
  • 프록시는 ES6에서 JavaScript에 추가된 요소로 다른 객체를 제어하는 데 활용
    • 프록시는 객체에서 상호작용이 일어날 때 실행되는 사용자 지정 작업을 정의하는 데 쓰인다.(프로퍼티가 읽히거나 함수가 호출되는 때 등)
    • 모든 상호작용은 특정 작업이 발생할 때 호출되는 트랩을 가진 프록시를 통해 일어나야 한다. 
  • 프록시는 다음과 같은 기능을 구현하는 데 유용하게 쓰인다.
    • 로깅
    • 성능 측정
    • 데어터 검증
    • 자동 완성 객체 프로퍼티
    • 음수 배열 인덱스
  • 프록시를 활용한 작업은 속도가 빠르지 않으므로 여러 번 실행되어야 하는 코드에서는 주의해서 사용해야 한다.

  • proxy

https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy

 

Proxy - JavaScript | MDN

The Proxy object enables you to create a proxy for another object, which can intercept and redefine fundamental operations for that object.

developer.mozilla.org

 

728x90
반응형

댓글