깊은 구조와 배열의 함정

9

발행일: 2025년 05월 30일

useSnapshot이라는 마법의 다리를 놓은 다이시 카토는 잠시 안도의 한숨을 내쉬었다. React와의 통합이라는 가장 큰 산을 넘었다고 생각했다. 이제 남은 것은 세부적인 구현과 최적화라고 여겼다. 그는 자신 있게 좀 더 복잡한 상태 구조를 Valtio로 다루는 테스트를 시작했다.

import { proxy, useSnapshot } from 'valtio';

const state = proxy({
  user: {
    name: '다이시 카토',
    address: {
      city: '도쿄',
      zip: '100-0000',
    },
  },
  posts: [
    { id: 1, title: 'Valtio 첫인상' },
    { id: 2, title: 'Proxy는 강력하다' },
  ],
});

function AddressEditor() {
  const snap = useSnapshot(state.user.address); // 주소 객체의 스냅샷 구독

  const handleCityChange = (e) => {
    // 문제 발생 가능 지점 1: 깊은 객체 직접 변경
    state.user.address.city = e.target.value;
  };

  return (
    <div>
      <p>도시: {snap.city}</p>
      <input type="text" value={snap.city} onChange={handleCityChange} />
    </div>
  );
}

function PostAdder() {
  const snap = useSnapshot(state.posts); // 게시물 배열의 스냅샷 구독

  const addPost = () => {
    // 문제 발생 가능 지점 2: 배열 메서드 직접 호출
    state.posts.push({ id: Date.now(), title: '새로운 포스트' });
    console.log('포스트 추가됨:', state.posts); // 콘솔에는 반영될까? 화면은?
  };

  return (
    <div>
      <button onClick={addPost}>포스트 추가</button>
      <ul>
        {snap.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

이론적으로는 완벽했다. state.user.address.city = ... 처럼 깊숙이 중첩된 객체의 속성을 직접 변경해도, state.posts.push(...) 처럼 배열 메서드를 호출해도, Proxy가 모든 것을 감지하고 useSnapshot을 통해 React에게 알려줘야 했다.

하지만 현실은 그의 기대를 배신했다. 도시 이름을 변경해도 AddressEditor 컴포넌트가 즉각적으로 반응하지 않는 경우가 발생했고, 더 심각한 것은 state.posts.push(...)를 호출해도 PostAdder 컴포넌트가 새로운 게시물을 화면에 그리지 못하는 현상이었다. 콘솔에는 배열이 변경된 것으로 나타나기도 했지만, React는 감감무소식이었다.

"이럴 리가… Proxy가 모든 걸 감지해야 하는데!"

카토의 미간에 깊은 골이 패였다. 그는 Proxy 핸들러 코드를 다시 들여다보았다. 문제는 생각보다 훨씬 교묘하고 깊은 곳에 있었다.

단순히 최상위 객체만 Proxy로 감싸는 것으로는 부족했다. state.user에 접근할 때, 반환되는 user 객체 역시 Proxy여야 했다. 그리고 state.user.address에 접근할 때 반환되는 address 객체마저도 Proxy로 감싸져야, 최종적으로 city 속성의 변경을 set 트랩이 감지할 수 있었다. 즉, 재귀적인 프록시(Recursive Proxying)가 필요했다.

배열은 더욱 까다로운 존재였다. push, pop, splice, sort 같은 메서드들은 배열 내부의 데이터를 직접 변경한다. Proxy의 set 트랩은 배열의 특정 인덱스(state.posts = ...)나 length 속성이 변경될 때는 작동하지만, push 같은 메서드 호출 자체를 직접적으로 감지하지는 못했다. 어떻게 이 메서드 호출로 인한 변경까지 추적할 것인가?

"Proxy… 생각보다 훨씬 다루기 까다로운 녀석이었군."

카토는 잠시 막막함을 느꼈다. '직관적인 변경'이라는 아름다운 목표 뒤에는, 이처럼 복잡하고 지저분한 기술적 함정들이 도사리고 있었다. 그는 키보드 앞에서 몇 시간을 고민하고 실험하며 보냈다. 다양한 접근법을 시도했다.

  • 모든 중첩 객체와 배열을 미리 재귀적으로 Proxy로 감싸는 방식? -> 초기 생성 비용이 크고 비효율적일 수 있다.
  • 객체 속성에 접근하는 get 트랩에서, 반환값이 객체나 배열이면 즉석에서 Proxy로 감싸서 반환하는 방식? -> 좀 더 영리하지만, 매번 접근 시 오버헤드가 발생할 수 있다.
  • 배열 메서드를 가로채기 위해 get 트랩에서 push 같은 함수 요청이 오면, 원본 메서드를 실행하고 추가로 변경 알림을 발생시키는 래핑(wrapping)된 함수를 반환하는 방식? -> 구현이 복잡하지만 가장 확실한 방법일 수 있다.

그는 포기하지 않고 Proxy 트랩들을 더욱 정교하게 조합하고, 내부적으로 변경된 부분만 효율적으로 추적하여 스냅샷을 생성하는 최적화 로직을 설계하기 시작했다. 단순히 전체 상태를 복사하는 것이 아니라, 변경된 부분과 관련된 최소한의 정보만을 이용하여 새로운 스냅샷을 만드는, 마치 외과의사의 수술처럼 정밀한 작업이 필요했다.

수많은 시행착오 끝에, 마침내 그의 코드가 다시 생명을 얻었다. state.user.address.city = '...'state.posts.push(...) 가 의도대로 작동하며, 연결된 React 컴포넌트들이 즉각적으로 반응했다! 깊은 구조의 객체도, 예측 불가능해 보였던 배열 메서드도, 그의 정교한 Proxy 핸들러와 스냅샷 생성 로직 앞에서는 더 이상 문제가 되지 않았다.

"후우…"

카토는 깊은 숨을 내쉬었다. 화면에는 여전히 state.posts.push(...) 라는, 믿을 수 없을 만큼 단순한 코드가 남아있었다. 하지만 그 한 줄의 간결함 뒤에는, 재귀적 프록시 처리, 배열 메서드 인터셉션, 최적화된 스냅샷 생성 로직이라는, 보이지 않는 수많은 노력과 복잡성이 숨겨져 있었다.

"단순해 보이는 API 뒤에는… 언제나 복잡한 노력이 숨어있지."

그는 씁쓸하게 웃으며 중얼거렸다. 하지만 그 노력 덕분에, 개발자들은 그 복잡성을 전혀 알 필요 없이, Valtio가 제공하는 '직관적인 변경'이라는 달콤한 과실만을 맛볼 수 있을 터였다. Valtio는 단순한 아이디어의 산물이 아니라, 치열한 기술적 난관을 극복하며 빚어낸 장인정신의 결정체였다. 이제 다음 과제는 성능, 이 마법이 현실적인 속도를 낼 수 있을지 증명하는 것이었다.