Setup with Next.js

김동현·2026년 3월 4일

zustand 공식문서 번역

목록 보기
14/19

Next.js와 함께 설정하기 (Setup with Next.js)

반갑습니다, 예비 프론트엔드 장인 여러분! 오늘은 상태 관리 라이브러리인 Zustand를 Next.js 환경에 어떻게 우아하게 세팅하는지 공식 문서를 통해 함께 파헤쳐보겠습니다. 문서 내용 하나하나 놓치지 않고 꼼꼼히 살펴볼 테니 잘 따라와 주세요!

📝 참고 (Note)

https://github.com/pmndrs/zustand/discussions/2740 에서의 논의를 바탕으로 조만간 이 가이드를 업데이트할 예정입니다.


Next.js는 정말 인기 있는 React용 서버 사이드 렌더링(SSR) 프레임워크죠! 하지만 Zustand를 여기서 제대로 사용하려면 몇 가지 독특한 과제들을 마주하게 됩니다.

명심해야 할 점은, Zustand 스토어는 기본적으로 전역 변수(일명 모듈 상태)이기 때문에 React의 Context를 사용하는 것이 선택 사항이라는 거예요.

우리가 직면하게 될 과제들은 다음과 같습니다:

  • 요청당 스토어 (Per-request store): Next.js 서버는 여러 요청을 동시에 처리할 수 있어요. 이게 무슨 뜻이냐면, 스토어가 각 접속자의 요청마다 새롭게 생성되어야 하고, 서로 다른 요청(사용자) 간에 스토어가 공유되어서는 절대 안 된다는 걸 의미합니다.
  • SSR 친화성 (SSR friendly): Next.js 애플리케이션은 두 번 렌더링됩니다. 처음에는 서버에서, 그리고 다시 클라이언트에서 한 번 더 렌더링되죠. 이때 클라이언트와 서버의 렌더링 결과물이 다르면 그 악명 높은 "하이드레이션 에러(Hydration errors)"가 발생하게 됩니다. 이를 방지하려면 서버에서 스토어를 초기화한 다음, 클라이언트에서 동일한 데이터로 다시 초기화해야만 해요. 이에 대한 더 자세한 내용은 SSR 및 하이드레이션 (SSR and Hydration) 가이드에서 꼭 읽어보세요.
  • SPA 라우팅 친화성 (SPA routing friendly): Next.js는 클라이언트 사이드 라우팅을 위한 하이브리드 모델을 지원합니다. 즉, 라우팅 시 스토어를 안전하게 초기화(리셋)하려면 Context를 사용하여 컴포넌트 레벨에서 스토어를 초기화해주어야 합니다.
  • 서버 캐싱 친화성 (Server caching friendly): 최신 버전의 Next.js (특히 App Router 아키텍처를 사용하는 앱)는 매우 공격적인 서버 캐싱을 지원합니다. 다행히 우리의 Zustand 스토어는 모듈 상태이기 때문에 이러한 캐싱 메커니즘과 완벽하게 호환됩니다.

👨‍🏫 강사의 부연 설명:
"모듈 상태"라는 말이 조금 헷갈리실 수 있어요. 파일 최상단에 선언되어서 앱 전체가 자바스크립트 메모리로 공유하는 상태를 말합니다. 일반적인 React SPA(싱글 페이지 애플리케이션)에서는 이게 아주 편리하지만, 서버 사이드 렌더링 환경인 Next.js에서는 A 사용자의 상태가 B 사용자에게 노출될 수 있는 치명적인 버그를 유발할 수 있어요. 그래서 우리는 스토어를 안전하게 '격리'하는 방법을 배울 겁니다.

이러한 이유로 Zustand를 적절하게 사용하기 위해 다음과 같은 일반적인 권장 사항을 드립니다:

  • 전역 스토어 사용 금지 (No global stores) - 스토어는 서버 요청 간에 공유되어서는 안 되기 때문에 일반적인 전역 변수로 정의하면 안 됩니다. 대신, 스토어는 각 요청마다 새롭게 생성되어야 합니다.
  • React 서버 컴포넌트(RSC)는 스토어를 읽거나 쓰면 안 됩니다 - 서버 컴포넌트는 기본적으로 훅(hooks)이나 컨텍스트(context)를 사용할 수 없어요. 애초에 상태를 가지도록(stateful) 설계되지 않았거든요. 서버 컴포넌트가 전역 스토어에서 값을 읽거나 쓰는 것은 Next.js의 근본적인 아키텍처 원칙을 위반하는 행동입니다.

