Next.js 이론 9강

Zustand로 클라이언트 상태 관리

Zustand v5를 사용하여 클라이언트 상태를 간단하고 효율적으로 관리하는 방법을 학습합니다.

1. 클라이언트 상태란

클라이언트 상태는 브라우저에서만 존재하고 서버와 동기화할 필요가 없는 상태입니다. 서버 상태(TanStack Query)와 구분하여 관리해야 합니다.

클라이언트 상태 예시

🎨 UI 상태

모달 열림/닫힘, 사이드바 토글, 탭 선택, 드롭다운

🌙 테마 설정

다크 모드, 폰트 크기, 언어 설정

📝 폼 입력 중 상태

제출 전 임시 데이터, 유효성 검사 결과

🛒 장바구니 (로그인 전)

비회원 장바구니, 로컬 저장

서버 상태 vs 클라이언트 상태

구분서버 상태클라이언트 상태
출처서버/DB브라우저
동기화서버와 동기화 필요동기화 불필요
캐싱복잡 (stale, refetch)단순
예시상품 목록, 사용자 정보모달 상태, 테마
도구TanStack QueryZustand, Jotai

왜 Zustand인가?

간단한 API

Redux 대비 보일러플레이트 90% 감소

작은 번들 크기

~1KB (gzipped), Redux Toolkit ~11KB

TypeScript 지원

완벽한 타입 추론

미들웨어 지원

persist, devtools, immer 등

상태 관리 선택 기준

  • 서버 데이터: TanStack Query (캐싱, 동기화)
  • 전역 클라이언트 상태: Zustand (간단, 가벼움)
  • 컴포넌트 로컬 상태: useState (React 기본)

2. Zustand 기본 사용법

Zustand는 간단한 API로 전역 상태를 관리합니다. create 함수로 스토어를 만들고, 훅처럼 사용합니다.

설치

npm install zustand

기본 스토어 생성

// store/counter.ts
import { create } from 'zustand';

interface CounterState {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
}

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

// 컴포넌트에서 사용
'use client';

import { useCounterStore } from '@/store/counter';

export function Counter() {
  const { count, increment, decrement, reset } = useCounterStore();
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
      <button onClick={reset}>Reset</button>
    </div>
  );
}

선택적 구독 (Selector)

// 전체 상태 구독 (비권장 - 모든 변경에 리렌더링)
const { count, increment } = useCounterStore();

// 선택적 구독 (권장 - 필요한 것만)
const count = useCounterStore((state) => state.count);
const increment = useCounterStore((state) => state.increment);

// 여러 값을 한번에 선택 (shallow 비교 필요)
import { shallow } from 'zustand/shallow';

const { count, increment } = useCounterStore(
  (state) => ({ count: state.count, increment: state.increment }),
  shallow
);

// 또는 useShallow 훅 사용 (Zustand 4.4+)
import { useShallow } from 'zustand/react/shallow';

const { count, increment } = useCounterStore(
  useShallow((state) => ({ count: state.count, increment: state.increment }))
);

get으로 현재 상태 읽기

// set과 get 모두 사용
export const useCartStore = create<CartState>((set, get) => ({
  items: [],
  
  addItem: (item) => {
    const currentItems = get().items;
    const existingItem = currentItems.find(i => i.id === item.id);
    
    if (existingItem) {
      // 이미 있으면 수량 증가
      set({
        items: currentItems.map(i =>
          i.id === item.id
            ? { ...i, quantity: i.quantity + 1 }
            : i
        ),
      });
    } else {
      // 새 상품 추가
      set({ items: [...currentItems, { ...item, quantity: 1 }] });
    }
  },
  
  // Computed value (getter 함수)
  getTotalPrice: () => {
    return get().items.reduce(
      (sum, item) => sum + item.price * item.quantity,
      0
    );
  },
  
  getTotalItems: () => {
    return get().items.reduce((sum, item) => sum + item.quantity, 0);
  },
}));

스토어 외부에서 접근

// 컴포넌트 외부에서 상태 접근
const currentCount = useCounterStore.getState().count;

