icon
13장 : React와 타입스크립트

Zustand를 활용한 상태 관리


React 애플리케이션에서 상태 관리는 애플리케이션의 규모와 복잡성이 커질수록 중요해집니다. useStateuseContext와 같은 React 내장 훅만으로는 전역 상태나 복잡한 비동기 상태를 효율적으로 관리하기 어려워질 수 있습니다. Redux, Recoil, Jotai 등 다양한 외부 상태 관리 라이브러리가 존재하며, 그중 Zustand는 가볍고 빠르며 간결한 API로 최근 많은 개발자들에게 주목받고 있습니다.

Zustand는 훅 기반의 상태 관리 라이브러리로, 별도의 보일러플레이트 코드 없이 쉽게 전역 상태를 만들고 사용할 수 있게 해줍니다. 타입스크립트와 함께 사용하면 상태의 구조와 액션의 타입을 명확하게 정의하여 더욱 견고한 상태 관리가 가능합니다.


Zustand 시작하기

Zustand를 사용하려면 먼저 프로젝트에 설치해야 합니다.

npm install zustand
# 또는
yarn add zustand

기본적인 전역 스토어 생성 및 사용

Zustand의 핵심은 create 함수를 사용하여 스토어(Store)를 정의하는 것입니다. 이 스토어는 함수형 컴포넌트의 useState와 유사하게 상태와 해당 상태를 업데이트하는 함수들을 포함합니다.

1. 스토어 정의 (src/store/counterStore.ts)

먼저 카운터 상태를 관리할 스토어를 정의해봅시다.

import { create } from 'zustand';

// 1. 상태의 타입 정의
interface CounterState {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
  incrementByAmount: (amount: number) => void;
}

// 2. create 함수를 사용하여 스토어 생성
//    create<T>() 제네릭을 사용하여 스토어의 상태와 액션의 타입을 명확히 지정합니다.
export const useCounterStore = create<CounterState>((set) => ({
  // 초기 상태 정의
  count: 0,

  // 상태를 업데이트하는 액션 함수 정의
  // `set` 함수를 사용하여 상태를 불변적으로 업데이트합니다.
  increment: () => set((state) => ({ count: state.count + 1 })),
  decrement: () => set((state) => ({ count: state.count - 1 })),
  reset: () => set({ count: 0 }), // 초기값으로 직접 설정
  incrementByAmount: (amount: number) => set((state) => ({ count: state.count + amount })),
}));

// 추가 설명:
// - `set`: 상태를 업데이트하는 함수입니다. `set(newState)` 또는 `set((state) => newState)` 형태로 사용됩니다.
// - `get`: (선택 사항) 현재 스토어의 상태를 가져올 때 사용됩니다.
// - `zustand/middleware`의 `devtools`나 `persist` 미들웨어와 함께 사용할 수 있습니다.

2. 컴포넌트에서 스토어 사용 (src/components/ZustandCounter.tsx)

useCounterStore 훅을 사용하여 컴포넌트에서 상태와 액션에 접근할 수 있습니다.

import React from 'react';
import { useCounterStore } from '../store/counterStore'; // 스토어 임포트

const ZustandCounter: React.FC = () => {
  // 스토어에서 원하는 상태와 액션을 선택적으로 가져올 수 있습니다.
  // 이 방식은 컴포넌트가 필요한 상태만 구독하므로 불필요한 리렌더링을 방지합니다.
  const count = useCounterStore((state) => state.count);
  const increment = useCounterStore((state) => state.increment);
  const decrement = useCounterStore((state) => state.decrement);
  const reset = useCounterStore((state) => state.reset);
  const incrementByAmount = useCounterStore((state) => state.incrementByAmount);

  return (
    <div>
      <h2>Zustand Counter</h2>
      <p>Count: {count}</p>
      <button onClick={increment}>Increment</button>
      <button onClick={decrement}>Decrement</button>
      <button onClick={reset}>Reset</button>
      <button onClick={() => incrementByAmount(5)}>Increment by 5</button>
    </div>
  );
};

export default ZustandCounter;

3. 다른 컴포넌트에서 스토어 사용 (src/components/ZustandCountDisplay.tsx)

어떤 컴포넌트에서든 useCounterStore 훅을 호출하여 전역 상태에 접근할 수 있습니다.

import React from 'react';
import { useCounterStore } from '../store/counterStore';

const ZustandCountDisplay: React.FC = () => {
  const count = useCounterStore((state) => state.count);

  return (
    <div style={{ border: '1px solid #ccc', padding: '10px', marginTop: '20px' }}>
      <h3>Current Count (from another component): {count}</h3>
    </div>
  );
};

export default ZustandCountDisplay;

4. App.tsx에서 사용

// src/App.tsx
import React from 'react';
import ZustandCounter from './components/ZustandCounter';
import ZustandCountDisplay from './components/ZustandCountDisplay';

const App: React.FC = () => {
  return (
    <div style={{ padding: '20px' }}>
      <h1>React with Zustand & TypeScript</h1>
      <ZustandCounter />
      <ZustandCountDisplay /> {/* 같은 전역 상태를 공유 */}
    </div>
  );
};

export default App;

비동기 액션 처리

Zustand는 비동기 액션 처리도 매우 간결하게 지원합니다. 액션 함수 내에서 async/await를 사용하여 비동기 작업을 수행하고, 그 결과에 따라 상태를 업데이트할 수 있습니다.

1. 스토어에 비동기 액션 추가 (src/store/userStore.ts)

import { create } from 'zustand';

interface User {
  id: number;
  name: string;
  email: string;
}

