Zustand 학습 로드맵: store·action·selector·persist 순서

2026.05.12·수정 2026.07.19·약 19분

Zustand는 이 순서로 익히면 됩니다

먼저 로컬 상태와 공유 상태의 경계를 정하고, TypeScript로 작은 store를 만든 뒤 action과 원자 selector를 익힙니다. 여러 값을 한 번에 선택할 때만 useShallow를 검토하고, 저장할 이유가 분명한 상태에만 마지막 단계로 persist를 붙입니다. Next.js에서는 요청 간 store 공유와 hydration까지 별도 문제로 다뤄야 합니다.

검증 기준: 2026년 7월 19일 확인한 Zustand 공식 문서와 Next.js 가이드입니다. 예제는 React·TypeScript를 기준으로 하며 설치한 Zustand 버전의 API를 함께 확인하세요.

Zustand store action selector persist 학습 경로를 단계별로 보여주는 다이어그램

0단계: 무엇을 store에 넣을지 먼저 정한다

Zustand 문법부터 외우면 입력값, hover, 서버 응답까지 전부 전역 store에 넣기 쉽습니다. 학습의 시작점은 create가 아니라 상태의 소유 범위입니다. 한 컴포넌트나 가까운 부모·자식 안에서만 쓰는 값은 useState가 더 단순할 수 있습니다.

상태 예시 첫 선택 store를 검토할 신호
입력 중인 검색어, 단일 모달 useState 서로 먼 화면이 같은 값을 읽고 변경함
테마, 장바구니, 여러 영역의 필터 공유 위치에 따라 Context 또는 Zustand Provider 계층과 변경 로직이 복잡해짐
API 목록, 캐시, 재시도 상태 요구사항에 맞는 data fetching 계층 Zustand로 직접 캐시를 설계할 명확한 이유가 있음
URL로 공유해야 하는 필터 route 또는 search params URL과 store를 동기화할 책임을 감수할 수 있음

판단이 아직 어렵다면 Zustand란 무엇인가에서 props, Context, Zustand, 서버 상태의 선택 기준을 먼저 확인하세요.

1단계: TypeScript store를 작게 만든다

TypeScript에서는 공식 문서가 안내하는 커리드 형태 create<State>()(...)를 기본으로 두면 state와 action의 형태를 명시적으로 검사할 수 있습니다. 처음부터 모든 앱 상태를 하나로 합치지 말고 기능 경계가 분명한 작은 store로 시작하세요.

// stores/useProductFilterStore.ts
import { create } from 'zustand'

type Sort = 'recommended' | 'price-asc' | 'price-desc'

type ProductFilterStore = {
  keyword: string
  category: string
  sort: Sort
  setKeyword: (keyword: string) => void
  setCategory: (category: string) => void
  setSort: (sort: Sort) => void
  reset: () => void
}

const initialState = {
  keyword: '',
  category: 'all',
  sort: 'recommended' as Sort,
}

export const useProductFilterStore = create<ProductFilterStore>()((set) => ({
  ...initialState,
  setKeyword: (keyword) => set({ keyword }),
  setCategory: (category) => set({ category }),
  setSort: (sort) => set({ sort }),
  reset: () => set(initialState),
}))

Zustand의 set은 기본적으로 state 객체를 얕게 병합합니다. 중첩 객체를 바꿀 때는 내부 필드가 자동으로 깊게 합쳐진다고 생각하지 말고 새 객체를 명시적으로 만드세요. 전체 state를 교체하는 두 번째 인자 true는 action까지 지울 수 있으므로 필요한 경우에만 조심해서 사용합니다.

2단계: action으로 변경 규칙을 모은다

action은 Zustand가 강제하는 특별 문법이 아니라 store에 함께 둔 함수입니다. 그러나 컴포넌트마다 setState로 서로 다른 객체를 만들기 시작하면 같은 기능의 규칙이 흩어집니다. “카테고리를 바꾸면 페이지를 1로 초기화한다” 같은 도메인 규칙은 하나의 action으로 묶는 편이 추적하기 쉽습니다.

type CatalogStore = {
  category: string
  page: number
  selectCategory: (category: string) => void
  nextPage: () => void
}

export const useCatalogStore = create<CatalogStore>()((set) => ({
  category: 'all',
  page: 1,
  selectCategory: (category) => set({ category, page: 1 }),
  nextPage: () => set((state) => ({ page: state.page + 1 })),
}))

이전 값으로 다음 값을 계산할 때는 set((state) => ...) updater를 사용합니다. 컴포넌트는 구현 세부사항 대신 selectCategory, nextPage 같은 동작 이름을 호출합니다.

