Zustand로 클라이언트 상태 관리 — 서버 컴포넌트 시대의 전역 상태 패턴

프론트엔드

Zustand상태 관리React Server ComponentsNext.js프론트엔드

이 글은 누구를 위한 것인가

  • Next.js App Router로 이전했는데 전역 상태를 어디 두어야 할지 모르는 개발자
  • Redux를 쓰다가 보일러플레이트에 지쳐 더 가벼운 대안을 찾는 팀
  • Zustand를 쓰고 있지만 스토어가 점점 커지면서 구조화가 필요한 분

RSC 시대에 클라이언트 상태는 어떻게 달라지나

RSC 시대 서버 상태 vs 클라이언트 상태 분리

React Server Components가 도입되면서 상태 관리의 역할이 재정의됐다.

Before RSC (모든 것이 클라이언트):
  전역 상태 ──→ 서버 데이터 + UI 상태 + 폼 상태 모두 포함

After RSC:
  서버 데이터 ──→ Server Components가 직접 fetch (useState/useEffect 불필요)
  클라이언트 상태 ──→ UI 인터랙션, 사용자 세션, 임시 상태만 남음

즉, 좋은 RSC 설계는 클라이언트 상태를 최소화하는 것이다. 그 최소화된 클라이언트 상태를 관리하는 데 Zustand가 적합하다.


왜 Zustand인가

기준ReduxZustandJotaiRecoil
보일러플레이트많음적음매우 적음적음
번들 크기~7KB~1KB~3KB~21KB
비동기 처리미들웨어스토어 내장간단비교적 복잡
DevTools완성도 높음지원지원지원
RSC 호환성⚠ 주의 필요
학습 곡선높음낮음낮음중간

Zustand는 작은 번들, 낮은 학습 곡선, Context 없이도 동작하는 구조가 RSC 환경에 잘 맞는다.


1. 기본 스토어 설계

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

interface UIState {
  isSidebarOpen: boolean;
  toasts: Toast[];
  theme: 'light' | 'dark' | 'system';
  
  // Actions
  toggleSidebar: () => void;
  addToast: (toast: Omit<Toast, 'id'>) => void;
  removeToast: (id: string) => void;
  setTheme: (theme: UIState['theme']) => void;
}

export const useUIStore = create<UIState>((set, get) => ({
  isSidebarOpen: false,
  toasts: [],
  theme: 'system',
  
  toggleSidebar: () =>
    set((state) => ({ isSidebarOpen: !state.isSidebarOpen })),
  
  addToast: (toast) =>
    set((state) => ({
      toasts: [...state.toasts, { ...toast, id: crypto.randomUUID() }],
    })),
  
  removeToast: (id) =>
    set((state) => ({
      toasts: state.toasts.filter((t) => t.id !== id),
    })),
  
  setTheme: (theme) => set({ theme }),
}));

불필요한 리렌더링 방지

Zustand는 기본적으로 **얕은 비교(shallow equality)**로 구독한다.

// ❌ 매번 새 객체를 반환 → 불필요한 리렌더링
const { isSidebarOpen, theme } = useUIStore();

// ✅ shallow 비교 사용 → isSidebarOpen이나 theme 중 하나만 바뀔 때만 리렌더링
import { useShallow } from 'zustand/react/shallow';

const { isSidebarOpen, theme } = useUIStore(
  useShallow((state) => ({ isSidebarOpen: state.isSidebarOpen, theme: state.theme }))
);

// ✅ 단일 값 구독 (가장 최적)
const isSidebarOpen = useUIStore((state) => state.isSidebarOpen);

2. Slice 패턴으로 스토어 구조화

Zustand Slice 패턴 루트 스토어 구조

스토어가 커지면 Slice 패턴으로 도메인별로 나눈다.

// stores/slices/cart-slice.ts
import type { StateCreator } from 'zustand';

export interface CartSlice {
  items: CartItem[];
  addItem: (item: CartItem) => void;
  removeItem: (id: string) => void;
  clearCart: () => void;
  getTotalPrice: () => number;
}

