본문 바로가기

Proxy와 Reflect

Proxy 트랩으로 객체 조회·할당·호출을 가로채고 Reflect의 기본 연산에 위임해 메타 프로그래밍을 구현합니다.

8장에서는 let과 const, 화살표 함수, 구조 분해 할당과 스프레드, Symbol과 Map, Set까지 최신 ECMAScript의 핵심 기능을 익혔습니다. 마지막 주제인 Proxy와 Reflect는 조금 더 추상적이지만, 여러 프레임워크의 내부에서 쓰이는 중요한 기능입니다.

Proxy는 객체의 속성을 읽거나 쓰는 것 같은 기본 동작을 가로채(intercept) 원하는 방식으로 바꿀 수 있게 해 줍니다. Reflect는 그 기본 동작을 함수 형태로 제공해, Proxy가 가로챈 뒤 원래 동작을 정확하게 이어서 실행할 수 있게 돕습니다. 이렇게 코드가 언어의 기본 동작 자체를 다루는 것을 메타 프로그래밍(metaprogramming)이라고 부릅니다.


Proxy

Proxy 객체는 다른 객체, 즉 대상(target) 객체의 대리인 역할을 합니다. Proxy를 거쳐 속성을 읽거나 쓰면, 그 연산이 대상 객체에 바로 가지 않고 먼저 미리 정해 둔 트랩(trap) 함수로 갑니다. 함수 호출을 가로채는 트랩은 대상이 함수일 때, new를 가로채는 트랩은 대상이 생성자일 때만 동작합니다. 일반 객체를 Proxy로 감쌌다고 함수나 생성자가 되지는 않습니다.

Proxy 만들기

new Proxy(target, handler)로 만듭니다. target은 감쌀 대상 객체로 일반 객체, 배열, 함수 모두 될 수 있고, handler는 가로챌 동작마다 트랩 함수를 담은 객체입니다.

const targetObject = {
    message1: '안녕하세요',
    message2: '반갑습니다'
};

const handler = {
    // get 트랩: 속성을 읽으려 할 때 호출됨
    get: function(target, property, receiver) {
        console.log(`[Proxy] '${String(property)}' 속성을 읽으려 합니다.`);
        return target[property]; // 원래의 속성 값을 반환
    },
    // set 트랩: 속성에 값을 쓰려 할 때 호출됨
    set: function(target, property, value, receiver) {
        console.log(`[Proxy] '${String(property)}' 속성에 '${value}'를 쓰려 합니다.`);
        target[property] = value; // 원래의 할당 동작 수행
        return true; // 할당에 성공했음을 알림
    }
};

const proxyObject = new Proxy(targetObject, handler);

console.log(proxyObject.message1); // get 트랩 로그 뒤 "안녕하세요"
proxyObject.message1 = '안녕';     // set 트랩 로그
console.log(proxyObject.message1); // get 트랩 로그 뒤 "안녕"
console.log(proxyObject.message2); // get 트랩 로그 뒤 "반갑습니다"

proxyObject로 속성을 읽을 때마다 get 트랩이, 값을 쓸 때마다 set 트랩이 먼저 실행되어 로그를 남깁니다. 트랩 안에서 target[property]로 실제 대상을 읽고 쓰므로 결과는 대상 객체에 반영됩니다. set 트랩은 할당에 성공했는지를 불리언으로 돌려줘야 합니다. 이 예제는 트랩의 기본 모양을 보여 주기 위해 target[property]를 직접 썼지만, 뒤에서 볼 Reflect로 위임하는 편이 더 정확합니다.

주요 트랩

handler에는 다음과 같은 트랩을 정의할 수 있으며, 각 트랩은 정해진 객체 연산 하나를 가로챕니다.

트랩 메서드가로채는 동작예
get(target, property, receiver)속성 읽기proxy.foo, proxy['bar']
set(target, property, value, receiver)속성 쓰기proxy.foo = 1
has(target, property)in 연산자'foo' in proxy
deleteProperty(target, property)delete 연산자delete proxy.foo
apply(target, thisArg, argumentsList)함수 호출 (대상이 함수일 때)proxy(...args)
construct(target, argumentsList, newTarget)new 연산자 (대상이 생성자일 때)new proxy(...args)
ownKeys(target)자체 키 목록Object.keys(proxy)
getPrototypeOf(target)프로토타입 읽기Object.getPrototypeOf(proxy)
setPrototypeOf(target, prototype)프로토타입 바꾸기Object.setPrototypeOf(proxy, proto)
isExtensible(target)확장 가능 여부Object.isExtensible(proxy)
preventExtensions(target)확장 막기Object.preventExtensions(proxy)
defineProperty(target, property, descriptor)속성 정의Object.defineProperty(proxy, 'foo', descriptor)
getOwnPropertyDescriptor(target, property)속성 설명자 읽기Object.getOwnPropertyDescriptor(proxy, 'foo')

