Zustand Store 분리로 리렌더링 줄이기

Store 설계로 컴포넌트 리렌더링 범위를 결정하는 방법

작성일: 2025. 12. 297 min

들어가며

비디오 재생 중 아무것도 건드리지 않았는데 화면 전체가 리렌더링되고 있었다. React DevTools Highlight를 켜보니 댓글 목록, 사이드바 버튼, 검색창까지 수십 번씩 깜빡이고 있었다. 원인은 Zustand store 설계에 있었고, Store 분리와 selector 패턴, getState() 활용으로 대부분을 제거했다.

문제 파악

React DevTools의 Highlight 기능(렌더링되는 컴포넌트를 실시간으로 색으로 표시하는 기능)으로 어느 컴포넌트가 얼마나 자주 리렌더링되는지 확인했다. 비디오를 재생한 채로 켜두니 다음과 같은 수치가 나왔다.

컴포넌트리렌더링 횟수
Button (각종 액션 버튼)x178
MediaViewerx152
Searchx139
CommentListViewx120
PlayBarControlsx107

이 횟수는 특정 구간에서 측정한 값이고, 재생을 멈추지 않는 한 계속 쌓인다. 비디오를 재생하고 있을 뿐인데, 댓글 목록과 검색창이 100번 이상 리렌더링되고 있었다. 재생 시간 표시를 담당하는 PlayBar는 시간이 바뀔 때마다 업데이트되어야 하니 납득이 되지만, 나머지는 재생과 아무 관계가 없다.

화면에 변화가 없는 컴포넌트가 계속 리렌더링되는 건 어떻게 봐도 불필요한 작업이다. 지금 당장 체감되지 않더라도 컴포넌트가 복잡해지거나 데이터가 늘어나면 성능 병목이 될 수 있어서 정리하기로 했다.

Store 구독의 작동 방식

문제를 이해하려면 Zustand가 어떻게 리렌더링을 유발하는지 알아야 한다.

Zustand의 기본 사용법은 store 훅을 호출하는 것이다.

typescript
const { currentTime, isPlaying, volume, play, pause } = usePlayerStore();

Zustand는 selector 없이 usePlayerStore()만 호출해도 동작한다. 편하지만, 이렇게 쓰면 store 전체를 구독하게 돼서 store 안의 어떤 값이든 바뀌면 컴포넌트가 리렌더링된다. volume만 필요한 컴포넌트도 currentTime이 바뀔 때마다 같이 리렌더링되는 식이다.

Zustand도 selector(usePlayerStore(state => state.volume))를 쓸 수 있지만, selector 없이도 동작하다 보니 빠뜨리기 쉽다. 그래서 selector 사용 이전에 "어떤 상태를 한 store에 묶을 것인가"라는 store 설계 자체가 중요해진다.

처음에 단일 store로 만든 건 시간, 드로잉, UI 상태가 전부 같은 기능의 일부니까 한 곳에 모으는 게 응집도가 높다고 생각했기 때문이다. 그런데 Zustand에서는 store 경계가 곧 리렌더링 경계다. 같은 기능이라도 변경 시점이 다른 상태를 한 store에 묶으면, 한쪽이 변할 때 다른 쪽의 구독자까지 불필요하게 리렌더링된다.

Zustand 공식 README에서도 selector 없이 store 전체를 호출하면 "it will cause the component to update on every state change"라고 경고한다.

Zustand 공식 문서의 Fetching everything 섹션

이 경고가 말해주듯 Zustand에서는 store 경계가 곧 리렌더링 경계다. 같은 기능이라도 변경 시점이 다른 상태를 한 store에 묶으면 불필요한 리렌더링이 생기고, 그래서 store 분리의 기준은 "같은 기능인가"가 아니라 "하나가 변할 때 다른 것도 변해야 하는가"가 돼야 한다.

시간 상태는 사용자가 재생 위치를 바꿀 때 변하고, 드로잉 상태는 도구를 선택할 때 변하고, UI 상태는 모드를 전환할 때 변한다. 하나가 변할 때 나머지가 같이 변해야 할 이유가 없다. 이런 상태들을 한 store에 묶으면 응집도가 높은 게 아니라, 서로 무관한 변경이 전파되는 구조가 된다.

