개인의 기록
  • 소개
  • 프로젝트
  • 글
  • 링크

© 2026 newgirok

← 글 목록

Zustand — 간결한 전역 상태 관리 라이브러리

2026년 6월 21일
React상태관리전역상태Zustand

Zustand는 Redux의 복잡한 보일러플레이트 없이 전역 상태를 간결하게 관리하는 라이브러리입니다. Provider 래퍼 없이 훅 하나로 어디서든 상태에 접근할 수 있으며, 불필요한 리렌더링을 막기 위한 셀렉터 패턴을 기본으로 지원합니다.

npm install zustand

1. Store

Store: 전역 상태(State)와 그것을 변경하는 함수(Action)를 하나의 객체로 묶어 관리하는 컨테이너

create 함수에 초기 상태와 액션을 담은 객체를 반환하는 콜백을 넘기면 커스텀 훅이 생성됩니다.

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

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

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

export default useCounterStore;

생성된 useCounterStore는 그냥 훅처럼 호출합니다. Provider 설정이 필요 없습니다.

function Counter() {
  const count = useCounterStore((state) => state.count);
  const increment = useCounterStore((state) => state.increment);

  return <button onClick={increment}>{count}</button>;
}

2. State

State는 create에 전달한 초기 객체에서 함수를 제외한 나머지 값입니다. 직접 변형하지 않고 반드시 set을 통해 새 값으로 교체합니다.

const useUserStore = create<{
  name: string;
  age: number;
  profile: { bio: string };
}>((set) => ({
  name: "홍길동",
  age: 30,
  profile: { bio: "개발자입니다." },

  updateName: (name: string) => set({ name }),

  // 중첩 객체 업데이트 — 스프레드로 병합
  updateBio: (bio: string) =>
    set((state) => ({
      profile: { ...state.profile, bio },
    })),
}));

set은 기본적으로 객체를 얕은 병합(shallow merge)합니다. 두 번째 인자로 true를 전달하면 전체 교체합니다.

// 얕은 병합 (기본)
set({ name: "이순신" }); // age는 유지됨

// 전체 교체
set({ name: "이순신", age: 40, profile: { bio: "" } }, true);

3. Action

Action은 Store 정의 안에 함수 형태로 선언합니다. set으로 상태를 변경하고, get으로 현재 상태를 읽습니다.

const useTodoStore = create<TodoState>((set, get) => ({
  todos: [] as Todo[],

  addTodo: (title: string) => {
    const newTodo: Todo = { id: Date.now(), title, done: false };
    set((state) => ({ todos: [...state.todos, newTodo] }));
  },

  toggleTodo: (id: number) => {
    set((state) => ({
      todos: state.todos.map((todo) =>
        todo.id === id ? { ...todo, done: !todo.done } : todo
      ),
    }));
  },

  removeDone: () => {
    // get()으로 현재 상태를 읽어 활용
    const activeTodos = get().todos.filter((todo) => !todo.done);
    set({ todos: activeTodos });
  },
}));

4. Selector

useStore(selector)처럼 셀렉터 함수를 넘기면, 해당 값이 변경될 때만 컴포넌트가 리렌더링됩니다. Store 전체를 구독하면 관련 없는 상태가 변경될 때도 리렌더링됩니다.

const useStore = create<{ count: number; name: string }>(() => ({
  count: 0,
  name: "홍길동",
}));

// count가 변경될 때만 리렌더링
function CountDisplay() {
  const count = useStore((state) => state.count);
  return <p>{count}</p>;
}

// name이 변경될 때만 리렌더링
function NameDisplay() {
  const name = useStore((state) => state.name);
  return <p>{name}</p>;
}

객체나 배열을 셀렉터로 반환할 때는 참조 동일성 비교를 주의해야 합니다.

import { useShallow } from "zustand/react/shallow";

// 잘못된 예: 매 렌더링마다 새 객체를 반환 → 항상 리렌더링 발생
const { count, name } = useStore((state) => ({ count: state.count, name: state.name }));

// 올바른 예: useShallow로 얕은 비교
const { count, name } = useStore(
  useShallow((state) => ({ count: state.count, name: state.name }))
);

