Zustand 실무 사용 기준: store가 복잡해질 때 피할 실수

2026.05.07·수정 2026.09.12·약 17분·작성: 해비·블로그 소개

학습 목표·선수 지식: 소유권·수명·변경의 원자성을 기준으로 store 경계를 정하고 SSR·로그아웃 초기화까지 점검합니다. 선수 지식: store·action·selector·persist 기본 사용.

이 글에서 정리하는 내용

Zustand는 설정이 단순해 빠르게 도입할 수 있지만, 어떤 상태를 전역 store에 넣을지 기준이 없으면 UI 상태와 서버 데이터, 임시 값이 한곳에 섞이기 쉽습니다. 이 글에서는 상태의 출처와 수명, 공유 범위를 기준으로 Zustand에 넣을 값과 다른 도구에 맡길 값을 구분하고, selector·persist·store 분리 기준까지 정리합니다.

Zustand가 쉬워서 더 쉽게 무너지는 지점

Zustand store가 UI 상태와 서버 상태를 함께 담으며 복잡해지는 흐름을 보여주는 구조도

Zustand의 장점은 작은 store를 빠르게 만들 수 있다는 점입니다. 모달 열림 여부, 메뉴 상태, 테마처럼 여러 컴포넌트가 공유하는 클라이언트 상태는 별도의 reducer 구조 없이도 간단하게 관리할 수 있습니다.

문제는 편하다는 이유로 모든 값을 store에 넣기 시작할 때 생깁니다. 메뉴 상태, 관리자 탭, 로그인 UI, API로 받아온 상품 목록까지 한 store에 섞이면 시간이 지날수록 “이 값은 누가 바꾸는가”, “서버에서 다시 받아야 하는가”, “새로고침 후에도 남아야 하는가”가 불분명해집니다.

따라서 Zustand를 사용할 때 가장 먼저 볼 것은 문법이 아니라 상태의 경계입니다. 여러 곳에서 접근할 수 있다는 사실과 전역 store에 넣어야 한다는 판단은 다릅니다.

개념 부분 코드: src/examples/post-2968-1.tsx에 해당하는 비교·설명 조각입니다. 서로 다른 변형을 한 파일에 동시에 붙이지 마세요.

import { create } from 'zustand';

type UiStore = {
  isMenuOpen: boolean;
  selectedAdminTab: 'dashboard' | 'orders' | 'settings';
  setMenuOpen: (open: boolean) => void;
  setSelectedAdminTab: (tab: UiStore['selectedAdminTab']) => void;
};

export const useUiStore = create<UiStore>((set) => ({
  isMenuOpen: false,
  selectedAdminTab: 'dashboard',
  setMenuOpen: (open) => set({ isMenuOpen: open }),
  setSelectedAdminTab: (tab) => set({ selectedAdminTab: tab }),
}));

위처럼 같은 성격의 UI 상태만 묶으면 store의 역할이 분명합니다. 반대로 여기에 서버에서 받은 상품 목록과 로딩·에러 상태까지 계속 추가하면 캐시와 재요청 책임까지 Zustand가 떠안게 됩니다.

Zustand에 넣을 상태와 넣지 않을 상태

Zustand에 잘 맞는 값은 여러 컴포넌트가 함께 읽거나 수정해야 하는 클라이언트 상태입니다. 모바일 메뉴, 전역 모달, 사용자 보기 방식, 테마, 관리자 탭처럼 브라우저 안에서 상태의 원본이 정해지는 값이 대표적입니다.

반대로 한 컴포넌트 안에서만 의미가 있는 입력값이나 토글은 useState가 더 단순합니다. 전역 store로 올리는 순간 접근 범위는 넓어지지만 변경 경로도 넓어집니다.

상태 유형 권장 방향
여러 위치에서 여닫는 모달 Zustand 후보
관리자 탭·보기 모드 여러 화면이 공유하면 Zustand 후보
단일 입력창 값 useState가 단순
API 상품 목록·게시글 상세 TanStack Query 같은 서버 상태 도구 우선
검색어·페이지 번호·공유 가능한 필터 URL query 우선 검토

