Zustand 동작 원리 — 왜 이렇게 간단한데 잘 동작하나
Provider도 없고, 보일러플레이트도 거의 없는데 전역 상태 관리가 된다면, Zustand는 내부적으로 어떤 마법을 부리고 있는 걸까요?
개념 정의
Zustand는 가볍고 간결한 상태 관리 라이브러리 입니다. 불과 수백 바이트의 코어에서 subscribe/getState 패턴으로 동작하며, Provider 없이도 전역 상태를 관리할 수 있습니다.
왜 필요한가
- Redux는 강력하지만 설정과 보일러플레이트가 많음
- Context API는 값 변경 시 모든 구독자가 리렌더링됨
- Zustand는 최소한의 코드로 선택적 구독 이 가능
// Zustand: 이게 전부입니다
import { create } from 'zustand';
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
decrement: () => set((state) => ({ count: state.count - 1 })),
}));
function Counter() {
const count = useStore((state) => state.count);
const increment = useStore((state) => state.increment);
return <button onClick={increment}>{count}</button>;
}
내부 동작
subscribe/getState 패턴
Zustand의 핵심은 바닐라 JavaScript의 발행-구독(pub-sub) 패턴 입니다.
// Zustand 코어의 개념적 구현
function createStore(createState) {
let state;
const listeners = new Set();
const getState = () => state;
const setState = (partial) => {
const nextState = typeof partial === 'function' ? partial(state) : partial;
if (!Object.is(nextState, state)) {
state = { ...state, ...nextState };
listeners.forEach((listener) => listener(state));
}
};
const subscribe = (listener) => {
listeners.add(listener);
return () => listeners.delete(listener);
};
// 초기 상태 생성
state = createState(setState, getState);
return { getState, setState, subscribe };
}
React 통합: useSyncExternalStore
React 18 이후 Zustand는 내부적으로 useSyncExternalStore를 사용하여 React와 통합합니다.
// create 훅의 개념적 구현
function create(createState) {
const store = createStore(createState);
function useStore(selector) {
return useSyncExternalStore(
store.subscribe,
() => selector(store.getState()),
);
}
// 스토어 메서드도 노출
useStore.getState = store.getState;
useStore.setState = store.setState;
useStore.subscribe = store.subscribe;
return useStore;
}
selector 기반 리렌더링
이것이 Context API와의 가장 큰 차이입니다.
const useStore = create((set) => ({
user: { name: '홍길동', age: 25 },
theme: 'dark',
notifications: [],
setTheme: (theme) => set({ theme }),
}));
// 이 컴포넌트는 theme이 변할 때만 리렌더링
function ThemeToggle() {
const theme = useStore((state) => state.theme);
const setTheme = useStore((state) => state.setTheme);
return <button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>{theme}</button>;
}
// 이 컴포넌트는 user가 변할 때만 리렌더링
function UserName() {
const name = useStore((state) => state.user.name);
return <span>{name}</span>;
}
selector의 반환값이 Object.is로 이전과 같으면 리렌더링을 스킵합니다.
미들웨어
persist — 로컬 스토리지 동기화
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
const useSettingsStore = create(
persist(
(set) => ({
theme: 'light',
language: 'ko',
setTheme: (theme) => set({ theme }),
setLanguage: (lang) => set({ language: lang }),
}),
{
name: 'settings-storage', // localStorage key
partialize: (state) => ({
theme: state.theme,
language: state.language,
}), // 저장할 상태만 선택
}
)
);
devtools — Redux DevTools 연동
import { devtools } from 'zustand/middleware';
const useStore = create(
devtools(
(set) => ({
count: 0,
increment: () => set(
(state) => ({ count: state.count + 1 }),
false,
'increment' // DevTools에 표시될 액션 이름
),
}),
{ name: 'CounterStore' }
)
);
immer — 불변 업데이트 간소화
import { immer } from 'zustand/middleware/immer';
const useStore = create(
immer((set) => ({
todos: [],
addTodo: (text) => set((state) => {
state.todos.push({ id: Date.now(), text, done: false });
}),
toggleTodo: (id) => set((state) => {
const todo = state.todos.find(t => t.id === id);
if (todo) todo.done = !todo.done;
}),
}))
);
미들웨어 조합
const useStore = create(
devtools(
persist(
immer((set) => ({
// 스토어 정의
})),
{ name: 'my-storage' }
),
{ name: 'MyStore' }
)
);
React 외부에서 사용
Provider 없이 동작하므로, React 외부에서도 상태에 접근할 수 있습니다.
// API 인터셉터에서 사용
const authStore = create((set) => ({
token: null,
setToken: (token) => set({ token }),
clearToken: () => set({ token: null }),
}));
// Axios 인터셉터 (React 외부)
axios.interceptors.request.use((config) => {
const token = authStore.getState().token;
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
// 상태 변화 구독 (React 외부)
const unsubscribe = authStore.subscribe((state) => {
console.log('인증 상태 변경:', state.token ? '로그인' : '로그아웃');
});
Zustand vs Jotai vs Valtio
| 특성 | Zustand | Jotai | Valtio |
|---|---|---|---|
| 모델 | 단일 스토어 | 원자(atom) 단위 | 프록시 기반 |
| API 스타일 | flux (get/set) | 원자적 | 직접 변경 |
| Provider | 불필요 | 불필요 | 불필요 |
| 번들 크기 | ~1KB | ~2KB | ~3KB |
| 적합한 경우 | 여러 상태를 한 곳에서 관리 | 파생 상태가 많은 경우 | 간단한 상태 관리 |
// Jotai 스타일: 원자 단위
const countAtom = atom(0);
const doubledAtom = atom((get) => get(countAtom) * 2); // 파생 상태
function Counter() {
const [count, setCount] = useAtom(countAtom);
const doubled = useAtomValue(doubledAtom);
}
// Valtio 스타일: 프록시 기반
const state = proxy({ count: 0 });
function Counter() {
const snap = useSnapshot(state);
return <button onClick={() => state.count++}>{snap.count}</button>;
}
패턴: 슬라이스 분리
큰 스토어를 슬라이스로 나누어 관리할 수 있습니다.
// 각 슬라이스 정의
const createAuthSlice = (set) => ({
user: null,
isLoggedIn: false,
login: (user) => set({ user, isLoggedIn: true }),
logout: () => set({ user: null, isLoggedIn: false }),
});
const createUISlice = (set) => ({
sidebarOpen: true,
theme: 'light',
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
});
// 슬라이스 합치기
const useStore = create((...args) => ({
...createAuthSlice(...args),
...createUISlice(...args),
}));
주의할 점
selector 없이 전체 store를 구독
const store = useStore()처럼 selector 없이 사용하면, store의 어떤 값이든 변경될 때마다 해당 컴포넌트가 리렌더링됩니다. useStore(state => state.count)처럼 필요한 값만 선택해야 Zustand의 성능 이점을 누릴 수 있습니다.
객체 selector의 참조 문제
useStore(state => ({ a: state.a, b: state.b }))는 매 호출마다 새 객체를 반환하므로 항상 리렌더링됩니다. shallow 비교를 두 번째 인자로 전달하거나, 원시값 selector를 분리해야 합니다.
정리
| 항목 | 설명 |
|---|---|
| 핵심 패턴 | subscribe/getState — 외부 스토어를 React에 연결 |
| selector 구독 | 필요한 값만 선택하여 ** 최소 리렌더링** |
| Provider 불필요 | 모듈 레벨 스토어 — React 외부에서도 접근 가능 |
| 미들웨어 | persist, devtools, immer 등으로 기능 확장 |
| 슬라이스 패턴 | 큰 스토어를 도메인별 모듈로 분리 |
Zustand는 "이게 전부야?"라고 느낄 정도로 간결하지만, 실제로 이것만으로 대부분의 전역 상태 관리가 해결됩니다.