Vue가 채택한 Proxy, React는 왜 안 사용할까?

작성자
현우
작성시각
수정시각
레퍼런스
Vacío
Proxy는 JavaScript 객체에 대한 접근을 가로채서 중간에서 원하는 동작을 끼워 넣을 수 있는 기능이다. Vue 3의 반응형 시스템이 이 위에서 동작하고, MobX도 같은 방식을 쓴다.
Vue 3를 공부하다가 reactive()로 감싼 객체의 프로퍼티를 직접 수정했는데 화면이 자동으로 업데이트되는 걸 보고 신기했다. React에서는 반드시 setState를 호출해야 하는데, Vue는 그냥 obj.count++만 해도 자동으로 반응성을 유지하는데, 내부에서 어떤 일이 일어나고 있을까?

Proxy 이전에 라이브러리는 어떻게 만들어졌을까?

전에는 SPA 라는 개념 자체가 생소했고, MPA 개념으로 웹을 주로 사용해왔다. Proxy 라는 강력한 기능이 나오기 전에는 SPA 라이브러리는 어떻게 구성이 되었는지 너무 궁금했다.
현재는 React와 Vue를 섞어쓰다보니 메이저 버젼이 바뀌면서 아예 내부 패러다임이 변경된 Vue를 기준으로 알아보자면 Vue 3 이전, Vue 2는 Object.defineProperty()로 반응형을 구현했다.
// Vue 2 방식 (단순화)
function defineReactive(obj, key, value) {
  Object.defineProperty(obj, key, {
    get() {
      // 의존성 추적
      track(key)
      return value
    },
    set(newValue) {
      value = newValue
      // 변경 알림
      trigger(key)
    }
  })
}
Object.defineProperty는 이미 존재하는 프로퍼티의 getter/setter만 가로챌 수 있다. 그래서 Vue 2에는 구조적인 한계가 있었다.
// Vue 2에서 반응형이 동작하지 않는 경우
const state = Vue.observable({ items: [] })

state.items.push('new item')    // ← 배열 변경 미감지 (일부 메서드만 패치됨)
state.newProp = 'hello'         // ← 새로운 프로퍼티 추가 미감지
delete state.items              // ← 삭제 미감지
이 문제를 해결하려고 Vue 2는 배열 메서드를 직접 패치하고, Vue.set()이라는 특별한 API를 만들었다. 근본적인 해결이 아니라 우회였다.
2015년 ES6에 Proxy가 추가됐다. Vue 3는 2020년에 이 Proxy를 기반으로 반응형 시스템을 완전히 재설계했다.

Proxy의 내부 동작

new Proxy(target, handler)target 객체를 감싸는 래퍼를 만든다. 이 래퍼에 대한 모든 작업(읽기, 쓰기, 삭제 등)이 handler트랩(trap) 을 통해 가로채진다.
const target = { count: 0 }

const proxy = new Proxy(target, {
  get(target, key, receiver) {           // ← 프로퍼티 읽기 시 호출
    console.log(`get: ${key}`)
    return Reflect.get(target, key, receiver)  // ← 원래 동작 수행
  },
  set(target, key, value, receiver) {    // ← 프로퍼티 쓰기 시 호출
    console.log(`set: ${key} = ${value}`)
    return Reflect.set(target, key, value, receiver)
  }
})

proxy.count        // 출력: "get: count"
proxy.count = 5    // 출력: "set: count = 5"
proxy.newProp = 1  // 출력: "set: newProp = 1" ← 새 프로퍼티도 감지됨
Reflect는 Proxy 트랩과 1:1로 대응하는 API다. 트랩 안에서 원래 동작을 수행할 때 사용한다. target[key]로 직접 접근하는 것과 결과는 같지만, receiver(프록시 자신)를 올바르게 처리하기 위해 Reflect를 쓰는 것이 안전하다.
트랩의 종류
Proxy가 가로챌 수 있는 작업은 13가지다.
const handler = {
  get(target, key, receiver) {},          // obj.key
  set(target, key, value, receiver) {},   // obj.key = value
  has(target, key) {},                    // key in obj
  deleteProperty(target, key) {},         // delete obj.key
  apply(target, thisArg, args) {},        // fn()     ← 함수에 적용
  construct(target, args) {},             // new Fn() ← 생성자에 적용
  ownKeys(target) {},                     // Object.keys(obj)
  getOwnPropertyDescriptor(target, key) {},
  defineProperty(target, key, desc) {},
  getPrototypeOf(target) {},
  setPrototypeOf(target, proto) {},
  isExtensible(target) {},
  preventExtensions(target) {},
}
Object.definePropertyget/set만 가로챌 수 있었다. Proxy는 객체에 대한 거의 모든 작업을 가로챌 수 있다. 이게 Vue 3가 Vue 2의 한계를 전부 해결한 이유다.

