[React] 장바구니 기능 만들기

2025. 8. 4.공부/React

728x90

2022년에 했던 프로젝트를 반응형으로 개선!

 

2022년에 만들었던 제주완니라는 웹서비스를 반응형으로 리뉴얼하면서 장바구니 기능을 정상화하고 리팩토링하였다. 우선 2022버전은 https://animated-mandazi-93e439.netlify.app/ 이고, 2025년 버전은 https://jeju2025-5e32e.web.app/ 에서 볼 수 있다.

 

좀 더 눈에 띄는 아이콘으로 바뀐 장바구니 페이지 진입 버튼(좌:v2022,우:v2025)

 

2022년에 만들어진 장바구니 기능 코드를 보니 정리가 많이 안되어 있었다. 특히, 백엔드 통신을 할지 로컬 스토리지로 저장할지 리덕스를 사용할지 고민한 흔적이 고스란히 나타나있었고, 결국 로컬 스토리지에 저장하는 코드를 짰으나 작동이 되지 않았다. (아래는 고민한 흔적..)

    /* 백엔드 통신 함수 (한 유저의 장바구니 가져오기)
    const getCartItems = async() => {
        axios.get(`${serverURL}/cart/list/`${userId}`, {
            headers: {
              'Content-Type': 'application/json',}}).then(res => {
                console.log("카트 리스트 get");
                setCartItems(res.json());
            }).catch(ex =>{
                console.log("requset fail");
                }).finally(()=>{console.log("request end")});
        }
    */

 

결국 리팩토링을 하여 현재 활용되지 않는 장바구니 기능을 작동시키고자 하였고, 기능 개발 후에는 기능이 튼튼한지 사용자 테스트도 진행하였다. 이번에도 장바구니에 담는 아이템 정보는 브라우저의 로컬스토리지에 저장한다. 사용자가 상품을 고르고 상세페이지에서 장바구니 버튼을 클릭하면 해당 정보가 로컬스토리지에 저장되고 이후 사용자가 장바구니 페이지에 진입하면 페이지에서 로컬스토리지에 저장된 정보를 목록 형태로 보여준다.

 

기능을 완성하려면 로컬스토리에 저장하는 것 뿐 아니라 고려해야할 부분이 많았다. 우선 장바구니에서 로컬스토리지가 비어있는 경우와 비어있지 않은 경우를 구별하여 UI를 노출해주어야 하고, 비어있지 않다면 해당 항목 삭제와 수량 조작이 가능해야 한다. 본 글에서는 사용자 여정 중 하나인 관광지 상세페이지 > 장바구니 페이지 순서로 어떻게 만들었는지에 대하여 설명하고자 한다. 데이터 흐름도로 표현한다면 다음과 같다.

장바구니 기능의 설계도(왼쪽 화살표는 액션, 오른쪽 화살표는 상태)

 

1. ProductDetailActivity.jsx (관광지 상세 페이지)

상품(관광지=액티비티) 상세페이지

 

jsx파일 선언부에서 상품 API에서 전달된 데이터를 기반으로 product 객체를 생성한다. 중요한 것은 "수량"만큼은 전달된 데이터를 그대로 쓰지 않고 조작이 가능해야하기 때문에 quantity라는 선언을 해주었다는 것이다. 사용자 테스트 때 "2,3개 수량을 선택하여 장바구니에 담아도 장바구니에 들어가보면 수량이 다 1개예요"라는 피드백이 있어 수정한 부분이다.  useEffect에서도 quantity가 변경되었을 때 product.count도 자동으로 반영되도록 setProduct를 useEffect 안에서 호출해주었다.

const [quantity, setQuantity] = useState(1);

const [product, setProduct] = useState({
  product_id: product_info.post.id,
  title: product_info.post.title,
  price: product_info.post.price,
  count: quantity,
  ...
});

