최신 Next.js 프로젝트에서 Zustand 사용법 및 활용 케이스 핸드북

핵심 요약

Zustand는 Next.js 15와 완벽 호환되는 경량 상태 관리 라이브러리로, App RouterServer Components 환경에서 Provider 없이도 강력한 전역 상태 관리를 제공한다. 특히 SSR 안전성, TypeScript 완벽 지원, persist 미들웨어를 통한 상태 영속화가 특징이며, E-commerce 카트부터 복잡한 사용자 인증까지 다양한 실제 사용 사례에서 검증되었다.

1. Next.js 15 환경에서의 기본 설정

1.1 설치 및 초기 설정

npm install zustand
# 또는
yarn add zustand

1.2 기본 스토어 생성 (App Router 전용)

// stores/counter-store.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 })
}))

1.3 Client Component에서 사용

// components/Counter.tsx
'use client'
 
import { useCounterStore } from '@/stores/counter-store'
 
export default function Counter() {
  const { count, increment, decrement } = useCounterStore()
 
  return (
    <div className="flex flex-col items-center gap-4">
      <h2 className="text-2xl font-bold">Count: {count}</h2>
      <div className="flex gap-2">
        <button onClick={increment} className="px-4 py-2 bg-blue-500 text-white rounded">
          Increment
        </button>
        <button onClick={decrement} className="px-4 py-2 bg-red-500 text-white rounded">
          Decrement
        </button>
      </div>
    </div>
  )
}

2. SSR 및 하이드레이션 안전 패턴

2.1 Context Provider 패턴 (권장)

// stores/counter-store.ts
import { createStore } from 'zustand/vanilla'
 
export type CounterState = {
  count: number
}
 
export type CounterActions = {
  decrementCount: () => void
  incrementCount: () => void
}
 
export type CounterStore = CounterState & CounterActions
 
export const createCounterStore = (
  initState: CounterState = { count: 0 }
) => {
  return createStore<CounterStore>()((set) => ({
    ...initState,
    decrementCount: () => set((state) => ({ count: state.count - 1 })),
    incrementCount: () => set((state) => ({ count: state.count + 1 }))
  }))
}
// providers/counter-store-provider.tsx
'use client'
 
import { type ReactNode, createContext, useRef, useContext } from 'react'
import { useStore } from 'zustand'
import { type CounterStore, createCounterStore } from '@/stores/counter-store'
 
export type CounterStoreApi = ReturnType<typeof createCounterStore>
 
export const CounterStoreContext = createContext<CounterStoreApi | undefined>(
  undefined
)
 
export const CounterStoreProvider = ({ children }: { children: ReactNode }) => {
  const storeRef = useRef<CounterStoreApi | null>(null)
  
  if (storeRef.current === null) {
    storeRef.current = createCounterStore()
  }
 
  return (
    <CounterStoreContext.Provider value={storeRef.current}>
      {children}
    </CounterStoreContext.Provider>
  )
}
 
export const useCounterStore = <T,>(
  selector: (store: CounterStore) => T
): T => {
  const counterStoreContext = useContext(CounterStoreContext)
  if (!counterStoreContext) {
    throw new Error('useCounterStore must be used within CounterStoreProvider')
  }
  return useStore(counterStoreContext, selector)
}

2.2 Layout에서 Provider 설정

// app/layout.tsx
import { CounterStoreProvider } from '@/providers/counter-store-provider'
 
export default function RootLayout({
  children
}: {
  children: React.ReactNode
}) {
  return (
    <html lang="ko">
      <body>
        <CounterStoreProvider>
          {children}
        </CounterStoreProvider>
      </body>
    </html>
  )
}

3. 고급 미들웨어 활용

3.1 Persist 미들웨어 (상태 영속화)

// stores/auth-store.ts
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
 
interface AuthUser {
  id: string
  name: string
  email: string
}
 
interface AuthState {
  user: AuthUser | null
  isAuthenticated: boolean
  login: (user: AuthUser) => void
  logout: () => void
}
 