Vue 3가 Proxy로 반응형을 구현하는 방법

의존성 저장 구조
Vue 3는 아래 구조로 의존성을 추적한다.
targetMap (WeakMap)
  target 객체 → depsMap (Map)
    프로퍼티 key → dep (Set<effect>)
WeakMap을 쓰는 이유는 target 객체가 가비지 컬렉션될 때 자동으로 정리되기 때문이다.
get 트랩 — 의존성 수집
// Vue 3 내부 (단순화)
let activeEffect = null   // ← 현재 실행 중인 effect

function get(target, key, receiver) {
  const result = Reflect.get(target, key, receiver)

  // activeEffect가 있으면 이 키를 의존성으로 등록
  track(target, key)
  //  targetMap[target][key].add(activeEffect)

  return result
}
set 트랩 — 변경 알림
function set(target, key, value, receiver) {
  const result = Reflect.set(target, key, value, receiver)

  // 이 키에 등록된 모든 effect를 실행
  trigger(target, key)
  //  targetMap[target][key].forEach(effect => effect())

  return result
}
전체 흐름
import { reactive, effect } from '@vue/reactivity'

const state = reactive({ count: 0 })   // ← Proxy 생성

effect(() => {
  // 1. activeEffect = 이 함수로 설정
  // 2. state.count 읽기 → get 트랩 → track() 호출
  //    → targetMap[state]["count"].add(현재 effect)
  console.log(state.count)
  // 3. activeEffect = null
})

state.count = 1
// → set 트랩 → trigger() 호출
// → targetMap[state]["count"]에 등록된 effect 실행
// → 콘솔에 1 출력
실제 실행 순서를 단계별로 보면 이렇다.
1. reactive({ count: 0 }) 호출
   → new Proxy(target, handler) 생성

2. effect(fn) 호출
   → activeEffect = fn
   → fn() 실행
     → state.count 읽기
     → get 트랩 발동
     → track(state, "count")
       → targetMap.get(state).get("count").add(fn)
     → 0 반환
   → activeEffect = null

3. state.count = 1 실행
   → set 트랩 발동
   → Reflect.set으로 원본 업데이트
   → trigger(state, "count")
     → targetMap.get(state).get("count")
       → [fn] 실행
       → 콘솔에 1 출력
reactive()로 감싼 객체의 프로퍼티를 직접 수정해도 화면이 업데이트되는 이유가 이것이다. 할당 연산자(=)가 set 트랩을 발동시키고, 그게 등록된 effect들을 실행한다.
중첩 객체는 어떻게 처리할까
reactive({ user: { name: 'Kim' } })처럼 중첩된 객체에서 state.user.name을 읽으면 어떻게 될까.
get 트랩이 user를 반환할 때, 반환값이 객체이면 그 객체도 Proxy로 감싸서 반환한다. 중첩 깊이에 상관없이 반응형이 동작하는 이유다.
get(target, key, receiver) {
  const result = Reflect.get(target, key, receiver)

  track(target, key)

  // 반환값이 객체이면 재귀적으로 reactive 적용
  if (typeof result === 'object' && result !== null) {
    return reactive(result)   // ← 이미 Proxy면 캐시에서 반환
  }

  return result
}
💬
Vue 3의 반응형 시스템 핵심은 세 가지(reactive, track, trigger)다.
* effect() 는 세 가지를 사용하는 부수 효과와 같은 소비자 역할이라 직접적인 영향을 주지는 않는다.
이 effect() 는 ref나 reactive가 내부 로직에서만 쓰인다면 활성화되지 않지만, 해당 반응형 값이 템플릿 문법에 쓰이게 되면 Vue가 컴포넌트를 마운트할 때 렌더 함수 자체를 effect로 등록해준다.
reactive()  — 객체를 Proxy로 감싸서 반응형으로 만든다
effect()    — 반응형 데이터를 읽는 함수를 등록한다
track()     — get 트랩에서 현재 effect를 의존성으로 기록한다
trigger()   — set 트랩에서 의존성에 등록된 effect를 실행한다
reactive() — Proxy로 감싸기
 const state = reactive({ count: 0 })
