dev.syw

Context의 한계를 넘어 Zustand·Redux·Jotai 등으로 확장 가능한 상태 설계.

상태 관리 아키텍처와 외부 스토어

입문편의 Context API와 useReducer는 전역 상태를 공유하는 훌륭한 출발점입니다. 그러나 애플리케이션이 성장하면서 "왜 이 컴포넌트까지 리렌더가 일어나는가?", "서버에서 받아온 데이터와 UI 인터랙션 상태를 어떻게 분리해야 하는가?"와 같은 질문과 맞닥뜨리게 됩니다. 이 레슨은 그 질문들에 답하기 위해 Context의 구조적 한계를 명확히 진단하고, Zustand·Redux Toolkit·Jotai와 같은 외부 스토어가 어떤 원칙으로 그 한계를 해결하는지 설계 수준에서 이해하는 데 집중합니다.

단순히 라이브러리 사용법을 익히는 것을 넘어, 어떤 상황에서 어떤 선택이 적절한지 판단할 수 있는 기준과 실무에서 바로 적용 가능한 패턴을 함께 다룹니다.

학습 목표

  • Context API의 리렌더 전파 구조를 이해하고, 언제 외부 스토어가 필요한지 판단할 수 있다.
  • 서버 상태클라이언트 상태를 개념적으로 분리하고, 각각에 적합한 도구를 선택할 수 있다.
  • Zustand, Redux Toolkit, Jotai의 핵심 차이를 비교하고 프로젝트 특성에 맞게 고를 수 있다.
  • 선택자(selector) 기반 구독으로 불필요한 리렌더를 최소화하는 방법을 구현할 수 있다.
  • 정규화(normalization) 된 상태 구조와 미들웨어·devtools를 활용한 디버깅 전략을 적용할 수 있다.

Context의 리렌더 한계와 거대 전역 상태의 문제

Context는 구독 기반이 아니라 값 비교 기반입니다. Provider의 value prop이 새 참조로 바뀌면, useContext를 호출하는 모든 컴포넌트가 리렌더됩니다. 이를 회피하려면 값을 useMemo·useCallback으로 안정화해야 하지만, 하나의 Context에 여러 상태를 묶어 두면 그 중 하나만 바뀌어도 전체 구독자가 리렌더되는 구조적 문제가 남습니다.

// ❌ 하나의 Context에 연관 없는 상태를 묶으면 리렌더 폭발
const AppContext = createContext<{
  user: User;
  cart: CartItem[];
  theme: string;
  notifications: Notification[];
} | null>(null);

function AppProvider({ children }: { children: React.ReactNode }) {
  const [user, setUser] = useState<User>(initialUser);
  const [cart, setCart] = useState<CartItem[]>([]);
  const [theme, setTheme] = useState("light");
  const [notifications, setNotifications] = useState<Notification[]>([]);

  // cart가 바뀌어도 theme만 쓰는 컴포넌트까지 리렌더됨
  const value = { user, cart, theme, notifications };

  return <AppContext.Provider value={value}>{children}</AppContext.Provider>;
}
// ✅ Context를 관심사별로 쪼개면 범위를 좁힐 수 있다
const ThemeContext = createContext<string>("light");
const CartContext = createContext<CartItem[]>([]);
// 하지만 컨텍스트 수가 늘어날수록 Provider 중첩도 증가한다

Context를 쪼개는 것은 단기 처방입니다. 상태가 10개를 넘어서거나, 서로 다른 트리 깊이의 컴포넌트들이 같은 상태의 서로 다른 슬라이스를 구독해야 할 때 Context만으로는 유지보수가 어려워집니다.

⚠️ 주의 Context 분리가 능사는 아닙니다. Provider가 20개가 되면 오히려 흐름 추적이 어려워집니다. 상태 크기와 구독 패턴을 함께 고려해 외부 스토어 도입 시점을 결정하세요.

서버 상태 vs 클라이언트 상태의 분리 원칙

상태를 잘못 관리하는 가장 흔한 실수 중 하나는 서버에서 온 데이터를 클라이언트 상태처럼 Redux/Zustand 스토어에 그대로 저장하는 것입니다. 두 종류의 상태는 성격이 근본적으로 다릅니다.

구분서버 상태클라이언트 상태
소유자서버/DB브라우저
최신성언제든 stale해질 수 있음앱 메모리와 항상 동기
캐싱필요 (cache invalidation)일반적으로 불필요
공유여러 클라이언트가 공유이 세션에만 존재
적합한 도구TanStack Query, SWRZustand, Jotai, Redux
// ✅ 서버 상태는 TanStack Query로, 클라이언트 UI 상태는 Zustand로 분리
import { useQuery } from "@tanstack/react-query";
import { create } from "zustand";

