App Router로 오면서 상태 관리가 다시 복잡해졌다. “이 데이터는 서버에서 가져올까, 클라이언트에서 관리할까?” — 이 질문에 제대로 답하지 못하면 하이드레이션 오류, 불필요한 API 호출, 앱 전체를 'use client'로 도배하는 상황을 만든다.

핵심 요약 (TL;DR)

서버 상태(Server State): 외부 API/DB에서 가져오는 데이터. fetch + Server Component or React Query(TanStack Query) — 캐싱, 로딩, 에러, 재검증을 자동으로 처리.

전역 클라이언트 상태(Global Client State): 장바구니, 모달 열림 여부, 테마 등 서버와 무관한 클라이언트 전용 상태. Zustand — 보일러플레이트 없이 가장 단순.

로컬 상태(Local State): 단일 컴포넌트 내 상태. useState — 끌어올릴 필요 없으면 여기서.


상태 분류와 설계 원칙

flowchart TD
    Q{"상태의 출처는?"}

    Q -->|"서버 API / DB 데이터"| SQ["서버 상태\n→ Server Component fetch\n   or React Query"]
    Q -->|"여러 컴포넌트 공유\n(장바구니, 인증, 테마)"| ZU["전역 클라이언트 상태\n→ Zustand"]
    Q -->|"단일 컴포넌트 내부"| LS["로컬 상태\n→ useState / useReducer"]
    Q -->|"URL 파라미터\n(필터, 페이지)"| US["URL 상태\n→ useSearchParams / useRouter"]
    Q -->|"폼 입력값"| FM["폼 상태\n→ React Hook Form"]

    SQ --> SQ2["✅ 캐싱 자동\n✅ 로딩/에러 처리\n✅ 재검증 지원"]
    ZU --> ZU2["✅ 보일러플레이트 없음\n✅ 미들웨어(persist, devtools)\n⚠️ 하이드레이션 주의"]

    style SQ fill:#c8e6c9,stroke:#388e3c
    style ZU fill:#bbdefb,stroke:#1565c0
    style LS fill:#fff9c4,stroke:#f9a825
    style US fill:#f3e5f5,stroke:#7b1fa2

설치

# 상태 관리 라이브러리
npm install zustand @tanstack/react-query @tanstack/react-query-devtools

# 폼 상태 (선택)
npm install react-hook-form zod @hookform/resolvers

Zustand — 전역 클라이언트 상태

장바구니 스토어 구현

// src/store/cart-store.ts
import { create } from 'zustand'
import { persist, createJSONStorage, devtools } from 'zustand/middleware'
import { immer } from 'zustand/middleware/immer'

export interface CartItem {
  id: number
  name: string
  price: number
  quantity: number
  imageUrl: string
}

interface CartState {
  items: CartItem[]
  isOpen: boolean

  // Actions
  addItem: (product: Omit<CartItem, 'quantity'>) => void
  removeItem: (id: number) => void
  updateQuantity: (id: number, quantity: number) => void
  clearCart: () => void
  toggleCart: () => void

  // Computed (Derived State)
  totalItems: () => number
  totalPrice: () => number
}

export const useCartStore = create<CartState>()(
  devtools(  // Redux DevTools 연동
    persist(  // localStorage에 지속 저장
      immer(  // Immer로 불변 업데이트 단순화
        (set, get) => ({
          items: [],
          isOpen: false,

          addItem: (product) =>
            set((state) => {
              const existing = state.items.find(i => i.id === product.id)
              if (existing) {
                existing.quantity += 1  // Immer: 직접 변경 가능
              } else {
                state.items.push({ ...product, quantity: 1 })
              }
            }),

          removeItem: (id) =>
            set((state) => {
              state.items = state.items.filter(i => i.id !== id)
            }),

          updateQuantity: (id, quantity) =>
            set((state) => {
              if (quantity <= 0) {
                state.items = state.items.filter(i => i.id !== id)
                return
              }
              const item = state.items.find(i => i.id === id)
              if (item) item.quantity = quantity
            }),

          clearCart: () =>
            set((state) => { state.items = [] }),

          toggleCart: () =>
            set((state) => { state.isOpen = !state.isOpen }),

          // Computed — 함수로 선언하여 파생 상태 계산
          totalItems: () => get().items.reduce((sum, i) => sum + i.quantity, 0),
          totalPrice: () => get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),
        })
      ),
      {
        name: 'honey-cart',            // localStorage 키
        storage: createJSONStorage(() => localStorage),
        partialize: (state) => ({      // 저장할 필드만 선택 (isOpen 제외)
          items: state.items,
        }),
      }
    ),
    { name: 'CartStore' }             // DevTools에 표시될 이름
  )
)

