반디북
리액트 훅을 활용한 마이크로 상태 관리
Ch9
준영

사용 사례 시나리오 3: Valtio

Valtio의 주요 특징은 다음과 같다.

  • 앞서 설명한 라이브러리들과 다르게 변경 가능한 갱신 모델 기반
  • Zustand와 같은 모듈 상태 사용 (Provider가 필요하지 않음)
  • Proxy를 활용한 상태 사용 추적 으로 자동 리렌더링 최적화

또 다른 모듈 상태 라이브러리인 Valitio 살펴보기

Valito는 불변 상태 모델이 아닌 갱신 모델 기반의 상태 관리 라이브러리이다.
따라서 state.count++ 과 같이 일반 JavaScript 코드처럼 상태의 갱신이 가능하다.
그럼 이러한 변경사항을 어떻게 감지할까?
Valtio는 Proxy를 활용하여 이를 구현하였다.

✅ Valtio가 Proxy를 활용한 라이브러리라고 소개했을때, useSyncExternalStore 훅을 사용하지 않을까라는 생각을 했다.
우선 먼저, 실제로 소스코드를 확인해보니 Valtio에서 제공하는 함수인 proxy는 상태로 사용할 객체를 파라미터로 받아 JavaScript의 Proxy (opens in a new tab)를 생성하는 함수인 것을 확인할 수 있었다.
Proxy라는 객체가 JavaScript 안에 내장되어있는 줄은 몰랐는데, “한 객체에 대한 기본 작업을 가로채고 재정의하는 역할”을 수행한다고 한다.
따라서 책에서 소개한 것처럼 객체와 해당 객체의 변경을 감지하는 핸들러를 생성할 수 있다.

const buildProxyFunction = (
  /* ... */
 
  newProxy = <T extends object>(target: T, handler: ProxyHandler<T>): T => new Proxy(target, handler),
 
  /* ... */
) => [
  /* ... */
 
  newProxy,
 
  /* ... */
];
export function proxy<T extends object>(baseObject: T = {} as T): T {
  return defaultProxyFunction(baseObject);
}

Valtio에서는 proxy 함수 내부적으로 buildProxyFunction 함수를 통해 Proxy 객체를 만들어 반환할 배열에 담아주고, 이를 활용하여 프록시 객체를 생성한다.
그리고 useSnapshot 함수는 이렇게 proxy 함수로 생성된 프록시 객체를 파라미터로 받아 useSyncExternalStore 훅을 활용하여 스냅샷과 스냅샷의 변경사항이 발생했을때의 동작을 callback으로 받아 처리하는 것을 확인할 수 있었다.

export function useSnapshot<T extends object>(
  proxyObject: T,
  options?: Options,
): Snapshot<T> {
  const notifyInSync = options?.sync
  // per-proxy & per-hook affected, it's not ideal but memo compatible
  // eslint-disable-next-line react-hooks/exhaustive-deps
  const affected = useMemo(() => new WeakMap<object, unknown>(), [proxyObject])
  const lastSnapshot = useRef<Snapshot<T>>()
  let inRender = true
  const currSnapshot = useSyncExternalStore(
    useCallback(
      (callback) => {
        const unsub = subscribe(proxyObject, callback, notifyInSync)
        callback() // Note: do we really need this?
        return unsub
      },
      [proxyObject, notifyInSync],
    ),
    () => {
      const nextSnapshot = snapshot(proxyObject)
      /* ... */
      return nextSnapshot
    },
    () => snapshot(proxyObject),
  )
  /* ... */
  return ...;
}

프락시를 활용한 변경 감지 및 불변 상태 생성하기

Valtio는 Proxy를 사용해 변경이 불가능한 객체인 snapshot을 생성할 수 있다.
snapshot 함수를 통해 이 객체를 생성할 수 있는데, Object.freeze가 사용되어 변경이 불가능하다.

import { proxy, snapshot } from 'valtio';
 
const state = proxy({ count: 0 }); // 변경이 가능한 Proxy 객체 생성
 
const snap = snapshot(state); // 변경이 불가능한 snapshot 생성

위 함수들은 중첩된 객체에 대해서도 작동하며 snapshot 생성을 최적화한다.

const state = proxy({
  obj1: { c: 0 },
  obj2: { c: 0 },
});
 
const snap1 = snapshot(state); // { obj1: { c: 0 }, obj2: { c: 0 } }
 
++state.obj1.c;
 
const snap2 = snapshot(state); // { obj1: { c: 1 }, obj2: { c: 0 } }

snap1과 snap2는 다른 참조를 가지기 때문에 snap1 !== snap2 이다.
하지만 내부에 중첩된 객체에 대해선 어떨까?
snap1.obj1snap2.obj1은 값이 다르지만, snap1.obj2snap1.obj2는 같다.
따라서 obj2는 변경할 필요가 없어 snap1.obj2 === snap2.obj2는 유지된다.
이렇듯 snap1.obj2snap1.obj2 의 참조가 동일하다는 것은 메모리가 공유된다라는 것이다.
Valtio는 이렇게 필요할 때만 snapshot을 생성하여 메모리 사용량을 최적화한다.

