Zustand와 Next.js, 안전한 데이터 동기화(Hydration)

2026. 1. 4. 22:19STUDY

반응형

Next.js의 서버 사이드 렌더링(SSR) 환경으로 넘어오면 한 가지 고민이 생깁니다.
"서버에서 받아온 데이터를 어떻게 클라이언트의 Zustand 스토어에 안전하게 전달할 것인가?"
useEffect로 넣기엔 깜빡임(Layout Shift)이 발생하고, 전역 변수로 관리하기엔 서버에서 여러 사용자의 상태가 뒤섞일 위험이 있습니다. 오늘은 이 문제를 해결하는 Hydration 전략을 알아봅니다.

 

✓ 하이드레이션(Hydration)
서버가 보낸 정적인 HTML 위에 자바스크립트가 실행되면서 동적인 기능(이벤트 리스너, 상태 관리 등)이 결합되는 과정

- CSR (React) 처음에 빈 화면(<body></body>)만 받음 → 자바스크립트가 다 로드되어야 화면이 그려진다.
- SSR (Next.js) 서버가 미리 HTML을 다 그려서 보냄 → 사용자는 바로 화면을 볼 수 있지만, 버튼을 눌러도 반응이 없는 '정지 화면' 상태
- Hydration 브라우저가 자바스크립트를 다운로드한 뒤, 서버가 만든 HTML 구조와 자바스크립트 로직을 서로 맞춰보고 연결 → 이때부터 버튼이 클릭되고 스토어가 작동합니다.

Zustand와 Hydration의 관계
(e.g. 유저 정보를 가져오는 상황)
서버가 DB에서 유저 이름 "홍길동"을 가져와서 HTML에 적어서 보냄
그런데 클라이언트의 Zustand 스토어는 초기값이 user: null
하이드레이션 에러
자바스크립트가 실행되면서 "어? 서버는 '홍길동'이라는데, 내(Zustand) 데이터는 왜 null이지?"라며 충돌 Hydration Mismatch 에러
서버가 가진 데이터를 클라이언트 스토어에 미리 주입(...initState)해서, 자바스크립트가 실행될 때 서버와 똑같은 데이터를 갖게 만드는 것입니다. 이것이 우리가 위에서 구현하려던 방식의 핵심입니다.

 

1) Zustand 스토어 작성 (stores/use-user-store.ts)

import { createStore } from 'zustand';

interface UserState {
  name: string;
  isLoggedIn: boolean;
}

interface UserActions {
  setName: (name: string) => void;
  setLogin: (status: boolean) => void;
}

export type UserStore = UserState & UserActions;

export const createUserStore = (initState: UserState) => {
  return createStore<UserStore>((set) => ({
    // 상태 주입: 서버에서 받아온 값들을 먼저 쫙 펼쳐놓습니다 (name, isLoggedIn 등)
    ...initState,
    //액션 정의: 상태를 변경하는 함수들은 고정적으로 정의
    setName: (name) => set({ name }),
    setLogin: (status) => set({ isLoggedIn: status }),
  }));
};

 

...initState (Spread Operator)를 사용하는 이유
1) 서버 데이터를 스토어의 "초기 상태"로 주입하기 위해서 (Hydration)
이 함수의 목적은 서버에서 받아온 데이터(initState)를 기반으로 새로운 스토어를 만드는 것
• initState에는 서버에서 fetch한 유저의 이름, 로그인 여부 등이 담겨 있다
• ...initState를 작성함으로써, 서버에서 가져온 값들을 Zustand 스토어의 기본 상태 값으로 복사해 넣는 것
• 만약 이 코드가 없다면, 스토어는 텅 빈 상태로 시작하거나 수동으로 하나하나 name: initState.name처럼 매핑
2) 상태(State)와 액션(Action)의 분리 보존
이렇게 작성하면 서버에서 어떤 초기값을 내려주든 상관없이, 스토어는 그 값을 가진 채로 생성되며 미리 정의된 setName 같은 함수들도 정상적으로 사용할 수 있게 된다.

 

