본문으로 건너뛰기

안동민 개발노트

본문 시작

장바구니 컴포넌트 통합

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

컴포넌트 구성

장바구니 실습에서는 reducer 전이, Context 전달, 총액 계산, 외부 시스템 동기화를 서로 다른 책임으로 점검합니다.

장바구니 상태와 변경 규칙은 CartProvider 안의 useReducercartReducer가 소유합니다. Context 자체는 별도 store가 아니라 현재 cartStatedispatch를 트리 아래로 전달하고, useContext를 호출한 컴포넌트를 그 값에 구독시키는 통로입니다.

React는 Provider의 이전 value와 다음 valueObject.is로 비교합니다. 이 예제의 value={{ cartState, dispatch }}는 Provider가 렌더될 때마다 새 객체이므로 Context를 읽는 소비자도 다시 렌더될 수 있습니다. 작은 실습에서는 먼저 단순한 구조를 유지하고, 실제 측정에서 문제가 확인될 때 Context 분리나 값 메모화를 검토합니다. 메모화는 성능 최적화일 뿐 정확성을 보장하는 장치가 아닙니다.

CartProvider와 reducer가 장바구니 상태 전이를 소유하고 Context는 값을 전달하며 렌더와 Effect가 파생 계산과 외부 동기화를 나누는 책임 지도

Responsibility boundaries

Context는 상태 저장소가 아니라 Provider가 만든 현재 값의 전달 통로다. 이벤트, reducer, 렌더, Effect의 책임을 섞지 않는다.

상태 소유·순수 전이
CartProvideruseReducercartState를 소유하고, cartReducer가 현재 상태와 액션으로 새 배열을 반환한다.
직접 입력 Number.isInteger를 통과한 값만 UPDATE_QUANTITY로 보낸다.
증감 버튼 현재 item.quantity가 숫자형 정수일 때에만 정수를 유지한다.
현재 전이 0 이하는 제거하지만 양의 소수는 남을 수 있다.
검증 범위 앱 전체의 양의 정수 불변식은 ADD_ITEM·RESTORE_CART·UPDATE_QUANTITY를 포함한 모든 payload를 reducer 경계에서 검증·정규화해야 보장된다.
상품·수량 UI 이벤트는 액션만 dispatch하고 다음 상태 규칙을 복제하지 않는다.
Context 전달·구독
CartContextcartState와 안정적인 dispatch를 자손에게 전달한다. 별도 store나 reducer 소유자가 아니다.
Provider의 이전·다음 valueObject.is로 비교되며, 값이 달라지면 이를 읽는 소비자가 다시 렌더된다.
useCart는 가장 가까운 Provider를 읽고, Provider 밖에서는 명시적인 계약 오류를 낸다.
렌더 중 파생 계산
CartSummary의 총액은 cartState로부터 렌더 중 계산한다. 같은 값을 별도 state로 복제하거나 Effect로 다시 쓰지 않는다.
useMemo는 측정된 계산 비용을 줄이는 성능 최적화일 뿐, 총액 정확성이나 생명주기 계약이 아니다.
외부 시스템 동기화
useFetch의 Effect는 네트워크 요청과 cleanup을, Provider의 Effect는 브라우저 localStorage 복원·저장을 맡는다.
Effect는 React 밖 시스템과 맞추는 경계다. state와 props만으로 구할 수 있는 값은 렌더에서 계산한다.
회귀 검증은 직접 입력의 정수 제한, 정수 수량에서 시작한 증감, 0 이하 제거, Context 소비자의 갱신, 렌더의 파생 총액을 나눠 확인한다. 현재 전이는 양의 소수를 허용하므로 앱 전체의 양의 정수 불변식은 모든 액션 payload의 reducer 경계 검증을 별도로 요구한다.

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

useCart.js (Provider 경계 검사)

CartContext의 기본값 null은 Provider가 없을 때만 쓰이는 정적인 fallback입니다. 소비자가 실수로 CartProvider 밖에 배치되면 구조 분해 단계의 모호한 오류 대신 명시적인 계약 오류를 내도록 전용 훅에서 검사합니다.

