Zustand selector가 객체를 반환할 때 발생한 무한 렌더링 문제 해결 (useShallow 적용)

ssssm·2026년 2월 10일
post-thumbnail

1.문제 상황 : Progress 로직 리펙토링 중 무한 랜더링 발생

스쿼드 에디터에서 진행 상태(포메이션 선택, 선수 11명 배치, 제목 입력, 감독 이미지, 메모 입력)를 표시하는 SquadEditorProgress 컴포넌트를 구현하면서, 여러 컴포넌트에서 중복으로 계산하던 로직을 하나의 훅으로 통합하려고 했다.

기존에는 각 컴포넌트에서 직접 조건을 계산하고 있었는데, 유지보수성을 높이기 위해 Zustand 스토어에 다음과 같은 파생 상태 훅을 추가했다.

  • useSquadProgress() : 진행 상태 계산
  • useCanSaveSquad() : 저장 가능 여부 계산

즉, UI는 표시만 담당하고, 실제 조건 계산은 스토어 훅에서 관리하도록 구조를 정리하는 것이 목적이었다.

2.발생한 에러


리팩토링 이후 아래 에러가 발생했다.

  • The result of getSnapshot should be cached to avoid an infinite loop
  • Maximum update depth exceeded

즉, 렌더링이 반복되는 무한 업데이트 루프가 발생한 상황이었다.

3.원인 분석

문제의 원인은 Zustand Selector가 객체를 반환하는 방식이었다.

export const useSquadProgress = () =>
  useSquadEditorStore((s) => {
    return {
      filledCount,
      hasFormation,
      isAllPlayersFilled,
      hasTitle,
      hasCoachImage,
      hasMemo,
      isMemoValid,
    };
  });

이 코드에서는 렌더링될 때마다 새로운 객체{...}가 생성된다.
값이 동일하더라도 객체의 참조(reference)는 매번 달라지기 때문에 Zustand는 "상태가 변경된 것"으로 판단하게 된다.

그 결과 snapshot이 계속 변경되고 React가 업데이트를 반복하면서 Maximum update depth exceeded 에러가 발생했다.

4.해결 방법

selector 결과를 안정화하기 위해 Zustand의 useShallow를 적용했다.

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

export const useSquadProgress = () =>
  useSquadEditorStore(
    useShallow((s) => {
      return {
        filledCount,
        hasFormation,
        isAllPlayersFilled,
        hasTitle,
        hasCoachImage,
        hasMemo,
        isMemoValid,
      };
    }),
  );

useShallow는 이전 selector의 결과와 현재 결과를 비교(shallow compare)하여 실제 값이 동일하면 동일한 snapshot으로 처리해준다.

이렇게 하면 selector가 새 객체를 반환하더라도 값이 같으면 업데이트가 발생하지 않기 때문에 무한 루프가 해결된다.

5. 추가 고민 : useShallow로 객체를 반환하는 방식 vs selector를 모두 쪼개는 방식

문제를 해결한 뒤 자연스럽게 또 하나의 고민이 생겼다.

selector에서 여러 값을 묶어서 useShallow로 반환하는 방식이 좋은가, 아니면 selector를 모두 분리해서 각각 가져오는 방식이 더 좋은가?

두 방식은 모두 실무에서 사용되지만, 관점별로 장단점이 분명히 존재한다.

useShallow로 "객체 1개" 반환 (묶음 selector)

장점

  • "진행도에 필요한 값" 묶음으로 코드를 읽기 쉽다
  • 훅이 1개니까 컴포넌트 상단이 깔끔하다

단점

  • 묶음 중 하나만 바뀌어도 “전체 묶음” 비교가 발생하고 컴포넌트가 갱신됨.

selector를 “모두 쪼개서” 분리

장점

  • UI가 실제로 사용하는 값만 구독하니까 변화 범위가 작아짐

단점

  • 훅 호출이 여러 줄로 늘어남

6. 결론

Zustand에서는 selector를 무조건 쪼개거나 무조건 묶는 것보다,
상태는 분리하고 의미적으로 함께 사용되는 파생 상태만 useShallow로 묶는 혼합형 패턴이 실무적으로 가장 효율적이다.

0개의 댓글