App Router에서 Zustand 하이드레이션 문제 해결

// ⚠️ 문제: localStorage persist가 SSR과 충돌
// 서버: items = [] (localStorage 없음)
// 클라이언트: items = [이전 장바구니] (localStorage)
// → 하이드레이션 불일치 오류

// ✅ 해결 패턴 1: useEffect로 마운트 후 렌더링
// src/components/CartItemCount.tsx
'use client'

import { useState, useEffect } from 'react'
import { useCartStore } from '@/store/cart-store'

export function CartItemCount() {
  const [mounted, setMounted] = useState(false)
  const totalItems = useCartStore(state => state.totalItems())

  // 클라이언트 마운트 후에만 표시 (하이드레이션 안전)
  useEffect(() => setMounted(true), [])

  if (!mounted) return <span className="text-xs">-</span>

  return (
    <span className="bg-amber-500 text-white text-xs rounded-full w-5 h-5 flex items-center justify-center">
      {totalItems}
    </span>
  )
}

// ✅ 해결 패턴 2: Zustand의 공식 권장 방식 — StoreProvider
// src/providers/cart-provider.tsx
'use client'

import { createContext, useContext, useRef, type ReactNode } from 'react'
import { createStore, useStore } from 'zustand'
import type { CartState } from '@/store/cart-store'

// 인스턴스 기반 스토어 (요청별 격리)
type CartStore = ReturnType<typeof createCartStore>

function createCartStore(initialItems?: CartState['items']) {
  return createStore<CartState>()((set, get) => ({
    items: initialItems ?? [],
    isOpen: false,
    // ... 나머지 actions
    addItem: (product) => set(state => ({ /* ... */ })),
    totalItems: () => get().items.reduce((sum, i) => sum + i.quantity, 0),
    totalPrice: () => get().items.reduce((sum, i) => sum + i.price * i.quantity, 0),
    removeItem: (id) => set(state => ({ items: state.items.filter(i => i.id !== id) })),
    updateQuantity: (id, qty) => set(state => ({ items: state.items.map(i => i.id === id ? { ...i, quantity: qty } : i) })),
    clearCart: () => set({ items: [] }),
    toggleCart: () => set(state => ({ isOpen: !state.isOpen })),
  }))
}

const CartStoreContext = createContext<CartStore | null>(null)

export function CartProvider({ children, initialItems }: {
  children: ReactNode
  initialItems?: CartState['items']
}) {
  const storeRef = useRef<CartStore | null>(null)
  if (!storeRef.current) {
    storeRef.current = createCartStore(initialItems)
  }
  return (
    <CartStoreContext.Provider value={storeRef.current}>
      {children}
    </CartStoreContext.Provider>
  )
}

export function useCart<T>(selector: (state: CartState) => T): T {
  const store = useContext(CartStoreContext)
  if (!store) throw new Error('useCart must be used within CartProvider')
  return useStore(store, selector)
}

장바구니 UI 컴포넌트

// src/components/CartDrawer.tsx
'use client'

import { useCartStore } from '@/store/cart-store'
import Image from 'next/image'

