Next.js App Router에서 Redux·Zustand Hydration Error가 터지는 진짜 이유와 SSR 직렬화 해결 패턴
Next.js App Router로 넘어오면서 상태 관리 쪽에서 처음 맞닥뜨린 게 바로 이 문제였습니다. 서버에서 분명히 데이터를 잘 불러왔는데 화면이 깜박이고, 콘솔에는 빨간 Hydration Error가 쏟아지는 상황. "서버랑 클라이언트가 같은 값인데 왜 에러가 나지?"라며 꽤 오래 헤맸습니다. Pages Router 프로젝트를 App Router로 마이그레이션하던 중에 특히 이 함정을 자주 밟았는데, 알고 보면 원인이 생각보다 단순합니다.
이 글은 Next.js App Router를 이미 쓰고 있거나 마이그레이션 중인, TypeScript와 Redux 혹은 Zustand를 한 번 이상 써본 프론트엔드 개발자를 대상으로 합니다. SSR에서 상태 관리 버그의 가장 흔한 원인은 직렬화 경계(Serialization Boundary)를 명시적으로 처리하지 않은 것과, 싱글톤 스토어를 per-request 격리 없이 사용한 것, 이 두 가지입니다. 이 글을 다 읽으면 Redux·Zustand 코드 어디를 어떻게 바꿔야 하는지 구체적인 체크리스트가 생깁니다.
잠깐, 브라우저와 서버가 왜 이렇게 다른지 짚고 넘어갈 필요가 있습니다. 브라우저는 상태를 메모리에 유지하지만 서버는 요청마다 새 메모리 컨텍스트에서 렌더링하고 버립니다. 상태를 서버→클라이언트로 전달하려면 반드시 JSON을 거쳐야 하는데, 이 JSON 변환 과정에서 정보가 조용히 손실되는 경우가 문제의 시작입니다. Pages Router 시절엔 getServerSideProps가 이 경계를 비교적 명확하게 드러냈는데, App Router에선 Server Component와 Client Component 사이 경계가 암묵적이라 실수하기 더 쉬워졌습니다.
핵심 개념
직렬화 경계란 무엇인가
SSR 흐름을 단순화하면 이렇습니다.
서버 상태 → JSON.stringify() → HTML 포함 → 네트워크 전송
→ JSON.parse() → 클라이언트 스토어 복원서버에서 만들어진 JavaScript 상태를 JSON으로 변환해 HTML에 심고, 클라이언트 JavaScript가 실행되면서 그 JSON을 읽어 스토어를 복원합니다. 이 과정을 Hydration(재수화) 이라 부르고, JSON 변환이 일어나는 지점이 직렬화 경계입니다.
문제의 핵심:
JSON.stringify()는Date,Map,Set,BigInt, 함수, 클래스 인스턴스를 정확히 표현하지 못합니다.new Date('2024-01-15')를 직렬화하면 문자열"2024-01-15T00:00:00.000Z"가 되고, 역직렬화하면Date객체가 아닌 문자열로 돌아옵니다.
BigInt를 Number로 변환할 때도 주의가 필요합니다. Number(1500n)처럼 값이 작을 때는 안전하지만, Number.MAX_SAFE_INTEGER(2⁵³−1)를 넘는 BigInt를 Number로 변환하면 정밀도가 잘립니다. 큰 ID나 금액을 다룬다면 Number() 대신 String()을 쓰는 게 안전합니다.
Hydration Error는 왜 발생하나
React는 클라이언트에서 실행될 때 가상 DOM을 새로 만들어 서버가 내려준 HTML과 비교합니다(재조정, Reconciliation). 이때 두 결과가 조금이라도 다르면 Hydration Error를 냅니다. 서버에서는 count: 0이었는데 클라이언트에서 localStorage의 count: 5로 초기화됐다면, 서버 HTML의 0과 클라이언트 렌더 결과의 5가 달라 불일치가 발생합니다.
싱글톤 스토어의 위험
SSR 환경에서 자주 간과하는 함정이 있습니다. Node.js 서버는 여러 요청을 동시에 처리하지만 모듈은 한 번만 로드됩니다. 전역 싱글톤으로 스토어를 만들면 사용자 A의 요청에서 변경된 상태가 사용자 B에게 그대로 노출될 수 있습니다.
// ❌ 위험한 패턴 — 모든 요청이 같은 스토어를 공유
const store = createStore(reducer)
export default store// ✅ 안전한 패턴 — 요청마다 새 스토어 생성
export const makeStore = () => createStore(reducer)이 차이가 요청 격리(per-request isolation)의 핵심입니다.
'use client' 지시어가 StoreProvider에 필요한 이유
App Router에서 모든 컴포넌트는 기본적으로 React Server Component(RSC)입니다. RSC는 서버에서만 실행되고 useState, useRef, useContext 같은 React 훅을 쓸 수 없습니다. StoreProvider는 useRef로 스토어를 초기화하고 React Context를 통해 하위 트리에 스토어를 제공해야 하므로, 반드시 클라이언트 컴포넌트여야 합니다. 파일 맨 위에 'use client'를 붙이는 것이 그 선언입니다.
이제 이 개념들이 실제 코드에서 어떻게 나타나는지 살펴보겠습니다.
실전 적용
예시 1: Redux — makeStore 팩토리 패턴으로 요청 격리와 초기 상태 전달
Pages Router에서 App Router로 마이그레이션하던 프로젝트에서 이 패턴을 놓쳐서 한참 고생했습니다. 기존 store.ts 파일에 const store = makeStore()가 모듈 최상위에 딱 한 줄 있었는데, 이게 모든 요청에서 공유되고 있었던 겁니다. Redux Toolkit 공식 문서는 App Router에서 이 싱글톤 패턴을 완전히 버리고 팩토리 함수 패턴을 권장합니다.
// lib/store.ts
import { configureStore } from '@reduxjs/toolkit'
import counterReducer from './counterSlice'
export const makeStore = (preloadedState?: Partial<RootState>) =>
configureStore({
reducer: {
counter: counterReducer,
},
preloadedState,
})
export type AppStore = ReturnType<typeof makeStore>
export type RootState = ReturnType<AppStore['getState']>
export type AppDispatch = AppStore['dispatch']StoreProvider는 서버에서 직렬화한 초기 상태를 받아 makeStore에 넘깁니다. useRef로 초기화하는 이유는 클라이언트 컴포넌트가 리렌더링될 때마다 스토어가 새로 만들어지는 걸 막기 위해서입니다.
// components/StoreProvider.tsx
'use client'
import { useRef } from 'react'
import { Provider } from 'react-redux'
import { makeStore, AppStore, RootState } from '@/lib/store'
export function StoreProvider({
initialState,
children,
}: {
initialState?: Partial<RootState>
children: React.ReactNode
}) {
const storeRef = useRef<AppStore | null>(null)
if (!storeRef.current) {
storeRef.current = makeStore(initialState)
}
return <Provider store={storeRef.current}>{children}</Provider>
}서버 컴포넌트에서 데이터를 직렬화해 StoreProvider의 초기 상태로 흘려보내는 흐름이 완성되면 이렇습니다.
// app/page.tsx (Server Component)
import { StoreProvider } from '@/components/StoreProvider'
async function fetchUserData() {
return {
name: '홍길동',
createdAt: new Date('2024-01-15'),
score: 9007199254740991n, // Number.MAX_SAFE_INTEGER에 근접한 BigInt
}
}
export default async function Page() {
const data = await fetchUserData()
const serialized = {
counter: {
name: data.name,
createdAt: data.createdAt.toISOString(), // Date → ISO 문자열
score: String(data.score), // 큰 BigInt → 문자열 (정밀도 보장)
},
}
return (
<StoreProvider initialState={serialized}>
<ClientPage />
</StoreProvider>
)
}| 코드 포인트 | 설명 |
|---|---|
makeStore(preloadedState) |
요청마다 독립 스토어 생성, 서버 초기 상태 주입 |
useRef 초기화 |
클라이언트 리렌더링 시 스토어 중복 생성 방지 |
toISOString() |
Date 직렬화 경계 안전 통과 |
String(bigIntValue) |
MAX_SAFE_INTEGER 초과 시 정밀도 손실 방지 |
예시 2: Zustand — StoreProvider + skipHydration 패턴
Zustand는 Redux보다 보일러플레이트가 적은 만큼 SSR에서 실수하기도 쉽습니다. 솔직히 말씀드리면, persist 미들웨어를 skipHydration 없이 그냥 붙였다가 Hydration Mismatch로 한참 고생한 경험이 있습니다. 서버에서는 count: 0으로 렌더링됐는데, persist가 localStorage의 count: 5로 초기화해버리니 불일치가 날 수밖에 없었습니다.
예시 1(Redux)이 대규모 앱이나 DevTools 시간 여행 디버깅이 중요한 경우에 적합하다면, 예시 2(Zustand)는 번들 크기가 중요하거나 보일러플레이트를 최소화하고 싶은 프로젝트에 더 잘 맞습니다.
// stores/counterStore.ts
import { createStore } from 'zustand'
import { persist } from 'zustand/middleware'
interface CounterState {
count: number
increment: () => void
}
export const createCounterStore = (initCount = 0) =>
createStore<CounterState>()(
persist(
(set) => ({
count: initCount,
increment: () => set((s) => ({ count: s.count + 1 })),
}),
{
name: 'counter-storage',
skipHydration: true, // 서버에서 localStorage 접근 차단
}
)
)
export type CounterStore = ReturnType<typeof createCounterStore>create() 대신 createStore()를 쓰는 것이 중요합니다. create()는 모듈 레벨 싱글톤 훅을 만들어 SSR에서 요청 간 상태가 공유되지만, createStore()는 순수 스토어 인스턴스를 반환해 Context를 통한 격리가 가능합니다.
// components/CounterStoreProvider.tsx
'use client'
import { createContext, useContext, useRef, useEffect } from 'react'
import { useStore } from 'zustand'
import { createCounterStore, CounterStore } from '@/stores/counterStore'
const CounterStoreContext = createContext<CounterStore | null>(null)
export function CounterStoreProvider({
initialCount,
children,
}: {
initialCount: number
children: React.ReactNode
}) {
const storeRef = useRef<CounterStore | null>(null)
if (!storeRef.current) {
storeRef.current = createCounterStore(initialCount)
}
useEffect(() => {
// 클라이언트 마운트 이후에만 localStorage 수화 실행
storeRef.current?.persist.rehydrate()
}, [])
return (
<CounterStoreContext.Provider value={storeRef.current}>
{children}
</CounterStoreContext.Provider>
)
}
export function useCounterStore<T>(
selector: (state: ReturnType<CounterStore['getState']>) => T
) {
const store = useContext(CounterStoreContext)
if (!store) throw new Error('CounterStoreProvider 안에서 사용해야 합니다')
return useStore(store, selector)
}셀렉터 타입을 ReturnType<CounterStore['getState']>로 쓰면 CounterStore extends { getState: () => infer S } ? S : never 같은 복잡한 조건부 타입 없이 동일한 결과를 얻을 수 있습니다.
예시 3: useSyncExternalStore로 Hydration Mismatch 원천 차단
skipHydration으로도 해결하기 어려운 경우, useSyncExternalStore를 직접 활용하는 방법이 있습니다. 직접 써보니 서버/클라이언트 초기값 불일치를 코드 수준에서 명확하게 표현할 수 있어서, Hydration Error 원인을 추적하는 데 훨씬 수월했습니다.
// hooks/useCountOnClient.ts
import { useSyncExternalStore } from 'react'
import { CounterStore } from '@/stores/counterStore'
export function useCountOnClient(store: CounterStore) {
return useSyncExternalStore(
store.subscribe,
() => store.getState().count, // 클라이언트에서 읽을 값
() => 0 // 서버 SSR + 클라이언트 hydration 단계 fallback
)
}
useSyncExternalStore세 번째 인자getServerSnapshot: 서버 렌더링과 클라이언트의 hydration 단계 양쪽 모두에서 이 값이 사용됩니다. hydration이 완료된 이후 재렌더링부터는 두 번째 인자getSnapshot으로 전환됩니다. 즉, 이 값이 서버 초기 상태와 다르면 hydration 단계에서 여전히 Hydration Error가 발생합니다.
장단점 분석
라이브러리별 특성 비교
| 항목 | Redux (RTK) | Zustand |
|---|---|---|
| SSR 안전성 | serializabilityMiddleware로 비직렬화 값 런타임 감지 |
createStore 팩토리로 요청 격리 간단 구현 |
| 디버깅 | Redux DevTools 시간 여행 디버깅 | DevTools 미들웨어 지원, 설정 간단 |
| 번들 크기 | RTK 포함 약 40KB+ | ~1KB (미들웨어 제외) |
| 보일러플레이트 | Slice + Provider 구조 필요 | 스토어 한 파일로 완결 |
| 서버 상태 통합 | RTK Query로 캐싱/재수화 일원화 | 별도 라이브러리 조합 필요 |
| 직렬화 강제 | serializabilityMiddleware가 런타임 경고 |
강제하지 않음, 자유도 높음 |
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| Redux — 비직렬화 값 | Date, Map, Set 스토어 직접 저장 불가 |
스토어엔 ISO 문자열 저장, 셀렉터에서 Date로 변환 |
| Redux — 미들웨어 비활성화 | serializabilityMiddleware 전역 비활성화 시 SSR 안전성 붕괴 |
특정 액션 타입만 예외 처리, 전역 비활성화 금지 |
| Zustand — 싱글톤 위험 | 전역 create() 사용 시 요청 간 상태 누출 |
createStore 팩토리 + Context 패턴 사용 |
| Zustand — persist Mismatch | localStorage 초기값과 서버 초기값 불일치 |
skipHydration: true + useEffect에서 rehydrate() 호출 |
| 공통 — XSS | superjson이 HTML 이스케이프를 지원하지 않음 |
외부 입력 포함 시 devalue 또는 serialize-javascript 사용 |
| 공통 — RSC 제한 | React Server Components에서 스토어 직접 읽기/쓰기 불가 | 서버 데이터는 prop으로 내려보내 StoreProvider에서 초기화 |
superjson의 XSS 위험에 대해 좀 더 짚어볼 필요가 있습니다. superjson은 값 자체를 HTML 이스케이프하지 않습니다. 직렬화된 JSON이 <script> 태그 안에 인라인으로 삽입될 때, 사용자 입력에 </script>가 포함되어 있으면 스크립트 컨텍스트를 탈출해 임의의 HTML을 주입할 수 있습니다. 사용자가 제출한 데이터나 외부 API 응답을 그대로 직렬화해 HTML에 심는 경우라면 devalue나 serialize-javascript처럼 이스케이프를 지원하는 라이브러리를 쓰는 게 안전합니다.
실무에서 가장 흔한 실수
-
create()대신createStore()를 써야 하는 상황에create()사용 — Zustand에서 Context 기반 스토어를 만들 때는createStore를 씁니다.create()는 모듈 레벨 싱글톤이 되어 SSR에서 요청 간 상태가 공유됩니다. -
serializabilityMiddleware전역 비활성화 — Redux에서Date객체를 스토어에 넣고 싶어서 미들웨어를 통째로 끄는 경우가 있습니다. 이렇게 하면 직렬화 경계 전체가 무방비 상태가 됩니다. 특정 액션 타입이나 경로만 예외 처리하거나, 스토어에는 문자열만 저장하는 방향이 낫습니다. -
persist미들웨어 +skipHydration없이 SSR 적용 —persist는 브라우저의localStorage에서 상태를 읽어 초기화합니다. 서버에는localStorage가 없으므로 서버 초기값과 클라이언트 초기값이 달라져 Hydration Error가 발생합니다.skipHydration: true를 설정하고 마운트 이후 수동으로rehydrate()를 호출하는 패턴을 씁니다.
마치며
지금 당장 코드베이스에서 확인할 수 있는 것들이 있습니다. 아래 순서대로 점검해보시면 대부분의 Hydration Error 원인을 찾을 수 있습니다.
-
스토어 생성 방식 확인 — 모듈 최상위에
const store = makeStore()형태의 싱글톤이 있다면,export const makeStore = (preloadedState?) => configureStore({...})팩토리 패턴으로 전환하고StoreProvider에서useRef로 초기화하는 구조로 바꿉니다. -
직렬화 경계 감사 — 스토어에
Date,Map,Set,BigInt, 함수, 클래스 인스턴스가 저장되어 있는지 확인합니다. Redux라면serializabilityMiddleware경고를 개발 환경에서 켜두고, Zustand라면 SSR 초기값과localStorage값이 일치하는지 비교합니다. -
수정 후 프로덕션 빌드로 검증 —
next dev대신next build && next start로 프로덕션 빌드를 로컬에서 실행하면 Hydration Error가 더 정확하게 재현됩니다.useSyncExternalStore의 세 번째 인자(getServerSnapshot)를 서버 초기값과 일치시키는 것만으로도 대부분의 Hydration Mismatch가 잡힙니다.
참고 자료
- Redux Toolkit Setup with Next.js — 공식 문서
- Server Side Rendering — RTK Query 공식 문서
- Serializability Middleware — Redux Toolkit 공식 문서
- Setup with Next.js — Zustand 공식 문서
- persist Middleware — Zustand 공식 문서
- Fixing React hydration errors when using Zustand persist with useSyncExternalStore — Medium
- Hydration in Redux: Why You're Likely Doing It Wrong — Medium/Devglyph
- Why Redux Doesn't Allow Non-Serializable Data — Medium
- superjson GitHub — flightcontrolhq/superjson
- Next.js Hydration Error 공식 문서
- NextJS and zustand Discussion #2326 — GitHub pmndrs/zustand