본문으로 건너뛰기 Zustand 완벽 가이드 | 간단한 상태 관리·React·TypeScript·Middleware·실전 활용

Zustand 완벽 가이드 | 간단한 상태 관리·React·TypeScript·Middleware·실전 활용

Zustand 완벽 가이드 | 간단한 상태 관리·React·TypeScript·Middleware·실전 활용

이 글의 핵심

Zustand는 외부 스토어(vanilla)와 React 바인딩을 분리하고, 셀렉터 기반으로 리렌더를 최소화합니다. immer·persist·SSR·Next.js App Router에서의 스토어 공유 패턴과 흔한 함정을 정리합니다.

이 글의 핵심

Zustand로 간단한 상태 관리를 구현하는 완벽 가이드입니다. Redux 없이 가볍게, TypeScript 지원, Middleware, Persist까지 실전 예제로 정리했습니다.

실무 경험 공유: Redux에서 Zustand로 전환하면서, 보일러플레이트가 90% 감소하고 번들 크기가 50% 줄어든 경험을 공유합니다.

들어가며: “Redux가 복잡해요”

실무에서 마주치는 문제들

보일러플레이트가 너무 많아요

Redux는 복잡합니다. Zustand는 간단합니다. 번들 크기가 커요

Redux는 무겁습니다. Zustand는 1KB입니다. TypeScript 설정이 어려워요

Redux는 타입 설정이 복잡합니다. Zustand는 자동 추론됩니다.

1. Zustand란?

핵심 특징

Zustand는 간단한 React 상태 관리 라이브러리입니다. 주요 장점

  • 간단한 API: 보일러플레이트 없음
  • 작은 크기: 1KB
  • TypeScript: 완벽한 지원
  • Middleware: Persist, Devtools
  • React 외부: Vanilla JS 사용 가능

2. 기본 사용

설치

npm install zustand

Store 생성

// store/useStore.ts
import { create } from 'zustand';
interface Store {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
}
export const useStore = create<Store>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }),
}));

컴포넌트에서 사용

// components/Counter.tsx
import { useStore } from '../store/useStore';
export default function Counter() {
  const count = useStore((state) => state.count);
  const increment = useStore((state) => state.increment);
  const decrement = useStore((state) => state.decrement);
  return (
    <div>
      <h1>Count: {count}</h1>
      <button onClick={increment}>+</button>
      <button onClick={decrement}>-</button>
    </div>
  );
}

3. 고급 패턴

Async Actions

interface UserStore {
  users: User[];
  loading: boolean;
  error: string | null;
  fetchUsers: () => Promise<void>;
}
export const useUserStore = create<UserStore>((set) => ({
  users: [],
  loading: false,
  error: null,
  fetchUsers: async () => {
    set({ loading: true, error: null });
    try {
      const response = await fetch('/api/users');
      const users = await response.json();
      set({ users, loading: false });
    } catch (error) {
      set({ error: error.message, loading: false });
    }
  },
}));

Computed Values

interface CartStore {
  items: CartItem[];
  addItem: (item: CartItem) => void;
  removeItem: (id: string) => void;
  total: () => number;
}
export const useCartStore = create<CartStore>((set, get) => ({
  items: [],
  addItem: (item) =>
    set((state) => ({ items: [...state.items, item] })),
  removeItem: (id) =>
    set((state) => ({
      items: state.items.filter((item) => item.id !== id),
    })),
  total: () => {
    const { items } = get();
    return items.reduce((sum, item) => sum + item.price, 0);
  },
}));

4. Middleware

Persist

import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface AuthStore {
  user: User | null;
  token: string | null;
  login: (user: User, token: string) => void;
  logout: () => void;
}
export const useAuthStore = create<AuthStore>()(
  persist(
    (set) => ({
      user: null,
      token: null,
      login: (user, token) => set({ user, token }),
      logout: () => set({ user: null, token: null }),
    }),
    {
      name: 'auth-storage',
    }
  )
);

Devtools

import { devtools } from 'zustand/middleware';
export const useStore = create<Store>()(
  devtools(
    (set) => ({
      count: 0,
      increment: () => set((state) => ({ count: state.count + 1 })),
    }),
    { name: 'CounterStore' }
  )
);

Immer

import { immer } from 'zustand/middleware/immer';
export const useStore = create<Store>()(
  immer((set) => ({
    nested: { deep: { value: 0 } },
    updateDeep: (value: number) =>
      set((state) => {
        state.nested.deep.value = value;
      }),
  }))
);

