본문 바로가기

교육

#005 React Context 교육자료: 장바구니 상태 관리

반응형

1. 문제 상황: Prop Drilling

이미지에서 보시는 것처럼, App 컴포넌트에서 Cart State를 관리하고 있습니다. 이 상태는:

  • Shop → Product 경로로 전달되어 장바구니에 추가 (Update Cart)
  • Header → CartModal → Cart 경로로 전달되어 장바구니 보기 (Display Cart)

이처럼 중간 컴포넌트(Shop, Header, CartModal)가 상태를 단순히 전달만 하는 경우, Prop Drilling 문제가 발생합니다.

이는 코드 유지보수성과 가독성을 떨어뜨립니다.

 

해결책: React Context 사용

 

2. React Context란?

React Context는 컴포넌트 트리 전체에 걸쳐 데이터를 공유할 수 있도록 해주는 기능입니다.

주로 테마, 사용자 정보, 로그인 상태, 장바구니 상태 등 전역적으로 필요한 상태를 관리할 때 사용됩니다.

핵심 개념:

  • createContext: Context 객체 생성
  • Provider: 상태를 제공하는 컴포넌트
  • useContext: 상태를 소비하는 컴포넌트

3. 장바구니 Context 설계

우리는 아래와 같은 구조로 Context를 설계합니다:

// CartContext.tsx
interface CartItem {
  id: number;
  name: string;
  price: number;
  quantity: number;
}

interface CartContextType {
  cart: CartItem[];
  addToCart: (item: Omit<CartItem, 'quantity'>) => void;
  removeFromCart: (id: number) => void;
  clearCart: () => void;
  totalItems: number;
  totalPrice: number;
}

 

React Context로 장바구니 구현

🗂️ 프로젝트 구조

src/
├── contexts/
│   └── CartContext.tsx
├── components/
│   ├── Header.tsx
│   ├── CartModal.tsx
│   ├── Cart.tsx
│   ├── Shop.tsx
│   └── Product.tsx
└── App.tsx

 

 

1. contexts/CartContext.tsx

// contexts/CartContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react';

// 장바구니 아이템 타입 정의
interface CartItem {
  id: number;
  name: string;
  price: number;
  quantity: number;
}

// Context 타입 정의
interface CartContextType {
  cart: CartItem[];
  addToCart: (item: Omit<CartItem, 'quantity'>) => void;
  removeFromCart: (id: number) => void;
  clearCart: () => void;
  totalItems: number;
  totalPrice: number;
}

// 초기값 설정
const CartContext = createContext<CartContextType | undefined>(undefined);

// 커스텀 훅: useContext를 더 쉽게 사용하기 위해
export const useCart = () => {
  const context = useContext(CartContext);
  if (!context) {
    throw new Error('useCart must be used within a CartProvider');
  }
  return context;
};

// Provider 컴포넌트
export const CartProvider = ({ children }: { children: ReactNode }) => {
  const [cart, setCart] = useState<CartItem[]>([]);

  // 장바구니에 상품 추가
  const addToCart = (item: Omit<CartItem, 'quantity'>) => {
    setCart(prevCart => {
      const existingItem = prevCart.find(i => i.id === item.id);
      if (existingItem) {
        return prevCart.map(i =>
          i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
        );
      }
      return [...prevCart, { ...item, quantity: 1 }];
    });
  };

  // 장바구니에서 상품 제거
  const removeFromCart = (id: number) => {
    setCart(prevCart => prevCart.filter(item => item.id !== id));
  };

  // 장바구니 비우기
  const clearCart = () => {
    setCart([]);
  };

  // 총 개수 및 총 가격 계산
  const totalItems = cart.reduce((sum, item) => sum + item.quantity, 0);
  const totalPrice = cart.reduce((sum, item) => sum + item.price * item.quantity, 0);

  return (
    <CartContext.Provider
      value={{
        cart,
        addToCart,
        removeFromCart,
        clearCart,
        totalItems,
        totalPrice,
      }}
    >
      {children}
    </CartContext.Provider>
  );
};

 

 

2. App.tsx — Context Provider 설정

// App.tsx
import { CartProvider } from './contexts/CartContext';
import Header from './components/Header';
import Shop from './components/Shop';

function App() {
  return (
    <CartProvider>
      <div className="App">
        <Header />
        <Shop />
      </div>
    </CartProvider>
  );
}

export default App;

 

3. components/Product.tsx — 장바구니에 상품 추가

// components/Product.tsx
import { useCart } from '../contexts/CartContext';

interface ProductProps {
  id: number;
  name: string;
  price: number;
}

const Product = ({ id, name, price }: ProductProps) => {
  const { addToCart } = useCart();

  const handleAddToCart = () => {
    addToCart({ id, name, price });
    alert(`${name}이(가) 장바구니에 추가되었습니다.`);
  };

  return (
    <div style={{ border: '1px solid #ccc', padding: '10px', margin: '10px' }}>
      <h3>{name}</h3>
      <p>₩{price.toLocaleString()}</p>
      <button onClick={handleAddToCart}>장바구니에 담기</button>
    </div>
  );
};

export default Product;

 

4. components/Shop.tsx

// components/Shop.tsx
import Product from './Product';

const products = [
  { id: 1, name: '노트북', price: 1500000 },
  { id: 2, name: '마우스', price: 30000 },
  { id: 3, name: '키보드', price: 80000 },
];

const Shop = () => {
  return (
    <div>
      <h2>상품 목록</h2>
      {products.map(product => (
        <Product key={product.id} {...product} />
      ))}
    </div>
  );
};

export default Shop;

 

5. components/Header.tsx — 장바구니 모달 열기

// components/Header.tsx
import { useState } from 'react';
import CartModal from './CartModal';

