본문으로 건너뛰기
최서희Frontend Engineer
← 블로그

Frontend

Redux보다 가볍고 Context보다 단순하게 — Zustand로 전역 상태를 관리

· 4분 읽기

안녕하세요, 이번 포스팅에서는 React 전역 상태 관리의 핵심 라이브러리인 Zustand를 정리해봤어요. React로 프로젝트를 하다 보면 한 번쯤 이런 고민을 하게 됩니다.

“props를 몇 번이나 내려보내고 있지?” “Context가 점점 늘어나고 있는데 괜찮을까?” “Redux는 너무 무겁고 설정이 복잡한데...”

저도 같은 고민 끝에 Zustand를 선택했습니다. 결제 페이지처럼 여러 단계의 상태를 관리해야 할 때, Zustand는 깔끔하고 단순한 해법을 제시해줬습니다.


1. Zustand를 선택한 이유

당시 진행 중이던 프로젝트는 인터파크 티켓 결제 리디자인이었습니다. 결제는 다음과 같은 단계로 구성되어 있었죠.

  1. 예매자 정보 입력
  2. 배송 방법 선택
  3. 결제 수단 선택

문제는 단계가 바뀔 때마다 이전 단계의 데이터를 유지해야 했다는 점입니다. Context API는 Provider가 중첩되고, Redux는 보일러플레이트가 너무 많았습니다. 그때 알게 된 것이 바로 Zustand입니다. 한 줄의 create()로 전역 상태를 만들 수 있는 가볍고 직관적인 라이브러리였어요.


2. Zustand의 기본 구조

Zustand의 핵심은 create() 함수입니다. store를 만들고, 훅처럼 구독해서 사용하는 방식이죠.

// src/store/useCounterStore.ts
import { create } from "zustand";

interface CounterState {
  count: number;
  increase: () => void;
  decrease: () => void;
  reset: () => void;
}

export const useCounterStore = create<CounterState>((set) => ({
  count: 0,
  increase: () => set((state) => ({ count: state.count + 1 })),
  decrease: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
}));

컴포넌트에서는 이렇게 바로 사용할 수 있습니다.

import { useCounterStore } from "@/store/useCounterStore";

export default function Counter() {
  const { count, increase, decrease, reset } = useCounterStore();
  return (
    <div>
      <p>현재 카운트: {count}</p>
      <button onClick={increase}>+1</button>
      <button onClick={decrease}>-1</button>
      <button onClick={reset}>리셋</button>
    </div>
  );
}

✅ Redux의 dispatch, action, reducer 없이 set() 한 줄로 상태를 갱신할 수 있습니다.


3. Zustand로 실무 패턴 다루기

3.1 비동기 API 관리

Zustand는 store 내부에서 async/await를 바로 사용할 수 있습니다. 로딩 상태, 에러 상태까지 함께 관리할 수 있죠.

import { create } from "zustand";
import axios from "axios";

interface UserState {
  user: string | null;
  isLoading: boolean;
  error: string | null;
  fetchUser: () => Promise<void>;
}

export const useUserStore = create<UserState>((set) => ({
  user: null,
  isLoading: false,
  error: null,
  fetchUser: async () => {
    set({ isLoading: true });
    try {
      const res = await axios.get("/api/user");
      set({ user: res.data.name, isLoading: false });
    } catch {
      set({ error: "유저 정보를 가져오지 못했습니다.", isLoading: false });
    }
  },
}));

3.2 렌더링 최적화 — shallow 비교

Zustand는 shallow 함수를 이용해 불필요한 리렌더링을 막을 수 있습니다. 상태가 커질수록 이 한 줄이 체감됩니다.

import { shallow } from "zustand/shallow";
import { useUserStore } from "@/store/useUserStore";

const [user, isLoading] = useUserStore(
  (state) => [state.user, state.isLoading],
  shallow
);

⚡ shallow는 “얕은 비교”를 통해 필요한 상태만 감지합니다. 변경된 데이터만 렌더링되므로 성능이 크게 향상됩니다.