유효성을 검사하는 Proxy

set 트랩으로 값을 쓰기 전에 검사하면, 대상 객체의 코드를 고치지 않고도 잘못된 값이 들어가지 않게 할 수 있습니다. 다음 코드의 userValidator는 age와 name에 들어올 값만 검사하고, 통과한 값은 obj[prop] = value로 대상 객체에 직접 저장합니다. 두 속성이 아닌 다른 속성은 검사 없이 그대로 저장됩니다.

const userProfile = {
    name: "Unknown",
    age: 0
};

const userValidator = {
    set: function(obj, prop, value) {
        if (prop === 'age') {
            if (!Number.isFinite(value) || value < 0 || value > 150) {
                console.error("나이는 0에서 150 사이의 숫자여야 합니다.");
                return false; // 할당 실패
            }
        } else if (prop === 'name') {
            if (typeof value !== 'string' || value.length < 2) {
                console.error("이름은 최소 2글자 이상이어야 합니다.");
                return false; // 할당 실패
            }
        }
        obj[prop] = value; // 검사를 통과하면 실제로 할당
        return true;
    }
};

const proxyUser = new Proxy(userProfile, userValidator);

proxyUser.name = "John Doe"; // 검사 통과
proxyUser.age = 30;          // 검사 통과
console.log(proxyUser.name, proxyUser.age); // John Doe 30

proxyUser.age = -5;   // 나이는 0에서 150 사이의 숫자여야 합니다.
proxyUser.name = "A"; // 이름은 최소 2글자 이상이어야 합니다.

console.log(proxyUser.name, proxyUser.age); // 여전히 John Doe 30

Number.isFinite()는 숫자가 아닌 값뿐 아니라 NaN과 Infinity도 걸러 냅니다. 이 예제는 엄격 모드가 아닌 classic script를 가정합니다. 엄격 모드나 ES 모듈에서는 set 트랩이 false를 돌려준 할당문이 TypeError를 던지므로, 오류를 잡지 않으면 그 뒤의 줄이 실행되지 않습니다.

Proxy를 거친 접근만 트랩을 지나간다는 점도 기억해야 합니다. 원본 userProfile에 직접 userProfile.age = -5를 쓰면 트랩을 거치지 않아 그대로 저장됩니다. Vue 3의 반응성(reactivity) 시스템이나 MobX 같은 상태 관리 라이브러리는 이런 가로채기로 데이터가 바뀌는 것을 감지합니다. 다만 화면을 다시 그리는 일은 라이브러리가 따로 하는 것이며, Proxy 자체가 화면을 갱신하지는 않습니다.

더 깊이: Map이나 비공개 필드를 가진 객체를 감쌀 때

Proxy는 대상 객체와 다른 정체성을 가진 별개의 객체라서, 대상만 가진 내부 상태에는 닿지 못합니다. 예를 들어 Map은 키와 값의 쌍을 [[MapData]]라는 내부 슬롯(internal slot)에 담는데, 이 슬롯은 자바스크립트 코드로 읽을 수 없고 프록시에는 없습니다. 그래서 트랩이 없는 new Proxy(new Map(), {})에서 proxy.size를 읽기만 해도 TypeError가 납니다. size의 getter가 this로 받은 프록시에서 [[MapData]]를 찾지 못하기 때문입니다.

클래스의 비공개 필드도 같은 이유로 실패합니다. #secret 필드를 읽는 메서드를 프록시를 거쳐 호출하면, 메서드 안의 this가 원래 인스턴스가 아니라 프록시가 되어 TypeError: Cannot read private member #secret from an object whose class did not declare it가 납니다. Set의 [[SetData]], Date의 [[DateValue]]처럼 내부 슬롯에 값을 두는 다른 내장 객체도 같은 문제를 겪습니다.

해결하려면 get 트랩에서 메서드를 꺼낼 때, 그 메서드를 프록시가 아니라 원래 대상을 this로 호출하는 함수로 감싸 돌려줍니다. 다만 그러면 메서드 안의 접근은 더 이상 트랩을 지나가지 않으므로, 앞에서 본 검사나 기록도 그 부분에서는 빠집니다. 예제와 설명은 MDN의 Proxy 설명의 비공개 필드 전달 항목에 있습니다.


Reflect