const Header = () => {
  const [isModalOpen, setIsModalOpen] = useState(false);

  return (
    <header style={{ display: 'flex', justifyContent: 'space-between', padding: '10px', background: '#f5f5f5' }}>
      <h1>쇼핑몰</h1>
      <button onClick={() => setIsModalOpen(true)}>🛒 장바구니 ({useCart().totalItems})</button>
      {isModalOpen && <CartModal onClose={() => setIsModalOpen(false)} />}
    </header>
  );
};

export default Header;

 

⚠️ useCart()를 여기서 사용하려면 HeaderCartProvider 안에 있어야 합니다. 이미 App.tsx에서 감싸고 있으므로 문제 없음.

 

6. components/CartModal.tsx

// components/CartModal.tsx
import { useCart } from '../contexts/CartContext';

interface CartModalProps {
  onClose: () => void;
}

const CartModal = ({ onClose }: CartModalProps) => {
  const { cart, removeFromCart, clearCart, totalPrice } = useCart();

  return (
    <div style={{
      position: 'fixed',
      top: 0,
      left: 0,
      right: 0,
      bottom: 0,
      backgroundColor: 'rgba(0,0,0,0.5)',
      display: 'flex',
      justifyContent: 'center',
      alignItems: 'center',
    }}>
      <div style={{
        background: 'white',
        padding: '20px',
        borderRadius: '8px',
        width: '400px',
        maxHeight: '80vh',
        overflowY: 'auto',
      }}>
        <h2>장바구니</h2>
        {cart.length === 0 ? (
          <p>장바구니가 비어있습니다.</p>
        ) : (
          <>
            <ul>
              {cart.map(item => (
                <li key={item.id} style={{ marginBottom: '10px' }}>
                  {item.name} x {item.quantity} — ₩{(item.price * item.quantity).toLocaleString()}
                  <button onClick={() => removeFromCart(item.id)} style={{ marginLeft: '10px' }}>삭제</button>
                </li>
              ))}
            </ul>
            <p><strong>총 금액: ₩{totalPrice.toLocaleString()}</strong></p>
            <button onClick={clearCart} style={{ marginRight: '10px' }}>장바구니 비우기</button>
            <button onClick={onClose}>닫기</button>
          </>
        )}
      </div>
    </div>
  );
};

export default CartModal;

 

 

7. components/Cart.tsx — 장바구니 표시 (예시용)

// components/Cart.tsx
import { useCart } from '../contexts/CartContext';

const Cart = () => {
  const { cart, totalPrice } = useCart();

  return (
    <div>
      <h3>장바구니 요약</h3>
      <p>총 상품 수: {cart.reduce((sum, item) => sum + item.quantity, 0)}</p>
      <p>총 가격: ₩{totalPrice.toLocaleString()}</p>
    </div>
  );
};

export default Cart;

 

핵심 학습 포인트

Context 생성
createContext<T>()로 타입 지정
Provider 사용
App에서 최상위에CartProvider로 감싸기
useContext 훅
useCart()로 상태와 함수 접근
상태 업데이트
setCart를 통해 불변성 유지하며 상태 변경
재사용성
Product,CartModal등 어디서든 장바구니 상태 사용 가능

 

 

Omit<T, K>란?

Omit은 TypeScript의 유틸리티 타입(Utility Type) 중 하나로,
기존 타입 T에서 특정 속성 K를 제외한 새 타입을 생성합니다.

Omit<T, K>
 
  • T: 기존 타입 (예: 인터페이스 또는 타입 별칭)
  • K: 제외할 속성명 (문자열 리터럴 또는 유니언)
interface CartItem {
  id: number;
  name: string;
  price: number;
  quantity: number;
}

// quantity를 제외한 타입
type CartItemWithoutQuantity = Omit<CartItem, 'quantity'>;
// 👇 위와 동일합니다:
// type CartItemWithoutQuantity = {
//   id: number;
//   name: string;
//   price: number;
// };

즉, Omit<CartItem, 'quantity'>
CartItem에서 quantity 필드만 빼고 나머지 필드만 허용하는 타입입니다.

 

왜 Omit<CartItem, 'quantity'>를 쓸까요?

→ 사용자(외부)가 quantity를 직접 지정하지 못하도록 제어하기 위함입니다.

예를 들어, 상품을 장바구니에 처음 담을 때, 일반적으로:

  • 상품 정보(id, name, price)는 외부에서 제공되고,
  • quantity는 자동으로 1로 설정되거나, 기존에 있으면 +1됩니다.

따라서 함수 시그니처에서 quantity를 의도적으로 제외하여:

  • ❌ addToCart({ id: 1, name: '노트북', price: 1500000, quantity: 999 }) 같은 잘못된 호출을 방지
  • ✅ 사용자가 quantity를 신경 쓸 필요 없이, 로직 내부에서 안전하게 관리 가능
const addToCart = (item: Omit<CartItem, 'quantity'>) => {
  setCart(prevCart => {
    const existingItem = prevCart.find(i => i.id === item.id);
    if (existingItem) {
      return prevCart.map(i =>
        i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
      );
    }
    return [...prevCart, { ...item, quantity: 1 }];
  });
};

 

Omit<T, K>
타입T에서 속성K를 제거한 새 타입 생성
사용 목적
외부 입력에서 불필요하거나 위험한 필드(quantity)를 차단
장점
타입 안정성 ↑, API 설계 명확성 ↑, 실수 방지
실제 활용
addToCart는quantity없이 호출되며, 내부에서 자동 관리

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

첨부하신 이미지를 AI 이미지 생성기에 입력할 수 있도록, 정확하고 상세한 프롬프트를 만들어주세요. 단 배경은 투명하거나 흰색으로 만들어주세요.

 

반응형