blog

Zustand 셀렉터 한 줄이 만든 무한 리렌더: getSnapshot 경고부터 Maximum update depth까지

Front-end / React#Zustand#useSyncExternalStore#useShallow#Radix UI#트러블슈팅

한 줄 요약: Zustand v5에서 셀렉터가 객체 리터럴을 반환하면 useSyncExternalStore가 매번 "값이 바뀌었다"고 판단해 무한 리렌더가 생긴다. 에러 스택은 엉뚱하게 Radix UI 내부를 가리켰다.

증상

목록에서 항목을 클릭하자 콘솔이 두 에러로 가득 찼다.

The result of getSnapshot should be cached to avoid an infinite loop

Error: Maximum update depth exceeded.
    at setRef (compose-refs.tsx:11:12)
    at compose-refs.tsx:25:23
    ...

스택에 반복해서 나오는 compose-refs.tsx는 Radix UI 내부 파일이다. 거슬러 올라가 보니 원인은 Zustand 셀렉터 한 줄이었다.

// 문제의 코드
const { userList } = useUserStore(({ userList }) => ({ userList }));

여러 화면에서 중복 호출되던 사용자 목록 API를 한 번만 불러 Zustand 스토어에 담고, 하위 컴포넌트는 스토어에서 읽기만 하도록 리팩터링하던 중이었다.

원인 1: 셀렉터가 매번 새 객체를 만든다

Zustand의 React 바인딩은 내부적으로 useSyncExternalStore를 사용한다. React는 렌더링할 때 getSnapshot()을 호출하고, 이전 결과와 Object.is로 비교해 다르면 다시 렌더링한다. 같은 상태에서 getSnapshot()을 두 번 호출하면 같은 참조를 돌려줘야 한다는 것이 이 훅의 계약이다.

셀렉터를 넘기면 사실상 셀렉터 결과가 스냅샷이 된다.

getSnapshot = () => selector(store.getState());
// = () => ({ userList: store.getState().userList })  ← 호출마다 새 객체

userList 배열이 그대로여도 감싸는 {}는 매번 새 객체이므로 비교 결과는 항상 false다.

Object.is({ userList }, { userList }); // false

React는 스냅샷이 계속 바뀐다고 보고 렌더링을 반복한다. 이것이 첫 번째 경고 getSnapshot should be cached다.

Zustand 버전에 따라 다르다

  • v4: 셀렉터 결과를 비교하는 래퍼(useSyncExternalStoreWithSelector)를 썼기 때문에 객체를 반환하는 셀렉터는 무한 루프까지 가지 않았다. 대신 스토어의 아무 값이 바뀔 때마다 불필요하게 리렌더링됐다.
  • v5: React의 useSyncExternalStore를 직접 쓰므로, 매번 새 참조를 반환하는 셀렉터는 무한 루프를 만든다. v4에서 문제없던 코드가 v5에서 터지는 대표적인 마이그레이션 함정이다.

원인 2: 왜 에러는 Radix UI에서 났나

리렌더가 끝없이 반복되면 React는 중첩 업데이트 횟수가 한도를 넘는 순간 Maximum update depth exceeded를 던진다. 이 에러는 한도를 넘긴 시점에 상태 업데이트를 일으킨 코드의 스택을 보여준다.

Radix UI 컴포넌트(Select, Popover 등)는 여러 ref를 합치는 composeRefs를 쓰고, 렌더링마다 콜백 ref가 다시 연결되면서 내부 상태가 갱신된다. 무한 리렌더 중에 마침 그 지점에서 한도를 넘었기 때문에 스택이 compose-refs.tsx를 가리킨 것이다. 에러가 보고된 위치와 근본 원인은 다를 수 있다. 이번에는 앞서 나온 getSnapshot 경고가 진짜 단서였다.

셀렉터가 매번 새 {} 반환
  → getSnapshot 결과가 항상 다름
  → 리렌더 예약 → 다시 새 {} → 리렌더 ...      [getSnapshot should be cached]
  → 중첩 업데이트 한도 초과
  → 그 순간 업데이트 중이던 Radix 내부 코드에서 에러 보고   [Maximum update depth exceeded]

해결: 값을 직접 반환하기

const userList = useUserStore((state) => state.userList);

스냅샷이 배열 자체가 되므로, 스토어의 userList가 바뀌지 않는 한 같은 참조가 반환되고 리렌더도 일어나지 않는다.

여러 값이 필요할 때

1. 하나씩 구독

const userList = useUserStore((state) => state.userList);
const setUserList = useUserStore((state) => state.setUserList);

2. useShallow로 얕은 비교

import { useShallow } from 'zustand/react/shallow';

const { userList, setUserList } = useUserStore(
  useShallow((state) => ({ userList: state.userList, setUserList: state.setUserList })),
);

useShallow는 반환 객체의 각 필드를 얕게 비교해서, 내용이 같으면 이전 참조를 그대로 돌려준다.

셀렉터 안에서 state.list.filter(...), state.items.map(...)처럼 새 배열을 만드는 것도 같은 문제를 일으킨다. 파생 데이터는 원본만 구독한 뒤 컴포넌트에서 useMemo로 계산하거나 useShallow로 감싼다.

배운 점

  • Zustand 셀렉터는 같은 상태에 대해 같은 참조를 반환해야 한다. 객체 리터럴·새 배열을 반환하지 않는다.
  • 스택 트레이스가 라이브러리 내부를 가리킨다고 라이브러리 버그라고 단정하지 않는다. 함께 출력된 경고부터 읽는다.

참고