useSnapshot 훅의 설계
제8화
발행일: 2025년 05월 29일
불변 스냅샷(Immutable Snapshot). 가변적인 프록시 상태와 불변성을 사랑하는 React 사이를 잇는 마법의 다리. 다이시 카토의 머릿속에서 그 개념은 명확해졌다. 이제 남은 과제는 이 스냅샷을 어떻게 React 컴포넌트에게 우아하고 효율적으로 전달할 것인가였다.
"단순히 스냅샷을 생성하는 것만으로는 부족해. 컴포넌트가 이 스냅샷의 '최신 버전'을 안정적으로 받아보고, 변경될 때마다 알아서 다시 그려지도록 만들어야 해."
그의 손가락이 다시 키보드 위를 질주했다. React의 세계에서 외부 시스템의 상태를 컴포넌트와 연결하는 가장 자연스러운 방법은 단연 커스텀 훅(Custom Hook)이었다. 그는 망설임 없이 새로운 훅의 이름을 결정했다.
useSnapshot
.
이름 그 자체가 모든 것을 말해주고 있었다. '스냅샷을 사용한다.' 이보다 더 직관적일 수는 없었다.
그는 훅의 인터페이스를 설계했다. 사용법은 지극히 단순해야 했다. 개발자는 그저 Valtio의 proxy()
함수로 생성된 프록시 상태 객체를 이 훅에 넘겨주기만 하면 되었다.
import { useSnapshot } from 'valtio'; // 미래의 모습
import { state } from './store'; // Valtio 프록시 상태
function CounterDisplay() {
// 마법의 한 줄!
const snap = useSnapshot(state);
// 이제 'snap' 객체를 통해 상태를 안전하게 읽는다!
return <p>Count: {snap.count}</p>;
}
function UserProfile() {
const snap = useSnapshot(state);
return <p>User: {snap.user.name}</p>;
}
"이거야…! useSnapshot
이 프록시 상태(state
)를 받아서, 최신의 불변 스냅샷(snap
)을 돌려주는 거지."
useSnapshot
훅의 내부 작동 원리는 다음과 같아야 했다.
- 구독(Subscription): 훅은 인자로 받은 프록시 상태(
state
)의 변경 알림을 내부적으로 구독한다. Valtio의subscribe
함수 혹은 더 발전된 형태의 메커니즘을 사용하는 것이다. - 스냅샷 반환: 훅은 현재 시점의 프록시 상태에 대한 최신 불변 스냅샷을 생성하거나 가져와서 컴포넌트에게 반환한다. 이
snap
객체는 순수한 읽기 전용(read-only) 데이터다. - 리렌더링 트리거: 프록시 상태(
state
)에 변경이 발생하여 알림이 오면, 훅은 새로운 스냅샷을 생성한다. 그리고 중요한 것은, 이 새로운 스냅샷은 이전 스냅샷과 다른 참조(reference)를 가지도록 보장하는 것이다. React는 이 참조 변경을 감지하고, 해당 컴포넌트를 자동으로 리렌더링하게 된다.
"핵심은 분리야." 카토는 중얼거렸다. "상태를 변경하는 행위와 상태를 읽어서 사용하는 행위를 명확히 분리하는 것!"
- 상태 변경: 여전히 원본 프록시 객체(
state
)에 직접 수행한다.state.count++
,state.user.name = '...'
처럼 쉽고 직관적으로. - 상태 사용 (읽기): React 컴포넌트 내에서는
useSnapshot
훅이 반환한 불변 스냅샷(snap
)을 사용한다.snap.count
,snap.user.name
처럼 안전하게 읽기만 한다.
이로써 Valtio의 핵심 철학이 완성되었다.
"변경은 쉽게, 사용은 안전하게 (Mutate easily, Consume safely)"
개발자는 프록시 상태를 마치 평범한 자바스크립트 객체처럼 자유롭게 변경하는 편리함을 누리면서도, React 컴포넌트는 불변 스냅샷을 통해 예측 가능하고 안정적으로 상태를 사용할 수 있게 되는 것이다. useSnapshot
훅은 이 두 세계를 잇는 완벽한 통역가이자 다리 역할을 수행했다.
카토는 다시 간단한 테스트 컴포넌트를 수정했다.
import React from 'react';
import { useSnapshot } from 'valtio'; // 구현될 useSnapshot
import { state } from './store'; // Valtio 프록시 상태
function Counter() {
// 이제 스냅샷을 사용한다!
const snap = useSnapshot(state);
const handleClick = () => {
// 변경은 여전히 원본 프록시에 직접!
state.count++;
console.log('카운터 증가 시도됨. 프록시 값:', state.count);
};
// 화면에는 스냅샷의 값을 표시한다.
return (
<div>
<p>Count: {snap.count}</p> {/* 이제 이 값이 업데이트될 것이다! */}
<button onClick={handleClick}>증가</button>
</div>
);
}
그는 코드를 실행하며 상상했다. 버튼을 누르면 state.count++
가 실행되고, Proxy의 set
트랩이 변경을 감지하여 알림을 보낸다. useSnapshot
훅은 이 알림을 받고 새로운 스냅샷(예: { count: 1 }
)을 생성한다. 이 새로운 스냅샷은 이전 스냅샷({ count: 0 }
)과 다른 참조를 가지므로, React는 Counter
컴포넌트를 리렌더링한다. 마침내 화면의 Count: 0
이 Count: 1
로 바뀔 것이다!
"완벽해…!"
카토의 얼굴에 다시 환한 미소가 떠올랐다. React와의 통합이라는 가장 큰 난관을 넘을 결정적인 무기, useSnapshot
훅의 설계가 완료된 순간이었다. 가변성과 불변성이라는, 마치 상극처럼 보였던 두 개념이 Valtio 안에서 아름답게 조화를 이루기 시작했다.
하지만 그의 시선은 금세 다음 도전을 향했다. 스냅샷을 만드는 과정, 특히 객체나 배열이 깊게 중첩된 경우, 어떻게 효율적으로 처리할 것인가? Proxy 자체를 다루는 데 숨겨진 함정은 없을까? Valtio의 여정에는 여전히 풀어야 할 숙제들이 기다리고 있었다.