본문 바로가기
react

Next.js SSR과 Zustand 사용시 주의점

by 아촌 2025. 5. 17.

 Next.js는 React 애플리케이션을 구축하기 위한 강력한 프레임워크이며, 서버 사이드 렌더링(SSR) 기능을 통해 성능 및 SEO 이점을 제공합니다. 상태 관리 라이브러리인 Zustand는 간결하고 유연한 API로 많은 개발자에게 사랑받고 있습니다.
하지만 Next.js의 SSR 환경에서 Zustand를 사용할 때는 한 가지 중요한 주의사항이 있습니다. 바로 상태(Store)를 어떻게 관리하고 제공하느냐입니다. 특히 SSR의 특성 때문에 발생하는 문제를 해결하기 위해서는 특정 패턴을 따르는 것이 필수적입니다.
이 글에서는 Next.js SSR에서 Zustand 사용 시 주의해야 할 점과 그 해결책인 Provider 패턴의 중요성에 대해 알아보겠습니다.

 

1. Zustand란 무엇인가?

 
Zustand는 작고 빠르며 확장 가능한 상태 관리 솔루션입니다. Redux나 MobX에 비해 비교적 적은 보일러플레이트 코드로 전역 상태를 쉽게 생성하고 관리할 수 있도록 설계되었습니다.
일반적으로 Zustand 스토어는 create 함수를 사용하여 정의합니다.

 

// stores/counter-store.ts
import { create } from 'zustand';

interface CounterState {
  count: number;
  increment: () => void;
  decrement: () => void;
}

// 🚨 단순 전역으로 선언하는 방식 (SSR에서 문제 발생 가능)
const useCounterStore = create<CounterState>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
}));

export default useCounterStore;

 

그리고 컴포넌트에서는 훅처럼 가져와 사용합니다.

 

// components/CounterDisplay.tsx
import useCounterStore from '@/stores/counter-store';

const CounterDisplay = () => {
  const count = useCounterStore((state) => state.count);
  return <div>Count: {count}</div>;
};

이 방식은 클라이언트 사이드 렌더링(CSR) 환경에서는 대부분 문제없이 작동합니다. 하지만 Next.js의 SSR 환경에서는 심각한 문제가 발생할 수 있습니다.


2. Next.js SSR 환경에서의 문제점: 전역 상태 공유

Next.js 서버는 여러 사용자의 요청을 동일한 Node.js 프로세스 안에서 동시에 처리할 수 있습니다. 사용자의 요청이 들어올 때마다 서버는 해당 페이지의 React 컴포넌트를 렌더링 하여 초기 HTML을 생성합니다.
만약 위 예시처럼 create 함수로 생성된 스토어 인스턴스를 모듈 최상위 레벨(사실상 서버 프로세스 내 전역 스코프와 유사)에 정의하면, 이 하나의 스토어 인스턴스를 서버에서 처리되는 모든 요청이 공유하게 됩니다.


이것이 왜 문제일까요?

  • 데이터 혼합: 사용자 A의 요청을 처리하며 스토어에 저장한 데이터(예: 장바구니, 사용자 정보)가 사용자 B의 요청 처리 코드에서도 접근 가능하게 됩니다.
  • 데이터 유출: 결과적으로 사용자 A에게 보여져야 할 민감한 정보가 사용자 B에게 노출되거나, 사용자 간의 상태가 꼬이는 심각한 버그가 발생합니다.


"스토어는 요청들 간에 공유되어서는 안 된다 (the store should not be shared across requests)"는 원칙이 SSR 환경에서는 매우 중요하며, 모듈 최상위에 create()로 스토어를 정의하는 방식은 이 원칙을 위반합니다.


3. 해결책: 요청(Request)마다 독립적인 스토어 인스턴스 생성

 이 문제를 해결하기 위한 핵심 아이디어는 서버에서 각 요청이 들어올 때마다 해당 요청만을 위한 새로운 스토어 인스턴스를 생성하고, 이 인스턴스를 해당 요청의 라이프사이클 동안만 사용하도록 하는 것입니다.


 React 환경에서 이러한 "스코프된(scoped)" 인스턴스를 하위 컴포넌트에 전달하는 가장 일반적인 방법은 Context API와 Provider 패턴을 사용하는 것입니다.


 Zustand 공식 문서에서 제시하는 Next.js SSR 환경에서의 권장 패턴이 바로 Provider를 사용하여 스토어 인스턴스를 관리하는 방식입니다.

 

4. Provider 패턴으로 Zustand 스토어 제공하기

// src/providers/counter-store-provider.tsx
'use client'

import { type ReactNode, createContext, useRef, 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) => {
  // useRef를 사용하여 스토어 인스턴스를 저장. 리렌더링 되어도 값 유지
  const storeRef = useRef<CounterStoreApi | null>(null)

  // storeRef.current가 null이면 (아직 스토어가 생성되지 않았으면)
  if (storeRef.current === null) {
    // 스토어 생성 '함수'를 호출하여 새로운 스토어 인스턴스를 생성
    storeRef.current = createCounterStore()
    // 참고: 여기서 createCounterStore는 위 1번 예시의 'const useCounterStore = create...'가 아니라,
    //      'export const createCounterStore = () => create<CounterState>(...)' 와 같이 스토어를 반환하는 함수여야 합니다.
  }

  // 생성된 스토어 인스턴스를 Context.Provider의 value로 제공
  return (
    <CounterStoreContext.Provider value={storeRef.current}>
      {children}
    </CounterStoreContext.Provider>
  )
}

