본문 바로가기

실습

reducer·Context·데이터 요청 커스텀 훅을 결합해 상품 조회와 장바구니 상태 전이를 관리하는 화면을 구현합니다.

useState의 미묘한 동작부터 useEffect의 부수 효과 제어, useContext를 통한 전역 데이터 공유, useReducer를 이용한 복잡한 상태 관리, 그리고 이를 캡슐화해 재사용하는 커스텀 훅까지 배웠습니다.

이제는 함수형 컴포넌트 개발의 탄탄한 기반을 갖춘 상태입니다.

이번 실습에서는 이 핵심 훅들을 통합해 간단한 쇼핑 카트(Shopping Cart) 애플리케이션을 만들어 봅니다.

이 앱은 다음과 같은 기능을 가집니다.

  • 제품 목록 표시
  • 제품을 장바구니에 추가/제거
  • 장바구니 항목의 수량 변경
  • 장바구니 총액 계산
  • 장바구니 상태를 전역적으로 공유하여 여러 컴포넌트에서 접근 가능
  • 로딩 상태 및 에러 처리 (가상 API 호출)

이 실습을 통해 배운 개념이 실제 복잡한 애플리케이션에서 어떻게 유기적으로 동작하는지 체감하고, 리액트의 힘을 경험할 수 있습니다.

장바구니의 계산·전달·합계·저장을 다른 경계에 둡니다

CART · STATE ARCHITECTURE

장바구니의 계산·전달·합계·저장을 다른 경계에 둡니다

UI는 action을 dispatch하고 CartProvider의 reducer가 항목과 수량을 계산합니다. Context가 현재 값을 전달하고, CartSummary는 총액을 파생하며 Effect는 저장소 복원·저장을 맡습니다.

장바구니의 계산·배포·파생값·저장을 서로 다른 경계에 둔다상품 목록과 장바구니 조작이 Cart Provider에 action을 보내고 reducer state가 Context consumer와 파생 합계로 흐르며 Effect가 저장소와 동기화하는 구조. 상태 경계상품 UIADD_ITEM수량 · 삭제 UI변경 액션dispatch계산 요청cartReducer순수 전이cartStateContext로 전달저장 Effect복원 완료 뒤 쓰기CartSummary렌더에서 total 계산localStorage복원 Effect의 원본
장바구니의 계산·배포·파생값·저장을 서로 다른 경계에 둔다상품 목록과 장바구니 조작이 Cart Provider에 action을 보내고 reducer state가 Context consumer와 파생 합계로 흐르며 Effect가 저장소와 동기화하는 구조. 상태 경계상품 UIADD_ITEM수량 · 삭제 UI변경 액션dispatch계산 요청cartReducer순수 전이cartStateContext로 전달저장 Effect복원 완료 뒤 쓰기CartSummary렌더에서 total 계산localStorage복원 Effect의 원본

본문 상품 예제 · 중복 추가와 파생 총액

스마트폰: 가격 1,200,000 / quantity=1
같은 id ADD_ITEM(quantity=1)
한 행 유지: quantity=2
CartSummary: 1,200,000 × 2 = 2,400,000

총액은 items에서 계산하며 별도 state로 저장하지 않습니다. 복원 점선은 mount Effect가 저장 배열로 RESTORE_CART를 dispatch하는 경로입니다.

같은 cartState를 Context로 전달하고 저장 Effect에서도 사용합니다. 저장 Effect가 Context를 읽는 별도 소비자라는 뜻은 아닙니다.

상품 요청은 별도 수명 주기입니다. reducer는 Context나 storage를 모르고, custom Hook은 사용 경계를 단순화합니다. 저장 배열의 내부 필드는 현재 구현이 검사하지 않습니다.


실습 목표: 장바구니 상태 전이 교정

이번 실습은 장바구니에서 자주 발생하는 오작동(중복 추가, 수량 0 처리, 합계 불일치)을 먼저 상정하고, reducer 중심으로 교정하는 방식으로 진행합니다.

useReducer를 이용한 장바구니 상태 관리: 장바구니 항목(아이템, 수량)의 추가, 제거, 수량 변경 등 복잡한 상태 로직을 reducer 함수로 중앙 집중화합니다.