판단이 애매하면 “이 컴포넌트가 사라져도 값이 남아야 하는가?”, “멀리 떨어진 다른 컴포넌트도 같은 값을 사용해야 하는가?”를 먼저 확인하면 됩니다. 두 질문 모두 아니라면 지역 상태로 두는 편이 낫습니다.

서버 상태를 Zustand에 넣을 때 생기는 문제

상품 목록, 사용자 목록, 주문 내역처럼 서버에서 가져오는 데이터는 단순한 값 저장 외에도 캐시, 재요청, stale 판단, 로딩, 오류, mutation 이후 갱신이 필요합니다. 이 책임을 Zustand 안에서 직접 구현하면 store가 빠르게 복잡해집니다.

통합 부분 코드: src/pages/ProductPage.tsx의 책임 분리 예시입니다. useProductUiStore, useQuery의 import, fetchProducts, ProductList와 QueryClientProvider는 서비스 프로젝트가 제공해야 합니다. 독립 실행 예제나 외부 API 검증 결과가 아닙니다. useQuery 오류 상태도 실제 서비스의 오류 UI에 연결해야 합니다.

개념 부분 코드: src/examples/post-2968-2.tsx에 해당하는 비교·설명 조각입니다. 서로 다른 변형을 한 파일에 동시에 붙이지 마세요.

function ProductPage() {
  const viewMode = useProductUiStore((state) => state.viewMode);

  const { data: products = [], isLoading } = useQuery({
    queryKey: ['products'],
    queryFn: fetchProducts,
  });

  if (isLoading) return <p>상품을 불러오는 중입니다.</p>;

  return <ProductList products={products} viewMode={viewMode} />;
}

이 구조에서는 상품 목록 자체는 TanStack Query가 맡고, 사용자가 선택한 보기 방식만 Zustand가 맡습니다. 같은 화면에서 함께 쓰이더라도 상태의 출처가 다르면 책임도 분리하는 편이 유지보수에 유리합니다.

store를 나누는 기준

분리 전에 함께 바뀌어야 하는 값을 찾습니다

카테고리 변경과 페이지 1 초기화가 한 동작이라면 한 action의 set으로 함께 반영하는 편이 중간 상태를 줄입니다. 이를 서로 다른 store로 나누면 A만 바뀌고 B가 아직 이전인 순간을 소비자가 볼 수 있습니다. 파일 수나 렌더 수만으로 분리하지 말고 변경의 원자성과 상태 수명을 먼저 정하세요.

공유 가능한 검색어·정렬은 URL을 원본으로 정하고 store에는 패널 열림처럼 일시적인 UI만 둘 수 있습니다. URL과 store를 모두 원본으로 취급하면 뒤로 가기 때 덮어쓰기 순서가 모호해집니다.

처음부터 store를 지나치게 잘게 나눌 필요는 없습니다. 작은 프로젝트에서는 하나의 UI store로 시작해도 충분합니다. 다만 상태 변경 이유가 서로 달라지고 action 이름이 모호해지기 시작하면 분리를 검토할 시점입니다.

예를 들어 관리자 탭 변경과 결제 모달 제어는 모두 전역 UI 상태일 수 있지만 변경 이유가 다릅니다. 서로 관련 없는 상태가 한 action에서 함께 초기화되기 시작한다면 도메인별 store로 나누는 편이 좋습니다.

개념 부분 코드: src/examples/post-2968-3.tsx에 해당하는 비교·설명 조각입니다. 서로 다른 변형을 한 파일에 동시에 붙이지 마세요.

export const useAdminUiStore = create<{
  selectedTab: 'dashboard' | 'orders' | 'settings';
  setSelectedTab: (tab: 'dashboard' | 'orders' | 'settings') => void;
}>((set) => ({
  selectedTab: 'dashboard',
  setSelectedTab: (tab) => set({ selectedTab: tab }),
}));

export const useModalStore = create<{
  activeModal: 'payment' | 'preview' | null;
  openModal: (modal: 'payment' | 'preview') => void;
  closeModal: () => void;
}>((set) => ({
  activeModal: null,
  openModal: (modal) => set({ activeModal: modal }),
  closeModal: () => set({ activeModal: null }),
}));