3단계: 필요한 값만 selector로 구독한다

useProductFilterStore()처럼 selector 없이 전체 store를 읽으면 그 컴포넌트는 store 전체를 구독합니다. 작은 예제에서는 편하지만 store가 커질수록 관련 없는 변경에도 다시 렌더링될 수 있습니다. 원자 selector를 기본으로 두면 컴포넌트의 의존성이 코드에 드러납니다.

export function SearchInput() {
  const keyword = useProductFilterStore((state) => state.keyword)
  const setKeyword = useProductFilterStore((state) => state.setKeyword)

  return (
    <input
      value={keyword}
      onChange={(event) => setKeyword(event.target.value)}
      aria-label='상품 검색'
    />
  )
}

여러 값을 객체나 배열 하나로 묶어 선택하면 selector가 실행될 때마다 새 참조가 생길 수 있습니다. 선택 결과의 최상위 값이 같을 때 렌더링을 건너뛰고 싶다면 useShallow를 적용합니다. 무조건 모든 selector에 붙이지 말고 여러 pick을 묶어 반환하는 경우에 검토하세요.

import { useShallow } from 'zustand/react/shallow'

export function FilterSummary() {
  const { category, sort, reset } = useProductFilterStore(
    useShallow((state) => ({
      category: state.category,
      sort: state.sort,
      reset: state.reset,
    })),
  )

  return (
    <div>
      <p>{category} · {sort}</p>
      <button type='button' onClick={reset}>초기화</button>
    </div>
  )
}

리렌더링 원인을 더 자세히 확인하려면 Zustand 리렌더링과 selector에서 전체 구독, 새 객체 반환, 얕은 비교를 구분해 보세요.

4단계: persist는 저장할 상태에만 붙인다

persist는 새로고침이나 앱 재시작 뒤 상태를 복원하는 middleware입니다. store 구조를 자동으로 좋게 만드는 기능이 아닙니다. 테마와 목록 표시 방식처럼 다시 방문해도 유지할 값만 partialize로 골라 저장하고, 모달 열림·요청 진행·서버 응답 캐시·민감한 인증값은 관성적으로 저장하지 마세요.

Zustand persist 적용 전 저장할 상태와 저장하지 않을 상태를 구분하는 구조 다이어그램
// 클라이언트 전용 SPA 예제
import { create } from 'zustand'
import { createJSONStorage, persist } from 'zustand/middleware'

type Theme = 'light' | 'dark'

type PreferencesStore = {
  theme: Theme
  compact: boolean
  setTheme: (theme: Theme) => void
  toggleCompact: () => void
}

type PersistedPreferences = Pick<PreferencesStore, 'theme' | 'compact'>

export const usePreferencesStore = create<PreferencesStore>()(
  persist(
    (set) => ({
      theme: 'light',
      compact: false,
      setTheme: (theme) => set({ theme }),
      toggleCompact: () => set((state) => ({ compact: !state.compact })),
    }),
    {
      name: 'preferences',
      storage: createJSONStorage(() => localStorage),
      skipHydration: true,
      partialize: (state) => ({ theme: state.theme, compact: state.compact }),
      version: 1,
      migrate: (persisted, version) => {
        const previous = persisted as PersistedPreferences & { darkMode?: boolean }

        if (version === 0) {
          return {
            theme: previous.darkMode ? 'dark' : 'light',
            compact: previous.compact ?? false,
          }
        }

        return persisted as PersistedPreferences
      },
    },
  ),
)

version은 저장 구조의 버전이고, 저장된 버전이 현재와 다를 때 migrate가 최신 구조를 반환합니다. 필드명을 바꾸거나 타입을 변경했는데 version과 migrate를 두지 않으면 오래된 브라우저 저장값 때문에 특정 사용자에게만 오류가 날 수 있습니다. 자세한 옵션은 공식 persist 문서에서 partialize, version, migrate, skipHydration을 함께 확인하세요.

5단계: 저장과 hydration은 같은 문제가 아니다

브라우저 storage에서 복원한 값과 서버가 만든 초기 HTML의 값이 다르면 hydration 경고나 화면 깜빡임이 생길 수 있습니다. 예를 들어 서버는 light theme로 렌더링했는데 첫 클라이언트 렌더에서 localStorage의 dark theme를 즉시 읽으면 결과가 달라집니다.

해결책은 앱 요구사항에 따라 다릅니다. 서버가 같은 초기값을 전달해 서버·클라이언트가 일치하게 만들거나, 저장값 복원이 끝난 뒤 해당 UI를 표시하거나, SSR이 필요 없는 일부 UI만 클라이언트에서 렌더링할 수 있습니다. skipHydration: truestore.persist.rehydrate()로 복원 시점을 직접 제어할 수도 있지만 로딩 상태와 접근성을 함께 설계해야 합니다.

