Zustand 리렌더링 원리와 selector 최적화 방법

··설명 약 9분·작성: 해비·블로그 소개

Zustand selector의 반환값과 참조가 리렌더링에 어떤 영향을 주나요?

Zustand는 selector가 돌려준 값의 변화를 기준으로 구독 컴포넌트의 갱신을 판단합니다. 필요한 값만 고르는지와 selector가 매번 새 객체를 만드는지를 함께 살펴야 합니다.

적용 조건·범위: 위 객체 selector는 참조 문제를 보여주는 예입니다. Zustand 5의 기본 create에서는 매번 새 객체를 반환하면 무한 업데이트가 발생할 수 있으므로 렌더 횟수만의 문제로 보지 않습니다. 값별 selector 또는 아래 useShallow 예제로 결과 참조를 안정화하세요. useShallow는 반환 객체의 속성을 얕게 비교하며 중첩 값까지 깊게 같다고 판정하는 도구는 아닙니다. 공식 useShallow 문서에서 이 조건을 확인할 수 있습니다.

본문 근거: 공식 useShallow 문서 · zustand.docs.pmnd.rs · zustand.docs.pmnd.rs/guides/prevent-rerenders-with-use-shallow

이 기술만 따로 실행하기

이 개념 직접 실행

상태를 바꾸고 화면에 반영되는 결과를 확인합니다.

예제 크게 보기 (새 탭)

기대 결과: FilterLabel의 초기 렌더 로그 수를 먼저 기록합니다. 횟수 증가로는 필터 selector 결과가 달라지지 않고 필터 변경으로는 달라집니다. StrictMode 때문에 초기 로그가 여러 번일 수 있으므로 절대 횟수를 일반화하지 않습니다.

직접 실행: 환경 준비와 전체 파일

실행 환경: Zustand 5가 설치된 별도 React·TypeScript 연습 앱입니다. 환경 준비는 Zustand 설치 사용법: 기본 Store 만들고 상태 연결하기를 참고하세요. 기존 TaskFlow 데이터는 사용하지 않습니다. 아래 전체 코드를 src/App.tsx에 저장하고 기존 src/main.tsx의 App 연결을 유지합니다.

import { create } from 'zustand';
type State = { filter: string; count: number; changeFilter: () => void; increase: () => void };
const useDemo = create<State>()((set) => ({
  filter: 'all',
  count: 0,
  changeFilter: () => set((state) => ({ filter: state.filter === 'all' ? 'completed' : 'all' })),
  increase: () => set((state) => ({ count: state.count + 1 })),
}));
function FilterLabel() {
  const filter = useDemo((state) => state.filter);
  console.count('FilterLabel render');
  return <p>필터: {filter}</p>;
}
function CountLabel() {
  const count = useDemo((state) => state.count);
  return <p>횟수: {count}</p>;
}
export default function App() {
  const increase = useDemo((state) => state.increase);
  const changeFilter = useDemo((state) => state.changeFilter);
  return (
    <main>
      <FilterLabel />
      <CountLabel />
      <button onClick={increase}>횟수 증가</button>
      <button onClick={changeFilter}>필터 변경</button>
    </main>
  );
}
npm run dev
npm run build

확인 순서: 개발자 콘솔을 열고 횟수 증가와 필터 변경을 각각 조작

자가 확인: FilterLabel에서 store 전체를 구독하도록 바꾼 전후를 같은 버튼 조작으로 비교하세요. 부모 렌더와 store 구독 변경을 별도로 판단해야 합니다.

기존 검증 기록: 2026-09-28, TypeScript strict 검사와 Vite 라이브러리 빌드, jsdom 버튼 조작 후 표시값 검사를 통과했습니다. 실제 브라우저 배치와 누적 프로젝트 전체 실행은 확인 범위 밖입니다.

selector 결과가 리렌더링 기준이 됩니다

다음 컴포넌트는 filter만 구독합니다.

const filter = useTaskStore(
  (state) => state.filter,
);

tasks가 바뀌더라도 filter 값이 그대로라면 이 selector 때문에 다시 렌더링될 이유는 없습니다.

반대로 store 전체를 구독하면 여러 state 변경에 영향을 받습니다.

const store = useTaskStore();

원시값 selector

