Next.js 14 Zustand 상태 관리와 Hydration 에러 완벽 해결
[Troubleshooting] Next.js 14 Zustand 상태 관리와 Hydration 에러 완벽 해결
"Next.js 14 App Router 환경에서 전역 상태 관리를 위해 가벼운 Zustand를 도입했습니다. 그런데 스토어에 저장된 값을 화면에 렌더링하려고만 하면 Text content does not match server-rendered HTML이라는 무시무시한 Hydration 에러가 쏟아집니다."
클라이언트 사이드 렌더링(CSR)에 익숙했던 프론트엔드 개발자가 Next.js의 서버 사이드 렌더링(SSR) 환경으로 넘어올 때 가장 뼈아프게 겪는 상태 관리의 딜레마입니다. Zustand는 훌륭하고 가벼운 도구지만, React 외부에서 상태를 관리하기 때문에 Next.js의 렌더링 생명주기와 엇박자가 나기 쉽습니다.
초보자들은 이 에러를 피하기 위해 전역 상태를 무조건 useEffect 안으로 밀어 넣거나 강제로 렌더링을 지연시킵니다. 하지만 이는 화면 깜빡임(Flickering)을 유발하여 사용자 경험(UX)을 심각하게 훼손합니다. 본 가이드에서는 서버와 클라이언트 간의 상태 불일치를 해체하고, Zustand와 Context API를 결합하여 안전하게 상태를 주입하는 프론트엔드 아키텍처를 단호하게 제시합니다.
📌 이 글의 핵심 포인트
- Hydration 불일치의 본질: 서버에서 생성된 초기 상태와 브라우저 로컬 스토리지 등에 의해 변경된 클라이언트 상태의 시공간 충돌
- 안티 패턴 방어: 글로벌 스토어 인스턴스가 서버 런타임 간에 공유되어 발생하는 메모리 누수 및 교차 오염 위험성
- 아키텍처 혁신:
Context API를 Provider 껍데기로 사용하여 Zustand 스토어를 컴포넌트 트리 내부에 안전하게 격리하는 방법
1단계: 핵심 원인 분석 - 싱글톤(Singleton) 스토어의 반역
일반적인 React(CRA/Vite)에서는 create() 함수로 스토어를 만들면 브라우저 메모리에 하나만 존재하므로 안전합니다. 하지만 Next.js 서버 환경에서는 이 글로벌 스토어가 모든 사용자의 요청(Request) 간에 공유되어 버립니다. A 유저가 로그인했는데 B 유저의 화면에 A 유저의 정보가 렌더링되는 대참사가 발생할 수 있습니다.
또한, 서버는 초기 스토어 값(예: 0)으로 HTML을 그렸는데 브라우저가 스토어를 읽어보니 값이 변경되어 있다면(예: 1), 텍스트 불일치로 인해 하이드레이션 에러가 폭발하게 됩니다.
2단계: 완벽한 트러블슈팅 - 스토어 팩토리(Factory) 패턴 도입
서버 렌더링 환경에서는 스토어를 전역 변수로 만들지 말고, 요청이 들어올 때마다 새로운 스토어를 찍어내는 팩토리 함수를 만들어야 합니다.
import { createStore } from 'zustand';
// ❌ 기존 안티 패턴: 전역 스토어 생성 (Next.js 서버에서 공유됨)
// export const useStore = create((set) => ({ count: 0 }));
// ✅ 완벽한 아키텍처: 스토어를 생성하는 팩토리 함수만 선언
export const createCounterStore = (initState = 0) => {
return createStore((set) => ({
count: initState,
inc: () => set((state) => ({ count: state.count + 1 })),
}));
};
3단계: Context API를 활용한 안전한 스토어 주입
이제 찍어낸 스토어 인스턴스를 React 컴포넌트 트리에 안전하게 공급하기 위해, React의 Context API를 껍데기(Provider)로 사용합니다.
'use client';
import { createContext, useRef, useContext } from 'react';
import { useStore } from 'zustand';
import { createCounterStore } from './store';
const CounterContext = createContext(null);
export function CounterProvider({ children }) {
const storeRef = useRef();
// 컴포넌트가 처음 렌더링될 때 딱 한 번만 스토어를 생성
if (!storeRef.current) {
storeRef.current = createCounterStore(0);
}
return (
<CounterContext.Provider value={storeRef.current}>
{children}
</CounterContext.Provider>
);
}
// 하위 컴포넌트에서 안전하게 Zustand 상태를 꺼내 쓰는 커스텀 훅
export function useCounterStore(selector) {
const store = useContext(CounterContext);
if (!store) throw new Error('Missing CounterProvider');
return useStore(store, selector);
}
🙋♂️ 자주 묻는 질문 (FAQ)
Q. 이렇게 복잡하게 짤 바에는 그냥 Redux를 쓰는 게 낫지 않나요?
A. Redux Toolkit도 훌륭한 대안이지만 설정 보일러플레이트가 훨씬 무겁습니다. Zustand에 Context를 결합하는 패턴은 초기 세팅만 한 번 해두면 Redux보다 훨씬 가볍고 직관적으로 상태를 쪼개어(Slice) 관리할 수 있는 최적의 타협점입니다.
💡 핵심 정리 및 마무리
서버 컴포넌트(RSC) 시대의 프론트엔드는 단순한 화면 그리기를 넘어 서버 인프라의 메모리 주기까지 고려해야 하는 고도의 엔지니어링 영역입니다. 글로벌 상태의 위험성을 직시하고, Context를 통한 스토어 격리 아키텍처를 도입하여 하이드레이션 에러와 데이터 오염이 0%로 수렴하는 무결점 웹 애플리케이션을 완성하십시오.
댓글
댓글 쓰기