Reflect는 ES2015에 추가된 내장 객체로, 객체의 기본 연산을 메서드로 제공합니다. Object.defineProperty() 같은 Object의 메서드와 비슷하지만, 속성 읽기나 new 연산처럼 연산자로만 하던 일까지 함수 호출로 할 수 있게 해 줍니다. Reflect의 메서드는 Proxy의 트랩과 이름과 인수가 하나씩 대응하므로, 트랩 안에서 원래의 기본 동작을 실행할 때 잘 맞습니다.

대표적인 메서드는 Reflect.get(target, property, receiver), Reflect.set(target, property, value, receiver), Reflect.has(target, property), Reflect.deleteProperty(target, property), Reflect.apply(target, thisArgument, argumentsList), Reflect.construct(target, argumentsList, newTarget)이며, 나머지 트랩에 대응하는 메서드도 있습니다.

트랩 안에서 Reflect로 위임하기

트랩 안에서 Reflect를 쓰면 기본 동작을 그대로 실행하면서, receiver 인수로 getter와 setter의 this까지 정확하게 맞출 수 있습니다. 다음 코드의 user에는 일반 속성 두 개와, 두 속성을 이어 붙여 돌려주는 getter fullName이 있습니다. 트랩은 접근할 때마다 로그를 남기고, 실제 읽기와 쓰기는 Reflect.get()과 Reflect.set()에 맡깁니다.

const user = {
    firstName: "Jane",
    lastName: "Doe",
    get fullName() {
        return `${this.firstName} ${this.lastName}`;
    }
};

const loggingHandler = {
    get(target, prop, receiver) {
        console.log(`[Reflect] '${String(prop)}' 프로퍼티에 접근했습니다.`);
        // Reflect.get을 사용하여 원래의 getter를 정확하게 호출
        return Reflect.get(target, prop, receiver);
    },
    set(target, prop, value, receiver) {
        console.log(`[Reflect] '${String(prop)}' 프로퍼티를 '${value}'로 설정했습니다.`);
        // Reflect.set을 사용하여 원래의 setter를 정확하게 호출
        return Reflect.set(target, prop, value, receiver);
    }
};

const proxiedUser = new Proxy(user, loggingHandler);

console.log(proxiedUser.firstName); // get 트랩, Reflect.get 호출
proxiedUser.lastName = "Smith";     // set 트랩, Reflect.set 호출
console.log(proxiedUser.fullName);  // get 트랩 (fullName getter가 this.firstName/lastName을 참조하므로 다시 get 트랩 호출)

proxiedUser.firstName은 get 트랩의 로그를 남긴 뒤 Jane을 출력하고, lastName을 바꿀 때는 set 트랩의 로그가 남습니다. 마지막 fullName 읽기에서는 로그가 세 번 찍힙니다. 먼저 fullName을 읽는 get 트랩이 실행되고, Reflect.get()이 getter를 receiver인 프록시를 this로 실행합니다. 그래서 getter 안의 this.firstName과 this.lastName도 프록시를 거쳐 두 번 더 트랩을 지나가고, 마지막에 Jane Smith가 출력됩니다.

트랩에서 Reflect.get() 대신 target[prop]으로 읽었다면, getter의 this는 프록시가 아니라 대상 객체가 됩니다. 그러면 getter 안의 읽기는 트랩을 거치지 않아, 값은 같아도 어떤 속성을 읽었는지 기록하거나 추적할 수 없습니다. 반대로 트랩 안에서 receiver[prop]이나 Reflect.get(receiver, prop)처럼 프록시를 다시 읽으면 같은 트랩에 다시 들어가 끝없이 반복하다 호출 스택이 넘쳐 오류가 납니다. 이 오류는 Chrome과 Safari에서는 RangeError, Firefox에서는 InternalError입니다. 기본 동작을 위임할 때 첫 인수는 항상 target이어야 합니다.

실패를 불리언으로 알려 주는 Reflect

Object의 일부 메서드는 실패하면 오류를 던지지만, 같은 일을 하는 Reflect의 메서드는 false를 돌려줍니다. 그래서 실패를 try...catch 대신 if 문으로 처리할 수 있습니다. 다음 코드는 다시 정의할 수 없는 속성 prop을 두 방법으로 다시 정의해 보고 결과를 비교합니다.

const obj = {};

// Object.defineProperty는 실패 시 에러를 던짐
try {
    Object.defineProperty(obj, 'prop', { value: 1, writable: false });
    Object.defineProperty(obj, 'prop', { value: 2 }); // TypeError: Cannot redefine property: prop
} catch (e) {
    console.error(e.message);
}

// Reflect.defineProperty는 실패 시 false를 반환
const success1 = Reflect.defineProperty(obj, 'anotherProp', { value: 10 });
console.log(success1); // 결과: true

