자바스크립트 Proxy와 Reflect - 반응형 시스템의 원리와 트랩 구현
이 단원에서 배우는 것
19단원에서 Symbol.iterator를 구현해 for...of의 동작을 우리가 정했다. 언어의 기본 동작을 코드로 바꿔치기한 첫 경험이었다. Proxy는 그것을 객체의 모든 기본 동작으로 확장한다. 속성을 읽는 순간, 쓰는 순간, in으로 검사하는 순간을 가로챌 수 있다. Vue의 반응형 시스템, MobX, 각종 검증 라이브러리가 전부 이 위에 서 있다. 이번 단원에서는 그 반응형 시스템을 직접 만들어 본다.
Proxy의 주요 트랩과Reflect가 왜 짝으로 쓰이는지를receiver관점에서 설명한다.- 의존성 추적 기반의 최소 반응형 상태(
reactive+effect)를 60줄 안에 구현한다. - 프록시가 통하지 않는 경계(내부 슬롯, 불변 규칙, 동일성)와 성능 비용을 판단해 쓸 곳과 쓰지 말 곳을 가른다.
기준은 ES2015에 들어온 Proxy/Reflect다. 폴리필이 불가능한 기능이라 구형 브라우저 지원이 필요하면 아예 선택지에서 빠진다. 예제는 앞 단원과 같은 스터디 관리 대시보드를 쓴다.
왜 필요한가
8단원 이후로 우리 대시보드는 이 구조였다.
members.push(newMember);
render(); // 잊으면 화면이 안 바뀐다
members[0].sessions += 1;
render(); // 여기도
members.splice(index, 1);
// render(); ← 여기서 하나 빠졌다
상태를 고칠 때마다 render()를 부르는 규약이다. 규약은 언젠가 깨진다. 함수가 열 개가 되고 조건 분기가 생기면 어느 경로에서 갱신이 빠졌는지 찾는 데 시간이 든다. 반대로 안전하게 하려고 아무 데서나 render()를 부르면 한 번의 클릭에 열 번씩 그린다.
해결 방향은 둘이다. 하나는 상태 변경을 함수로만 하게 강제하는 것(setState 방식). 다른 하나는 대입 자체를 감지하는 것이다. 후자를 하려면 members[0].sessions += 1이라는 평범한 코드가 실행될 때 우리 코드가 끼어들 수 있어야 한다. 그것이 Proxy다.
문법과 예제
가장 작은 프록시
const target = { name: "김민수", sessions: 3 };
const proxy = new Proxy(target, {
get(obj, key, receiver) {
console.log(`읽기: ${String(key)}`);
return Reflect.get(obj, key, receiver);
},
set(obj, key, value, receiver) {
console.log(`쓰기: ${String(key)} = ${value}`);
return Reflect.set(obj, key, value, receiver);
},
});
proxy.sessions; // 읽기: sessions
proxy.sessions = 4; // 쓰기: sessions = 4
console.log(target.sessions); // 4 — 원본이 실제로 바뀐다
new Proxy(target, handler)의 두 번째 인자를 핸들러라 하고, 그 안의 각 메서드를 트랩이라 한다. 트랩을 정의하지 않은 동작은 그대로 원본에 전달된다. 실무에서 쓰는 트랩은 대개 다음 다섯 개다.
| 트랩 | 가로채는 동작 | 반환해야 하는 것 |
|---|---|---|
get | obj.key, obj[key] | 속성 값 |
set | obj.key = v | 성공 여부(boolean) |
has | key in obj | boolean |
deleteProperty | delete obj.key | boolean |
ownKeys | Object.keys, for...in, 스프레드 | 키 배열 |
set이 false를 반환하면 strict 모드에서 TypeError가 난다. ES 모듈은 항상 strict이므로 Reflect.set의 반환값을 그대로 돌려주는 습관이 중요하다.
Reflect 는 왜 필요한가
Reflect.get(obj, key)는 obj[key]와 거의 같다. "거의"가 문제다. 세 번째 인자 receiver가 getter 안의 this를 결정한다.
const base = {
first: "김",
last: "민수",
get fullName() { return this.first + this.last; },
};
const bad = new Proxy(base, {
get(obj, key) { return obj[key]; }, // receiver 를 잃는다
});
const good = new Proxy(base, {
get(obj, key, receiver) { return Reflect.get(obj, key, receiver); },
});
bad.fullName을 읽으면 getter 안의 this가 base가 되어 base.first, base.last를 직접 읽는다. 프록시를 거치지 않으므로 get 트랩이 호출되지 않는다. 반응형 시스템이라면 first와 last의 의존성이 기록되지 않아, 나중에 first를 바꿔도 화면이 갱신되지 않는다. good은 this가 프록시가 되어 안쪽 읽기까지 전부 잡힌다. 결론은 단순하다. 트랩 안에서는 항상 Reflect의 동명 메서드를 receiver까지 넘겨 호출한다.
Reflect의 두 번째 쓸모는 반환값의 형태다. Object.defineProperty는 실패하면 예외를 던지지만 Reflect.defineProperty는 false를 반환한다. 트랩이 요구하는 반환 형태와 그대로 맞아떨어진다.
미니 반응형 시스템
이제 목표를 코드로 만든다. 요구사항은 "상태를 평범하게 대입하면, 그 값을 읽었던 함수만 다시 실행된다"이다.
let activeEffect = null;
const depsMap = new WeakMap(); // target -> Map(key -> Set(effect))
function track(target, key) {
if (!activeEffect) return; // 추적 중이 아니면 무시
let keys = depsMap.get(target);
if (!keys) depsMap.set(target, (keys = new Map()));
let dep = keys.get(key);
if (!dep) keys.set(key, (dep = new Set()));
dep.add(activeEffect);
}
function trigger(target, key) {
const dep = depsMap.get(target)?.get(key);
if (!dep) return;
for (const fn of [...dep]) fn(); // 복사 후 실행 — 실행 중 Set 변경 대비
}
function reactive(target) {
return new Proxy(target, {
get(obj, key, receiver) {
track(obj, key);
const value = Reflect.get(obj, key, receiver);
return typeof value === "object" && value !== null ? reactive(value) : value;
},
set(obj, key, value, receiver) {
const old = obj[key];
const ok = Reflect.set(obj, key, value, receiver);
if (ok && !Object.is(old, value)) trigger(obj, key);
return ok;
},
deleteProperty(obj, key) {
const had = Reflect.has(obj, key);
const ok = Reflect.deleteProperty(obj, key);
if (had && ok) trigger(obj, key);
return ok;
},
});
}
function effect(fn) {
activeEffect = fn;
fn(); // 실행하면서 읽은 키가 자동 등록된다
activeEffect = null;
}
쓰는 쪽은 이렇게 된다.
const state = reactive({ name: "김민수", sessions: 0, paid: false });
effect(() => console.log(`출석 ${state.sessions}회`));
state.sessions = 1; // 출석 1회
state.sessions = 1; // 값이 같아 아무 일도 없다
state.paid = true; // paid 를 읽은 이펙트가 없어 아무 일도 없다
state.sessions = 2; // 출석 2회
세 가지가 자동으로 이뤄졌다. 첫째, effect가 어떤 값에 의존하는지 선언하지 않았는데 fn()을 실행하면서 읽힌 키가 그대로 의존성이 됐다. 둘째, 같은 값 대입은 Object.is 비교로 걸러졌다. 셋째, paid처럼 아무도 읽지 않은 키는 바뀌어도 비용이 0이다. render()를 부르는 규약은 사라졌고, 갱신 범위도 최소가 됐다.
depsMap을 WeakMap으로 잡은 것은 18단원의 이유 그대로다. 상태 객체가 버려지면 의존성 정보도 함께 회수돼야 한다. Map으로 잡으면 화면을 아무리 닫아도 예전 상태가 전부 남는다.
중첩 객체와 배열
get 트랩에서 객체를 만나면 다시 reactive로 감싸는 부분이 중첩 대응이다. 미리 전체를 순회해 감싸지 않고 읽는 순간에 감싸므로, 깊은 트리에서도 실제로 화면에 쓰인 가지만 비용을 낸다.
const store = reactive({
study: { title: "자바스크립트 스터디", members: [] },
});
effect(() => console.log(`인원 ${store.study.members.length}명`));
store.study.members.push({ name: "이서연" });
// 출력: 인원 0명 ← 그리고 끝. 갱신되지 않는다
중첩은 잘 동작하는데 배열이 말썽이다. 위 코드는 화면이 갱신되지 않는다. push가 하는 일을 트랩 로그로 찍어 보면 이유가 보인다. push는 인덱스 0에 값을 대입하고, 그다음 length에 1을 대입한다. 문제는 인덱스 대입만으로 배열의 length가 이미 1이 되어 있다는 점이다. 그 자동 갱신은 배열 내부 동작이라 트랩을 지나지 않는다. 뒤이은 명시적 length = 1 대입은 트랩을 지나지만 이전 값도 1이라 Object.is 비교에 걸려 무시된다. 결과적으로 length에 의존하는 이펙트는 한 번도 깨어나지 않는다.
set 트랩에서 "배열 끝에 새 항목이 추가되는 경우"를 따로 알아채면 해결된다.
set(obj, key, value, receiver) {
const isAppend =
Array.isArray(obj) && key !== "length" && Number(key) >= obj.length;
const old = obj[key];
const ok = Reflect.set(obj, key, value, receiver);
if (!ok) return false;
if (!Object.is(old, value)) trigger(obj, key);
if (isAppend) trigger(obj, "length"); // length 의존 이펙트도 깨운다
return true;
}
이제 push 두 번에 "인원 1명", "인원 2명"이 순서대로 나온다. 이 한 가지 예외만 봐도 배열의 다른 변경 메서드(splice, unshift, sort)마다 비슷한 검토가 필요하다는 것을 알 수 있다. Vue 3가 배열용 처리를 별도로 두고 있는 이유이며, 반응형 시스템을 직접 만들지 말고 검증된 라이브러리를 쓰라는 조언의 근거이기도 하다.
남은 문제는 중복 실행이다. 인덱스와 length를 모두 읽는 이펙트는 같은 push에 두 번 실행된다. 상태를 연달아 다섯 번 고치면 다섯 번 그린다. 실제 프레임워크는 이펙트를 즉시 실행하지 않고 마이크로태스크로 모은다.
// 17단원의 마이크로태스크로 중복 실행을 접는다
const queue = new Set();
let scheduled = false;
function schedule(fn) {
queue.add(fn);
if (scheduled) return;
scheduled = true;
queueMicrotask(() => {
const jobs = [...queue];
queue.clear();
scheduled = false;
for (const job of jobs) job();
});
}
trigger에서 fn() 대신 schedule(fn)을 부르면, 같은 태스크 안에서 상태를 열 번 고쳐도 화면 갱신은 한 번이다. Set을 쓴 덕분에 같은 이펙트의 중복도 자동으로 제거된다.
검증에 쓰기
반응형 말고도 쓸모가 있다. 오타 잡기가 대표적이다.
function strict(target, label) {
return new Proxy(target, {
get(obj, key, receiver) {
if (typeof key === "string" && !(key in obj)) {
throw new ReferenceError(`${label}에 없는 속성: ${key}`);
}
return Reflect.get(obj, key, receiver);
},
});
}
const member = strict({ id: 1, name: "김민수", sessions: 3 }, "member");
console.log(member.name); // 김민수
console.log(member.session); // ReferenceError: member에 없는 속성: session
평소라면 undefined가 나와 한참 뒤 다른 곳에서 터질 오타가 즉시 잡힌다. 다만 이런 프록시는 개발 환경에서만 씌운다. 운영 코드에 넣으면 뒤에서 볼 비용과 호환성 문제를 그대로 떠안는다.
실무에서 자주 틀리는 것
1. Map, Set, Date 를 그대로 감싼다
const m = new Proxy(new Map([["a", 1]]), {});
m.get("a");
// TypeError: Method Map.prototype.get called on incompatible receiver
Map은 데이터를 일반 속성이 아니라 내부 슬롯에 담는다. Map.prototype.get은 this가 진짜 Map 인스턴스인지 검사하는데, 프록시는 그 슬롯이 없어서 즉시 예외다. Date, Set, WeakMap, Promise, 클래스의 #private 필드가 전부 같은 문제를 겪는다. 해결책은 메서드를 get 트랩에서 원본에 바인딩해 돌려주는 것이다.
const m = new Proxy(new Map([["a", 1]]), {
get(obj, key, receiver) {
const value = Reflect.get(obj, key, receiver);
return typeof value === "function" ? value.bind(obj) : value;
},
});
console.log(m.get("a")); // 1
대신 이 방식은 메서드 호출이 원본에 직접 가므로 set 같은 변경을 감지하려면 메서드별로 따로 감싸야 한다. Vue 3가 Map/Set용 핸들러를 별도로 두고 있는 이유다. 남이 만든 인스턴스를 프록시로 감쌀 때는 항상 이 함정을 먼저 확인한다.
2. 프록시와 원본을 === 로 비교한다
const target = { id: 1 };
const proxy = new Proxy(target, {});
console.log(proxy === target); // false
console.log(members.indexOf(proxy)); // -1 — 배열에 원본이 담겨 있으면 못 찾는다
프록시는 원본과 다른 객체다. 반응형 상태를 쓰다 보면 어떤 경로로 얻은 값이 프록시고 어떤 것이 원본인지 헷갈리는 순간이 오고, 그때 === 비교나 Set.has가 조용히 실패한다. 증상은 "분명히 목록에 있는데 선택이 안 됨"이다. 규칙을 정해 경계 밖으로 나가는 값은 항상 원본으로 되돌리거나, 앱 전체에서 프록시만 쓰거나 둘 중 하나로 통일한다. 원본으로 되돌리려면 reactive에서 만든 프록시와 원본의 대응을 WeakMap에 기록해 두고 조회하는 방식이 흔하다.
3. 트랩에서 불변 규칙을 어긴다
const frozen = Object.freeze({ id: 7 });
const p = new Proxy(frozen, { get() { return 999; } });
p.id;
// TypeError: 'get' on proxy: property 'id' is a read-only and
// non-configurable data property ... (expected '7' but got '999')
프록시는 마음대로 거짓말할 수 없다. 원본에 쓰기 불가·재정의 불가 속성이 있으면 get 트랩은 반드시 그 값을 돌려줘야 한다. ownKeys도 재정의 불가 속성을 빠뜨리면 예외가 난다. 이 예외는 프록시를 만들 때가 아니라 속성을 읽을 때 터지므로 원인이 먼 곳에 있는 것처럼 보인다. Object.freeze된 설정 객체나 얼려서 넘긴 상수 테이블을 감쌀 때 실제로 부딪힌다.
4. 이펙트 중첩과 무한 루프를 고려하지 않는다
effect(() => {
state.total = state.sessions * 2; // 읽고 쓰기를 같이 한다
});
같은 이펙트가 읽은 키에 쓰기까지 하면 트리거가 자기 자신을 다시 부른다. 위 코드는 total과 sessions가 달라서 당장은 안 터지지만, state.sessions = state.sessions + 1 형태가 되면 무한 재귀다. 실제 구현에서는 trigger에서 현재 실행 중인 이펙트는 건너뛰는 방어가 들어간다. 위에서 [...dep]로 복사한 것도 같은 부류의 대비다. 순회 중에 Set이 커지면 for...of가 새로 추가된 항목까지 계속 돌아 끝나지 않는다.
5. 비용을 재지 않고 전 구간에 씌운다
프록시를 통한 속성 접근은 원본 접근보다 느리다. 트랩 호출 자체의 비용도 있지만, 더 큰 문제는 엔진의 인라인 캐시와 히든 클래스 최적화가 프록시 객체에서는 잘 작동하지 않는다는 점이다. 수만 건을 반복 순회하는 계산 루프에서 프록시 객체를 그대로 쓰면 체감이 온다.
// 계산 전에 원본 스냅숏을 뜬다
const raw = state.members.map((m) => ({ ...m }));
const total = raw.reduce((sum, m) => sum + m.sessions, 0);
UI 상태처럼 "변경 횟수는 적고 읽기는 사람 속도"인 곳에 쓰는 것이 프록시의 자리다. 대량 계산 경로에서는 원본을 꺼내 쓴다. 판단은 감이 아니라 performance.now()로 양쪽을 재서 한다. 참고로 이 최적화 자체가 필요 없는 경우가 대부분이므로, 재 보기 전에 미리 걷어내지는 않는다.
트랩 목록과 각 트랩의 불변 규칙은 MDN의 Proxy 문서에 전부 나열돼 있다.
스스로 확인하기
- 다음 두 핸들러의 차이를
fullNamegetter가 있는 객체를 예로 설명하라. 반응형 시스템에서 어떤 버그로 이어지는가?const a = { get(obj, key) { return obj[key]; } }; const b = { get(obj, key, receiver) { return Reflect.get(obj, key, receiver); } }; - 위
reactive구현에서depsMap을WeakMap대신Map으로 바꾸면 어떤 문제가 생기는가? state.members.push({ name: "박지훈" })한 번에set트랩은 몇 번, 어떤 키로 호출되는가? 그런데도members.length에 의존하는 이펙트가 깨어나지 않는 이유는 무엇인가?
정답
a는obj[key]로 직접 읽으므로 getter 안의this가 원본 객체가 된다.fullName이 내부에서this.first,this.last를 읽을 때 프록시를 거치지 않아get트랩이 호출되지 않는다.b는receiver를 넘기므로this가 프록시가 되어 안쪽 읽기까지 잡힌다. 반응형 시스템에서a를 쓰면first와last가 의존성으로 기록되지 않아, 그 값을 바꿔도fullName을 쓰는 화면이 갱신되지 않는다. 처음 렌더는 정상이라 원인을 찾기 어렵다.Map은 키를 강하게 참조하므로 상태 객체가 더 이상 쓰이지 않아도depsMap이 계속 붙잡아 회수되지 않는다. 화면을 열고 닫을 때마다 상태 객체와 그에 딸린 이펙트 함수, 이펙트가 캡처한 DOM까지 누적된다. 18단원의 전형적인 캐시 누수 패턴이다.- 두 번이다. 새 인덱스 키(빈 배열이면
"0")로 한 번,"length"로 한 번 지난다. 그런데도 갱신이 안 되는 이유는 인덱스 대입이 성공한 시점에 배열의length가 이미 1로 바뀌어 있고 그 자동 갱신은 트랩을 지나지 않기 때문이다. 뒤이은 명시적length = 1은 트랩을 지나지만 이전 값도 1이라Object.is비교에 걸려trigger가 생략된다. 해결은set트랩에서 배열 끝에 항목이 추가되는 경우(Number(key) >= obj.length)를 따로 판정해trigger(obj, "length")를 직접 호출하는 것이다.