React 상태 관리 라이브러리 Zustand, Jotai, Redux Toolkit 비교
세 라이브러리의 설계 철학, 구독 방식, 확장 전략을 비교하고 선택 기준을 정리합니다.
새 프로젝트를 시작할 때마다 상태 관리 라이브러리 선택에서 멈추게 됩니다.
Zustand? Jotai? 혹은 Redux?
const useStore = create((set) => ({ count: 0 }));
const countAtom = atom(0);
const counterSlice = createSlice({ name: 'counter', initialState: { count: 0 } });같은 count: 0을 관리하더라도 라이브러리마다 모델링 방식은 크게 다릅니다.
이 글은 Zustand, Jotai, Redux Toolkit을 다음 기준으로 비교합니다.
- 상태 모델링 방식(Store/Atom/Slice)
- 구독 및 리렌더링 특성
- 미들웨어/유틸리티 확장성
- Next.js(App Router) 환경에서의 적용 포인트
어떤 기준으로 선택할 수 있는지 각 라이브러리에 적합한 상황을 함께 정리했습니다.
세 라이브러리의 핵심 철학
Zustand: “Store 하나로 단순하게”
Zustand는 Top-Down 방식입니다.
하나의 Store를 만들고, 컴포넌트가 필요한 상태를 구독하는 구조입니다.
import { create } from 'zustand';
const useStore = create((set) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
}));
function Counter() {
const count = useStore((state) => state.count);
const increment = useStore((state) => state.increment);
return <button onClick={increment}>{count}</button>;
}Zustand는 Provider 없이 동작하고 보일러플레이트가 적습니다.
create로 Store를 만들고 selector로 필요한 상태만 구독하면 됩니다.
필요할 경우 React Context와 조합해 특정 트리 범위에서만 상태를 공유하도록 구성할 수 있습니다.
Zustand의 핵심은 selector 입니다.
useStore((state) => state.count)처럼 필요한 값만 선택하면, 해당 값이 바뀔 때만 리렌더링됩니다. 전체 Store가 바뀌어도 내가 구독한 값이 같으면 렌더링이 발생하지 않습니다.
Jotai: “Atom 단위로 쪼개서 조합”
Jotai는 Bottom-Up 방식입니다.
상태를 작은 Atom 단위로 쪼개고, 이를 조합해서 복잡한 상태를 만듭니다.
Recoil에서 영감을 받았지만, 훨씬 가볍고 단순합니다.
import { atom, useAtom } from 'jotai';
const countAtom = atom(0);
function Counter() {
const [count, setCount] = useAtom(countAtom);
return <button onClick={() => setCount((c) => c + 1)}>{count}</button>;
}사용 방식은 useState와 유사하지만, Atom이 컴포넌트 외부에 존재한다는 점이 핵심 차이입니다.
같은 Atom을 import하면 동일한 상태를 공유합니다.
Jotai는 기본적으로 Provider 없이 동작합니다(내부 기본 Store 사용). 필요하면 <Provider>로 감싸서 특정 트리에 별도 스코프를 만들 수 있습니다. 또한 jotai/vanilla를 통해 React 외부에서도 createStore로 상태를 관리할 수 있습니다.
Atom 단위 구독을 통해 리렌더링 범위가 자연스럽게 좁아집니다.
Jotai의 강점은 파생 상태 입니다.
const countAtom = atom(0);
const doubleCountAtom = atom((get) => get(countAtom) * 2);
// 비동기 파생도 가능
const userAtom = atom(async (get) => {
const id = get(userIdAtom);
const response = await fetch(`/api/users/${id}`);
return response.json();
});doubleCountAtom은 countAtom에 의존합니다.
countAtom이 바뀌면 doubleCountAtom도 자동으로 업데이트됩니다.
이 의존 관계가 그래프 형태로 연결되면서, 변경된 Atom을 구독하는 컴포넌트만 정확히 리렌더링됩니다.
Redux Toolkit: “예측 가능한 단방향 흐름”
Redux는 Flux 아키텍처 기반입니다.
Action을 dispatch하면, Reducer가 상태를 업데이트하고, 구독 중인 컴포넌트가 리렌더링됩니다.
import { createSlice, configureStore } from '@reduxjs/toolkit';
import { useSelector, useDispatch } from 'react-redux';
const counterSlice = createSlice({
name: 'counter',
initialState: { count: 0 },
reducers: {
increment: (state) => { state.count += 1; },
},
});
const store = configureStore({ reducer: { counter: counterSlice.reducer } });
function Counter() {
const count = useSelector((state) => state.counter.count);
const dispatch = useDispatch();
return <button onClick={() => dispatch(counterSlice.actions.increment())}>{count}</button>;
}셋 중 가장 코드가 많습니다. 하지만 단순히 코드가 많은 게 아니라, 대규모 팀에서 “누가 짜도 똑같은 구조”가 나오게 만드는 엄격한 가이드라인을 제공하는 것이 Redux의 의도입니다.
모든 상태 변경이 Action으로 기록 되기 때문에 DevTools에서 디버깅이 가능하고, 상태 흐름을 완전히 추적할 수 있습니다.
구독과 리렌더링 방식
철학 차이는 결국 “어떤 변경이 어떤 컴포넌트를 다시 그리게 만드는가”에서 체감됩니다.
Zustand: selector 기반 구독
const useStore = create((set) => ({
name: '',
age: 0,
setName: (name) => set({ name }),
setAge: (age) => set({ age }),
}));
function AgeDisplay() {
const age = useStore((s) => s.age);
return <span>{age}</span>;
}selector가 반환한 값이 같으면 리렌더링을 건너뜁니다.
다만 객체를 반환하면 매번 새 참조가 생길 수 있어 useShallow를 함께 쓰는 것이 안전합니다.
Jotai: Atom 단위 자동 구독
const nameAtom = atom('');
const ageAtom = atom(0);
function AgeDisplay() {
const age = useAtomValue(ageAtom);
return <span>{age}</span>;
}Atom이 이미 분리돼 있기 때문에 selector를 따로 신경 쓰지 않아도 구독 범위가 자연스럽게 좁아집니다.
Redux Toolkit: useSelector + 메모이제이션
const selectAge = (state) => state.profile.age;
function AgeDisplay() {
const age = useSelector(selectAge);
return <span>{age}</span>;
}기본적으로 useSelector 결과가 바뀔 때만 리렌더링됩니다.
파생 데이터가 복잡해지면 createSelector로 메모이제이션을 붙여 계산과 렌더링 비용을 줄입니다.
구조 비교: 같은 기능, 다른 접근
Todo 앱의 상태 관리를 세 가지 방식으로 비교해보겠습니다.
Zustand: 하나의 Store에 모든 것
const useTodoStore = create((set) => ({
todos: [],
filter: 'all',
addTodo: (text) =>
set((state) => ({
todos: [...state.todos, { id: Date.now(), text, completed: false }],
})),
toggleTodo: (id) =>
set((state) => ({
todos: state.todos.map((todo) =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
),
})),
setFilter: (filter) => set({ filter }),
}));
// 컴포넌트에서 필요한 것만 구독
function TodoList() {
const todos = useTodoStore((state) => state.todos);
const filter = useTodoStore((state) => state.filter);
const filtered = todos.filter((todo) =>
filter === 'all' ? true : filter === 'done' ? todo.completed : !todo.completed
);
return filtered.map((todo) => <TodoItem key={todo.id} todo={todo} />);
}상태와 액션이 한 곳에 모여 있어서 코드를 파악하기 쉽습니다. 파일 하나면 충분합니다.
Jotai: Atom으로 분리
const todosAtom = atom([]);
const filterAtom = atom('all');
// 파생 Atom으로 필터링 로직 분리
const filteredTodosAtom = atom((get) => {
const todos = get(todosAtom);
const filter = get(filterAtom);
if (filter === 'all') return todos;
if (filter === 'done') return todos.filter((t) => t.completed);
return todos.filter((t) => !t.completed);
});
function TodoList() {
const filtered = useAtomValue(filteredTodosAtom);
return filtered.map((todo) => <TodoItem key={todo.id} todo={todo} />);
}
function FilterButtons() {
const [filter, setFilter] = useAtom(filterAtom);
return (
<div>
{['all', 'done', 'active'].map((f) => (
<button key={f} onClick={() => setFilter(f)} data-active={filter === f}>
{f}
</button>
))}
</div>
);
}FilterButtons는 filterAtom만 구독합니다.
Todo를 추가해도 이 컴포넌트는 리렌더링되지 않습니다.
Atom 단위로 구독이 나뉘기 때문에 렌더링 최적화가 자연스럽게 따라옵니다.
Redux Toolkit — Slice + Selector
const todoSlice = createSlice({
name: 'todos',
initialState: { items: [], filter: 'all' },
reducers: {
addTodo: (state, action) => {
state.items.push({ id: Date.now(), text: action.payload, completed: false });
},
toggleTodo: (state, action) => {
const todo = state.items.find((t) => t.id === action.payload);
if (todo) todo.completed = !todo.completed;
},
setFilter: (state, action) => {
state.filter = action.payload;
},
},
});
// Memoized selector
const selectFilteredTodos = createSelector(
[(state) => state.todos.items, (state) => state.todos.filter],
(items, filter) => {
if (filter === 'all') return items;
if (filter === 'done') return items.filter((t) => t.completed);
return items.filter((t) => !t.completed);
}
);Immer 덕분에 state.items.push()같은 mutable한 코드를 쓸 수 있습니다.실제로는 immutable하게 처리됩니다.
createSelector로 메모이제이션된 파생 상태도 만들 수 있지만, Jotai의 파생 Atom에 비하면 코드량이 많습니다.
상황별 적용 예시
1) 다크모드/테마 전환: Zustand
const useThemeStore = create(
persist(
(set) => ({
theme: 'light',
toggleTheme: () =>
set((s) => ({ theme: s.theme === 'light' ? 'dark' : 'light' })),
}),
{ name: 'theme-storage' }
)
);전역에서 공유되는 단일 상태 + 영속화 조합은 Zustand가 가장 빠르게 구현됩니다.
2) 복잡한 폼 유효성: Jotai
const emailAtom = atom('');
const passwordAtom = atom('');
const confirmPasswordAtom = atom('');
const passwordMatchAtom = atom(
(get) => get(passwordAtom) === get(confirmPasswordAtom)
);
const isFormValidAtom = atom((get) => {
const email = get(emailAtom);
const password = get(passwordAtom);
return email.includes('@') && password.length >= 8 && get(passwordMatchAtom);
});독립 필드와 파생 검증 상태가 많을수록 Atom 조합이 단순하고 유지보수가 쉽습니다.
3) 액션 추적이 중요한 대시보드: Redux Toolkit
const auditSlice = createSlice({
name: 'audit',
initialState: { logs: [] },
reducers: {
pushed: (state, action) => {
state.logs.push(action.payload);
},
},
});Action 히스토리를 팀 단위로 추적하고 재현해야 한다면 Redux DevTools + 표준 패턴의 이점이 큽니다.
미들웨어와 확장성
Zustand 그리고 미들웨어의 조합
const useStore = create(
devtools(
persist(
immer((set) => ({
count: 0,
increment: () => set((state) => { state.count += 1; }),
})),
{ name: 'my-store' }
)
)
);devtools, persist, immer 등을 함수 조합으로 감싸는 방식입니다. 필요한 것만 골라서 쓸 수 있습니다.
Jotai 그리고 Atom 유틸리티
import { atomWithStorage } from 'jotai/utils';
// localStorage 자동 동기화
const themeAtom = atomWithStorage('theme', 'light');
// Atom에 디버깅 레이블
countAtom.debugLabel = 'count';미들웨어라기보다 특수 목적 Atom 을 사용합니다.
atomWithStorage, atomWithReset 등 필요한 동작이 내장된 Atom을 골라 쓰는 방식입니다.
Redux Toolkit, 내장 미들웨어 + RTK Query
const store = configureStore({
reducer: { todos: todoSlice.reducer, [api.reducerPath]: api.reducer },
middleware: (getDefault) => getDefault().concat(api.middleware),
});Redux의 가장 큰 차별점은 RTK Query 입니다.
서버 상태 관리(데이터 패칭, 캐싱, 백그라운드 싱크)까지 하나의 생태계 안에서 해결할 수 있습니다.
물론 Zustand/Jotai에서는 TanStack Query와 조합하는 게 일반적입니다.
번들 사이즈와 러닝 커브
| Zustand | Jotai | Redux Toolkit | |
|---|---|---|---|
| 번들 사이즈 (min+gzip) | ~1.1 kB | ~2.4 kB | ~11 kB (+ react-redux) |
| Provider 필요 | 없음 | 선택적 | 필수 |
| TypeScript | 추론 우수 | 추론 우수 | 타입 설정 필요 |
| 러닝 커브 | 낮음 | 낮음 | 중간 |
| DevTools | 미들웨어 추가 | jotai-devtools | 내장 (시간 여행) |
| React 외부 사용 | 가능 | 가능 (jotai/vanilla) | 가능 |
Zustand와 Jotai는 “10분이면 시작할 수 있다”는 공통점이 있습니다.
Redux는 Flux 패턴, Slice, dispatch 같은 개념을 이해해야 하지만, Redux Toolkit이 보일러플레이트를 크게 줄여준 덕분에 예전만큼 진입 장벽이 높지는 않습니다.
위 표의 번들 사이즈는 문서/비교 글 기준의 대략치입니다. 실제 결과는 번들러, 트리셰이킹, 사용한 API 범위에 따라 달라집니다.
요약하자면 다음과 같아요.
- 작은 팀/빠른 구현: Zustand
- 세밀한 의존 관계/파생 상태: Jotai
- 팀 표준화/추적 가능한 상태 흐름: Redux Toolkit
구조적 한계와 우회 패턴
Zustand
- Store 비대화(일명 God Store)
- 한계: 전역 Store 하나에 도메인을 계속 추가하다 보면 의존성이 얽혀서 변경 영향 범위가 커집니다.
- 우회: 도메인별 slice로 분리하고, selector를 파일 단위로 고정해 재사용하는 패턴이 안전합니다.
- selector 참조 안정성 이슈
- 한계: selector에서 객체/배열을 바로 만들면 참조가 매번 바뀌어 리렌더링이 늘어날 수 있습니다.
- 우회: 원시값 selector를 우선 사용하고, 객체가 필요하면
useShallow또는 메모이제이션을 적용합니다.
- 비동기 액션 경쟁 상태
- 한계: 동시에 실행된 비동기 액션이 늦게 끝난 응답으로 최신 상태를 덮어쓸 수 있습니다.
- 우회: 요청 ID, abort, 최신성 체크를 액션 내부에 두고,
set((prev) => ...)기반 업데이트를 유지합니다.
Jotai
- 클로저 stale state
- 한계: 비동기 콜백에서 오래된 값을 캡처하면 최신 atom 값을 놓치는 상황이 생깁니다.
- 우회: 값 캡처 대신
get기반으로 읽는 writable atom/action atom 또는useAtomCallback패턴을 사용합니다.
stateRef우회 패턴 남발
- 한계: stale state를 피하려고
useRef에 최신 값을 넣는 패턴을 과하게 쓰면, atom 그래프 바깥에 숨은 상태가 늘어 추적이 어려워집니다. - 우회: ref는 브리지 용도로 최소화하고, 가능한 상태는 atom 내부에서 읽고 쓰도록 유지합니다.
- atom 간 조율 난이도
- 한계: 여러 atom을 하나의 트랜잭션처럼 동기화해야 하는 케이스에서는 흐름이 흩어지기 쉽습니다.
- 우회: “조율 전용 action atom”을 두고 내부에서 여러 atom을 한번에 갱신해 경계를 명확히 둡니다.
Redux Toolkit
- 구조 비용
- 한계: Slice, action, selector 계층이 명확한 대신 소규모 기능에서는 구현 비용이 커 보일 수 있습니다.
- 우회: 초기에 도메인 단위 slice를 작게 시작하고, 중복 패턴은 유틸/템플릿으로 고정합니다.
- selector 설계 비용
- 한계: 파생 상태를 컴포넌트에서 즉석 계산하면 중복과 리렌더링 비용이 함께 증가합니다.
- 우회: 파생 계산은 selector로 끌어올리고,
createSelector로 메모이제이션을 표준화합니다.
- 비동기 계층 복잡도
- 한계: thunk, RTK Query, listener middleware를 섞어 쓰면 팀 내 규칙이 없을 때 흐름이 빠르게 복잡해집니다.
- 우회: “서버 상태는 RTK Query, 클라이언트 비즈니스 비동기는 thunk”처럼 역할 규칙을 먼저 정합니다.
Next.js (App Router) 적용 포인트
- 서버 상태와 클라이언트 상태를 분리하는 것이 좋습니다. 서버 데이터는 TanStack Query/서버 컴포넌트, UI 상호작용 상태는 Zustand/Jotai/Redux로 분리하면 경계가 명확해집니다.
- Provider 배치는 최소화하는 것이 유리합니다. App Router에서는 필요한 경계에서만 클라이언트 Provider를 두면 번들과 렌더링 비용을 줄일 수 있습니다.
- SSR에서는 전역 싱글톤 Store 오염을 주의해야 합니다. 서버는 하나의 프로세스에서 여러 요청을 동시에 처리하기 때문에, 모듈 레벨의 전역 Store가 요청 간 상태를 공유하게 됩니다. Zustand 공식 가이드에서는 이를 방지하기 위해 컴포넌트 레벨에서 Store를 생성하는 패턴을 권장합니다.
// Zustand: 요청별 Store 생성 패턴
import { createStore } from 'zustand';
const createTodoStore = () =>
createStore((set) => ({
todos: [],
addTodo: (text) =>
set((state) => ({
todos: [...state.todos, { id: Date.now(), text, completed: false }],
})),
}));
// Context로 감싸서 요청별 격리
const TodoStoreContext = createContext(null);
export function TodoStoreProvider({ children }) {
const storeRef = useRef();
if (!storeRef.current) {
storeRef.current = createTodoStore();
}
return (
<TodoStoreContext.Provider value={storeRef.current}>
{children}
</TodoStoreContext.Provider>
);
}Jotai는 useHydrateAtoms로 서버에서 내려준 값을 클라이언트에서 복원합니다.
// Jotai: 서버 데이터 하이드레이션
'use client';
import { useHydrateAtoms } from 'jotai/utils';
function TodoPage({ initialTodos }) {
useHydrateAtoms([[todosAtom, initialTodos]]);
// ...
}persist계열 저장소 사용 시 하이드레이션 시점을 고려해야 합니다. 초기 렌더와 클라이언트 복원 시점 차이로 UI 깜빡임이 발생할 수 있습니다.
선택 기준
Zustand를 선택하면 좋은 경우
- 중소규모 프로젝트 에서 빠르게 전역 상태를 설정하고 싶을 때
- Store 하나로 관련 상태와 액션을 직관적으로 묶고 싶을 때
- React 외부(vanilla JS, 테스트)에서도 상태에 접근해야 할 때
- Provider 없이 깔끔한 구조를 원할 때
Jotai를 선택하면 좋은 경우
- 상태 간 의존 관계가 복잡 하고, 파생 상태가 많을 때
- 컴포넌트별로 세밀한 리렌더링 최적화 가 필요할 때
useState처럼 가볍게 시작하되, 전역으로 확장하고 싶을 때- 폼이나 필터처럼 독립적인 상태 조각 이 많은 UI일 때
Redux Toolkit을 선택하면 좋은 경우
- 대규모 팀 에서 일관된 상태 관리 패턴이 필요할 때
- 모든 상태 변경을 추적 하고 디버깅이 중요할 때
- RTK Query로 서버 상태까지 하나의 생태계 에서 관리하고 싶을 때
- 복잡한 비동기 흐름(saga, thunk)을 체계적으로 다뤄야 할 때
조합 전략
단일 도구로 모든 상태를 처리하기보다, 상태 성격에 맞춰 조합하는 전략이 유지보수에 유리합니다.
전역 상태 (인증, 테마) → Zustand
UI 로컬 상태 (모달, 폼) → Jotai
서버 상태 (API 캐싱) → TanStack Query일반적으로 전역 UI 상태는 Zustand, 세밀한 파생 UI 상태는 Jotai, 서버 상태는 TanStack Query로 분리하는 패턴이 안정적으로 동작합니다.
Zustand는 구현 속도, Jotai는 파생 상태 표현력, Redux Toolkit은 추적 가능성과 팀 표준화 에 강점이 있습니다.
선택의 핵심은 “이 상태를 어떤 흐름으로 관리할 것인가”를 명확히 정의하는 것입니다.
도구 자체보다 상태 경계와 변경 추적 전략을 먼저 정하면, 라이브러리 선택의 품질이 높아집니다.
참고 자료
- Zustand Documentation
- Jotai Documentation
- Redux Toolkit Documentation
- Announcing Zustand v5 | Poimandres
- Redux Style Guide
- When to use Jotai vs Zustand — Daishi Kato
- Zustand vs. Redux Toolkit vs. Jotai | Better Stack
- Redux Toolkit v2.0.0 Release
- Reselect v5.0.0 — weakMapMemoize
- Using Store Outside React — Jotai
- Setup with Next.js — Zustand
- SSR — Jotai Utilities