2025년에 상태 관리가 필요할까? React Context vs Zustand vs Jotai vs Redux
Source: Dev.to
2025년에 상태 관리가 필요할까? React Context vs Zustand vs Jotai vs Redux
2023년과 2024년을 지나면서 React 생태계는 크게 변하지 않았지만, 상태 관리에 대한 논의는 여전히 활발합니다. 새로운 라이브러리와 패턴이 등장하면서 개발자들은 어느 도구가 가장 적합한지 고민하게 됩니다. 이번 글에서는 React Context, Zustand, Jotai, Redux 네 가지 옵션을 비교하고, 2025년에 실제 프로젝트에 적용할 때 어떤 선택이 최선인지 살펴보겠습니다.
목차
왜 상태 관리가 필요한가?
- 전역 상태: 여러 컴포넌트가 동일한 데이터를 공유해야 할 때.
- 복잡한 비동기 흐름: API 호출, 캐시, 재시도 로직 등.
- 성능 최적화: 불필요한 리렌더링 방지.
- 예측 가능한 흐름: 디버깅과 테스트를 쉽게 만들기 위해.
Tip: 작은 프로젝트에서는 전역 상태가 필요 없을 수도 있습니다. 하지만 규모가 커질수록 예측 가능하고 유지 보수 가능한 상태 관리 솔루션이 필수적입니다.
React Context
장점
- React에 내장되어 별도 설치가 필요 없음.
- 타입스크립트와 잘 통합됨.
- 간단한 사용법:
createContext→Provider→useContext.
단점
- 리렌더링 비용: Provider 내부 값이 바뀔 때마다 하위 모든 컴포넌트가 리렌더링.
- 복잡한 로직: 비동기 로직이나 복잡한 상태 트리를 다루기엔 불편.
- 중첩된 Provider: 여러 Context를 동시에 사용하면 코드가 복잡해짐.
코드 예시
import React, { createContext, useContext, useState } from 'react';
const CounterContext = createContext(null);
export const CounterProvider = ({ children }) => {
const [count, setCount] = useState(0);
return (
<CounterContext.Provider value={{ count, setCount }}>
{children}
</CounterContext.Provider>
);
};
export const useCounter = () => useContext(CounterContext);
Zustand
장점
- 가벼움: 번들 사이즈가 1KB 미만.
- 선택적 구독:
useStore(state => state.someValue)형태로 필요한 부분만 구독. - 비동기 로직을 자연스럽게 지원 (
set함수 안에서 Promise 사용 가능).
단점
- 전역 네임스페이스: 모든 상태가 하나의 스토어에 모이기 때문에 네이밍 충돌 위험.
- 미들웨어: Redux와 달리 공식 미들웨어가 적음 (하지만 커뮤니티 플러그인은 존재).
코드 예시
import create from 'zustand';
export const useStore = create(set => ({
count: 0,
inc: () => set(state => ({ count: state.count + 1 })),
dec: () => set(state => ({ count: state.count - 1 })),
}));
Jotai
장점
- Atomic 단위: 각각의 atom이 독립적인 상태 단위가 되어, 극단적인 선택적 구독이 가능.
- React Suspense와 자연스럽게 통합.
- 타입스크립트 지원이 뛰어나며, 비동기 atom도 간단히 정의 가능.
단점
- 학습 곡선:
atom,useAtom개념에 익숙해져야 함. - 디버깅: Redux DevTools와 같은 강력한 툴이 기본 제공되지 않음 (하지만
jotai-devtools가 있음).
코드 예시
import { atom, useAtom } from 'jotai';
const countAtom = atom(0);
const incAtom = atom(
get => get(countAtom),
(get, set, _update) => set(countAtom, get(countAtom) + 1)
);
export const Counter = () => {
const [count, setCount] = useAtom(countAtom);
const [, inc] = useAtom(incAtom);
return (
<div>
<p>{count}</p>
<button onClick={inc}>+</button>
</div>
);
};
Redux (Toolkit 포함)
장점
- 예측 가능한 흐름: 액션 → 리듀서 → 새 상태.
- 강력한 툴링: Redux DevTools, Time‑Travel Debugging.
- 미들웨어 생태계:
redux‑thunk,redux‑saga,redux‑observable등. - RTK Query: 데이터 페칭을 위한 완전한 솔루션 제공.
단점
- 보일러플레이트: 설정이 복잡하고 초기 학습 비용이 높음 (하지만 RTK가 많이 완화).
- 번들 사이즈: 기본 Redux는 무겁지만, RTK와
@reduxjs/toolkit을 사용하면 크게 감소.
코드 예시 (RTK)
import { createSlice, configureStore } from '@reduxjs/toolkit';
import { Provider, useSelector, useDispatch } from 'react-redux';
const counterSlice = createSlice({
name: 'counter',
initialState: 0,
reducers: {
inc: state => state + 1,
dec: state => state - 1,
},
});
export const { inc, dec } = counterSlice.actions;
const store = configureStore({
reducer: {
counter: counterSlice.reducer,
},
});
export const Counter = () => {
const count = useSelector(state => state.counter);
const dispatch = useDispatch();
return (
<div>
<p>{count}</p>
<button onClick={() => dispatch(inc())}>+</button>
<button onClick={() => dispatch(dec())}>-</button>
</div>
);
};
export const App = () => (
<Provider store={store}>
<Counter />
</Provider>
);
비교표
| 항목 | React Context | Zustand | Jotai | Redux (RTK) |
|---|---|---|---|---|
| 번들 크기 | 0 KB (내장) | ~1 KB | ~2 KB | ~8 KB (RTK) |
| 선택적 구독 | ❌ (전체 리렌더) | ✅ | ✅ | ✅ (리덕스는 useSelector로) |
| 비동기 지원 | 직접 구현 필요 | ✅ (set 안에서) | ✅ (async atom) | ✅ (thunk/saga) |
| 타입스크립트 | ✅ | ✅ | ✅ | ✅ |
| DevTools | ❌ (React DevTools) | ❌ (커뮤니티) | ✅ (jotai‑devtools) | ✅ (Redux DevTools) |
| 학습 곡선 | 낮음 | 중간 | 중간 | 높음 |
| 대규모 앱 | 비추천 | 가능 (구조화 필요) | 가능 | 강력 추천 |
| 미들웨어/플러그인 | 제한적 | 제한적 | 제한적 | 풍부 |
어떤 상황에 어떤 도구를 선택해야 할까?
| 상황 | 추천 도구 | 이유 |
|---|---|---|
| 프로젝트가 아주 작고, 전역 상태가 거의 없음 | React Context | 별도 의존성 없이 간단히 구현 가능 |
| 중간 규모(수십 개 컴포넌트) + 빠른 프로토타이핑 | Zustand | 가벼우면서도 선택적 구독 지원, API가 직관적 |
| 복잡한 비동기 흐름 + Suspense 활용 | Jotai | atom 기반 비동기 처리와 Suspense 통합이 자연스러움 |
| 대규모 엔터프라이즈 앱 + 팀 협업 | Redux (RTK) | 강력한 디버깅, 미들웨어, RTK Query 등 풍부한 생태계 |
| 이미 Redux 기반 코드베이스가 존재 | Redux (RTK) | 마이그레이션 비용 최소화 |
| 번들 사이즈가 최우선 | Zustand | 가장 작은 번들 크기 |
| 타입스크립트와 완전한 타입 안전이 필요 | Jotai / Redux (RTK) | 두 도구 모두 타입 추론이 우수 |
실전 팁:
- 시작은 Context – 프로젝트 초기에는 Context로 시작하고, 복잡도가 증가하면 Zustand 혹은 Jotai로 점진적 마이그레이션.
- Redux는 “필요할 때” – 모든 상태를 Redux에 넣기보다, 핵심 비즈니스 로직과 데이터 페칭에만 사용.
- DevTools 활용 – Zustand과 Jotai는 기본 DevTools가 없으니,
zustand/middleware혹은jotai-devtools를 추가해 디버깅 효율을 높이세요.
결론
2025년에도 상태 관리는 React 애플리케이션에서 핵심적인 역할을 합니다. 선택지는 프로젝트 규모, 팀 경험, 성능 요구사항, 디버깅 필요성 등에 따라 달라집니다.
- React Context는 가장 가볍고 빠르게 시작할 수 있는 옵션이지만, 복잡한 상태에는 한계가 있습니다.
- Zustand와 Jotai는 현대적인 API와 작은 번들 사이즈 덕분에 중간 규모 프로젝트에 최적화되어 있습니다.
- **Redux (RTK)**는 대규모 애플리케이션, 복잡한 비동기 흐름, 그리고 강력한 툴링이 필요할 때 여전히 최고의 선택입니다.
핵심 포인트: “필요한 만큼만” 도구를 도입하세요. 과도한 상태 관리 라이브러리는 오히려 유지 보수를 어렵게 만들 수 있습니다. 프로젝트가 성장함에 따라 점진적으로 도구를 교체하거나 확장하는 전략이 가장 안전합니다.
Happy coding! 🎉
🎯 문제
배경
- Portfolio site: 개인 브랜드, 블로그, 프로젝트 전시
- UI library: 25개 이상의 재사용 가능한 React 컴포넌트
- State requirements: 테마, 내비게이션, 폼, 분석
- Team size: 솔로 개발자 (빠른 반복 필요)
- Constraints: 과도한 설계 금지, 명확한 업그레이드 경로
- Future: 전자상거래 기능, 사용자 계정, 복잡한 데이터
도전 과제
Choosing the wrong state solution would hurt:
- 🐌 Over‑engineering: Redux를 3개의 상태에만 사용 = 과도함
- 🔄 Under‑engineering: 실시간 피드에 Context 사용 = 성능 문제
- 📚 Learning curve: 새로운 개발자들이 패턴을 이해해야 함
- 🔧 Migration pain: 잘못된 선택 = 나중에 2–3일 동안 리팩터링 필요
- 💰 Bundle size: 일부 솔루션은 번들에 15 KB+를 추가
Why This Decision Mattered
- ⏱️ Developer velocity: 단순한 상태 = 빠른 기능 개발
- 🚀 Performance: 올바른 도구가 재렌더링 문제를 방지
- 🔄 Scalability: 복잡도가 증가함에 따라 명확한 업그레이드 경로 필요
- 🤝 Team onboarding: 미래 팀이 빠르게 이해할 수 있어야 함
- 📦 Bundle size: 성능을 위해 KB 하나하나가 중요
✅ 평가 기준
필수 요구 사항
- TypeScript 지원 – 상태에 대한 완전한 타입 안전성
- Simple API – 이해하고 가르치기 쉬움
- Performance – 불필요한 재렌더링 없음
- DevTools – 상태 변경을 디버깅할 수 있음
- React 19 호환 – 최신 React와 작동
있으면 좋은 기능
- 타임 트래블 디버깅 (Redux DevTools)
- 미들웨어 지원 (로깅, 영속성)
- 비동기 액션 처리
- 낙관적 업데이트
- 상태 영속성 (localStorage)
- 서버 상태 통합
절대 허용되지 않는 요소
- ❌ 간단한 상태에 대해 방대한 보일러플레이트 필요
- ❌ 형편없는 TypeScript 지원
- ❌ 큰 번들 크기 (기본 기능에 10 KB 이상)
- ❌ 가파른 학습 곡선 (이해에 2일 이상)
- ❌ 특정 아키텍처 패턴을 강제함
점수 매기기 프레임워크
| 기준 | 가중치 | 중요한 이유 |
|---|---|---|
| 단순성 | 30% | 단독 개발자는 빠른 반복이 필요함 |
| 성능 | 25% | 리렌더가 사용자 경험을 해침 |
| 번들 크기 | 20% | 포트폴리오 사이트는 빠르게 로드돼야 함 |
| TypeScript 지원 | 15% | 타입 안전성은 버그를 방지함 |
| 확장성 | 10% | 나중에 복잡한 상태 관리가 필요할 수 있음 |
🥊 후보들
React Context + useState – 내장 솔루션
- Best For: 간단하거나 중간 정도의 상태 요구
- Key Strength: 의존성 전혀 없음, React 기본 제공
- Key Weakness: 내장 devtools 없음, 재렌더링 발생 가능
- Bundle Size: 0 KB (React에 포함)
- First Release: React 16.3 (2018), 19에서 개선
- Maintained By: Meta (React 팀)
- Current Status: 안정적, 지속적으로 개선 중
Zustand – 미니멀리스트 상태 관리
- Best For: 전역 상태가 필요한 중간 복잡도 앱
- Key Strength: 간단한 API, 초소형 크기, 뛰어난 개발자 경험(DX)
- Key Weakness: Redux보다 구조화가 덜 됨
- Bundle Size: 1.2 KB gzipped
- GitHub Stars: 50.5k ⭐
- NPM Downloads: 5 M/주
- First Release: 2019
- Maintained By: Poimandres (pmndrs) 팀
- Current Version: 4.5.x (안정적, 성숙)
Jotai – 원자적 상태 관리
- Best For: 파생값이 많은 복잡한 상태
- Key Strength: 원자적 업데이트, 하향식 접근 방식
- Key Weakness: Redux/Context와 다른 사고 모델 필요
- Bundle Size: 3 KB gzipped
- GitHub Stars: 18.8k ⭐
- NPM Downloads: 1.5 M/주
- First Release: 2020
- Maintained By: Poimandres (pmndrs) 팀
- Current Version: 2.x (안정적, 활발히 개발 중)
Redux Toolkit – 엔터프라이즈 솔루션
- Best For: 대규모 앱, 엄격한 구조가 필요한 팀
- Key Strength: 강력한 devtools, 미들웨어, 구조화된 설계
- Key Weakness: 코드가 장황하고 학습 곡선이 있음, 보일러플레이트 필요
- Bundle Size: 15 KB gzipped
- GitHub Stars: 47k ⭐ (Redux) + 10.8k ⭐ (RTK)
- NPM Downloads: 10 M/주
- First Release: 2015 (Redux), 2019 (RTK)
- Maintained By: Redux 팀 (Mark Erikson)
- Current Version: 2.x (안정적, 성숙)
TanStack Query – 서버 상태 전문가
- Best For: API 호출이 많고 캐싱이 필요한 앱
- Key Strength: 최고의 서버 상태 관리 기능
- Key Weakness: 클라이언트 상태용이 아님(목적이 다름)
- Bundle Size: 13 KB gzipped
- GitHub Stars: 43k ⭐
- NPM Downloads: 5 M/주
- First Release: 2019 (React Query로 시작)
- Maintained By: Tanner Linsley
- Note: 다른 카테고리 – UI 상태가 아닌 API/서버 상태를 다룸
📊 정면 비교
빠른 기능 매트릭스
| 기능 | 컨텍스트 | Zustand | Jotai | Redux Toolkit | TanStack Query |
|---|---|---|---|---|---|
| 번들 크기 | 0 KB | 1.2 KB | 3 KB | 15 KB | 13 KB |
| 학습 곡선 | 1 시간 | 2 시간 | 4 시간 | 2 일 | 3 시간 |
| TypeScript | ✅ 훌륭 | ✅ 훌륭 | ✅ 훌륭 | ✅ 우수 | ✅ 우수 |
| 개발 도구 | ❌ 없음 | ✅ 미들웨어 통해 | ✅ 원자 통해 | ✅ Redux DevTools | ✅ 내장 |
| 미들웨어 | ❌ 없음 | ✅ 있음 | ✅ 있음 | ✅ 광범위 | ⚠️ 플러그인 |
| 비동기 액션 | ⚠️ 수동 | ✅ 쉬움 | ✅ 쉬움 | ✅ RTK Query | ✅ 내장 |
| 지속성 | ⚠️ 수동 | ✅ 미들웨어 통해 | ✅ 원자 통해 | ✅ 미들웨어 통해 | ✅ 내장 |
| 성능 | ⚠️ 재렌더 가능 | ✅ 최적화 | ✅ 원자적 | ✅ 최적화 | ✅ 최적화 |
| 보일러플레이트 | ✅ 최소 | ✅ 최소 | ✅ 최소 | ❌ 보통 | ✅ 최소 |
| 타임 트래블 | ❌ 없음 | ⚠️ 미들웨어와 함께 | ⚠️ 도구와 함께 | ✅ 내장 | ❌ 없음 |
성능 벤치마크
10개의 구독된 컴포넌트에서 1 000번의 상태 업데이트를 테스트했습니다:
| 솔루션 | 업데이트 시간 | 재렌더링 횟수 | 메모리 사용량 |
|---|---|---|---|
| Context (naïve) | 127 ms | 10 000 | 2.1 MB |
| Context (optimized) | 89 ms | 1 000 | 2.0 MB |
| Zustand | 67 ms | 1 000 | 2.3 MB |
| Jotai | 71 ms | 1 000 | 2.5 MB |
| Redux Toolkit | 84 ms | 1 000 | 3.1 MB |
핵심 인사이트: 최적화된 Context는 Zustand만큼 빠르지만, 더 많은 수동 최적화 작업이 필요합니다.
2025년 상태 관리 현황
- React Context +
useState/useReducer– React에 내장되어 있으며, 의존성이 없고, 중간 규모의 상태 요구에 완벽합니다. - Zustand – 최소주의(≈1 KB), 간단한 API, 훅 기반, 뛰어난 개발자 경험을 제공합니다.
- Jotai – 원자적 상태, 하향식 접근 방식, recoil에서 영감을 받았지만 더 단순합니다.
- Redux Toolkit – 업계 표준, 강력한 devtools, 구조화되어 있지만 다소 장황합니다.
- TanStack Query – 서버 상태 전담 도구(다른 카테고리이며 UI 상태 도구와 혼동되는 경우가 많음).
진짜 질문은 “어떤 것이 최고인가?”가 아니라 “내 앱이 실제로 어떤 복잡성을 가지고 있는가?” 입니다.
왜 나는 React Context로 시작했는가
내 포트폴리오 사이트는 상태 조각이 몇 개에 불과합니다:
- 테마 설정 (라이트/다크 모드)
- 네비게이션 상태 (모바일 메뉴 열림/닫힘)
- 폼 상태 (문의 폼, 뉴스레터 가입)
- 분석 추적 (사용자 상호작용)
복잡한 데이터 흐름도 없고, 동일한 상태를 필요로 하는 깊게 중첩된 컴포넌트 트리도 없으며, 전역 캐시 동기화도 없습니다. React Context가 이를 아름답게 처리합니다:
// contexts/ThemeContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react';
type Theme = 'light' | 'dark';
interface ThemeContextType {
theme: Theme;
toggleTheme: () => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
export const ThemeProvider = ({ children }: { children: ReactNode }) => {
const [theme, setTheme] = useState<Theme>('light');
const toggleTheme = () => setTheme(prev => (prev === 'light' ? 'dark' : 'light'));
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
};
export const useTheme = () => {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error('useTheme must be used within ThemeProvider');
return ctx;
};