'use client'

import { useEffect, useState } from 'react'

export function PreferencesHydrator({ children }: { children: React.ReactNode }) {
  const [ready, setReady] = useState(false)

  useEffect(() => {
    const hydration = usePreferencesStore.persist.rehydrate()
    void Promise.resolve(hydration).finally(() => setReady(true))
  }, [])

  if (!ready) return <p aria-live='polite'>설정을 불러오는 중…</p>
  return children
}

이 패턴을 쓰려면 persist 옵션에 skipHydration: true를 추가해야 합니다. 모든 화면을 hydration 뒤까지 숨기는 것이 정답은 아닙니다. 저장 상태가 실제로 첫 화면에 필요한지부터 다시 확인하세요.

6단계: Next.js에서는 요청별 store와 RSC 경계를 지킨다

Zustand store는 module state가 될 수 있습니다. Next.js 서버는 여러 요청을 동시에 처리하므로 서버 모듈의 단일 store를 모든 사용자와 공유하면 요청 간 상태가 섞일 수 있습니다. 공식 Next.js 가이드는 store를 요청별로 만들고 React Server Component가 store를 읽거나 쓰지 않도록 권장합니다. RSC는 hooks나 Context를 쓰지 않으며 사용자별 클라이언트 상태를 보관하는 곳이 아닙니다.

// stores/counter-store.ts
import { createStore } from 'zustand/vanilla'

type CounterStore = {
  count: number
  increment: () => void
}

export function createCounterStore(initialCount = 0) {
  return createStore<CounterStore>()((set) => ({
    count: initialCount,
    increment: () => set((state) => ({ count: state.count + 1 })),
  }))
}

export type CounterStoreApi = ReturnType<typeof createCounterStore>
// providers/counter-store-provider.tsx
'use client'

import { createContext, useContext, useRef } from 'react'
import { useStore } from 'zustand'
import { createCounterStore, type CounterStoreApi } from '@/stores/counter-store'

const CounterStoreContext = createContext<CounterStoreApi | null>(null)

export function CounterStoreProvider({ children }: { children: React.ReactNode }) {
  const storeRef = useRef<CounterStoreApi | null>(null)
  if (storeRef.current === null) storeRef.current = createCounterStore()

  return (
    <CounterStoreContext.Provider value={storeRef.current}>
      {children}
    </CounterStoreContext.Provider>
  )
}

export function useCounter<T>(selector: (state: ReturnType<CounterStoreApi['getState']>) => T) {
  const store = useContext(CounterStoreContext)
  if (!store) throw new Error('CounterStoreProvider가 필요합니다.')
  return useStore(store, selector)
}

Provider의 범위는 상태 수명과 맞춰야 합니다. route 이동에도 유지할 상태는 공통 layout 아래에, 특정 화면을 떠날 때 초기화할 상태는 해당 화면 경계에 둡니다. 서버에서 계산한 초기값을 넘긴다면 서버 HTML과 클라이언트 첫 상태가 같도록 직렬화 가능한 값만 전달하세요.

실무 적용 전 점검표

  1. 이 값이 정말 여러 컴포넌트에서 공유되는지 확인합니다.
  2. store가 사용자 입력·UI 상태와 서버 데이터 캐시를 무분별하게 섞지 않는지 확인합니다.
  3. TypeScript store는 create<State>()(...) 형태로 state와 action을 검사합니다.
  4. 컴포넌트는 전체 store 대신 필요한 값을 selector로 구독합니다.
  5. 객체·배열 pick을 묶을 때만 useShallow 필요성을 검토합니다.
  6. persist에는 저장할 필드만 partialize하고 schema 변경 시 version·migrate를 추가합니다.
  7. SSR HTML과 브라우저 저장값이 다른 경우 hydration 전략을 테스트합니다.
  8. Next.js 서버 module-global store를 만들지 않고 RSC에서 store를 읽거나 쓰지 않습니다.
  9. 새로고침, 로그아웃, 다중 탭, 저장값 구버전, route 이동을 테스트합니다.

공식 문서

이 순서를 한 번 적용한 뒤에는 Zustand persist 사용법Zustand 실무 주의사항에서 저장·분리 기준을 더 구체적으로 점검할 수 있습니다.

이 글이 마음에 드세요?

RSS 피드를 구독하세요!

“Zustand 학습 로드맵: store·action·selector·persist 순서”에 대한 4개의 생각

댓글 남기기