src/hooks/useCart.js
import { useContext } from 'react';
import { CartContext } from '../contexts/CartContext';

function useCart() {
  const value = useContext(CartContext);

  if (value === null) {
    throw new Error('useCart must be used within <CartProvider>.');
  }

  return value;
}

export default useCart;

LoadingSpinner.js (로딩 상태 분리)

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

function LoadingSpinner() {
  return (
    <div
      className="loading-spinner"
      role="status"
      aria-label="제품 목록 불러오는 중"
    />
  );
}

export default LoadingSpinner;

ProductItem.js

src/components/ProductItem.js
import React from 'react';
import useCart from '../hooks/useCart';
import { ADD_ITEM } from '../reducers/cartReducer';

function ProductItem({ product }) {
  const { dispatch } = useCart();

  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' },
  ];

  // 실제 프로젝트에서는 서버가 제공하는 엔드포인트로 교체합니다.
  const { data: products, loading, error } = useFetch('/api/products');

  if (loading) return <LoadingSpinner />;

  const usingMockProducts = !Array.isArray(products);
  const actualProducts = usingMockProducts ? mockProducts : products;

  return (
    <div>
      <h2>제품 목록</h2>
      {usingMockProducts && (
        <p className="error-message" role="status">
          {error
            ? `API 요청에 실패해 목업 상품을 표시합니다. (${error.message})`
            : 'API 응답이 상품 배열이 아니어서 목업 상품을 표시합니다.'}
        </p>
      )}
      <div className="product-grid">
        {actualProducts.map(product => (
          <ProductItem key={product.id} product={product} />
        ))}
      </div>
    </div>
  );
}

export default ProductList;

이 예제는 API 성공 시 응답 배열을, 요청 실패나 잘못된 응답 형식에서는 안내 문구와 함께 목업 배열을 표시합니다. 목업을 사용할 것이라고 설명하면서 오류에서 즉시 반환해 상품 목록을 숨기는 두 계약을 섞지 않습니다.

CartItem.js

src/components/CartItem.js
import React from 'react';
import useCart from '../hooks/useCart';
import { UPDATE_QUANTITY, REMOVE_ITEM } from '../reducers/cartReducer';

