리액트 상태관리에 Proxy를? Valtio 내부 뜯어보기

October 14, 2025

"리액트 훅을 활용한 마이크로 상태 관리" 라는 책을 읽고 Valtio 를 알게 되었는데 이 라이브러리는 Proxy 를 사용하고 있었습니다. Javascript Proxy에 대해 사용되는 코드를 아직 본적이 없어서 흥미로웠습니다. 어떤 방식으로 활용하고 있을까!! 그래서 이참이 라이브러리를 좀 뜯어봤는데 그중 인상 깊었던 네 가지 포인트를 공유합니다.

1. Proxy

저는 Javascript에서 Proxy를 말만 들어봤지 제대로 써본적도 사용하는걸 본적도 없었습니다. 그래서 이 라이브러리에 끌렸던건데 이게 동작이 좀 재미있었습니다.

JavaScript의 Proxy

객체 Proxy를 사용하면 다른 객체에 대한 프록시를 만들 수 있으며, 프록시는 해당 객체의 기본 작업을 가로채고 다시 정의할 수 있습니다.

const target = { count: 0 };

const handler = {
  get(target, prop, receiver) {
    console.log(`${prop} 읽혔어!`); // 접근 감지!
    return target[prop];
  },
  set(target, prop, value) {
    console.log(`${prop}${value}로 바뀌었어!`); // 변경 감지!
    target[prop] = value;
    return true;
  },
};

const proxy = new Proxy(target, handler);

proxy.count++;
// "count 읽혔어!"
// "count이 1로 바뀌었어!"

핸들러 함수는 때때로 트랩 이라고 불리는데 , 아마도 대상 객체에 대한 호출을 트랩하기 때문일 것입니다. 참조 - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/GlobalObjects/Proxy

Valtio에서는 Proxy handler에 set, deleteProperty 를 넣고 사용하고 있었습니다.

set

set 에서는 이전값과 비교해서 다를경우 업데이트를 하고 업데이트 알림(notifyUpdate)을 보내고 있었습니다. 그런데 여기서 더 인상적인 건 이거였습니다.

const state = proxy({
	user: {
		profile: {
			name: 'John'
		}
	}
})

// 이렇게 깊숙한 변경도 자동으로 감지됨!
state.user.profile.name = 'Jane'

어떻게 가능할까? Valtio는 중첩된 객체도 자동으로 프록시화 합니다!

// set handler에서
const nextValue = !proxyStateMap.has(value) && canProxy(value)
? proxy(value)  // 객체면 자동으로 프록시 생성
: value

동작 원리

  1. 처음에 최상위 객체만 프록시로 감쌈
  2. 중첩 객체에 접근할 때, 그 객체가 아직 프록시가 아니면 즉시 프록시로 변환
  3. 새로 생성된 프록시들이 부모 프록시와 연결되어 변화 전파 체인 형성

즉, 객체를 할당할 때마다 그 객체도 프록시로 감싸버립니다. 그래서 아무리 깊은 변경도 감지할 수 있습니다.

기존 상태관리 라이브러리들(Redux, Zustand 등)은 불변성을 위해 새 객체를 만들거나 복잡한 업데이트 로직이 필요한데 Proxy 덕분에 자연스러운 mutation이 가능해서 빠르게 개발을 해야되는 상황이라면 편리할거 같았습니다.

deleteProperty

deleteProperty을 통해 삭제에 대한것도 삭제되면 업데이트 알림(notifyUpdate)을 보내도록 구성되어 있었습니다.

delete state.user.temp  // 이런 삭제도 자동으로 감지되어 상태 업데이트

notifyUpdate

각 변경사항은 notifyUpdate()를 통해 전파되고 있었습니다.

// 변경 시마다 호출
notifyUpdate(['set', ['user', 'name'], 'Jane', 'John'])
// 어떤 경로의 무엇이 바뀌었는지

이를 통해서 모든 리스너(React 컴포넌트, subscribe 콜백 등)에게 변화를 알리고 최종적으로 useSyncExternalStore를 거쳐서 React 리렌터링까지 연결됩니다.

2. WeakMap, WeakSet - 메모리 안전성

Valtio 코드를 보다보니 곳곳에 WeakMap과 WeakSet이 사용되고 있었습니다. 왜 일반 Map, Set 대신 WeakMap을 사용했을까 궁금했습니다.

WeakMap/WeakSet의 가장 큰 특징은 '약한 참조(weak reference)' 를 사용한다는 점입니다.
즉, 키로 사용된 객체가 더 이상 다른 곳에서 참조되지 않으면 그 엔트리는 자동으로 가비지 컬렉션 대상이 되어 제거됩니다.

만약 일반 Map을 썼다면, 컴포넌트가 언마운트돼도 그 객체가 계속 Map에 남아 메모리 누수가 발생했을 텐데 이런 구조 덕분에 Valtio는 프록시 상태와 관련된 참조를 안전하게 관리 하면서 불필요한 메모리 점유를 최소화 할 수 있었습니다.

실시간 상태 관리 라이브러리에서 메모리 안정성을 확보하는 핵심 포인트가 바로 이 부분같았습니다.