interface UserState {
  user: User | null;
  loading: boolean;
  error: string | null;
  fetchUser: (userId: number) => Promise<void>; // 비동기 액션
}

export const useUserStore = create<UserState>((set) => ({
  user: null,
  loading: false,
  error: null,

  fetchUser: async (userId: number) => {
    set({ loading: true, error: null }); // 로딩 시작, 에러 초기화
    try {
      // 가상 API 호출
      const response = await new Promise<User>((resolve, reject) => {
        setTimeout(() => {
          if (userId === 1) {
            resolve({ id: 1, name: 'Alice', email: 'alice@example.com' });
          } else {
            reject(new Error('User not found!'));
          }
        }, 1500);
      });
      set({ user: response, loading: false }); // 성공 시 상태 업데이트
    } catch (err: any) {
      set({ error: err.message, loading: false, user: null }); // 실패 시 에러 상태 업데이트
    }
  },
}));

2. 컴포넌트에서 비동기 액션 사용 (src/components/ZustandUserProfile.tsx)

import React from 'react';
import { useUserStore } from '../store/userStore';

const ZustandUserProfile: React.FC = () => {
  const { user, loading, error, fetchUser } = useUserStore();

  return (
    <div style={{ marginTop: '30px', borderTop: '1px solid #eee', paddingTop: '20px' }}>
      <h2>Zustand User Profile</h2>
      <button onClick={() => fetchUser(1)} disabled={loading}>
        {loading ? 'Fetching User 1...' : 'Fetch Alice (ID 1)'}
      </button>
      <button onClick={() => fetchUser(99)} disabled={loading} style={{ marginLeft: '10px' }}>
        {loading ? 'Fetching User 99...' : 'Fetch Non-Existent User (ID 99)'}
      </button>

      {error && <p style={{ color: 'red' }}>Error: {error}</p>}
      {user ? (
        <div>
          <p>ID: {user.id}</p>
          <p>Name: {user.name}</p>
          <p>Email: {user.email}</p>
        </div>
      ) : (
        !loading && !error && <p>Click a button to fetch user data.</p>
      )}
    </div>
  );
};

export default ZustandUserProfile;

fetchUser 함수가 호출되면 loading 상태가 true로 바뀌고, 비동기 작업이 완료되면 결과에 따라 user 또는 error 상태가 업데이트됩니다.


Zustand와 타입스크립트의 시너지

Zustand와 타입스크립트의 조합은 다음과 같은 강력한 이점을 제공합니다.

  1. 강력한 타입 안전성: 스토어의 상태와 액션 함수에 대한 타입을 명확하게 정의함으로써, 잘못된 타입의 데이터를 상태에 저장하거나, 액션을 잘못 호출하는 등의 실수를 컴파일 시점에 방지할 수 있습니다.
  2. 자동 완성 및 리팩토링: IDE는 스토어의 타입 정보를 기반으로 상태 속성 및 액션 함수에 대한 강력한 자동 완성 기능을 제공합니다. 상태 구조가 변경될 때도 안전한 리팩토링이 가능합니다.
  3. 코드 가독성 및 명확성: 스토어가 어떤 상태를 포함하고, 어떤 방식으로 상태를 변경하는지 타입 정의를 통해 한눈에 파악할 수 있습니다. 이는 팀원 간의 협업 효율을 높입니다.
  4. 불변성 보장: set 함수를 사용할 때 객체 스프레드 문법({ ...state, ...updates })을 통해 상태를 불변적으로 업데이트하도록 유도하여, React의 렌더링 최적화와 데이터 예측 가능성을 높입니다.

미들웨어 (Middleware) 사용

Zustand는 미들웨어를 통해 스토어의 기능을 확장할 수 있습니다. 대표적으로 devtools (Redux DevTools와 통합) 및 persist (상태를 localStorage 등에 저장) 미들웨어가 있습니다.

// src/store/settingsStore.ts
import { create } from 'zustand';
import { devtools, persist } from 'zustand/middleware';

interface SettingsState {
  darkMode: boolean;
  fontSize: number;
  toggleDarkMode: () => void;
  setFontSize: (size: number) => void;
}

// devtools와 persist 미들웨어 적용
export const useSettingsStore = create<SettingsState>()(
  devtools( // Redux DevTools에서 스토어 상태 변화를 확인할 수 있게 해줍니다.
    persist( // 상태를 localStorage에 저장하고 복원합니다.
      (set) => ({
        darkMode: false,
        fontSize: 16,
        toggleDarkMode: () => set((state) => ({ darkMode: !state.darkMode })),
        setFontSize: (size: number) => set({ fontSize: size }),
      }),
      {
        name: 'user-settings-storage', // localStorage에 저장될 키 이름
        // partialize: (state) => ({ /* 저장할 상태의 일부만 선택 가능 */ }),
        // getStorage: () => sessionStorage, // 또는 sessionStorage 등 다른 저장소 지정
      }
    )
  )
);

미들웨어는 create 함수의 인자를 감싸는 형태로 사용됩니다. 이렇게 하면 디버깅과 사용자 설정 유지 같은 고급 기능을 쉽게 추가할 수 있습니다.


요약

Zustand는 React 애플리케이션의 상태 관리를 위한 간결하고 강력한 솔루션입니다. 타입스크립트와 함께 사용하면 상태와 액션의 타입을 명확하게 정의하여 개발 생산성과 코드의 안정성을 크게 향상시킬 수 있습니다. 전역 상태 공유, 비동기 데이터 로딩, 그리고 미들웨어를 통한 기능 확장까지, Zustand는 복잡한 React 애플리케이션의 상태를 효율적으로 관리하는 데 필요한 모든 것을 제공합니다.