프락시를 활용한 리렌더링 최적화

Valtio는 예전 장에서 나왔던 속성 접근 감지 방식으로 리렌더링을 최적화한다.
이때 접근 감지를 위해 Proxy를 사용한다.
접근은 useSnapshot 훅에 의해 추적 정보로 감지되며, 추적 정보를 기반으로 useSnapshot 훅은 필요한 경우에만 리렌더링을 감지한다.
기본적으로 일반 객체와 배열을 포함하는 어떤 객체도 완전하게 지원한다.

✅ 먼저, “중첩된 객체”라는 말이 뭘까를 예전 장에 등장했을때부터 계속 생각해봐도 잘 이해가 가지않았다.
하지만 이번 장을 통해 단순히 객체 안에 객체가 속성으로 들어가 있는 경우라는 것을 이해했다..
그리고 책에서는 proxy 함수의 Proxy와 useSnapshot 함수의 Proxy를 다르게 이야기한다.
처음에 잘 이해가 되지 않았지만, 다시 곱씹어보며 생각해본 결과, 다음과 같이 생각하였다.

proxy 함수의 Proxy

로컬에서 Proxy 객체의 변경을 감지하고, 이를 모듈 상태에 반영한다.

useSnapshot 함수의 Proxy

모듈 상태의 속성 변경을 감지하고 해당 속성을 사용하고 있는 컴포넌트들에 반영하기 위해 새로운 snapshot을 제공하는 Proxy로 이해하였다.
따라서 Proxy 객체의 갱신이 일어나면, 모듈 상태가 갱신되고, 변경사항을 snapshot에 뿌려주는 것으로 이해하였다.
해당 snapshot이 변경되어 참조가 바뀌기 때문에 리액트는 이를 감지하고 리렌더링을 일으키는 것 같다.
Zustand의 선택자 함수 활용 대신 객체의 속성 접근으로 좀더 편하게 사용할 수 있는 것은 장점 같긴하다.

작은 애플리케이션 만들어보기

해당 부분은 Valtio를 활용하여 TodoList를 구현하는 예시로 이루어진 절로 넘어가겠습니다!

✅ 해당 절을 보면서 이전에 C와 C++을 다룰 때와 비슷하게 로직을 구현하여 친숙한 느낌을 받았다.
하지만 리액트를 사용하면서 좀 어색하다는 느낌을 같이 받았다.

const removeTodo = (id: string) => {
  const index = state.todos.findIndex((item) => item.id === id);
  state.todos.splice(index, 1);
};
 
// 리액트로 했다면 이런 식으로 하지 않았을까..
setState((prev) => [...prev.filter((item) => item.id !== id)]);

보통 리액트의 컴포넌트 안에서는 로직을 숨기고 뷰에 집중하는 느낌을 많이 받았는데, 로직을 구현하더라도 훅으로 분리하거나 setState와 같은 함수로 명시되어 상태가 갱신되는 포인트를 잘 알 수 있었기 때문이다.
하지만 Valtio는 Proxy 객체를 직접 조작하여 모듈 상태를 갱신하고 리렌더링이 일어난다.
따라서 이 둘이 혼용될 경우, 여러 사람이 코드를 작성한 느낌을 받을 것 같다는 생각이 들었다.
뭔가 프로그래밍 방식이 나뉜 것 같아 보이기 때문이다.
이 책을 통해 Valtio라는 라이브러리를 처음 접했는데, 그중 하나가 이러한 이유로 즐겨 사용되지 않았기 때문이지 않을까 생각했다.

이 접근 방식의 장단점

Valtio는 두 가지 상태 업데이트 모델이 있다.

  • 변경 가능한 Proxy 객체
  • 변경이 불가능한 snapshot

JavaScript 자체는 변경을 허용하지만, 리액트는 불변 상태 기반이다.
따라서 두 모델을 같이 사용할 경우, 이를 혼동하지 않도록 주의해야 한다.
괜찮은 해결책은 Valtio의 상태와 리액트의 상태를 명확히 분리하는 것이다.
또한 JavaScript 자체적으로 제공하는 함수들을 사용할 수 있다는 것이 장점이다.

array.splice(index, 1);
 
[...array.slice(0,index), ...array.slice(index + 1)]
// 사실 위에보단 아래처럼 하지 않을까 싶긴한데..
[...array.filter((item, idx) => idx !== index)]

Zustand와 비교하였을때, Zustand는 조건이 많아질수록 useStore 훅을 많이 필요로 한다.
하지만 Valtio는 모듈 상태 기반으로 proxy와 그에 해당하는 snapshot만 가져와 사용하면 된다. 단점은 예측 가능성이 떨어진다는 것이다.
내부적으로 렌더링 최적화를 진행하기 때문에 동작을 디버깅할 때 예측이 어려울 수 있다.
따라서 상황에 따라 올바르게 사용하자.

