Project/SeSAC 2차 팀 프로젝트

[새싹x코딩온] 웹 개발자 부트캠프 과정 2차 팀프로젝트 회고 #2 | 리덕스를 활용한 장바구니 및 결제 데이터 상태관리

다니니니 2024. 9. 15. 02:15
728x90

장바구니의 레이아웃은 오늘의 집과 네이버 쇼핑을 벤치마킹 했다.

그 둘을 벤치마킹한 이유는 '판매자'별로 묶여서 아이템을 보여주었기 때문이다. 판매자별로 묶어서 아이템의 합계와 배송비의 합계를 보여주는 것을 벤치마킹했다.

장바구니와 결제에 대한 플로우는 위의 플로우차트처럼 진행했다.

 

저렇게 진행하려다 보니 어쩌다 컴포넌트를 많이 나누게 되었다.

1. 장바구니에 상품이 하나라도 있을 때 렌더링 되는 컴포넌트

2. 장바구니에 상품이 아무것도 없을 때 렌더링 되는 컴포넌트

3. 장바구니에 담긴 상품을 판매자별로 보여주는 컴포넌트

4. 장바구니에 담긴 상품들의 가격 정보를 보여주는 컴포넌트

 

위와 같이 컴포넌트를 나누다 보니, 저 정보들을 전역적으로 관리해주는 리덕스가 필요할 것 같았고, 

그래서 여기서 리덕스를 사용해서 상태관리를 했다.

 

사실 처음에 페이지를 맡았을 때는 장바구니에는 계산이 들어가니깐 리덕스를 사용하면 쉽게 할 수 있겠다라고 단순하게 생각했었지만 구현을 하면 할 수록 컴포넌트를 나누게 되면서 리덕스를 사용하길 잘했다라는 생각이 들게 되었다.

 

1. 장바구니 담기 기능 (장바구니 데이터 등록)

장바구니 담기 기능은 상품 상세페이지에서 진행할 수 있도록 구현했다.

상품 상세페이지는 다른 프론트엔드 분이 맡아서 장바구니 버튼에 대한 컴포넌트를 따로 만들고

기능에 대한 구현은 그 컴포넌트에서 진행했다.

그리고 상세페이지 컴포넌트에서 props로 장바구니 담기에 필요한 데이터를 전달받았다.

(필요한 데이터는 상세페이지에서 사용하는 테이블의 pk와, 판매상태, 그리고 판매자의 pk였다.

상세페이지의 pk를 전달해서 그것으로 장바구니 데이터를 생성하고, 판매상태에 따라서 장바구니에 담기 여부를 결정해야 했다. 그리고 자기 자신이 올린 판매글을 담을 수 없도록 하기 위해 판매자 테이블의 pk를 받았다.)

 

위의 상세페이지에서 장바구니 버튼을 클릭하면 

 

위와 같이 장바구니에 상품을 담았다는 모달을 표시했고, 계속 쇼핑하기를 누르면 페이지에서 벗어나지 않았다. 장바구니 가기 버튼을 누르면 장바구니 페이지로 이동했다.

 

여기까지는 단순히 장바구니 데이터를 등록(create)하는 것이기 때문에 리덕스를 사용하진 않았다.

물론 로그인해야지만 장바구니에 담을 수 있었기 때문에 로그인 상태를 관리하는 리덕스는 사용했다.

dispatch 한건 아니고 그냥 useSelector를 이용해서 읽어오기만..

 

 

2. 장바구니 내역 조회

여기서 리덕스를 많이 사용했다.

먼저 리덕스에서 비동기 통신으로 데이터를 불러오는 방법인 createAsyncThunk 메서드를 사용했다.

export const loadCart = createAsyncThunk(
  // action 이름
  'load/cart',
  // 처리할 비동기 함수
  async () => {
    // 서버에서 데이터 불러오기
    const res = await getCartData();
    return res.data;
  },
);

