반갑습니다, 예비 프론트엔드 장인 여러분! 오늘은 상태 관리 라이브러리인 Zustand를 Next.js 환경에 어떻게 우아하게 세팅하는지 공식 문서를 통해 함께 파헤쳐보겠습니다. 문서 내용 하나하나 놓치지 않고 꼼꼼히 살펴볼 테니 잘 따라와 주세요!
📝 참고 (Note)
https://github.com/pmndrs/zustand/discussions/2740 에서의 논의를 바탕으로 조만간 이 가이드를 업데이트할 예정입니다.
Next.js는 정말 인기 있는 React용 서버 사이드 렌더링(SSR) 프레임워크죠! 하지만 Zustand를 여기서 제대로 사용하려면 몇 가지 독특한 과제들을 마주하게 됩니다.
명심해야 할 점은, Zustand 스토어는 기본적으로 전역 변수(일명 모듈 상태)이기 때문에 React의 Context를 사용하는 것이 선택 사항이라는 거예요.
우리가 직면하게 될 과제들은 다음과 같습니다:
Context를 사용하여 컴포넌트 레벨에서 스토어를 초기화해주어야 합니다.👨🏫 강사의 부연 설명:
"모듈 상태"라는 말이 조금 헷갈리실 수 있어요. 파일 최상단에 선언되어서 앱 전체가 자바스크립트 메모리로 공유하는 상태를 말합니다. 일반적인 React SPA(싱글 페이지 애플리케이션)에서는 이게 아주 편리하지만, 서버 사이드 렌더링 환경인 Next.js에서는 A 사용자의 상태가 B 사용자에게 노출될 수 있는 치명적인 버그를 유발할 수 있어요. 그래서 우리는 스토어를 안전하게 '격리'하는 방법을 배울 겁니다.
이러한 이유로 Zustand를 적절하게 사용하기 위해 다음과 같은 일반적인 권장 사항을 드립니다:
💡 강사의 실무 팁:
실무에서 Next.js 13 이상의 App Router를 처음 쓸 때 가장 많이 하는 실수입니다! Zustand는 철저하게 '클라이언트 상태'를 관리하기 위한 도구로 바라보세요. 서버 컴포넌트에서는 데이터를 직접 fetch하고, 필요한 데이터만 클라이언트 컴포넌트로 넘겨주는(Props) 방식으로 역할을 명확히 분리해야 유지보수가 편해집니다.
자, 이제 각 요청마다 새로운 스토어 인스턴스를 생성해 줄 스토어 팩토리(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 파싱 에러가 날 수 있습니다!)
// 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를 사용한 점도 눈여겨보세요.
이제 방금 만든 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())처럼 쓴다면 리렌더링이 일어날 때마다 불필요하게 스토어 생성 함수가 실행되겠죠? 성능 최적화와 안정성을 위해 꼭 콜백 형태로 넘겨주어야 합니다.
Next.js 애플리케이션에는 두 가지 큰 아키텍처가 존재합니다. 바로 Pages Router와 최신의 App Router입니다. 두 아키텍처 모두 Zustand를 사용하는 핵심 원리는 같지만, 각각의 특성에 따라 약간의 차이가 존재합니다.
// 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 환경에서는 파일 구조가 조금 다르죠? 어떻게 적용하는지 살펴봅시다.
// 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'지시어를 명시했기 때문입니다. 이렇게 하면 서버 컴포넌트 트리 중간에 클라이언트 컴포넌트를 안전하게 끼워 넣을 수 있습니다.
이전 (Previous): 자동으로 선택자 생성하기 (Auto Generating Selectors)
다음 (Next): SSR 및 하이드레이션 (SSR and Hydration)