3.3 LocalStorage에 상태 저장 — persist

사용자의 테마나 설정을 저장하려면 persist 미들웨어를 쓰면 됩니다.

import { create } from "zustand";
import { persist } from "zustand/middleware";

interface ThemeState {
  isDark: boolean;
  toggleTheme: () => void;
}

export const useThemeStore = create(
  persist<ThemeState>(
    (set) => ({
      isDark: false,
      toggleTheme: () => set((state) => ({ isDark: !state.isDark })),
    }),
    { name: "theme-storage" }
  )
);

💾 페이지를 새로고침해도 상태가 유지됩니다.


3.4 React Query와 병행하기

Zustand와 React Query는 완벽한 짝입니다. React Query는 서버 상태를, Zustand는 클라이언트 상태를 담당하도록 나누면 깔끔합니다.

// React Query - 서버 상태
import { useQuery } from "@tanstack/react-query";
import axios from "axios";

export const usePosts = () =>
  useQuery({
    queryKey: ["posts"],
    queryFn: () => axios.get("/api/posts").then((res) => res.data),
  });

// Zustand - 클라이언트 상태
import { create } from "zustand";

export const useTabStore = create((set) => ({
  selectedTab: "all",
  setTab: (tab) => set({ selectedTab: tab }),
}));

4. 결제 페이지에서 Zustand 활용하기

SOPT 36기 프로젝트에서는 Zustand를 사용해 결제 단계별 상태를 전역으로 관리했습니다.

import { create } from "zustand";

export const usePaymentStore = create((set) => ({
  ticketCount: 0,
  totalPrice: 0,
  deliveryMethod: "",
  userInfo: null,
  paymentMethod: "",
  setTicketCount: (count) => set({ ticketCount: count }),
  setTotalPrice: (price) => set({ totalPrice: price }),
  setDeliveryMethod: (method) => set({ deliveryMethod: method }),
  setUserInfo: (info) => set({ userInfo: info }),
  setPaymentMethod: (method) => set({ paymentMethod: method }),
  resetPayment: () =>
    set({
      ticketCount: 0,
      totalPrice: 0,
      deliveryMethod: "",
      userInfo: null,
      paymentMethod: "",
    }),
}));
  • Step1에서 입력한 예매자 정보
  • Step2에서 선택한 배송 방법
  • Step3에서 설정한 결제 수단

모두 하나의 store에서 관리합니다. 결제 완료 후에는 resetPayment()로 초기화하면 끝입니다.

✅ 덕분에 단계별 데이터 흐름이 단순하고 명확해졌습니다.


5. Zustand 사용할 때 주의할 점

항목 설명

<table style="border-collapse: collapse; width: 100%;" border="1" data-ke-align="alignLeft"><tbody><tr><td>1. Store 분리</td><td>기능별로 나누어 관리</td></tr><tr><td>2. 필요한 값만 구독</td><td>selector로 최소 단위로 접근</td></tr><tr><td>3. shallow 적극 활용</td><td>리렌더링 최소화</td></tr><tr><td>4. 비즈니스 로직 분리</td><td>API 호출은 service 모듈로 이동</td></tr></tbody></table>

Zustand는 간단하지만, 모든 상태를 한곳에 몰아넣으면 Redux보다 복잡해질 수도 있습니다. 작게 쪼개고, 필요한 것만 구독하는 습관이 중요합니다.


6. Zustand가 실무에서 사랑받는 이유

  1. Provider 불필요 → 구조가 단순하다.
  2. Redux보다 코드량이 3분의 1 수준이다.
  3. React Query와 찰떡궁합이다.
  4. 소규모부터 대규모 앱까지 유연하게 확장된다.
// Context API
<AuthProvider>
  <ThemeProvider>
    <CartProvider>
      <App />
    </CartProvider>
  </ThemeProvider>
</AuthProvider>

// Zustand
const { user } = useAuthStore();
const { theme } = useThemeStore();

댓글

GitHub 계정으로 댓글을 남길 수 있어요.