그러고 createSlice 내에서 extraReducers 를 사용해서 불러올 데이터를 내가 원하는 대로 가공했다.

  extraReducers: (builder) => {
    builder
      .addCase(loadCart.pending, (state) => {
        state.loading = true;
      })
      .addCase(loadCart.fulfilled, (state, action) => {
        state.loading = false;
        // 요청 성공
        state.cartData = groupBySeller(action.payload);
        state.totalAmount = sumAmount(state.cartData);
        state.totalDeliveryFee = sumDeliveryFee(state.cartData);
        state.totalPayment = state.totalAmount + state.totalDeliveryFee;
      })
      .addCase(loadCart.rejected, (state, action) => {
        // 요청 실패
        state.loading = true;
        state.error = action.error.message;
      });
  },

 

백엔드에서 데이터는 아래와 같이 넘겨지고, 나는 프론트에서 화면을 구성할 때 판매자별로 아이템이 묶여서 보여지는 것을 원했으므로 데이터의 가공이 필요했다. 

그래서 일단은 백엔드로부터 json 형태의 데이터를 로드해 온 다음에 아래 함수를 이용해서 판매자 별로 아이템 데이터가 묶이는 함수를 이용했다.

// 판매자별로 데이터 묶어주는 함수
function groupBySeller(data) {
  // 판매자별로 묶어서 객체
  const groupBySellerData = data.reduce((acc, item) => {
    // 현재 아이템의 sellerId 가져오기
    const sellerId = item.Post.sellerId;

    // sellerId 없으면 새로운 배열 생성
    if (!acc[sellerId]) {
      acc[sellerId] = [];
    }

    // 해당 sellerId에 맞는 배열에 아이템 추가
    acc[sellerId].push(item);

    return acc;
  }, {});

  // 카트 데이터 객체를 배열로 만들기
  const sellerByCartData = Object.keys(groupBySellerData).map((key) => ({
    sellerId: parseInt(key, 10),
    items: groupBySellerData[key],
  }));

  return sellerByCartData;
}

저 만들어진 데이터는 cartData라는 이름으로 리덕스 state에 배열형태로 담겼다. 

그래서 cart가 렌더링되는 컴포넌트에서 cartData를 불러와서 map 메서드를 이용하여 

sellerByCart라는 컴포넌트가 담겨진 판매자 별로 묶이고 수만큼 렌더링되도록 구현했다.

(판매자가 2명이면 컴포넌트가 2개 반복되어 렌더링)

 

  {/* 장바구니 아이템들, 판매자별로 묶어서 보여주기 */}
  {cartData.map((value, idx) => (
    <SellerByCart
      key={value.sellerId}
      cart={value}
      forwardRef={checkEachRef}
      handleCheckEach={handlecheckEach}
    />
  ))}

 

상품을 판매자별로 묶어서 보여지는 것은 처리했으니 이제 상품들의 가격을 보여줘야 했다.

상품의 가격은 1. 배송비를 제외한 상품금액 합산 2. 배송비의 합산(판매자가 2명이면 판매자의 배송비를 합산해야함) 이렇게 보여져야 했다.

그래서 아래의 금액의 경우는 판매자가 2명이라 2명의 판매자에 대한 배송비를 합쳐서 7000원이 나온 것이다.

이를 위해서 아이템 가격을 합해주는 함수와 배송비를 합해주는 함수를 따로 구현했다.

그리고 판매 중인 상태의 상품의 가격만 합쳐주는것으로 예외처리를 했다.

// 아이템 가격 합계 함수
const sumAmount = (data) => {
  return data.reduce((acc, cur) => {
    return (
      acc +
      cur.items.reduce((total, item) => {
        // 판매 중 상태의 상품만 합산
        if (item.Post.sellStatus === '판매 중') {
          return total + item.Post.productPrice;
        }
        return total;
      }, 0)
    );
  }, 0);
};