✅ ”괜찮은 해결책은 Valtio의 상태와 리액트의 상태를 명확히 분리하는 것이다.”라는 문구를 보고,

갑자기 문득 든 생각인데 훅으로 분리해서 사용하면 꽤나 좋을 것 같기도?!

라는 생각을 했다.

상태를 갱신할 때, 로직을 좀더 간결하고 읽기 쉽게 작성할 수 있을 것 같았기 때문이다.
따라서 훅으로 분리하여 잘 사용한다면 꽤나 유용하게 사용할 수 있을듯 싶다.

도은
  • Valtio는 변경 가능한 갱신 모델 기반
  • 모듈 상태용
  • 리액트와의 통합을 위해 Proxy를 사용해 변경 불가능한 스냅숏을 가져온다.
  • Proxy를 활용해 자동으로 리렌더링 최적화
  • selector 필요 X
  • 자동 렌더링 최적화: 상태 사용 추적(state usage tracking)
    • 상태의 어느 부분이 사용되는지 감지 가능
    • 사용된 부분이 변경될 경우에만 컴포넌트를 리렌더링

또 다른 모듈 상태 라이브러리인 Valtio 살펴보기

  • Zustand와 비슷하게 스토어 생성
    const store = create(() => ({
      count: 0,
      text: 'hello',
    }));
  • 상태를 갱신하기 위해서는, setState를 사용
    • 상태를 불변으로 갱신하기 위해서

🤔 상태를 불변으로 갱신?

  • 상태(state)를 직접 수정하지 않고,
  • 기존 상태를 기반으로 새로운 상태 객체를 생성하여 상태를 갱신하는 방식

→ React에서 setState와 동일하다

  • 만약 불변 갱신 규칙을 따를 필요가 없다면?
    ++count;
  • Proxy를 사용한다면 이를 구현할 수 있을 것이다.
    • 객체 연산을 감지하기 위한 핸들러를 만드는 데 활용 가능
  • 예를 들어 다음과 같이 객체 변경을 감지하는 set 핸들러를 추가 가능
    const proxyObject = new Proxy(
      {
        count: 0,
        text: 'hello',
      },
      {
        set: (target, prop, value) => {
          console.log('start setting', prop);
          target[prop] = value;
          console.log('end setting', prop);
        },
      },
    );
  • new Proxy(객체, 핸들러를 담는 컬렉션 객체)
  • 실행하면 다음과 같다.
    > ++proxyObject.count
    start setting count
    end setting count
    1
  • Proxy는 모든 변경을 감지 가능 → 기술적으로 ZustandsetState와 유사한 동작 구현 가능
  • ⭐️ Valtio는 Proxy를 활용해 상태 변경을 감지하는 라이브러리!

🤔 Proxy 객체

MDN (opens in a new tab)

프락시를 활용한 변경 감지 및 불변 상태 생성하기

  • Valtio는 Proxy를 사용하여
  • 변경 가능한 객체에서 변경 불가능한 객체를 생성
  • ⭐️ 불변 객체 = 스냅숏(snapshot)
  • Valtio를 통해 proxy 함수를 사용하면 변경 가능한 객체를 생성 가능
import { proxy } from 'valtio';
 
// 👇 변경 가능한 객체를 생성한 것
const state = proxy({ count: 0 });
  • 불변 객체를 생성하려면 snapshot 함수 사용
import { snapshot } from 'valtio';
 
// NOTE: const state = proxy({ count: 0 });
// 👇 Object.freeze로 동경 → 변경 불가능한 객체
const snap1 = snapshot(state);
 
// 변경 가능한 객체를 변화시킴
// 👇 이전과 동일한 참조를 가진다.
+state.count;
 
// 새로운 참조
// snap1과 snap2는 불변이므로 snap1 === snap2로 동등성 확인
const snap2 = snapshot(state);
  • ⭐️ proxysnapshot 함수는 중첩된 객체에 대해서도 작동, 최적화
  • 필요한 경우에만 스냅숏을 생성 → 메모리 사용량 최적화

🤔 Valtio가 snapshot이라는 개념을 사용하는 이유?

  • 직접 수정하는 것이 사용법 측면에서 편리하지만
  • 싱글톤 관점에서 객체의 직접적인 수정은 예상치 못한 결과를...
  • 그래서 Valtio는 snapshot이라는 개념을 사용해서
    • 현재 상태를 복사해서 새 객체로 반환

🤔 snapshot을 사용하면 왜 변경사항을 감지하고 렌더링 최적화가 가능한걸까?

  • 상태가 변경될 때마다 스냅샷으로, 어떤 부분이 변경되었는지를 정확히 파악
  • 사용하고 있는 부분의 변경사항이 업데이트되었을 때만 리렌더링
  • 😨 정확히 어떻게 구현되었길래 객체에서 필요한 부분만 업데이트가 가능한 건지 궁금하다

이 접근 방식의 장단점

  • React의 불변 상태를 중심으로 동작 → 혼동..
  • 코드를 줄이기에는 도움이 됨