state.count를 읽을 때, 쓸 때 중간에 끼어들 수 있게 Proxy로 감싼다.
get 트랩 → track() 호출
set 트랩 → trigger() 호출
effect() — "이 함수를 반응형으로 만들겠다" 선언
effect(() => {
    console.log(state.count) // 이 줄이 실행될 때 track()이 호출됨
})
effect 안의 함수를 즉시 한 번 실행한다.
실행되는 동안 "
지금 실행 중인 effect = 나야" 라고 전역에 표시하는 작업이다.
track() — 의존성 기록
get 트랩에서 호출되며, 지금 실행 중인 effect가 있다면 state.count ↔ 이 effect 연결을 저장한다.
이 연결 정보가 Map에 쌓이게 된다 : state.count → [effect1, effect2, ...]
trigger() — 의존성 실행
state.count = 1 // 여기서 trigger()
set 트랩에서 실행이되며, state.count에 연결된 effect를 모두 꺼내서 다시 실행한다.
💬
실제로 템플릿에 ref , reactive 가 포함될 때는 아래와 같은 일이 발생한다.
컴포넌트 마운트
    → Vue 내부에서 effect(렌더 함수) 등록
    → 렌더 함수 실행
        → 템플릿에서 count.value 읽힘
        → track() → "렌더 effect ↔ count.value" 연결

// <template><div>{{ count }}</div></template>
// 컴파일 결과
function render() {
  return h('div', count.value) // 가상 DOM 생성
}

// 아래 effect에 등록이 된다.
// count.value가 변경되면 trigger → 아래 렌더 함수 재실행 → DOM 업데이트 진행
effect(() => {
  const vnode = render()       // 가상 DOM 생성
  patch(prevVnode, vnode)      // 실제 DOM 업데이트
})

React는 왜 Proxy를 채택하지 않을까?

Vue를 사용하게 되면 자연스럽게 비교하게 되는 대상인데,
React도 Proxy를 쓰면 setState 없이 그냥 state.count++만 해도 될 텐데 왜 쓰지 않을까?
이건 React의 설계 철학과 아키텍처 전체와 연결된 문제이기도 하다.
이유 1 — React의 상태 업데이트는 의도적으로 명시적이다
React의 핵심 원칙 중 하나는 UI = f(state)다.
상태가 바뀌면 그 결과로 UI가 결정된다. 여기서 중요한 건 상태 변경이 추적 가능하고 예측 가능해야 한다는 것이다.
// React의 방식 — 명시적
setCount(prev => prev + 1)
// 누가, 언제, 어디서 상태를 바꿨는지 명확히 알 수 있다

// Proxy 방식이었다면
count++
// 어디서 변경이 일어났는지 추적하기 어려움
Proxy는 변경을 암묵적으로 가로챈다. 코드 어디서든 객체 프로퍼티를 수정하면 트랩이 발동한다. (Vue가 양방향인 이유이라고 하는 이유이다, 내부적으로 상태를 개발자가 어떻게 변하는지 명시적으로 알 필요가 없다) 이건 편리하지만, 큰 앱에서 상태가 어디서 바뀌는지 추적하기 어렵게 만든다.
이유 2 — Fiber 스케줄러와 동시성 기능
React 18의 Concurrent Mode, startTransition, Suspense는 모두 업데이트를 지연하거나 중단하거나 우선순위를 조정하는 기능이다.
// React가 업데이트를 스케줄링하는 방식
startTransition(() => {
  setItems(heavyComputation())   // ← 우선순위 낮게 처리
})

// 사용자 입력 업데이트는 즉시 처리
setInputValue(e.target.value)
setState를 통해 업데이트가 들어오면 React는 이를 큐에 쌓고 스케줄러가 적절한 타이밍에 처리한다. 업데이트를 일시 중지하거나 취소하거나 재시도할 수 있다.
Proxy 방식에서는 set 트랩이 동기적으로 즉시 발동한다. React 스케줄러가 개입할 여지가 없다. 시간 분할(time-slicing)이나 업데이트 우선순위 조정이 불가능해진다.
이유 3 — 불변성 기반 최적화
React의 메모이제이션은 참조 비교(===)를 기반으로 한다.
// React.memo, useMemo, useCallback의 동작 원리
const prev = { count: 0 }
const next = { count: 0 }