5. Slice Pattern

// store/slices/userSlice.ts
export const createUserSlice = (set, get) => ({
  users: [],
  fetchUsers: async () => {
    const users = await api.getUsers();
    set({ users });
  },
});
// store/slices/cartSlice.ts
export const createCartSlice = (set, get) => ({
  items: [],
  addItem: (item) => set((state) => ({ items: [...state.items, item] })),
});
// store/index.ts
import { create } from 'zustand';
import { createUserSlice } from './slices/userSlice';
import { createCartSlice } from './slices/cartSlice';
export const useStore = create((set, get) => ({
  ...createUserSlice(set, get),
  ...createCartSlice(set, get),
}));

6. Selector 최적화

잘못된 예

// 전체 store를 구독 (불필요한 리렌더링)
const store = useStore();

올바른 예

// 필요한 값만 구독
const count = useStore((state) => state.count);
const increment = useStore((state) => state.increment);

Shallow 비교

import { shallow } from 'zustand/shallow';
const { count, increment } = useStore(
  (state) => ({ count: state.count, increment: state.increment }),
  shallow
);

7. Vanilla JS

import { createStore } from 'zustand/vanilla';
const store = createStore<Store>((set) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
}));
// 구독
const unsubscribe = store.subscribe((state) => {
  console.log('Count:', state.count);
});
// 사용
store.getState().increment();
console.log(store.getState().count); // 1
// 구독 해제
unsubscribe();

심화: 구독 모델·Next.js·트러블슈팅

스토어와 리렌더

useStore(selector)는 Zustand가 셀렉터 결과의 동일성을 추적해 변경된 컴포넌트만 다시 그립니다. 셀렉터가 매번 새 객체를 만들면({ a, b } 형태) 참조가 달라져 불필요한 리렌더가 납니다. 이때 shallow 비교를 쓰거나, 필드를 쪼개 여러 useStore 호출로 나눕니다.

미들웨어 체인

devtools·persist·immer스토어를 래핑해 동작합니다. 순서에 따라 타입 추론저장 포맷이 달라질 수 있으므로, 팀에서 표준 순서를 문서화하는 편이 좋습니다. persist부분 저장·마이그레이션 버전은 장기 운영 시 필수입니다.

Next.js App Router

서버 컴포넌트와 클라이언트 컴포넌트 경계에서 전역 스토어를 공유하려면, 클라이언트 트리 안에서만 스토어 모듈을 import하도록 합니다. SSR 초기 상태propsfetch 결과로 내려받으며, 클라이언트에서 하이드레이션 후 스토어를 시드하는 패턴이 안전합니다.

트러블슈팅

증상점검
상태는 맞는데 UI만 안 바뀜셀렉터가 참조 동일 객체를 반환
무한 루프setsubscribe 콜백 안에서 다시 호출
테스트끼리 상태 공유각 테스트마다 새 스토어 또는 setState 초기화

취업·면접과 연결하기

상태 관리·전역 스토어 설계는 프론트엔드 면접에서 자주 묻습니다. 기술 면접 완벽 대비 가이드와, 이력서에 Redux→Zustand 전환 같은 수치 스토리를 쓰는 법은 개발자 이력서·서류·면접 가이드를 참고하세요.

정리 및 체크리스트

핵심 요약

  • Zustand: 간단한 상태 관리
  • 작은 크기: 1KB
  • TypeScript: 완벽한 지원
  • Middleware: Persist, Devtools, Immer
  • Selector: 최적화 가능
  • Vanilla JS: React 외부 사용

구현 체크리스트

  • Zustand 설치
  • Store 생성
  • 컴포넌트 연결
  • Async Actions 구현
  • Middleware 추가
  • Selector 최적화
  • Slice Pattern 적용

같이 보면 좋은 글


이 글에서 다루는 키워드

Zustand, State Management, React, TypeScript, Redux, Frontend, Performance

자주 묻는 질문 (FAQ)

Q. Redux와 비교하면 어떤가요?

A. Zustand가 훨씬 간단하고 가볍습니다. Redux는 더 많은 기능을 제공하지만 복잡합니다.

Q. Context API와 비교하면 어떤가요?

A. Zustand가 성능이 더 좋고 사용이 간편합니다. Context API는 prop drilling을 피하는 용도로 적합합니다.

Q. 프로덕션에서 사용해도 되나요?

A. 네, 많은 기업에서 안정적으로 사용하고 있습니다.

Q. SSR을 지원하나요?

A. 네, Next.js와 함께 사용할 수 있습니다.