5.6 Proxy와 Reflect
Proxy와 Reflect는 객체의 기본 동작을 가로채고(intercept) 재정의할 수 있는 메타프로그래밍 도구입니다.
Proxy 기본
new Proxy(target, handler)로 대상 객체의 기본 동작을 트랩(trap)으로 가로챕니다.
const target = { name: "Alice", age: 30 };
const proxy = new Proxy(target, {
// get 트랩: 프로퍼티 읽기 가로채기
get(target, property, receiver) {
console.log(`읽기: ${String(property)}`);
return Reflect.get(target, property, receiver);
},
// set 트랩: 프로퍼티 쓰기 가로채기
set(target, property, value, receiver) {
console.log(`쓰기: ${String(property)} = ${value}`);
return Reflect.set(target, property, value, receiver);
},
});
proxy.name; // "읽기: name"
proxy.age = 31; // "쓰기: age = 31"
console.log(proxy.age); // "읽기: age" → 31
주요 트랩 (Traps)
get 트랩
// 기본값 제공 + undefined 방지
function withDefaults(target, defaults = {}) {
return new Proxy(target, {
get(obj, key, receiver) {
if (key in obj) return Reflect.get(obj, key, receiver);
if (key in defaults) return defaults[key];
return undefined;
},
});
}
const config = withDefaults(
{ theme: "dark" },
{ theme: "light", lang: "ko", fontSize: 14, timeout: 5000 }
);
console.log(config.theme); // "dark" (직접 설정)
console.log(config.lang); // "ko" (기본값)
console.log(config.missing); // undefined
// 부정 인덱스 배열
function negativeIndex(arr) {
return new Proxy(arr, {
get(target, prop, receiver) {
const index = Number(prop);
if (Number.isInteger(index) && index < 0) {
return Reflect.get(target, target.length + index, receiver);
}
return Reflect.get(target, prop, receiver);
},
});
}
const arr = negativeIndex([1, 2, 3, 4, 5]);
console.log(arr[-1]); // 5
console.log(arr[-2]); // 4
console.log(arr[0]); // 1
console.log(arr.length); // 5 (정상 동작)
set 트랩
// 유효성 검사 프록시
function validated(target, validators) {
return new Proxy(target, {
set(obj, prop, value, receiver) {
if (prop in validators) {
const error = validators[prop](value);
if (error) throw new TypeError(`[${prop}] ${error}`);
}
return Reflect.set(obj, prop, value, receiver);
},
});
}
const user = validated({}, {
name: v => (!v?.trim() ? "이름은 필수입니다" : null),
age: v => (typeof v !== "number" || v < 0 || v > 150 ? "나이는 0~150 사이여야 합니다" : null),
email: v => (!v?.includes("@") ? "유효한 이메일을 입력하세요" : null),
});
user.name = "Alice"; // OK
user.age = 30; // OK
user.email = "alice@example.com"; // OK
try {
user.age = -1; // TypeError: [age] 나이는 0~150 사이여야 합니다
} catch (e) {
console.log(e.message);
}
try {
user.email = "notanemail"; // TypeError: [email] 유효한 이메일을 입력하세요
} catch (e) {
console.log(e.message);
}
has 트랩
// in 연산자 커스터마이징
function rangeCheck(min, max) {
return new Proxy({}, {
has(target, prop) {
const num = Number(prop);
return num >= min && num <= max;
},
});
}
const range = rangeCheck(1, 100);
console.log(50 in range); // true
console.log(0 in range); // false
console.log(100 in range); // true
console.log(101 in range); // false
deleteProperty 트랩
// 삭제 방지 프록시
function protected_(target, protectedKeys = []) {
return new Proxy(target, {
deleteProperty(obj, prop) {
if (protectedKeys.includes(prop)) {
throw new Error(`'${prop}' 프로퍼티는 삭제할 수 없습니다`);
}
return Reflect.deleteProperty(obj, prop);
},
});
}
const obj = protected_({ id: 1, name: "Alice", temp: "삭제가능" }, ["id", "name"]);
delete obj.temp; // OK
try {
delete obj.id; // Error: 'id' 프로퍼티는 삭제할 수 없습니다
} catch (e) {
console.log(e.message);
}
apply 트랩 (함수 프록시)
// 함수 호출 가로채기
function memoize(fn) {
const cache = new Map();
return new Proxy(fn, {
apply(target, thisArg, args) {
const key = JSON.stringify(args);
if (cache.has(key)) {
return cache.get(key);
}
const result = Reflect.apply(target, thisArg, args);
cache.set(key, result);
return result;
},
});
}
function expensiveCalc(n) {
console.log(`계산 중: ${n}`);
return n * n;
}
const fastCalc = memoize(expensiveCalc);
console.log(fastCalc(10)); // 계산 중: 10 → 100
console.log(fastCalc(10)); // 캐시에서 즉시 반환 → 100
console.log(fastCalc(20)); // 계산 중: 20 → 400
// 호출 로깅 + 타이밍
function timed(fn, label = fn.name) {
return new Proxy(fn, {
apply(target, thisArg, args) {
const start = performance.now();
const result = Reflect.apply(target, thisArg, args);
const elapsed = (performance.now() - start).toFixed(3);
console.log(`[${label}] ${elapsed}ms`);
return result;
},
});
}
construct 트랩 (new 가로채기)
// 싱글톤 패턴 with Proxy
function singleton(Class) {
let instance = null;
return new Proxy(Class, {
construct(Target, args) {
if (!instance) {
instance = Reflect.construct(Target, args);
}
return instance;
},
});
}
class Config {
constructor(env) {
this.env = env;
this.settings = {};
}
}
const SingletonConfig = singleton(Config);
const c1 = new SingletonConfig("production");
const c2 = new SingletonConfig("development");
console.log(c1 === c2); // true (같은 인스턴스)
console.log(c1.env); // "production" (최초 생성 시 값 유지)
Reflect API
Reflect는 기본 객체 동작을 수행하는 정적 메서드 모음입니다. Proxy 트랩 내에서 기본 동작을 수행할 때 주로 사용합니다.
const obj = { x: 1, y: 2 };
// Reflect.get: 프로퍼티 읽기
console.log(Reflect.get(obj, "x")); // 1
console.log(Reflect.get([1, 2, 3], 1)); // 2
// Reflect.set: 프로퍼티 쓰기
Reflect.set(obj, "z", 3);
console.log(obj.z); // 3
// Reflect.has: in 연산자
console.log(Reflect.has(obj, "x")); // true
// Reflect.deleteProperty: delete 연산자
console.log(Reflect.deleteProperty(obj, "z")); // true
// Reflect.ownKeys: 모든 자신의 키 (Symbol 포함)
const sym = Symbol("key");
const obj2 = { a: 1, [sym]: 2 };
console.log(Reflect.ownKeys(obj2)); // ["a", Symbol(key)]
// Reflect.construct: new 호출
class Point { constructor(x, y) { this.x = x; this.y = y; } }
const p = Reflect.construct(Point, [1, 2]);
console.log(p instanceof Point); // true
// Reflect.apply: 함수 호출
console.log(Reflect.apply(Math.max, null, [1, 5, 3])); // 5
// Reflect.defineProperty, getOwnPropertyDescriptor, getPrototypeOf, setPrototypeOf
// Reflect.isExtensible, preventExtensions
Reflect vs 직접 연산의 차이
// 직접 연산: 실패 시 에러를 던지거나 false를 반환 (일관성 없음)
// delete obj.key → true/false 반환
// Object.defineProperty → 실패 시 TypeError
// Reflect: 항상 boolean을 반환 (일관성 있음)
const frozen = Object.freeze({ x: 1 });
console.log(Reflect.set(frozen, "x", 2)); // false (실패, 에러 없음)
console.log(Reflect.defineProperty(frozen, "y", { value: 3 })); // false
// 트랩에서 Reflect 사용의 이점: 기본 동작 그대로 위임
const transparent = new Proxy(obj, {
get(target, prop, receiver) {
// Reflect.get: receiver(this)를 올바르게 전달
return Reflect.get(target, prop, receiver);
},
});
활용 패턴 1: 읽기 전용 객체
function readonly(target) {
return new Proxy(target, {
set(obj, prop) {
throw new TypeError(`읽기 전용 객체: '${String(prop)}' 프로퍼티를 수정할 수 없습니다`);
},
deleteProperty(obj, prop) {
throw new TypeError(`읽기 전용 객체: '${String(prop)}' 프로퍼티를 삭제할 수 없습니다`);
},
defineProperty() {
throw new TypeError("읽기 전용 객체: 프로퍼티를 정의할 수 없습니다");
},
// 중첩 객체도 읽기 전용으로
get(obj, prop, receiver) {
const value = Reflect.get(obj, prop, receiver);
if (typeof value === "object" && value !== null) {
return readonly(value);
}
return value;
},
});
}
const config = readonly({
server: { host: "localhost", port: 3000 },
db: { host: "localhost", port: 5432 },
});
console.log(config.server.host); // "localhost"
try {
config.server.port = 8080; // TypeError!
} catch (e) {
console.log(e.message);
}
활용 패턴 2: 로깅/트레이싱
function traced(target, name = "Object") {
return new Proxy(target, {
get(obj, prop, receiver) {
const value = Reflect.get(obj, prop, receiver);
if (typeof value === "function") {
return new Proxy(value, {
apply(fn, thisArg, args) {
console.log(`[CALL] ${name}.${String(prop)}(${args.map(a => JSON.stringify(a)).join(", ")})`);
const result = Reflect.apply(fn, thisArg, args);
console.log(`[RETURN] ${JSON.stringify(result)}`);
return result;
},
});
}
console.log(`[GET] ${name}.${String(prop)} → ${JSON.stringify(value)}`);
return value;
},
set(obj, prop, value, receiver) {
console.log(`[SET] ${name}.${String(prop)} = ${JSON.stringify(value)}`);
return Reflect.set(obj, prop, value, receiver);
},
});
}
const calculator = traced({
value: 0,
add(n) { this.value += n; return this.value; },
reset() { this.value = 0; },
}, "Calculator");
calculator.add(10);
// [CALL] Calculator.add(10)
// [RETURN] 10
calculator.value = 5;
// [SET] Calculator.value = 5
활용 패턴 3: Vue 3의 반응형 시스템 원리
Vue 3은 Proxy를 사용해 반응형 데이터를 구현합니다. 간략한 원리를 살펴봅니다.
// Vue 3 반응형 시스템의 핵심 아이디어 (간략화)
class ReactiveSystem {
#effectStack = [];
#dependencies = new WeakMap(); // 객체 → Map(프로퍼티 → Set(effect))
reactive(target) {
return new Proxy(target, {
get: (obj, prop, receiver) => {
this.#track(obj, prop); // 의존성 추적
const value = Reflect.get(obj, prop, receiver);
if (typeof value === "object" && value !== null) {
return this.reactive(value); // 중첩 객체도 반응형으로
}
return value;
},
set: (obj, prop, value, receiver) => {
const result = Reflect.set(obj, prop, value, receiver);
this.#trigger(obj, prop); // 변경 알림
return result;
},
});
}
effect(fn) {
const runEffect = () => {
this.#effectStack.push(runEffect);
fn();
this.#effectStack.pop();
};
runEffect();
return runEffect;
}
#track(target, prop) {
if (this.#effectStack.length === 0) return;
if (!this.#dependencies.has(target)) {
this.#dependencies.set(target, new Map());
}
const depsMap = this.#dependencies.get(target);
if (!depsMap.has(prop)) depsMap.set(prop, new Set());
depsMap.get(prop).add(this.#effectStack[this.#effectStack.length - 1]);
}
#trigger(target, prop) {
const depsMap = this.#dependencies.get(target);
if (!depsMap) return;
depsMap.get(prop)?.forEach(effect => effect());
}
}
// 사용 예시
const rs = new ReactiveSystem();
const state = rs.reactive({ count: 0, name: "앱" });
// effect: 의존성이 변경되면 자동으로 재실행
rs.effect(() => {
console.log(`카운트: ${state.count}, 이름: ${state.name}`);
});
// 즉시 실행: "카운트: 0, 이름: 앱"
state.count++; // "카운트: 1, 이름: 앱"
state.count++; // "카운트: 2, 이름: 앱"
state.name = "수정된 앱"; // "카운트: 2, 이름: 수정된 앱"
실전 예제: 스키마 기반 데이터 모델
function createModel(schema) {
return class Model {
constructor(data = {}) {
const target = {};
// 스키마 기반 초기화
for (const [key, rules] of Object.entries(schema)) {
target[key] = key in data ? data[key] : rules.default;
}
return new Proxy(this, {
get(obj, prop, receiver) {
if (prop in target) return target[prop];
return Reflect.get(obj, prop, receiver);
},
set(obj, prop, value, receiver) {
if (prop in schema) {
const rules = schema[prop];
// 타입 검사
if (rules.type && typeof value !== rules.type) {
throw new TypeError(`${prop}: ${rules.type} 타입 필요, 받은 ${typeof value}`);
}
// 커스텀 유효성 검사
if (rules.validate) {
const error = rules.validate(value);
if (error) throw new RangeError(`${prop}: ${error}`);
}
// 변환
if (rules.transform) value = rules.transform(value);
target[prop] = value;
return true;
}
return Reflect.set(obj, prop, value, receiver);
},
has(obj, prop) {
return prop in schema || Reflect.has(obj, prop);
},
ownKeys() {
return [...Object.keys(schema), ...Reflect.ownKeys(obj)];
},
});
}
toJSON() {
const result = {};
for (const key of Object.keys(schema)) {
result[key] = this[key];
}
return result;
}
};
}
const UserModel = createModel({
name: {
type: "string",
default: "",
validate: v => !v.trim() ? "이름은 필수입니다" : null,
transform: v => v.trim(),
},
age: {
type: "number",
default: 0,
validate: v => v < 0 || v > 150 ? "유효한 나이를 입력하세요" : null,
},
email: {
type: "string",
default: "",
validate: v => !v.includes("@") ? "유효한 이메일을 입력하세요" : null,
transform: v => v.toLowerCase(),
},
});
const user = new UserModel({ name: " Alice ", age: 30, email: "ALICE@EXAMPLE.COM" });
console.log(user.name); // "Alice" (transform 적용)
console.log(user.email); // "alice@example.com" (소문자 변환)
console.log(JSON.stringify(user.toJSON()));
// {"name":"Alice","age":30,"email":"alice@example.com"}
try {
user.age = -1; // RangeError: age: 유효한 나이를 입력하세요
} catch (e) {
console.log(e.message);
}
고수 팁
팁 1: Revocable Proxy — 권한 취소 가능한 접근
const { proxy: secureData, revoke } = Proxy.revocable(
{ secret: "비밀 데이터", token: "abc123" },
{
get(obj, prop) {
console.log(`보안 접근: ${prop}`);
return obj[prop];
},
}
);
console.log(secureData.token); // "보안 접근: token" → "abc123"
// 접근 권한 취소
revoke();
try {
console.log(secureData.token); // TypeError: Cannot perform 'get' on a proxy that has been revoked
} catch (e) {
console.log("접근 거부됨");
}
팁 2: 타입 안전한 API 클라이언트
function createApiClient(baseURL) {
const handler = {
get(target, endpoint) {
return new Proxy({}, {
get(_, method) {
return (data) => {
const url = `${baseURL}/${endpoint}`;
console.log(`${method.toUpperCase()} ${url}`, data ?? "");
// 실제로는 fetch(url, { method, body: JSON.stringify(data) })
return Promise.resolve({ endpoint, method, data });
};
},
});
},
};
return new Proxy({}, handler);
}
const api = createApiClient("https://api.example.com");
// api.users.get() → GET https://api.example.com/users
// api.posts.post({ title: "Hello" }) → POST https://api.example.com/posts
api.users.get();
api.users.post({ name: "Alice", email: "alice@example.com" });
api.posts.get();
api.posts.delete(1);
팁 3: 성능 주의사항
// Proxy는 모든 연산에 오버헤드가 있음
// 핫 패스(자주 호출되는 경로)에서는 주의 필요
// ❌ 고성능 루프 내에서 Proxy 사용
// for (let i = 0; i < 1_000_000; i++) {
// proxy.value++; // 매 연산마다 트랩 호출
// }
// ✅ 필요할 때만 Proxy 사용
// const raw = proxy; // Proxy 없는 원본 참조 (Proxy에서는 불가)
// — 설계 시 원본과 proxy를 분리해서 유지