
2026년 프론트엔드 상태 관리: Zustand vs Jotai vs Redux vs Signals
2026년 프론트엔드 상태 관리: Zustand vs Jotai vs Redux vs Signals
Zustand, Jotai, Redux Toolkit, Signals 등 2026년 프론트엔드 상태 관리 라이브러리를 번들 크기, 성능, 개발자 경험, 코드 예제까지 종합 비교합니다.
상태 관리, 왜 아직도 어려운가?
프론트엔드 개발에서 **상태 관리(State Management)**는 항상 뜨거운 주제입니다. React가 처음 등장했을 때 Redux가 사실상 유일한 선택이었지만, 2026년 현재는 Zustand, Jotai, Signals, Valtio 등 수많은 대안이 존재합니다.
선택지가 많아진 만큼 혼란도 커졌습니다. 이 글에서는 2026년에 가장 많이 사용되는 4가지 상태 관리 솔루션을 번들 크기, 성능, 개발자 경험(DX), 실전 코드 예제까지 심층 비교합니다.
2026년 상태 관리 생태계 현황
npm 다운로드 트렌드 (주간 기준)
| 라이브러리 | 주간 다운로드 | 성장률 (전년 대비) |
|---|---|---|
| Redux Toolkit | ~8.5M | -5% (감소 중) |
| Zustand | ~6M | +40% |
| Jotai | ~2M | +35% |
| @preact/signals | ~800K | +120% |
| Valtio | ~600K | +25% |
| Recoil | ~400K | -30% (쇠퇴 중) |
핵심 트렌드:
- Redux는 여전히 1위이나 점유율 감소 중
- Zustand가 가장 빠르게 성장
- Signals가 폭발적 성장 (프레임워크 네이티브 채택)
- Recoil은 Meta의 지원 감소로 쇠퇴
한눈에 보는 비교표
| 특성 | Zustand | Jotai | Redux Toolkit | Signals |
|---|---|---|---|---|
| 번들 크기 | ~1.1KB | ~2.4KB | ~10KB | ~1.8KB |
| 보일러플레이트 | 매우 적음 | 매우 적음 | 보통 | 매우 적음 |
| 학습 곡선 | 쉬움 | 쉬움 | 중간 | 쉬움 |
| TypeScript | 우수 | 우수 | 우수 | 우수 |
| DevTools | 기본 | 기본 | 강력 | 기본 |
| 미들웨어 | 내장 | 별도 | 풍부 | 제한적 |
| 서버 상태 | 별도 | 별도 | RTK Query | 별도 |
| React 외 | O | X | X | O (네이티브) |
| 패턴 | 스토어 | 원자(Atom) | 스토어 | 신호(Signal) |
| 리렌더링 최적화 | 셀렉터 | 원자 단위 | 셀렉터 | 자동 |