// 배송비 합계 함수
const sumDeliveryFee = (data) => {
  return data.reduce((acc, cur) => {
    const hasSellingItems = cur.items.some(
      (item) => item.Post.sellStatus === '판매 중',
    );
    if (hasSellingItems) {
      const firstItem = cur.items.find(
        (item) => item.Post.sellStatus === '판매 중',
      );
      const deliveryFee = firstItem.Post.Seller.Delivery.deliveryFee || 0;
      return acc + deliveryFee;
    }
    return acc;
  }, 0);
};

 

3. 장바구니 상품 삭제

장바구니 아이템에 아래와 같이 아이템 옆에 X 버튼을 만들고 그 버튼을 누르면 장바구니 목록에서 사라지도록 구현했다.

화면에서도 사라져야 하고 DB에 장바구니 테이블에도 삭제되도록 구현했다.


삭제가 사실 가장 쉬웠던거 같은데.. 그냥 저 X버튼에 클릭이벤트를 걸어주었다.

  // 장바구니 아이템 삭제
  const deleteCartItem = async (e, targetId) => {
    try {
      const res = await deleteCartData(targetId);
      if (res) {
        dispatch(deleteItem(targetId));
        dispatch(loadCart()); // 아이템이 삭제될때마다 반영을 위한 재호출
      }
    } catch (err) {
      console.error(err);
    }
  };

그리고 reducer에도 아이템의 삭제되는 메서드를 추가해주었다.

    deleteItem: (state, action) => {
      const targetIds = Array.isArray(action.payload)
        ? action.payload
        : [Number(action.payload)];

      // cartData에서 targetIds와 일치하지 않는 아이템만 남기기
      state.cartData = state.cartData.map((seller) => ({
        ...seller,
        items: seller.items.filter((item) => !targetIds.includes(item.cartId)),
      }));
      // 업데이트된 totalAmount와 totalDeliveryFee 계산
      const updatedAmount = sumAmount(state.cartData);
      const updatedDeliveryFee = sumDeliveryFee(state.cartData);

      // 업데이트된 totalPayment 계산
      state.totalAmount = updatedAmount;
      state.totalDeliveryFee = updatedDeliveryFee;
      state.totalPayment = updatedAmount + updatedDeliveryFee;
    },

원래는 선택된 아이템'들'을 고려해서 코드를 짰다. (해당되는 targetId들의 배열을 넘기는 식으로)

후에 이 부분 백엔드 담당이랑 얘기를 해서 하나씩 삭제하는거로 얘기를 했지만 하나씩 삭제를 해도 저 코드는 작동하므로 나중에 리팩토링을 할 것을 대비해서 그냥 여러개를 삭제하는 코드로 남겨두었다.

 

4. 장바구니 페이지 →결제 페이지로 이동

결제 페이지로 넘어갈 떄는 장바구니 테이블의 pk인 cartId에 해당하는 정보를 서버에 넘겨주고,

서버에서는 그 정보를 기반으로 판매글을 찾아서 다시 클라이언트에 응답하는 식으로 구현했다.

백엔드 담당과 얘기해서 cartId를 배열 형태로 넘겨주기로 했다.

  const postCartNumbers = async (e) => {
    e.preventDefault();
    let cartIds = [];
    const checkedItem = document.querySelectorAll(
      '.cartItem-check input:checked',
    );
    checkedItem.forEach((ele) => {
      const cartId = ele.getAttribute('data-cart');
      // cartId 배열로 만들기
      cartIds.push(Number(cartId));
    });

    try {
      const res = await getOrderData({ cartIds });
      if (res.status === 200) {
        navigate('/order', { state: res.data });
      }
    } catch (err) {
      console.error(err);
      alert('이동할 수 없습니다');
    }
  };

 

그래서 input이 체크된 상품에 대한 cartId를 읽어오고, 그것을 배열로 만들어서 서버로 넘겼다.

그 받은 정보를 리액트의 useNavigate를 써서 결제 페이지로 이동시키면서 같이 넘겨주었다.

 

 

