리액트 상태 관리 심화 #3 Zustand로 다루는 가벼운 클라이언트 상태
#2에서 서버 상태를 TanStack Query로 떼어냈습니다. 그러고 나면 전역으로 공유할 상태로는 진짜 클라이언트 상태만 남습니다. 다크 모드, 사이드바 열림 여부, 로그인한 사용자 정보, 장바구니처럼 브라우저 안에서 완결되는 값들입니다. 이번 글은 그 클라이언트 상태를 가장 가볍게 다루는 Zustand를 봅니다.
useContext로는 무엇이 아쉬운가 #
기초 강좌 #12에서 useContext로 전역 값을 공유하는 법을 배웠습니다. 작은 값 하나라면 충분하지만, 앱이 커지면 두 가지가 거슬립니다.
- Provider 중첩 — 공유할 상태 묶음이 늘어날 때마다 Provider가 트리 위쪽에 한 겹씩 쌓입니다.
- 불필요한 리렌더 — Context 값이 바뀌면, 그 값 중 일부만 쓰는 컴포넌트까지 Provider 아래 전체가 리렌더되기 쉽습니다.
Zustand는 이 두 가지를 정면으로 해결합니다. Provider가 없고, 필요한 값만 골라 구독해 그 값이 바뀔 때만 리렌더됩니다.
설치와 첫 스토어 #
npm install zustandcreate로 스토어를 만듭니다. 상태와 그 상태를 바꾸는 함수를 한 객체에 함께 담는 것이 특징입니다.
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로 트리를 감쌀 필요가 없습니다.
컴포넌트에서 사용하기 #
만든 스토어는 그냥 훅처럼 호출합니다.
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 }),
}));function ThemeBadge() {
// theme만 구독 → isSidebarOpen이 바뀌어도 이 컴포넌트는 리렌더되지 않음
const theme = useUiStore((state) => state.theme);
return <span>현재 테마: {theme}</span>;
}ThemeBadge는 theme만 구독하므로 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에 자동으로 저장하고 복원합니다.
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는 상태를 작은 원자로 쪼개 조립하는 상향식입니다.