본문으로 건너뛰기

안동민 개발노트

본문 시작

실습

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

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

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

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

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

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

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

HTML 다이어그램: /docs/react/ch4/ch4-6/1.html

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

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

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

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

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

useState 심화: 입력 필드 등 단순 상태에 useState를 적절히 사용합니다.

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

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


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

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

전역으로 공유되는 장바구니 상태는 useReducer + Context에 모으고, 입력 필드처럼 국소적인 값은 useState로 분리해 복잡도를 낮춥니다.

Vite로 생성된 프로젝트가 있다고 가정합니다.

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

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

기본 스타일링 (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;
}

이어서 보기