// 컴포넌트 외부에서 상태 변경
useCounterStore.getState().increment();

// 상태 변경 구독 (컴포넌트 외부)
const unsubscribe = useCounterStore.subscribe(
  (state) => console.log('Count changed:', state.count)
);

// 구독 해제
unsubscribe();

Zustand 핵심 포인트

  • create: 스토어 생성
  • set: 상태 업데이트
  • get: 현재 상태 읽기
  • selector: 필요한 상태만 구독

3. 미들웨어 활용

Zustand 미들웨어로 상태 영속화, 개발 도구 연동, 불변성 관리 등을 쉽게 구현합니다.

persist - 로컬 저장

// 장바구니를 localStorage에 저장
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';

interface CartState {
  items: CartItem[];
  addItem: (item: CartItem) => void;
  clearCart: () => void;
}

export const useCartStore = create<CartState>()(
  persist(
    (set) => ({
      items: [],
      addItem: (item) => set((state) => ({
        items: [...state.items, item],
      })),
      clearCart: () => set({ items: [] }),
    }),
    {
      name: 'cart-storage', // localStorage 키
      storage: createJSONStorage(() => localStorage),
      
      // 저장할 상태만 선택 (선택적)
      partialize: (state) => ({ items: state.items }),
      
      // 버전 관리 (마이그레이션)
      version: 1,
      migrate: (persistedState, version) => {
        if (version === 0) {
          // v0 → v1 마이그레이션
          return { ...persistedState, items: [] };
        }
        return persistedState;
      },
    }
  )
);

devtools - 개발 도구

// Redux DevTools 연동
import { create } from 'zustand';
import { devtools } from 'zustand/middleware';

export const useCartStore = create<CartState>()(
  devtools(
    (set) => ({
      items: [],
      addItem: (item) => set(
        (state) => ({ items: [...state.items, item] }),
        false,
        'addItem' // 액션 이름 (DevTools에 표시)
      ),
      removeItem: (id) => set(
        (state) => ({ items: state.items.filter(i => i.id !== id) }),
        false,
        'removeItem'
      ),
    }),
    {
      name: 'CartStore', // DevTools에 표시될 스토어 이름
      enabled: process.env.NODE_ENV === 'development',
    }
  )
);

immer - 불변성 관리

// immer로 복잡한 상태 업데이트 간소화
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';

interface CartState {
  items: CartItem[];
  updateQuantity: (id: string, quantity: number) => void;
}

export const useCartStore = create<CartState>()(
  immer((set) => ({
    items: [],
    
    // immer 없이 (복잡)
    // updateQuantity: (id, quantity) => set((state) => ({
    //   items: state.items.map(item =>
    //     item.id === id ? { ...item, quantity } : item
    //   ),
    // })),
    
    // immer 사용 (간단)
    updateQuantity: (id, quantity) => set((state) => {
      const item = state.items.find(i => i.id === id);
      if (item) {
        item.quantity = quantity; // 직접 수정 가능!
      }
    }),
    
    // 중첩 객체도 쉽게
    updateItemOption: (id, optionKey, value) => set((state) => {
      const item = state.items.find(i => i.id === id);
      if (item) {
        item.options[optionKey] = value;
      }
    }),
  }))
);

미들웨어 조합

// 여러 미들웨어 함께 사용
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';

export const useCartStore = create<CartState>()(
  devtools(
    persist(
      immer((set) => ({
        items: [],
        
        addItem: (item) => set((state) => {
          state.items.push(item);
        }),
        
        updateQuantity: (id, quantity) => set((state) => {
          const item = state.items.find(i => i.id === id);
          if (item) item.quantity = quantity;
        }),
      })),
      {
        name: 'cart-storage',
      }
    ),
    {
      name: 'CartStore',
      enabled: process.env.NODE_ENV === 'development',
    }
  )
);

// 미들웨어 순서: devtools > persist > immer > 스토어
// 바깥쪽부터 안쪽으로 감싸는 구조

