React 상태 관리 라이브러리 Zustand, Jotai, Redux Toolkit 비교

세 라이브러리의 설계 철학, 구독 방식, 확장 전략을 비교하고 선택 기준을 정리합니다.

Frontend
React, State Management, Zustand, Jotai

새 프로젝트를 시작할 때마다 상태 관리 라이브러리 선택에서 멈추게 됩니다.
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();
});

doubleCountAtomcountAtom에 의존합니다.
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>
  );
}

FilterButtonsfilterAtom만 구독합니다.
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와 조합하는 게 일반적입니다.


번들 사이즈와 러닝 커브

ZustandJotaiRedux 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

  1. Store 비대화(일명 God Store)
  • 한계: 전역 Store 하나에 도메인을 계속 추가하다 보면 의존성이 얽혀서 변경 영향 범위가 커집니다.
  • 우회: 도메인별 slice로 분리하고, selector를 파일 단위로 고정해 재사용하는 패턴이 안전합니다.
  1. selector 참조 안정성 이슈
  • 한계: selector에서 객체/배열을 바로 만들면 참조가 매번 바뀌어 리렌더링이 늘어날 수 있습니다.
  • 우회: 원시값 selector를 우선 사용하고, 객체가 필요하면 useShallow 또는 메모이제이션을 적용합니다.
  1. 비동기 액션 경쟁 상태
  • 한계: 동시에 실행된 비동기 액션이 늦게 끝난 응답으로 최신 상태를 덮어쓸 수 있습니다.
  • 우회: 요청 ID, abort, 최신성 체크를 액션 내부에 두고, set((prev) => ...) 기반 업데이트를 유지합니다.

Jotai

  1. 클로저 stale state
  • 한계: 비동기 콜백에서 오래된 값을 캡처하면 최신 atom 값을 놓치는 상황이 생깁니다.
  • 우회: 값 캡처 대신 get 기반으로 읽는 writable atom/action atom 또는 useAtomCallback 패턴을 사용합니다.
  1. stateRef 우회 패턴 남발
  • 한계: stale state를 피하려고 useRef에 최신 값을 넣는 패턴을 과하게 쓰면, atom 그래프 바깥에 숨은 상태가 늘어 추적이 어려워집니다.
  • 우회: ref는 브리지 용도로 최소화하고, 가능한 상태는 atom 내부에서 읽고 쓰도록 유지합니다.
  1. atom 간 조율 난이도
  • 한계: 여러 atom을 하나의 트랜잭션처럼 동기화해야 하는 케이스에서는 흐름이 흩어지기 쉽습니다.
  • 우회: “조율 전용 action atom”을 두고 내부에서 여러 atom을 한번에 갱신해 경계를 명확히 둡니다.

Redux Toolkit

  1. 구조 비용
  • 한계: Slice, action, selector 계층이 명확한 대신 소규모 기능에서는 구현 비용이 커 보일 수 있습니다.
  • 우회: 초기에 도메인 단위 slice를 작게 시작하고, 중복 패턴은 유틸/템플릿으로 고정합니다.
  1. selector 설계 비용
  • 한계: 파생 상태를 컴포넌트에서 즉석 계산하면 중복과 리렌더링 비용이 함께 증가합니다.
  • 우회: 파생 계산은 selector로 끌어올리고, createSelector로 메모이제이션을 표준화합니다.
  1. 비동기 계층 복잡도
  • 한계: 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은 추적 가능성과 팀 표준화 에 강점이 있습니다.

선택의 핵심은 “이 상태를 어떤 흐름으로 관리할 것인가”를 명확히 정의하는 것입니다.
도구 자체보다 상태 경계와 변경 추적 전략을 먼저 정하면, 라이브러리 선택의 품질이 높아집니다.


참고 자료

관련 글