상태관리

React 강좌 10강 — Context API + useReducer: 전역 상태 관리 첫걸음

🎯 학습 목표

  • Prop drilling 문제를 이해하고 Context로 해결할 수 있다
  • createContext → Provider → useContext 세 단계 구조를 구현한다
  • useReducer와 Context를 결합한 완전한 상태 관리 패턴을 구현한다
  • Context 성능 이슈와 useMemo 해결책을 이해한다
  • Context 분리 전략을 알고 적용할 수 있다

📖 핵심 개념 1 — Prop Drilling 문제

컴포넌트 트리가 깊어지면 중간 컴포넌트가 사용하지 않는 props를 단순히 전달하기 위해 받아야 하는 상황이 생깁니다. 이를 Prop Drilling이라고 합니다.

// ❌ Prop Drilling — user를 사용하지 않는 중간 컴포넌트도 받아서 전달해야 함
function App() {
  const [user, setUser] = useState({ name: '김개발', role: 'admin' });
  return <PageLayout user={user} />;
}

function PageLayout({ user }) {
  // PageLayout은 user를 직접 사용하지 않음 — 단지 전달만 함
  return <Header user={user} />;
}

function Header({ user }) {
  // Header도 user를 직접 사용하지 않음 — 또 전달
  return <UserMenu user={user} />;
}

function UserMenu({ user }) {
  // 여기서 처음으로 user를 실제로 사용
  return <p>{user.name}님 환영합니다</p>;
}
// 3단계 drilling이지만 실제 앱에서는 5~10단계가 되기도 합니다

📖 핵심 개념 2 — Context 3단계 구조

// contexts/UserContext.jsx

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

// 1단계: Context 생성 (기본값은 useContext를 Provider 밖에서 쓸 때 사용됨)
const UserContext = createContext(null);

// Provider 컴포넌트 — Context 값을 트리에 제공
export function UserProvider({ children }) {
  const [user, setUser] = useState(null);

  function login(userData) {
    setUser(userData);
  }

  function logout() {
    setUser(null);
  }

  // 2단계: Provider로 값 제공
  return (
    <UserContext.Provider value={{ user, login, logout }}>
      {children}
    </UserContext.Provider>
  );
}

// 커스텀 훅으로 래핑 — Provider 밖에서 사용 시 에러를 명확하게
export function useUser() {
  const context = useContext(UserContext);
  if (!context) {
    throw new Error('useUser는 UserProvider 안에서만 사용할 수 있습니다');
  }
  return context;
}
// main.jsx — Provider 설정
import { UserProvider } from './contexts/UserContext';

createRoot(document.getElementById('root')).render(
  <StrictMode>
    <BrowserRouter>
      <UserProvider>  {/* 전체 앱을 감쌈 */}
        <App />
      </UserProvider>
    </BrowserRouter>
  </StrictMode>
);

// 3단계: 어디서든 useContext로 값 사용
function UserMenu() {
  const { user, logout } = useUser(); // Props 없이 직접 접근!

  if (!user) return <p>로그인해주세요</p>;
  return (
    <div>
      <p>{user.name}님</p>
      <button onClick={logout}>로그아웃</button>
    </div>
  );
}

📖 핵심 개념 3 — useReducer + Context 패턴

상태 업데이트 로직이 복잡해지면 useState 대신 useReducer를 사용합니다. Redux의 개념(action, reducer, dispatch)을 경량화한 버전입니다.

// contexts/CartContext.jsx
import { createContext, useContext, useReducer, useMemo } from 'react';

// 액션 타입 상수 — 오타 방지를 위해 상수로 정의
const CART_ACTIONS = {
  ADD_ITEM: 'ADD_ITEM',
  REMOVE_ITEM: 'REMOVE_ITEM',
  UPDATE_QUANTITY: 'UPDATE_QUANTITY',
  CLEAR_CART: 'CLEAR_CART',
};

// 초기 상태
const initialState = {
  items: [],
  totalCount: 0,
  totalPrice: 0,
};

