본문으로 건너뛰기

안동민 개발노트

본문 시작
4장 : React 훅 기초실습

장바구니 컴포넌트 통합

상품과 장바구니 컴포넌트를 조립하고 최종 화면의 상태 전이를 검증합니다.

컴포넌트 구성

장바구니 실습에서는 reducer 전이, Context 전달, 총액 계산, 화면 반응을 함께 점검합니다.

이제 위에서 만든 Context와 Reducer, 커스텀 훅을 활용하여 UI 컴포넌트들을 만들어 봅시다.

LoadingSpinner.js (로딩 상태 분리)

src/components/LoadingSpinner.js
import React from 'react';

function LoadingSpinner() {
  return (
    <div className="loading-spinner"></div>
  );
}

export default LoadingSpinner;

ProductItem.js

src/components/ProductItem.js
import React, { useContext } from 'react';
import { CartContext } from '../contexts/CartContext';
import { ADD_ITEM } from '../reducers/cartReducer';

function ProductItem({ product }) {
  const { dispatch } = useContext(CartContext); // Context에서 dispatch 함수 가져오기

  const handleAddToCart = () => {
    dispatch({ type: ADD_ITEM, payload: { item: product, quantity: 1 } });
  };

  return (
    <div className="product-item">
      <img
        src={product.imageUrl}
        onError={(event) => {
          event.currentTarget.onerror = null;
          event.currentTarget.src = '/docs/common/fallback-image.svg';
        }}
        alt={product.name}
      />
      <h3>{product.name}</h3>
      <p>{product.price.toLocaleString()}</p>
      <button className="add-to-cart" onClick={handleAddToCart}>
        장바구니에 추가
      </button>
    </div>
  );
}

export default ProductItem;

ProductList.js

src/components/ProductList.js
import React from 'react';
import useFetch from '../hooks/useFetch'; // 커스텀 훅 불러오기
import ProductItem from './ProductItem';
import LoadingSpinner from './LoadingSpinner';

function ProductList() {
  // 가상 제품 데이터 (실제로는 API에서 가져옴)
  const mockProducts = [
    { id: 1, name: '스마트폰', price: 1200000, imageUrl: '/docs/common/fallback-image.svg' },
    { id: 2, name: '노트북', price: 1500000, imageUrl: '/docs/common/fallback-image.svg' },
    { id: 3, name: '무선 이어폰', price: 200000, imageUrl: '/docs/common/fallback-image.svg' },
    { id: 4, name: '스마트 워치', price: 350000, imageUrl: '/docs/common/fallback-image.svg' },
    { id: 5, name: '태블릿', price: 800000, imageUrl: '/docs/common/fallback-image.svg' },
  ];

  // (가상) API URL. 실제로는 서버 엔드포인트가 될 것입니다.
  const { data: products, loading, error } = useFetch('/api/products'); // useFetch 훅 사용

  // 목업 데이터 사용 (실제 API가 없을 경우)
  const actualProducts = products || mockProducts;

  if (loading) return <LoadingSpinner />;
  if (error) return <p className="error-message">제품 목록을 불러오는 데 실패했습니다. {error.message}</p>;

  return (
    <div>
      <h2>제품 목록</h2>
      <div className="product-grid">
        {actualProducts.map(product => (
          <ProductItem key={product.id} product={product} />
        ))}
      </div>
    </div>
  );
}

export default ProductList;

CartItem.js

src/components/CartItem.js
import React, { useContext } from 'react';
import { CartContext } from '../contexts/CartContext';
import { UPDATE_QUANTITY, REMOVE_ITEM } from '../reducers/cartReducer';

function CartItem({ item }) {
  const { dispatch } = useContext(CartContext);

  const handleQuantityChange = (e) => {
    const newQuantity = Number(e.target.value);
    // 수량이 0이하로 내려가면 삭제, 아니면 업데이트
    if (newQuantity <= 0) {
      dispatch({ type: REMOVE_ITEM, payload: { id: item.id } });
    } else {
      dispatch({ type: UPDATE_QUANTITY, payload: { id: item.id, quantity: newQuantity } });
    }
  };

  const handleIncrement = () => {
    dispatch({ type: UPDATE_QUANTITY, payload: { id: item.id, quantity: item.quantity + 1 } });
  };

  const handleDecrement = () => {
    // 수량이 1일 때 감소시키면 제거 액션 발생
    if (item.quantity === 1) {
      dispatch({ type: REMOVE_ITEM, payload: { id: item.id } });
    } else {
      dispatch({ type: UPDATE_QUANTITY, payload: { id: item.id, quantity: item.quantity - 1 } });
    }
  };

  const handleRemove = () => {
    dispatch({ type: REMOVE_ITEM, payload: { id: item.id } });
  };

  return (
    <li className="cart-item">
      <div className="cart-item-info">
        <h4>{item.name}</h4>
        <p>{item.price.toLocaleString()}원 x {item.quantity}</p>
        <p>합계: {(item.price * item.quantity).toLocaleString()}</p>
      </div>
      <div className="cart-item-controls">
        <button className="quantity-btn" onClick={handleDecrement}>-</button>
        <input
          type="number"
          value={item.quantity}
          onChange={handleQuantityChange}
          min="0"
        />
        <button className="quantity-btn" onClick={handleIncrement}>+</button>
        <button className="remove-from-cart" onClick={handleRemove} style={{ marginLeft: '10px' }}>
          삭제
        </button>
      </div>
    </li>
  );
}