2) StoreProvider를 만들어 서버에서 가져온 데이터를 클라이언트 스토어에 주입

Store Provider 작성 -- 서버 데이터를 클라이언트 전역 상태로 안전하게 넘겨주는 브릿지 역할

components/providers/store-provider.tsx

'use client';

import { createContext, useContext, useRef, ReactNode } from 'react';
import { useStore } from 'zustand';
import { createUserStore, UserStore } from '@/stores/use-user-store';

// Context 생성
export const UserStoreContext = createContext<ReturnType<typeof createUserStore> | null>(null);

export const UserStoreProvider = ({ children, initialData }: { 
  children: ReactNode;
  initialData: { name: string; isLoggedIn: boolean }; // 서버에서 받을 데이터 타입
}) => {
  // useRef를 사용하여 렌더링 시 스토어가 재생성되지 않도록 보장 (클라이언트 싱글톤)
  const storeRef = useRef<ReturnType<typeof createUserStore>>();
  
  if (!storeRef.current) {
    storeRef.current = createUserStore(initialData);
  }

  return (
    <UserStoreContext.Provider value={storeRef.current}>
      {children}
    </UserStoreContext.Provider>
  );
};

// 커스텀 훅으로 안전하게 접근
export const useUserStore = <T,>(selector: (store: UserStore) => T): T => {
  const context = useContext(UserStoreContext);
  if (!context) throw new Error('useUserStore must be used within UserStoreProvider');
  return useStore(context, selector);
};

 

✓createContext
createContext는 "우리 앱은 이 통로를 통해 각자 자기만의 Zustand 스토어를 전달받을 것이다"라고 선언하는 역할
- Isomorphism (서버-클라이언트 동일성) 서버에서 만든 스토어 인스턴스를 클라이언트가 그대로 이어받아야(Hydration) 화면 깜빡임이 없다. Context는 이 인스턴스를 전달하는 최적의 운반 수단
- 안전성: useRef와 Context를 조합하면, 클라이언트에서 페이지가 리렌더링되어도 스토어가 초기화되지 않고 유지
더보기

export const UserStoreContext = createContext<ReturnType<typeof createUserStore> | null>(null);

  • ReturnType<typeof createUserStore> TypeScript의 강력한 기능을 활용한 것
    typeof createUserStore: createUserStore라는 함수 그 자체의 타입을 가져옵니다.
  • ReturnType<...> "이 함수를 실행했을 때 최종적으로 반환되는 값의 타입이 뭐야?"라고 묻는 것입니다.
  • 결과: Zustand의 createStore가 반환하는 스토어 객체의 타입이 자동으로 추출됩니다. 개발자가 일일이 복잡한 타입을 손으로 적지 않아도 되므로 오타를 방지하고 유지보수가 쉬워집니다.
  • < ... | null > Context의 초기값은 보통 null로 설정합니다. 실제 스토어 객체는 서버에서 데이터를 받아온 후 Provider 단계에서 생성되어 채워지기 때문입니다.
  • (null) Context를 처음 생성할 때 비어있는 상태로 시작한다는 뜻입니다.

 

// 커스텀 훅으로 안전하게 접근 export const useUserStore = <T,>(selector: (store: UserStore) => T): T => { const context = useContext(UserStoreContext); if (!context) throw new Error('useUserStore must be used within UserStoreProvider'); return useStore(context, selector); };

 

3) 실전 사용 (React vs Next.js) 방법

- React.js (CSR 전용)
  보통 파일 상단에 스토어를 선언하고 어디서든 불러와 사용. 이는 클라이언트 메모리에서만 동작하므로 단순.

- Next.js (SSR + Hydration)
  서버에서 데이터를 Fetch한 뒤, StoreProvider에 담아 내려줍니다. 