// 서버 상태: 캐싱·재검증·낙관적 업데이트를 Query가 담당
function useProducts() {
  return useQuery({
    queryKey: ["products"],
    queryFn: () => fetch("/api/products").then((r) => r.json()),
    staleTime: 1000 * 60, // 1분간 fresh 유지
  });
}

// 클라이언트 상태: 사이드바 열림 여부, 선택된 필터 등
interface UIState {
  sidebarOpen: boolean;
  selectedFilter: string;
  toggleSidebar: () => void;
  setFilter: (filter: string) => void;
}

const useUIStore = create<UIState>((set) => ({
  sidebarOpen: false,
  selectedFilter: "all",
  toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
  setFilter: (filter) => set({ selectedFilter: filter }),
}));

이 분리 원칙만 지켜도 전역 스토어의 복잡도가 절반 이하로 줄어드는 경우가 많습니다. 서버 상태를 Zustand에 넣으면 캐시 무효화, 폴링, 낙관적 업데이트 같은 로직을 직접 구현해야 하지만, TanStack Query에 맡기면 이 모든 것이 선언적으로 해결됩니다.

외부 스토어 라이브러리 비교: Zustand / Redux Toolkit / Jotai

세 라이브러리는 철학부터 다릅니다. 각각의 핵심 개념과 설계 판단 기준을 비교해 보겠습니다.

Zustand — 최소한의 보일러플레이트

// store/counterStore.ts
import { create } from "zustand";
import { devtools, persist } from "zustand/middleware";

interface CounterState {
  count: number;
  increment: () => void;
  decrement: () => void;
  reset: () => void;
}

export const useCounterStore = create<CounterState>()(
  devtools(
    persist(
      (set) => ({
        count: 0,
        increment: () => set((s) => ({ count: s.count + 1 }), false, "increment"),
        decrement: () => set((s) => ({ count: s.count - 1 }), false, "decrement"),
        reset: () => set({ count: 0 }, false, "reset"),
      }),
      { name: "counter-storage" }
    ),
    { name: "CounterStore" }
  )
);

// 컴포넌트에서 선택자로 구독 (아래 섹션에서 상세 설명)
function Counter() {
  const count = useCounterStore((s) => s.count);
  const increment = useCounterStore((s) => s.increment);
  return <button onClick={increment}>{count}</button>;
}

Redux Toolkit — 대규모 팀, 엄격한 패턴

// store/cartSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from "@reduxjs/toolkit";

interface CartItem {
  id: string;
  name: string;
  quantity: number;
  price: number;
}

interface CartState {
  items: Record<string, CartItem>; // 정규화: id를 키로 사용
  status: "idle" | "loading" | "failed";
}

export const fetchCart = createAsyncThunk("cart/fetch", async (userId: string) => {
  const response = await fetch(`/api/cart/${userId}`);
  return response.json() as Promise<CartItem[]>;
});

const cartSlice = createSlice({
  name: "cart",
  initialState: { items: {}, status: "idle" } as CartState,
  reducers: {
    addItem(state, action: PayloadAction<CartItem>) {
      const item = action.payload;
      if (state.items[item.id]) {
        state.items[item.id].quantity += 1; // Immer 덕분에 직접 변이 가능
      } else {
        state.items[item.id] = item;
      }
    },
    removeItem(state, action: PayloadAction<string>) {
      delete state.items[action.payload];
    },
  },
  extraReducers: (builder) => {
    builder
      .addCase(fetchCart.pending, (state) => { state.status = "loading"; })
      .addCase(fetchCart.fulfilled, (state, action) => {
        state.status = "idle";
        action.payload.forEach((item) => { state.items[item.id] = item; });
      });
  },
});

export const { addItem, removeItem } = cartSlice.actions;
export default cartSlice.reducer;

Jotai — 아토믹(atomic) 접근

// atoms/filterAtoms.ts
import { atom, useAtom, useAtomValue, useSetAtom } from "jotai";
import { atomWithStorage } from "jotai/utils";

interface Product {
  id: string;
  name: string;
  category: string;
}

// 기본 아톰
const searchQueryAtom = atom("");
const categoryAtom = atomWithStorage("category", "all"); // localStorage 연동
const productsAtom = atom<Product[]>([]); // 상품 원본 (서버 연동 시 atomWithQuery로 대체)

// 파생 아톰 (selector와 동일)
const filteredProductsAtom = atom((get) => {
  const query = get(searchQueryAtom).toLowerCase();
  const category = get(categoryAtom);
  const products = get(productsAtom);

  return products.filter(
    (p) =>
      (category === "all" || p.category === category) &&
      p.name.toLowerCase().includes(query)
  );
});