export const useAuthStore = create<AuthState>()(
  persist(
    (set) => ({
      user: null,
      isAuthenticated: false,
      login: (user) => set({ user, isAuthenticated: true }),
      logout: () => set({ user: null, isAuthenticated: false })
    }),
    {
      name: 'auth-storage',
      storage: createJSONStorage(() => localStorage),
      // 하이드레이션 에러 방지
      skipHydration: true
    }
  )
)

3.2 하이드레이션 에러 해결

// components/AuthGuard.tsx
'use client'
 
import { useEffect, useState } from 'react'
import { useAuthStore } from '@/stores/auth-store'
 
export default function AuthGuard({ children }: { children: React.ReactNode }) {
  const [hasHydrated, setHasHydrated] = useState(false)
  
  useEffect(() => {
    useAuthStore.persist.rehydrate()
    setHasHydrated(true)
  }, [])
 
  if (!hasHydrated) {
    return <div>Loading...</div>
  }
 
  return <>{children}</>
}

3.3 DevTools 미들웨어

// stores/debug-store.ts
import { create } from 'zustand'
import { devtools } from 'zustand/middleware'
 
interface DebugState {
  items: string[]
  addItem: (item: string) => void
  removeItem: (index: number) => void
}
 
export const useDebugStore = create<DebugState>()(
  devtools(
    (set) => ({
      items: [],
      addItem: (item) => set(
        (state) => ({ items: [...state.items, item] }),
        false,
        'addItem'
      ),
      removeItem: (index) => set(
        (state) => ({ items: state.items.filter((_, i) => i !== index) }),
        false,
        'removeItem'
      )
    }),
    { name: 'debug-store' }
  )
)

4. 실제 활용 사례

4.1 E-commerce 쇼핑카트

// stores/cart-store.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
 
interface CartItem {
  id: string
  title: string
  price: number
  quantity: number
  image: string
}
 
interface CartState {
  items: CartItem[]
  addItem: (item: Omit<CartItem, 'quantity'>) => void
  updateQuantity: (id: string, quantity: number) => void
  removeItem: (id: string) => void
  clearCart: () => void
  getTotalPrice: () => number
  getItemCount: () => number
}
 
export const useCartStore = create<CartState>()(
  persist(
    (set, get) => ({
      items: [],
      
      addItem: (newItem) => set((state) => {
        const existingItem = state.items.find(item => item.id === newItem.id)
        if (existingItem) {
          return {
            items: state.items.map(item =>
              item.id === newItem.id
                ? { ...item, quantity: item.quantity + 1 }
                : item
            )
          }
        }
        return { items: [...state.items, { ...newItem, quantity: 1 }] }
      }),
      
      updateQuantity: (id, quantity) => set((state) => ({
        items: quantity <= 0
          ? state.items.filter(item => item.id !== id)
          : state.items.map(item =>
              item.id === id ? { ...item, quantity } : item
            )
      })),
      
      removeItem: (id) => set((state) => ({
        items: state.items.filter(item => item.id !== id)
      })),
      
      clearCart: () => set({ items: [] }),
      
      getTotalPrice: () => {
        return get().items.reduce((total, item) => total + (item.price * item.quantity), 0)
      },
      
      getItemCount: () => {
        return get().items.reduce((count, item) => count + item.quantity, 0)
      }
    }),
    {
      name: 'cart-storage'
    }
  )
)

4.2 모달 상태 관리

// stores/modal-store.ts
import { create } from 'zustand'
 
type ModalType = 'login' | 'signup' | 'profile' | 'cart'
 
interface ModalState {
  type: ModalType | null
  isOpen: boolean
  data?: any
  openModal: (type: ModalType, data?: any) => void
  closeModal: () => void
}
 
export const useModalStore = create<ModalState>((set) => ({
  type: null,
  isOpen: false,
  data: undefined,
  
  openModal: (type, data) => set({ type, isOpen: true, data }),
  closeModal: () => set({ type: null, isOpen: false, data: undefined })
}))

4.3 슬라이스 패턴 (대규모 앱)

// stores/slices/user-slice.ts
export interface UserSlice {
  user: User | null
  setUser: (user: User) => void
  clearUser: () => void
}
 
export const createUserSlice: StateCreator<
  UserSlice & CartSlice & ThemeSlice,
  [],
  [],
  UserSlice