시도한 방법

Store 분리

기존 피드백 관련 store에는 시간, 드로잉, UI 상태가 전부 들어 있었다.

typescript
const useFormStore = create<FormState>((set) => ({
  // 시간 관련
  timeMode: "point",
  timePoint: null,
  timeRange: null,

  // 드로잉 관련
  drawingTool: "pen",
  drawingColor: "#ff0000",
  drawingStrokes: [],

  // UI 관련
  isDrawingMode: false,
  toolbarVisible: true,

  // ...
}));

드로잉 도구를 선택해도 시간 관련 컴포넌트가 리렌더링되고, 시간이 변해도 드로잉 컴포넌트가 리렌더링되는 구조였다. 관심사가 다른 상태들이 한 store에 묶여 있어 변경이 서로에게 전파되고 있었다.

typescript
const useTimeStore = create<TimeState>((set) => ({
  timeMode: "point",
  timePoint: null,
  timeRange: null,
  setTimePoint: (point) => set({ timePoint: point }),
  setTimeRange: (range) => set({ timeRange: range }),
}));

const useDrawingStore = create<DrawingState>((set) => ({
  drawingTool: "pen",
  drawingColor: "#ff0000",
  drawingStrokes: [],
  setDrawingTool: (tool) => set({ drawingTool: tool }),
}));

const useUIStore = create<UIState>((set) => ({
  isDrawingMode: false,
  toolbarVisible: true,
  setDrawingMode: (enabled) => set({ isDrawingMode: enabled }),
}));

Zustand의 구독 메커니즘은 store 단위로 작동한다. store를 분리하면 useTimeStore를 사용하는 컴포넌트는 시간 관련 상태가 변할 때만 리렌더링된다. 드로잉 도구를 선택해도 시간 컴포넌트는 반응하지 않는다.

store를 너무 잘게 쪼개면 관리가 복잡해지고 상태 간 동기화가 어려워진다. 하나가 변할 때 다른 것도 같이 변해야 하는 상태라면 같은 store에 두는 게 낫다. 시간/드로잉/UI는 하나가 변해도 나머지는 그대로이므로 분리가 자연스러웠다.

Selector 패턴

selector 없이 usePlayerStore()를 호출하면 Zustand는 store 전체 객체를 반환한다. set()이 호출될 때마다 새 객체가 만들어지고, React는 이전 객체와 새 객체를 비교하는데, 매번 다른 객체이기 때문에 항상 리렌더링된다.

selector를 쓰면 비교 대상이 달라진다. usePlayerStore(state => state.volume)은 store 전체가 아니라 volume 값만 반환한다. currentTime이 바뀌어도 volume이 그대로면 React는 값이 변하지 않았다고 판단해 리렌더링을 건너뛴다.

하지만 store를 분리해도, store 전체를 구독하면 여전히 문제가 생긴다.

typescript
// AS-IS: store 전체를 구독
const { currentTime, totalTime, isPlaying, volume } = usePlayerStore();

// TO-BE: 필요한 값만 구독
const currentTime = usePlayerStore((state) => state.currentTime);
const volume = usePlayerStore((state) => state.volume);

TO-BE처럼 selector를 쓰면 비교 대상이 selector가 반환하는 값으로 좁혀진다. currentTime이 변해도 volume selector를 사용하는 컴포넌트는 영향받지 않는다.

getState()

store에 접근해야 하지만 React의 렌더링 흐름 안이 아닌 경우가 있다. 예를 들어 플레이어 컨트롤을 setState()로 등록하는 콜백 안에서는 훅을 호출할 수 없다. 이런 곳에서는 getState()로 store의 스냅샷을 가져온다.

typescript
// 콜백 내부에서 store 접근 — 훅 사용 불가, getState() 사용
usePlayerStore.setState({
  togglePlayPause: () => {
    const { isPlaying } = usePlayerStore.getState();
    const controls = playerControlsRef.current;

    if (isPlaying) {
      controls.pause();
    } else {
      controls.play();
    }
  },
});

이 콜백은 컴포넌트의 렌더링 시점이 아니라 사용자가 버튼을 클릭하는 시점에 실행된다. 실행 시점에 isPlaying의 현재 값을 읽어야 하므로 getState()가 적합하다.

