본문 바로가기

Context API

4. Context API

React의 Context API는 컴포넌트 트리를 통해 데이터를 props drilling 없이 전달할 수 있게 해주는 기능입니다. 전역 상태 관리나 테마, 사용자 정보 등을 여러 컴포넌트에서 공유할 때 유용합니다.

Props Drilling 문제

Props Drilling이란?

// Props를 여러 레벨에 걸쳐 전달해야 하는 문제
function App() {
  const user = { name: '김개발', role: 'admin' };
  
  return <Header user={user} />;
}

function Header({ user }) {
  return (
    <div>
      <Navigation user={user} />
    </div>
  );
}

function Navigation({ user }) {
  return (
    <nav>
      <UserMenu user={user} />
    </nav>
  );
}

function UserMenu({ user }) {
  return (
    <div>
      <p>안녕하세요, {user.name}님</p>
      <p>권한: {user.role}</p>
    </div>
  );
}

위의 예제에서 user 데이터는 App → Header → Navigation → UserMenu로 3단계를 거쳐 전달됩니다. Header와 Navigation은 실제로 user 데이터를 사용하지 않지만, 단순히 전달만 하고 있습니다.

Context API 기본 사용법

Context 생성하고 사용하기

import { createContext, useContext } from 'react';

// 1. Context 생성
const UserContext = createContext();

// 2. Provider로 데이터 제공
function App() {
  const user = { name: '김개발', role: 'admin' };
  
  return (
    <UserContext.Provider value={user}>
      <Header />
    </UserContext.Provider>
  );
}

function Header() {
  return (
    <div>
      <h1>헤더</h1>
      <Navigation />
    </div>
  );
}

function Navigation() {
  return (
    <nav>
      <p>네비게이션</p>
      <UserMenu />
    </nav>
  );
}

// 3. useContext로 데이터 사용
function UserMenu() {
  const user = useContext(UserContext);
  
  return (
    <div>
      <p>안녕하세요, {user.name}님</p>
      <p>권한: {user.role}</p>
    </div>
  );
}

기본값 설정하기

import { createContext, useContext } from 'react';

// Context 생성 시 기본값 설정
const UserContext = createContext({
  name: '게스트',
  role: 'user'
});

// Provider 없이도 기본값 사용 가능
function GuestComponent() {
  const user = useContext(UserContext);
  
  return (
    <div>
      <p>사용자: {user.name}</p>
      <p>권한: {user.role}</p>
    </div>
  );
}

테마 Context 예제

다크모드/라이트모드 구현

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

// 테마 Context 생성
const ThemeContext = createContext();

// 테마 Provider 컴포넌트
function ThemeProvider({ children }) {
  const [theme, setTheme] = useState('light');
  
  const toggleTheme = () => {
    setTheme(prevTheme => prevTheme === 'light' ? 'dark' : 'light');
  };
  
  const value = {
    theme,
    toggleTheme
  };
  
  return (
    <ThemeContext.Provider value={value}>
      {children}
    </ThemeContext.Provider>
  );
}

// 테마 사용을 위한 커스텀 Hook
function useTheme() {
  const context = useContext(ThemeContext);
  
  if (!context) {
    throw new Error('useTheme은 ThemeProvider 내부에서 사용해야 합니다');
  }
  
  return context;
}

// 헤더 컴포넌트
function Header() {
  const { theme, toggleTheme } = useTheme();
  
  return (
    <header>
      <h1>My App</h1>
      <button onClick={toggleTheme}>
        {theme === 'light' ? '다크 모드' : '라이트 모드'}
      </button>
    </header>
  );
}

// 메인 컨텐츠
function MainContent() {
  const { theme } = useTheme();
  
  return (
    <main>
      <h2>메인 컨텐츠</h2>
      <p>현재 테마: {theme}</p>
      <Card />
    </main>
  );
}

// 카드 컴포넌트
function Card() {
  const { theme } = useTheme();
  
  return (
    <div>
      <h3>카드 제목</h3>
      <p>카드 내용입니다. 테마에 따라 색상이 변경됩니다.</p>
    </div>
  );
}

// 앱 컴포넌트
function App() {
  return (
    <ThemeProvider>
      <div>
        <Header />
        <MainContent />
      </div>
    </ThemeProvider>
  );
}

여러 Context 사용하기

사용자 정보와 설정을 분리

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

// 사용자 Context
const UserContext = createContext();

function UserProvider({ children }) {
  const [user, setUser] = useState({
    name: '김개발',
    email: 'kim@example.com',
    isLoggedIn: true
  });
  
  const login = (name, email) => {
    setUser({ name, email, isLoggedIn: true });
  };
  
  const logout = () => {
    setUser({ name: '', email: '', isLoggedIn: false });
  };
  
  return (
    <UserContext.Provider value={{ user, login, logout }}>
      {children}
    </UserContext.Provider>
  );
}

// 설정 Context
const SettingsContext = createContext();