function CartItem({ item }) {
  const { dispatch } = useCart();

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

  const handleQuantityChange = (event) => {
    const newQuantity = event.target.valueAsNumber;
    if (!Number.isInteger(newQuantity)) return;

    updateQuantity(newQuantity);
  };

  const handleIncrement = () => {
    updateQuantity(item.quantity + 1);
  };

  const handleDecrement = () => {
    // 1에서 감소하면 0을 보내고, reducer의 전이 규칙이 항목을 제거한다.
    updateQuantity(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
          type="button"
          className="quantity-btn"
          onClick={handleDecrement}
          aria-label={`${item.name} 수량 줄이기`}
        >
          -
        </button>
        <input
          type="number"
          value={item.quantity}
          onChange={handleQuantityChange}
          min="0"
          step="1"
          aria-label={`${item.name} 수량`}
        />
        <button
          type="button"
          className="quantity-btn"
          onClick={handleIncrement}
          aria-label={`${item.name} 수량 늘리기`}
        >
          +
        </button>
        <button
          type="button"
          className="remove-from-cart"
          onClick={handleRemove}
          style={{ marginLeft: '10px' }}
        >
          삭제
        </button>
      </div>
    </li>
  );
}

export default CartItem;

컴포넌트는 사용자의 의도를 액션으로 전달합니다. 직접 입력 핸들러는 Number.isInteger를 통과한 값만 UPDATE_QUANTITY로 보내지만, +- 버튼은 현재 item.quantity가 이미 숫자형 정수일 때에만 정수를 유지합니다. 현재 reducer의 UPDATE_QUANTITY 분기는 0 이하만 제거하므로 양의 소수는 남을 수 있습니다. reducer는 이전 배열이나 항목을 변경하지 않고 새 다음 상태를 반환하는 순수 함수여야 합니다.

또한 현재 reducer는 ADD_ITEM의 수량과 RESTORE_CART 배열 안 각 항목의 수량을 검증하지 않습니다. “모든 장바구니 항목의 수량은 언제나 1 이상의 정수”를 앱 전체 불변식으로 보장하려면 ADD_ITEM, RESTORE_CART, UPDATE_QUANTITY를 포함한 모든 액션 payload를 reducer 경계에서 검증해 거부하거나 정규화해야 합니다.

CartSummary.js

src/components/CartSummary.js
import React from 'react';
import useCart from '../hooks/useCart';
import CartItem from './CartItem';

function CartSummary() {
  const { cartState } = useCart();

  // 총액은 cartState에서 구할 수 있는 파생값이므로 렌더 중 계산한다.
  const totalAmount = cartState.reduce(
    (total, item) => total + (item.price * item.quantity),
    0
  );

  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;

totalAmount는 별도 state가 아니라 cartState에서 계산되는 파생값입니다. 이 정도의 짧은 합산은 먼저 렌더 중 직접 계산합니다. 목록이 커져 실제 측정에서 계산 비용이 확인되면 [cartState]를 의존성으로 한 useMemo를 추가할 수 있지만, 캐시가 없어도 결과가 정확해야 합니다.


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 아래 트리에 장바구니 상태와 dispatch를 전달
    <CartProvider>
      <div className="App">
        <ProductList /> {/* 제품 목록 */}
        <CartSummary /> {/* 장바구니 요약 */}
      </div>
    </CartProvider>
  );
}

export default App;

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

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

이 순서를 유지하면 이벤트는 액션만 보내고 reducer는 순수하게 다음 상태를 계산하는지 확인할 수 있습니다. 직접 입력은 정수만 dispatch하는지, +·-는 정수 수량에서 시작할 때 정수를 유지하는지, UPDATE_QUANTITY의 0 이하 값은 항목을 제거하는지 각각 점검합니다. 현재 예제는 양의 소수를 거부하지 않으며 ADD_ITEM·RESTORE_CART payload도 검증하지 않으므로, 앱 전체의 양의 정수 불변식은 별도 reducer 경계 검증이 필요합니다. 렌더의 파생 총액과 Effect의 네트워크·localStorage 동기화도 분리해 점검합니다.

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

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

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

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

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

/api/products가 준비되지 않은 실습 환경에서는 오류 안내와 목업 상품 목록이 함께 표시되는지도 확인합니다.

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

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

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

개발자 도구 활용
  • Components 탭에서 각 컴포넌트의 propsstate 변화를 관찰해 보세요.
  • Network 탭에서 /api/products 응답 상태와 오류 시 목업 전환을 확인해 보세요. cleanup으로 취소한 AbortError는 사용자 오류나 필수 로그로 표시하지 않습니다.
  • Application 탭에서 Local Storage를 열어 shoppingCart 항목에 장바구니 데이터가 JSON 형태로 저장되는 것을 확인해 보세요.

이제는 useReducer로 상태 전이를 소유하고, Context로 현재 값과 dispatch를 전달하며, useEffect로 외부 시스템 동기화를 감싸고, 커스텀 훅으로 소비 계약과 비동기 로직을 재사용하는 구성을 확인했습니다.

개발 Strict Mode에서 reducer가 순수성 검사를 위해 추가 호출되어도 같은 입력에는 같은 다음 상태가 나와야 합니다. Context 값 메모화 여부와 관계없이 수량·합계·복원 결과가 같아야 하며, 최적화는 이 정확성 위에만 추가합니다.


실습을 끝낸 뒤에는 reducer의 상태 전이, Context의 전달·구독 경계, 렌더의 파생값 계산, Effect와 fetch 훅의 외부 동기화를 따로 검증해 회귀를 줄여야 합니다.