function ProductFilter() {
  const [query, setQuery] = useAtom(searchQueryAtom);
  const filteredProducts = useAtomValue(filteredProductsAtom);

  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <span>{filteredProducts.length}개 결과</span>
    </div>
  );
}

라이브러리 선택 기준 요약

기준ZustandRedux ToolkitJotai
보일러플레이트최소중간 (슬라이스 구조)매우 적음
팀 규모소~중중~대소~중
DevToolsRedux DevTools 연동 가능네이티브 지원Jotai DevTools
파생 상태selector 함수createSelector (Reselect)파생 아톰
비동기스토어 내 async 함수createAsyncThunk, RTK Queryjotai/utils atomWithQuery
TypeScript우수우수우수

💡 TIP 팀 전체가 Redux에 익숙하다면 RTK가 가장 안전합니다. 새 프로젝트를 소규모 팀에서 빠르게 시작한다면 Zustand가 진입 장벽이 낮습니다. React Suspense와 긴밀하게 통합하고 싶다면 Jotai의 아토믹 모델이 적합합니다.

선택자(Selector) 기반 구독과 리렌더 최소화

외부 스토어의 핵심 이점은 구독하는 값이 바뀔 때만 해당 컴포넌트를 리렌더할 수 있다는 점입니다. 이를 위해 각 라이브러리가 제공하는 선택자 패턴을 제대로 이해해야 합니다.

// Zustand에서 선택자 제대로 쓰기

interface StoreState {
  user: { name: string; email: string; avatar: string };
  cart: CartItem[];
  preferences: { theme: string; language: string };
}

const useStore = create<StoreState>()(() => ({
  user: { name: "Alice", email: "alice@example.com", avatar: "/img/alice.png" },
  cart: [],
  preferences: { theme: "light", language: "ko" },
}));

// ❌ 스토어 전체를 구독하면 어떤 값이 바뀌어도 리렌더
function BadComponent() {
  const store = useStore(); // 전체 구독
  return <div>{store.user.name}</div>;
}

// ✅ 필요한 슬라이스만 선택자로 구독
function GoodComponent() {
  const userName = useStore((s) => s.user.name); // name만 바뀔 때 리렌더
  return <div>{userName}</div>;
}

// ✅ 여러 값을 하나의 구독으로 묶을 때는 shallow 비교 활용
import { useShallow } from "zustand/react/shallow";

function UserProfile() {
  const { name, email } = useStore(
    useShallow((s) => ({ name: s.user.name, email: s.user.email }))
  );
  return <div>{name} ({email})</div>;
}

Redux Toolkit에서는 createSelector(Reselect)로 메모이제이션된 파생 선택자를 만듭니다.

// store/selectors.ts
import { createSelector } from "@reduxjs/toolkit";
import type { RootState } from "./store";

const selectCartItems = (state: RootState) => state.cart.items;

// 파생 선택자: 입력이 같으면 재계산하지 않음
export const selectCartTotal = createSelector(selectCartItems, (items) =>
  Object.values(items).reduce((sum, item) => sum + item.price * item.quantity, 0)
);

export const selectCartCount = createSelector(selectCartItems, (items) =>
  Object.values(items).reduce((sum, item) => sum + item.quantity, 0)
);

// 컴포넌트에서
function CartBadge() {
  const count = useSelector(selectCartCount); // count가 바뀔 때만 리렌더
  return <span>{count}</span>;
}

⚠️ 주의 useSelector나 Zustand 선택자에서 () => ({ a: s.a, b: s.b }) 형태로 새 객체를 매번 생성하면, 참조 비교로 인해 항상 리렌더가 발생합니다. useShallow 또는 createSelector로 반드시 안정화하세요.

불변성과 정규화된 상태 구조 설계

상태가 커질수록 구조 설계가 성능과 유지보수성을 좌우합니다. 두 가지 원칙이 핵심입니다.

불변성(Immutability): 상태를 직접 변이하지 않고 새 참조를 생성해야 React가 변경을 감지합니다. Redux Toolkit은 Immer를 내장해 직접 변이처럼 보이는 코드를 불변 업데이트로 변환해 줍니다. Zustand도 immer 미들웨어를 지원합니다.

import { create } from "zustand";
import { immer } from "zustand/middleware/immer";

interface TodoState {
  todos: Record<string, { id: string; text: string; done: boolean }>;
  toggleTodo: (id: string) => void;
}