function SettingsProvider({ children }) {
  const [settings, setSettings] = useState({
    language: 'ko',
    notifications: true,
    autoSave: true
  });
  
  const updateSetting = (key, value) => {
    setSettings(prev => ({
      ...prev,
      [key]: value
    }));
  };
  
  return (
    <SettingsContext.Provider value={{ settings, updateSetting }}>
      {children}
    </SettingsContext.Provider>
  );
}

// 사용자 정보 표시 컴포넌트
function UserProfile() {
  const { user, logout } = useContext(UserContext);
  
  if (!user.isLoggedIn) {
    return <div>로그인이 필요합니다.</div>;
  }
  
  return (
    <div>
      <h3>사용자 프로필</h3>
      <p>이름: {user.name}</p>
      <p>이메일: {user.email}</p>
      <button onClick={logout}>로그아웃</button>
    </div>
  );
}

// 설정 패널
function SettingsPanel() {
  const { settings, updateSetting } = useContext(SettingsContext);
  
  return (
    <div>
      <h3>설정</h3>
      <label>
        언어:
        <select
          value={settings.language}
          onChange={(e) => updateSetting('language', e.target.value)}
        >
          <option value="ko">한국어</option>
          <option value="en">English</option>
        </select>
      </label>
      <br />
      <label>
        <input
          type="checkbox"
          checked={settings.notifications}
          onChange={(e) => updateSetting('notifications', e.target.checked)}
        />
        알림 받기
      </label>
      <br />
      <label>
        <input
          type="checkbox"
          checked={settings.autoSave}
          onChange={(e) => updateSetting('autoSave', e.target.checked)}
        />
        자동 저장
      </label>
    </div>
  );
}

// 앱 컴포넌트
function MultiContextApp() {
  return (
    <UserProvider>
      <SettingsProvider>
        <div>
          <UserProfile />
          <SettingsPanel />
        </div>
      </SettingsProvider>
    </UserProvider>
  );
}

Context 최적화

Context 분리로 불필요한 리렌더링 방지

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

// 값과 액션을 분리한 Context
const CountValueContext = createContext();
const CountActionsContext = createContext();

function CountProvider({ children }) {
  const [count, setCount] = useState(0);
  
  const increment = () => setCount(prev => prev + 1);
  const decrement = () => setCount(prev => prev - 1);
  const reset = () => setCount(0);
  
  return (
    <CountValueContext.Provider value={count}>
      <CountActionsContext.Provider value={{ increment, decrement, reset }}>
        {children}
      </CountActionsContext.Provider>
    </CountValueContext.Provider>
  );
}

// 카운트 값만 사용하는 컴포넌트
const CountDisplay = memo(function CountDisplay() {
  const count = useContext(CountValueContext);
  console.log('CountDisplay 렌더링');
  
  return <h2>카운트: {count}</h2>;
});

// 액션만 사용하는 컴포넌트 (카운트 변경 시 리렌더링되지 않음)
const CountControls = memo(function CountControls() {
  const { increment, decrement, reset } = useContext(CountActionsContext);
  console.log('CountControls 렌더링');
  
  return (
    <div>
      <button onClick={increment}>증가</button>
      <button onClick={decrement}>감소</button>
      <button onClick={reset}>리셋</button>
    </div>
  );
});

function OptimizedApp() {
  return (
    <CountProvider>
      <div>
        <CountDisplay />
        <CountControls />
      </div>
    </CountProvider>
  );
}

실습: 장바구니 애플리케이션

Context를 활용한 전역 상태 관리

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

// 장바구니 상태 관리를 위한 reducer
function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM':
      const existingItem = state.items.find(item => item.id === action.payload.id);
      
      if (existingItem) {
        return {
          ...state,
          items: state.items.map(item =>
            item.id === action.payload.id
              ? { ...item, quantity: item.quantity + 1 }
              : item
          )
        };
      } else {
        return {
          ...state,
          items: [...state.items, { ...action.payload, quantity: 1 }]
        };
      }
    
    case 'REMOVE_ITEM':
      return {
        ...state,
        items: state.items.filter(item => item.id !== action.payload)
      };
    
    case 'UPDATE_QUANTITY':
      return {
        ...state,
        items: state.items.map(item =>
          item.id === action.payload.id
            ? { ...item, quantity: action.payload.quantity }
            : item
        )
      };
    
    case 'CLEAR_CART':
      return { ...state, items: [] };
    
    default:
      return state;
  }
}

// 장바구니 Context
const CartContext = createContext();

// 장바구니 Provider
function CartProvider({ children }) {
  const [state, dispatch] = useReducer(cartReducer, { items: [] });
  
  const addItem = (item) => {
    dispatch({ type: 'ADD_ITEM', payload: item });
  };
  
  const removeItem = (id) => {
    dispatch({ type: 'REMOVE_ITEM', payload: id });
  };
  
  const updateQuantity = (id, quantity) => {
    if (quantity <= 0) {
      removeItem(id);
    } else {
      dispatch({ type: 'UPDATE_QUANTITY', payload: { id, quantity } });
    }
  };
  
  const clearCart = () => {
    dispatch({ type: 'CLEAR_CART' });
  };
  
  const getTotalPrice = () => {
    return state.items.reduce((total, item) => total + (item.price * item.quantity), 0);
  };
  
  const getTotalItems = () => {
    return state.items.reduce((total, item) => total + item.quantity, 0);
  };
  
  return (
    <CartContext.Provider value={{
      items: state.items,
      addItem,
      removeItem,
      updateQuantity,
      clearCart,
      totalPrice: getTotalPrice(),
      totalItems: getTotalItems()
    }}>
      {children}
    </CartContext.Provider>
  );
}

