icon
4장 : React 훅 기초

실습

useState의 미묘한 동작부터 useEffect의 강력한 부수 효과 제어, useContext를 통한 전역 데이터 공유, useReducer를 이용한 복잡한 상태 관리, 그리고 이 모든 것을 캡슐화하고 재사용하는 커스텀 훅까지, 이제 여러분은 함수형 컴포넌트 개발의 진정한 고수가 되기 위한 탄탄한 기반을 다졌습니다.

이번 실습에서는 이 모든 핵심 훅들을 통합하여 간단한 "쇼핑 카트(Shopping Cart)" 애플리케이션을 만들어 볼 것입니다. 이 앱은 다음과 같은 기능을 가질 것입니다.

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

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


실습 목표

  1. useReducer를 이용한 장바구니 상태 관리: 장바구니 항목(아이템, 수량)의 추가, 제거, 수량 변경 등 복잡한 상태 로직을 reducer 함수로 중앙 집중화합니다.
  2. useContext를 이용한 장바구니 전역 공유: 장바구니 상태와 dispatch 함수를 Context API를 통해 여러 컴포넌트(예: 제품 목록, 장바구니 요약)에서 props drilling 없이 접근하도록 합니다.
  3. useEffect를 이용한 초기 데이터 로딩: useEffect와 가상 API 호출을 통해 제품 목록을 비동기적으로 불러오고, 로딩 및 에러 상태를 관리합니다. (클린업 함수 활용)
  4. useState 심화: 입력 필드 등 단순 상태에 useState를 적절히 사용합니다.
  5. 커스텀 훅 만들기: API 데이터 로딩 로직을 useFetch와 같은 커스텀 훅으로 분리하여 재사용성과 가독성을 높입니다.
  6. 불변성 유지: 객체 및 배열 상태를 업데이트할 때 항상 불변성을 지키는 코드를 작성합니다.

프로젝트 준비

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
/* 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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
// 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;

실습 진행 방법

  1. 위의 모든 코드 파일들을 해당 경로에 맞게 생성하고 내용을 복사하여 붙여넣으세요.
  2. npm start (또는 yarn start) 명령어를 실행하여 개발 서버를 시작합니다.
  3. 브라우저에서 http://localhost:3000에 접속합니다.
  4. 제품 추가: '장바구니에 추가' 버튼을 클릭하여 제품을 장바구니에 담아보세요. 장바구니 요약 섹션에 제품이 추가되고 총액이 업데이트되는 것을 확인합니다.
  5. 수량 변경: 장바구니 내에서 '+' 또는 '-' 버튼을 클릭하여 제품 수량을 변경하거나, 수량 입력 필드를 직접 수정해보세요.
  6. 제품 삭제: 장바구니 내 '삭제' 버튼을 클릭하여 제품을 제거해보세요.
  7. 새로고침 테스트: 페이지를 새로고침했을 때 장바구니 상태가 그대로 유지되는지 확인해 보세요. (로컬 스토리지에 저장되었기 때문)
  8. 개발자 도구 활용
    • 'Components' 탭에서 각 컴포넌트의 propsstate 변화를 관찰해 보세요.
    • 'Console' 탭에서 useFetch 훅이 데이터를 불러오는 과정(Fetch aborted 메시지 등)을 확인해 보세요.
    • 'Application' 탭에서 'Local Storage'를 열어 shoppingCart 항목에 장바구니 데이터가 JSON 형태로 저장되는 것을 확인해 보세요.

이제 여러분은 리액트의 핵심 훅들을 모두 통합하여 useReducer로 복잡한 상태를 관리하고, useContext로 이를 전역적으로 공유하며, useEffect로 비동기 작업을 처리하고, 커스텀 훅으로 로직을 재사용하는 등, 실제 상업 애플리케이션에 적용될 수 있는 수준의 기능을 구현해 보셨습니다.

이 실습을 통해 함수형 컴포넌트와 훅이 제공하는 강력한 기능들을 완벽하게 이해하고 활용할 수 있는 역량을 갖추게 되셨기를 바랍니다. 이는 리액트 개발자로서 한 단계 더 성장하는 데 큰 도움이 될 것입니다.