미들웨어 선택 가이드

  • persist: 새로고침 후에도 상태 유지 필요 시
  • devtools: 개발 중 상태 디버깅
  • immer: 복잡한 중첩 객체 업데이트

4. 이커머스 장바구니 구현

실제 이커머스에서 사용하는 장바구니 스토어를 Zustand로 구현합니다. 재고 검증, 수량 제한, 로컬 저장 등 실전 기능을 포함합니다.

타입 정의

// types/cart.ts
export interface CartItem {
  productId: string;
  name: string;
  price: number;
  originalPrice?: number;
  image: string;
  quantity: number;
  maxQuantity: number;  // 재고 수량
  options?: {
    size?: string;
    color?: string;
  };
}

export interface CartState {
  items: CartItem[];
  isLoading: boolean;
  
  // Actions
  addItem: (item: Omit<CartItem, 'quantity'> & { quantity?: number }) => void;
  removeItem: (productId: string) => void;
  updateQuantity: (productId: string, quantity: number) => void;
  clearCart: () => void;
  
  // Computed
  getTotalItems: () => number;
  getTotalPrice: () => number;
  getDiscountAmount: () => number;
  isInCart: (productId: string) => boolean;
}

장바구니 스토어

// store/cart.ts
import { create } from 'zustand';
import { persist, devtools } from 'zustand/middleware';
import { CartState, CartItem } from '@/types/cart';

export const useCartStore = create<CartState>()(
  devtools(
    persist(
      (set, get) => ({
        items: [],
        isLoading: false,
        
        addItem: (newItem) => {
          set((state) => {
            const existingIndex = state.items.findIndex(
              item => item.productId === newItem.productId
            );
            
            if (existingIndex !== -1) {
              // 이미 있으면 수량 증가
              const updatedItems = [...state.items];
              const existing = updatedItems[existingIndex];
              const newQuantity = Math.min(
                existing.quantity + (newItem.quantity || 1),
                newItem.maxQuantity
              );
              updatedItems[existingIndex] = {
                ...existing,
                quantity: newQuantity,
              };
              return { items: updatedItems };
            }
            
            // 새 상품 추가
            return {
              items: [...state.items, {
                ...newItem,
                quantity: newItem.quantity || 1,
              }],
            };
          }, false, 'addItem');
        },
        
        removeItem: (productId) => {
          set(
            (state) => ({
              items: state.items.filter(item => item.productId !== productId),
            }),
            false,
            'removeItem'
          );
        },
        
        updateQuantity: (productId, quantity) => {
          set((state) => {
            const item = state.items.find(i => i.productId === productId);
            if (!item) return state;
            
            // 0 이하면 삭제
            if (quantity <= 0) {
              return {
                items: state.items.filter(i => i.productId !== productId),
              };
            }
            
            // 최대 수량 제한
            const validQuantity = Math.min(quantity, item.maxQuantity);
            
            return {
              items: state.items.map(i =>
                i.productId === productId
                  ? { ...i, quantity: validQuantity }
                  : i
              ),
            };
          }, false, 'updateQuantity');
        },
        
        clearCart: () => set({ items: [] }, false, 'clearCart'),
        
        // Computed values
        getTotalItems: () => {
          return get().items.reduce((sum, item) => sum + item.quantity, 0);
        },
        
        getTotalPrice: () => {
          return get().items.reduce(
            (sum, item) => sum + item.price * item.quantity,
            0
          );
        },
        
        getDiscountAmount: () => {
          return get().items.reduce((sum, item) => {
            if (item.originalPrice) {
              return sum + (item.originalPrice - item.price) * item.quantity;
            }
            return sum;
          }, 0);
        },
        
        isInCart: (productId) => {
          return get().items.some(item => item.productId === productId);
        },
      }),
      {
        name: 'cart-storage',
        partialize: (state) => ({ items: state.items }),
      }
    ),
    { name: 'CartStore' }
  )
);

장바구니 컴포넌트

// components/cart/CartItem.tsx
'use client';