export function CartDrawer() {
  const { items, isOpen, toggleCart, removeItem, updateQuantity, totalPrice, clearCart } = useCartStore()

  return (
    <>
      {/* 오버레이 */}
      {isOpen && (
        <div
          className="fixed inset-0 bg-black/40 z-40"
          onClick={toggleCart}
        />
      )}

      {/* 드로어 */}
      <aside className={`
        fixed right-0 top-0 h-full w-80 bg-white shadow-2xl z-50
        transition-transform duration-300
        ${isOpen ? 'translate-x-0' : 'translate-x-full'}
      `}>
        <div className="flex items-center justify-between p-4 border-b">
          <h2 className="text-lg font-bold">🛒 장바구니</h2>
          <button onClick={toggleCart} className="text-gray-400 hover:text-gray-600"></button>
        </div>

        {items.length === 0 ? (
          <div className="flex flex-col items-center justify-center h-64 text-gray-400">
            <span className="text-4xl mb-3">🍯</span>
            <p>장바구니가 비어있습니다</p>
          </div>
        ) : (
          <>
            <ul className="flex-1 overflow-y-auto p-4 space-y-3">
              {items.map(item => (
                <li key={item.id} className="flex gap-3 items-center">
                  <div className="relative w-16 h-16 shrink-0">
                    <Image src={item.imageUrl} alt={item.name} fill className="object-cover rounded-lg" />
                  </div>
                  <div className="flex-1 min-w-0">
                    <p className="font-medium truncate text-sm">{item.name}</p>
                    <p className="text-amber-600 text-sm">{item.price.toLocaleString()}</p>
                    <div className="flex items-center gap-2 mt-1">
                      <button
                        onClick={() => updateQuantity(item.id, item.quantity - 1)}
                        className="w-6 h-6 rounded-full border flex items-center justify-center text-sm"
                      >-</button>
                      <span className="text-sm w-4 text-center">{item.quantity}</span>
                      <button
                        onClick={() => updateQuantity(item.id, item.quantity + 1)}
                        className="w-6 h-6 rounded-full border flex items-center justify-center text-sm"
                      >+</button>
                    </div>
                  </div>
                  <button
                    onClick={() => removeItem(item.id)}
                    className="text-gray-300 hover:text-red-400 text-lg"
                  ></button>
                </li>
              ))}
            </ul>

            <div className="border-t p-4">
              <div className="flex justify-between font-bold mb-4">
                <span>합계</span>
                <span className="text-amber-600">{totalPrice().toLocaleString()}</span>
              </div>
              <button className="w-full bg-amber-400 text-amber-900 py-3 rounded-xl font-bold hover:bg-amber-500">
                결제하기
              </button>
              <button onClick={clearCart} className="w-full text-gray-400 text-sm mt-2">
                장바구니 비우기
              </button>
            </div>
          </>
        )}
      </aside>
    </>
  )
}

React Query — 서버 상태 관리

QueryClient Provider 설정

// src/providers/query-provider.tsx
'use client'

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from '@tanstack/react-query-devtools'
import { useState, type ReactNode } from 'react'

export function QueryProvider({ children }: { children: ReactNode }) {
  // useState로 QueryClient 생성 → 요청별 독립 인스턴스 보장
  const [queryClient] = useState(
    () => new QueryClient({
      defaultOptions: {
        queries: {
          staleTime: 60 * 1000,     // 1분 stale
          gcTime: 5 * 60 * 1000,   // 5분 후 캐시 제거 (구: cacheTime)
          retry: 1,                  // 실패 시 1회 재시도
          refetchOnWindowFocus: false, // 탭 포커스 시 재요청 비활성화
        },
      },
    })
  )

  return (
    <QueryClientProvider client={queryClient}>
      {children}
      {process.env.NODE_ENV === 'development' && (
        <ReactQueryDevtools initialIsOpen={false} />
      )}
    </QueryClientProvider>
  )
}

// src/app/layout.tsx에 추가
// <QueryProvider><CartProvider>{children}</CartProvider></QueryProvider>

상품 목록 — useQuery + 낙관적 업데이트

// src/hooks/use-products.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'

export interface Product {
  id: number
  name: string
  price: number
  stock: number
  imageUrl: string
}

// 쿼리 키 팩토리 — 중앙 관리 (타입 안전성 + 재사용)
export const productKeys = {
  all: ['products'] as const,
  list: (filters?: { category?: string; page?: number }) =>
    [...productKeys.all, 'list', filters] as const,
  detail: (id: number) => [...productKeys.all, 'detail', id] as const,
}

// 서버 상태 조회
export function useProducts(filters?: { category?: string; page?: number }) {
  return useQuery({
    queryKey: productKeys.list(filters),
    queryFn: async () => {
      const params = new URLSearchParams()
      if (filters?.category) params.set('category', filters.category)
      if (filters?.page) params.set('page', String(filters.page))

      const res = await fetch(`/api/products?${params}`)
      if (!res.ok) throw new Error('상품 목록 로딩 실패')
      return res.json() as Promise<{ products: Product[]; total: number }>
    },
    staleTime: 5 * 60 * 1000,  // 5분 fresh
    placeholderData: (prev) => prev,  // 필터 변경 시 이전 데이터 유지 (깜빡임 방지)
  })
}