💡 강사의 실무 팁:
실무에서 Next.js 13 이상의 App Router를 처음 쓸 때 가장 많이 하는 실수입니다! Zustand는 철저하게 '클라이언트 상태'를 관리하기 위한 도구로 바라보세요. 서버 컴포넌트에서는 데이터를 직접 fetch하고, 필요한 데이터만 클라이언트 컴포넌트로 넘겨주는(Props) 방식으로 역할을 명확히 분리해야 유지보수가 편해집니다.


각 요청당 스토어 생성하기 (Creating a store per request)

자, 이제 각 요청마다 새로운 스토어 인스턴스를 생성해 줄 스토어 팩토리(factory) 함수를 작성해 보겠습니다.

// tsconfig.json
{
  "compilerOptions": {
    "lib": ["dom", "dom.iterable", "esnext"],
    "allowJs": true,
    "skipLibCheck": true,
    "strict": true,
    "noEmit": true,
    "esModuleInterop": true,
    "module": "esnext",
    "moduleResolution": "bundler",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "jsx": "preserve",
    "incremental": true,
    "plugins": [
      {
        "name": "next"
      }
    ],
    "paths": {
      "@/*": ["./src/*"]
    }
  },
  "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
  "exclude": ["node_modules"]
}

📝 참고: tsconfig.json 파일에서 모든 주석을 지우는 것을 잊지 마세요. (주석이 있으면 JSON 파싱 에러가 날 수 있습니다!)


스토어 초기화하기 (Initializing the store)

// src/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 defaultInitState: CounterState = {
  count: 0,
}

export const createCounterStore = (
  initState: CounterState = defaultInitState,
) => {
  return createStore<CounterStore>()((set) => ({
    ...initState,
    decrementCount: () => set((state) => ({ count: state.count - 1 })),
    incrementCount: () => set((state) => ({ count: state.count + 1 })),
  }))
}

👨‍🏫 강사의 부연 설명:
코드를 잘 보세요! 평소에 보던 create 함수를 바로 호출해서 스토어를 내보내는(export) 방식이 아닙니다. createCounterStore라는 함수를 만들어서, 호출할 때마다 완전히 독립적인 새로운 스토어가 반환되도록 팩토리 패턴을 적용했습니다. 바닐라 Zustand(zustand/vanilla)의 createStore를 사용한 점도 눈여겨보세요.


스토어 제공하기 (Providing the store)

이제 방금 만든 createCounterStore를 우리 컴포넌트 안에서 사용하고, 컨텍스트 프로바이더(Context Provider)를 통해 하위 컴포넌트들에게 공유해 보겠습니다.

// src/providers/counter-store-provider.tsx
'use client'