selector와의 차이

selector와 getState() 모두 store에서 값을 가져온다는 점은 같다. 차이는 값이 바뀔 때 React한테 알려주느냐다.

  • selector (useStore(state => state.value)): store를 구독한다. 값이 바뀌면 React가 알아채고 컴포넌트를 리렌더링한다. volume을 화면에 표시하는 컴포넌트라면 selector로 구독해야 한다. 안 그러면 사용자가 볼륨을 바꿔도 화면에 반영되지 않는다.
  • getState() (useStore.getState().value): 구독 없이 호출 시점의 값만 읽는다. 나중에 값이 바뀌어도 React는 모르고, 리렌더링도 발생하지 않는다. 제출 버튼을 클릭했을 때 현재 시간을 한 번만 읽으면 되는 경우처럼, 특정 시점의 값만 필요할 때 사용한다.

숨어있던 근본 원인

Store 분리, selector, getState() 패턴을 적용한 뒤에도 특정 컴포넌트에서 리렌더링이 계속되었다. 리렌더링이 발생하는 컴포넌트를 따라 올라가보니, 특정 훅에서 재생 시간을 불필요하게 구독하고 있는 것이 원인이었다.

typescript
export function useCommentSubmit(versionType: string, formEnabled: boolean) {
  const playerCurrentTime = useCurrentTime(); // 재생 중 계속 변경

  const createSubmitData = useCallback(
    (baseData: CommentBaseData) => {
      return { ...baseData, timestamp: playerCurrentTime };
    },
    [playerCurrentTime]
  ); // 의존성 배열에 포함

  return { createSubmitData };
}

비디오 플레이어는 재생 중 짧은 간격으로 현재 재생 시간을 업데이트한다. useCurrentTime()가 이 값을 구독하고 있어서, 이 훅을 사용하는 CommentInput이 리렌더링되었다. 그 부모인 CommentSection이 리렌더링되고, 그 자식인 CommentListView까지 연쇄적으로 이어졌다.

댓글의 타임스탬프는 제출 버튼을 누르는 순간에만 필요하다. 재생 중 계속 시간을 구독할 이유가 없다.

typescript
export function useCommentSubmit(versionType: string, formEnabled: boolean) {
  // useCurrentTime() 구독 제거

  const createSubmitData = useCallback((baseData: CommentBaseData) => {
    // 콜백 실행 시점에 현재 값을 가져옴
    const playerCurrentTime = usePlayerStore.getState().currentTime;
    return { ...baseData, timestamp: playerCurrentTime };
  }, []); // 의존성 배열 비어있음

  return { createSubmitData };
}

getState()를 콜백 내부에서 호출하면 함수가 실행되는 시점의 최신 값을 가져온다. 렌더링 시에는 구독하지 않으므로 리렌더링이 발생하지 않는다.

결과

적용 후 각 컴포넌트의 리렌더링 횟수를 줄일 수 있었다.

컴포넌트최적화 전최적화 후
Button (각종 액션 버튼)x1780
MediaViewerx1520
Searchx1390
CommentListViewx1200
PlayBarControlsx107x39

PlayBar는 현재 재생 시간을 화면에 표시해야 하므로 재생 시간이 바뀔 때마다 리렌더링되었다. 나머지 컴포넌트는 재생 중에 리렌더링을 막을 수 있었다.

정리

  • Zustand store를 구독하면 구독한 값이 변할 때마다 컴포넌트가 리렌더링된다. 자주 변하는 값(재생 시간)과 그렇지 않은 값(볼륨, UI 상태)을 같은 store에 두면 불필요한 리렌더링이 발생한다
  • store 분리의 기준은 "하나가 변할 때 다른 것도 변해야 하는가"다. 그렇지 않다면 별도 store로 나누는 것이 맞다
  • selector 패턴(useStore(state => state.value))으로 컴포넌트가 실제로 의존하는 값만 구독한다
  • getState()는 구독 없이 호출 시점의 값만 읽는다. 이벤트 핸들러나 콜백처럼 훅을 쓸 수 없는 곳에서 store에 접근할 때 사용한다
  • 리렌더링 문제는 원인이 여러 레이어에 걸쳐 있을 수 있다. 한 번의 수정으로 해결되지 않을 수 있다