파일 수보다 중요한 것은 상태 변경 이유가 같은지입니다. 같은 기능 흐름에 속하지 않는 상태가 서로 영향을 주기 시작하면 store를 나누는 편이 구조를 이해하기 쉽습니다.

selector와 persist 사용 기준

컴포넌트에서는 store 전체보다 실제 필요한 값만 selector로 구독하는 편이 좋습니다. 관련 없는 상태 변경으로 불필요한 렌더링이 발생하는 범위를 줄일 수 있고, 컴포넌트가 어떤 상태에 의존하는지도 코드에서 바로 보입니다.

개념 부분 코드: src/examples/post-2968-4.tsx에 해당하는 비교·설명 조각입니다. 서로 다른 변형을 한 파일에 동시에 붙이지 마세요.

// src/components/Header.tsx: 위 useUiStore와 연결하는 부분 코드
function Header() {
  const isMenuOpen = useUiStore((state) => state.isMenuOpen);
  const setMenuOpen = useUiStore((state) => state.setMenuOpen);
  return (
    <header>
      {isMenuOpen && <button onClick={() => setMenuOpen(false)}>메뉴 닫기</button>}
    </header>
  );
}

여러 값을 객체로 묶어 selector에서 반환한다면 새 객체가 매번 만들어지는 점도 확인해야 합니다. 필요한 경우 Zustand의 얕은 비교 도구인 useShallow를 검토할 수 있습니다.

persist는 새로고침 후에도 남아야 하는 값에만 사용합니다. 테마, 언어, 사이드바 접힘 여부는 자연스럽지만 모달 열림 여부, 로딩 상태, 일회성 에러 메시지까지 저장하면 다시 방문했을 때 이상한 화면이 복원될 수 있습니다.

개념 부분 코드: src/examples/post-2968-5.tsx에 해당하는 비교·설명 조각입니다. 서로 다른 변형을 한 파일에 동시에 붙이지 마세요.

// src/stores/usePreferenceStore.ts: 브라우저 SPA의 저장 범위 예시
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
type Preference = {
  theme: 'light' | 'dark';
  sidebarCollapsed: boolean;
  setTheme: (theme: 'light' | 'dark') => void;
  setSidebarCollapsed: (value: boolean) => void;
};
export const usePreferenceStore = create<Preference>()(
  persist(
    (set) => ({
      theme: 'light',
      sidebarCollapsed: false,
      setTheme: (theme) => set({ theme }),
      setSidebarCollapsed: (sidebarCollapsed) => set({ sidebarCollapsed }),
    }),
    {
      name: 'preference-storage',
      partialize: (state) => ({
        theme: state.theme,
        sidebarCollapsed: state.sidebarCollapsed,
      }),
      merge: (saved, current) => {
        const data =
          typeof saved === 'object' && saved !== null && !Array.isArray(saved)
            ? (saved as Record<string, unknown>)
            : {};
        return {
          ...current,
          theme: data.theme === 'dark' ? 'dark' : 'light',
          sidebarCollapsed:
            typeof data.sidebarCollapsed === 'boolean' ? data.sidebarCollapsed : false,
        };
      },
    },
  ),
);

partialize를 이용하면 persist 대상만 명시적으로 골라 저장할 수 있습니다. persist의 목적은 store 전체를 보존하는 것이 아니라, 다시 방문했을 때 복원되어도 자연스러운 값만 남기는 것입니다.

실무 적용 체크리스트

사용자와 요청의 경계

클라이언트의 모듈 store는 SPA 수명 동안 유지될 수 있으므로 로그아웃·계정 전환 때 사용자별 선택과 초안을 초기화하는 정책이 필요합니다. 메모리 reset과 persist 저장소 삭제는 별개이며 삭제 후 어떤 action이 다시 저장하는지도 확인해야 합니다.

SSR 서버에서는 사용자별 module singleton을 요청 간 공유하지 않습니다. 요청별로 store를 만들고 서버·클라이언트 첫 값이 일치하도록 초기 데이터를 전달합니다. RSC는 Zustand store를 읽거나 쓰는 상태 보관소로 쓰지 않습니다. 아래 부분 예제는 브라우저 UI 설계 설명이며 SSR 요청 격리 구현까지 제공하지 않습니다.

