
스쿼드 에디터에서 진행 상태(포메이션 선택, 선수 11명 배치, 제목 입력, 감독 이미지, 메모 입력)를 표시하는 SquadEditorProgress 컴포넌트를 구현하면서, 여러 컴포넌트에서 중복으로 계산하던 로직을 하나의 훅으로 통합하려고 했다.
기존에는 각 컴포넌트에서 직접 조건을 계산하고 있었는데, 유지보수성을 높이기 위해 Zustand 스토어에 다음과 같은 파생 상태 훅을 추가했다.
즉, UI는 표시만 담당하고, 실제 조건 계산은 스토어 훅에서 관리하도록 구조를 정리하는 것이 목적이었다.

리팩토링 이후 아래 에러가 발생했다.
즉, 렌더링이 반복되는 무한 업데이트 루프가 발생한 상황이었다.
문제의 원인은 Zustand Selector가 객체를 반환하는 방식이었다.
export const useSquadProgress = () =>
useSquadEditorStore((s) => {
return {
filledCount,
hasFormation,
isAllPlayersFilled,
hasTitle,
hasCoachImage,
hasMemo,
isMemoValid,
};
});
이 코드에서는 렌더링될 때마다 새로운 객체{...}가 생성된다.
값이 동일하더라도 객체의 참조(reference)는 매번 달라지기 때문에 Zustand는 "상태가 변경된 것"으로 판단하게 된다.
그 결과 snapshot이 계속 변경되고 React가 업데이트를 반복하면서 Maximum update depth exceeded 에러가 발생했다.
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가 새 객체를 반환하더라도 값이 같으면 업데이트가 발생하지 않기 때문에 무한 루프가 해결된다.
문제를 해결한 뒤 자연스럽게 또 하나의 고민이 생겼다.
selector에서 여러 값을 묶어서
useShallow로 반환하는 방식이 좋은가, 아니면 selector를 모두 분리해서 각각 가져오는 방식이 더 좋은가?
두 방식은 모두 실무에서 사용되지만, 관점별로 장단점이 분명히 존재한다.
장점
단점
장점
단점
Zustand에서는 selector를 무조건 쪼개거나 무조건 묶는 것보다,
상태는 분리하고 의미적으로 함께 사용되는 파생 상태만 useShallow로 묶는 혼합형 패턴이 실무적으로 가장 효율적이다.