export const createCartSlice: StateCreator<
  CartSlice & UserSlice,  // 전체 스토어 타입 (다른 슬라이스 접근 가능)
  [],
  [],
  CartSlice
> = (set, get) => ({
  items: [],
  
  addItem: (item) =>
    set((state) => {
      const existing = state.items.find((i) => i.id === item.id);
      if (existing) {
        return {
          items: state.items.map((i) =>
            i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
          ),
        };
      }
      return { items: [...state.items, { ...item, quantity: 1 }] };
    }),
  
  removeItem: (id) =>
    set((state) => ({ items: state.items.filter((i) => i.id !== id) })),
  
  clearCart: () => set({ items: [] }),
  
  getTotalPrice: () =>
    get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
});
// stores/slices/user-slice.ts
export interface UserSlice {
  user: User | null;
  isAuthenticated: boolean;
  setUser: (user: User | null) => void;
}

export const createUserSlice: StateCreator<
  CartSlice & UserSlice,
  [],
  [],
  UserSlice
> = (set) => ({
  user: null,
  isAuthenticated: false,
  
  setUser: (user) => set({ user, isAuthenticated: !!user }),
});
// stores/root-store.ts
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';
import { createCartSlice, CartSlice } from './slices/cart-slice';
import { createUserSlice, UserSlice } from './slices/user-slice';

type RootStore = CartSlice & UserSlice;

export const useStore = create<RootStore>()(
  devtools(
    persist(
      (...args) => ({
        ...createCartSlice(...args),
        ...createUserSlice(...args),
      }),
      {
        name: 'app-storage',
        partialize: (state) => ({
          // cart만 localStorage에 저장 (user는 제외 — 세션으로 관리)
          items: state.items,
        }),
      }
    ),
    { name: 'AppStore' }
  )
);

// 슬라이스별 편의 훅
export const useCart = () => useStore(
  useShallow((s) => ({
    items: s.items,
    addItem: s.addItem,
    removeItem: s.removeItem,
    clearCart: s.clearCart,
    totalPrice: s.getTotalPrice(),
  }))
);

export const useCurrentUser = () => useStore((s) => s.user);

3. RSC + Zustand: SSR 초기화 패턴

RSC와 함께 쓸 때 가장 흔한 문제는 서버에서 가져온 데이터를 Zustand 스토어에 어떻게 초기화하는가다.

잘못된 방법: 전역 스토어 직접 초기화

// ❌ 서버에서 호출되면 모든 요청이 같은 스토어를 공유
useStore.setState({ user: serverUser });

올바른 방법: Context + 인스턴스 분리

요청별로 독립된 스토어 인스턴스를 만들어야 한다.

// stores/user-store-context.tsx
'use client';

import { createContext, useContext, useRef } from 'react';
import { create, useStore as useZustandStore } from 'zustand';

interface UserStoreState {
  user: User | null;
  setUser: (user: User | null) => void;
}

function createUserStore(initialUser: User | null) {
  return create<UserStoreState>((set) => ({
    user: initialUser,
    setUser: (user) => set({ user }),
  }));
}

type UserStoreApi = ReturnType<typeof createUserStore>;

const UserStoreContext = createContext<UserStoreApi | null>(null);

interface UserStoreProviderProps {
  children: React.ReactNode;
  initialUser: User | null;   // 서버에서 전달
}

export function UserStoreProvider({ children, initialUser }: UserStoreProviderProps) {
  // useRef로 인스턴스 재생성 방지
  const storeRef = useRef<UserStoreApi>();
  if (!storeRef.current) {
    storeRef.current = createUserStore(initialUser);
  }
  
  return (
    <UserStoreContext.Provider value={storeRef.current}>
      {children}
    </UserStoreContext.Provider>
  );
}

export function useUserStore<T>(selector: (state: UserStoreState) => T): T {
  const store = useContext(UserStoreContext);
  if (!store) throw new Error('UserStoreProvider not found');
  return useZustandStore(store, selector);
}
// app/layout.tsx (Server Component)
import { UserStoreProvider } from '@/stores/user-store-context';

