Zustand를 활용한 상태 관리
React 애플리케이션에서 상태 관리는 애플리케이션의 규모와 복잡성이 커질수록 중요해집니다. useState
나 useContext
와 같은 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와 타입스크립트의 조합은 다음과 같은 강력한 이점을 제공합니다.
- 강력한 타입 안전성: 스토어의 상태와 액션 함수에 대한 타입을 명확하게 정의함으로써, 잘못된 타입의 데이터를 상태에 저장하거나, 액션을 잘못 호출하는 등의 실수를 컴파일 시점에 방지할 수 있습니다.
- 자동 완성 및 리팩토링: IDE는 스토어의 타입 정보를 기반으로 상태 속성 및 액션 함수에 대한 강력한 자동 완성 기능을 제공합니다. 상태 구조가 변경될 때도 안전한 리팩토링이 가능합니다.
- 코드 가독성 및 명확성: 스토어가 어떤 상태를 포함하고, 어떤 방식으로 상태를 변경하는지 타입 정의를 통해 한눈에 파악할 수 있습니다. 이는 팀원 간의 협업 효율을 높입니다.
- 불변성 보장:
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 애플리케이션의 상태를 효율적으로 관리하는 데 필요한 모든 것을 제공합니다.