Proxy와 Reflect — 객체 동작을 가로채는 메타프로그래밍
객체의 속성을 읽거나 쓸 때, 그 동작 자체를 가로채서 다른 일을 하게 만들 수 있다면 어떨까요?
자바스크립트의 Proxy는 정확히 이 일을 합니다. 객체의 기본 동작(속성 읽기, 쓰기, 삭제 등)에 훅(hook) 을 걸어서 동작을 커스터마이징할 수 있는 메타프로그래밍 도구입니다.
Proxy란 — 객체의 기본 동작을 가로채는 래퍼
Proxy는 원본 객체(target) 앞에 놓이는 투명한 래퍼 입니다. 외부에서는 프록시를 통해 원본 객체와 동일하게 상호작용하지만, 프록시가 중간에서 동작을 가로채서 추가 로직을 실행할 수 있습니다.
const target = { name: '홍길동', age: 25 };
const proxy = new Proxy(target, {
// get 트랩: 속성을 읽을 때 가로채기
get(target, property, receiver) {
console.log(`${property} 속성에 접근했습니다`);
return target[property];
}
});
console.log(proxy.name);
// "name 속성에 접근했습니다"
// "홍길동"
console.log(proxy.age);
// "age 속성에 접근했습니다"
// 25
중요한 건, 원본 객체는 전혀 변하지 않는다 는 점입니다. 프록시는 원본을 감싸고 있을 뿐이에요.
new Proxy(target, handler) — 두 가지 구성 요소
const proxy = new Proxy(target, handler);
- target: 감싸려는 원본 객체 (배열, 함수, 다른 프록시도 가능)
- handler: 트랩(trap)을 정의한 객체. 트랩이 없으면 원본 객체에 그대로 전달
handler에 아무 트랩도 없으면 원본 객체와 완전히 동일하게 동작합니다.
const target = { x: 1 };
const proxy = new Proxy(target, {}); // 빈 핸들러
proxy.x = 2;
console.log(target.x); // 2 — 원본에 직접 반영
console.log(proxy.x); // 2 — 투명하게 통과
주요 트랩 — get, set, has, deleteProperty, apply, construct
Proxy가 가로챌 수 있는 동작은 총 13가지 입니다. 그중 자주 쓰는 것들을 살펴보겠습니다.
get — 속성 읽기
const handler = {
get(target, prop, receiver) {
// 존재하지 않는 속성 접근 시 기본값 반환
if (prop in target) {
return target[prop];
}
return `${prop}은(는) 존재하지 않는 속성입니다`;
}
};
const user = new Proxy({}, handler);
user.name = '김개발';
console.log(user.name); // "김개발"
console.log(user.address); // "address은(는) 존재하지 않는 속성입니다"
set — 속성 쓰기
const handler = {
set(target, prop, value, receiver) {
if (prop === 'age' && typeof value !== 'number') {
throw new TypeError('age는 숫자여야 합니다');
}
if (prop === 'age' && (value < 0 || value > 150)) {
throw new RangeError('age는 0~150 사이여야 합니다');
}
target[prop] = value;
return true; // set 트랩은 반드시 boolean을 반환해야 함
}
};
const user = new Proxy({}, handler);
user.name = '홍길동'; // 정상
user.age = 25; // 정상
// user.age = '스물다섯'; // TypeError: age는 숫자여야 합니다
// user.age = -1; // RangeError: age는 0~150 사이여야 합니다
has — in 연산자
const handler = {
has(target, prop) {
// 언더스코어로 시작하는 속성은 숨기기
if (prop.startsWith('_')) {
return false;
}
return prop in target;
}
};
const obj = new Proxy({ _secret: '비밀', visible: '보임' }, handler);
console.log('visible' in obj); // true
console.log('_secret' in obj); // false — 실제로는 있지만 숨김
deleteProperty — 속성 삭제
const handler = {
deleteProperty(target, prop) {
if (prop.startsWith('_')) {
throw new Error(`${prop}은(는) 삭제할 수 없는 내부 속성입니다`);
}
delete target[prop];
return true;
}
};
const obj = new Proxy({ _id: 1, name: '홍길동' }, handler);
delete obj.name; // 정상
// delete obj._id; // Error: _id은(는) 삭제할 수 없는 내부 속성입니다
apply — 함수 호출
function sum(a, b) {
return a + b;
}
const handler = {
apply(target, thisArg, args) {
console.log(`호출: sum(${args.join(', ')})`);
const result = target.apply(thisArg, args);
console.log(`결과: ${result}`);
return result;
}
};
const proxiedSum = new Proxy(sum, handler);
proxiedSum(1, 2);
// "호출: sum(1, 2)"
// "결과: 3"
construct — new 연산자
class User {
constructor(name) {
this.name = name;
}
}
const handler = {
construct(target, args, newTarget) {
console.log(`새 인스턴스 생성: ${args[0]}`);
return new target(...args);
}
};
const ProxiedUser = new Proxy(User, handler);
const user = new ProxiedUser('홍길동');
// "새 인스턴스 생성: 홍길동"
Reflect — Proxy 트랩의 기본 동작을 위임하는 정적 메서드
Reflect는 Proxy 트랩과 1:1로 대응 하는 정적 메서드를 가진 내장 객체입니다. Proxy의 13가지 트랩에 대응하는 13가지 메서드가 있습니다.
그런데 왜 target[prop] 대신 Reflect.get(target, prop, receiver)를 쓸까요?
핵심: receiver를 올바르게 전달
const parent = new Proxy({
_name: '부모',
get name() {
return this._name; // 여기서 this는 누구?
}
}, {
get(target, prop, receiver) {
return Reflect.get(target, prop, receiver);
// receiver를 전달해야 getter의 this가 올바르게 바인딩됨
}
});
const child = Object.create(parent);
child._name = '자식';
// Reflect.get 사용 시: "자식" (올바른 결과)
// target[prop] 사용 시: "부모" (잘못된 결과 — this가 target에 고정됨)
console.log(child.name);
Reflect.get에 receiver를 전달하면 getter 내부의 this가 실제 호출 객체(child)를 가리킵니다. target[prop]을 직접 쓰면 this가 항상 원본 target을 가리켜서 프로토타입 상속이 깨집니다.
Reflect를 쓰는 또 다른 이유
// Reflect 없이 — 에러 발생 시 try-catch 필요
try {
Object.defineProperty(target, 'prop', descriptor);
} catch (e) {
// 실패 처리
}
// Reflect 사용 — 성공/실패를 boolean으로 반환
if (Reflect.defineProperty(target, 'prop', descriptor)) {
// 성공
} else {
// 실패
}
공부하다 보니, Reflect는 단순히 편의 메서드가 아니라 Proxy 트랩 안에서 기본 동작을 안전하게 위임하기 위한 필수 도구 라는 걸 알게 됐습니다.
Proxy + Reflect 조합 패턴
패턴 1: 로깅 프록시
function createLoggingProxy(target, label = 'Object') {
return new Proxy(target, {
get(target, prop, receiver) {
console.log(`[GET] ${label}.${String(prop)}`);
return Reflect.get(target, prop, receiver);
},
set(target, prop, value, receiver) {
console.log(`[SET] ${label}.${String(prop)} = ${JSON.stringify(value)}`);
return Reflect.set(target, prop, value, receiver);
},
deleteProperty(target, prop) {
console.log(`[DELETE] ${label}.${String(prop)}`);
return Reflect.deleteProperty(target, prop);
}
});
}
const user = createLoggingProxy({ name: '홍길동' }, 'User');
user.name; // [GET] User.name
user.age = 25; // [SET] User.age = 25
delete user.age; // [DELETE] User.age
디버깅할 때 "이 객체의 어떤 속성이 언제 바뀌는지" 추적하기 좋습니다.
패턴 2: 유효성 검사
function createValidatedObject(schema) {
return new Proxy({}, {
set(target, prop, value, receiver) {
const validator = schema[prop];
if (validator && !validator(value)) {
throw new TypeError(`${prop}에 유효하지 않은 값: ${value}`);
}
return Reflect.set(target, prop, value, receiver);
}
});
}
const user = createValidatedObject({
name: (v) => typeof v === 'string' && v.length > 0,
age: (v) => typeof v === 'number' && v >= 0 && v <= 150,
email: (v) => typeof v === 'string' && v.includes('@')
});
user.name = '홍길동'; // 정상
user.age = 25; // 정상
user.email = 'test@a.com'; // 정상
// user.age = -1; // TypeError: age에 유효하지 않은 값: -1
패턴 3: 읽기 전용 객체
function createReadOnly(target) {
return new Proxy(target, {
set(target, prop) {
throw new Error(`읽기 전용 객체입니다: ${String(prop)} 속성을 수정할 수 없습니다`);
},
deleteProperty(target, prop) {
throw new Error(`읽기 전용 객체입니다: ${String(prop)} 속성을 삭제할 수 없습니다`);
}
});
}
const config = createReadOnly({
apiUrl: 'https://api.example.com',
timeout: 5000
});
console.log(config.apiUrl); // "https://api.example.com"
// config.apiUrl = 'http://hack.com'; // Error: 읽기 전용 객체입니다
Object.freeze와 비슷하지만, Proxy 방식은 에러 메시지를 커스터마이징 하거나 중첩 객체까지 재귀적으로 보호 하기 쉽습니다.
Vue 3의 반응성 시스템이 Proxy를 사용하는 이유
Vue 2는 Object.defineProperty로 반응성을 구현했습니다. 하지만 이 방식에는 근본적인 한계가 있었습니다.
Object.defineProperty의 한계
// Vue 2 스타일 — 속성 하나하나에 getter/setter를 설정
const data = { name: '홍길동' };
Object.defineProperty(data, 'name', {
get() { /* 의존성 추적 */ },
set(newVal) { /* 변경 감지 → 재렌더링 */ }
});
// 문제 1: 새 속성 추가를 감지할 수 없음
data.age = 25; // 반응성 없음! Vue.set() 필요
// 문제 2: 배열 인덱스 변경을 감지할 수 없음
const arr = [1, 2, 3];
arr[0] = 10; // 반응성 없음!
// 문제 3: 모든 속성을 순회하며 defineProperty 호출 → 초기화 비용
Vue 3의 Proxy 기반 반응성
// Vue 3 스타일 — 객체 전체를 Proxy로 감싸기
function reactive(target) {
return new Proxy(target, {
get(target, prop, receiver) {
track(target, prop); // 의존성 추적
return Reflect.get(target, prop, receiver);
},
set(target, prop, value, receiver) {
const result = Reflect.set(target, prop, value, receiver);
trigger(target, prop); // 변경 알림 → 재렌더링
return result;
}
});
}
const state = reactive({ name: '홍길동' });
state.age = 25; // 새 속성도 자동 감지!
state.items = [1, 2, 3];
state.items[0] = 10; // 배열 인덱스도 감지!
| 비교 항목 | Object.defineProperty (Vue 2) | Proxy (Vue 3) |
|---|---|---|
| 새 속성 추가 감지 | 불가 (Vue.set 필요) | 자동 감지 |
| 배열 인덱스 변경 | 불가 | 자동 감지 |
| 속성 삭제 감지 | 불가 (Vue.delete 필요) | 자동 감지 |
| 초기화 비용 | 모든 속성 순회 필요 | 접근 시 lazy하게 처리 |
| 브라우저 지원 | IE 포함 | IE 미지원 |
Vue 3가 IE 지원을 드롭한 대신 Proxy를 선택한 건 이런 이유입니다. 결국 더 깔끔하고 완전한 반응성을 위한 트레이드오프였던 셈이에요.
주의사항 — 세 가지 함정
1. 성능 오버헤드
Proxy는 모든 동작마다 트랩을 거치기 때문에 일반 객체보다 느립니다.
// 성능 비교 (대략적인 수치)
const plain = { x: 1 };
const proxied = new Proxy({ x: 1 }, {
get(target, prop, receiver) {
return Reflect.get(target, prop, receiver);
}
});
// 수백만 번 반복 접근 시 proxied가 2~5배 느릴 수 있음
// 일반적인 사용에서는 체감하기 어렵지만, 핫 경로(hot path)에서는 주의
트랩이 비어 있더라도 오버헤드가 있습니다. 성능이 중요한 루프 안에서는 Proxy 사용을 피하는 게 좋습니다.
2. this 바인딩 문제
class MyMap extends Map {
// Map의 내부 메서드는 [[MapData]] 내부 슬롯에 접근해야 함
}
const map = new Map();
const proxiedMap = new Proxy(map, {});
// proxiedMap.set('key', 'value');
// TypeError! Map의 내부 메서드가 Proxy를 this로 받으면 실패
// 해결: get 트랩에서 메서드를 원본에 바인딩
const fixedProxy = new Proxy(map, {
get(target, prop, receiver) {
const value = Reflect.get(target, prop, receiver);
// 함수면 원본 target에 바인딩해서 반환
if (typeof value === 'function') {
return value.bind(target);
}
return value;
}
});
fixedProxy.set('key', 'value'); // 정상 동작
Map, Set, Date, Promise 같은 내장 객체는 내부 슬롯(internal slot)에 의존하기 때문에, Proxy를 통해 메서드를 호출하면 TypeError가 발생할 수 있습니다.
3. Proxy 불변 조건(Invariants)
Proxy 트랩은 아무거나 반환해도 되는 게 아닙니다. 자바스크립트 엔진이 일관성을 보장하기 위한 규칙 을 강제합니다.
const obj = {};
Object.defineProperty(obj, 'x', {
value: 42,
writable: false,
configurable: false
});
const proxy = new Proxy(obj, {
get(target, prop) {
return 100; // x의 실제 값(42)과 다른 값을 반환하려고 시도
}
});
// proxy.x → TypeError!
// configurable: false, writable: false인 속성은
// get 트랩에서 실제 값과 다른 값을 반환할 수 없음
이건 보안과 일관성을 위한 장치입니다. 엔진이 "이 속성은 절대 바뀌지 않는다"고 보장했는데 Proxy가 다른 값을 반환하면 모순이 생기니까요.
Revocable Proxy — 접근 권한을 회수하는 프록시
Proxy.revocable()은 취소 가능한 프록시 를 만듭니다. revoke()를 호출하면 프록시가 완전히 무효화됩니다.
const { proxy, revoke } = Proxy.revocable(
{ secret: '비밀 데이터' },
{
get(target, prop, receiver) {
return Reflect.get(target, prop, receiver);
}
}
);
console.log(proxy.secret); // "비밀 데이터"
revoke(); // 프록시 무효화
// proxy.secret; // TypeError: Cannot perform 'get' on a proxy that has been revoked
// proxy.anything; // TypeError
실전 활용: 시간 제한 접근
function createTimeLimitedAccess(target, timeoutMs) {
const { proxy, revoke } = Proxy.revocable(target, {
get(target, prop, receiver) {
return Reflect.get(target, prop, receiver);
},
set(target, prop, value, receiver) {
return Reflect.set(target, prop, value, receiver);
}
});
// 지정 시간 후 접근 권한 회수
setTimeout(() => {
console.log('접근 권한이 만료되었습니다');
revoke();
}, timeoutMs);
return proxy;
}
const tempAccess = createTimeLimitedAccess(
{ apiKey: 'abc123', data: [1, 2, 3] },
5000 // 5초 후 접근 불가
);
console.log(tempAccess.apiKey); // "abc123"
// 5초 후...
// tempAccess.apiKey → TypeError
서드파티 코드에 객체를 전달할 때, 일정 시간 후 접근을 차단하는 보안 패턴으로 활용할 수 있습니다.
트랩 전체 목록 정리
| 트랩 | 가로채는 동작 | Reflect 대응 |
|---|---|---|
get | 속성 읽기 | Reflect.get() |
set | 속성 쓰기 | Reflect.set() |
has | in 연산자 | Reflect.has() |
deleteProperty | delete 연산자 | Reflect.deleteProperty() |
apply | 함수 호출 | Reflect.apply() |
construct | new 연산자 | Reflect.construct() |
getOwnPropertyDescriptor | Object.getOwnPropertyDescriptor | Reflect.getOwnPropertyDescriptor() |
defineProperty | Object.defineProperty | Reflect.defineProperty() |
getPrototypeOf | Object.getPrototypeOf | Reflect.getPrototypeOf() |
setPrototypeOf | Object.setPrototypeOf | Reflect.setPrototypeOf() |
ownKeys | Object.keys, for...in | Reflect.ownKeys() |
isExtensible | Object.isExtensible | Reflect.isExtensible() |
preventExtensions | Object.preventExtensions | Reflect.preventExtensions() |
기억할 포인트: Proxy의 13가지 트랩은 Reflect의 13가지 메서드와 정확히 1:1 대응합니다. 트랩 안에서는 항상 Reflect를 써서 기본 동작을 위임하는 습관을 들이면 receiver 관련 버그를 예방할 수 있습니다.
정리
- Proxy: 객체의 기본 동작(get, set, delete 등)을 가로채는 래퍼 객체
- Reflect: Proxy 트랩 안에서 기본 동작을 안전하게 위임하는 정적 메서드 모음
- Proxy + Reflect 조합으로 로깅, 유효성 검사, 읽기 전용 객체 등의 패턴을 구현할 수 있음
- Vue 3는
Object.defineProperty의 한계(새 속성/배열 감지 불가)를 극복하기 위해 Proxy를 채택 - 내장 객체의
this바인딩 문제, 불변 조건, 성능 오버헤드에 주의 Proxy.revocable()로 접근 권한을 나중에 회수하는 보안 패턴도 가능