Zustand 적용 전 상태 수명과 출처, persist 대상을 점검하는 체크리스트 인포그래픽
  • 여러 컴포넌트가 실제로 같은 값을 읽고 수정하는가?
  • 컴포넌트 지역 상태로 두면 오히려 전달 구조가 복잡해지는가?
  • 서버에서 다시 받아오는 데이터가 아니라 클라이언트 상태인가?
  • URL로 표현해야 할 필터·검색·페이지 상태는 아닌가?
  • action 이름만 봐도 무엇을 바꾸는지 설명할 수 있는가?
  • 새로고침 후 유지해야 할 값만 persist하고 있는가?
  • 컴포넌트는 필요한 상태만 selector로 구독하고 있는가?

과정 마무리 실습

두 컴포넌트가 공유하는 선택 목록을 만들고 추가·제거 action을 분리하세요.

완료 기준: 중복 추가·마지막 항목 제거·새로고침 이후 유지 범위와 selector 구독 대상을 확인합니다.

이어서 공부할 과정: TanStack Query 첫 글 · 테스트 첫 글

전체 학습 경로

정리

Zustand를 잘 쓰는 기준은 store를 크게 만드는 것이 아니라 상태의 책임을 좁게 유지하는 것입니다. 가까운 컴포넌트에서 끝나는 값은 지역 상태로 두고, 여러 영역이 공유하는 클라이언트 상태만 store로 올리는 편이 단순합니다.

서버 데이터는 TanStack Query 같은 서버 상태 도구와 역할을 나누고, 공유 가능한 필터나 페이지 번호는 URL 상태인지 먼저 확인해야 합니다. persist도 모든 상태를 남기는 기능이 아니라 다시 방문했을 때 복원되어도 자연스러운 값만 선택하는 용도로 사용하는 것이 좋습니다.

같이 읽으면 좋은 글

공식 근거와 확인 범위

확인일: 2026-09-12. 실행 프로젝트는 Zustand 5.0.15, React 19.3.0, TypeScript 7.0.2로 검사했습니다. 공식 문서의 API 설명과 실제 설치 버전의 동작을 구분해 확인합니다.

공식 문서

이 글이 도움이 되었나요?

조회 중

Zustand 학습 순서

필수 14개 · 전체 15개

읽음 기록 관리

전체 과정 목차 (15개)
  1. 필수 길잡이 · Zustand 학습 로드맵: store·action·selector·persist 순서
  2. 필수 길잡이 · React state vs Zustand: 전역 상태가 필요한 기준
  3. 필수 학습 · Zustand란? React 상태 관리 선택 기준과 기본 Store
  4. 필수 학습 · Zustand 설치 사용법: 기본 Store 만들고 상태 연결하기
  5. 필수 학습 · Zustand state 사용법: 값 읽기와 변경 흐름 익히기
  6. 필수 학습 · Zustand action 사용법: 상태 변경 로직을 store로 분리하기
  7. 필수 학습 · Zustand selector 사용법: 필요한 상태만 가져와 리렌더링 줄이기
  8. 필수 학습 · Zustand 리렌더링 원리와 selector 최적화 방법
  9. 필수 학습 · Zustand persist 사용법: 새로고침 후 상태 저장하기
  10. 필수 학습 · Zustand persist 마이그레이션 기준: 저장된 상태 구조가 바뀔 때
  11. 선택 참고 · Zustand 상태 변경 후 리렌더링이 안 될 때 해결 방법
  12. 필수 선수 · Zustand 실무 사용 기준: store가 복잡해질 때 피할 실수 현재 글
  13. 필수 학습 · Zustand combine·immer 실습: 타입 추론과 중첩 상태 불변성
  14. 필수 학습 · Zustand subscribeWithSelector·devtools: 선택 구독과 해제 실습
  15. 필수 학습 · Zustand Todo 완성 실습: actions·선택 훅·persist 연결

새 글 받아보기

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

RSS 피드 구독하기

댓글 남기기