useEffect(() => {
    setProduct((prev) => ({
      ...prev,
      count: quantity,
    }))...},[quanity];

 

아래는 장바구니 버튼 클릭 시 실행되는 함수인데, 기존 로컬 스토리지에 저장되있는 값을 불러오고 클릭된 product를 추가해서 다시 저장하는 구조이다. (함수명이 왜 modalClose냐면 버튼 클릭 시 원래 모달이 열리는 UX였는데 그것의 잔재이다..) 

const modalClose = () => {
  const existing = localStorage.getItem("cartItem");
  const parsed = existing ? JSON.parse(existing) : [];
  parsed.push({ ...product });
  localStorage.setItem("cartItem", JSON.stringify(parsed));
};

 

선언부에서는 위와 같이 정의가 되어있고, 사용자는 구현부의 ProductShowActi 컴포넌트 안의 Link 컴포넌트를 타고 장바구니 페이지로 이동한다. 

 

2. Cart.jsx (장바구니 페이지)

 

Link를 타고 간 장바구니 페이지의 선언부에서는 최초 마운트 시 로컬스토리지에서 데이터를 불러온다.

 useEffect(() => {
    let initialCartItem = JSON.parse(localStorage.getItem("cartItem")) || [];
    if (initialCartItem.length === 0) {
      conFirmList(!isItEmpty);
    } else {
      const newCartItem = initialCartItem.map((it) => ({
        id: it.product_id || it.id,
        price: it.price,
        title: it.title,
        count: it.count,
      }));
      setCartItems(newCartItem);
    }
  }, []);

 

또한 선언부에서 수량의 증가, 감소, 삭제 기능을 포함하고 있다.

  const onIncrease = (i) => {
    const data = cartData.map((it) => {
      if (it.id == i) {
        const count = it.count + 1;
        it = { ...it, count };
      }
      return it;
    });
    setCartItems(data);
  };

  const onDecrease = (i) => {
    const data = cartData.map((it) => {
      if (it.id == i) {
        const count = it.count - 1;
        it = { ...it, count: count <= 0 ? 1 : count };
      }
      return it;
    });
    setCartItems(data);
  };

  const onRemove = (targetId) => {
    if (window.confirm("해당 상품을 정말 삭제하시겠습니까 ?")) {
      const newArr = cartData.filter((it) => it.id != targetId);
      localStorage.setItem("cartItem", JSON.stringify(newArr));
      setCartItems(newArr);
      if (newArr.length === 0) {
        conFirmList(!isItEmpty);
      }
    }
  };

 

구현부에서는 세 가지의 컴포넌트를 사용하였다. <CartList_empty>와 <CartItem>는 장바구니 비었을 때와 찼을 때 분기를 렌더링한다. 또, 총 수량 및 금액을 합산하여 실시간으로 정보를 한 눈에 보여주면서 하나의 데이터로 결제 페이지에 넘기기 위해 <CartCount>컴포넌트를 만들어서 사용하였다. 

 {/* 장바구니 비었을 때와 찼을 때 분기 렌더링 */}
            {isItEmpty ? (
              <CartList_empty />
            ) : (
              <CartItem
                cartItem={cartData}
                onRemove={onRemove}
                onIncrease={onIncrease}
                onDecrease={onDecrease}
                userId={userId}
                serverURL={serverURL}
                isItEmpty={isItEmpty}
              />
            )}

            {/* 총 수량 및 금액 */}
            <CartCount item={cartData}></CartCount>

 

 

<CartList_empty>

function CartList_empty() {
    return (
        <div className="empty-cart">
            <p>장바구니에 상품이 없습니다.</p>
        </div>
    );
}

 

<CartItem>

import { useState, useEffect, useRef } from "react";

function CartItem({ item, onIncrease, onDecrease, onRemove }) {
	
// 수량 증가 
const handleIncrement = (e) => {
    onIncrease(e.target.value);
};

// 수량 감소
const handleDecrement = (e) => {
    onDecrease(e.target.value);
};

// 아이템 삭제 
const handleRemove = (e) => {
    onRemove(e.target.value);
};

  return (
    <>
      {cartItems.map((i, idx) => (
                <li className="cart-item">
                    <div className="cart-item-txt">
                        <div className="cart-item-txt-wrap">
                            <span>상품번호 {i.id || i.product_id}</span>
                            <h3 className="text-style-24">{i.title}</h3>
                        </div>

                        <p className="price-32">
                            {(i.price * i.count).toLocaleString('ko-KR')}
                            <span className="currency">원</span>
                        </p>
                        <div className="cart-item-count">
                            <button
                                className="decrease-btn"
                                value={i.id}
                                onClick={handleDecrement}
                            >
                                -
                            </button>
                            <div>{i.count}</div>
                            <button
                                className="increase-btn"
                                value={i.id}
                                onClick={handleIncrement}
                            >
                                +
                            </button>
                        </div>
                    </div>
                    <div className="cart-item-btn-sec">
                        <button
                            type="button"
                            className="btn-outlined btn-40 remove-btn"
                            value={i.id}
                            onClick={handleRemove}
                        >
                            삭제하기
                        </button>
                    </div>
                </li>
            ))}

 

<CartCount>

import { useState, useEffect } from "react";
import { useNavigate } from "react-router";

function CartCount({ item }) {
    const navigate = useNavigate();
    const [isCartEmpty, setIsCartEmpty] = useState(false);

    useEffect(() => {
        setIsCartEmpty(item.length === 0);
        if (item.length === 0) {
            console.log("카운트에서 빈 배열 확인");
        }
    }, [item]);

    const wholeCountNum = item.reduce(
        (acc, cur) => acc + Number(cur.price) * Number(cur.count),
        0
    );

    return (
        <div className="likeUnitwrapper">
            <strong className="text-style-16">결제 예정 금액</strong>
            <div className="amount-bottom">
                <div className="sumcount">
                    <div className="price-32" id="price-sum">
                        <div className="price-sumcount">
                            <p className="amount-bottom">
                                <span className="text-style-20">Total</span>
                                <span className="currency">
                                    {wholeCountNum.toLocaleString("ko-KR")}원
                                </span>
                            </p>
                        </div>
                    </div>
                </div>
                <div className="buyBtn-section">
                    <button
                        className="btn-primary btn-55 btn-buy"
                        onClick={() =>
                            navigate("/order", {
                                state: { item: item, wholeCountNum: wholeCountNum },
                            })
                        }
                        disabled={isCartEmpty}
                    >
                        구매하기
                    </button>
                </div>
            </div>
        </div>
    );
}

export default CartCount;

 

마지막으로 정리하자면, ProductDetailActivity 페이지에서 일어난 Actions을 Cart 페이지가 전달받고, Cart에서 상태변화(수량 감소, 증가, 삭제)이 일어나면 CartItem에서 이벤트핸들러를 실행하여 Cart에 전달, 상위함수를 실행하고 cartData의 상태를 변경시킨다. 실시간으로 변경된 상태는 CartCount 컴포넌트에 반영되고 이 데이터를 Order페이지로 넘겨 결제 모듈에 전달한다.

 

Order 페이지, 포트원 결제 모듈도 달아놨다!

 

실제 결제도 가능한 포트원 모듈도 달아놨다. (ㅠㅠ) 홈페이지 만들다가 여기까지 온 나.. 개발자들 정말 존경스럽