Zustand: 심플함의 정석
철학
Zustand(독일어로 "상태")는 최소한의 API로 최대한의 기능을 제공합니다. "필요한 것만, 깔끔하게"가 모토입니다.
기본 사용법
import { create } from 'zustand';
// 1. 스토어 정의 - 이것이 전부입니다
interface TodoStore {
todos: Todo[];
filter: 'all' | 'active' | 'completed';
addTodo: (text: string) => void;
toggleTodo: (id: string) => void;
removeTodo: (id: string) => void;
setFilter: (filter: 'all' | 'active' | 'completed') => void;
}
const useTodoStore = create<TodoStore>((set, get) => ({
todos: [],
filter: 'all',
addTodo: (text) =>
set((state) => ({
todos: [...state.todos, {
id: crypto.randomUUID(),
text,
completed: false,
createdAt: new Date(),
}],
})),
toggleTodo: (id) =>
set((state) => ({
todos: state.todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
),
})),
removeTodo: (id) =>
set((state) => ({
todos: state.todos.filter((todo) => todo.id !== id),
})),
setFilter: (filter) => set({ filter }),
}));
// 2. 컴포넌트에서 사용
function TodoList() {
// 필요한 상태만 구독 → 불필요한 리렌더링 방지
const todos = useTodoStore((state) => state.todos);
const filter = useTodoStore((state) => state.filter);
const filteredTodos = todos.filter((todo) => {
if (filter === 'active') return !todo.completed;
if (filter === 'completed') return todo.completed;
return true;
});
return (
<ul>
{filteredTodos.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
);
}
function TodoItem({ todo }: { todo: Todo }) {
const toggleTodo = useTodoStore((state) => state.toggleTodo);
const removeTodo = useTodoStore((state) => state.removeTodo);
return (
<li>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<span>{todo.text}</span>
<button onClick={() => removeTodo(todo.id)}>삭제</button>
</li>
);
}
고급 패턴: 미들웨어
import { create } from 'zustand';
import { persist, devtools, immer } from 'zustand/middleware';
const useStore = create<AppStore>()(
devtools( // Redux DevTools 연결
persist( // localStorage 영속성
immer( // 불변성 헬퍼
(set) => ({
user: null,
theme: 'light',
setUser: (user) =>
set((state) => {
state.user = user; // immer 덕분에 직접 수정 가능
}),
toggleTheme: () =>
set((state) => {
state.theme = state.theme === 'light' ? 'dark' : 'light';
}),
})
),
{ name: 'app-storage' } // localStorage 키
),
{ name: 'AppStore' } // DevTools 이름
)
);
Zustand의 장점
- 번들 크기 1.1KB: 가장 가벼운 솔루션
- 제로 보일러플레이트: Provider 불필요, 스토어 생성이 간단
- React 외부에서도 사용 가능: Vanilla JS에서도 동작
- 셀렉터 기반 구독: 세밀한 리렌더링 제어
Zustand의 단점
- 큰 스토어를 잘 분리하려면 패턴 학습 필요
- 파생 상태(computed) 처리가 약간 불편
- DevTools 기능이 Redux보다 제한적
Jotai: 원자적 상태 관리
철학
Jotai(일본어로 "상태")는 원자(Atom) 단위로 상태를 관리합니다. 각 atom은 독립적이고, 필요한 컴포넌트만 구독합니다.
기본 사용법
import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai';
// 1. Atom 정의
const todosAtom = atom<Todo[]>([]);
const filterAtom = atom<'all' | 'active' | 'completed'>('all');
// 2. 파생 Atom (computed state)
const filteredTodosAtom = atom((get) => {
const todos = get(todosAtom);
const filter = get(filterAtom);
switch (filter) {
case 'active':
return todos.filter((t) => !t.completed);
case 'completed':
return todos.filter((t) => t.completed);
default:
return todos;
}
});
// 3. 통계 Atom
const todoStatsAtom = atom((get) => {
const todos = get(todosAtom);
return {
total: todos.length,
active: todos.filter((t) => !t.completed).length,
completed: todos.filter((t) => t.completed).length,
};
});
// 4. 쓰기 전용 Atom (액션)
const addTodoAtom = atom(
null, // 읽기 값 없음
(get, set, text: string) => {
set(todosAtom, [
...get(todosAtom),
{
id: crypto.randomUUID(),
text,
completed: false,
createdAt: new Date(),
},
]);
}
);
const toggleTodoAtom = atom(
null,
(get, set, id: string) => {
set(
todosAtom,
get(todosAtom).map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
);
}
);
// 5. 컴포넌트에서 사용
function TodoList() {
const filteredTodos = useAtomValue(filteredTodosAtom);
// filteredTodosAtom이 변경될 때만 리렌더링
return (
<ul>
{filteredTodos.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
);
}
function TodoStats() {
const stats = useAtomValue(todoStatsAtom);
// 통계가 변경될 때만 리렌더링
return (
<div>
<span>전체: {stats.total}</span>
<span>활성: {stats.active}</span>
<span>완료: {stats.completed}</span>
</div>
);
}
function AddTodoForm() {
const addTodo = useSetAtom(addTodoAtom);
const [text, setText] = useState('');
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
if (text.trim()) {
addTodo(text.trim());
setText('');
}
};
return (
<form onSubmit={handleSubmit}>
<input value={text} onChange={(e) => setText(e.target.value)} />
<button type="submit">추가</button>
</form>
);
}
비동기 Atom
import { atom } from 'jotai';
// 비동기 데이터 페칭
const userAtom = atom(async () => {
const response = await fetch('/api/user');
return response.json();
});
// 의존하는 비동기 Atom
const userPostsAtom = atom(async (get) => {
const user = await get(userAtom);
const response = await fetch(`/api/users/${user.id}/posts`);
return response.json();
});
// 컴포넌트에서 Suspense와 함께 사용
function UserProfile() {
const user = useAtomValue(userAtom);
return <h1>{user.name}</h1>;
}
function App() {
return (
<Suspense fallback={<Loading />}>
<UserProfile />
</Suspense>
);
}
Jotai의 장점
- 원자 단위 구독: 최소한의 리렌더링
- 파생 상태가 자연스러움: atom을 조합하여 computed 상태 생성
- Suspense 네이티브 지원: 비동기 처리가 깔끔
- Bottom-up 설계: 작은 단위에서 큰 단위로 조합
Jotai의 단점
- atom이 많아지면 관리가 복잡해질 수 있음
- 전역 상태의 전체 구조를 파악하기 어려울 수 있음
- DevTools가 Zustand/Redux보다 제한적

Redux Toolkit: 검증된 선택
철학
Redux Toolkit(RTK)는 Redux의 공식 권장 도구 세트입니다. Redux의 복잡한 보일러플레이트를 크게 줄이고, 모던한 개발 경험을 제공합니다.
기본 사용법
import { configureStore, createSlice, PayloadAction } from '@reduxjs/toolkit';
import { useSelector, useDispatch } from 'react-redux';
// 1. Slice 정의
const todoSlice = createSlice({
name: 'todos',
initialState: {
items: [] as Todo[],
filter: 'all' as 'all' | 'active' | 'completed',
loading: false,
},
reducers: {
addTodo: (state, action: PayloadAction<string>) => {
// immer 내장으로 직접 수정 가능
state.items.push({
id: crypto.randomUUID(),
text: action.payload,
completed: false,
createdAt: new Date().toISOString(),
});
},
toggleTodo: (state, action: PayloadAction<string>) => {
const todo = state.items.find((t) => t.id === action.payload);
if (todo) {
todo.completed = !todo.completed;
}
},
removeTodo: (state, action: PayloadAction<string>) => {
state.items = state.items.filter((t) => t.id !== action.payload);
},
setFilter: (state, action: PayloadAction<'all' | 'active' | 'completed'>) => {
state.filter = action.payload;
},
},
});
// 2. 스토어 생성
const store = configureStore({
reducer: {
todos: todoSlice.reducer,
},
});
type RootState = ReturnType<typeof store.getState>;
type AppDispatch = typeof store.dispatch;
// 3. 타입 안전한 커스텀 훅
const useAppDispatch = useDispatch.withTypes<AppDispatch>();
const useAppSelector = useSelector.withTypes<RootState>();
// 4. 셀렉터 (memoized)
import { createSelector } from '@reduxjs/toolkit';
const selectTodos = (state: RootState) => state.todos.items;
const selectFilter = (state: RootState) => state.todos.filter;
const selectFilteredTodos = createSelector(
[selectTodos, selectFilter],
(todos, filter) => {
switch (filter) {
case 'active':
return todos.filter((t) => !t.completed);
case 'completed':
return todos.filter((t) => t.completed);
default:
return todos;
}
}
);
// 5. 컴포넌트에서 사용
function TodoList() {
const filteredTodos = useAppSelector(selectFilteredTodos);
const dispatch = useAppDispatch();
return (
<ul>
{filteredTodos.map((todo) => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => dispatch(todoSlice.actions.toggleTodo(todo.id))}
/>
<span>{todo.text}</span>
</li>
))}
</ul>
);
}
RTK Query: 서버 상태 관리
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
const api = createApi({
baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
tagTypes: ['Posts'],
endpoints: (builder) => ({
getPosts: builder.query<Post[], void>({
query: () => '/posts',
providesTags: ['Posts'],
}),
addPost: builder.mutation<Post, Partial<Post>>({
query: (body) => ({
url: '/posts',
method: 'POST',
body,
}),
invalidatesTags: ['Posts'],
}),
}),
});
export const { useGetPostsQuery, useAddPostMutation } = api;
// 컴포넌트에서 사용
function PostList() {
const { data: posts, isLoading, error } = useGetPostsQuery();
const [addPost] = useAddPostMutation();
if (isLoading) return <div>로딩 중...</div>;
if (error) return <div>에러 발생</div>;
return (
<div>
{posts?.map((post) => <PostCard key={post.id} post={post} />)}
<button onClick={() => addPost({ title: '새 글' })}>추가</button>
</div>
);
}
Redux Toolkit의 장점
- 가장 성숙한 생태계: 미들웨어, DevTools, 커뮤니티
- Redux DevTools: 시간 여행 디버깅, 상태 diff
- RTK Query: 서버 상태 관리까지 통합
- 예측 가능성: 엄격한 단방향 데이터 흐름
Redux Toolkit의 단점
- 번들 크기가 큼 (~10KB)
- 여전히 보일러플레이트가 Zustand/Jotai보다 많음
- 작은 프로젝트에는 과할 수 있음
- Provider 래핑 필요
Signals: 리렌더링 없는 반응성
철학
Signals는 **세밀한 반응성(Fine-grained Reactivity)**을 제공합니다. 컴포넌트 전체가 아닌, 변경된 값만 DOM에 직접 업데이트합니다.
@preact/signals-react 사용법
import { signal, computed, effect } from '@preact/signals-react';
import { useSignals } from '@preact/signals-react/runtime';
// 1. Signal 정의 (컴포넌트 외부)
const todos = signal<Todo[]>([]);
const filter = signal<'all' | 'active' | 'completed'>('all');
// 2. Computed Signal (자동으로 의존성 추적)
const filteredTodos = computed(() => {
switch (filter.value) {
case 'active':
return todos.value.filter((t) => !t.completed);
case 'completed':
return todos.value.filter((t) => t.completed);
default:
return todos.value;
}
});
const todoCount = computed(() => ({
total: todos.value.length,
active: todos.value.filter((t) => !t.completed).length,
completed: todos.value.filter((t) => t.completed).length,
}));
// 3. 액션 함수
function addTodo(text: string) {
todos.value = [
...todos.value,
{
id: crypto.randomUUID(),
text,
completed: false,
createdAt: new Date(),
},
];
}
function toggleTodo(id: string) {
todos.value = todos.value.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
);
}
// 4. Side effect
effect(() => {
// todos가 변경될 때마다 자동 실행
localStorage.setItem('todos', JSON.stringify(todos.value));
});
// 5. 컴포넌트에서 사용
function TodoList() {
useSignals(); // Signal 런타임 활성화
return (
<ul>
{filteredTodos.value.map((todo) => (
<TodoItem key={todo.id} todo={todo} />
))}
</ul>
);
}
function TodoStats() {
useSignals();
// Signal의 핵심: 이 컴포넌트는 리렌더링 없이 DOM만 업데이트
return (
<div>
<span>전체: {todoCount.value.total}</span>
<span>활성: {todoCount.value.active}</span>
<span>완료: {todoCount.value.completed}</span>
</div>
);
}
Signals의 핵심: 리렌더링 없는 업데이트
전통적인 React 상태 관리는 상태 변경 시 컴포넌트를 리렌더링합니다:
[기존 방식]
상태 변경 → 컴포넌트 리렌더링 → Virtual DOM 비교 → DOM 업데이트
[Signals 방식]
Signal 변경 → DOM 직접 업데이트 (리렌더링 스킵)
이로 인해 대규모 리스트나 빈번한 업데이트에서 성능 이점이 큽니다.
TC39 Signal 제안
2026년 현재 TC39 Signals 제안이 Stage 2에 도달했으며, JavaScript 언어 자체에 Signal이 내장될 가능성이 높습니다:
// 미래: 브라우저 네이티브 Signal (TC39 제안)
const count = new Signal.State(0);
const doubled = new Signal.Computed(() => count.get() * 2);
// Watcher로 side effect 처리
const watcher = new Signal.subtle.Watcher(() => {
console.log(`count: ${count.get()}, doubled: ${doubled.get()}`);
});
watcher.watch(doubled);
Signals의 장점
- 최고의 업데이트 성능: 리렌더링 없는 DOM 업데이트
- 자동 의존성 추적: computed가 자동으로 의존성 파악
- 프레임워크 독립적: Preact, Angular, Solid, Vue 등에서 사용
- 미래 표준: TC39 제안으로 브라우저 네이티브 지원 예정
Signals의 단점
- React와의 통합이 아직 실험적
- 생태계가 작음 (미들웨어, DevTools 부족)
- React의 동시성 모드(Concurrent Mode)와 충돌 가능성
- 학습 패러다임이 React의 선언적 모델과 다름
성능 벤치마크
10,000개 아이템 리스트 업데이트 성능
| 작업 | Zustand | Jotai | Redux TK | Signals |
|---|---|---|---|---|
| 초기 렌더링 | 45ms | 42ms | 48ms | 38ms |
| 단일 아이템 업데이트 | 12ms | 8ms | 15ms | 3ms |
| 100개 아이템 동시 업데이트 | 35ms | 25ms | 40ms | 10ms |
| 필터 변경 | 20ms | 15ms | 22ms | 12ms |
| 메모리 사용 | 2.1MB | 2.3MB | 2.5MB | 1.8MB |
번들 크기 비교 (gzipped)
Signals (@preact/signals-react): ~1.8KB
Zustand: ~1.1KB
Jotai: ~2.4KB
Valtio: ~2.8KB
Redux Toolkit: ~10KB
Redux TK + RTK Query: ~25KB
시나리오별 추천
"간단한 전역 상태만 필요해요" → Zustand
// 이보다 더 간단할 수 없습니다
const useStore = create((set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
}));
"복잡한 파생 상태가 많아요" → Jotai
// Atom 조합으로 복잡한 파생 상태를 깔끔하게
const priceAtom = atom(100);
const quantityAtom = atom(2);
const taxRateAtom = atom(0.1);
const subtotalAtom = atom((get) => get(priceAtom) * get(quantityAtom));
const taxAtom = atom((get) => get(subtotalAtom) * get(taxRateAtom));
const totalAtom = atom((get) => get(subtotalAtom) + get(taxAtom));
"대규모 팀 + 엔터프라이즈" → Redux Toolkit
// 엄격한 패턴 + 강력한 DevTools + RTK Query
// 팀 전체가 일관된 코드를 작성
const store = configureStore({
reducer: {
auth: authSlice.reducer,
products: productsSlice.reducer,
cart: cartSlice.reducer,
[api.reducerPath]: api.reducer,
},
middleware: (getDefault) =>
getDefault().concat(api.middleware, logger),
});
"성능이 최우선이에요" → Signals
// 리렌더링 없는 초고속 업데이트
// 실시간 대시보드, 게임, 에디터 등에 적합
const price = signal(0);
const chart = computed(() => generateChart(price.value));
조합 사용
실제 프로젝트에서는 여러 솔루션을 조합할 수 있습니다:
권장 조합 1: Zustand (클라이언트 상태) + TanStack Query (서버 상태)
권장 조합 2: Jotai (로컬 상태) + TanStack Query (서버 상태)
권장 조합 3: Redux Toolkit + RTK Query (올인원)
마이그레이션 가이드
Redux에서 Zustand로
// Before (Redux)
const counterSlice = createSlice({
name: 'counter',
initialState: { value: 0 },
reducers: {
increment: (state) => { state.value += 1 },
decrement: (state) => { state.value -= 1 },
},
});
// After (Zustand)
const useCounterStore = create((set) => ({
value: 0,
increment: () => set((s) => ({ value: s.value + 1 })),
decrement: () => set((s) => ({ value: s.value - 1 })),
}));
Redux에서 Jotai로
// Before (Redux)
const selectCount = (state) => state.counter.value;
const count = useSelector(selectCount);
// After (Jotai)
const countAtom = atom(0);
const count = useAtomValue(countAtom);
마이그레이션 전략
1. 점진적 마이그레이션
├── 새로운 기능은 새 라이브러리로 작성
├── 기존 코드는 점진적으로 변환
└── 두 라이브러리가 공존하는 기간을 허용
2. 한 번에 전환 (작은 프로젝트)
├── 전체 상태 구조를 새 라이브러리로 재작성
├── 테스트로 동일한 동작 검증
└── 한 번에 교체
2026년의 특별한 고려사항
React Server Components와 상태 관리
React Server Components(RSC) 환경에서는 클라이언트 상태 관리의 범위가 줄어듭니다:
// Server Component - 상태 관리 불필요
async function ProductList() {
const products = await db.products.findMany();
return (
<div>
{products.map((p) => <ProductCard key={p.id} product={p} />)}
</div>
);
}
// Client Component - 여기서만 상태 관리 필요
'use client';
function ProductFilters() {
const filters = useStore((s) => s.filters);
// 클라이언트 인터랙션 상태만 관리
}
React 19 + use() Hook
// React 19의 use() Hook으로 Promise 기반 상태가 더 쉬워짐
function UserProfile({ userPromise }) {
const user = use(userPromise); // Suspense와 자동 통합
return <h1>{user.name}</h1>;
}
결론: 어떤 것을 선택할까?
결론을 한 문장으로:
- Zustand: 대부분의 프로젝트에 가장 좋은 기본 선택
- Jotai: 복잡한 파생 상태가 많은 애플리케이션
- Redux Toolkit: 대규모 팀 + 엔터프라이즈 + 강력한 DevTools 필요
- Signals: 성능 극대화가 필요한 특수 케이스
2026년의 현실적 추천은 다음과 같습니다:
- 새 프로젝트를 시작한다면: Zustand + TanStack Query
- 기존 Redux 프로젝트를 유지한다면: Redux Toolkit으로 업그레이드
- 성능이 극도로 중요하다면: Signals 도입 검토
- 복잡한 폼이나 에디터를 만든다면: Jotai
어떤 것을 선택하든, 서버 상태와 클라이언트 상태를 분리하는 것이 2026년의 가장 중요한 원칙입니다. TanStack Query(또는 SWR, RTK Query)로 서버 상태를 관리하고, 클라이언트 상태만 위의 라이브러리로 관리하세요.
프론트엔드 개발에 필요한 도구는 무료 CSS 그라디언트 생성기와 색상 대비 검사기를 활용해보세요.