import Image from 'next/image';
import { Minus, Plus, Trash2 } from 'lucide-react';
import { useCartStore } from '@/store/cart';
import { CartItem as CartItemType } from '@/types/cart';
import { formatPrice } from '@/lib/format';

interface CartItemProps {
  item: CartItemType;
}

export function CartItem({ item }: CartItemProps) {
  const { updateQuantity, removeItem } = useCartStore();
  
  const handleDecrease = () => {
    updateQuantity(item.productId, item.quantity - 1);
  };
  
  const handleIncrease = () => {
    if (item.quantity < item.maxQuantity) {
      updateQuantity(item.productId, item.quantity + 1);
    }
  };
  
  return (
    <div className="flex gap-4 py-4 border-b">
      <div className="relative w-24 h-24 flex-shrink-0">
        <Image
          src={item.image}
          alt={item.name}
          fill
          className="object-cover rounded"
        />
      </div>
      
      <div className="flex-1">
        <h3 className="font-medium">{item.name}</h3>
        
        {item.options && (
          <p className="text-sm text-muted-foreground">
            {item.options.size && `사이즈: ${item.options.size}`}
            {item.options.color && ` / 색상: ${item.options.color}`}
          </p>
        )}
        
        <div className="flex items-center justify-between mt-2">
          <div className="flex items-center gap-2">
            <button
              onClick={handleDecrease}
              className="p-1 border rounded hover:bg-gray-100"
            >
              <Minus className="h-4 w-4" />
            </button>
            <span className="w-8 text-center">{item.quantity}</span>
            <button
              onClick={handleIncrease}
              disabled={item.quantity >= item.maxQuantity}
              className="p-1 border rounded hover:bg-gray-100 disabled:opacity-50"
            >
              <Plus className="h-4 w-4" />
            </button>
          </div>
          
          <div className="text-right">
            <p className="font-bold">{formatPrice(item.price * item.quantity)}</p>
            {item.originalPrice && (
              <p className="text-sm text-gray-400 line-through">
                {formatPrice(item.originalPrice * item.quantity)}
              </p>
            )}
          </div>
        </div>
      </div>
      
      <button
        onClick={() => removeItem(item.productId)}
        className="p-2 text-gray-400 hover:text-red-500"
      >
        <Trash2 className="h-5 w-5" />
      </button>
    </div>
  );
}

장바구니 구현 포인트

  • 재고 검증: maxQuantity로 재고 초과 방지
  • 로컬 저장: persist로 새로고침 후에도 유지
  • 할인 계산: originalPrice로 할인액 표시
  • 옵션 관리: 사이즈, 색상 등 옵션 지원

5. UI 상태 관리

모달, 사이드바, 토스트 등 UI 상태를 Zustand로 관리합니다. 전역 UI 상태를 중앙에서 제어하여 일관된 UX를 제공합니다.

모달 상태 관리

// store/modal.ts
import { create } from 'zustand';

type ModalType = 'login' | 'cart' | 'search' | 'confirm' | null;

interface ModalData {
  title?: string;
  message?: string;
  onConfirm?: () => void;
  onCancel?: () => void;
}

interface ModalState {
  type: ModalType;
  data: ModalData | null;
  isOpen: boolean;
  
  openModal: (type: ModalType, data?: ModalData) => void;
  closeModal: () => void;
}

export const useModalStore = create<ModalState>((set) => ({
  type: null,
  data: null,
  isOpen: false,
  
  openModal: (type, data = null) => set({
    type,
    data,
    isOpen: true,
  }),
  
  closeModal: () => set({
    type: null,
    data: null,
    isOpen: false,
  }),
}));

// 사용 예시
// useModalStore.getState().openModal('login');
// useModalStore.getState().openModal('confirm', {
//   title: '삭제 확인',
//   message: '정말 삭제하시겠습니까?',
//   onConfirm: () => deleteItem(),
// });

모달 컴포넌트

// components/modal/ModalProvider.tsx
'use client';

import { useModalStore } from '@/store/modal';
import { LoginModal } from './LoginModal';
import { CartModal } from './CartModal';
import { SearchModal } from './SearchModal';
import { ConfirmModal } from './ConfirmModal';

