개요
React 프로젝트에서 Context API, Zustand, Valtio, MobX 중 어떤 상태관리 라이브러리를 선택할지 결정하는 가이드입니다. 각 라이브러리가 해결하는 문제와 한계를 분석하여 프로젝트에 맞는 선택을 도와드립니다.
왜 Redux와 Recoil/Jotai는 제외했나요?
Redux의 경우 보일러플레이트가 많고 학습 곡선이 가파르며, Redux Toolkit을 사용해도 여전히 복잡합니다. 현재는 더 간단하고 효율적인 대안들이 있습니다.
Recoil과 Jotai 같은 Atomic 패턴의 경우 Facebook이 Recoil 개발을 중단했고, Jotai는 학습 비용이 높으면서도 제공하는 이점이 Zustand나 Valtio 대비 명확하지 않습니다.
이 가이드는 2025년 현재 실무에서 가장 널리 사용되고 유지보수가 활발한 라이브러리들을 중심으로 작성되었습니다.
빠른 선택 가이드
프로젝트 특성 | 권장 라이브러리 | 주요 이유 |
소규모, 안정적 상태 | Context API | React 내장, 추가 의존성 없음 |
중규모, 성능 중시 | Zustand | 선택적 구독, 간단한 API |
대규모, 복잡한 상태 관계 | Valtio/MobX | 자동 반응성, 정교한 상태 관리 |
팀이 OOP에 익숙 | MobX | 클래스 기반, 명확한 구조 |
함수형 패러다임 선호 | Valtio | Hooks 패턴, 자연스러운 문법 |
1️⃣ Context API의 한계
React의 기본 상태관리인 Context API는 props drilling 문제를 해결하지만, 복잡한 애플리케이션에서는 한계가 있습니다.
문제 1: 전체 구독 방식
Context 값의 일부만 변경되어도 모든 구독 컴포넌트가 리렌더링됩니다.
const AppContext = createContext({ user: { name: 'John', email: 'john@example.com' }, posts: [...], ui: { theme: 'dark' } }); // user.email만 변경되어도 모든 컴포넌트가 리렌더링
문제 2: Provider Hell
Context를 세분화하면 Provider가 중첩됩니다.
<UserProvider> <PostsProvider> <UIProvider> <SettingsProvider> <App /> {/* 관리가 어려움 */} </SettingsProvider> </UIProvider> </PostsProvider> </UserProvider>
문제 3: 복잡한 메모이제이션
불필요한 리렌더링을 방지하려면 모든 값을 메모이제이션해야 합니다.
function UserProvider({ children }) { const [user, setUser] = useState({ name: 'John' }); const updateUser = useCallback((updates) => { setUser(prev => ({ ...prev, ...updates })); }, []); const contextValue = useMemo(() => ({ user, updateUser }), [user, updateUser]); return ( <UserContext.Provider value={contextValue}> {children} </UserContext.Provider> ); }
Context API 사용 시기
테마, 언어 설정, 인증 상태처럼 안정적이고 자주 변경되지 않는 전역 상태에 적합합니다.
2️⃣ Zustand
Zustand는 Context API의 주요 문제들을 해결한 경량 상태관리 라이브러리입니다.
해결책 1: Provider 제거
복잡한 Provider 중첩 없이 React 외부 상태를 사용할 수 있습니다.
const useStore = create((set) => ({ user: { name: 'John', age: 30 }, posts: [], updateUser: (updates) => set((state) => ({ user: { ...state.user, ...updates } })) })); function App() { return <HomePage />; // Provider 불필요 } function UserProfile() { const user = useStore(state => state.user); return <h1>{user.name}</h1>; }
해결책 2: 선택적 구독
필요한 상태 부분만 구독하여 불필요한 리렌더링을 방지합니다.
const useAppStore = create((set) => ({ user: { name: 'John', age: 30, email: 'john@example.com' }, posts: { items: [], loading: false }, ui: { theme: 'dark', sidebar: false } })); function UserName() { const userName = useAppStore(state => state.user.name); // posts나 ui 변경시 리렌더링 안됨 return <span>{userName}</span>; } function LoadingIndicator() { const loading = useAppStore(state => state.posts.loading); // user나 ui 변경시 리렌더링 안됨 return loading ? <div>Loading...</div> : null; }
Zustand의 한계
상태 간 연관성이 복잡할 때 모든 관련 상태를 수동으로 동기화해야 합니다.
const useUserStore = create((set) => ({ user: { id: 1, name: 'John' }, posts: [{ authorId: 1, authorName: 'John' }], comments: [{ userId: 1, userName: 'John' }], updateUser: (updates) => { set((state) => { const newUser = { ...state.user, ...updates }; // 연관된 모든 상태를 수동으로 업데이트 const updatedPosts = state.posts.map(post => post.authorId === newUser.id ? { ...post, authorName: newUser.name } : post ); const updatedComments = state.comments.map(comment => comment.userId === newUser.id ? { ...comment, userName: newUser.name } : comment ); return { user: newUser, posts: updatedPosts, comments: updatedComments }; }); } }));
Zustand 사용 시기
다음과 같은 경우에 적합합니다.
- 중소규모 애플리케이션에서 상태 간 연관성이 복잡하지 않은 경우
- 명확한 상태 구조로 도메인별 분리가 가능한 경우
- 성능이 중요한 앱에서 Context API의 불필요한 리렌더링을 피하고 싶은 경우
3️⃣ Valtio와 MobX
Zustand의 수동적 상태 관리 한계를 자동 반응성으로 해결합니다.
자동 상태 동기화
Zustand의 문제: 연관된 상태들을 수동으로 동기화해야 함
// ❌ Zustand - 수동 동기화 updateUser: (updates) => { set((state) => { const newUser = { ...state.user, ...updates }; // 모든 연관 상태를 수동으로 업데이트 const updatedPosts = state.posts.map(post => post.authorId === newUser.id ? { ...post, authorName: newUser.name } : post ); return { user: newUser, posts: updatedPosts }; }); }
Valtio의 해결책: 자연스러운 JavaScript 문법으로 자동 동기화
// ✅ Valtio - 자동 동기화 const state = proxy({ user: { id: 1, name: 'John' }, posts: [{ authorId: 1, authorName: 'John' }], comments: [{ userId: 1, userName: 'John' }] }); function updateUser(updates) { Object.assign(state.user, updates); // 연관된 상태들도 자연스럽게 업데이트 state.posts.forEach(post => { if (post.authorId === state.user.id) { post.authorName = state.user.name; } }); }
자동 Computed 값 관리
Zustand의 문제: computed 값을 매번 재계산하거나 수동 캐싱 필요
// ❌ Zustand - 매번 전체 계산 getUserStats: () => { const { users, posts } = get(); return users.map(user => ({ ...user, postCount: posts.filter(p => p.authorId === user.id).length, totalLikes: posts .filter(p => p.authorId === user.id) .reduce((sum, post) => sum + post.likes, 0) })); }
Valtio/MobX의 해결책: 의존성 자동 추적과 캐싱
// ✅ Valtio - 자동 computed 캐싱 const state = proxy({ users: [], posts: [], get userStats() { return this.users.map(user => ({ ...user, postCount: this.posts.filter(p => p.authorId === user.id).length, totalLikes: this.posts .filter(p => p.authorId === user.id) .reduce((sum, post) => sum + post.likes, 0) })); } // users나 posts가 실제로 변경될 때만 재계산 }); // ✅ MobX - computed 데코레이터 class StatsStore { users = []; posts = []; constructor() { makeObservable(this, { users: observable, posts: observable, userStats: computed }); } get userStats() { return this.users.map(user => ({ ...user, postCount: this.posts.filter(p => p.authorId === user.id).length })); } // 의존성 변경시에만 자동 재계산 }
자동 Fine-grained Reactivity
Zustand의 한계: 선택적 구독을 위해 수동으로 selector 작성 필요
// 🔄 Zustand - 수동 selector 작성 function UserName() { const userName = useStore(state => state.user.name); return <span>{userName}</span>; }
Valtio/MobX의 해결책: 사용되는 속성 자동 추적
// ✅ Valtio - 자동 속성 추적 function UserName() { const snap = useSnapshot(appState); return <span>{snap.user.name}</span>; // user.name 자동 추적 } // ✅ MobX - observer가 자동 추적 const UserName = observer(() => { return <span>{appStore.user.name}</span>; // 사용된 속성 자동 추적 });
4️⃣ 프로젝트별 선택 가이드
단순한 상태 관리
- Context API: 로그인 상태, 테마, 언어 설정처럼 가끔 바뀌는 전역 상태
- Zustand: 장바구니, 사용자 설정처럼 자주 바뀌지만 단순한 상태
복잡한 상태 관리
- Zustand: 폼 데이터, 리스트 필터링처럼 독립적인 상태들
- Valtio: 사용자 프로필 변경시 댓글 작성자명도 함께 바뀌는 등 연관된 상태들
매우 복잡한 상태 관리
- Valtio: 실시간 협업 도구, 복잡한 폼 위저드처럼 상태간 의존성이 많은 경우
- MobX: CRM, ERP처럼 복잡한 비즈니스 규칙과 계산 로직이 많은 경우
마이그레이션 시점
Context API에서 Zustand로 바꾸는 경우
- Provider Hell이 발생하기 시작할 때
- 성능 이슈로 인한 불필요한 리렌더링이 문제가 될 때
Zustand에서 Valtio나 MobX로 바꾸는 경우
- 상태 간 수동 동기화 로직이 복잡해질 때
- computed 값 관리가 어려워질 때
- 상태 관련 버그가 자주 발생할 때
Valtio를 선택하면 좋은 경우
- JavaScript의 자연스러운 문법을 선호하는 팀
- 깊은 중첩 상태를 많이 다루는 경우
- React Hooks 패턴에 익숙한 팀
- 최소한의 학습 비용으로 고급 기능을 원하는 경우
MobX를 선택하면 좋은 경우
- 객체지향 프로그래밍에 익숙한 팀
- 복잡한 비즈니스 로직이 많은 애플리케이션
- 클래스 기반 도메인 모델링을 선호하는 경우
- computed 값과 액션의 명확한 분리가 필요한 경우
결론
상태관리 라이브러리는 해결하려는 문제의 복잡도에 따라 선택해야 합니다. Context API의 Provider Hell과 성능 문제를 Zustand가 해결했지만, 복잡한 상태 간 관계에서는 한계가 있습니다. Valtio와 MobX의 자동 반응성은 복잡성을 근본적으로 해결하여 개발자가 비즈니스 로직에 집중할 수 있게 해줍니다.
작게 시작해서 필요에 따라 점진적으로 마이그레이션하는 것이 가장 실용적인 접근 방법입니다.