const useTodoStore = create<TodoState>()(
  immer((set) => ({
    todos: {},
    toggleTodo: (id) =>
      set((state) => {
        // ✅ Immer 덕분에 직접 변이처럼 작성해도 불변 업데이트
        if (state.todos[id]) {
          state.todos[id].done = !state.todos[id].done;
        }
      }),
  }))
);

정규화(Normalization): 서버에서 배열로 받아온 데이터를 그대로 배열로 저장하면, 특정 항목을 찾거나 수정할 때 O(n) 탐색이 필요합니다. id를 키로 하는 맵(Record)으로 정규화하면 O(1)로 줄어듭니다.

// ❌ 중첩 배열 구조 — 업데이트와 조회가 복잡
interface BadState {
  posts: Array<{
    id: string;
    title: string;
    comments: Array<{ id: string; text: string }>;
  }>;
}

// ✅ 정규화 구조 — 각 엔티티를 id 맵으로 분리
interface NormalizedState {
  posts: Record<string, { id: string; title: string; commentIds: string[] }>;
  comments: Record<string, { id: string; postId: string; text: string }>;
}

// RTK의 createEntityAdapter는 이 패턴을 자동화해 줍니다
import { createEntityAdapter, createSlice } from "@reduxjs/toolkit";

const postsAdapter = createEntityAdapter<Post>();

const postsSlice = createSlice({
  name: "posts",
  initialState: postsAdapter.getInitialState(),
  reducers: {
    postAdded: postsAdapter.addOne,
    postUpdated: postsAdapter.updateOne,
    postRemoved: postsAdapter.removeOne,
  },
});

// selectAll, selectById 등의 선택자가 자동 생성됨
export const { selectAll: selectAllPosts, selectById: selectPostById } =
  postsAdapter.getSelectors((state: RootState) => state.posts);

미들웨어·DevTools를 통한 상태 추적과 디버깅

프로덕션 레벨 애플리케이션에서 상태 버그를 추적하려면 미들웨어와 DevTools가 필수입니다.

Zustand 미들웨어 체이닝

import { create } from "zustand";
import { devtools, persist, subscribeWithSelector } from "zustand/middleware";
import { immer } from "zustand/middleware/immer";

const useStore = create<MyState>()(
  devtools(           // Redux DevTools 연동
    persist(          // localStorage 영속화
      subscribeWithSelector( // 선택자 기반 외부 구독
        immer(        // Immer 불변 업데이트
          (set, get) => ({
            // 상태 정의
          })
        )
      ),
      { name: "my-app-storage" }
    ),
    { name: "MyStore" }
  )
);

// subscribeWithSelector로 스토어 외부에서 특정 상태 변화 감지
const unsubscribe = useStore.subscribe(
  (state) => state.count,
  (count) => {
    console.log("count changed:", count);
    // 분석 이벤트 전송, 로컬 캐시 갱신 등
  }
);

Redux Toolkit DevTools 활용

RTK는 Redux DevTools Extension과 네이티브로 연동됩니다. 액션 이름(slice명/액션명)이 자동으로 의미 있게 붙어 타임라인에서 추적하기 쉽습니다.

// store/store.ts
import { configureStore } from "@reduxjs/toolkit";
import cartReducer from "./cartSlice";

export const store = configureStore({
  reducer: {
    cart: cartReducer,
  },
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(
      // 커스텀 미들웨어: 액션 로깅
      (storeAPI) => (next) => (action) => {
        console.group(`[action] ${(action as any).type}`);
        console.log("prev state:", storeAPI.getState());
        const result = next(action);
        console.log("next state:", storeAPI.getState());
        console.groupEnd();
        return result;
      }
    ),
  devTools: process.env.NODE_ENV !== "production",
});

export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

💡 TIP DevTools를 프로덕션에서도 활성화해야 하는 상황이라면 devTools: { maxAge: 50 } 처럼 히스토리 크기를 제한해 메모리 사용량을 통제하세요.

상태 코로케이션과 끌어올리기의 트레이드오프

전역 스토어를 도입한다고 해서 모든 상태를 전역으로 올려야 한다는 뜻은 아닙니다. 상태를 어디에 두느냐는 다음 질문으로 결정합니다.

"이 상태를 몇 개의 컴포넌트가 필요로 하는가?"