숫자, 문자열, boolean처럼 비교가 단순한 값을 반환하면 동작을 이해하기 쉽습니다.

const taskCount = useTaskStore(
  (state) => state.tasks.length,
);

tasks 배열이 바뀌더라도 길이가 같으면 selector 결과는 같은 숫자입니다.

객체 selector

다음 코드는 매번 새 객체를 만들 수 있습니다.

const summary = useTaskStore((state) => ({
  total: state.tasks.length,
  filter: state.filter,
}));

위 객체 selector는 참조 문제를 보여주는 예입니다. Zustand 5의 기본 create에서는 매번 새 객체를 반환하면 무한 업데이트가 발생할 수 있으므로 렌더 횟수만의 문제로 보지 않습니다. 값별 selector 또는 아래 useShallow 예제로 결과 참조를 안정화하세요. useShallow는 반환 객체의 속성을 얕게 비교하며 중첩 값까지 깊게 같다고 판정하는 도구는 아닙니다. 공식 useShallow 문서에서 이 조건을 확인할 수 있습니다.

여러 값을 묶어서 가져와야 한다면 useShallow를 사용할 수 있습니다.

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


const summary = useTaskStore(
  useShallow((state) => ({
    total: state.tasks.length,
    filter: state.filter,
  })),
);

배열 selector도 주의

const activeTasks = useTaskStore(
  (state) =>
    state.tasks.filter(
      (task) => !task.completed,
    ),
);

위 filter selector도 새 배열을 반환하므로 Zustand 5의 기본 create에서 그대로 사용할 안전한 예제가 아닙니다. 아래 안내처럼 tasks의 기존 배열 참조를 구독한 뒤 컴포넌트에서 계산하거나, 원소 참조가 같은 결과를 useShallow로 재사용하세요.

이런 경우에는 전체 tasks를 구독하고 컴포넌트에서 계산하거나, useShallow 등 비교 기준을 명확하게 적용할지 판단해야 합니다.

무조건 memoization을 붙이는 것이 아니라 실제 렌더 비용을 측정한 뒤 결정하는 것이 좋습니다.

console 로그로 빠르게 확인

학습 단계에서는 컴포넌트 시작 부분에 로그를 넣어 변화 범위를 확인할 수 있습니다.

console.log('TaskFilter render');

다음 행동을 하나씩 실행합니다.

  1. 작업 추가
  2. 작업 완료
  3. filter 변경
  4. modal 열기
  5. selectedTaskId 변경

각 행동마다 어떤 컴포넌트가 다시 렌더링되는지 확인합니다.

개발 모드 StrictMode에서는 렌더 로그가 두 번 보일 수 있으므로 실제 최적화 판단은 Profiler와 함께 확인합니다.

최적화가 필요한 기준

리렌더링 자체는 오류가 아닙니다.

다음 조건이 함께 있을 때 최적화를 검토합니다.

  • 렌더 비용이 실제로 큼
  • 리스트가 크거나 계산이 무거움
  • 사용자가 체감할 지연이 있음
  • Profiler에서 반복 비용이 확인됨

작은 컴포넌트가 한 번 더 렌더링된다는 이유만으로 복잡한 최적화를 넣으면 유지보수 비용이 더 커질 수 있습니다.

학습 완료 체크리스트

  • selector 결과가 구독 기준이라는 것을 이해했습니다.
  • 객체·배열 selector의 참조 문제를 설명할 수 있습니다.
  • useShallow를 언제 사용할지 판단할 수 있습니다.
  • 리렌더링을 추측하지 않고 측정할 수 있습니다.

선택 실습: 할 일 앱에 적용하기

TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 여러 화면이 공유하는 상태를 관리합니다.

TaskFlow의 TaskFilter와 TaskSummary 구독을 점검하고 불필요한 객체 selector를 제거합니다.

선택: 연재 프로젝트와 전체 자료

연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.

TaskFlow 프로젝트 적용

TaskFlow 직접 실행

TaskFilter에 렌더 횟수 로그를 추가합니다. 작업 추가·완료로 filter가 변하지 않으면 Filter가 다시 렌더링되지 않는지 확인합니다.

예제 크게 보기 (새 탭)

중간부터 시작하는 전체 실습 파일

기존 React Context TaskFlow의 작업 목록·추가·완료·삭제 화면을 이어서 확장합니다.