prev === next  // false → 다른 객체이므로 리렌더링

// 불변 업데이트
const updated = { ...prev, count: 1 }
prev === updated  // false → 명확히 다름
Proxy 방식으로 객체를 직접 수정하면 참조가 바뀌지 않는다.
const state = reactive({ count: 0 })

const before = state
state.count++
const after = state

before === after  // true ← 같은 Proxy 객체
React가 before === after를 비교해서 변경을 감지하는 구조라면, Proxy 기반 뮤테이션은 변경을 감지하지 못한다. React의 최적화 전략 전체가 불변성 위에 구축되어 있다.
이유 4 — 타임트래블 디버깅과 직렬화
Redux DevTools의 타임트래블 기능은 각 시점의 상태 스냅샷을 저장해서 되감는 방식이다. 불변 상태는 각 업데이트마다 새 객체가 만들어지기 때문에 스냅샷 저장이 자연스럽다.
Proxy 기반 뮤테이션은 상태를 직렬화하기 어렵고, 이전 상태로 되돌아가는 기능도 구현하기 복잡하다.

Vue와 React의 반응형 모델 비교

항목
Vue 3 (Proxy)
React (불변성)
상태 변경 방식
직접 수정 (obj.key = value)
setState / useState
변경 감지
Proxy set 트랩 (암묵적)
참조 비교 (명시적)
업데이트 스케줄링
동기적, 즉시 트리거
비동기, 스케줄러 관리
메모이제이션 기반
의존성 추적
참조 동일성 (===)
중첩 객체 반응형
자동 (재귀 Proxy)
직접 불변 업데이트 필요
디버깅 추적
암묵적 변경 추적 어려움
setState 호출 지점 명확
동시성 지원
어려움
Concurrent Mode 지원
학습 곡선
낮음 (직관적)
불변성 개념 학습 필요

Proxy를 실제로 쓸 만한 상황이 언제일까?

Vue나 MobX 같은 라이브러리를 쓰지 않고 Proxy를 직접 활용하면 유용한 경우들이 있다.
유효성 검사가 필요한 객체
function createValidatedUser(initial) {
  return new Proxy(initial, {
    set(target, key, value) {
      if (key === 'age' && typeof value !== 'number') {
        throw new TypeError('age는 숫자여야 합니다')  // ← 쓰기 시점에 검증
      }
      if (key === 'email' && !value.includes('@')) {
        throw new Error('올바른 이메일 형식이 아닙니다')
      }
      return Reflect.set(target, key, value)
    }
  })
}

const user = createValidatedUser({ name: 'Kim', age: 20 })
user.age = 'abc'   // ← TypeError 발생
user.email = 'invalid'  // ← Error 발생
접근 로그가 필요한 객체
function withLogging(target, label) {
  return new Proxy(target, {
    get(target, key, receiver) {
      console.log(`[${label}] get: ${String(key)}`)
      return Reflect.get(target, key, receiver)
    },
    set(target, key, value, receiver) {
      console.log(`[${label}] set: ${String(key)} = ${JSON.stringify(value)}`)
      return Reflect.set(target, key, value, receiver)
    }
  })
}

const config = withLogging({ timeout: 3000 }, 'Config')
config.timeout       // 출력: [Config] get: timeout
config.timeout = 5000  // 출력: [Config] set: timeout = 5000
존재하지 않는 프로퍼티에 기본값 제공
const withDefault = (target, defaultValue) =>
  new Proxy(target, {
    get(target, key) {
      return key in target
        ? Reflect.get(target, key)
        : defaultValue   // ← 없는 키 접근 시 기본값 반환
    }
  })

const scores = withDefault({}, 0)
scores.math = 90
console.log(scores.math)     // 90
console.log(scores.english)  // 0 ← 기본값
Proxy는 객체 동작을 커스터마이징하는 강력한 도구라고 생각이 든다. Vue의 reactive()가 내부적으로 어떻게 동작하는지 알고 나면 프레임워크 코드를 작성할 때마다 내부 동작이 너무 신기하게 느껴지는 것 같다.
전에 Proxy 가 무엇이냐 질문이 들어왔을 때, Proxy 객체로 객체를 래핑하면 자동으로 감지한다고만 얼렁뚱땅 넘어가서 가물가물해서 정리를 해보았다 ^,^ ..