React 전역 상태 관리 완벽 가이드: Context API vs Zustand vs Jotai

React 애플리케이션을 만들다 보면 상태 관리가 점점 복잡해진다. 처음엔 useState 몇 개면 충분했는데, 어느새 props를 5단계씩 내려 전달하고 있는 자신을 발견한다. Context API를 쓰면 간단할 것 같은데 성능 문제가 걱정된다. Zustand? Jotai? Redux? 선택지는 너무 많고, 각각 어떤 차이가 있는지 명확하지 않다.

이 글에서는 전역 상태가 필요한지부터 시작해서, 각 라이브러리가 내부적으로 어떻게 동작하길래 성능 차이가 나는지까지 깊이 있게 다룬다. 원리를 이해하면 프로젝트에 맞는 현명한 선택을 할 수 있다.


상태의 세 가지 종류

본격적으로 시작하기 전에, 상태를 세 가지로 구분하는 것이 중요하다.

로컬 상태(Local State)
한 컴포넌트 안에서만 사용하는 상태다. 마치 개인 수첩에 적어둔 메모와 같다. 다른 사람(컴포넌트)이 볼 필요가 없다.

function LoginForm() {
  const [email, setEmail] = useState("");
  const [password, setPassword] = useState("");
  // email, password는 이 폼에서만 필요
}ts

전역 상태(Global State)
여러 컴포넌트가 공유하는 상태다. 회사 공지 게시판처럼, 누구나 볼 수 있고 수정하면 모두에게 반영된다.

// 장바구니는 헤더, 상품 목록, 결제 페이지에서 모두 필요
const cart = useCartStore((state) => state.cart);ts

서버 상태(Server State)
API에서 가져온 데이터다. 이건 조금 특별한데, 우리가 완전히 통제할 수 없다. 다른 사용자가 바꿀 수도 있고, 네트워크가 느릴 수도 있다.

// 사용자 목록은 서버에서 가져온 데이터
const { data: users } = useQuery(["users"], fetchUsers);ts

이 글에서는 로컬 상태전역 상태에 집중한다. 서버 상태는 기회가 되면 다른 글에서 자세히 다루겠다.


Local State로만 관리하면 뭐가 문제일까?

가장 간단한 방법부터 시작해보자. useState로 모든 상태를 관리하면 안 될까?

function App() {
  const [user, setUser] = useState(null);

  return (
    <div>
      <Header user={user} />
      <Dashboard user={user} />
      <Footer user={user} />
    </div>
  );
}ts

문제없어 보인다. 하지만 컴포넌트 트리가 깊어지면 상황이 달라진다.


Props Drilling - 중간 전달자만 3명

실제 앱의 컴포넌트 구조를 보자.

function App() {
  return <Parent name="매튜" />;
}

// Parent는 name을 사용하지 않는다
function Parent({ name }: { name: string }) {
  return <Child name={name} />;
}

// Child도 name을 사용하지 않는다
function Child({ name }: { name: string }) {
  return <GrandChild name={name} />;
}

// 드디어 실제로 name을 사용한다
function GrandChild({ name }: { name: string }) {
  return <div>Hello {name}</div>;
}ts

이것이 바로 Prop Drilling이다.

name을 실제로 사용하는 건 GrandChild뿐인데, ParentChild는 단지 전달만 한다. 중간 컴포넌트들은 데이터를 사용하지 않으면서도, 단지 하위로 전달하기 위해서만 받아야 한다.

문제는 무엇일까?

  1. 불필요한 의존성: Parent, Childname을 전혀 사용하지 않는데 props로 받아야 한다
  2. 재사용 불가능: Child를 다른 곳에서 쓰려면? name props를 무조건 전달해야 한다
  3. 타입 변경 시 폭탄 돌리기: name 타입이 바뀌면 모든 중간 컴포넌트를 수정해야 한다
  4. 코드 가독성 저하: 이 컴포넌트가 실제로 뭘 사용하는지 한눈에 안 들어온다

그렇다면 어떻게 해결할까? 바로 전역 상태의 등장 배경이다.


Context API는 왜 느릴까?

createContext는 React 16.3에서 추가된 공식 해결책이다. Props Drilling 없이 데이터를 전달할 수 있다.

import { createContext, useContext, useState } from "react";

interface User {
  name: string;
  role: string;
}

const UserContext = createContext<{
  user: User | null;
  setUser: (user: User | null) => void;
} | null>(null);

export function UserProvider({ children }) {
  const [user, setUser] = useState<User | null>(null);

  return (
    <UserContext.Provider value={{ user, setUser }}>
      {children}
    </UserContext.Provider>
  );
}

export function useUser() {
  const context = useContext(UserContext);
  if (!context) {
    throw new Error("useUser는 UserProvider 안에서만 사용할 수 있다");
  }
  return context;
}ts

이제 중간 컴포넌트들이 props를 전달할 필요가 없다.

function App() {
  return (
    <UserProvider>
      <Parent />
    </UserProvider>
  );
}

// Parent는 name을 몰라도 된다
function Parent() {
  return <Child />;
}

// Child도 name을 몰라도 된다
function Child() {
  return <GrandChild />;
}

// GrandChild만 직접 가져간다
function GrandChild() {
  const { user } = useUser();
  return <div>Hello {user?.name}</div>;
}ts

깔끔하다! Props Drilling 문제가 완벽히 해결됐다.

하지만 여기엔 한계가 있다.


Context API의 한계 - 선택적 구독 불가능

Context API는 편리하지만, 규모가 커지면 성능 이슈가 생길 수 있다. 왜 그럴까? React의 내부 렌더링 메커니즘을 이해해야 한다.