이 글의 시작본은 직전 프로젝트 단계 완료본 전체입니다. 이미 직전 단계를 마쳤다면 그 폴더를 이어 쓰셔도 됩니다. Node.js 24.x와 npm을 사용하며 패키지 버전은 package-lock.json에 고정했습니다.

시작본에서 직접 바꾸기

시작본을 실행한 뒤 아래 변경 파일을 순서대로 수정합니다. 완료본의 같은 경로 파일에는 import와 입력 타입을 포함한 전체 코드가 있습니다.

  1. src/App.tsx — 기존 파일의 같은 위치를 갱신합니다.
  2. src/components/TaskFilter.tsx — 기존 파일의 같은 위치를 갱신합니다.

변경 이유와 결과: TaskFilter에 렌더 횟수 로그를 추가합니다. 작업 추가·완료로 filter가 변하지 않으면 Filter가 다시 렌더링되지 않는지 확인합니다.

완료본의 docs/changes.patch에서 시작·완료 차이를, docs/current-step.md에서 파일별 적용 위치를 확인할 수 있습니다. 아래 전체 파일을 같은 경로에 반영한 다음 실행하세요.

src/App.tsx 전체 파일

import { useEffect } from 'react';
import Header from './components/Header';
import NetworkStatus from './components/NetworkStatus';
import StateResetDemo from './components/StateResetDemo';
import TaskForm from './components/TaskForm';
import TaskList from './components/TaskList';
import TaskFilter from './components/TaskFilter';
import { useTaskStore } from './store/useTaskStore';
import TaskSelection from './components/TaskSelection';
import TaskModalControls from './components/TaskModalControls';
import TaskSummary from './components/TaskSummary';

function RemainingCount() {
  const tasks = useTaskStore((state) => state.tasks);
  const remainingCount = tasks.filter((task) => !task.completed).length;
  useEffect(() => { document.title = `TaskFlow (${remainingCount})`; }, [remainingCount]);
  return <p>남은 작업: {remainingCount}</p>;
}

export default function App() {
  return (
    <main className="app">
      <Header />
      <NetworkStatus />
      <RemainingCount />
      <TaskFilter />
      <TaskForm />
      <TaskList />
      <TaskSelection />
      <TaskModalControls />
      <TaskSummary />
      <StateResetDemo />
    </main>
  );
}

src/components/TaskFilter.tsx 전체 파일

import {
  useTaskStore,
  type TaskFilter as TaskFilterValue,
} from '../store/useTaskStore';


const options: TaskFilterValue[] = [
  'all',
  'active',
  'completed',
];


export default function TaskFilter() {
  console.count('TaskFilter render');
  const filter = useTaskStore((state) => state.filter);
  const setFilter = useTaskStore((state) => state.setFilter);


  return (
    <section>
      <h2>필터</h2>
      <select
        value={filter}
        onChange={(event) =>
          setFilter(event.target.value as TaskFilterValue)
        }
      >
        {options.map((option) => (
          <option key={option} value={option}>
            {option}
          </option>
        ))}
      </select>
    </section>
  );
}

완료 확인

  1. 시작본은 직전 완료본과 동일한 ZIP입니다. 압축을 푼 뒤 npm ci로 잠금 파일의 의존성을 설치합니다.
  2. npm run dev 실행 후 터미널의 localhost 주소를 엽니다.
  3. TaskFilter에 렌더 횟수 로그를 추가합니다. 작업 추가·완료로 filter가 변하지 않으면 Filter가 다시 렌더링되지 않는지 확인합니다.
  4. npm run typecheck와 npm run build가 성공하면 다음 프로젝트 단계로 갑니다.

원문에 있는 기존 체크포인트 검증 기록은 TypeScript 검사·프로덕션 빌드와 실제 소스를 사용한 JSDOM/상태 시나리오 검증 통과입니다. 별도 실제 브라우저 화면 검수는 이 파일 검증 범위에 포함하지 않았습니다.

아래 부분 예제는 핵심 변경을 설명합니다. 실행에 필요한 전체 파일과 보조 타입·API는 위 완료본에서 확인하세요.

누적 실습은 이 절의 프로젝트 단계 링크를 따라 진행합니다. 페이지 끝 학습 순서는 기술 주제 읽기 순서입니다.