const success2 = Reflect.defineProperty(obj, 'prop', { value: 2 }); // 실패
console.log(success2); // 결과: false (에러를 던지지 않음)

writable과 configurable을 지정하지 않으면 false로 정의되므로, prop은 다시 정의할 수 없는 속성이 됩니다. Object.defineProperty()로 다시 정의하면 TypeError가 나지만, Reflect.defineProperty()는 오류 없이 false를 돌려줍니다. 다만 모든 Reflect 메서드가 불리언을 돌려주는 것은 아니며, Reflect.get()은 읽은 값을 돌려주고 대상이 객체가 아니면 TypeError를 던집니다.


트랩을 지나는 길

Proxy trap과 Reflect 기본 경로 — 상태 추적

한 Proxy 쓰기 요청의 set 경로만 추적합니다. set trap이 있으면 handler가 검사·위임하고 없으면 기본 target 동작을 따르며 성공 Boolean과 invariant·strict mode 경계를 확인합니다.

Proxy trap과 Reflect 기본 경로

SEQUENCE · PROXY INTERNAL METHOD

Proxy trap과 Reflect 기본 경로

get·set 연산에서 trap의 유무로 경로가 갈린다. 이 예제는 trap에서 Reflect로 기본 연산을 위임하며, Proxy는 결과가 대상의 불변 조건을 지키는지 확인한다.

get·set의 대안 경로와 원본 우회요청 뒤 trap 없음 경로는 target 기본 연산으로 직접 위임합니다. trap 있음 경로는 정책을 실행한 뒤 이 예제에서는 Reflect로 위임합니다. 두 영역은 대안이며 연속 단계가 아닙니다. Proxy는 trap 결과에 대한 대상의 불변 조건을 검사하고 위반하면 TypeError를 던집니다. 모바일은 handler와 target을 대상측 역할로 묶고 내부의 Reflect와 기본 연산을 자기 호출로 표시합니다. Reflect 호출은 선택적이며 원본 직접 접근은 Proxy와 trap을 우회합니다. get의 값과 set의 성공 불리언은 할당식의 값·엄격 할당 실패 오류와 구별합니다. 호출자Proxy연산·조건 검사Handlerget / set trapTarget기본 연산읽기 / 쓰기 요청대안 1: trap 없음target 기본 연산값 / 성공 불리언결과 반환대안 2: trap 있음trap 정책 실행예제: Reflect 위임값 / 성공 불리언trap 결과불변 조건 검사 뒤 결과원본 target 직접 접근: Proxy·trap 우회실패 불리언 · 엄격 할당 오류 · 불변 조건 위반을 구별
get·set의 대안 경로와 원본 우회요청 뒤 trap 없음 경로는 target 기본 연산으로 직접 위임합니다. trap 있음 경로는 정책을 실행한 뒤 이 예제에서는 Reflect로 위임합니다. 두 영역은 대안이며 연속 단계가 아닙니다. Proxy는 trap 결과에 대한 대상의 불변 조건을 검사하고 위반하면 TypeError를 던집니다. 모바일은 handler와 target을 대상측 역할로 묶고 내부의 Reflect와 기본 연산을 자기 호출로 표시합니다. Reflect 호출은 선택적이며 원본 직접 접근은 Proxy와 trap을 우회합니다. get의 값과 set의 성공 불리언은 할당식의 값·엄격 할당 실패 오류와 구별합니다.호출자Proxy대상측두 역할읽기 / 쓰기 요청대안 1: trap 없음target 기본 연산값 / 성공 불리언결과 반환대안 2: trap 있음trap 정책 실행예제: Reflect 위임target 기본 연산trap 결과불변 조건 검사위반이면 TypeError대상측 두 역할 병합원본 직접 접근Proxy·trap 우회할당식 값 ≠ 성공 불리언

trap 없음

Proxy 내부 메서드가 target의 대응 내부 연산에 직접 위임하고 그 결과를 caller에게 돌려준다.

trap 있음

이 예제의 handler는 정책을 적용한 뒤 Reflect.get/set(target, …, receiver)로 기본 연산을 위임한다. Reflect 호출 자체는 trap의 필수 요건이 아니다.

결과와 invariant

Reflect.set의 false는 내부 연산의 실패 결과다. 엄격 모드 할당문에서 set trap이 false를 반환하면 TypeError가 발생한다. trap 결과가 non-configurable 등 target invariant를 어기면 Proxy가 TypeError를 던진다.

target reference 누출

원본 target을 직접 읽거나 쓰면 Proxy와 모든 trap을 우회한다. 접근 제어를 맡겼다면 원본 참조를 외부에 노출하지 않는다.