useContext를 이용한 장바구니 전역 공유: 장바구니 상태와 dispatch 함수를 Context API를 통해 여러 컴포넌트(예: 제품 목록, 장바구니 요약)에서 props drilling 없이 접근하도록 합니다.

useEffect를 이용한 초기 데이터 로딩: useEffect와 가상 API 호출을 통해 제품 목록을 비동기적으로 불러오고, 로딩 및 에러 상태를 관리합니다. (클린업 함수 활용)

useState 심화: 저장소 복원 완료 여부와 요청의 데이터·로딩·오류 상태를 관리합니다.

커스텀 훅 만들기: API 데이터 로딩 로직을 useFetch와 같은 커스텀 훅으로 분리하여 재사용성과 가독성을 높입니다.

불변성 유지: 객체 및 배열 상태를 업데이트할 때 항상 불변성을 지키는 코드를 작성합니다.


준비 단계: reducer 중심 구조 세팅

준비 단계에서는 구현 선택 기준을 분명히 둡니다.

장바구니 항목과 수량은 useReducer + Context에서 관리합니다. useState는 저장소 복원 완료 여부와 요청 상태에 사용하며, 수량 입력도 별도 로컬 상태 대신 reducer에 액션을 보냅니다.

Vite로 생성된 프로젝트가 있다고 가정합니다. 아래 원문의 파일명은 .js로 표시했지만, Vite의 기본 React 설정에서 JSX가 들어 있는 파일은 .jsx로 저장하고 해당 import 경로도 맞춥니다.

src 폴더에 다음과 같은 구조로 파일들을 생성하고 코드를 작성하겠습니다.

App.js
index.css # 간단한 스타일링
ProductList.js
ProductItem.js
CartSummary.js
CartItem.js
LoadingSpinner.js
CartContext.js
cartReducer.js

파일 트리에는 생략되어 있지만 src/hooks 폴더도 만들고, 이어지는 절의 useFetch.js와 useCart.js를 각각 저장합니다. JSX를 포함하지 않는 reducer·hook 파일은 .js를 유지할 수 있습니다. 여기서 /api/products는 제공되는 서버가 아니므로, 준비되지 않은 경우 뒤의 ProductList가 안내와 목업을 표시하는 경로로 먼저 연습합니다.

상품 이미지의 대체 경로도 학습자의 프로젝트에 파일이 있어야 합니다. public/docs/common 폴더를 만들고 다음 파일을 저장하면 원래 이미지 실패 처리와 목업의 같은 URL이 모두 이 그림을 사용합니다.

public/docs/common/fallback-image.svg
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 160" role="img" aria-label="상품 이미지 없음">
  <rect width="240" height="160" fill="#eef2f6" />
  <path d="M40 116L92 64L124 96L156 72L200 116Z" fill="#718096" />
  <circle cx="170" cy="40" r="12" fill="#718096" />
</svg>

이 public 경로는 프로젝트 루트 기준의 이미지 URL을 제공하기 위한 준비입니다. 교재 사이트의 파일이 새 프로젝트에 자동으로 복사되지는 않습니다.


기본 스타일링 (index.css 또는 App.css)

간단한 레이아웃과 버튼 스타일을 위해 CSS를 추가합니다.

src/index.css
body {
  font-family: 'Arial', sans-serif;
  margin: 0;
  padding: 0;
  background-color: #f4f7f6;
  color: #333;
}

.App {
  max-width: 1200px;
  margin: 20px auto;
  padding: 20px;
  background-color: #fff;
  border-radius: 10px;
  box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
  display: grid;
  grid-template-columns: 2fr 1fr;
  gap: 30px;
}

h1, h2, h3 {
  color: #2c3e50;
  margin-top: 0;
}

button {
  padding: 8px 15px;
  border: none;
  border-radius: 5px;
  cursor: pointer;
  font-size: 1em;
  transition: background-color 0.2s ease;
}

button.add-to-cart {
  background-color: #28a745;
  color: white;
}

button.add-to-cart:hover {
  background-color: #218838;
}

button.remove-from-cart {
  background-color: #dc3545;
  color: white;
}