// app/layout.tsx (Server Component)
export default async function RootLayout({ children }: { children: React.ReactNode }) {
  // 1. 서버에서 데이터 가져오기
  const userData = await fetchUserInfo(); 
  return (
    <html>
      <body>
        {/* 2. 클라이언트 스토어에 데이터 주입 */}
        <UserStoreProvider initialData={userData}>
          {children}
        </UserStoreProvider>
      </body>
    </html>
  );
}

 

 

요약: 왜 이렇게 하나요?

데이터 무결성 서버에서 렌더링된 HTML과 클라이언트의 첫 번째 상태가 일치해야 하이드레이션 에러가 발생하지 않느다.
보안(Isomorphic) 서버 환경에서 전역 변수로 스토어를 관리하면 모든 사용자가 동일한 스토어를 공유하게 되는 대참사가 발생할 수있는데, StoreProvider를 사용하면 요청마다 독립적인 스토어 인스턴스를 생성하므로 안전하다.
타입 안정성 interface와 useStore 셀렉터를 활용해 컴포넌트에서 필요한 데이터만 타입 추론을 받으며 꺼내 쓸 수 있다.

 

 

로딩관련스토어는 위와 다른 점이 무엇일까?

사용 목적과 환경(Next.js SSR vs 일반 Client Side)에 따라 접근 방식이 조금 다르다.

◼︎ useLoadingStore와 같은 로딩 스토어는 순수 클라이언트 상태

create를 사용해 전역적으로 하나만 관리하는 것이 좋음

export const useUserStore = create(...) 방식은 클라이언트 전용(CSR) 환경에 최적화

  useLoadingStore createUserStore
데이터 성격 UI 상태 (로딩 중인가?) 비즈니스 데이터 (사용자 정보, 게시글 등)
데이터 출처 클라이언트 브라우저에서 발생 서버(DB)에서 가져와서 내려줌
생성 방식 싱글톤 (create로 즉시 생성) 인스턴스 (createStore로 요청마다 생성)
Hydration 필요 없음 (항상 false로 시작해도 무관) 매우 중요 (서버와 클라이언트 값이 같아야 함)

 

◼︎ 모든 것을 Context로 관리할 필요다
Global UI State (로딩, 모달, 다크모드): 기존 방식대로 create를 사용해 클라이언트 전역에서 공유
Server-Synced State (유저 정보, 게시글 데이터): 서버 데이터를 안전하게 전달받기 위해 createStore와 Provider 구조를 사용하여 Hydration을 처리

 


 

💡 마무리하며..

처음에는 React에서 사용하던 방식 그대로 create와 useEffect를 조합해 상태를 업데이트했습니다. "코드가 잘 동작하니까 문제없다"고 생각했는데, 프로젝트 규모가 커질수록 UX와 서비스 완성도에 대한 고민이 깊어졌습니다.
특히 페이지가 로드될 때 유저 정보가 뒤늦게 채워지며 UI가 덜컥거리는 현상은 볼 때마다 아쉬웠고, 이를 억지로 끼워 맞추려다 보니 코드는 점점 더 복잡해지고 있었습니다.
이번에 AI와 함께 코드 리뷰를 진행하고 스터디를 거치며, 데이터의 성격에 따라 관리 전략을 완전히 분리해야 한다는 점을 깨달았습니다.

덕분에 불필요한 코드를 걷어내고 훨씬 견고한 구조로 리팩토링할 수 있었습니다.


💻 공식문서 확인

 

Introduction - Zustand

How to use Zustand

zustand.docs.pmnd.rs

 

 

지난 글

 

상태 관리 라이브러리 -- Zustand

Zustand란 무엇인가?Zustand는 발행/구독(Pub/Sub) 모델을 기반으로 하는 작고 빠르며 확장 가능한 상태 관리 라이브러리입니다.Flux 패턴을 따르되, 보일러플레이트(반복되는 코드)를 최소화하여 개발

radan.tistory.com

 

 

 

 

반응형