그림의 value·boolean은 내부 get·set 연산 결과다. 할당식은 대입 값을 결과로 내며, 엄격 모드에서 내부 set 실패는 TypeError가 된다. 기본 위임·선택적인 Reflect 호출·invariant 검증은 서로 다른 단계다. false 결과와 invariant 위반의 TypeError도 구분한다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
  • 이 단계는 한 set 연산의 요청·처리·결과입니다. 원도표 get은 독립적인 읽기 연산이며 set의 선행 단계가 아닙니다. Reflect.set 위임은 이를 선택한 handler 경로에만 적용됩니다.
관계도의 보충 설명
원도표의 전체 관계·조건·설명
구분내용
전체 관계·조건 설명요청 뒤 trap 없음 경로는 target 기본 연산으로 직접 위임합니다. trap 있음 경로는 정책을 실행한 뒤 이 예제에서는 Reflect로 위임합니다. 두 영역은 대안이며 연속 단계가 아닙니다. Proxy는 trap 결과에 대한 대상의 불변 조건을 검사하고 위반하면 TypeError를 던집니다. 모바일은 handler와 target을 대상 측 역할로 묶고 내부의 Reflect와 기본 연산을 자기 호출로 표시합니다. Reflect 호출은 선택적이며 원본 직접 접근은 Proxy와 trap을 우회합니다. get의 값과 set의 성공 불리언은 할당식의 값·엄격 할당 실패 오류와 구별합니다.
원도표 노드·간선·영역 라벨대상 측
원도표 노드·간선·영역 라벨대상 측 두 역할 병합
원도표 설명이 예제의 handler는 정책을 적용한 뒤 Reflect.get/set(target, …, receiver)로 기본 연산을 위임한다. Reflect 호출 자체는 trap의 필수 요건이 아니다.
원도표 설명Reflect.set의 false는 내부 연산의 실패 결과다. 엄격 모드 할당문에서 set trap이 false를 반환하면 TypeError가 발생한다. trap 결과가 non-configurable 등 target invariant를 어기면 Proxy가 TypeError를 던진다.
원도표 설명그림의 value·boolean은 내부 get·set 연산 결과다. 할당식은 대입 값을 결과로 내며, 엄격 모드에서 내부 set 실패는 TypeError가 된다. 기본 위임·선택적인 Reflect 호출·invariant 검증은 서로 다른 단계다. false 결과와 invariant 위반의 TypeError도 구분한다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
쓰기 요청 · set 경로요청: target에 대한 쓰기 / set 경로: trap 있으면 handler / 없으면 기본 동작Proxy 쓰기 요청에서 set trap 유무로 처리 경로를 선택한다호출자는 Proxy를 통하고 실제 target은 별도다
set 위임set 결과: 처리 성공 Boolean / target: 조건에 따라 변경set trap이 있는 경로는 검사 후 선택한 Reflect.set 위임 결과를 반환한다set 처리 성공 Boolean과 대입 표현식의 오른쪽 값은 별개다
결과 판정허용: 대입 동작 완료 / 거절: 조건에 따라 throwinvariant 및 strict mode 실패 조건을 판정한다trap이 임의로 target invariant를 무시할 수 없다

Proxy trap과 Reflect 기본 경로 — 상태 추적

한 Proxy 쓰기 요청의 set 경로만 추적합니다. set trap이 있으면 handler가 검사·위임하고 없으면 기본 target 동작을 따르며 성공 Boolean과 invariant·strict mode 경계를 확인합니다.

Proxy trap과 Reflect 기본 경로

SEQUENCE · PROXY INTERNAL METHOD

Proxy trap과 Reflect 기본 경로

get·set 연산에서 trap의 유무로 경로가 갈린다. 이 예제는 trap에서 Reflect로 기본 연산을 위임하며, Proxy는 결과가 대상의 불변 조건을 지키는지 확인한다.

