실습
useState
의 미묘한 동작부터 useEffect
의 강력한 부수 효과 제어, useContext
를 통한 전역 데이터 공유, useReducer
를 이용한 복잡한 상태 관리, 그리고 이 모든 것을 캡슐화하고 재사용하는 커스텀 훅까지, 이제 여러분은 함수형 컴포넌트 개발의 진정한 고수가 되기 위한 탄탄한 기반을 다졌습니다.
이번 실습에서는 이 모든 핵심 훅들을 통합하여 간단한 "쇼핑 카트(Shopping Cart)" 애플리케이션을 만들어 볼 것입니다. 이 앱은 다음과 같은 기능을 가질 것입니다.
- 제품 목록 표시
- 제품을 장바구니에 추가/제거
- 장바구니 항목의 수량 변경
- 장바구니 총액 계산
- 장바구니 상태를 전역적으로 공유하여 여러 컴포넌트에서 접근 가능
- 로딩 상태 및 에러 처리 (가상 API 호출)
이 실습을 통해 배운 개념들이 실제 복잡한 애플리케이션에서 어떻게 유기적으로 동작하는지 체감하고, 리액트의 진정한 힘을 경험하실 수 있을 것입니다.
실습 목표
useReducer
를 이용한 장바구니 상태 관리: 장바구니 항목(아이템, 수량)의 추가, 제거, 수량 변경 등 복잡한 상태 로직을reducer
함수로 중앙 집중화합니다.useContext
를 이용한 장바구니 전역 공유: 장바구니 상태와dispatch
함수를 Context API를 통해 여러 컴포넌트(예: 제품 목록, 장바구니 요약)에서props drilling
없이 접근하도록 합니다.useEffect
를 이용한 초기 데이터 로딩:useEffect
와 가상 API 호출을 통해 제품 목록을 비동기적으로 불러오고, 로딩 및 에러 상태를 관리합니다. (클린업 함수 활용)useState
심화: 입력 필드 등 단순 상태에useState
를 적절히 사용합니다.- 커스텀 훅 만들기: API 데이터 로딩 로직을
useFetch
와 같은 커스텀 훅으로 분리하여 재사용성과 가독성을 높입니다. - 불변성 유지: 객체 및 배열 상태를 업데이트할 때 항상 불변성을 지키는 코드를 작성합니다.
프로젝트 준비
create-react-app
으로 생성된 프로젝트가 있다고 가정합니다. src
폴더에 다음과 같은 구조로 파일들을 생성하고 코드를 작성하겠습니다.
src/
├── App.js
├── index.css (간단한 스타일링)
├── components/
│ ├── ProductList.js
│ ├── ProductItem.js
│ ├── CartSummary.js
│ ├── CartItem.js
│ └── LoadingSpinner.js
├── contexts/
│ └── CartContext.js
└── reducers/
└── cartReducer.js
기본 스타일링 (index.css
또는 App.css
)
간단한 레이아웃과 버튼 스타일을 위해 CSS를 추가합니다.
/* src/index.css (또는 App.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;
}
cartReducer.js
(useReducer)
장바구니 상태를 관리하는 리듀서 함수를 정의합니다.
// src/reducers/cartReducer.js
// 액션 타입 상수 정의
export const ADD_ITEM = 'ADD_ITEM';
export const REMOVE_ITEM = 'REMOVE_ITEM';
export const UPDATE_QUANTITY = 'UPDATE_QUANTITY';
// 초기 장바구니 상태
export const initialCartState = [];
// 리듀서 함수
export const cartReducer = (state, action) => {
switch (action.type) {
case ADD_ITEM:
const existingItem = state.find(item => item.id === action.payload.item.id);
if (existingItem) {
// 이미 있는 상품이면 수량만 증가
return state.map(item =>
item.id === action.payload.item.id
? { ...item, quantity: item.quantity + action.payload.quantity }
: item
);
} else {
// 새 상품이면 목록에 추가
return [
...state,
{ ...action.payload.item, quantity: action.payload.quantity }
];
}
case REMOVE_ITEM:
return state.filter(item => item.id !== action.payload.id);
case UPDATE_QUANTITY:
return state.map(item =>
item.id === action.payload.id
? { ...item, quantity: action.payload.quantity }
: item
).filter(item => item.quantity > 0); // 수량이 0이하가 되면 제거
default:
throw new Error(`Unhandled action type: ${action.type}`);
}
};
CartContext.js
(useContext)
장바구니 상태와 디스패치 함수를 전역적으로 제공하는 Context를 생성합니다.
// src/contexts/CartContext.js
import React, { createContext, useReducer, useEffect } from 'react';
import { cartReducer, initialCartState } from '../reducers/cartReducer';
// 1. Context 객체 생성
export const CartContext = createContext();
// 2. 초기화 함수 (로컬 스토리지에서 장바구니 불러오기)
const initCart = (initialState) => {
try {
const savedCart = localStorage.getItem('shoppingCart');
return savedCart ? JSON.parse(savedCart) : initialState;
} catch (error) {
console.error("Failed to parse cart from localStorage", error);
return initialState;
}
};
// 3. Provider 컴포넌트 생성
export const CartProvider = ({ children }) => {
// useReducer와 지연 초기화(initCart)를 사용하여 장바구니 상태 관리
const [cartState, dispatch] = useReducer(cartReducer, initialCartState, initCart);
// cartState가 변경될 때마다 로컬 스토리지에 저장 (useEffect)
useEffect(() => {
localStorage.setItem('shoppingCart', JSON.stringify(cartState));
}, [cartState]);
// Context Provider를 통해 상태와 dispatch 함수 제공
return (
<CartContext.Provider value={{ cartState, dispatch }}>
{children}
</CartContext.Provider>
);
};
useFetch.js
(Custom Hook)
API 호출 로직을 재사용 가능한 커스텀 훅으로 만듭니다.
// src/hooks/useFetch.js
import { useState, useEffect } from 'react';
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const abortController = new AbortController(); // 요청 취소를 위한 AbortController
const signal = abortController.signal;
const fetchData = async () => {
setLoading(true);
setError(null);
try {
const response = await fetch(url, { signal });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const json = await response.json();
setData(json);
} catch (e) {
if (e.name === 'AbortError') {
console.log('Fetch aborted');
} else {
setError(e);
}
} finally {
setLoading(false);
}
};
fetchData();
// 클린업 함수: 컴포넌트 언마운트 또는 의존성 변경 시 요청 취소
return () => {
abortController.abort();
};
}, [url]); // url이 변경될 때마다 데이터를 다시 가져옵니다.
return { data, loading, error };
}
export default useFetch;
컴포넌트 구성
이제 위에서 만든 Context와 Reducer, 커스텀 훅을 활용하여 UI 컴포넌트들을 만들어 봅시다.
LoadingSpinner.js
// src/components/LoadingSpinner.js
import React from 'react';
function LoadingSpinner() {
return (
<div className="loading-spinner"></div>
);
}
export default LoadingSpinner;
ProductItem.js
// src/components/ProductItem.js
import React, { useContext } from 'react';
import { CartContext } from '../contexts/CartContext';
import { ADD_ITEM } from '../reducers/cartReducer';
function ProductItem({ product }) {
const { dispatch } = useContext(CartContext); // Context에서 dispatch 함수 가져오기
const handleAddToCart = () => {
dispatch({ type: ADD_ITEM, payload: { item: product, quantity: 1 } });
};
return (
<div className="product-item">
<img src={product.imageUrl} 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: 'https://via.placeholder.com/150/FF5733/FFFFFF?text=Phone' },
{ id: 2, name: '노트북', price: 1500000, imageUrl: 'https://via.placeholder.com/150/33FF57/FFFFFF?text=Laptop' },
{ id: 3, name: '무선 이어폰', price: 200000, imageUrl: 'https://via.placeholder.com/150/3357FF/FFFFFF?text=Earbuds' },
{ id: 4, name: '스마트 워치', price: 350000, imageUrl: 'https://via.placeholder.com/150/FF33CC/FFFFFF?text=Watch' },
{ id: 5, name: '태블릿', price: 800000, imageUrl: 'https://via.placeholder.com/150/FFFF33/FFFFFF?text=Tablet' },
];
// (가상) API URL. 실제로는 서버 엔드포인트가 될 것입니다.
const { data: products, loading, error } = useFetch('/api/products'); // useFetch 훅 사용
// 목업 데이터 사용 (실제 API가 없을 경우)
const actualProducts = products || mockProducts;
if (loading) return <LoadingSpinner />;
if (error) return <p className="error-message">제품 목록을 불러오는 데 실패했습니다: {error.message}</p>;
return (
<div>
<h2>제품 목록</h2>
<div className="product-grid">
{actualProducts.map(product => (
<ProductItem key={product.id} product={product} />
))}
</div>
</div>
);
}
export default ProductList;
CartItem.js
// src/components/CartItem.js
import React, { useContext } from 'react';
import { CartContext } from '../contexts/CartContext';
import { UPDATE_QUANTITY, REMOVE_ITEM } from '../reducers/cartReducer';
function CartItem({ item }) {
const { dispatch } = useContext(CartContext);
const handleQuantityChange = (e) => {
const newQuantity = Number(e.target.value);
// 수량이 0이하로 내려가면 삭제, 아니면 업데이트
if (newQuantity <= 0) {
dispatch({ type: REMOVE_ITEM, payload: { id: item.id } });
} else {
dispatch({ type: UPDATE_QUANTITY, payload: { id: item.id, quantity: newQuantity } });
}
};
const handleIncrement = () => {
dispatch({ type: UPDATE_QUANTITY, payload: { id: item.id, quantity: item.quantity + 1 } });
};
const handleDecrement = () => {
// 수량이 1일 때 감소시키면 제거 액션 발생
if (item.quantity === 1) {
dispatch({ type: REMOVE_ITEM, payload: { id: item.id } });
} else {
dispatch({ type: UPDATE_QUANTITY, payload: { id: item.id, quantity: 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 className="quantity-btn" onClick={handleDecrement}>-</button>
<input
type="number"
value={item.quantity}
onChange={handleQuantityChange}
min="0"
/>
<button className="quantity-btn" onClick={handleIncrement}>+</button>
<button className="remove-from-cart" onClick={handleRemove} style={{ marginLeft: '10px' }}>
삭제
</button>
</div>
</li>
);
}
export default CartItem;
CartSummary.js
// src/components/CartSummary.js
import React, { useContext, useMemo } from 'react';
import { CartContext } from '../contexts/CartContext';
import CartItem from './CartItem';
function CartSummary() {
const { cartState } = useContext(CartContext); // Context에서 장바구니 상태 가져오기
// useMemo를 사용하여 총액을 계산 (cartState가 변경될 때만 다시 계산)
const totalAmount = useMemo(() => {
return cartState.reduce((total, item) => total + (item.price * item.quantity), 0);
}, [cartState]);
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;
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로 전체 앱을 감싸서 장바구니 상태를 전역적으로 제공
<CartProvider>
<div className="App">
<ProductList /> {/* 제품 목록 */}
<CartSummary /> {/* 장바구니 요약 */}
</div>
</CartProvider>
);
}
export default App;
실습 진행 방법
- 위의 모든 코드 파일들을 해당 경로에 맞게 생성하고 내용을 복사하여 붙여넣으세요.
npm start
(또는yarn start
) 명령어를 실행하여 개발 서버를 시작합니다.- 브라우저에서
http://localhost:3000
에 접속합니다. - 제품 추가: '장바구니에 추가' 버튼을 클릭하여 제품을 장바구니에 담아보세요. 장바구니 요약 섹션에 제품이 추가되고 총액이 업데이트되는 것을 확인합니다.
- 수량 변경: 장바구니 내에서 '+' 또는 '-' 버튼을 클릭하여 제품 수량을 변경하거나, 수량 입력 필드를 직접 수정해보세요.
- 제품 삭제: 장바구니 내 '삭제' 버튼을 클릭하여 제품을 제거해보세요.
- 새로고침 테스트: 페이지를 새로고침했을 때 장바구니 상태가 그대로 유지되는지 확인해 보세요. (로컬 스토리지에 저장되었기 때문)
- 개발자 도구 활용
- 'Components' 탭에서 각 컴포넌트의
props
와state
변화를 관찰해 보세요. - 'Console' 탭에서
useFetch
훅이 데이터를 불러오는 과정(Fetch aborted
메시지 등)을 확인해 보세요. - 'Application' 탭에서 'Local Storage'를 열어
shoppingCart
항목에 장바구니 데이터가 JSON 형태로 저장되는 것을 확인해 보세요.
- 'Components' 탭에서 각 컴포넌트의
이제 여러분은 리액트의 핵심 훅들을 모두 통합하여 useReducer
로 복잡한 상태를 관리하고, useContext
로 이를 전역적으로 공유하며, useEffect
로 비동기 작업을 처리하고, 커스텀 훅
으로 로직을 재사용하는 등, 실제 상업 애플리케이션에 적용될 수 있는 수준의 기능을 구현해 보셨습니다.
이 실습을 통해 함수형 컴포넌트와 훅이 제공하는 강력한 기능들을 완벽하게 이해하고 활용할 수 있는 역량을 갖추게 되셨기를 바랍니다. 이는 리액트 개발자로서 한 단계 더 성장하는 데 큰 도움이 될 것입니다.