단일 컴포넌트 → useState (코로케이션)
부모-자식 간 → props / useReducer
사촌/형제 → 공통 부모로 끌어올리기 (state lifting)
트리 전반 → Context 또는 외부 스토어
서버 데이터 → TanStack Query / SWR
// ✅ 폼 내부 상태는 전역이 아닌 로컬에 코로케이션
function CheckoutForm() {
  // 이 폼에서만 쓰이는 상태 — 전역으로 올릴 이유 없음
  const [cardNumber, setCardNumber] = useState("");
  const [expiry, setExpiry] = useState("");

  // 장바구니는 전역 스토어에서
  const cartTotal = useCartStore((s) => s.total);

  return (
    <form>
      <input value={cardNumber} onChange={(e) => setCardNumber(e.target.value)} />
      <input value={expiry} onChange={(e) => setExpiry(e.target.value)} />
      <p>결제 금액: {cartTotal}원</p>
    </form>
  );
}

전역 스토어를 과도하게 사용하면 컴포넌트 간 결합도가 높아져 테스트와 재사용이 어려워집니다. 반대로 끌어올리기를 지나치게 하면 prop drilling이 발생합니다. "가능한 한 가깝게, 필요한 만큼만 올린다" 는 원칙이 실무에서 가장 유용합니다.

⚠️ 주의 서버 컴포넌트(RSC) 환경에서는 Zustand·Redux 같은 클라이언트 스토어를 서버 컴포넌트 내부에서 직접 사용할 수 없습니다. 클라이언트 바운더리("use client")를 명확히 설정하고, 서버에서 클라이언트로 초기 데이터를 주입하는 패턴을 별도로 설계해야 합니다.

요약

  • Context는 값 비교 기반이라 구독하는 모든 컴포넌트를 리렌더시키며, 상태가 커질수록 성능 문제가 생긴다.
  • 서버 상태(TanStack Query/SWR)와 클라이언트 상태(Zustand/RTK/Jotai)를 분리하면 스토어 복잡도가 크게 줄어든다.
  • Zustand는 보일러플레이트 최소, RTK는 대규모 팀·엄격한 패턴, Jotai는 아토믹·Suspense 친화적으로 각각 다른 시나리오에 적합하다.
  • 선택자(selector) 로 필요한 값만 구독하고, 객체 선택자에는 얕은 비교(useShallow/createSelector) 를 적용해 리렌더를 최소화한다.
  • 상태 구조는 정규화(id 맵) 로 설계해 업데이트와 조회를 O(1)로 유지하고, Immer로 불변성 부담을 줄인다.
  • 미들웨어와 DevTools를 통해 액션 흐름과 상태 변화를 타임라인에서 추적하면 디버깅 속도가 크게 향상된다.

연습문제

  1. 다음 코드에서 ThemeToggle 컴포넌트가 user.name이 바뀔 때도 리렌더되는 이유를 설명하고, Zustand 선택자를 사용해 리렌더를 막도록 수정하세요.
const useStore = create<{
  user: { name: string };
  theme: string;
  setName: (name: string) => void;
}>()((set) => ({
  user: { name: "Alice" },
  theme: "light",
  setName: (name) => set((s) => ({ user: { ...s.user, name } })),
}));

function ThemeToggle() {
  const store = useStore();
  return <button>{store.theme}</button>;
}

힌트 useStore((s) => s.theme) 형태로 필요한 값만 구독하면 해당 값이 바뀔 때만 리렌더됩니다.

  1. 아래처럼 배열로 관리되는 comments 상태를 정규화된 Record<string, Comment> 구조로 바꾸고, updateComment 액션을 RTK createSlice로 구현하세요. 배열 방식 대비 업데이트 로직이 어떻게 단순해지는지 비교하세요.
// 변경 전
interface BadState {
  comments: Array<{ id: string; text: string; likes: number }>;
}

힌트 createEntityAdapter를 쓰거나, state.comments[id].text = newText 처럼 Immer 스타일로 직접 작성할 수 있습니다.

  1. Zustand 스토어에 devtoolspersist 미들웨어를 동시에 적용하고, 스토어 외부(컴포넌트 바깥)에서 count 값이 10을 초과할 때 콘솔 경고를 출력하는 구독 코드를 작성하세요.

힌트 subscribeWithSelector 미들웨어와 useStore.subscribe(selector, listener) 패턴을 조합하세요.

  1. 쇼핑몰 앱에서 다음 상태들을 "서버 상태"와 "클라이언트 상태"로 분류하고, 각각에 적합한 도구를 이유와 함께 추천하세요: ① 상품 목록, ② 장바구니 수량, ③ 모달 열림 여부, ④ 로그인한 유저 프로필, ⑤ 검색 키워드.

힌트 "이 데이터가 서버/DB에 존재하는가?"를 기준으로 분류하세요.

💡 연습문제 풀이

불러오는 중…

함께 보면 좋은 자료

댓글 0

React.js 심화” 강좌에 대한 댓글입니다.

댓글을 작성하려면 로그인이 필요합니다.