get·set의 대안 경로와 원본 우회요청 뒤 trap 없음 경로는 target 기본 연산으로 직접 위임합니다. trap 있음 경로는 정책을 실행한 뒤 이 예제에서는 Reflect로 위임합니다. 두 영역은 대안이며 연속 단계가 아닙니다. Proxy는 trap 결과에 대한 대상의 불변 조건을 검사하고 위반하면 TypeError를 던집니다. 모바일은 handler와 target을 대상측 역할로 묶고 내부의 Reflect와 기본 연산을 자기 호출로 표시합니다. Reflect 호출은 선택적이며 원본 직접 접근은 Proxy와 trap을 우회합니다. get의 값과 set의 성공 불리언은 할당식의 값·엄격 할당 실패 오류와 구별합니다. 호출자Proxy연산·조건 검사Handlerget / set trapTarget기본 연산읽기 / 쓰기 요청대안 1: trap 없음target 기본 연산값 / 성공 불리언결과 반환대안 2: trap 있음trap 정책 실행예제: Reflect 위임값 / 성공 불리언trap 결과불변 조건 검사 뒤 결과원본 target 직접 접근: Proxy·trap 우회실패 불리언 · 엄격 할당 오류 · 불변 조건 위반을 구별
get·set의 대안 경로와 원본 우회요청 뒤 trap 없음 경로는 target 기본 연산으로 직접 위임합니다. trap 있음 경로는 정책을 실행한 뒤 이 예제에서는 Reflect로 위임합니다. 두 영역은 대안이며 연속 단계가 아닙니다. Proxy는 trap 결과에 대한 대상의 불변 조건을 검사하고 위반하면 TypeError를 던집니다. 모바일은 handler와 target을 대상측 역할로 묶고 내부의 Reflect와 기본 연산을 자기 호출로 표시합니다. Reflect 호출은 선택적이며 원본 직접 접근은 Proxy와 trap을 우회합니다. get의 값과 set의 성공 불리언은 할당식의 값·엄격 할당 실패 오류와 구별합니다.호출자Proxy대상측두 역할읽기 / 쓰기 요청대안 1: trap 없음target 기본 연산값 / 성공 불리언결과 반환대안 2: trap 있음trap 정책 실행예제: Reflect 위임target 기본 연산trap 결과불변 조건 검사위반이면 TypeError대상측 두 역할 병합원본 직접 접근Proxy·trap 우회할당식 값 ≠ 성공 불리언

trap 없음

Proxy 내부 메서드가 target의 대응 내부 연산에 직접 위임하고 그 결과를 caller에게 돌려준다.

trap 있음

이 예제의 handler는 정책을 적용한 뒤 Reflect.get/set(target, …, receiver)로 기본 연산을 위임한다. Reflect 호출 자체는 trap의 필수 요건이 아니다.

결과와 invariant

Reflect.set의 false는 내부 연산의 실패 결과다. 엄격 모드 할당문에서 set trap이 false를 반환하면 TypeError가 발생한다. trap 결과가 non-configurable 등 target invariant를 어기면 Proxy가 TypeError를 던진다.

target reference 누출

원본 target을 직접 읽거나 쓰면 Proxy와 모든 trap을 우회한다. 접근 제어를 맡겼다면 원본 참조를 외부에 노출하지 않는다.

그림의 value·boolean은 내부 get·set 연산 결과다. 할당식은 대입 값을 결과로 내며, 엄격 모드에서 내부 set 실패는 TypeError가 된다. 기본 위임·선택적인 Reflect 호출·invariant 검증은 서로 다른 단계다. false 결과와 invariant 위반의 TypeError도 구분한다.

이 예의 조건

  • 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
  • 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
  • 이 단계는 한 set 연산의 요청·처리·결과입니다. 원도표 get은 독립적인 읽기 연산이며 set의 선행 단계가 아닙니다. Reflect.set 위임은 이를 선택한 handler 경로에만 적용됩니다.

관계도의 보충 설명

원도표의 전체 관계·조건·설명
구분내용
전체 관계·조건 설명요청 뒤 trap 없음 경로는 target 기본 연산으로 직접 위임합니다. trap 있음 경로는 정책을 실행한 뒤 이 예제에서는 Reflect로 위임합니다. 두 영역은 대안이며 연속 단계가 아닙니다. Proxy는 trap 결과에 대한 대상의 불변 조건을 검사하고 위반하면 TypeError를 던집니다. 모바일은 handler와 target을 대상 측 역할로 묶고 내부의 Reflect와 기본 연산을 자기 호출로 표시합니다. Reflect 호출은 선택적이며 원본 직접 접근은 Proxy와 trap을 우회합니다. get의 값과 set의 성공 불리언은 할당식의 값·엄격 할당 실패 오류와 구별합니다.
원도표 노드·간선·영역 라벨대상 측
원도표 노드·간선·영역 라벨대상 측 두 역할 병합
원도표 설명이 예제의 handler는 정책을 적용한 뒤 Reflect.get/set(target, …, receiver)로 기본 연산을 위임한다. Reflect 호출 자체는 trap의 필수 요건이 아니다.
원도표 설명Reflect.set의 false는 내부 연산의 실패 결과다. 엄격 모드 할당문에서 set trap이 false를 반환하면 TypeError가 발생한다. trap 결과가 non-configurable 등 target invariant를 어기면 Proxy가 TypeError를 던진다.
원도표 설명그림의 value·boolean은 내부 get·set 연산 결과다. 할당식은 대입 값을 결과로 내며, 엄격 모드에서 내부 set 실패는 TypeError가 된다. 기본 위임·선택적인 Reflect 호출·invariant 검증은 서로 다른 단계다. false 결과와 invariant 위반의 TypeError도 구분한다.
전체 상태와 변화 근거
장면현재 관찰값설명변화 이유
쓰기 요청 · set 경로요청: target에 대한 쓰기 / set 경로: trap 있으면 handler / 없으면 기본 동작Proxy 쓰기 요청에서 set trap 유무로 처리 경로를 선택한다호출자는 Proxy를 통하고 실제 target은 별도다
set 위임set 결과: 처리 성공 Boolean / target: 조건에 따라 변경set trap이 있는 경로는 검사 후 선택한 Reflect.set 위임 결과를 반환한다set 처리 성공 Boolean과 대입 표현식의 오른쪽 값은 별개다
결과 판정허용: 대입 동작 완료 / 거절: 조건에 따라 throwinvariant 및 strict mode 실패 조건을 판정한다trap이 임의로 target invariant를 무시할 수 없다