> = (set) => ({
  user: null,
  setUser: (user) => set({ user }),
  clearUser: () => set({ user: null })
})
 
// stores/slices/cart-slice.ts
export const createCartSlice: StateCreator<
  UserSlice & CartSlice & ThemeSlice,
  [],
  [],
  CartSlice
> = (set, get) => ({
  items: [],
  addItem: (item) => {
    // 카트 로직
  }
})
 
// stores/app-store.ts
import { create } from 'zustand'
import { createUserSlice } from './slices/user-slice'
import { createCartSlice } from './slices/cart-slice'
 
export const useAppStore = create<UserSlice & CartSlice>()((...a) => ({
  ...createUserSlice(...a),
  ...createCartSlice(...a)
}))

5. 성능 최적화 기법

5.1 선택적 구독

// 🚫 잘못된 방법 - 전체 스토어 구독
const store = useCartStore()
 
// ✅ 올바른 방법 - 필요한 부분만 구독
const itemCount = useCartStore(state => state.getItemCount())
const totalPrice = useCartStore(state => state.getTotalPrice())

5.2 useShallow를 통한 객체 구독 최적화

import { useShallow } from 'zustand/react/shallow'
 
// ✅ shallow comparison으로 불필요한 리렌더링 방지
const { items, addItem, removeItem } = useCartStore(
  useShallow(state => ({
    items: state.items,
    addItem: state.addItem,
    removeItem: state.removeItem
  }))
)

5.3 메모이제이션과 결합

const CartSummary = memo(() => {
  const totalPrice = useCartStore(state => state.getTotalPrice())
  const itemCount = useCartStore(state => state.getItemCount())
  
  return (
    <div>
      <p>Items: {itemCount}</p>
      <p>Total: ${totalPrice}</p>
    </div>
  )
})

6. 트러블슈팅 가이드

6.1 하이드레이션 불일치 해결

// 문제: "Text content does not match server-rendered HTML"
// 해결책 1: skipHydration 사용
export const useStore = create(
  persist(
    (set) => ({ ... }),
    {
      name: 'storage-key',
      skipHydration: true
    }
  )
)
 
// 해결책 2: 하이드레이션 상태 추적
const [hasHydrated, setHasHydrated] = useState(false)
 
useEffect(() => {
  useStore.persist.rehydrate()
  setHasHydrated(true)
}, [])
 
if (!hasHydrated) return <div>Loading...</div>

6.2 Server Components 사용 제한

// 🚫 Server Component에서 사용 불가
export default function ServerPage() {
  const count = useCounterStore(state => state.count) // 에러!
  return <div>{count}</div>
}
 
// ✅ Client Component로 분리
'use client'
export default function ClientCounter() {
  const count = useCounterStore(state => state.count)
  return <div>{count}</div>
}

6.3 함수 undefined 오류 해결

// 문제: persist 사용 시 함수가 undefined
interface StoreState {
  count: number
  // 함수도 타입에 명시적으로 포함
  increment: () => void
  decrement: () => void
}
 
export const useStore = create<StoreState>()(
  persist(
    (set) => ({
      count: 0,
      increment: () => set(state => ({ count: state.count + 1 })),
      decrement: () => set(state => ({ count: state.count - 1 }))
    }),
    {
      name: 'counter-storage',
      // 함수는 저장하지 않도록 설정
      partialize: (state) => ({ count: state.count })
    }
  )
)

7. 모범 사례 및 패턴

7.1 타입 안전성 보장

// 엄격한 타입 정의
interface StrictCartState {
  readonly items: readonly CartItem[]
  readonly isLoading: boolean
  readonly error: string | null
}
 
interface StrictCartActions {
  addItem: (item: Omit<CartItem, 'id'>) => Promise<void>
  removeItem: (id: string) => Promise<void>
  clearError: () => void
}
 
type StrictCartStore = StrictCartState & StrictCartActions

7.2 에러 핸들링 패턴

export const useApiStore = create<ApiState>((set, get) => ({
  data: null,
  isLoading: false,
  error: null,
  
  fetchData: async () => {
    set({ isLoading: true, error: null })
    try {
      const response = await fetch('/api/data')
      if (!response.ok) throw new Error('Failed to fetch')
      const data = await response.json()
      set({ data, isLoading: false })
    } catch (error) {
      set({ 
        error: error instanceof Error ? error.message : 'Unknown error',
        isLoading: false 
      })
    }
  }
}))