5. 결제 페이지

결제 페이지에서는 장바구니 페이지에서 넘겨진 데이터를 리액트의 useLocation을 이용해서 받아왔다.

  const location = useLocation();
  const orderData = location.state;

  useEffect(() => {
    dispatch(sellerByOrder(orderData));
  }, []);

 

여기서도 판매자별로 장바구니 페이지에서와 마찬가지로 판매자별로 상품을 표시하기 위해

리덕스를 사용했다.

    sellerByOrder: (state, action) => {
      state.sellerByOrderData = groupBySeller(action.payload.postInfo);
      state.orderTotalAmount = sumAmount(state.sellerByOrderData);
      state.orderTotalDeliveryFee = sumDeliveryFee(state.sellerByOrderData);
      state.orderTotalPayment =
        state.orderTotalAmount + state.orderTotalDeliveryFee;
    },

장바구니 마찬가지로 판매자별로 묶어주는 함수를 사용하고, 총 상품금액과 배송비와 그것을 합친 총 가격을 표시하도록 함수를 구현했다.

판매자별로 묶어서 화면에 넣어주는 기능은 장바구니와 같은 방법으로 구현했다.

 

그래서 위의 그림처럼 장바구니 페이지와 레이아웃 적으로는 비슷하다

다만 여기서는 주문고객정보와 배송지를 선택할 수 있는 기능, 그리고 결제할 금액을 입력할 수 있는 기능을 넣었다.

 

6. 결제하기

배송지 선택에 대해서는 다른 포스팅에서 다루기 하겠다. 흐름을 위해 결제하기에 대해서 써보고자 한다.

결제를 하기 위해서는 위에 있는 리블링머니라는 것을 사용해야 한다.

리블링머니는 결제시스템을 구현하기 위해 만든 가상의 화폐다.

실제로 계좌에서 돈이 빠져나가는 것이 아니고, 사용자에게 부여된 가상의 화폐를 사용해서 결제를 하도록 했다.

그 이유는 실제 결제 관련 API를 붙이기에는 결제 관련은.. 조금 민감한 문제기 때문에

현재의 프로젝트에서는 하지 않았다. 나중에 리팩토링을 하게 된다면 붙이는 방법을 좀 더 연구해서 붙여볼 생각이다.

 

아무튼 리블링머니를 입력할때, 자기가 갖고 있는 리블링 머니의 범위를 벗어나지 않게 해야했다.

그래서 현재 유저의 잔액에 대한 정보를 서버로부터 받아오고, 그 받은 정보와 입력되고 있는 정보를 비교했다.

  const useBalance = (e) => {
    if (e.target.value > userInfo.balance) {
      setBalanceComment('잔액이 부족합니다.');
      return (e.target.value = '');
    }
    if (e.target.value < 1) {
      setBalanceComment('0이상 입력해주세요.');
      return (e.target.value = '');
    }
    setBalanceComment('');
  };

머니를 입력한 후 결제하기 버튼을 누르면 해당되는 아이템에 대한 판매글 정보, 장바구니 번호 정보, 판매자에 대한 정보, 구매자의 배송지 정보, 결제 금액에 대한 정보를 서버로 전달했다.