Context의 렌더링 메커니즘

React의 Context는 구독-발행 패턴(Publish-Subscribe Pattern)으로 동작한다. 하지만 중요한 차이가 있다.

function UserProvider({ children }) {
  const [user, setUser] = useState<User | null>(null);
  const [theme, setTheme] = useState<"light" | "dark">("light");

  // value 객체가 새로 생성되면, Context를 사용하는 모든 컴포넌트가 재평가된다
  const value = { user, setUser, theme, setTheme };

  return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}ts

핵심 문제: usertheme하나만 바뀌어도 value 객체가 새로 생성된다. 그러면 useContext를 사용하는 모든 컴포넌트가 재렌더링된다.

// Parent는 user만 사용한다
function Parent() {
  const { user } = useUser(); // theme도 value에 포함되어 있음
  return <div>{user?.name}</div>;
}

// Child는 theme만 사용한다
function Child() {
  const { theme, setTheme } = useUser(); // user도 value에 포함되어 있음
  return (
    <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
      {theme} 모드
    </button>
  );
}ts

문제 발생 시나리오:

  1. Child에서 테마를 변경한다 (lightdark)
  2. theme state가 변경되어 value 객체가 새로 생성된다
  3. Parent도 재렌더링된다 (theme을 전혀 사용하지 않는데!)
  4. 반대로 user가 변경되면 Child도 재렌더링된다

왜 선택적 구독이 안 될까?

React의 Context는 값 전체를 하나의 단위로 취급한다. value 객체의 참조가 바뀌면, 그 안의 어떤 속성이 바뀌었는지 상관없이 모든 Consumer가 재평가된다.

마치 우유 한 팩에 여러 가지가 들어있는데, 하나만 상했는지 확인하려면 전체를 다시 검사해야 하는 것과 같다.

해결 방법들

  1. Context 분리
// UserContext와 ThemeContext를 따로 만든다
const UserContext = createContext<UserContextType | null>(null);
const ThemeContext = createContext<ThemeContextType | null>(null);

function App() {
  return (
    <UserProvider>
      <ThemeProvider>
        <Dashboard />
      </ThemeProvider>
    </UserProvider>
  );
}ts

하지만 Context가 많아지면 Provider Hell이 발생한다.

  1. useMemo로 최적화
function UserProvider({ children }) {
  const [user, setUser] = useState<User | null>(null);

  // user나 setUser가 바뀔 때만 value 재생성
  const value = useMemo(() => ({ user, setUser }), [user]);

  return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
}ts

도움이 되지만, 근본적인 해결책은 아니다. value의 어떤 부분이 바뀌었는지 여전히 추적할 수 없다.

  1. 더 나은 대안 - 전문 라이브러리

Context API의 한계는 명확하다. 선택적 구독(Selective Subscription)이 불가능하다. 전역 상태 관리 라이브러리들은 바로 이 문제를 해결하기 위해 만들어졌다.


React 18의 useSyncExternalStore - 게임 체인저

전역 상태 관리 라이브러리들이 어떻게 선택적 구독을 구현하는지 이해하려면, React 18에서 추가된 useSyncExternalStore 훅을 알아야 한다.

React 18 이전의 문제 - Tearing

React 18 이전에는 외부 스토어(React 컴포넌트 트리 밖의 상태)를 구독할 때 Tearing 문제가 발생했다.

Tearing이란? 동일한 상태를 구독하는 여러 컴포넌트가 서로 다른 값을 표시하는 현상이다.

// 외부 스토어 (React 밖)
let count = 0;
const listeners = new Set();

export const store = {
  getState: () => count,
  setState: (newCount) => {
    count = newCount;
    listeners.forEach((listener) => listener());
  },
  subscribe: (listener) => {
    listeners.add(listener);
    return () => listeners.delete(listener);
  },
};

// React 18 이전: useEffect로 구독 시도
function useStore() {
  const [state, setState] = useState(store.getState());

  useEffect(() => {
    const unsubscribe = store.subscribe(() => {
      setState(store.getState());
    });
    return unsubscribe;
  }, []);

  return state;
}

// 문제: Concurrent Rendering 시 일시적으로 다른 값 표시 가능
function Parent() {
  const count = useStore(); // count: 5
  return <div>{count}</div>;
}

function Child() {
  const count = useStore(); // count: 4 (일시적으로 다른 값!)
  return <div>{count}</div>;
}ts

React가 렌더링을 중단하고 재개할 때, 외부 스토어의 값이 바뀌면 컴포넌트마다 다른 스냅샷을 보게 되는 것이다.

useSyncExternalStore의 해결책

React 18은 이 문제를 해결하기 위해 useSyncExternalStore를 제공한다.

import { useSyncExternalStore } from "react";

function useStore() {
  return useSyncExternalStore(
    store.subscribe, // 구독 함수
    store.getState, // 상태 가져오기 함수
    store.getState // 서버 사이드 렌더링용 (선택)
  );
}ts

핵심 동작:

  1. subscribe 함수로 외부 스토어 변경을 구독한다
  2. getState 함수로 현재 상태를 가져온다
  3. React가 렌더링을 중단/재개해도 항상 동일한 스냅샷을 보장한다

ZustandJotai는 어떻게 활용할까?

Zustand는 React 18 이후 권장되는 방식대로 useSyncExternalStore 기반으로 외부 스토어를 구현하고 있다.

Jotai는 현재 useReducer + useEffect 기반의 커스텀 구독 모델을 사용하지만, useSyncExternalStore로 마이그레이션하는 RFC가 열린 상태다.

참고: Jotai RFC - Migrate to useSyncExternalStore

Zustand의 경우:

// 단순화한 Zustand 구현
function create(createState) {
  let state = createState(setState);
  const listeners = new Set();

  function setState(partial) {
    state = { ...state, ...partial };
    listeners.forEach((listener) => listener());
  }

  function subscribe(listener) {
    listeners.add(listener);
    return () => listeners.delete(listener);
  }

  function getState() {
    return state;
  }

  // useSyncExternalStore 활용
  return function useStore(selector) {
    return useSyncExternalStore(
      subscribe,
      () => selector(getState()), // selector로 선택한 부분만
      () => selector(getState())
    );
  };
}ts

Jotai의 경우 (실제 구현이 아니라, 개념을 설명하기 위한 단순화된 의사 코드):

// 단순화한 Jotai atom 구현 (실제 구현과 다를 수 있음)
function atom(initialValue) {
  let value = initialValue;
  const listeners = new Set();

  return {
    read: () => value,
    write: (newValue) => {
      value = newValue;
      listeners.forEach((listener) => listener());
    },
    subscribe: (listener) => {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
  };
}

// 개념적 예시: 실제로는 useReducer 기반이지만, 외부 스토어처럼 동작한다는 개념
function useAtom(atom) {
  const [value, setValue] = useState(atom.read());

  useEffect(() => {
    return atom.subscribe(() => setValue(atom.read()));
  }, [atom]);

  return [value, atom.write];
}ts

Jotai는 실제로는 WeakMap 기반의 atom 상태 테이블의존성 그래프를 관리하는 훨씬 복잡한 구현을 가지고 있다. 이 코드는 “각 atom이 독립적인 외부 스토어처럼 동작하고, 훅에서 이를 구독한다”는 개념만 이해하면 된다.

왜 이게 중요할까?

useSyncExternalStore 덕분에 Zustand:

  1. Concurrent Rendering과 호환되는 외부 스토어 패턴을 공식적으로 따르고
  2. Tearing 없이 안전하게 상태를 읽을 수 있으며
  3. 선택적 구독(selector 기반)이 가능하다

Jotai 역시 Concurrent Rendering을 고려한 설계를 하고 있지만, 기본 구현은 아직 useSyncExternalStore 기반이 아니고, 향후 이를 도입하는 RFC가 논의 중이다.

Context API는 React가 내부적으로 관리하는 메커니즘 덕분에 안전하지만, 값 전체를 한 덩어리로만 전달하기 때문에 “필드 단위로 선택해서 구독하는 것” 같은 미세한 제어는 기본적으로 제공하지 않는다.

반면 ZustandJotaiuseSyncExternalStore(또는 이에 준하는 외부 스토어 패턴)를 활용해 React 밖에 상태 저장소를 두고, 각 컴포넌트가 필요한 부분만 선택적으로 구독할 수 있도록 설계되어 있다.


Zustand - useSyncExternalStore로 해결하다

Zustand는 독일어로 “상태”를 뜻한다. 2025년 가장 빠르게 성장하는 상태 관리 라이브러리다.

핵심 철학: “곰의 필수품(Bear necessities)” - 정말 필요한 기능만 남기고 모두 제거했다.

import { create } from "zustand";

interface User {
  name: string;
  role: string;
}

interface UserStore {
  user: User | null;
  theme: "light" | "dark";
  setUser: (user: User | null) => void;
  setTheme: (theme: "light" | "dark") => void;
}

export const useUserStore = create<UserStore>((set) => ({
  user: null,
  theme: "light",
  setUser: (user) => set({ user }),
  setTheme: (theme) => set({ theme }),
}));ts

이게 전부다. Provider도 필요 없다.

function Parent() {
  const user = useUserStore((state) => state.user);
  return <div>{user?.name}</div>;
}

function Child() {
  const theme = useUserStore((state) => state.theme);
  const setTheme = useUserStore((state) => state.setTheme);

  return (
    <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
      {theme} 모드
    </button>
  );
}ts

Zustand의 내부 동작 원리 - 왜 빠를까?

ZustandContext API보다 빠른 이유는 선택적 구독(Selective Subscription)이 가능하기 때문이다.

  1. Module-level 저장소

Zustand의 store는 React 컴포넌트 트리 외부에 존재한다.

// store는 모듈 레벨에 생성되어 전역적으로 접근 가능
const store = createStore((set) => ({
  user: null,
  theme: "light",
  // ...
}));ts

이는 Context API와 근본적으로 다르다. ContextProvider 컴포넌트 안에 상태가 있지만, Zustand독립적인 저장소를 갖는다.

  1. Selector 기반 구독

Zustand는 컴포넌트가 필요한 부분만 선택해서 구독할 수 있다.

// user만 구독 - theme이 바뀌어도 재렌더링 안 됨
const user = useUserStore((state) => state.user);

// theme만 구독 - user가 바뀌어도 재렌더링 안 됨
const theme = useUserStore((state) => state.theme);ts

실제 Zustand 내부 구현 (useSyncExternalStore 기반):

import { useSyncExternalStore } from "react";

function create(createState) {
  // 1. 외부 store 생성 (React 밖)
  let state;
  const listeners = new Set();

  const setState = (partial) => {
    const nextState = typeof partial === "function" ? partial(state) : partial;
    if (!Object.is(nextState, state)) {
      const previousState = state;
      state = Object.assign({}, state, nextState);
      // 모든 구독자에게 알림
      listeners.forEach((listener) => listener(state, previousState));
    }
  };

  const getState = () => state;

  const subscribe = (listener) => {
    listeners.add(listener);
    return () => listeners.delete(listener); // cleanup
  };

  // 초기 상태 설정
  state = createState(setState, getState);

  // 2. React 훅 반환
  return function useStore(selector = (s) => s) {
    // useSyncExternalStore로 외부 스토어 구독
    return useSyncExternalStore(
      subscribe, // 구독 함수
      () => selector(getState()), // 현재 스냅샷
      () => selector(getState()) // SSR용 스냅샷
    );
  };
}ts

핵심 동작 원리:

  1. 외부 스토어 생성: statelisteners는 React 컴포넌트 밖에 존재한다
  2. useSyncExternalStore 활용: Tearing 없이 안전하게 외부 스토어 구독
  3. Selector 기반 구독: selector(getState())로 필요한 부분만 선택
  4. 자동 비교: useSyncExternalStore가 내부적으로 Object.is()로 비교해서 변경된 경우에만 재렌더링

왜 빠를까?

// user 변경 시
setState({ user: newUser });
// ↓
// listeners에게 알림
// ↓
// 각 컴포넌트의 useSyncExternalStore가 실행
// ↓
// selector(getState()) 재계산
// ↓
// Object.is(newValue, oldValue) 비교
// ↓
// user를 구독하는 컴포넌트만 재렌더링ts
  • Parent의 selector: (state) => state.user → 변경됨 → 재렌더링 ✅
  • Child의 selector: (state) => state.theme → 변경 안 됨 → 재렌더링 안 함 ❌

이것이 Context API와의 결정적 차이다. Contextvalue 객체 전체를 비교하지만, Zustandselector가 반환한 값만 비교한다.

성능 비교: Context API vs Zustand

100개의 컴포넌트가 있고, 각각 다른 상태를 구독한다고 가정하자.

Context API:

// user 하나만 바뀌어도
setUser(newUser);

// user를 사용하지 않는 99개 컴포넌트도 재평가된다
// (실제 재렌더링은 React.memo로 막을 수 있지만, 재평가는 발생)ts

Zustand:

// user 하나만 바뀌면
setUser(newUser);

// user를 구독하는 컴포넌트만 재렌더링된다
// 나머지 99개는 selector가 다른 값을 반환하지 않으므로 무시ts

실제 측정 결과 (커뮤니티 벤치마크):

  • Context API: 50개 필드 폼에서 하나 변경 시 ~280ms
  • Zustand: 동일 상황에서 ~45ms (약 6배 빠름)
  • 번들 사이즈: Zustand~1KBContext API보다 작음 (Context는 React 내장)

참고: React 상태 관리 성능 벤치마크

Zustand의 추가 장점

  1. 미들웨어 지원
import { persist, devtools } from "zustand/middleware";

export const useUserStore = create<UserStore>()(
  devtools(
    persist(
      (set) => ({
        user: null,
        setUser: (user) => set({ user }),
      }),
      { name: "user-storage" } // localStorage에 자동 저장
    )
  )
);ts
  1. React 외부에서 사용 가능
// 컴포넌트 밖에서도 store에 접근
const currentUser = useUserStore.getState().user;
useUserStore.getState().setUser(newUser);ts
  1. 타입 안정성

TypeScript와 완벽하게 통합된다. 타입 추론이 자동으로 동작한다.


Jotai - Atom 의존성 그래프의 마법

Jotai는 일본어로 “상태”를 뜻한다. Recoil에서 영감을 받아 만들어졌지만, 더 가볍고 간단하다.

핵심 철학: Bottom-up 원자적(Atomic) 상태 관리 - 필요한 atom만 만들고, 조합해서 사용한다.

import { atom } from "jotai";

// 기본 atom
export const userAtom = atom<User | null>(null);
export const themeAtom = atom<"light" | "dark">("light");

// 파생 atom - userAtom이 변경되면 자동으로 재계산
export const isLoggedInAtom = atom((get) => {
  const user = get(userAtom);
  return user !== null;
});

// Write atom - 로직 캡슐화
export const logoutAtom = atom(
  null, // read 함수
  (get, set) => {
    set(userAtom, null);
  }
);ts

컴포넌트에서 사용하기:

import { useAtom, useAtomValue, useSetAtom } from "jotai";

function Parent() {
  const [user, setUser] = useAtom(userAtom);
  const isLoggedIn = useAtomValue(isLoggedInAtom);

  return (
    <div>
      {isLoggedIn ? (
        <p>Hello {user?.name}</p>
      ) : (
        <button onClick={() => setUser({ name: "매튜", role: "admin" })}>
          로그인
        </button>
      )}
    </div>
  );
}

function Child() {
  const [theme, setTheme] = useAtom(themeAtom);

  return (
    <button onClick={() => setTheme(theme === "light" ? "dark" : "light")}>
      {theme} 모드
    </button>
  );
}ts

Jotai의 내부 동작 원리 - WeakMap과 의존성 그래프

Jotai의 핵심은 WeakMap 기반 Atom Store의존성 자동 추적이다.

1. WeakMap 기반 Atom Store

각 atom은 독립적인 설정(config) 객체일 뿐이다. 실제 값은 WeakMap에 저장된다.

import { useSyncExternalStore } from "react";

// Atom은 그저 설정 객체
function atom(initialValue) {
  return {
    init: initialValue,
    read: (get) => get(atom), // 기본 read 함수
    write: (get, set, update) => set(atom, update), // 기본 write 함수
  };
}

// 실제 값과 의존성은 WeakMap에 저장
const atomStateMap = new WeakMap(); // atom -> { value, listeners, dependencies }

function getAtomState(atom) {
  if (!atomStateMap.has(atom)) {
    atomStateMap.set(atom, {
      value: atom.init,
      listeners: new Set(),
      dependencies: new Set(), // 이 atom이 의존하는 다른 atom들
    });
  }
  return atomStateMap.get(atom);
}ts

왜 WeakMap일까?

  • atom 객체가 가비지 컬렉션되면 자동으로 상태도 제거된다
  • 메모리 누수를 방지한다
  • Provider별로 독립적인 WeakMap을 가질 수 있다 (스코프 분리)

2. 파생 Atom의 의존성 자동 추적

const countAtom = atom(0);
const doubleCountAtom = atom((get) => {
  const count = get(countAtom); // get 호출 시 의존성 기록
  return count * 2;
});ts

내부 동작:

function useAtom(atom) {
  const getAtomValue = (targetAtom) => {
    const atomState = getAtomState(targetAtom);

    // 파생 atom이면 의존성 추적하며 계산
    if (typeof targetAtom.read === "function") {
      const dependencies = new Set();

      const get = (dependencyAtom) => {
        dependencies.add(dependencyAtom); // 의존성 기록
        return getAtomValue(dependencyAtom); // 재귀적으로 값 가져오기
      };

      const value = targetAtom.read(get);
      atomState.dependencies = dependencies; // 의존성 저장
      atomState.value = value;

      // 의존하는 atom들을 구독
      dependencies.forEach((dep) => {
        const depState = getAtomState(dep);
        depState.listeners.add(() => {
          // 의존성이 변경되면 이 atom도 재계산
          notify(targetAtom);
        });
      });
    }

    return atomState.value;
  };

  const notify = (targetAtom) => {
    const atomState = getAtomState(targetAtom);
    atomState.listeners.forEach((listener) => listener());
  };

  // useSyncExternalStore로 atom 구독
  const value = useSyncExternalStore(
    (callback) => {
      const atomState = getAtomState(atom);
      atomState.listeners.add(callback);
      return () => atomState.listeners.delete(callback);
    },
    () => getAtomValue(atom),
    () => getAtomValue(atom)
  );

  const setValue = (update) => {
    const atomState = getAtomState(atom);
    atomState.value =
      typeof update === "function" ? update(atomState.value) : update;
    notify(atom);
  };

  return [value, setValue];
}ts

3. 의존성 그래프 예시

const userAtom = atom({ name: "매튜", age: 30 });

const userNameAtom = atom((get) => get(userAtom).name);
const doubleAgeAtom = atom((get) => get(userAtom).age * 2);ts

그래프 구조:

userAtom
  ├─> userNameAtom (의존)
  └─> doubleAgeAtom (의존)

변경 전파:

// userAtom 변경
setUserAtom({ name: "존", age: 35 });
// ↓
// userAtom의 listeners 실행
// ↓
// userNameAtom과 doubleAgeAtom 재계산
// ↓
// 각 atom을 구독하는 컴포넌트만 재렌더링ts

핵심 차이:

  • Zustand: 하나의 큰 store를 selector로 쪼개서 구독
  • Jotai: 작은 atom들을 조합해서 의존성 그래프 형성
// Zustand: Top-down
const useStore = create((set) => ({
  user: { name: "매튜", age: 30 },
  theme: "light",
}));

const userName = useStore((s) => s.user.name); // selector로 쪼개기

// Jotai: Bottom-up
const userAtom = atom({ name: "매튜", age: 30 });
const themeAtom = atom("light");
const userNameAtom = atom((get) => get(userAtom).name); // atom 조합ts
  1. Provider로 스코프 분리 (선택적)
import { Provider } from "jotai";

function App() {
  return (
    <Provider>
      <Dashboard />
    </Provider>
  );
}ts

Provider를 사용하면 atom 값을 특정 컴포넌트 트리에 격리할 수 있다. 테스트나 SSR에 유용하다.

Zustand vs Jotai - 언제 무엇을 쓸까?

두 라이브러리 모두 훌륭하지만, 철학이 다르다.

Zustand: Top-down, 단일 저장소

// 하나의 store에 모든 상태
const useStore = create((set) => ({
  user: null,
  theme: "light",
  cart: [],
  notifications: [],
  // ... 모든 전역 상태가 여기 있음
}));ts

장점:

  • 한눈에 모든 상태를 볼 수 있다
  • 디버깅이 쉽다 (하나의 store만 확인)
  • 러닝 커브가 낮다

단점:

  • 규모가 커지면 store가 거대해진다
  • 파생 상태를 만들기 다소 번거롭다

Jotai: Bottom-up, 원자 단위

// 각각 독립적인 atom
const userAtom = atom(null);
const themeAtom = atom("light");
const cartAtom = atom([]);
const notificationsAtom = atom([]);ts

장점:

  • 파생 상태가 간단하다 (atom을 조합)
  • 필요한 것만 import해서 사용
  • React Suspense와 완벽 통합

단점:

  • atom이 많아지면 관리가 복잡할 수 있다
  • 초보자에게 개념이 다소 생소함

선택 기준:

상황추천
간단한 전역 상태 몇 개Zustand
복잡한 파생 상태가 많음Jotai
팀이 Redux 패턴에 익숙함Zustand
React Suspense 적극 활용Jotai
번들 크기가 정말 중요함Zustand (~1KB)
타입 안정성이 최우선둘 다 훌륭함

세 라이브러리, 렌더링 메커니즘 비교

지금까지 배운 내용을 정리하자.

렌더링 메커니즘 비교

Context API:

// Provider의 value가 바뀌면
<UserContext.Provider value={{ user, theme }}>

// useContext를 사용하는 모든 컴포넌트가 재평가된다
const { user } = useContext(UserContext); // theme이 바뀌어도 재렌더링ts

Zustand:

// selector가 반환하는 값이 바뀔 때만 재렌더링
const user = useUserStore((state) => state.user); // theme 변경 → 재렌더링 안 됨
const theme = useUserStore((state) => state.theme); // user 변경 → 재렌더링 안 됨ts

Jotai:

// 구독한 atom이 변경될 때만 재렌더링
const [user] = useAtom(userAtom); // themeAtom 변경 → 재렌더링 안 됨
const [theme] = useAtom(themeAtom); // userAtom 변경 → 재렌더링 안 됨ts

내부 동작 메커니즘 비교

Context APIZustandJotai
저장소 위치React Component TreeModule-level (외부)WeakMap (전역)
구독 방식useContext(MyContext)useStore(selector)useAtom(myAtom)
재렌더링 조건Provider value 변경selector 반환값 변경 (Object.is)atom 값 변경 (Object.is)
선택적 구독❌ 불가능✅ selector로 가능✅ atom 단위로 가능
주요 APIcreateContext, useContextcreate, useStore, setStateatom, useAtom, useSetAtom
동기화 메커니즘React의 렌더링 사이클useSyncExternalStore커스텀 구독 모델 (향후 useSyncExternalStore 도입 논의 중)
파생 상태수동 계산 (useMemo)selector 함수atom 조합 (자동 의존성 추적)
구독자 관리React 내부Set<listener>atom별 Set<listener> (WeakMap)
상태 비교참조 비교 (===)Object.is(prev, next)Object.is(prev, next)
번들 크기0KB (내장)~1KB~4KB

핵심 차이점:

  • Context API: React의 렌더링 시스템에 의존. value가 바뀌면 모든 Consumer가 재평가된다.
  • Zustand: useSyncExternalStore로 외부 저장소를 구독. selector 반환값만 비교해서 정밀한 재렌더링.
  • Jotai: WeakMap에 atom 상태를 저장하고, 의존성 그래프를 통해 변경 사항을 전파.

실제 성능 차이 (커뮤니티 벤치마크)

성능 비교 예시 (라이브러리 저자가 만든 벤치마크 중 하나에서):

  • Context API: 수백 ms 수준 (모든 Consumer 재평가)
  • Context API + 분리/최적화: 수십~100ms 안쪽
  • Zustand / Jotai: 같은 시나리오에서 수십 ms 수준

정확한 숫자는 벤치마크 환경과 시나리오에 따라 달라지지만, 공통적으로 “Context에 하나로 몰아넣는 것보다, 선택적 구독이 가능한 라이브러리들이 재렌더링 비용을 훨씬 낮출 수 있다”는 방향성은 일관되게 나타난다.

번들 크기 (대략, min+gzip):

  • Context API: 0KB (React 내장)
  • Zustand: ~1KB
  • Jotai: ~4KB
  • Redux Toolkit: ~13KB

참고: React 상태 관리 성능 벤치마크

언제 무엇을 써야 할까?

Context API를 써도 되는 경우:

  • 상태가 자주 변경되지 않는다 (테마, 언어 설정, 현재 사용자)
  • 소규모 앱 (5개 이하의 전역 상태)
  • 추가 의존성을 원하지 않는다

Zustand를 써야 하는 경우:

  • 상태가 자주 변경된다 (장바구니, 실시간 알림)
  • 여러 컴포넌트가 다양한 상태를 구독한다
  • 간단한 API를 원한다
  • 번들 크기가 중요하다

Jotai를 써야 하는 경우:

  • 파생 상태가 많다 (계산된 값, 필터링된 목록)
  • React Suspense를 활용한다
  • Bottom-up 설계를 선호한다
  • 원자 단위로 상태를 관리하고 싶다

흔한 실수들

실무에서 자주 보는 안티패턴을 피하자.

실수 1: 모든 것을 전역으로

// ❌ 나쁜 예 - 모달 열림/닫힘도 전역으로
const useUIStore = create((set) => ({
  isLoginModalOpen: false,
  isSignupModalOpen: false,
  isDeleteConfirmOpen: false,
  // ... 모든 UI 상태를 전역으로
}));

// ✅ 좋은 예 - 로컬로 충분한 것은 로컬로
function LoginButton() {
  const [isModalOpen, setIsModalOpen] = useState(false);

  return (
    <>
      <button onClick={() => setIsModalOpen(true)}>로그인</button>
      {isModalOpen && <LoginModal onClose={() => setIsModalOpen(false)} />}
    </>
  );
}ts

원칙: 상태를 가능한 한 가까운 곳에 둬라. 이를 State Colocation이라고 한다.

실수 2: 서버 상태를 클라이언트 상태처럼

// ❌ 나쁜 예 - API 데이터를 useState에 저장
function ProductList() {
  const [products, setProducts] = useState([]);
  const [loading, setLoading] = useState(false);

  useEffect(() => {
    setLoading(true);
    fetch("/api/products")
      .then((res) => res.json())
      .then((data) => {
        setProducts(data);
        setLoading(false);
      });
  }, []);

  return loading ? <div>로딩...</div> : <ul>{/* ... */}</ul>;
}ts

주의: useEffect에서 상태를 설정할 때 의존성 배열을 정확히 지정해야 한다.

이 코드의 문제점:

  • 캐싱이 없다 (컴포넌트 unmount 시 데이터 소실)
  • 다른 컴포넌트에서 같은 데이터를 중복 요청한다
  • 에러 처리, 재시도, 낙관적 업데이트가 복잡하다
  • 동일한 데이터를 여러 컴포넌트에서 각각 관리하면 불일치가 발생한다
// ✅ 좋은 예 - TanStack Query 사용
import { useQuery } from "@tanstack/react-query";

function ProductList() {
  const { data: products, isLoading } = useQuery({
    queryKey: ["products"],
    queryFn: () => fetch("/api/products").then((res) => res.json()),
  });

  if (isLoading) return <div>로딩...</div>;
  return <ul>{/* ... */}</ul>;
}ts

핵심: 서버에서 가져온 데이터는 캐시다. 우리가 완전히 통제할 수 없다.

실수 3: Context를 성능 고려 없이 남발

// ❌ 나쁜 예 - 모든 상태를 하나의 Context에
const AppContext = createContext({
  user: null,
  theme: "light",
  language: "ko",
  cart: [],
  notifications: [],
  sidebar: { isOpen: false },
  // ... 10개 이상의 상태
});

// theme만 바뀌어도 user, cart를 사용하는 컴포넌트도 재렌더링ts
// ✅ 좋은 예 - Context 분리 또는 Zustand 사용
const useAppStore = create((set) => ({
  user: null,
  theme: "light",
  language: "ko",
  cart: [],
  // ... selector로 선택적 구독
}));ts

실수 4: 측정하지 않고 최적화

// ❌ 나쁜 예 - 성능 문제도 없는데 과도한 최적화
const memoizedUser = useMemo(() => user, [user]);
const memoizedTheme = useMemo(() => theme, [theme]);
const memoizedCallback = useCallback(() => {}, []);
// ... 모든 것에 useMemo, useCallbackts

원칙: “조기 최적화는 만악의 근원” - 먼저 측정하고, 문제가 있을 때만 최적화하라.

React DevTools Profiler로 측정 후 판단하자.

최근에는 React Compiler를 활용하는 방법도 방법일 수 있다.


언제 무엇을 선택할 것인가

지금까지 배운 내용을 바탕으로 실전 전략을 정리하자.

단계별 의사결정 플로우

1. 이 상태를 여러 컴포넌트가 공유하나?
   NO → useState (로컬 상태)
   YES → 다음으로

2. 상태가 자주 변경되나? (1초에 여러 번)
   NO → `Context API`도 충분
   YES → 다음으로

3. 파생 상태가 많나? (계산된 값, 필터링)
   YES → `Jotai` (원자적 상태 + 의존성 그래프)
   NO → `Zustand` (간단한 전역 상태)

4. 서버에서 가져온 데이터인가?
   YES → TanStack Query (다음 글에서 다룸)

Context API를 선택해야 하는 경우

전역적으로 모든 컴포넌트에 영향을 주는 상태일 때:

// ✅ 좋은 예 - 인증 상태
function App() {
  return (
    <AuthProvider>
      <Header /> {/* 로그인 상태에 따라 UI 변경 */}
      <Sidebar /> {/* 권한에 따라 메뉴 표시 */}
      <Content /> {/* 사용자 정보 표시 */}
    </AuthProvider>
  );
}ts

적합한 사례:

  • 인증 상태: 로그인/로그아웃 시 전체 앱의 UI가 변경된다
  • 테마: light/dark 전환 시 모든 컴포넌트의 스타일이 바뀐다
  • 다국어 설정: 언어 변경 시 모든 텍스트가 변경된다
  • 현재 사용자 정보: 프로필 이미지, 이름 등 여러 곳에서 표시된다

주의사항:

  • 상태 변경 빈도가 낮아야 한다 (하루에 몇 번 정도)
  • 정말로 모든 컴포넌트가 영향을 받는지 확인하라
  • 자주 변경되는 상태(장바구니, 알림)는 피하라

Zustand를 선택해야 하는 경우

빠르게 변경되는 전역 상태를 다룰 때:

// ✅ 좋은 예 - 장바구니
const useCartStore = create((set) => ({
  items: [],
  addItem: (item) =>
    set((state) => ({
      items: [...state.items, item],
    })),
  // updateQuantity, removeItem, clearCart 등 추가 액션들...
}));

// 여러 컴포넌트가 서로 다른 부분만 구독
function CartButton() {
  const itemCount = useCartStore((s) => s.items.length);
  return <button>장바구니 ({itemCount})</button>;
}

function TotalPrice() {
  const total = useCartStore((s) =>
    s.items.reduce((sum, item) => sum + item.price * item.quantity, 0)
  );
  return <div> {total}</div>;
}ts

적합한 사례:

  • 장바구니: 실시간 수량 변경, 상품 추가/삭제가 빈번하다
  • 알림 시스템: 새 알림 도착, 읽음 처리가 자주 발생한다
  • UI 상태: 사이드바 열림/닫힘, 모달 관리, 탭 전환
  • 필터/정렬 상태: 검색 필터, 정렬 기준이 자주 바뀐다

장점:

  • 번들 크기 1KB 안팎으로 가볍다 (추가 의존성 부담 없음)
  • Redux 패턴에 익숙한 팀이 쉽게 적용할 수 있다
  • DevTools 지원으로 디버깅이 편하다
  • 간단한 API (러닝 커브가 낮음)

Jotai를 선택해야 하는 경우

복잡한 파생 상태가 많을 때:

// ✅ 좋은 예 - 파생 상태 조합
const productsAtom = atom([
  /* 제품 리스트 */
]);
const categoryFilterAtom = atom("전자기기");
const minPriceAtom = atom(0);
const maxPriceAtom = atom(1000000);

// atom을 조합해서 파생 상태 생성
const filteredProductsAtom = atom((get) => {
  const products = get(productsAtom);
  const category = get(categoryFilterAtom);
  const minPrice = get(minPriceAtom);
  const maxPrice = get(maxPriceAtom);

  return products.filter(
    (p) => p.category === category && p.price >= minPrice && p.price <= maxPrice
  );
});

// avgPriceAtom, totalCountAtom 등 추가 파생 atom도 동일한 방식으로 조합 가능ts

컴포넌트에서는 이 atom들을 조합해서 사용한다. categoryFilterAtom이 바뀌면 filteredProductsAtom이 자동으로 재계산되고, 이를 구독하는 컴포넌트만 재렌더링된다.

적합한 사례:

  • 필터링된 리스트: 검색어, 카테고리, 정렬 기준을 조합한 복잡한 필터
  • 계산된 값: 합계, 평균, 통계를 여러 atom에서 파생
  • 의존성 체인: A atom → B atom → C atom처럼 연쇄적인 의존성
  • 비동기 상태 + Suspense: 데이터 로딩을 Suspense로 처리

장점:

  • 파생 상태 관리가 매우 간단하다 (atom 조합만으로 해결)
  • TypeScript와 완벽 호환된다
  • React Suspense와 완벽 통합된다
  • 필요한 atom만 만들어서 사용 (Bottom-up)

라이브러리 조합 예시

// Client State는 Zustand로
const useUIStore = create((set) => ({
  sidebar: { isOpen: false },
  modal: { activeModal: null },
  theme: "light",
}));

// Server State는 TanStack Query로
function ProductList() {
  const { data, isLoading } = useQuery({
    queryKey: ["products"],
    queryFn: fetchProducts,
  });

  const sidebar = useUIStore((state) => state.sidebar);

  // Server State와 Client State를 함께 사용
}ts

핵심: 한 가지 라이브러리만 써야 하는 것이 아니다. 목적에 맞게 조합하는 것이 현명하다.


마무리 - 서버 상태의 세계로

지금까지 로컬 상태전역 상태를 깊이 있게 다뤘다. Props Drilling 문제부터 시작해서, Context API의 성능 한계, Zustand와 Jotai의 내부 동작 원리까지 살펴봤다.

하지만 실무에서 개발하다 보면 또 다른 문제를 마주한다.

이런 경험 있지 않나?

좋아요 버튼을 눌렀다. 네트워크 탭을 보니 API 요청은 성공했다 (200 OK). 그런데 UI에는 아무 변화가 없다. 새로고침을 해보니 좋아요가 취소되어 있다. 분명히 눌렀는데!

// 이렇게 작성했을 것이다
function LikeButton({ postId }) {
  const [isLiked, setIsLiked] = useState(false);

  const handleLike = async () => {
    setIsLiked(true); // 먼저 UI 업데이트

    try {
      await fetch(`/api/posts/${postId}/like`, { method: "POST" });
    } catch (error) {
      setIsLiked(false); // 실패하면 되돌리기
      // 그런데 성공했는데도 서버 상태와 안 맞는 경우는?
    }
  };

  return <button onClick={handleLike}>{isLiked ? "❤️" : "🤍"}</button>;
}ts

문제의 본질: 지금까지 다룬 상태 관리는 모두 클라이언트 상태였다. 우리가 완전히 통제할 수 있는 상태. 테마를 light로 설정하면, 다른 요인에 의해 갑자기 dark로 바뀌지 않는다.

하지만 서버 상태는 다르다:

  • 우리가 완전히 통제할 수 없다 (다른 사용자가 바꿀 수 있음)
  • 네트워크 지연이 있다 (비동기)
  • 신선도가 있다 (시간이 지나면 만료됨)
  • 캐싱이 필요하다 (같은 데이터를 반복 요청하면 낭비)

useStateZustand로 서버 데이터를 관리하려면:

  • 수동으로 캐싱 로직을 작성해야 한다
  • 낙관적 업데이트(Optimistic Update)를 직접 구현해야 한다
  • 데이터 동기화(서버 ↔ 클라이언트)를 신경 써야 한다
  • 에러 처리, 재시도, 로딩 상태를 모두 관리해야 한다

이것이 바로 TanStack Query(React Query), SWR, RTK Query 같은 도구가 필요한 이유다.


핵심 정리

로컬 상태는 한 컴포넌트에서만 사용한다. 폼 입력값, 모달 열림/닫힘처럼 공유할 필요 없는 상태는 useState로 충분하다.

Props Drilling은 여러 단계를 거쳐 props를 전달해야 하는 문제다. 중간 컴포넌트가 사용하지도 않는 값을 전달만 하게 된다.

Context API는 Props Drilling을 해결하지만, Provider의 value가 바뀌면 모든 Consumer가 재평가된다. 선택적 구독이 불가능하다.

Zustandmodule-level 저장소selector 기반 구독으로 성능 문제를 해결한다. 필요한 값만 선택해서 구독하므로, 다른 값이 바뀌어도 재렌더링되지 않는다. 번들 크기 약 1KB로 매우 가볍다.

Jotai원자 단위 상태의존성 그래프로 파생 상태를 우아하게 관리한다. Bottom-up 방식으로 필요한 atom만 만들고 조합한다. React Suspense와 완벽 통합된다.

성능 차이는 렌더링 메커니즘에서 나온다:

  • Context API: value 변경 → 모든 Consumer 재평가
  • Zustand: selector 값 변경 → 해당 컴포넌트만 재렌더링
  • Jotai: atom 변경 → 의존하는 컴포넌트만 재렌더링

선택 기준:

  • 전역적으로 모든 컴포넌트에 영향 (인증, 테마) → Context API
  • 빠르게 변경되는 전역 상태 (장바구니, 알림) → Zustand
  • 복잡한 파생 상태 (필터링, 계산) → Jotai
  • 서버 데이터 (API 응답, 캐싱) → TanStack Query

핵심 원칙: 가장 간단한 해결책부터 시작하라. useStateContext APIZustand/Jotai 순서로 복잡도를 높여간다. 성능 문제는 측정 후에 최적화한다.