5. Middleware

Middleware: Store 생성 함수를 감싸 기능을 추가하는 고차 함수, create(middleware(fn)) 형태로 사용

Zustand의 미들웨어는 create의 콜백 함수를 래핑합니다. 공식적으로 devtools, persist, immer 등을 제공합니다.

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

const useStore = create<State>()(
  devtools(
    persist(
      (set) => ({
        count: 0,
        increment: () => set((state) => ({ count: state.count + 1 })),
      }),
      { name: "counter-storage" }
    ),
    { name: "CounterStore" }
  )
);

미들웨어는 안쪽부터 바깥쪽 순으로 적용됩니다. 위 예시에서는 persist가 먼저 적용되고 devtools가 감쌉니다.


6. Persist

Persist: Store 상태를 localStorage 또는 sessionStorage에 자동으로 저장하고 복원하는 미들웨어

persist 미들웨어를 사용하면 페이지를 새로고침해도 상태가 유지됩니다.

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

const useAuthStore = create<AuthState>()(
  persist(
    (set) => ({
      token: null as string | null,
      user: null as User | null,
      setToken: (token: string) => set({ token }),
      logout: () => set({ token: null, user: null }),
    }),
    {
      name: "auth-storage",           // localStorage 키 이름
      storage: createJSONStorage(() => sessionStorage), // 기본값은 localStorage
      partialize: (state) => ({ token: state.token }), // 저장할 필드 선택
    }
  )
);

partialize로 저장할 필드를 제한하면, user처럼 민감하거나 크기가 큰 데이터는 스토리지에서 제외할 수 있습니다.


7. Devtools

Devtools: Redux DevTools 확장 프로그램과 연동하여 상태 변화 이력을 시각적으로 추적하는 미들웨어

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

const useStore = create<CounterState>()(
  devtools(
    (set) => ({
      count: 0,
      increment: () =>
        set(
          (state) => ({ count: state.count + 1 }),
          false,
          "counter/increment" // DevTools에 표시될 액션 이름
        ),
      decrement: () =>
        set(
          (state) => ({ count: state.count - 1 }),
          false,
          "counter/decrement"
        ),
    }),
    {
      name: "CounterStore", // DevTools에서 표시될 Store 이름
    }
  )
);

set의 세 번째 인자로 액션 이름을 지정하면 Redux DevTools에서 어떤 액션이 상태를 변경했는지 추적할 수 있습니다. 브라우저에 Redux DevTools 확장이 설치되어 있어야 합니다.


8. Slice Pattern

상태가 많아지면 하나의 create 안에 모두 넣으면 관리가 어려워집니다. 기능별로 slice를 만들고 합치면 파일 분리와 재사용이 쉬워집니다.

// src/store/slices/counterSlice.ts
import type { StateCreator } from "zustand";

export interface CounterSlice {
  count: number;
  increment: () => void;
}

export const createCounterSlice: StateCreator<CounterSlice> = (set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
});
// src/store/slices/authSlice.ts
import type { StateCreator } from "zustand";

export interface AuthSlice {
  token: string | null;
  setToken: (token: string) => void;
}

export const createAuthSlice: StateCreator<AuthSlice> = (set) => ({
  token: null,
  setToken: (token) => set({ token }),
});
// src/store/useBoundStore.ts — 슬라이스를 하나로 합침
import { create } from "zustand";
import { createCounterSlice, CounterSlice } from "./slices/counterSlice";
import { createAuthSlice, AuthSlice } from "./slices/authSlice";

type BoundStore = CounterSlice & AuthSlice;

const useBoundStore = create<BoundStore>()((...args) => ({
  ...createCounterSlice(...args),
  ...createAuthSlice(...args),
}));

export default useBoundStore;
// 사용 — 합쳐진 Store에서 각 slice의 상태와 액션을 모두 사용 가능
function App() {
  const count = useBoundStore((state) => state.count);
  const token = useBoundStore((state) => state.token);

  return <p>{count} / {token ?? "미인증"}</p>;
}
← 이전 글Zod — 런타임 데이터 검증과 TypeScript 타입 추론