🎯 학습 목표
- Redux와 Zustand의 코드 양을 비교하고 Zustand의 장점을 이해한다
- Zustand store의 기본 구조(create, set, get)를 구현할 수 있다
- 여러 slice로 store를 분리하는 패턴을 안다
- Zustand devtools를 설정하고 활용한다
- 서버 상태(React Query)와 클라이언트 상태(Zustand)를 구분한다
📖 핵심 개념 1 — Redux vs Zustand 코드 비교
동일한 카운터 기능을 Redux Toolkit과 Zustand로 각각 구현해보겠습니다.
// ❌ Redux Toolkit 방식 — 보일러플레이트가 많음
// store/counterSlice.js
import { createSlice } from '@reduxjs/toolkit';
export const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
increment: state => { state.value += 1; },
decrement: state => { state.value -= 1; },
incrementByAmount: (state, action) => { state.value += action.payload; },
},
});
export const { increment, decrement, incrementByAmount } = counterSlice.actions;
export default counterSlice.reducer;
// store/index.js
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from './counterSlice';
export const store = configureStore({
reducer: { counter: counterReducer },
});
// main.jsx
import { Provider } from 'react-redux';
import { store } from './store';
// <Provider store={store}><App /></Provider> 로 감싸야 함
// 컴포넌트에서 사용
import { useSelector, useDispatch } from 'react-redux';
function Counter() {
const count = useSelector(state => state.counter.value);
const dispatch = useDispatch();
return (
<button onClick={() => dispatch(increment())}>{count}</button>
);
}
// ✅ Zustand 방식 — 간결하고 직관적
// npm install zustand
// store/counterStore.js
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
const useCounterStore = create(devtools((set) => ({
count: 0,
increment: () => set(state => ({ count: state.count + 1 })),
decrement: () => set(state => ({ count: state.count - 1 })),
incrementByAmount: (amount) => set(state => ({ count: state.count + amount })),
reset: () => set({ count: 0 }),
})));
export default useCounterStore;
// 컴포넌트에서 사용 — Provider 불필요!
function Counter() {
const { count, increment, decrement } = useCounterStore();
return (
<div>
<p>{count}</p>
<button onClick={increment}>+1</button>
<button onClick={decrement}>-1</button>
</div>
);
}
Zustand의 핵심 장점은 Provider가 필요 없고, 설정 코드가 거의 없으며, 학습 곡선이 낮습니다. 패널에서 “스타트업이나 중소규모 프로젝트에서 Redux 대신 Zustand를 선택하는 경우가 빠르게 증가하고 있다”고 했습니다.
📖 핵심 개념 2 — Zustand Store 설계
// store/userStore.js — 실무 수준의 store
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
const useUserStore = create(
devtools(
persist( // 새로고침 후에도 상태 유지 (localStorage에 저장)
(set, get) => ({
// 상태
user: null,
isAuthenticated: false,
permissions: [],
// 액션
login: (userData) => set({
user: userData,
isAuthenticated: true,
permissions: userData.permissions || [],
}),
logout: () => set({
user: null,
isAuthenticated: false,
permissions: [],
}),
updateProfile: (updates) => set(state => ({
user: state.user ? { ...state.user, ...updates } : null,
})),
// get()으로 현재 상태 읽기
hasPermission: (permission) => {
const { permissions } = get();
return permissions.includes(permission);
},
}),
{
name: 'user-storage', // localStorage 키
// 민감한 정보는 제외하고 저장
partialize: (state) => ({ user: state.user, isAuthenticated: state.isAuthenticated }),
}
)
)
);
export default useUserStore;
📖 핵심 개념 3 — Store 분리 (Slice 패턴)
// 큰 store를 여러 slice로 분리
// store/slices/cartSlice.js
export const createCartSlice = (set, get) => ({
cart: { items: [], total: 0 },
addToCart: (product) => set(state => {
const newItems = [...state.cart.items, { ...product, quantity: 1 }];
return { cart: { items: newItems, total: newItems.reduce((s, i) => s + i.price, 0) } };
}),
clearCart: () => set({ cart: { items: [], total: 0 } }),
});
// store/slices/uiSlice.js
export const createUiSlice = (set) => ({
ui: { sidebarOpen: false, theme: 'light', modal: null },
toggleSidebar: () => set(state => ({
ui: { ...state.ui, sidebarOpen: !state.ui.sidebarOpen }
})),
openModal: (modalName) => set(state => ({
ui: { ...state.ui, modal: modalName }
})),
closeModal: () => set(state => ({
ui: { ...state.ui, modal: null }
})),
});
// store/index.js — slice 조합
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';
import { createCartSlice } from './slices/cartSlice';
import { createUiSlice } from './slices/uiSlice';
export const useStore = create(devtools((set, get) => ({
...createCartSlice(set, get),
...createUiSlice(set, get),
})));
// 선택적 구독 — 관련 상태만 구독하여 불필요한 리렌더링 방지
function CartIcon() {
// items만 구독 — ui 상태가 변해도 이 컴포넌트는 리렌더링 안 됨
const itemCount = useStore(state => state.cart.items.length);
return <span>🛒 {itemCount}</span>;
}
📖 핵심 개념 4 — 서버 상태 vs 클라이언트 상태
상태를 두 가지로 나눠 관리하는 것이 현대 React 앱의 표준 패턴입니다.
// 서버 상태 (Server State) → React Query(TanStack Query)로 관리
// - API에서 가져온 데이터
// - 캐싱, 동기화, 갱신이 필요
// - 예: 사용자 목록, 게시글, 상품 정보
const { data: products } = useQuery({
queryKey: ['products'],
queryFn: fetchProducts,
});
// 클라이언트 상태 (Client State) → Zustand로 관리
// - 서버와 관계없는 UI 상태
// - 캐싱 불필요
// - 예: 사이드바 열림/닫힘, 선택된 탭, 장바구니
const { sidebarOpen, toggleSidebar } = useStore(state => ({
sidebarOpen: state.ui.sidebarOpen,
toggleSidebar: state.toggleSidebar,
}));
패널에서 “모든 상태를 하나의 전역 store에 넣으려는 Redux 시절의 습관에서 벗어나야 한다. 서버 데이터는 React Query, UI 상태는 Zustand, 로컬 UI 상태는 useState — 이 세 가지를 조합하면 대부분의 앱을 깔끔하게 구현할 수 있다”고 강조했습니다.
⚠️ 흔한 실수 (よくあるミス)
- 서버 상태를 Zustand에 저장: API 데이터를 Zustand store에 저장하고 수동으로 관리하면 캐싱, 동기화, 로딩 상태 등을 직접 구현해야 합니다. React Query를 사용하세요.
- 컴포넌트에서 store 전체 구독:
const store = useStore()처럼 store 전체를 구독하면 store의 어떤 부분이 변해도 리렌더링됩니다. 필요한 부분만 선택적으로 구독하세요. - store 안에서 비동기 로직 남용: 복잡한 비동기 로직은 store 밖(컴포넌트, 커스텀 훅, React Query)에서 처리하고, store에는 순수 상태 변경만 담는 것이 권장됩니다.
💡 실무 팁
- Zustand DevTools: Chrome의 Redux DevTools 확장을 설치하면
devtools미들웨어를 통해 Zustand store의 상태 변화를 시각적으로 추적할 수 있습니다. - shallow 비교: 여러 상태를 한 번에 구독할 때
import { shallow } from 'zustand/shallow'를 사용하면 객체 내부 값을 비교하여 불필요한 리렌더링을 줄일 수 있습니다. - persist 미들웨어: 새로고침 후에도 유지해야 하는 상태(테마, 장바구니 등)에
persist미들웨어를 사용하면 localStorage 동기화를 자동으로 처리합니다.