상태관리

React 강좌 11강 — Zustand 실전 패턴: 가장 간결한 전역 상태 관리

🎯 학습 목표

  • 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 동기화를 자동으로 처리합니다.