button.remove-from-cart:hover {
  background-color: #c82333;
}

button.quantity-btn {
  background-color: #007bff;
  color: white;
  width: 30px;
  height: 30px;
  padding: 0;
  display: flex;
  align-items: center;
  justify-content: center;
}

button.quantity-btn:hover {
  background-color: #0056b3;
}

input[type="number"] {
  width: 50px;
  text-align: center;
  border: 1px solid #ddd;
  border-radius: 5px;
  padding: 5px;
  margin: 0 5px;
}

.loading-spinner {
  border: 4px solid rgba(0, 0, 0, .1);
  width: 36px;
  height: 36px;
  border-radius: 50%;
  border-left-color: #09f;
  animation: spin 1s ease infinite;
  margin: 20px auto;
}

@keyframes spin {
  0% { transform: rotate(0deg); }
  100% { transform: rotate(360deg); }
}

.error-message {
  color: red;
  font-weight: bold;
  text-align: center;
}

.product-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
  gap: 20px;
}

.product-item {
  background-color: #f9f9f9;
  border: 1px solid #eee;
  border-radius: 8px;
  padding: 15px;
  text-align: center;
  box-shadow: 0 2px 5px rgba(0,0,0,0.05);
}

.product-item img {
  max-width: 100%;
  height: 150px;
  object-fit: contain;
  margin-bottom: 10px;
}

.product-item h3 {
  font-size: 1.2em;
  margin-bottom: 5px;
}

.product-item p {
  font-size: 1.1em;
  font-weight: bold;
  color: #007bff;
  margin-bottom: 10px;
}

.cart-summary {
  background-color: #e9f7f0;
  border: 1px solid #d4edda;
  border-radius: 10px;
  padding: 20px;
}

.cart-summary h2 {
  border-bottom: 1px solid #d4edda;
  padding-bottom: 10px;
  margin-bottom: 15px;
}

.cart-item {
  display: flex;
  align-items: center;
  margin-bottom: 15px;
  padding-bottom: 15px;
  border-bottom: 1px dashed #e2e6ea;
}

.cart-item:last-child {
  border-bottom: none;
  margin-bottom: 0;
  padding-bottom: 0;
}

.cart-item-info {
  flex-grow: 1;
}

.cart-item-info h4 {
  margin: 0 0 5px 0;
  font-size: 1em;
}

.cart-item-info p {
  margin: 0;
  font-size: 0.9em;
  color: #555;
}

.cart-item-controls {
  display: flex;
  align-items: center;
  margin-left: 15px;
}

.cart-total {
  text-align: right;
  font-size: 1.3em;
  font-weight: bold;
  margin-top: 20px;
  padding-top: 15px;
  border-top: 2px solid #d4edda;
}

좁은 화면에서도 같은 장바구니를 확인하기

원래 CSS를 붙인 다음 아래 변형을 파일 끝에 추가합니다. 2열의 큰 화면 구조는 유지하고, 48rem 이하에서는 제품과 장바구니를 한 열로 배치합니다. 상품의 최소 폭도 컨테이너보다 커지지 않게 하고, 수량 버튼은 다음 줄로 내려갈 수 있게 합니다.

src/index.css 끝에 추가
@media (max-width: 48rem) {
  .App {
    grid-template-columns: minmax(0, 1fr);
    gap: 20px;
    margin: 12px;
    padding: 16px;
  }
  .product-grid {
    grid-template-columns: repeat(auto-fit, minmax(min(100%, 250px), 1fr));
  }
  .cart-item {
    flex-wrap: wrap;
    gap: 12px;
  }
  .cart-item-info { min-width: 0; }
  .cart-item-controls {
    margin-left: 0;
    flex-wrap: wrap;
    gap: 6px;
  }
}

개발자 도구에서 폭 320px와 375px로 바꿔 상품 카드, 수량 입력과 삭제 버튼이 모두 보이는지 확인합니다. 줄 배치가 달라도 같은 항목의 수량·총액·새로고침 복원 결과는 같아야 합니다. 좁은 화면에서 다음 줄로 내려간 버튼을 다른 상품의 제어로 읽지 않도록 각 항목의 이름과 함께 대조합니다.


이어서 보기