// 리듀서 — 순수 함수: (이전 상태, 액션) => 새 상태
function cartReducer(state, action) {
  switch (action.type) {
    case CART_ACTIONS.ADD_ITEM: {
      const existing = state.items.find(i => i.id === action.payload.id);
      const newItems = existing
        ? state.items.map(i =>
            i.id === action.payload.id
              ? { ...i, quantity: i.quantity + 1 }
              : i
          )
        : [...state.items, { ...action.payload, quantity: 1 }];
      return {
        ...state,
        items: newItems,
        totalCount: newItems.reduce((sum, i) => sum + i.quantity, 0),
        totalPrice: newItems.reduce((sum, i) => sum + i.price * i.quantity, 0),
      };
    }
    case CART_ACTIONS.REMOVE_ITEM: {
      const newItems = state.items.filter(i => i.id !== action.payload.id);
      return {
        ...state,
        items: newItems,
        totalCount: newItems.reduce((sum, i) => sum + i.quantity, 0),
        totalPrice: newItems.reduce((sum, i) => sum + i.price * i.quantity, 0),
      };
    }
    case CART_ACTIONS.CLEAR_CART:
      return initialState;
    default:
      return state;
  }
}

const CartContext = createContext(null);

export function CartProvider({ children }) {
  const [state, dispatch] = useReducer(cartReducer, initialState);

  // 성능 최적화: dispatch는 항상 동일 참조이므로 의존성에 포함 불필요
  const value = useMemo(() => ({
    ...state,
    addItem: (item) => dispatch({ type: CART_ACTIONS.ADD_ITEM, payload: item }),
    removeItem: (id) => dispatch({ type: CART_ACTIONS.REMOVE_ITEM, payload: { id } }),
    clearCart: () => dispatch({ type: CART_ACTIONS.CLEAR_CART }),
  }), [state]); // state가 바뀔 때만 새 value 객체 생성

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

export const useCart = () => {
  const ctx = useContext(CartContext);
  if (!ctx) throw new Error('useCart는 CartProvider 안에서 사용하세요');
  return ctx;
};

📖 핵심 개념 4 — Context 성능 이슈와 해결

// ❌ 성능 문제 — Provider value에 매번 새 객체 생성
function BadProvider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');

  return (
    // 렌더링마다 새 객체 생성 → 모든 Consumer가 리렌더링
    <MyContext.Provider value={{ user, theme, setUser, setTheme }}>
      {children}
    </MyContext.Provider>
  );
}

// ✅ useMemo로 최적화
function GoodProvider({ children }) {
  const [user, setUser] = useState(null);
  const [theme, setTheme] = useState('light');

  const value = useMemo(
    () => ({ user, theme, setUser, setTheme }),
    [user, theme] // user나 theme가 실제로 변경될 때만 새 객체 생성
  );

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

// ✅ Context 분리 전략 — 자주 변하는 값과 드물게 변하는 값을 분리
// 자주 변하는 상태와 드물게 변하는 설정을 하나의 Context에 담으면
// 설정을 읽는 컴포넌트도 상태 변경마다 리렌더링됨
const UserStateContext = createContext(null);    // 자주 변하는 값
const UserActionsContext = createContext(null);  // 드물게 변하는 함수

⚠️ 흔한 실수 (よくあるミス)

  • 모든 상태를 Context로: Context는 전역으로 필요한 상태(테마, 사용자 정보, 언어 설정 등)에 적합합니다. 특정 컴포넌트 트리에서만 필요한 상태는 그 공통 부모의 state로 충분합니다.
  • Provider value 최적화 누락: value 객체를 useMemo로 감싸지 않으면 불필요한 리렌더링이 발생합니다.
  • useReducer 없이 복잡한 상태 관리: 상태 업데이트 로직이 여러 곳에 분산되거나 복잡해지면 useReducer로 중앙화하세요.
  • Context vs Zustand 선택 혼란: Context는 단순한 전역 상태에 적합합니다. 복잡한 상태 로직, 성능이 중요한 상황, 빈번한 업데이트가 있다면 Zustand(11강)를 사용하세요.

💡 실무 팁

  • Context 파일 구조: src/contexts/ 폴더에 컨텍스트별 파일을 만들고, Provider와 커스텀 훅을 같은 파일에 export합니다.
  • 테마와 언어는 Context에 적합: 앱 전체에서 사용하지만 자주 변하지 않는 값(다크모드, i18n)은 Context의 최적 사용 사례입니다.
  • Context DevTools: React DevTools의 컴포넌트 탭에서 Context 값을 실시간으로 확인하고 수정할 수 있습니다.