Next.js App Router에서 Zustand·Jotai RSC 초기값 주입 패턴 — 서버 상태와 클라이언트 상태를 명확히 나누는 설계
Next.js App Router로 프로젝트를 이전하면서 가장 많이 받은 질문이 "그럼 Zustand는 어디서 써요?"였습니다. 이 질문을 처음 들었을 때 저도 "그냥 useEffect에서 fetch하면 안 되나?"라고 생각했거든요. 그런데 서버에서 이미 가져온 데이터를 클라이언트에서 다시 fetch하면 waterfall이 생기고, 서버에서 처리할 수 있는 작업이 번들에 끼어드는 낭비가 발생합니다. 기존 스타일로 전역 스토어에 모든 걸 밀어넣으면 더 이상한 일들이 생기고요 — 하이드레이션 에러가 뜨거나, 요청 사이에 다른 사람의 데이터가 섞이거나, 클라이언트 번들이 쓸데없이 커지는 상황을요.
이 글은 Next.js App Router를 처음 쓰거나, 기존 Pages Router에서 이전하는 중급 프론트엔드 개발자를 대상으로 합니다. TypeScript 기본 문법과 React 훅에 익숙하다면 충분히 따라올 수 있습니다. 핵심은 서버에서 초기값을 fetch해서 props로 내려주고, 클라이언트 스토어는 브라우저 상호작용 상태만 담는 단방향 흐름을 이해하는 것입니다. Zustand와 Jotai 각각의 구체적인 패턴을 실전 코드와 함께 살펴보고, 흔히 빠지는 함정도 솔직하게 짚어볼게요.
핵심 개념
상태에도 주소가 있습니다 — 서버 상태 vs 클라이언트 상태
RSC 아키텍처에서 가장 먼저 정리할 것은 "이 값이 어디서 태어나는가"입니다.
| 구분 | 특징 | 예시 | 담당 |
|---|---|---|---|
| 서버 상태 | DB 조회, 인증 정보, 초기 목록 등 서버에서 결정됨 | 상품 목록, 사용자 프로필 | RSC에서 fetch → props |
| 클라이언트 상태 | 브라우저 상호작용에서 발생 | 모달 열림, 필터 선택, 입력 폼 | Zustand · Jotai |
서버 컴포넌트는 훅도 컨텍스트도 쓸 수 없습니다. 스토어에 직접 손댈 방법이 없어요. 그래서 흐름은 자연스럽게 하나로 수렴됩니다.
서버(RSC): 데이터 fetch
↓ 직렬화 가능한 props로 전달
클라이언트 컴포넌트: 스토어 초기화
↓
UI 컴포넌트: 스토어 구독 · 상호작용 상태 관리전역 싱글턴 스토어가 위험한 이유
Next.js App Router 이전 방식처럼 모듈 최상단에 create()로 스토어를 만들면, 서버 사이드 렌더링 시 여러 요청이 같은 스토어 인스턴스를 공유하게 됩니다. A 사용자의 장바구니가 B 사용자에게 보이는, 꽤 심각한 데이터 누출이 발생할 수 있어요.
// ❌ 이렇게 하면 안 됩니다 — 서버에서 요청 간 상태 공유됨
const useProductStore = create<ProductState>((set) => ({
products: [],
// ...
}))해결책은 요청마다 새 스토어 인스턴스를 만들고 React Context로 공급하는 패턴입니다. 처음 보면 번거로워 보이지만, 팀 템플릿으로 한 번 만들어두면 이후 스토어 추가가 파일 복사 수준으로 줄어들어서 오히려 빠릅니다.
실전 적용
어떤 예시를 골라 읽을지 간단히 기준을 드리면 이렇습니다. 전역 상태가 많고 미들웨어(immer, persist, devtools)를 적극 쓰고 있다면 예시 1(Zustand), 컴포넌트별로 관심사가 분리된 원자 단위 구독이 필요하다면 **예시 2(Jotai)**가 더 맞을 수 있습니다. 두 라이브러리를 혼용하는 팀은 어느 쪽 패턴이든 적용 원칙은 같으니 두 예시를 모두 읽어보시면 좋습니다.
예시 1: Zustand — StoreProvider 패턴
가장 많이 쓰이는 구조입니다. createStore(React 없이 독립적으로 동작하는 스토어 생성 함수로, Context와 결합해 SSR 요청 격리에 사용합니다)로 팩토리 함수를 만들고, 클라이언트 컴포넌트인 StoreProvider가 서버 props를 받아 스토어를 초기화합니다.
Step 1. Vanilla store 팩토리 함수 작성
// store/product-store.ts
import { createStore } from 'zustand'
export interface Product {
id: string
name: string
price: number
}
export interface ProductState {
products: Product[] // 서버에서 주입되는 초기값
selectedId: string | null // 클라이언트 상호작용 상태
setSelectedId: (id: string) => void
}
export type ProductStore = ReturnType<typeof createProductStore>
export function createProductStore(initialProducts: Product[]) {
return createStore<ProductState>()((set) => ({
products: initialProducts,
selectedId: null,
setSelectedId: (id) => set({ selectedId: id }),
}))
}Step 2. StoreProvider 클라이언트 컴포넌트 작성
// components/product-store-provider.tsx
'use client'
import { createContext, useContext, useRef } from 'react'
import { useStore } from 'zustand'
import {
createProductStore,
ProductStore,
ProductState,
Product,
} from '@/store/product-store'
const ProductStoreContext = createContext<ProductStore | null>(null)
export function ProductStoreProvider({
children,
initialProducts,
}: {
children: React.ReactNode
initialProducts: Product[]
}) {
// useState나 useMemo는 리렌더 트리거나 재계산 가능성이 있어 스토어 보관에 적합하지 않습니다.
// useRef는 렌더와 무관하게 값을 안정적으로 유지하는 mutable 컨테이너라 여기 딱 맞습니다.
const storeRef = useRef<ProductStore | null>(null)
if (!storeRef.current) {
storeRef.current = createProductStore(initialProducts)
}
return (
<ProductStoreContext.Provider value={storeRef.current}>
{children}
</ProductStoreContext.Provider>
)
}
export function useProductStore<T>(selector: (state: ProductState) => T): T {
const store = useContext(ProductStoreContext)
if (!store) throw new Error('ProductStoreProvider가 없습니다')
return useStore(store, selector)
}Step 3. 서버 컴포넌트에서 조립
// app/products/page.tsx ← 서버 컴포넌트 (기본값)
import { notFound } from 'next/navigation'
import { fetchProducts } from '@/lib/api'
import { ProductStoreProvider } from '@/components/product-store-provider'
import { ProductList } from '@/components/product-list'
export default async function ProductsPage() {
let products
try {
products = await fetchProducts() // DB 직접 조회, 인증 쿠키 접근 가능
} catch {
notFound()
}
return (
<ProductStoreProvider initialProducts={products}>
<ProductList />
</ProductStoreProvider>
)
}Step 4. UI 컴포넌트에서 스토어 사용
// components/product-list.tsx
'use client'
import { useProductStore } from '@/components/product-store-provider'
export function ProductList() {
const products = useProductStore((s) => s.products)
const selectedId = useProductStore((s) => s.selectedId)
const setSelectedId = useProductStore((s) => s.setSelectedId)
return (
<ul>
{products.map((p) => (
<li
key={p.id}
onClick={() => setSelectedId(p.id)}
style={{ fontWeight: selectedId === p.id ? 'bold' : 'normal' }}
>
{p.name}
</li>
))}
</ul>
)
}| 포인트 | 설명 |
|---|---|
createStore vs create |
React 없이 독립 동작하는 스토어 생성 함수 — Context와 결합해 SSR 요청 격리에 사용 |
useRef로 스토어 보관 |
부모 리렌더가 발생해도 스토어가 새로 만들어지지 않도록 보호. useState·useMemo와 달리 렌더 사이클에 영향을 주지 않음 |
initialProducts props |
JSON 직렬화된 서버 데이터를 받아 스토어 초깃값으로 주입 |
실제로 이 패턴을 팀에 도입할 때 가장 많이 받은 질문이 "왜 useState로 스토어를 안 잡아두냐"였는데, useState는 리렌더를 트리거하고 초깃값 함수가 다시 실행될 여지가 있어서 스토어 보관에는 적합하지 않습니다. useRef가 여기서 정석인 이유입니다.
예시 2: Jotai — useHydrateAtoms 패턴
Zustand보다 API가 더 간결합니다. useHydrateAtoms 훅으로 서버 값을 atom 초깃값으로 주입할 수 있어요. 한 가지 중요한 점은, 이 훅은 렌더 중에 동기적으로 atom 값을 덮어씁니다. useEffect를 쓰면 마운트 이후 실행이라 SSR HTML과 클라이언트 초기 렌더 사이에 불일치가 생길 수 있는데, useHydrateAtoms는 그 문제를 렌더 단계에서 차단합니다.
Step 1. Atom 정의
타입을 atoms 파일에서 함께 export해두면 다른 컴포넌트에서 중복 정의를 피할 수 있습니다.
// atoms/product-atoms.ts
import { atom } from 'jotai'
export interface Product {
id: string
name: string
price: number
}
export const productsAtom = atom<Product[]>([]) // 서버 초기값용
export const selectedIdAtom = atom<string | null>(null) // 클라이언트 상호작용Step 2. Hydration 래퍼 컴포넌트 작성
// components/hydrate-products.tsx
'use client'
import { useHydrateAtoms } from 'jotai/utils'
import { productsAtom, Product } from '@/atoms/product-atoms'
export function HydrateProducts({
products,
children,
}: {
products: Product[]
children: React.ReactNode
}) {
// 렌더 중 동기적으로 atom 초깃값을 설정합니다 (useEffect보다 안전)
useHydrateAtoms([[productsAtom, products]])
return <>{children}</>
}Step 3. 서버 컴포넌트에서 조립
// app/products/page.tsx
import { notFound } from 'next/navigation'
import { fetchProducts } from '@/lib/api'
import { HydrateProducts } from '@/components/hydrate-products'
import { ProductList } from '@/components/product-list'
export default async function ProductsPage() {
let products
try {
products = await fetchProducts()
} catch {
notFound()
}
return (
<HydrateProducts products={products}>
<ProductList />
</HydrateProducts>
)
}Jotai를 팀 프로젝트에 처음 도입할 때 "atom이 한 번만 초기화된다는 게 무슨 뜻이냐"는 질문을 자주 받았습니다. 정확히는 Provider 인스턴스당 1회 초기화라는 의미입니다. Provider가 remount되면(라우트 이동으로 페이지 컴포넌트가 다시 마운트되는 경우 등) 초기화도 다시 일어납니다. 단일 마운트 라이프사이클 안에서 서버 데이터가 바뀌어도 자동 반영이 안 된다는 것이 실질적인 제약입니다.
직렬화(Serialization): RSC에서 클라이언트로 넘기는 props는 JSON으로 표현 가능한 값이어야 합니다.
Date,Map,Set, 함수, 클래스 인스턴스는 그대로 넘길 수 없습니다.Date는 ISO 문자열로,Map은 배열로 변환해서 넘기는 것이 일반적입니다.
예시 3: jotai-ssr — 서버 컴포넌트에서 직접 선언
위 Jotai 패턴의 단순화 버전입니다. jotai-ssr 패키지를 쓰면 Hydration 래퍼 컴포넌트조차 만들 필요 없이 서버 컴포넌트 안에서 바로 초깃값을 선언할 수 있습니다. 팀에서 직접 검토해봤는데, 코드가 확실히 깔끔해지는 건 사실입니다. 다만 아직 커뮤니티에서 논의 중인 패턴이라 프로덕션 도입은 신중하게 판단하시는 게 좋습니다.
// app/products/page.tsx (Server Component)
import { HydrationBoundary } from 'jotai-ssr'
import { notFound } from 'next/navigation'
import { fetchProducts } from '@/lib/api'
import { productsAtom } from '@/atoms/product-atoms'
import { ProductList } from '@/components/product-list'
export default async function ProductsPage() {
let products
try {
products = await fetchProducts()
} catch {
notFound()
}
return (
// 서버 컴포넌트 안에서 atom 초깃값을 바로 선언할 수 있습니다
<HydrationBoundary hydrateAtoms={[[productsAtom, products]]}>
<ProductList />
</HydrationBoundary>
)
}
useHydrateAtomsvsjotai-ssr: 전자는 클라이언트 컴포넌트 안에서 훅으로 초기화하고, 후자는 서버 컴포넌트에서 경계를 선언합니다. 현재 프로덕션에서 안정적으로 쓰려면useHydrateAtoms패턴이 더 검증되어 있습니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 관심사 분리 | 서버: 데이터 fetch / 클라이언트: UI 상태 — 역할이 명확해져 디버깅이 쉬워집니다 |
| 번들 사이즈 절감 | 서버 데이터를 스토어에 중복 저장할 필요가 없어 클라이언트 번들이 가벼워집니다 |
| SSR 안전 | createStore + Context 패턴으로 요청 간 상태 누출을 원천 차단합니다 |
| DevTools 연동 | Zustand DevTools 미들웨어를 그대로 쓸 수 있어 상태 디버깅이 편합니다 |
| Jotai 선택 시: 원자 구독 | 원자 단위 구독으로 불필요한 리렌더링이 최소화됩니다 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 보일러플레이트 | StoreProvider와 Context 설정이 추가됩니다 |
팀 내 템플릿으로 만들어두면 이후 스토어 추가가 파일 복사 수준으로 줄어듭니다 |
| 직렬화 제약 | Date, Map, Set 등을 props로 바로 넘길 수 없습니다 |
ISO 문자열, 배열 등으로 변환 후 전달합니다 |
| Jotai 1회 초기화 | atom은 Provider 인스턴스당 1회만 초기화됩니다 — 단일 마운트 안에서 서버 값이 바뀌어도 자동 반영이 안 됩니다 | 라우트 이동 시 remount를 유도하거나 별도 업데이트 로직을 구성합니다 |
| 하이드레이션 불일치 | 서버·클라이언트 초깃값이 다르면 hydration error가 발생합니다 | 서버에서 내려준 값과 동일한 값으로 스토어를 초기화하는 것이 원칙입니다 |
하이드레이션 에러(Hydration Error): 서버에서 렌더링한 HTML과 클라이언트에서 React가 다시 그린 결과가 달라서 발생하는 오류입니다.
Math.random()이나Date.now()처럼 실행 시점에 따라 값이 달라지는 코드가 초기 상태에 들어가 있을 때 자주 나타납니다.
⚠️ 실무에서 가장 흔한 실수 3가지
-
모듈 최상단에
create()로 전역 스토어를 만드는 것 — App Router에서는 서버 사이드 실행 시 요청 간 상태가 공유될 수 있습니다.createStore+ Context 패턴을 사용하는 것을 권장합니다. -
RSC가 이미 fetch한 데이터를 Zustand/Jotai에 다시 저장하는 것 — TanStack Query가 서버 데이터 캐싱을 담당하고 있다면, 같은 데이터를 클라이언트 스토어에 중복 저장하면 두 소스 간 동기화 문제가 생깁니다. 역할을 명확히 나누는 것이 좋습니다.
-
Date,Map,Set을 props로 그대로 넘기는 것 — RSC에서 클라이언트 컴포넌트로 전달하는 props는 JSON 직렬화가 가능해야 합니다.new Date()는.toISOString()으로,Map은Array.from(map.entries())으로 변환 후 전달하는 것을 권장합니다.
마치며
서버 컴포넌트 시대의 상태 관리 핵심은 "서버가 결정한 값은 props로, 브라우저가 만드는 값만 스토어로" 라는 원칙에 있습니다. Zustand든 Jotai든 이 원칙을 지키면 하이드레이션 에러도, 데이터 누출도, 불필요한 클라이언트 번들도 크게 줄어듭니다.
두 라이브러리를 혼용하는 팀이라면 간단한 기준이 있습니다. 미들웨어나 DevTools가 필요한 복잡한 전역 상태는 Zustand, 컴포넌트 경계별로 독립된 원자 상태는 Jotai로 나누면 역할이 자연스럽게 정리됩니다.
지금 바로 시작해볼 수 있는 3단계:
-
현재 프로젝트의 전역 스토어를 열어 어떤 값이 "서버에서 결정되는 값"인지 분류해보시면 좋습니다.
products,user,initialConfig같은 값이 보인다면 RSC fetch + props 주입 대상입니다. -
Zustand를 쓰고 있다면
create()대신createStore()로 팩토리 함수를 만들고, 위의ProductStoreProvider패턴을 복사해 팀 템플릿으로 만들어두시면 이후 스토어 추가 속도가 확실히 빨라집니다. -
Jotai를 쓰고 있다면
useHydrateAtoms를 활용한HydrateProducts래퍼 패턴을 하나 만들어두고, 점진적으로 서버 초깃값 주입이 필요한 atom에 적용해나가시면 됩니다.
다음 글: TanStack Query와 Zustand를 함께 쓰는 패턴 — 서버 데이터 캐싱과 UI 상태를 완전히 분리해 낙관적 업데이트를 구현하는 방법
참고 자료
- Zustand 공식 문서 — Setup with Next.js
- Jotai 공식 문서 — SSR Utilities (useHydrateAtoms)
- Jotai 공식 문서 — Next.js Guide
- jotai-ssr npm 패키지
- Jotai GitHub Discussion #2692 — New RSC Support Utility
- Jotai GitHub Discussion #2470 — Full App Router useHydrateAtoms Example
- Zustand GitHub Discussion #2200 — Using Zustand in RSC (misuse patterns)
- Zustand GitHub Discussion #2772 — Global vs Scoped Store in Next.js 14
- TkDodo — Zustand and React Context
- Next.js 공식 문서 — Server and Client Components