7.3 테스트 친화적 설계

// 테스트를 위한 초기화 함수
export const createTestStore = (initialState?: Partial<CartState>) => {
  return create<CartStore>()((set, get) => ({
    items: [],
    ...initialState,
    addItem: (item) => { /* 구현 */ },
    // ... 기타 액션들
  }))
}
 
// 테스트 코드
describe('CartStore', () => {
  it('should add item to cart', () => {
    const store = createTestStore({ items: [] })
    // 테스트 로직
  })
})

8. Next.js 15 특화 고려사항

8.1 App Router 완전 활용

// app/cart/layout.tsx - 카트 전용 레이아웃
export default function CartLayout({
  children
}: {
  children: React.ReactNode
}) {
  return (
    <CartStoreProvider>
      <div className="cart-layout">
        {children}
      </div>
    </CartStoreProvider>
  )
}

8.2 Server Actions와의 연동

// app/actions/cart-actions.ts
'use server'
 
export async function syncCartToServer(cartItems: CartItem[]) {
  // 서버에 카트 동기화
  const response = await fetch('/api/cart/sync', {
    method: 'POST',
    body: JSON.stringify({ items: cartItems })
  })
  return response.json()
}
 
// Client에서 사용
const syncCart = async () => {
  const items = useCartStore.getState().items
  await syncCartToServer(items)
}

8.3 미래 대응성

// React 19 Concurrent Features 대응
export const useConcurrentCartStore = create<CartState>((set) => ({
  items: [],
  addItem: (item) => {
    // startTransition 등을 활용한 비동기 상태 업데이트
    set((state) => ({ items: [...state.items, item] }))
  }
}))

9. 실제 프로덕션 사례 분석

프로젝트활용 분야특징규모
Cal.com1일정 관리, 사용자 설정tRPC, Prisma와 통합대규모
Documenso1문서 상태, 서명 플로우Auth.js, Stripe 연동중규모
TypeHero1코드 챌린지, 진행상황실시간 상태 동기화중규모
Unkey1API 키 관리, 대시보드Clerk, Planetscale 통합대규모

10. 결론 및 권장사항

10.1 언제 Zustand를 선택할까?

  • 중소규모 Next.js 앱: Context API보다 간단하면서도 Redux보다 가벼운 솔루션
  • TypeScript 프로젝트: 뛰어난 타입 추론과 안전성
  • App Router 환경: SSR/SSG와의 완벽한 호환성
  • 팀 개발: 학습 곡선이 낮아 온보딩이 쉬움

10.2 2025년 트렌드 전망

Zustand는 Redux의 복잡성 없이도 강력한 상태 관리를 제공하며, AI 개발 도구와의 호환성2도 우수하다. Next.js 15의 Server ComponentsApp Router 환경에서 표준 상태 관리 솔루션으로 자리잡을 것으로 예상된다34.

10.3 최종 권장사항

  • 소규모 프로젝트: 기본 Zustand + persist 미들웨어
  • 중규모 프로젝트: 슬라이스 패턴 + Context Provider
  • 대규모 프로젝트: 엄격한 타입 정의 + 테스트 친화적 설계
  • E-commerce: 카트, 사용자 인증, 위시리스트 통합 관리
  • 대시보드/관리도구: 모달, 사이드바, 테마 등 UI 상태 중심 관리

Zustand는 단순함과 강력함의 완벽한 균형을 제공하며, Next.js 15 환경에서 현대적이고 확장 가능한 상태 관리 아키텍처를 구축하는 데 이상적인 선택이다.

Footnotes

  1. https://dev.to/datarockets/real-world-open-source-projects-built-with-nextjs-14-and-app-router-i1n 2 3 4

  2. https://qiita.com/chamudi/items/59122397a593096e1660

  3. https://www.linkedin.com/pulse/state-management-2025-redux-zustand-react-query-sbtqc

  4. https://dev.to/hijazi313/state-management-in-2025-when-to-use-context-redux-zustand-or-jotai-2d2k