실습
reducer·Context·데이터 요청 커스텀 훅을 결합해 상품 조회와 장바구니 상태 전이를 관리하는 화면을 구현합니다.
4장에서는 useState의 세밀한 동작부터 useEffect로 부수 효과를 다루는 방법, useContext로 전역 데이터를 공유하는 방법, useReducer로 복잡한 상태를 관리하는 방법, 그리고 이를 감싸 재사용하는 커스텀 훅까지 배웠습니다. 이제 함수 컴포넌트를 개발할 바탕은 갖춘 셈입니다. 이 실습에서는 이 훅들을 모아 간단한 쇼핑 카트(shopping cart) 애플리케이션을 만듭니다.
이 앱은 상품 목록을 보여 주고, 상품을 장바구니에 넣거나 빼며, 장바구니 항목의 수량을 바꾸고, 장바구니의 총액을 계산합니다. 장바구니의 상태는 전역으로 공유해 여러 컴포넌트가 접근할 수 있게 하고, 가상의 API 호출로 로딩 상태와 오류도 처리합니다. 실습을 통해 배운 개념이 조금 더 복잡한 애플리케이션에서 어떻게 맞물려 동작하는지 체감해 보세요. 실습은 이 글에서 준비를 마친 뒤, reducer·Context·데이터 요청에서 상태 계층을, 장바구니 컴포넌트 통합에서 화면 컴포넌트를 만드는 순서로 이어집니다.
CART · STATE ARCHITECTURE
장바구니의 계산·전달·합계·저장을 다른 경계에 둡니다
UI는 action을 dispatch하고 CartProvider의 reducer가 항목과 수량을 계산합니다. Context가 현재 값을 전달하고, CartSummary는 총액을 파생하며 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은 사용 경계를 단순화합니다. 저장 배열의 내부 필드는 현재 구현이 검사하지 않습니다.
위 그림은 장바구니의 계산과 전달, 합계, 저장이 서로 다른 곳에서 일어나도록 나눈 구조를 보여 줍니다. 상품 UI는 ADD_ITEM 액션을, 수량과 삭제 UI는 변경 액션을 dispatch로 보내고, CartProvider 안의 cartReducer가 순수한 전이로 항목과 수량을 계산합니다. 그렇게 만들어진 cartState는 Context로 화면 컴포넌트에 전달되고, CartSummary는 렌더하는 동안 그 항목들에서 총액을 계산합니다. 저장 Effect는 복원이 끝난 뒤에만 로컬 스토리지에 쓰고, 처음 마운트될 때는 복원 Effect가 로컬 스토리지의 저장 배열로 RESTORE_CART를 보내 장바구니를 되살립니다.
그림 아래의 예는 같은 상품을 두 번 담는 경우입니다. 가격이 1,200,000원인 스마트폰이 quantity=1로 담겨 있을 때 같은 id로 ADD_ITEM을 한 번 더 보내면, 행은 하나로 유지되고 quantity만 2가 되며, CartSummary는 1,200,000 × 2 = 2,400,000원을 보여 줍니다. 총액은 items에서 계산할 뿐 별도의 state로 저장하지 않습니다. 그림의 설명처럼 같은 cartState를 Context로도 전달하고 저장 Effect에서도 쓰지만, 저장 Effect가 Context를 읽는 별도의 소비자라는 뜻은 아닙니다. 또 상품을 요청하는 일은 장바구니와 따로 움직이는 수명 주기이며, 리듀서는 Context나 저장소를 알지 못하고, 커스텀 훅은 컴포넌트가 이 구조를 쉽게 쓰도록 감싸 줍니다. 저장 배열 안의 필드가 올바른지는 지금의 구현이 검사하지 않습니다.
실습 목표: 장바구니 상태 전이 교정
이 실습은 장바구니에서 흔히 생기는 오작동, 곧 같은 상품이 두 줄로 들어가는 중복 추가, 수량이 0이 되었을 때의 처리, 합계가 실제 항목과 맞지 않는 문제를 먼저 가정하고, 리듀서를 중심으로 바로잡는 방식으로 진행합니다.
이 과정에서 4장의 훅을 모두 씁니다. 장바구니 항목과 수량의 추가, 제거, 변경 같은 복잡한 상태 로직은 useReducer의 리듀서 한곳에 모으고, 장바구니 상태와 dispatch는 Context API로 상품 목록과 장바구니 요약 같은 여러 컴포넌트에 props drilling 없이 전달합니다. 상품 목록은 useEffect와 가상의 API 호출로 비동기로 불러오며, 로딩과 오류 상태를 관리하고 정리 함수도 씁니다. useState로는 저장소의 복원이 끝났는지와 요청의 데이터, 로딩, 오류 상태를 관리하고, API 데이터를 불러오는 로직은 useFetch 같은 커스텀 훅으로 떼어 내 재사용성과 가독성을 높입니다. 객체와 배열의 상태를 갱신할 때는 언제나 불변성을 지킵니다.
준비 단계: reducer 중심 구조 세팅
준비 단계에서는 어떤 상태를 어디에 둘지 먼저 정합니다. 장바구니 항목과 수량은 useReducer와 Context에서 관리합니다. useState는 저장소의 복원이 끝났는지와 요청의 상태에만 쓰며, 수량 입력도 별도의 로컬 상태를 두지 않고 리듀서에 액션을 보냅니다.
Vite로 만든 프로젝트가 있다고 가정하고, src 폴더에 다음 구조로 파일을 만듭니다. JSX가 들어 있는 파일은 .jsx로, JSX가 없는 리듀서와 훅 파일은 .js로 저장하며, 다음 두 글의 코드 제목도 이 이름을 따릅니다.
useFetch.js는 다음 글에서, useCart.js는 그다음 글에서 작성합니다. 여기서 쓰는 /api/products는 이 교재가 제공하는 서버가 아니므로, 서버를 준비하지 않았다면 뒤의 ProductList가 안내 문구와 목업 데이터를 보여 주는 경로로 먼저 연습합니다.
상품 이미지를 불러오지 못했을 때 쓸 대체 이미지도 프로젝트에 있어야 합니다. public/docs/common 폴더를 만들고 다음 파일을 저장하면, 이미지 실패를 처리하는 코드와 목업 데이터의 같은 URL이 모두 이 그림을 씁니다.
<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)
간단한 레이아웃과 버튼 스타일을 위해 Vite 템플릿이 만든 src/index.css의 내용을 지우고 다음 CSS로 바꿉니다. 템플릿의 #root 규칙(width: 1126px, text-align: center, 좌우 테두리)이 남아 있으면 글자 정렬과 화면 폭이 이 글의 설명과 달라질 수 있습니다. 큰 화면에서는 .App이 grid-template-columns: 2fr 1fr로 상품 목록과 장바구니를 2:1의 두 열로 나눠 보여 줍니다. fr 단위나 repeat(), minmax() 같은 Grid 문법이 낯설다면 Grid 레이아웃을 먼저 읽어 보세요.
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를 붙인 다음 아래 변형을 파일 끝에 더합니다. 큰 화면의 두 열 구조는 유지하고, 48rem 이하에서는 상품과 장바구니를 한 열로 놓습니다. 상품 카드의 최소 폭이 화면보다 넓어지지 않게 하고, 수량 버튼은 다음 줄로 내려갈 수 있게 합니다.
@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로 바꿔, 상품 카드와 수량 입력, 삭제 버튼이 모두 보이는지 확인하세요. 줄 배치가 달라도 같은 항목의 수량과 총액, 새로고침 뒤의 복원 결과는 같아야 합니다. 좁은 화면에서 다음 줄로 내려간 버튼을 다른 상품의 버튼으로 잘못 읽지 않도록 각 항목의 이름과 함께 대조합니다.
정리
- 장바구니 실습은 리듀서가 항목과 수량을 계산하고, Context가 값을 전달하며,
CartSummary가 렌더에서 총액을 계산하고, Effect가 저장소의 복원과 저장을 맡도록 나눕니다. - 같은 상품을 다시 담으면 행을 늘리지 않고 수량만 올리며, 총액은 별도의 state가 아니라 항목에서 계산합니다.
- JSX가 있는 파일은
.jsx, 리듀서와 훅처럼 JSX가 없는 파일은.js로 저장합니다. /api/products와 대체 이미지는 직접 준비해야 하며, 서버가 없을 때는 목업 데이터 경로로 먼저 연습합니다.
연습 문제
1. 같은 상품을 세 번 담으면
빈 장바구니에 가격이 1,200,000원인 스마트폰을 같은 id로 ADD_ITEM을 세 번 보내 담았습니다. 장바구니의 행 수와 수량, 총액은 무엇이어야 할까요?
해설 보기
행은 하나이고 수량은 3이며, 총액은 1,200,000 × 3 = 3,600,000원입니다. 같은 id의 상품은 새 행을 만들지 않고 기존 행의 quantity만 올려야 합니다. 행이 세 개로 늘어난다면 리듀서가 기존 항목을 찾지 못하고 있는 것이므로, 다음 글에서 cartReducer의 ADD_ITEM 처리를 확인합니다.
2. 총액을 state로 두면
총액을 CartSummary에서 계산하지 않고, 리듀서의 state에 total이라는 값을 따로 두어 액션마다 더하고 빼도록 바꾸면 어떤 문제가 생길 수 있을까요?
해설 보기
항목과 총액이라는 두 값을 따로 맞춰야 하므로, 한 액션에서 총액을 갱신하는 것을 빠뜨리거나 수량 변경의 차이를 잘못 계산하면 화면의 항목과 총액이 어긋납니다. 저장소에서 복원할 때도 두 값이 서로 다른 시점의 값일 수 있습니다.
총액은 항목에서 언제나 계산할 수 있는 값이므로, 렌더하는 동안 items에서 계산하면 이런 불일치가 생길 여지가 없습니다.
다음 글 reducer·Context·데이터 요청에서는 장바구니 리듀서와 Context, 데이터를 요청하는 커스텀 훅으로 이 앱의 상태 계층을 만듭니다.