
JavaScript Proxy 패턴 소개
현대적인 JavaScript 프레임워크(Vue.js 3 등)의 내부를 들여다보면 빠지지 않고 등장하는 핵심 개념이 있습니다. 바로 Proxy입니다. Proxy는 이름 그대로 '대리인' 역할을 하며, 객체에 가해지는 작업을 가로채서(intercept) 커스텀 동작을 수행하게 해줍니다.
하지만 Proxy는 어느 날 갑자기 하늘에서 떨어진 개념이 아닙니다. 이 도구가 왜 탄생했는지, 그 뿌리부터 추적해 보겠습니다.
Proxy의 뿌리: 1994년과 Gang of Four
Proxy 패턴은 소프트웨어 디자인 패턴의 고전으로 불리는 **GoF(Gang of Four)의 1994년 저서 《Design Patterns》**에서 처음 체계화된 23가지 패턴 중 하나입니다.
왜 탄생했는가? (The "Why")
당시 소프트웨어 설계자들은 객체에 직접 접근하는 것이 때로는 **'위험'**하거나 **'비효율적'**이라는 것을 깨달았습니다. 그래서 객체 앞에 똑같이 생긴 '대리인'을 세워두고 다음과 같은 문제들을 해결하고자 했습니다.
- 가상 프록시 (Virtual Proxy): 용량이 큰 이미지나 리소스를 실제 사용하는 시점까지 로딩을 미루고(Lazy Loading), 그전까지는 가벼운 이름표(프록시)만 들고 있게 하기 위해.
- 보호 프록시 (Protection Proxy): 객체의 민감한 정보에 접근하기 전, 권한이 있는지 체크하는 '문지기' 역할을 수행하기 위해.
- 원격 프록시 (Remote Proxy): 네트워크 너머 다른 컴퓨터에 있는 객체를 마치 내 컴퓨터에 있는 것처럼 편하게 다루기 위해.
JavaScript의 Proxy: ES6(2015)와 메타 프로그래밍
디자인 패턴으로서 존재하던 Proxy가 JavaScript 언어의 표준 스펙으로 들어온 것은 ES6(ECMAScript 2015) 때입니다.
기존의 JavaScript는 객체의 내부 동작(속성 조회 등)이 엔진 깊숙한 곳에 숨겨져 있어 우리가 이를 마음대로 조정하기가 매우 까다로웠습니다. (예를 들어, 존재하지 않는 속성에 접근할 때 에러를 던지게 만드는 등의 작업이 불가능하거나 복잡했습니다.)
JavaScript에 Proxy가 도입된 결정적인 이유는 메타 프로그래밍(Meta-programming), 즉 '프로그램이 프로그램을 작성하거나 조작하게 하는 능력'을 개발자에게 개방하기 위함이었습니다. 이를 통해 우리는 언어의 기본 동작을 가로채서 완전히 새로운 가능성(반응형 데이터 등)을 열 수 있게 되었습니다.
Proxy란 무엇인가?
Proxy 객체는 대상 객체(target)를 감싸서, 대상 객체에 대한 작업을 제어할 수 있는 객체입니다. 속성 조회, 설정, 열거, 함수 호출 등 객체에 가해지는 다양한 동작을 가로챌 수 있습니다.
기본 문법
const proxy = new Proxy(target, handler);
- target: 감싸질 대상 객체 (데이터 본체)
- handler: 작업을 가로채는 '트랩(trap)'들을 담고 있는 객체
핵심 트랩: get과 set
가장 흔히 사용되는 두 가지 트랩은 속성을 읽을 때 발생하는 get과 속성을 수정할 때 발생하는 set입니다.
1. 기본 동작 가로채기
const user = {
name: "Duck",
age: 20
};
const userProxy = new Proxy(user, {
get(target, prop) {
console.log(`${prop} 속성에 접근했습니다.`);
return target[prop];
},
set(target, prop, value) {
if (prop === 'age' && value < 0) {
throw new Error("나이는 음수가 될 수 없습니다.");
}
console.log(`${prop} 속성을 ${value}로 변경합니다.`);
target[prop] = value;
return true;
}
});
console.log(userProxy.name); // "name 속성에 접근했습니다." 출력 후 "Duck" 반환
userProxy.age = 25; // "age 속성을 25로 변경합니다." 출력
// userProxy.age = -1; // Error: 나이는 음수가 될 수 없습니다.
실전 활용 사례
Proxy는 단순히 로그를 찍는 용도를 넘어 매우 강력한 기능을 구현하는 데 사용됩니다.
1. 데이터 검증 (Validation)
위의 예시처럼 객체에 잘못된 값이 들어오는 것을 방지하는 방어 로직을 객체 외부(Proxy)에서 깔끔하게 관리할 수 있습니다.
2. 기본값 설정 (Default Value)
존재하지 않는 속성에 접근할 때 undefined 대신 미리 정의된 기본값을 반환하도록 설계할 수 있습니다.
const withDefault = (target, defaultValue) => new Proxy(target, {
get: (obj, prop) => (prop in obj ? obj[prop] : defaultValue)
});
const settings = withDefault({ theme: 'dark' }, 'light');
console.log(settings.theme); // 'dark'
console.log(settings.fontSize); // 'light' (기본값)
3. 반응형 상태 관리 (Reactivity)
Vue 3의 reactive API는 Proxy를 기반으로 동작합니다. 객체의 값이 바뀔 때마다 자동으로 UI를 업데이트하거나 특정 함수를 실행하도록 만들 수 있습니다.
function createReactive(target, callback) {
return new Proxy(target, {
set(target, prop, value) {
const result = Reflect.set(target, prop, value);
callback(prop, value); // 값이 변경될 때마다 콜백 실행
return result;
}
});
}
const state = createReactive({ count: 0 }, (prop, val) => {
console.log(`상태 변경 감지! ${prop} -> ${val}`);
});
state.count++; // "상태 변경 감지! count -> 1"
💡 여담: Vue 3는 왜 갑자기 Proxy로 갈아탔을까? Vue 2까지는 상태 변화를 감지하기 위해
Object.defineProperty를 사용했습니다. 하지만 여기에는 치명적인 한계가 있었죠.
- 추가/삭제 감지 불가: 처음에 정의되지 않은 속성을 나중에 추가하거나 삭제하면 Vue가 이를 알 방법이 없었습니다. 그래서
Vue.set()같은 별도의 메서드를 써야만 했죠.- 배열의 비애: 배열의 인덱스를 직접 수정하거나 길이를 조절하는 것도 감지하지 못했습니다.
- 성능: 초기화 시 모든 객체의 속성을 하나하나 돌며 getter/setter로 변환해야 해서, 데이터가 크면 초기 로딩이 느려졌습니다. Vue 3는 Proxy를 도입하며 이 모든 문제를 해결했습니다. Proxy는 객체 전체를 감싸기 때문에 새로운 속성 추가도, 배열의 변화도 아무런 설정 없이 모두 가로챌 수 있습니다. 특히 "실제 사용할 때만" 중첩된 객체를 Proxy로 만드는 Lazy(지연) 방식을 채택해 초기 성능까지 비약적으로 향상시켰습니다.
⚠️ 반드시 알아야 할 주의사항 (Deep Dive)
Proxy는 매우 강력하지만, 그만큼 시스템의 복잡도를 높이며 예기치 못한 버그를 유발할 수 있습니다. 실무 도입 전 다음 세 가지를 깊이 있게 이해해야 합니다.
1. 디버깅의 난해함: "정체성 위기"
Proxy는 원본 객체가 아닙니다. 이 단순한 사실이 디버깅을 지옥으로 만들 수 있습니다.
- 객체 정체성 (
proxy !== target): 원본 객체와 Proxy는 서로 다른 메모리 주소를 가집니다. 만약 외부 라이브러리나 내부 로직에서map.set(target, value)를 하고map.get(proxy)를 한다면undefined를 만나게 됩니다. - 스택 트레이스의 복잡성: 모든 작업이 Proxy 트랩을 거치기 때문에, 에러 발생 시 호출 스택에 Proxy 내부 로직이 섞여 들어와 실제 원인이 어디인지 파악하기 어려워질 수 있습니다.
- 콘솔 출력: 브라우저 콘솔에서 Proxy 객체를 출력하면 내부 속성이 한눈에 보이지 않고
[[Target]],[[Handler]]내부 슬롯을 타고 들어가야 원본 데이터를 볼 수 있어 직관성이 떨어집니다.
2. 성능 오버헤드: "V8 엔진의 미소 짓지 않는 최적화"
일반 객체 접근은 최신 JS 엔진(V8 등)에서 **'Fast Path'**라는 고도로 최적화된 경로를 탑재하고 있습니다. 하지만 Proxy를 씌우는 순간 이 최적화는 무력화됩니다.
- JIT 컴파일 우회: 매번 트랩 함수를 실행해야 하므로 엔진이 인라인 캐싱(Inline Caching) 등의 기법을 적용할 수 없습니다.
- 수치적 오버헤드: 벤치마크 결과에 따르면, 단순히 속성을 읽고 쓰는 작업에서 Proxy는 일반 객체 접근보다 약 10배에서 20배까지 느려질 수 있습니다.
- 권장 사항: 초당 수백만 번의 연산이 필요한 게임 엔진이나 대규모 데이터 처리 루프 안에서는 Proxy 사용을 지양해야 합니다. 반면, 일반적인 UI 상태 관리(비교적 낮은 빈도)에서는 이 오버헤드가 사용자 경험을 해칠 정도는 아닙니다.
3. 비공개 필드(#) 이슈와 Reflect의 마법
가장 까다로운 부분은 클래스의 **비공개 필드(#private)**를 가진 객체를 Proxy로 감쌀 때입니다.
문제 상황
비공개 필드는 해당 클래스의 '진짜 인스턴스'가 아니면 접근할 수 없습니다. Proxy가 this가 되어 메서드를 실행하려고 하면, 엔진은 "너는 우리 클래스 인스턴스가 아니잖아!"라며 에러를 던집니다.
class SecretVault {
#code = "1234";
getCode() { return this.#code; }
}
const vault = new SecretVault();
const proxy = new Proxy(vault, {});
// console.log(proxy.getCode()); // TypeError 발생!
해결책: Reflect와 receiver의 이해
이때 필요한 것이 Reflect API입니다. Reflect.get(target, prop, receiver)의 세 번째 인자인 receiver는 this를 제어하는 열쇠입니다.
하지만 비공개 필드 문제를 해결하려면, 메서드를 가져올 때 this가 Proxy가 아닌 **원본 target**을 바라보도록 강제로 바인딩해줘야 합니다.
const safeHandler = {
get(target, prop, receiver) {
const value = Reflect.get(target, prop, receiver);
// 만약 가져온 것이 함수라면, this를 target으로 고정(bind)하여 반환
if (typeof value === 'function') {
return value.bind(target);
}
return value;
}
};
Reflect를 써야 하는 진짜 이유: 단순히 target[prop]을 쓰는 것보다 Reflect.get을 권장하는 이유는 상속 관계에서 프로토타입 체인이 꼬이지 않고, 에러 핸들링이 더 선언적이며 부수 효과를 최소화할 수 있기 때문입니다. 특히 receiver를 통해 Proxy 체이닝 상황에서도 정확한 this 전파를 보장합니다.
마무리하며
Proxy는 객체의 원형을 건드리지 않으면서도 기능을 확장하고 제어할 수 있는 '코드 위의 코드'를 작성하게 해줍니다. 하지만 방금 살펴본 정체성 문제와 성능 오버헤드, 그리고 비공개 필드와의 우정 문제 등을 고려하지 않으면 양날의 검이 될 수 있습니다.
여러분의 프로젝트에서 Proxy를 도입할 때는 **"이것이 시스템 전체의 디버깅 경험과 성능에 어떤 영향을 줄 것인가?"**를 먼저 고민해 보시길 바랍니다.
새 글 알림 받기
실무에서 바로 써먹을 수 있는 개발 팁과 경험담을 받아보세요
개인정보는 뉴스레터 발송 목적으로만 사용되며, 언제든 구독을 해지할 수 있습니다.