export function useProduct(id: number) {
  return useQuery({
    queryKey: productKeys.detail(id),
    queryFn: async () => {
      const res = await fetch(`/api/products/${id}`)
      if (!res.ok) throw new Error('상품 상세 로딩 실패')
      return res.json() as Promise<Product>
    },
  })
}

// 뮤테이션 — 낙관적 업데이트(Optimistic Update)
export function useUpdateProductStock() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: async ({ id, stock }: { id: number; stock: number }) => {
      const res = await fetch(`/api/products/${id}`, {
        method: 'PATCH',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ stock }),
      })
      if (!res.ok) throw new Error('재고 업데이트 실패')
      return res.json()
    },

    // 낙관적 업데이트: 서버 응답 전에 UI를 먼저 변경
    onMutate: async ({ id, stock }) => {
      // 진행 중인 관련 쿼리 취소
      await queryClient.cancelQueries({ queryKey: productKeys.detail(id) })

      // 현재 캐시 스냅샷 저장 (롤백용)
      const snapshot = queryClient.getQueryData<Product>(productKeys.detail(id))

      // 낙관적으로 캐시 업데이트
      queryClient.setQueryData<Product>(productKeys.detail(id), (old) =>
        old ? { ...old, stock } : undefined
      )

      return { snapshot }  // onError에서 롤백에 사용
    },

    // 실패 시 롤백
    onError: (err, { id }, context) => {
      if (context?.snapshot) {
        queryClient.setQueryData(productKeys.detail(id), context.snapshot)
      }
    },

    // 성공/실패 모두 서버에서 최신 데이터 재검증
    onSettled: (_, __, { id }) => {
      queryClient.invalidateQueries({ queryKey: productKeys.detail(id) })
      queryClient.invalidateQueries({ queryKey: productKeys.all })
    },
  })
}

서버 프리페칭 — Server Component + React Query 조합

// src/app/products/page.tsx (Server Component)
import { HydrationBoundary, QueryClient, dehydrate } from '@tanstack/react-query'
import { ProductList } from '@/components/ProductList'
import { productKeys } from '@/hooks/use-products'

export default async function ProductsPage({
  searchParams,
}: {
  searchParams: Promise<{ category?: string; page?: string }>
}) {
  const { category, page } = await searchParams
  const filters = { category, page: page ? Number(page) : 1 }

  // 서버에서 미리 데이터 페칭 → 클라이언트 하이드레이션
  const queryClient = new QueryClient()
  await queryClient.prefetchQuery({
    queryKey: productKeys.list(filters),
    queryFn: async () => {
      // 서버에서는 직접 DB 조회 또는 내부 API 호출
      const { products, total } = await getProductsFromDB(filters)
      return { products, total }
    },
  })

  return (
    // dehydrate: 서버 캐시를 직렬화하여 클라이언트로 전달
    <HydrationBoundary state={dehydrate(queryClient)}>
      {/* 클라이언트 컴포넌트에서 useProducts()를 호출하면
          서버에서 페칭한 데이터를 즉시 반환 (네트워크 요청 없음) */}
      <ProductList filters={filters} />
    </HydrationBoundary>
  )
}
// src/components/ProductList.tsx (Client Component)
'use client'

import { useProducts } from '@/hooks/use-products'
import { useCartStore } from '@/store/cart-store'

export function ProductList({ filters }: { filters: { category?: string; page?: number } }) {
  // HydrationBoundary 덕분에 서버 데이터로 즉시 렌더링 (로딩 없음)
  const { data, isLoading, error } = useProducts(filters)
  const addItem = useCartStore(state => state.addItem)
  const toggleCart = useCartStore(state => state.toggleCart)

  if (isLoading) return <ProductListSkeleton />
  if (error) return <ErrorMessage message={error.message} />

  return (
    <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
      {data?.products.map(product => (
        <div key={product.id} className="bg-white rounded-xl p-4 shadow-sm hover:shadow-md transition-shadow">
          <h3 className="font-semibold text-gray-900">{product.name}</h3>
          <p className="text-amber-600 font-bold mt-1">{product.price.toLocaleString()}</p>
          <p className="text-gray-400 text-sm">재고 {product.stock}</p>
          <button
            onClick={() => {
              addItem({ id: product.id, name: product.name, price: product.price, imageUrl: product.imageUrl })
              toggleCart()
            }}
            className="mt-3 w-full bg-amber-400 text-amber-900 py-2 rounded-lg text-sm font-semibold hover:bg-amber-500"
          >
            장바구니 담기
          </button>
        </div>
      ))}
    </div>
  )
}