// 결제하기 버튼 클릭
  const submitPayment = async (e) => {
    e.preventDefault();

    const isOrderCheck = document.querySelector('#order-check');

    // 판매불가 상품 있는 지 확인
    postInfo.forEach((item) => {
      const { sellStatus } = item.Post;
      if (sellStatus !== '판매 중') {
        setOrderCheck('구매 불가 상품이 포함되어 있습니다.');
        return;
      }
    });

    // 결제 진행사항 동의 체크 여부 확인
    if (!isOrderCheck.checked) {
      setOrderCheck('결제 진행 필수사항을 동의해주세요.');
      return;
    }
    // 리블링 머니 잔액 확인
    const balanceInput = balanceInputRef.current;
    if (balanceInput.value < orderTotalPayment || !balanceInput.value) {
      setOrderCheck('리블링머니를 입력해주세요.');
      return;
    }

    // 배송지 정보
    const addrInfo = document.querySelector(
      '.order-addr div:last-child',
    ).innerText;

    // sellerId 이미 존재하는 지 확인하기 위한 set 객체 사용
    const encounterdSellers = new Set();

    const orderCreateData = postInfo.map((item) => {
      // 이전에 sellerId를 발견한 적이 있는지 확인
      const isFisrstSeller = !encounterdSellers.has(item.Post.sellerId);

      // sellerId가 처음 발생하는 경우 세트에 추가
      if (isFisrstSeller) {
        encounterdSellers.add(item.Post.sellerId);
      }

      // 맨처음의 sellerId에 대한 항목에만 배송비 추가
      const deliveryPrice = isFisrstSeller
        ? item.Post.Seller.Delivery.deliveryFee
        : 0;
      const itemObj = {
        postId: item.postId,
        cartId: item.cartId,
        sellerId: item.Post.sellerId,
        address: addrInfo,
        productPrice: item.Post.productPrice,
        deliveryPrice,
        totalPrice: item.Post.productPrice + deliveryPrice,
      };

      return itemObj;
    });
    try {
      const res = await postOrderData({ orderData: orderCreateData });
      if (res.status === 201) {
        const allOrderId = res.data.allOrderId;
        navigate(`/order/complete/${allOrderId}`);
      }
    } catch (err) {
      console.error(err);
      alert('결제할 수 없습니다');
    }
  };

결제하기 버튼을 누르면 위와 같은 코드들이 실행된다.

나름의 유효성 검사??(결제 동의 체크여부, 리블링머니 잔액 입력여부, 판매불가 상품 있는 확인 여부 등등..)

을 진행하고 데이터를 서버로 전달한 뒤,

그로인해 생성된 구매글 테이블 에서의 주문번호 데이터를 전달받은 다음

useNavigate를 써서 결제가 완료되었다는 것을 표시해주는 페이지로 이동시켰다.

 

7. 결제 완료 페이지

결제 완료 페이지는 사실상 별 기능은 없고 유저가 다시 한번 결제에 대한 정보를 확인할 수 있는 페이지다.

그래서 주문번호라든지.. 어떤 상품을 주문했는지 배송지를 어떻게 되는지에 대한 정보를 보여주는 페이지다.

  const { allOrderId } = useParams();
  const [orderInfo, setOrderInfo] = useState(null);

  useEffect(() => {
    const orderInfos = async () => {
      try {
        const res = await getOrderCompleteData(allOrderId);
        if (res.status === 200) {
          const orderData = res.data.orderDetails;
          setOrderInfo(orderData);
        }
      } catch (err) {
        console.error(err);
      }
    };
    orderInfos();
  }, []);

이것같은 경우는 url에 있는 params 값을 읽어오고, 이를 서버에 전달한 뒤

이에 대한 정보를 받아오는 것으로 구현했다.

 

8. 후기?

이 장바구니와 결제 시스템에 대한 기능은 리덕스를 사용하고, 조금 복잡한 로직을 할 수 있겠다 싶어서 

하겠다고 한 기능이었다.

그래서 공통 UI를 제외하면 가장 먼저 레이아웃을 잡고, 기능을 구현한 페이지다.

이것을 쓰면서 리덕스와 리덕스의 비동기 통신 처리에 대한 것을 할 수 있어서 좋았고, 구현하면서도 재미있었지만

실제 결제 API를 붙이지 않아서 조금 아쉬웠다.

 

그래도 나의 리덕스를 사용함에 있어서 나의 부족함을 조금 되돌아볼 수 있어서 

비동기 통신 처리에 대한 것을 조금 더 공부해야 겠다고 느꼈다..

 

728x90