장바구니 컴포넌트 통합
상품과 장바구니 컴포넌트를 조립하고 최종 화면의 상태 전이를 검증합니다.
앞 글 reducer·Context·데이터 요청에서 장바구니의 상태 계층을 만들었습니다. 이 글에서는 그 위에 상품과 장바구니의 화면 컴포넌트를 만들어 연결하고, 완성된 앱에서 수량과 총액, 새로고침 뒤의 복원이 바르게 동작하는지 확인합니다.
컴포넌트 구성
장바구니 실습에서는 리듀서의 전이, Context의 전달, 총액의 계산, 외부 시스템과의 동기화를 서로 다른 역할로 나눠 점검합니다. 장바구니의 상태와 변경 규칙은 CartProvider 안의 useReducer와 cartReducer가 가지고 있습니다. Context는 별도의 저장소가 아니라, 현재의 cartState와 dispatch를 트리 아래로 전달하고 useContext를 호출한 컴포넌트가 그 값의 변화를 받게 해 주는 통로입니다.
React는 Provider의 이전 value와 다음 value를 Object.is로 비교합니다. 이 예제의 value={{ cartState, dispatch }}는 Provider가 렌더링될 때마다 새 객체이므로, Context를 읽는 소비자도 다시 렌더링될 수 있습니다. 작은 실습에서는 먼저 구조를 단순하게 두고, 실제로 측정해서 문제가 확인될 때 Context를 나누거나 값을 메모이제이션합니다. 메모이제이션은 성능을 위한 최적화일 뿐, 결과가 정확하게 나오도록 보장하는 장치가 아닙니다.
CART · RESPONSIBILITY BOUNDARIES
CartProvider가 상태를 소유하고 Context는 값을 전달합니다
장바구니의 상태 전이, 전달, 파생 계산, 외부 동기화를 각각 분리합니다. Context 자체는 별도 store도 reducer 소유자도 아닙니다.
1 · 소유와 순수 전이
CartProvider의 useReducer가 cartState를 소유합니다. cartReducer는 state와 action으로 다음 배열을 계산합니다.
상품·수량 UI는 dispatch만 하고 reducer 규칙을 복제하지 않습니다.
2 · 전달과 구독
CartContext는 cartState와 안정적인 dispatch를 전달합니다. Provider value가 Object.is 비교에서 달라지면 이를 읽는 소비자가 다시 렌더됩니다.
useCart는 가장 가까운 Provider를 읽고, Provider 밖에서는 계약 오류를 냅니다.
3 · 렌더의 파생 총액
CartSummary는 cartState에서 총액을 계산합니다. 별도 state로 복제하거나 Effect로 다시 쓰지 않습니다.
useMemo는 측정된 비용을 줄이는 최적화이며 정확성·생명주기 계약은 아닙니다.
4 · Effect의 외부 경계
useFetch는 요청과 cleanup을, Provider Effect는 localStorage 복원·저장을 맡습니다.
state와 props만으로 구할 수 있는 값은 렌더에서 계산합니다.
수량 검증 · UI와 reducer의 범위
직접 입력: Number.isInteger 검사
정수일 때만 dispatch
UPDATE_QUANTITY: quantity > 0 필터
양의 소수 0.5 → reducer에 남음증감 버튼은 현재 item.quantity가 숫자형 정수일 때 정수를 유지합니다. 직접 입력의 정수 제한만으로 앱 전체의 양의 정수 불변식을 보장하지는 않습니다.
회귀 검증은 직접 입력, 정수에서 시작한 증감, 0 이하 제거, Context 소비자의 갱신과 파생 총액을 나눕니다. 전역 불변식은 ADD_ITEM·RESTORE_CART·UPDATE_QUANTITY의 모든 payload를 reducer 경계에서 검증·정규화해야 보장됩니다.
위 그림은 장바구니 앱의 역할을 네 칸으로 나눠 보여 줍니다. 첫째는 상태를 가지고 순수하게 전이하는 일로, CartProvider의 useReducer가 cartState를 가지며 cartReducer가 state와 액션으로 다음 배열을 계산합니다. 상품과 수량의 UI는 dispatch만 할 뿐 리듀서의 규칙을 따로 베끼지 않습니다. 둘째는 전달과 구독으로, CartContext가 cartState와 참조가 바뀌지 않는 dispatch를 전달하고, Provider의 value가 Object.is 비교에서 달라지면 그 값을 읽는 소비자가 다시 렌더링됩니다. 이 글의 useCart는 가장 가까운 Provider를 읽고, Provider 밖에서 호출되면 오류를 냅니다.
셋째는 렌더에서 계산하는 파생 총액입니다. CartSummary는 cartState에서 총액을 계산하며, 그 값을 별도의 state로 복제하거나 Effect로 다시 쓰지 않습니다. useMemo는 측정한 비용을 줄이는 최적화일 뿐입니다. 넷째는 Effect가 맡는 외부와의 연결로, useFetch는 요청과 정리를, Provider의 Effect는 로컬 스토리지의 복원과 저장을 맡으며, state와 props만으로 구할 수 있는 값은 렌더에서 계산합니다.
그림의 아래 칸은 수량을 검증하는 범위를 정리합니다. 직접 입력은 Number.isInteger로 정수일 때만 dispatch하지만, 리듀서의 UPDATE_QUANTITY는 0보다 큰 값을 남기는 필터만 있으므로 0.5 같은 양의 소수가 들어오면 리듀서에 그대로 남습니다. 증감 버튼은 현재의 item.quantity가 숫자 정수일 때만 정수를 유지합니다. 그러니 직접 입력의 정수 제한만으로 앱 전체의 수량이 언제나 양의 정수라고 보장할 수는 없습니다.
이제 앞에서 만든 Context와 리듀서, 커스텀 훅을 써서 UI 컴포넌트를 만들어 봅니다.
useCart.js (Provider 확인)
CartContext의 기본값 null은 Provider가 없을 때만 쓰이는 정적인 대체값입니다. 소비자가 실수로 CartProvider 밖에 놓이면, 구조 분해 단계에서 알기 어려운 오류가 나는 대신 무엇이 잘못되었는지 분명한 오류를 내도록 전용 훅에서 검사합니다. 이 파일에는 JSX가 없으므로 .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;이 훅을 쓰는 컴포넌트는 useContext(CartContext) 대신 useCart()만 호출하면 되고, 돌려받은 값을 { cartState, dispatch }로 바로 구조 분해할 수 있습니다. Provider 밖에서 호출되면 구조 분해보다 먼저 이 훅의 오류 문구가 나타나므로, 원인을 찾기 쉽습니다.
LoadingSpinner.jsx (로딩 상태 분리)
import React from 'react';
function LoadingSpinner() {
return (
<div
className="loading-spinner"
role="status"
aria-label="제품 목록 불러오는 중"
/>
);
}
export default LoadingSpinner;스피너는 모양을 CSS 클래스로만 그리는 빈 div이므로, 화면을 보지 않는 사용자에게는 아무 내용도 전달되지 않습니다. 그래서 role="status"로 상태 알림 영역임을 밝히고, aria-label로 지금 무엇을 기다리는지 알려 줍니다.
ProductItem.jsx
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) => {
const image = event.currentTarget;
if (image.dataset.fallbackApplied) return;
image.dataset.fallbackApplied = 'true';
image.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;상품 카드는 장바구니에 추가 버튼을 누르면 ADD_ITEM 액션에 상품과 수량 1을 담아 보냅니다. 이미지를 불러오지 못하면 onError가 실습에서 준비한 대체 이미지로 바꾸고 data-fallback-applied 표시를 남기므로, 대체 이미지마저 실패했을 때는 바로 돌아가 같은 처리가 끝없이 되풀이되지 않습니다. React는 onError를 DOM의 onerror 속성이 아니라 addEventListener로 연결하므로, 흔히 보는 onerror = null로는 이 처리 함수를 끊을 수 없습니다. 가격은 toLocaleString()으로 브라우저의 지역 설정에 맞춰 1,200,000처럼 자릿수를 나눠 보여 줍니다.
ProductList.jsx
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 요청이 성공하면 응답 배열을 보여 주고, 요청이 실패하거나 응답이 배열이 아니면 안내 문구와 함께 목업 배열을 보여 줍니다. 목업을 쓰겠다고 안내하면서 오류가 났을 때 바로 반환해 상품 목록을 숨기는 방식과 섞지 않습니다. /api/products 서버를 준비하지 않은 채 Vite 개발 서버에서 실행하면, 개발 서버가 없는 경로에도 index.html을 200으로 돌려줄 수 있어서 response.json()의 파싱 오류가 error로 잡히고, 안내 문구 안에 그 오류 메시지가 함께 보입니다.
CartItem.jsx
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가 이미 숫자 정수일 때만 정수를 유지합니다. 지금의 리듀서는 UPDATE_QUANTITY에서 0 이하만 지우므로 양의 소수는 남을 수 있습니다. 리듀서는 이전 배열이나 항목을 바꾸지 않고 새 다음 상태를 돌려주는 순수 함수여야 합니다.
또 지금의 리듀서는 ADD_ITEM의 수량과 RESTORE_CART 배열 안 각 항목의 수량도 검증하지 않습니다. “장바구니의 모든 항목의 수량은 언제나 1 이상의 정수”라는 규칙을 앱 전체에서 지키려면, ADD_ITEM과 RESTORE_CART, UPDATE_QUANTITY를 포함한 모든 액션의 페이로드를 리듀서에서 검증해 거부하거나 정규화해야 합니다.
CartSummary.jsx
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.jsx (최종)
모든 컴포넌트와 Context Provider를 조합합니다. App 자체는 상태를 갖지 않으며, ProductList와 CartSummary에 props도 넘기지 않습니다. 장바구니 상태는 CartProvider 안의 useReducer에 있으므로, App이 할 일은 두 컴포넌트를 Provider 안에 배치하는 것뿐입니다.
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;CartProvider가 앱 전체를 감싸므로, 그 안의 ProductList와 CartSummary가 모두 같은 장바구니 상태를 읽고 바꿀 수 있습니다. 실습에서 넣은 CSS에 따라 큰 화면에서는 상품 목록이 왼쪽에, 장바구니가 오른쪽에 놓입니다.
목업 상품으로 수량과 합계 대조하기
먼저 다른 실험에서 남은 shoppingCart 키만 지워 빈 장바구니로 시작합니다. 이 코드는 복원한 JSON이 배열인지는 확인하지만, 각 항목의 id와 price, quantity 모양까지 검증하지는 않습니다. 따라 하는 동안에는 위 목업의 고유한 id, 숫자 가격, 정수 수량을 씁니다. 서버가 배열을 돌려주더라도 각 상품의 name과 imageUrl, 숫자 price가 올바른지는 별도의 입력 검증 문제이며, 정상적인 빈 배열 응답은 목업으로 바꾸지 않습니다.
스마트폰을 두 번 담으면 항목은 하나이고 수량은 2, 금액은 1,200,000 × 2 = 2,400,000원입니다. 이어서 무선 이어폰을 세 번 담으면 200,000 × 3 = 600,000원이 더해져 총 결제 금액은 3,000,000원이 됩니다. 스마트폰의 수량을 0으로 바꾸면 그 항목이 지워지고 총액은 600,000원이 됩니다. 새로고침한 뒤에는 저장에 성공한 마지막 상태가 복원되는지 확인합니다.
검증 순서: 장바구니 회귀 테스트
실습의 검증은 중복 담기, 수량 늘리고 줄이기, 수량 0으로 지우기, 새로고침 뒤 복원의 순서로 진행합니다. 이 순서를 지키면 이벤트는 액션만 보내고 리듀서는 순수하게 다음 상태를 계산하는지 확인할 수 있습니다. 직접 입력은 정수만 dispatch하는지, +와 -는 정수 수량에서 시작할 때 정수를 유지하는지, UPDATE_QUANTITY의 0 이하 값은 항목을 지우는지를 하나씩 점검합니다. 앞에서 말했듯 지금의 예제는 양의 소수를 거부하지 않고 ADD_ITEM과 RESTORE_CART의 페이로드도 검증하지 않으므로, 앱 전체에서 양의 정수 규칙을 지키려면 리듀서에서 따로 검증해야 합니다. 렌더에서 계산하는 총액과, Effect가 맡는 네트워크와 로컬 스토리지의 동기화도 나눠 점검합니다.
직접 실행하려면 위의 모든 코드를 경로에 맞는 파일로 만들어 붙여 넣고, npm run dev로 개발 서버를 시작해 터미널에 나온 Local 주소를 엽니다. 장바구니에 추가 버튼을 눌러 상품을 담고, 장바구니 영역에 상품이 나타나며 총액이 바뀌는지 확인하세요. /api/products를 준비하지 않았다면 오류 안내와 목업 상품 목록이 함께 보이는지도 확인합니다. 장바구니에서 +나 - 버튼, 또는 수량 입력칸으로 수량을 바꿔 보고, 삭제 버튼으로 상품을 지워 본 뒤, 새로고침해도 장바구니가 남는지 확인합니다.
개발자 도구도 함께 씁니다. React Developer Tools를 설치했다면 Components 탭에서 각 컴포넌트의 props와 state가 바뀌는 모습을 볼 수 있습니다. Network 탭에서는 /api/products의 응답 상태와 오류가 났을 때 목업으로 바뀌는 모습을 확인하며, 정리 함수가 취소한 AbortError는 사용자에게 보여 줄 오류가 아닙니다. Application 탭의 Local Storage에서는 shoppingCart 항목에 장바구니 데이터가 JSON으로 저장되는지 봅니다.
개발 모드의 Strict Mode가 순수성을 확인하려고 리듀서를 한 번 더 호출해도, 같은 입력에는 같은 다음 상태가 나와야 합니다. Context의 값을 메모이제이션하든 안 하든 수량과 합계, 복원 결과는 같아야 하며, 최적화는 이렇게 결과가 정확한 바탕 위에서만 더합니다.
정리
- 장바구니 앱은
useReducer가 상태의 전이를 맡고, Context가 현재 값과dispatch를 전달하며,useEffect가 외부 시스템과의 동기화를 감싸고, 커스텀 훅이 소비 방식과 비동기 로직을 재사용하게 나눕니다. useCart는 Provider 밖에서 호출되면 분명한 오류를 던져, 기본값null때문에 생기는 알기 어려운 오류를 막습니다.- 총액은
cartState에서 렌더하는 동안 계산하는 파생 값이며, 별도의 state로 두지 않습니다. - 직접 입력의 정수 검사만으로는 앱 전체의 수량 규칙이 지켜지지 않으므로, 규칙을 보장하려면 리듀서에서 모든 액션의 페이로드를 검증합니다.
- 리듀서와 렌더 계산은 Strict Mode의 추가 호출에도 같은 결과를 내야 하며, 메모이제이션은 정확성이 확인된 뒤에 더하는 최적화입니다.
연습 문제
1. 노트북과 스마트 워치
빈 장바구니에서 노트북 한 개와 스마트 워치 두 개를 담은 뒤, 워치의 수량을 1로 바꾸고 노트북을 삭제합니다. 각 단계의 항목 수와 총액은 얼마일까요? API 요청이 실패해 목업이 보이는 상태에서도 같은 계산을 할 수 있습니다.
해설 보기
두 상품을 담은 상태는 항목이 2개이고, 총액은 1,500,000 + 350,000 × 2 = 2,200,000원입니다. 워치의 수량을 1로 바꾸면 항목은 여전히 2개이고 총액은 1,850,000원입니다. 노트북을 지우면 항목은 1개, 총액은 350,000원입니다.
총액은 현재 배열에서 계산하는 파생 값이므로, 수량을 바꾼 뒤 예전 합계가 어딘가에 남지 않습니다.
2. Provider 밖의 상품 카드
ProductItem을 CartProvider 밖에서 렌더링하면 어떻게 될까요? useCart 없이 useContext(CartContext)를 바로 구조 분해했다면 어떤 차이가 있을까요?
해설 보기
useCart가 useContext(CartContext)의 결과 null을 보고 useCart must be used within <CartProvider>.라는 오류를 던지므로, 무엇이 잘못되었는지 바로 알 수 있습니다.
useContext(CartContext)를 바로 구조 분해했다면 null에서 dispatch를 꺼내려다 TypeError가 나며, 오류 메시지만 보고는 Provider가 빠졌다는 원인을 알아차리기 어렵습니다.
3. 빈 배열을 돌려주는 API
/api/products가 정상적으로 200과 빈 배열 []을 돌려준다면 상품 목록 영역에는 무엇이 보일까요?
해설 보기
products가 배열이므로 usingMockProducts는 false가 되어, 목업 상품도 안내 문구도 보이지 않고 “제품 목록” 제목 아래의 상품 영역이 비어 있게 됩니다. 정상적인 빈 응답은 실패가 아니므로 목업으로 바꾸지 않는 것이 이 예제의 규칙입니다. 실제 앱이라면 “표시할 상품이 없습니다” 같은 빈 상태 안내를 따로 더할 수 있습니다.
이것으로 4장을 마칩니다. 4장에서는 함수형 업데이트와 불변성, 지연 초기화로 useState를 깊이 다루고, useEffect의 의존성과 정리 함수, useContext로 값을 공유하는 방법, useReducer로 상태 전이를 모으는 방법, 커스텀 훅으로 로직을 재사용하는 방법을 익힌 뒤, 이 훅들을 모아 장바구니 앱을 만들었습니다. 다음 장의 인라인 스타일링과 CSS 클래스 사용부터는 인라인 스타일과 CSS 클래스, CSS 모듈, styled-components, 반응형 디자인으로 React 컴포넌트에 스타일을 입히는 방법을 다룹니다.