export function ModalProvider() {
  const { type, isOpen, data, closeModal } = useModalStore();
  
  if (!isOpen) return null;
  
  const modals = {
    login: <LoginModal onClose={closeModal} />,
    cart: <CartModal onClose={closeModal} />,
    search: <SearchModal onClose={closeModal} />,
    confirm: <ConfirmModal data={data} onClose={closeModal} />,
  };
  
  return (
    <div className="fixed inset-0 z-50">
      {/* 배경 오버레이 */}
      <div 
        className="absolute inset-0 bg-black/50"
        onClick={closeModal}
      />
      
      {/* 모달 컨텐츠 */}
      <div className="absolute inset-0 flex items-center justify-center p-4">
        {type && modals[type]}
      </div>
    </div>
  );
}

// app/layout.tsx에 추가
// <ModalProvider />

토스트 알림

// store/toast.ts
import { create } from 'zustand';

type ToastType = 'success' | 'error' | 'warning' | 'info';

interface Toast {
  id: string;
  type: ToastType;
  message: string;
  duration?: number;
}

interface ToastState {
  toasts: Toast[];
  addToast: (toast: Omit<Toast, 'id'>) => void;
  removeToast: (id: string) => void;
}

export const useToastStore = create<ToastState>((set, get) => ({
  toasts: [],
  
  addToast: (toast) => {
    const id = Math.random().toString(36).slice(2);
    const newToast = { ...toast, id };
    
    set((state) => ({
      toasts: [...state.toasts, newToast],
    }));
    
    // 자동 제거
    const duration = toast.duration || 3000;
    setTimeout(() => {
      get().removeToast(id);
    }, duration);
  },
  
  removeToast: (id) => {
    set((state) => ({
      toasts: state.toasts.filter(t => t.id !== id),
    }));
  },
}));

// 편의 함수
export const toast = {
  success: (message: string) => 
    useToastStore.getState().addToast({ type: 'success', message }),
  error: (message: string) => 
    useToastStore.getState().addToast({ type: 'error', message }),
  warning: (message: string) => 
    useToastStore.getState().addToast({ type: 'warning', message }),
  info: (message: string) => 
    useToastStore.getState().addToast({ type: 'info', message }),
};

// 사용 예시
// toast.success('장바구니에 추가되었습니다');
// toast.error('결제에 실패했습니다');

사이드바 상태

// store/sidebar.ts
import { create } from 'zustand';

interface SidebarState {
  isOpen: boolean;
  toggle: () => void;
  open: () => void;
  close: () => void;
}

export const useSidebarStore = create<SidebarState>((set) => ({
  isOpen: false,
  toggle: () => set((state) => ({ isOpen: !state.isOpen })),
  open: () => set({ isOpen: true }),
  close: () => set({ isOpen: false }),
}));

// 컴포넌트에서 사용
function Header() {
  const toggle = useSidebarStore((state) => state.toggle);
  
  return (
    <button onClick={toggle}>
      <Menu className="h-6 w-6" />
    </button>
  );
}

function Sidebar() {
  const { isOpen, close } = useSidebarStore();
  
  if (!isOpen) return null;
  
  return (
    <div className="fixed inset-y-0 left-0 w-64 bg-white shadow-lg">
      <button onClick={close}>닫기</button>
      {/* 사이드바 내용 */}
    </div>
  );
}

UI 상태 관리 팁

  • 중앙 집중: 모달, 토스트 등 전역 UI는 한 곳에서 관리
  • 타입 안전: 모달 타입을 union으로 정의
  • 자동 정리: 토스트는 타이머로 자동 제거
  • 편의 함수: toast.success() 같은 헬퍼 제공

6. 스토어 설계 패턴

대규모 프로젝트에서 Zustand 스토어를 효과적으로 구성하는 패턴을 알아봅니다. 슬라이스 패턴, 스토어 분리, 타입 안전성 등을 다룹니다.

슬라이스 패턴