이전 프로젝트 단계: Zustand selector 사용법: 필요한 상태만 가져와 리렌더링 줄이기의 적용 결과에서 시작합니다. 아래 체크포인트 이름은 단계 식별자이며 다운로드 파일명이나 Git 명령이 아닙니다.

TaskFilter와 TaskSummary의 selector를 각각 필요한 원시값 또는 안정적인 배열 참조로 바꿉니다. TaskToolbar처럼 복수 필드를 묶는 곳에만 useShallow를 적용하고 동일한 조작을 Profiler로 비교합니다.

이전 체크포인트

zustand-step-07-selectors

이번 목표

TaskFlow의 TaskFilter와 TaskSummary 구독을 점검하고 불필요한 객체 selector를 제거합니다.

잘못된 예

const taskState = useTaskStore((state) => ({
  tasks: state.tasks,
  filter: state.filter,
  selectedTaskId: state.selectedTaskId,
}));

TaskList가 selectedTaskId까지 구독할 이유가 없다면 범위를 넓힌 구조입니다.

개선

const tasks = useTaskStore(
  (state) => state.tasks,
);


const filter = useTaskStore(
  (state) => state.filter,
);

여러 값을 묶어야 하는 컴포넌트

TaskToolbar처럼 filter와 selectedTaskId를 함께 사용하는 경우 useShallow를 적용할 수 있습니다.

import { useShallow } from 'zustand/react/shallow';
import { useTaskStore } from '../store/useTaskStore';


export default function TaskToolbar() {
  const { filter, selectedTaskId } =
    useTaskStore(
      useShallow((state) => ({
        filter: state.filter,
        selectedTaskId: state.selectedTaskId,
      })),
    );


  console.log('TaskToolbar render');


  return (
    <section>
      <p>필터: {filter}</p>
      <p>
        선택:
        {selectedTaskId ?? '없음'}
      </p>
    </section>
  );
}

측정 시나리오

작업 추가
→ TaskList, TaskSummary 변화 확인


필터 변경
→ TaskFilter, TaskList 변화 확인


선택 상태 변경
→ 선택 상태를 구독하는 컴포넌트만 변화하는지 확인


modal 열기
→ modal 관련 컴포넌트만 변화하는지 확인

현재 확장 단계

✓ 1~7. Store·action·selector
→ 8. 리렌더링 측정
○ 9. 리렌더링 오류 해결
○ 10. persist
○ 11~15. middleware·migration·실무 설계

실행 확인

npm run dev
npm run build

필요하면 React DevTools Profiler로 각 상호작용을 기록합니다.

완료 체크포인트

zustand-step-08-rerender

다음 학습

다음 글에서는 상태는 바뀌었는데 화면이 갱신되지 않는 상황을 일부러 재현하고, 참조 변경과 selector 범위를 기준으로 문제를 해결합니다.

다음 프로젝트 단계: Zustand 상태 변경 후 리렌더링이 안 될 때 해결 방법

공식 문서

글 소개·학습 준비·전체 목차
Zustand: 리렌더링 최적화 표지

Zustand · 8 / 15 · 필수 학습 · 리렌더링

selector를 적용했다면 이제 실제 렌더링을 확인할 차례입니다.

Zustand는 selector가 반환한 값이 바뀌었는지를 기준으로 컴포넌트가 다시 렌더링될지 판단합니다. 따라서 어떤 값을 선택하는지, selector가 매번 새 객체를 만드는지, 참조가 안정적인지가 중요합니다.

이번 글에서 배우는 내용

  • Zustand 구독과 리렌더링 관계
  • selector 결과 비교
  • 원시값과 객체 반환 차이
  • useShallow가 필요한 경우
  • console 로그로 렌더 횟수 확인
  • React DevTools Profiler로 측정하는 기준

목차

이 글이 도움이 되었나요?

조회 중

읽음 기록

읽음 — / 전체 15개 · 필수 14개

이 글을 쓴 해비

웹사이트를 제작·운영하고, 개인 서비스와 자동화·AI 도구를 만듭니다. 직접 만들며 배운 내용과 문제 해결 과정을 BlogFlow에 기록합니다.

새 글 받아보기

RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.

RSS 피드 구독하기

댓글 남기기