function ProductListSkeleton() {
  return (
    <div className="grid grid-cols-1 md:grid-cols-3 gap-6">
      {Array.from({ length: 6 }).map((_, i) => (
        <div key={i} className="bg-white rounded-xl p-4 animate-pulse">
          <div className="h-5 bg-gray-200 rounded mb-2" />
          <div className="h-4 bg-gray-200 rounded w-24 mb-1" />
          <div className="h-4 bg-gray-200 rounded w-16 mb-3" />
          <div className="h-9 bg-gray-200 rounded" />
        </div>
      ))}
    </div>
  )
}

URL 상태 — 필터와 페이지네이션

// src/components/ProductFilters.tsx
// URL에 필터 상태 저장 → 공유 가능한 링크, 뒤로가기 지원
'use client'

import { useRouter, useSearchParams, usePathname } from 'next/navigation'
import { useTransition } from 'react'

const CATEGORIES = ['전체', '아카시아꿀', '밤꿀', '유채꿀', '프로폴리스']

export function ProductFilters() {
  const router = useRouter()
  const pathname = usePathname()
  const searchParams = useSearchParams()
  const [isPending, startTransition] = useTransition()

  const currentCategory = searchParams.get('category') ?? '전체'

  const setCategory = (category: string) => {
    const params = new URLSearchParams(searchParams.toString())
    if (category === '전체') {
      params.delete('category')
    } else {
      params.set('category', category)
    }
    params.delete('page')  // 필터 변경 시 첫 페이지로

    startTransition(() => {
      router.push(`${pathname}?${params.toString()}`, { scroll: false })
    })
  }

  return (
    <div className="flex gap-2 flex-wrap mb-6">
      {CATEGORIES.map(cat => (
        <button
          key={cat}
          onClick={() => setCategory(cat)}
          disabled={isPending}
          className={`px-4 py-1.5 rounded-full text-sm font-medium transition-colors ${
            currentCategory === cat
              ? 'bg-amber-400 text-amber-900'
              : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
          } disabled:opacity-50`}
        >
          {cat}
        </button>
      ))}
    </div>
  )
}

설계 원칙 적용

SRP (단일 책임 원칙):
  - useCartStore: 장바구니 상태만
  - useProducts: 상품 서버 상태만
  - ProductFilters: URL 상태만
  → 각 훅/스토어가 하나의 상태 도메인만 담당

OCP (개방-폐쇄 원칙):
  - productKeys 팩토리: 새 쿼리 추가 시 기존 키 변경 없음
  - Zustand 미들웨어 체이닝: persist → devtools → immer 조합 확장

DIP (의존성 역전):
  - 컴포넌트는 구체적인 API 함수가 아닌 useProducts 훅에 의존
  - 서버 구현 변경 시 훅만 수정, UI 컴포넌트 불변

트레이드오프 요약

상태 종류 도구 이유 피해야 할 경우
서버 데이터 React Query 캐싱, 재검증, 로딩/에러 자동 Server Component로 충분한 경우
전역 클라이언트 Zustand 최소 보일러플레이트 단일 컴포넌트 내 상태
로컬 UI useState 단순, 격리됨 여러 컴포넌트에서 공유 필요 시
URL/필터 searchParams 공유 가능, SEO 친화적 복잡한 객체 상태
React Hook Form 검증, 성능 최적화 단순 단일 필드

시리즈 안내

Part 주제 상태
Part 1 App Router 시작하기
Part 2 데이터 페칭과 캐싱
Part 3 인증과 미들웨어
Part 4 상태 관리와 클라이언트 패턴 현재 글
Part 5 성능 최적화 예정
Part 6 배포와 운영 예정

레퍼런스

공식 문서

기술 블로그


이 포스트는 HoneyByte Next.js Deep Dive 시리즈의 일부입니다.