import { type ReactNode, createContext, useState, 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 interface CounterStoreProviderProps {
  children: ReactNode
}

export const CounterStoreProvider = ({
  children,
}: CounterStoreProviderProps) => {
  const [store] = useState(() => createCounterStore())
  return (
    <CounterStoreContext.Provider value={store}>
      {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)
}

📝 참고: 이 예제에서는 참조(reference) 값을 확인하여 스토어가 한 번만 생성되도록 함으로써, 이 컴포넌트가 리렌더링에 안전(re-render-safe)하도록 보장합니다. 이 컴포넌트는 서버에서 요청당 딱 한 번만 렌더링되지만, 클라이언트 쪽에서는 트리 상단에 다른 상태를 가진 클라이언트 컴포넌트가 있거나 이 컴포넌트 자체에 리렌더링을 유발하는 다른 가변 상태가 있을 경우 여러 번 리렌더링될 수 있기 때문입니다.

💡 강사의 실무 팁:
useState(() => createCounterStore()) 부분을 꼭 기억하세요. 괄호 안에 함수를 넣는 '지연 초기화(Lazy Initialization)' 테크닉입니다. 만약 useState(createCounterStore()) 처럼 쓴다면 리렌더링이 일어날 때마다 불필요하게 스토어 생성 함수가 실행되겠죠? 성능 최적화와 안정성을 위해 꼭 콜백 형태로 넘겨주어야 합니다.


다양한 아키텍처에서 스토어 사용하기 (Using the store with different architectures)

Next.js 애플리케이션에는 두 가지 큰 아키텍처가 존재합니다. 바로 Pages Router와 최신의 App Router입니다. 두 아키텍처 모두 Zustand를 사용하는 핵심 원리는 같지만, 각각의 특성에 따라 약간의 차이가 존재합니다.

Pages Router

// src/components/pages/home-page.tsx
import { useCounterStore } from '@/providers/counter-store-provider'

export const HomePage = () => {
  const { count, incrementCount, decrementCount } = useCounterStore(
    (state) => state,
  )

  return (
    <div>
      Count: {count}
      <hr />
      <button type="button" onClick={incrementCount}>
        Increment Count
      </button>
      <button type="button" onClick={decrementCount}>
        Decrement Count
      </button>
    </div>
  )
}
// src/_app.tsx
import type { AppProps } from 'next/app'

import { CounterStoreProvider } from '@/providers/counter-store-provider'

export default function App({ Component, pageProps }: AppProps) {
  return (
    <CounterStoreProvider>
      <Component {...pageProps} />
    </CounterStoreProvider>
  )
}
// src/pages/index.tsx
import { HomePage } from '@/components/pages/home-page'

export default function Home() {
  return <HomePage />
}

📝 참고: 라우트(페이지)별로 독립적인 스토어를 생성하려면 페이지 컴포넌트 수준에서 스토어를 생성하고 공유해야 합니다. 라우트별로 스토어를 따로따로 만들 필요가 없다면 아래와 같은 방식은 굳이 사용하지 마세요.

// src/pages/index.tsx
import { CounterStoreProvider } from '@/providers/counter-store-provider'
import { HomePage } from '@/components/pages/home-page'

export default function Home() {
  return (
    <CounterStoreProvider>
      <HomePage />
    </CounterStoreProvider>
  )
}

👨‍🏫 강사의 부연 설명:
방금 보신 것처럼 _app.tsx에 Provider를 감싸면 앱 전체가 하나의 스토어를 공유하게 되고, 개별 페이지 컴포넌트(index.tsx)에 감싸면 해당 페이지에 진입할 때마다 완전히 새로운 스토어가 초기화됩니다. 여러분이 만들려는 서비스의 성격에 맞춰 범위를 잘 설정하셔야 해요!


App Router

App Router 환경에서는 파일 구조가 조금 다르죠? 어떻게 적용하는지 살펴봅시다.

// src/components/pages/home-page.tsx
'use client'

import { useCounterStore } from '@/providers/counter-store-provider'

export const HomePage = () => {
  const { count, incrementCount, decrementCount } = useCounterStore(
    (state) => state,
  )

  return (
    <div>
      Count: {count}
      <hr />
      <button type="button" onClick={incrementCount}>
        Increment Count
      </button>
      <button type="button" onClick={decrementCount}>
        Decrement Count
      </button>
    </div>
  )
}
// src/app/layout.tsx
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'

import { CounterStoreProvider } from '@/providers/counter-store-provider'

const inter = Inter({ subsets: ['latin'] })

export const metadata: Metadata = {
  title: 'Create Next App',
  description: 'Generated by create next app',
}

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode
}>) {
  return (
    <html lang="en">
      <body className={inter.className}>
        <CounterStoreProvider>{children}</CounterStoreProvider>
      </body>
    </html>
  )
}
// src/app/page.tsx
import { HomePage } from '@/components/pages/home-page'

export default function Home() {
  return <HomePage />
}

📝 참고: Pages Router와 마찬가지로, 라우트별로 스토어를 생성하려면 페이지 컴포넌트 수준에서 스토어를 생성하고 공유해야 합니다. 그럴 필요가 없다면 아래 패턴은 피하세요.

// src/app/page.tsx
import { CounterStoreProvider } from '@/providers/counter-store-provider'
import { HomePage } from '@/components/pages/home-page'

export default function Home() {
  return (
    <CounterStoreProvider>
      <HomePage />
    </CounterStoreProvider>
  )
}

💡 강사의 실무 팁:
App Router의 layout.tsx 파일은 기본적으로 '서버 컴포넌트'입니다. 그런데 그 안에서 클라이언트 상태를 관리하는 CounterStoreProvider를 렌더링하고 있죠? 에러가 안 나는 이유는 우리가 아까 Provider를 만들 때 파일 최상단에 'use client' 지시어를 명시했기 때문입니다. 이렇게 하면 서버 컴포넌트 트리 중간에 클라이언트 컴포넌트를 안전하게 끼워 넣을 수 있습니다.


이 페이지 편집하기 (Edit this page)

이전 (Previous): 자동으로 선택자 생성하기 (Auto Generating Selectors)
다음 (Next): SSR 및 하이드레이션 (SSR and Hydration)

profile
프론트에_가까운_풀스택_개발자

0개의 댓글