리액트 상태 관리 심화 #3 Zustand로 다루는 가벼운 클라이언트 상태

5 분 소요

#2에서 서버 상태를 TanStack Query로 떼어냈습니다. 그러고 나면 전역으로 공유할 상태로는 진짜 클라이언트 상태만 남습니다. 다크 모드, 사이드바 열림 여부, 로그인한 사용자 정보, 장바구니처럼 브라우저 안에서 완결되는 값들입니다. 이번 글은 그 클라이언트 상태를 가장 가볍게 다루는 Zustand를 봅니다.

useContext로는 무엇이 아쉬운가 #

기초 강좌 #12에서 useContext로 전역 값을 공유하는 법을 배웠습니다. 작은 값 하나라면 충분하지만, 앱이 커지면 두 가지가 거슬립니다.

  • Provider 중첩 — 공유할 상태 묶음이 늘어날 때마다 Provider가 트리 위쪽에 한 겹씩 쌓입니다.
  • 불필요한 리렌더 — Context 값이 바뀌면, 그 값 중 일부만 쓰는 컴포넌트까지 Provider 아래 전체가 리렌더되기 쉽습니다.

Zustand는 이 두 가지를 정면으로 해결합니다. Provider가 없고, 필요한 값만 골라 구독해 그 값이 바뀔 때만 리렌더됩니다.

설치와 첫 스토어 #

설치
npm install zustand

create로 스토어를 만듭니다. 상태와 그 상태를 바꾸는 함수를 한 객체에 함께 담는 것이 특징입니다.

store/useCounterStore.js
import { create } from "zustand";

export const useCounterStore = create((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
}));

set은 상태를 갱신하는 함수입니다. useReducer와 달리 반환한 객체를 기존 상태에 얕게 병합합니다. 그래서 count만 반환해도 나머지 필드는 그대로 유지됩니다.

여기서 눈여겨볼 점은 이 스토어가 컴포넌트 바깥에 산다는 사실입니다. Provider로 트리를 감쌀 필요가 없습니다.

컴포넌트에서 사용하기 #

만든 스토어는 그냥 훅처럼 호출합니다.

Counter.jsx
import { useCounterStore } from "./store/useCounterStore";

function Counter() {
  const count = useCounterStore((state) => state.count);
  const increment = useCounterStore((state) => state.increment);
  const decrement = useCounterStore((state) => state.decrement);

  return (
    <div>
      <button onClick={decrement}>-</button>
      <span>{count}</span>
      <button onClick={increment}>+</button>
    </div>
  );
}

useCounterStore((state) => state.count)처럼 함수를 넘기는 부분을 셀렉터(selector)라고 합니다. “이 스토어에서 count만 구독하겠다"는 뜻입니다. 트리의 어느 컴포넌트에서 호출하든, 같은 스토어를 가리키므로 값이 공유됩니다. props로 넘기지 않아도 됩니다.

셀렉터가 리렌더를 가르는 지점 #

Zustand의 성능 이점은 이 셀렉터에서 나옵니다. 셀렉터가 반환한 값이 바뀔 때만 그 컴포넌트가 리렌더됩니다.

스토어에 값이 여럿이라고 해보겠습니다.

여러 값이 있는 스토어
const useUiStore = create((set) => ({
  isSidebarOpen: false,
  theme: "light",
  toggleSidebar: () => set((s) => ({ isSidebarOpen: !s.isSidebarOpen })),
  setTheme: (theme) => set({ theme }),
}));
theme만 구독하는 컴포넌트
function ThemeBadge() {
  // theme만 구독 → isSidebarOpen이 바뀌어도 이 컴포넌트는 리렌더되지 않음
  const theme = useUiStore((state) => state.theme);
  return <span>현재 테마: {theme}</span>;
}

ThemeBadgetheme만 구독하므로 isSidebarOpen이 아무리 토글돼도 다시 그려지지 않습니다. useContext였다면 같은 Context 값이 바뀔 때 함께 리렌더됐을 부분입니다. 이 정밀함이 Zustand가 가벼운 이유입니다.

노트
여러 값을 한 번에 꺼내려고 객체를 반환하는 셀렉터((s) => ({ a: s.a, b: s.b }))를 쓰면, 매번 새 객체가 만들어져 불필요한 리렌더가 생길 수 있습니다. 이럴 때는 값을 각각 따로 구독하거나, Zustand가 제공하는 useShallow로 얕은 비교를 적용합니다.

컴포넌트 밖에서도 접근하기 #

스토어가 React 바깥에 있으므로, 컴포넌트가 아닌 곳에서도 상태를 읽고 쓸 수 있습니다.

컴포넌트 밖에서 스토어 다루기
// 현재 값 읽기 (구독 없이 한 번 읽기)
const current = useCounterStore.getState().count;

// 값 바꾸기
useCounterStore.getState().increment();

// 변화 구독
const unsubscribe = useCounterStore.subscribe((state) =>
  console.log("count가 바뀜:", state.count)
);

이벤트 핸들러 바깥의 유틸리티 함수, 라우터 가드, 일반 모듈에서 전역 상태가 필요할 때 유용합니다.

persist — 새로고침해도 유지하기 #

다크 모드 설정이나 로그인 상태처럼 새로고침 후에도 남아야 하는 값이 있습니다. Zustand의 persist 미들웨어가 스토어를 localStorage에 자동으로 저장하고 복원합니다.

persist 미들웨어
import { create } from "zustand";
import { persist } from "zustand/middleware";

export const useThemeStore = create(
  persist(
    (set) => ({
      theme: "light",
      toggleTheme: () =>
        set((s) => ({ theme: s.theme === "light" ? "dark" : "light" })),
    }),
    { name: "theme-storage" } // localStorage 키 이름
  )
);

기초 강좌의 Todo 앱에서 localStorage 저장과 복원을 손으로 짰던 것을 떠올려 보면, 이 미들웨어가 그 일을 통째로 대신합니다.

Zustand가 잘 맞는 상황 #

  • 전역 클라이언트 상태가 필요하지만 Redux는 무겁게 느껴질 때. API가 작고 보일러플레이트가 거의 없습니다.
  • Provider 중첩을 늘리고 싶지 않을 때. 스토어가 React 바깥에 있어 트리를 감싸지 않습니다.
  • 리렌더를 정밀하게 통제하고 싶을 때. 셀렉터 단위로 구독이 끊깁니다.

반대로 서버 데이터를 여기에 넣지는 마시기 바랍니다. #1에서 강조했듯, 서버 상태는 #2의 TanStack Query에 맡기고 Zustand에는 클라이언트 상태만 두는 편이 단순합니다.

마무리 #

Zustand는 가장 적은 코드로 전역 클라이언트 상태를 공유하는 도구입니다.

  • create((set) => ({ ... })) — 상태와 갱신 함수를 한 객체에, Provider 불필요
  • 셀렉터 useStore((s) => s.x) — 필요한 값만 구독, 그 값이 바뀔 때만 리렌더
  • getState / subscribe — 컴포넌트 밖에서도 접근
  • persist 미들웨어 — localStorage 저장과 복원 자동화

다음 글인 “리액트 상태 관리 심화 #4 Jotai와 원자(atom) 모델"에서는 같은 클라이언트 상태를 전혀 다른 각도에서 접근합니다. Zustand가 하나의 스토어에 상태를 모으는 하향식이라면, Jotai는 상태를 작은 원자로 쪼개 조립하는 상향식입니다.

X