export default CartItem;

CartSummary.js

src/components/CartSummary.js
import React, { useContext, useMemo } from 'react';
import { CartContext } from '../contexts/CartContext';
import CartItem from './CartItem';

function CartSummary() {
  const { cartState } = useContext(CartContext); // Context에서 장바구니 상태 가져오기

  // useMemo를 사용하여 총액을 계산 (cartState가 변경될 때만 다시 계산)
  const totalAmount = useMemo(() => {
    return cartState.reduce((total, item) => total + (item.price * item.quantity), 0);
  }, [cartState]);

  return (
    <div className="cart-summary">
      <h2>장바구니</h2>
      {cartState.length === 0 ? (
        <p style={{ textAlign: 'center', color: '#666' }}>장바구니가 비어있습니다.</p>
      ) : (
        <>
          <ul style={{ listStyle: 'none', padding: 0 }}>
            {cartState.map(item => (
              <CartItem key={item.id} item={item} />
            ))}
          </ul>
          <div className="cart-total">
            총 결제 금액: {totalAmount.toLocaleString()}
          </div>
        </>
      )}
    </div>
  );
}

export default CartSummary;

App.js (최종)

모든 컴포넌트와 Context Provider를 조합합니다.

src/App.js
import React from 'react';
import './index.css'; // 기본 스타일링 불러오기
import { CartProvider } from './contexts/CartContext'; // CartProvider 불러오기
import ProductList from './components/ProductList';
import CartSummary from './components/CartSummary';

function App() {
  return (
    // CartProvider로 전체 앱을 감싸서 장바구니 상태를 전역적으로 제공
    <CartProvider>
      <div className="App">
        <ProductList /> {/* 제품 목록 */}
        <CartSummary /> {/* 장바구니 요약 */}
      </div>
    </CartProvider>
  );
}

export default App;

검증 순서: 장바구니 회귀 테스트

실습 검증은 중복 담기 -> 수량 증감 -> 수량 0 제거 -> 새로고침 복원 순서로 진행합니다.

이 순서를 유지하면 상태 전이 오류를 단계별로 분리해 확인할 수 있습니다.

위의 모든 코드 파일들을 해당 경로에 맞게 생성하고 내용을 복사하여 붙여넣으세요.

npm run dev (또는 yarn dev) 명령어를 실행하여 개발 서버를 시작합니다.

브라우저에서 http://localhost:5173에 접속합니다.

제품 추가: 장바구니에 추가 버튼을 클릭하여 제품을 장바구니에 담아보세요.

장바구니 요약 섹션에 제품이 추가되고 총액이 업데이트되는 것을 확인합니다.

수량 변경: 장바구니 내에서 + 또는 - 버튼을 클릭하여 제품 수량을 변경하거나, 수량 입력 필드를 직접 수정해보세요.

제품 삭제: 장바구니 내 삭제 버튼을 클릭하여 제품을 제거해보세요.

새로고침 테스트: 페이지를 새로고침했을 때 장바구니 상태가 그대로 유지되는지 확인해 보세요. (로컬 스토리지에 저장되었기 때문)

개발자 도구 활용
  • Components 탭에서 각 컴포넌트의 propsstate 변화를 관찰해 보세요.
  • Console 탭에서 useFetch 훅이 데이터를 불러오는 과정(Fetch aborted 메시지 등)을 확인해 보세요.
  • Application 탭에서 Local Storage를 열어 shoppingCart 항목에 장바구니 데이터가 JSON 형태로 저장되는 것을 확인해 보세요.

이제는 리액트의 핵심 훅들을 모두 통합하여 useReducer로 복잡한 상태를 관리하고, useContext로 이를 전역적으로 공유하며, useEffect로 비동기 작업을 처리하고, 커스텀 훅으로 로직을 재사용하는 등, 실제 상업 애플리케이션에 적용될 수 있는 수준의 기능을 구현해 보셨습니다.

이 실습에서는 함수형 컴포넌트와 훅의 주요 사용 패턴을 장바구니 예제로 확인했습니다.


실습을 끝낸 뒤에는 reducer, context, fetch 훅이 맡은 책임을 따로 검증해 회귀를 줄여야 합니다.