// 장바구니 Hook
function useCart() {
  const context = useContext(CartContext);
  if (!context) {
    throw new Error('useCart는 CartProvider 내부에서 사용해야 합니다');
  }
  return context;
}

// 제품 목록 컴포넌트
function ProductList() {
  const { addItem } = useCart();
  
  const products = [
    { id: 1, name: '노트북', price: 1500000 },
    { id: 2, name: '마우스', price: 50000 },
    { id: 3, name: '키보드', price: 100000 },
    { id: 4, name: '모니터', price: 300000 }
  ];
  
  return (
    <div>
      <h2>제품 목록</h2>
      {products.map(product => (
        <div key={product.id}>
          <h3>{product.name}</h3>
          <p>가격: {product.price.toLocaleString()}원</p>
          <button onClick={() => addItem(product)}>
            장바구니 추가
          </button>
        </div>
      ))}
    </div>
  );
}

// 장바구니 컴포넌트
function Cart() {
  const { items, removeItem, updateQuantity, clearCart, totalPrice } = useCart();
  
  if (items.length === 0) {
    return (
      <div>
        <h2>장바구니</h2>
        <p>장바구니가 비어있습니다.</p>
      </div>
    );
  }
  
  return (
    <div>
      <h2>장바구니</h2>
      {items.map(item => (
        <div key={item.id}>
          <h4>{item.name}</h4>
          <p>가격: {item.price.toLocaleString()}원</p>
          <p>
            수량:
            <button onClick={() => updateQuantity(item.id, item.quantity - 1)}>
              -
            </button>
            {item.quantity}
            <button onClick={() => updateQuantity(item.id, item.quantity + 1)}>
              +
            </button>
          </p>
          <button onClick={() => removeItem(item.id)}>삭제</button>
        </div>
      ))}
      <div>
        <h3>총 금액: {totalPrice.toLocaleString()}원</h3>
        <button onClick={clearCart}>장바구니 비우기</button>
      </div>
    </div>
  );
}

// 헤더 컴포넌트 (장바구니 아이템 수 표시)
function Header() {
  const { totalItems } = useCart();
  
  return (
    <header>
      <h1>쇼핑몰</h1>
      <p>장바구니 아이템: {totalItems}개</p>
    </header>
  );
}

// 메인 앱
function ShoppingApp() {
  return (
    <CartProvider>
      <div>
        <Header />
        <ProductList />
        <Cart />
      </div>
    </CartProvider>
  );
}

Context API 사용 시 주의사항

성능 고려사항

// ❌ 피해야 할 패턴 - 객체를 직접 전달하면 매번 새 객체 생성
function BadProvider({ children }) {
  const [user, setUser] = useState({ name: '김개발' });
  
  return (
    <UserContext.Provider value={{ user, setUser }}>
      {children}
    </UserContext.Provider>
  );
}

// ✅ 좋은 패턴 - 값을 메모이제이션
function GoodProvider({ children }) {
  const [user, setUser] = useState({ name: '김개발' });
  
  const value = useMemo(() => ({ user, setUser }), [user]);
  
  return (
    <UserContext.Provider value={value}>
      {children}
    </UserContext.Provider>
  );
}

// 또는 useState에서 값과 setter를 분리
function BetterProvider({ children }) {
  const [user, setUser] = useState({ name: '김개발' });
  
  return (
    <UserContext.Provider value={user}>
      <UserActionsContext.Provider value={setUser}>
        {children}
      </UserActionsContext.Provider>
    </UserContext.Provider>
  );
}

정리

Context API의 핵심 개념을 학습했습니다:

  1. Props Drilling 해결: 중간 컴포넌트를 거치지 않고 데이터 전달
  2. createContext와 Provider: Context 생성과 데이터 제공
  3. useContext: Context 데이터 사용
  4. 커스텀 Hook: Context 사용을 위한 편리한 Hook 생성
  5. 여러 Context: 관련된 데이터별로 Context 분리
  6. 성능 최적화: Context 분리와 메모이제이션으로 리렌더링 최소화
  7. 실전 활용: 장바구니 같은 전역 상태 관리

Context API는 전역 상태가 필요한 경우에 유용하지만, 남용하면 컴포넌트 간의 결합도가 높아질 수 있습니다. 적절한 수준에서 사용하고, 복잡한 상태 관리가 필요한 경우에는 Redux나 Zustand 같은 전용 상태 관리 라이브러리를 고려해보세요.

다음 장에서는 에러 바운더리에 대해 알아보겠습니다.