// 하위 컴포넌트에서 스토어를 쉽게 사용하기 위한 커스텀 훅
export const useCounterStore = <T,>(
  selector: (store: CounterStore) => T,
): T => {
  // Context에서 스토어 인스턴스를 가져옴
  const counterStoreContext = useContext(CounterStoreContext)

  // Provider 없이 훅이 사용되면 에러 발생
  if (!counterStoreContext) {
    throw new Error(`useCounterStore must be used within CounterStoreProvider`)
  }

  // Zustand의 useStore 훅을 사용하여 특정 스토어 인스턴스에서 상태를 가져옴
  return useStore(counterStoreContext, selector)
}

// stores/counter-store.ts (Provider 패턴을 위해 변경된 스토어 정의)
import { create, type StoreApi } from 'zustand';

interface CounterState {
  count: number;
  increment: () => void;
  decrement: () => void;
}

// 스토어 자체를 바로 export 하지 않고, 스토어를 생성하는 '함수'를 export 합니다.
export const createCounterStore = () => create<CounterState>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
}));

// 스토어 인스턴스의 타입도 함께 export 하면 편리합니다.
export type CounterStore = CounterState & StoreApi<CounterState>;

 

1. createCounterStore 함수

  • Zustand create 함수로 스토어를 직접 만드는 대신, 스토어 인스턴스를 반환하는 함수를 정의합니다. 이렇게 하면 이 함수가 호출될 때마다 독립적인 새로운 스토어 인스턴스가 생성됩니다.


2. CounterStoreProvider

  • Next.js 서버는 요청이 들어올 때마다 이 CounterStoreProvider 컴포넌트의 새로운 인스턴스를 렌더링 합니다.
  • Provider가 렌더링될 때 useRef는 초기값 null을 가지고 있습니다.
  • if (storeRef.current === null) 조건이 참이 되면서 createCounterStore() 함수가 호출되고, 요청만을 위한 독립적인 새 스토어 인스턴스가 생성되어 storeRef.current에 저장됩니다.
  • 생성된 스토어 인스턴스는 CounterStoreContext.Provider를 통해 해당 요청 처리 동안 렌더링되는 모든 하위 컴포넌트에 전달됩니다.


3. useRef의 역할 (클라이언트 리렌더링 안정성)

  • 이 Provider는 'use client' 컴포넌트이므로 클라이언트에서도 실행되고 리렌더링 될 수 있습니다.
  • useRef는 컴포넌트의 라이프사이클 동안 값을 유지하므로, Provider가 리렌더링되어도 storeRef.current는 이전에 생성된 스토어 인스턴스를 그대로 가지고 있습니다.
  • 결과적으로 createCounterStore()는 이 Provider 인스턴스가 마운트된 이후 딱 한 번만 호출되어 스토어 인스턴스를 생성하고, 이후의 리렌더링 시에는 이미 생성된 인스턴스를 재사용합니다. 이것이 "re-render-safe"하다는 의미입니다.

 

4. useCounterStore 훅

 하위 컴포넌트는 이 훅을 사용하여 Context로부터 현재 요청/Provider 인스턴스에 해당하는 스토어를 안전하게 가져와 사용합니다.

 

결론적으로, 이 Provider 패턴은 Next.js SSR 환경에서

서버에서는 요청당 Provider 인스턴스가 새로 생성되고 그 안에서 요청당 스토어 인스턴스가 한 번 생성되어 데이터 고립성을 보장하고, 클라이언트에서는 Hydration 이후 해당 Provider 인스턴스 아래에서 동일한 스토어 인스턴스가 유지되어 클라이언트 상태 관리의 일관성을 유지합니다.


5. 추가 고려사항

루트 레이아웃에 Provider 배치: 일반적으로 app/layout.tsx 파일('use client' 컴포넌트로 분리하여) 또는 _app.tsx 파일에 Provider를 배치하여 앱 전체에서 스토어를 사용할 수 있도록 합니다.
여러 스토어: 여러 개의 독립적인 스토어가 필요하다면, 각각의 스토어에 대한 createStore 함수와 그에 대응하는 Provider를 만드는 것이 좋습니다. 하나의 거대한 스토어보다 관심사별로 분리하는 것이 관리하기 용이합니다.
클라이언트 전용 상태: 만약 스토어가 순수하게 UI 상태만을 관리하며 서버 렌더링 시점에 전혀 필요 없는 데이터(예: 모달 열림/닫힘 상태)를 다룬다면, 엄격하게 요청별 인스턴스가 필요 없을 수도 있습니다. 하지만 일관된 패턴 적용과 향후 요구사항 변화에 대비하여 Provider 패턴을 사용하는 것이 권장됩니다.

 


결론


Next.js의 강력한 SSR 기능을 Zustand와 함께 사용할 때, 단순히 create 함수로 스토어를 정의하고 바로 사용하는 것은 여러 사용자의 상태가 혼합되는 심각한 문제가 발생할 수 있습니다.
이를 방지하기 위해 React Context와 Provider 패턴을 사용하여 요청이 들어올 때마다 독립적인 스토어 인스턴스를 생성하고 제공해야 합니다. 
Next.js SSR 환경에서 Zustand를 사용한다면, 반드시 이 Provider 패턴의 필요성과 구현 방법을 잘 숙지하여 적용하시길 바랍니다.