위 그림은 호출자가 프록시에 읽기나 쓰기를 요청했을 때의 두 가지 경로를 보여 줍니다. 두 경로는 차례로 거치는 단계가 아니라, 트랩이 있는지에 따라 갈리는 대안입니다. 해당 트랩이 없으면 프록시는 요청을 대상 객체의 기본 연산에 그대로 넘기고, 그 결과인 값이나 성공 여부를 호출자에게 돌려줍니다.

트랩이 있으면 핸들러가 정책을 실행한 뒤, 이 예제처럼 Reflect로 기본 연산을 위임합니다. Reflect 호출이 꼭 필요한 것은 아니지만, 쓰면 기본 동작을 정확하게 이어 갈 수 있습니다. 프록시는 트랩이 돌려준 결과가 대상 객체의 불변 조건(invariant)을 지키는지 검사하고, 어기면 TypeError를 던집니다. 예를 들어 다시 정의할 수 없고 쓸 수도 없는 속성을 읽는 트랩은 실제 값과 다른 값을 돌려줄 수 없습니다.

그림 아래쪽은 두 가지를 구분합니다. 하나는 실패를 알리는 세 가지 결과입니다. Reflect.set()의 false는 내부 연산이 실패했다는 결과이고, 엄격 모드의 할당문에서 set 트랩이 false를 돌려주면 TypeError가 되며, 불변 조건을 어긴 트랩도 TypeError를 냅니다. 또 할당식 proxy.age = 42 자체의 값은 성공 여부가 아니라 대입한 값 42입니다. 다른 하나는 원본 대상에 직접 접근하면 프록시와 모든 트랩을 건너뛴다는 점입니다. 접근을 제어하려고 프록시를 썼다면 원본 객체의 참조를 바깥에 내놓지 않아야 합니다.

검증과 저장 성공을 함께 확인하기

앞의 나이 검사를 정수만 허용하도록 바꾸고, 할당문 대신 Reflect.set()으로 결과를 불리언으로 받아 봅니다. 실패해도 오류 없이 다음 줄로 넘어가므로 결과를 차례로 비교할 수 있습니다. 앞 예제와는 따로 실행하세요.

const profile = { age: 0 };
const checkedProfile = new Proxy(profile, {
    set(target, key, value, receiver) {
        if (key === "age" &&
            (!Number.isInteger(value) || value < 0 || value > 150)) {
            return false;
        }
        return Reflect.set(target, key, value, receiver);
    }
});
console.log(Reflect.set(checkedProfile, "age", 42), profile.age);
console.log(Reflect.set(checkedProfile, "age", -1), profile.age);
console.log(Reflect.set(checkedProfile, "age", NaN), profile.age);

출력은 true 42, false 42, false 42입니다. 42는 검사를 통과해 저장되었고, 트랩은 Reflect.set()이 돌려준 실제 저장 결과 true를 그대로 돌려줍니다. -1과 NaN은 정수 검사에서 거부되어 저장되지 않았으므로 profile.age는 42로 남습니다.

receiver를 확인하기

receiver를 넘기는지에 따라 getter 안의 읽기가 트랩을 지나가는지가 달라집니다. 다음 코드는 트랩을 지나간 속성 이름을 배열 reads에 모아, getter 안의 this.points 읽기까지 트랩을 지나갔는지 확인합니다.