3. 접근 추적 시스템 - 선택적 리렌더링

Valtio 코드를 뜯어보면서 가장 인상깊었던곳은 다음 코드였습니다.

// useSnapshot 내부에서
const affected = useMemo(
	() => proxyObject && new WeakMap<object, unknown>(),
	[proxyObject],
)

이 WeakMap은 컴포넌트가 렌더링되면서 실제로 접근한 프로퍼티들을 기록하는 저장소 역할을 하고있습니다.

function Component() {
	const snap = useSnapshot(state);
	return <div>{snap.user.name}</div>;
	// 이때 'user', 'name' 접근이 affected에 기록됨
}

접근 추적의 실제 구현은 proxy-compare 라이브러리의 createProxyToCompare() 함수가 담당하고 있었고, 이 함수가 생성한 프록시 객체의 get 핸들러가 프로퍼티 접근을 감지하여 affected WeakMap에 기록하고 있었습니다. 그리고 상태 변경 시 isChanged() 함수가 이 WeakMap을 참조해서 실제로 접근한 프로퍼티만 변경되었는지 체크하여 관련 없는 프로퍼티가 바뀌었다면 이전 스냅샷을 재사용해서 불필요한 리렌더링을 방지하고 있었던!!

사실 저는 Valtio를 처음 공부할때 "접근한 것만 추적해서 리렌더링 최적화" 라는 문구를 보고 막연하게 이것도 Proxy를 사용하면서 따라오는 부가적인 기능인건가 싶었습니다. 그런데 알고보니 접근한 프로퍼티를 기록하는 역할의 프록시는 내부에 따로 있었다는걸 뒤늦게 알았습니다.😱😱

실제로는 상태 변경을 감지하는 Proxy접근을 추적하는 Proxy가 별개로 존재했던 것이죠!

  • valtio -> vanilla.ts의 Proxy: 상태 변경 감지 및 알림 (set, deleteProperty 핸들러)
  • createProxyToCompare의 Proxy: 프로퍼티 접근 추적 (get 핸들러 + WeakMap 기록)
function createProxyToCompare(target, affected, ...) {
    return new Proxy(target, {
      get(target, prop) {
        console.log(`${prop} 접근 감지!`)  // 여기서 접근 추적!

        // affected WeakMap에 접근 기록
        affected.set(target, { ...affected.get(target), [prop]: true })

        ...
      }
    })
  }

이 두 Proxy가 협력해서 "정확히 필요한 때만 리렌더링" 이 되도록 하고 있었습니다.👍👍 정말 적재적소에 적절하게 Proxy를 사용했구나 느껴졌습니다.

4. useSyncExternalStore

valtio의 핵심 로직은 완전히 React 밖에서 돌아갑니다.

// vanilla.js - 순수 JS 코드
const state = proxy({ count: 0 });
state.count++;  // React 없이도 완벽 동작

그런데 어떻게 React 컴포넌트가 이 외부 상태의 변화를 감지할까요?? 바로 useSyncExternalStore가 사용되고 있었습니다! useSyncExternalStore는 간단하게 외부에서 React 내부에 데이터를 넘길때 사용하는데 Valtio에서도 사용되고 있었습니다. Zustand에서도 사용되는걸 봤는데 상태 관리 라이브러리에서 벌써 2번째 봤네요!

useSnapshot의 간략한 내부 구조

// react.ts에서
const currSnapshot = useSyncExternalStore(
	// 1. subscribe 함수 - 상태 바뀌면 알려줘
	useCallback(
		(callback) => {
			const unsub = subscribe(proxyObject, callback, notifyInSync);
			callback(); // 즉시 한 번 호출
			return unsub;
		},
		[proxyObject, notifyInSync],
	),

	// 2. getSnapshot 함수 - 현재 값 뭐야?
	() => {
	  const nextSnapshot = snapshot(proxyObject);
	  // 실제 변경된 부분만 체크
	  if (!isChanged(lastSnapshot.current, nextSnapshot, affected)) {
		return lastSnapshot.current;  // 이전 스냅샷 재사용
	  }
	  return nextSnapshot;  // 새 스냅샷
	},

	// 3. getServerSnapshot 함수 - SSR용
	() => snapshot(proxyObject),
)

전체 동작 흐름

  1. 외부에서 상태 변경 state.count = 1
  2. Proxy handler가 변화 감지 → notifyUpdate 호출
  3. subscribe의 callback 실행 → React에게 "업데이트 필요!"
  4. useSyncExternalStore가 getSnapshot 호출 → 최신 스냅샷 요청
  5. isChanged()로 affected 기반 비교 → 필요시에만 새 스냅샷 반환
  6. 새 스냅샷이면 리렌더링, 같은 스냅샷이면 무시

회고

책으로 공부를 하다가 Valtio에 흥미가 생겨 코드를 직접 뜯어봤습니다. “아, 이렇게도 짤 수 있구나” 싶었고, 시야가 한층 넓어진 느낌을 받았습니다. 언젠가는 저도 이렇게 다양한 기술을 자유롭게 활용해보겠다는 꿈이 생긴 시간이었습니다.