// 큰 스토어를 슬라이스로 분리
// store/slices/cartSlice.ts
import { StateCreator } from 'zustand';

export interface CartSlice {
  items: CartItem[];
  addItem: (item: CartItem) => void;
  removeItem: (id: string) => void;
}

export const createCartSlice: StateCreator<
  CartSlice & UserSlice,  // 전체 스토어 타입
  [],
  [],
  CartSlice
> = (set) => ({
  items: [],
  addItem: (item) => set((state) => ({
    items: [...state.items, item],
  })),
  removeItem: (id) => set((state) => ({
    items: state.items.filter(i => i.id !== id),
  })),
});

// store/slices/userSlice.ts
export interface UserSlice {
  user: User | null;
  setUser: (user: User | null) => void;
}

export const createUserSlice: StateCreator<
  CartSlice & UserSlice,
  [],
  [],
  UserSlice
> = (set) => ({
  user: null,
  setUser: (user) => set({ user }),
});

// store/index.ts - 슬라이스 합치기
import { create } from 'zustand';
import { createCartSlice, CartSlice } from './slices/cartSlice';
import { createUserSlice, UserSlice } from './slices/userSlice';

type StoreState = CartSlice & UserSlice;

export const useStore = create<StoreState>()((...args) => ({
  ...createCartSlice(...args),
  ...createUserSlice(...args),
}));

스토어 분리 vs 통합

방식장점단점사용 시점
분리된 스토어독립적, 테스트 용이스토어 간 통신 어려움독립적인 기능
슬라이스 패턴코드 분리, 상호 참조 가능타입 복잡연관된 기능

액션 분리

// 복잡한 액션을 별도 파일로 분리
// store/cart/actions.ts
import { useCartStore } from './store';
import { CartItem } from './types';

export async function addToCartWithValidation(item: CartItem) {
  const store = useCartStore.getState();
  
  // 재고 확인
  const response = await fetch(`/api/products/${item.productId}/stock`);
  const { stock } = await response.json();
  
  if (stock < item.quantity) {
    throw new Error('재고가 부족합니다');
  }
  
  // 장바구니에 추가
  store.addItem(item);
  
  // 서버와 동기화 (로그인 상태일 때)
  if (store.isLoggedIn) {
    await fetch('/api/cart', {
      method: 'POST',
      body: JSON.stringify(item),
    });
  }
}

// 컴포넌트에서 사용
import { addToCartWithValidation } from '@/store/cart/actions';

async function handleAddToCart() {
  try {
    await addToCartWithValidation(product);
    toast.success('장바구니에 추가되었습니다');
  } catch (error) {
    toast.error(error.message);
  }
}

이커머스 스토어 구조

// 권장 폴더 구조
store/
├── cart/
│   ├── store.ts        # 장바구니 스토어
│   ├── types.ts        # 타입 정의
│   ├── actions.ts      # 복잡한 액션
│   └── index.ts        # export
├── auth/
│   ├── store.ts        # 인증 스토어
│   ├── types.ts
│   └── index.ts
├── ui/
│   ├── modal.ts        # 모달 상태
│   ├── toast.ts        # 토스트 상태
│   ├── sidebar.ts      # 사이드바 상태
│   └── index.ts
└── index.ts            # 전체 export

// store/index.ts
export { useCartStore } from './cart';
export { useAuthStore } from './auth';
export { useModalStore, useToastStore, useSidebarStore } from './ui';

베스트 프랙티스

선택적 구독 사용

필요한 상태만 구독하여 불필요한 리렌더링 방지

액션에 이름 부여

devtools에서 디버깅 용이

타입 안전성 확보

StateCreator 타입으로 슬라이스 정의

서버 상태와 분리

서버 데이터는 TanStack Query, 클라이언트 상태만 Zustand

Zustand 핵심 정리

  • 간단함: 최소한의 보일러플레이트
  • 유연함: 미들웨어로 기능 확장
  • 성능: 선택적 구독으로 최적화
  • 타입 안전: TypeScript 완벽 지원