객체의 속성을 읽거나 쓸 때, 그 동작 자체를 가로채서 다른 일을 하게 만들 수 있다면 어떨까요?

자바스크립트의 Proxy는 정확히 이 일을 합니다. 객체의 기본 동작(속성 읽기, 쓰기, 삭제 등)에 훅(hook) 을 걸어서 동작을 커스터마이징할 수 있는 메타프로그래밍 도구입니다.

Proxy란 — 객체의 기본 동작을 가로채는 래퍼

Proxy는 원본 객체(target) 앞에 놓이는 투명한 래퍼 입니다. 외부에서는 프록시를 통해 원본 객체와 동일하게 상호작용하지만, 프록시가 중간에서 동작을 가로채서 추가 로직을 실행할 수 있습니다.

JS
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) — 두 가지 구성 요소

JS
const proxy = new Proxy(target, handler);
  • target: 감싸려는 원본 객체 (배열, 함수, 다른 프록시도 가능)
  • handler: 트랩(trap)을 정의한 객체. 트랩이 없으면 원본 객체에 그대로 전달

handler에 아무 트랩도 없으면 원본 객체와 완전히 동일하게 동작합니다.

JS
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 — 속성 읽기

JS
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 — 속성 쓰기

JS
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 연산자

JS
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 — 속성 삭제

JS
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 — 함수 호출

JS
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 연산자

JS
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를 올바르게 전달

JS
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.getreceiver를 전달하면 getter 내부의 this가 실제 호출 객체(child)를 가리킵니다. target[prop]을 직접 쓰면 this가 항상 원본 target을 가리켜서 프로토타입 상속이 깨집니다.

Reflect를 쓰는 또 다른 이유

JS
// 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: 로깅 프록시

JS
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: 유효성 검사

JS
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: 읽기 전용 객체

JS
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의 한계

JS
// 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 기반 반응성

JS
// 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는 모든 동작마다 트랩을 거치기 때문에 일반 객체보다 느립니다.

JS
// 성능 비교 (대략적인 수치)
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 바인딩 문제

JS
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 트랩은 아무거나 반환해도 되는 게 아닙니다. 자바스크립트 엔진이 일관성을 보장하기 위한 규칙 을 강제합니다.

JS
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()를 호출하면 프록시가 완전히 무효화됩니다.

JS
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

실전 활용: 시간 제한 접근

JS
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()
hasin 연산자Reflect.has()
deletePropertydelete 연산자Reflect.deleteProperty()
apply함수 호출Reflect.apply()
constructnew 연산자Reflect.construct()
getOwnPropertyDescriptorObject.getOwnPropertyDescriptorReflect.getOwnPropertyDescriptor()
definePropertyObject.definePropertyReflect.defineProperty()
getPrototypeOfObject.getPrototypeOfReflect.getPrototypeOf()
setPrototypeOfObject.setPrototypeOfReflect.setPrototypeOf()
ownKeysObject.keys, for...inReflect.ownKeys()
isExtensibleObject.isExtensibleReflect.isExtensible()
preventExtensionsObject.preventExtensionsReflect.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()로 접근 권한을 나중에 회수하는 보안 패턴도 가능
댓글 로딩 중...