const reads = [];
const scoreTarget = {
    points: 3,
    get score() { return this.points * 2; }
};
const scoreProxy = new Proxy(scoreTarget, {
    get(target, key, receiver) {
        reads.push(String(key));
        return Reflect.get(target, key, receiver);
    }
});
console.log(scoreProxy.score, reads.join(","));

6 score,points가 출력됩니다. score를 읽을 때 트랩이 score를 기록하고, Reflect.get()이 receiver인 프록시를 this로 getter를 실행하므로 getter 안의 this.points도 트랩을 지나며 points가 기록됩니다. 이 차이는 getter와 변경 추적을 함께 쓰는 반응성 라이브러리에서 중요합니다.


정리

  • Proxy는 대상 객체에 대한 읽기, 쓰기, in, delete, 함수 호출 같은 기본 연산을 핸들러의 트랩으로 가로챕니다.
  • 트랩으로 유효성 검사나 로깅, 접근 제어를 대상 객체의 코드와 따로 둘 수 있지만, 원본 대상에 직접 접근하면 트랩을 건너뜁니다.
  • Reflect는 기본 연산을 트랩과 같은 모양의 메서드로 제공하므로, 트랩 안에서 원래 동작을 위임할 때 씁니다.
  • Reflect.get()과 Reflect.set()에 receiver를 넘겨야 getter와 setter의 this가 프록시가 되고, 그 안의 접근도 트랩을 지나갑니다.
  • 위임할 때 프록시 자신을 다시 읽으면 트랩에 끝없이 다시 들어가므로, 첫 인수로는 항상 target을 넘깁니다.
  • Reflect.set()의 false, 엄격 모드 할당의 TypeError, 불변 조건 위반의 TypeError는 서로 다른 실패 결과입니다.

연습 문제

1. 소수와 원본 직접 할당

검증 예제의 마지막 호출을 Reflect.set(checkedProfile, "age", 42.5)로 바꾸면 결과는 무엇일까요? 또 따로 실행한 코드에서 원본에 profile.age = -1을 직접 쓰면 트랩이 막을 수 있을까요?

해설 보기

false 42입니다. 42.5는 Number.isInteger() 검사를 통과하지 못해 트랩이 false를 돌려주고 저장하지 않습니다.

원본 profile에 직접 쓰는 할당은 프록시를 거치지 않으므로 트랩이 실행되지 않고 -1이 그대로 저장됩니다. 이 예제의 검증은 프록시를 거치는 경로에만 적용되며, 원본 참조를 가진 코드까지 막아 주지는 않습니다.

2. receiver를 빼면

receiver 확인 예제의 트랩에서 Reflect.get(target, key, receiver)를 Reflect.get(target, key)로 바꾸고 처음부터 실행하면 무엇이 출력될까요?

해설 보기

6 score가 출력됩니다. receiver를 넘기지 않으면 getter가 대상 객체인 scoreTarget을 this로 실행되므로, getter 안의 this.points는 프록시를 거치지 않고 대상에서 바로 읽힙니다.

계산된 값 6은 같지만 points를 읽었다는 기록이 남지 않습니다. 어떤 속성에 의존하는지 추적해야 하는 코드라면 이 차이 때문에 points가 바뀌어도 변화를 알아채지 못하게 됩니다.

3. 거짓말하는 트랩

다음 코드에서 lie.x를 읽으면 어떻게 될까요?

const frozen = Object.freeze({ x: 1 });
const lie = new Proxy(frozen, {
    get() {
        return 2;
    }
});
console.log(lie.x);
해설 보기

TypeError가 납니다. Object.freeze()로 고정한 x는 다시 정의할 수도, 값을 바꿀 수도 없는 속성이라 값이 언제나 1이어야 합니다.

get 트랩이 2를 돌려주면 프록시가 결과를 검사하다가 이 불변 조건을 어긴 것을 알아채고 오류를 던집니다. 트랩은 동작을 가로챌 수 있지만, 대상 객체가 이미 보장한 규칙을 거스르는 결과까지 돌려줄 수는 없습니다.

트랩이 지켜야 하는 불변 조건은 MDN의 Proxy 문서에서, getter에 넘기는 receiver는 MDN의 Reflect.get() 문서에서 확인할 수 있습니다.

이것으로 8장을 마칩니다. 8장에서는 let과 const의 블록 스코프, 화살표 함수의 this, 구조 분해 할당과 스프레드, Symbol과 Map, Set, 그리고 Proxy와 Reflect까지 최신 ECMAScript가 더한 기능을 살펴봤습니다. 다음 장의 Fetch API와 AJAX부터는 서버와 통신하는 Fetch API를 시작으로 웹 스토리지, History API, Canvas API, 웹 워커와 서비스 워커까지 브라우저가 제공하는 기능을 다룹니다.