export default async function RootLayout({ children }: { children: React.ReactNode }) {
  const user = await getCurrentUser();  // 서버에서 세션 조회
  
  return (
    <html>
      <body>
        <UserStoreProvider initialUser={user}>
          {children}
        </UserStoreProvider>
      </body>
    </html>
  );
}
// components/Header.tsx (Client Component)
'use client';
import { useUserStore } from '@/stores/user-store-context';

export function Header() {
  const user = useUserStore((s) => s.user);
  
  return <nav>{user ? `안녕하세요, ${user.name}` : '로그인'}</nav>;
}

4. 미들웨어 활용

devtools — Redux DevTools 연동

import { devtools } from 'zustand/middleware';

const useStore = create<State>()(
  devtools(
    (set) => ({ ... }),
    { name: 'MyStore', enabled: process.env.NODE_ENV === 'development' }
  )
);

persist — localStorage/sessionStorage 동기화

import { persist, createJSONStorage } from 'zustand/middleware';

const useThemeStore = create<ThemeState>()(
  persist(
    (set) => ({
      theme: 'system',
      setTheme: (theme) => set({ theme }),
    }),
    {
      name: 'theme-preference',
      storage: createJSONStorage(() => localStorage),
    }
  )
);

immer — 불변 업데이트 간소화

import { immer } from 'zustand/middleware/immer';

const useCartStore = create<CartState>()(
  immer((set) => ({
    items: [],
    
    // immer 없이
    // addItem: (item) => set((state) => ({ items: [...state.items, item] })),
    
    // immer 사용 (mutable하게 작성, 내부적으로 불변 처리)
    addItem: (item) =>
      set((state) => {
        state.items.push(item);
      }),
    
    updateQuantity: (id, quantity) =>
      set((state) => {
        const item = state.items.find((i) => i.id === id);
        if (item) item.quantity = quantity;
      }),
  }))
);

5. 비동기 액션

Zustand는 별도 미들웨어 없이 스토어 안에서 async 함수를 지원한다.

interface OrderState {
  orders: Order[];
  isLoading: boolean;
  error: string | null;
  fetchOrders: () => Promise<void>;
  createOrder: (items: CartItem[]) => Promise<Order>;
}

const useOrderStore = create<OrderState>((set, get) => ({
  orders: [],
  isLoading: false,
  error: null,
  
  fetchOrders: async () => {
    set({ isLoading: true, error: null });
    try {
      const orders = await orderApi.getMyOrders();
      set({ orders, isLoading: false });
    } catch (err) {
      set({ error: err instanceof Error ? err.message : '주문 조회 실패', isLoading: false });
    }
  },
  
  createOrder: async (items) => {
    set({ isLoading: true });
    try {
      const order = await orderApi.create({ items });
      set((state) => ({
        orders: [order, ...state.orders],
        isLoading: false,
      }));
      return order;
    } catch (err) {
      set({ error: '주문 생성 실패', isLoading: false });
      throw err;
    }
  },
}));

6. 언제 Zustand를 쓰고 언제 Server State를 쓰나

상태 종류도구예시
서버 데이터 (API 응답)React Query / SWR상품 목록, 주문 내역
URL 상태searchParams (Next.js)필터, 페이지 번호
폼 상태React Hook Form입력값, 유효성 검사
UI 상태 (전역)Zustand사이드바 열림, 모달, 토스트
UI 상태 (로컬)useState드롭다운 열림, 호버 상태
서버 초기화 상태Zustand + Context세션 사용자, 설정

Zustand 사용 기준: 두 개 이상의 컴포넌트 트리에서 공유하고, 서버 데이터가 아닌 클라이언트 인터랙션 상태일 때.


마치며

RSC 시대에 Zustand의 역할은 Redux 시절보다 훨씬 좁아졌다 — 그리고 그게 좋은 방향이다. 서버 데이터는 Server Components가 처리하고, Zustand는 순수한 클라이언트 UI 상태에만 집중하면 된다.

Slice 패턴으로 스토어를 도메인별로 나누고, SSR 초기화가 필요한 상태는 Context 패턴으로 격리하면 Next.js App Router 환경에서 깔끔하게 동작하는 상태 관리 구조를 만들 수 있다.