상품리스트, 북마크 페이지를 포함하는 SPA 앱 구현

강성일·2023년 7월 19일
1
post-thumbnail

📝 프로젝트 요구사항 명세서




💬 회고


목차

1. 초기 기획 & 세팅

2. 과정을 기록하기

3. Error note



⚙️ 초기 기획 & 세팅


스크럼 보드 준비


Branch 준비

저번 13주차 회고에서 작성했다시피, 스크럼 보드와 브랜치를 이미 준비 완료했다.

스크럼 보드라는 것을 처음 알게 되었고, 백로그를 만들고 직접 기획하고 관리하는게 신기했다.
브랜치 또한, dev라는 중간 느낌(?) 개발 단계 브랜치가 있다는 것을 처음 알게되었다.

  • Main 은 최종본
  • 일단 dev 브랜치를 만들고 fetch,
  • 그 아래에 매번 header나 footer 같은 것을 만들어서 fetch, dev로 commit, mergh 후, 삭제
  • 그리고 최종본을 Main으로 mergh하고 끝


📝 과정을 기록하기


코드를 구현하기 전에 이번 프로젝트의 트리 구조를 생각해봤다.

메인 페이지를 일단 구현하는 것이 1차적인 목표였기 때문에 안에 구성 컴포넌트를 생각해봤다.

  1. 코드 초기세팅
  2. Header
  3. Header - 메뉴 드롭다운
  4. Footer
  5. ProductCard
  6. Modal
  7. ProductCard - 상품 리스트, 북마크 리스트

이렇게 MainPage에서 6개의 컴포넌트가 크게 필요할 것 같았고 이 순서로 구현하면 될 것 같았다.

전체 props 전달 흐름 기획에서 고민이 많았다.

결국, Modal → ProductCard(상품 리스트, 북마크 리스트) → MainPage → App 흐름 구조로
props 하나만 계속 전달하면 되니까 효율적이고, App 파일의 코드도 가독성이 좋을 것 같아 채택했다.



⚙️ 코드 초기세팅


이번은 완전 솔로 프로젝트이기 때문에 기존과 다르게 아예 쌩 백지부터 시작했다.

코드를 먼저 작성하기 전에 기획 단계에서 스크럼 보드를 통해 진행에 대해 생각해놨던터라
매우 수월하게 CRA 초기 세팅과 페이지 생성, 라우팅, 패키지 설치 등을 마쳤다.

확실히 스크럼 보드를 왜 사용하는지 알 것 같았다.



1. Header



1. Header 컴포넌트 구현

import React, { useState } from "react";
import { styled } from "styled-components";
import { useNavigate } from "react-router-dom";
import HeaderDropdown from "./HeaderDropdown";

import logo from "../img/logo.png";
import headerbutton from "../img/headerbutton.svg";

const Header = () => {
  const navigate = useNavigate();
  const [isShow, setIsShow] = useState(false);
  const dropDownHandler = () => {
    setIsShow((props) => !props);
  };

  return (
    <HeaderSection>
      <div className="logo">
        <img src={logo} alt="Logo" onClick={() => navigate("/")} />
        <h1 onClick={() => navigate("/")}>COZ Shopping</h1>
      </div>
      <button onClick={dropDownHandler}>
        <img src={headerbutton} alt="헤더 드롭다운 버튼" />
      </button>
      {isShow ? <HeaderDropdown /> : ""}
    </HeaderSection>
  );
};

const HeaderSection = styled.header`
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  width: 100vw;
  height: 5rem;
  background: #fff;
  display: flex;
  align-items: center;
  justify-content: space-between;
  background: #ffffff;
  box-shadow: 0px 8px 8px 0px rgba(0, 0, 0, 0.1);
  .logo {
    display: flex;
    margin-left: 4rem;
    height: 1.875rem;
    img {
      cursor: pointer;
    }
    h1 {
      width: 14.375rem;
      margin-left: 5rem;
      font-size: 2rem;
      line-height: 1.75rem;
      letter-spacing: 0em;
      cursor: pointer;
    }
  }
  button {
    width: 1.875rem;
    height: 1.5625rem;
    margin-right: 4.5rem;
    background: #ffffff;
    cursor: pointer;
  }
`;

export default Header;


이슈 내용에 따라 헤더 왼쪽엔 로고와 쇼핑몰 이름, 오른쪽엔 드롭다운을 넣었고
전체적으로 ~Section이라는 이름으로 통일하여 styled-components로 스타일을 지정하였다.

드롭다운에 대한 메뉴 컴포넌트도 따로 구현이 필요했다.

2. Header 드롭다운 구현

import React from "react";
import styled from "styled-components";
import { useNavigate } from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faGift } from "@fortawesome/free-solid-svg-icons";
import { faStar } from "@fortawesome/free-solid-svg-icons";

const HeaderDropdown = () => {
  const navigate = useNavigate();

  return (
    <DropDownSection>
      <Polygon />
      <MenuSection>
        <UserInfo>OOO, 안녕하세요</UserInfo>
        <ListSection onClick={() => navigate("/products/list")}>
          <FontAwesomeIcon icon={faGift} />
          <span>상품리스트 페이지</span>
        </ListSection>
        <ListSection onClick={() => navigate("/bookmark")}>
          <FontAwesomeIcon icon={faStar} />
          <span>북마크 페이지</span>
        </ListSection>
      </MenuSection>
    </DropDownSection>
  );
};

const DropDownSection = styled.nav`
  position: absolute;
  display: flex;
  flex-direction: column;
  justify-content: center;
  width: 12.5rem;
  height: 9.375rem;
  right: 2rem;
  top: 4.375rem;
`;

const Polygon = styled.div`
  position: absolute;
  width: 1rem;
  height: 1.125rem;
  right: 2.8rem;
  top: -0.5rem;
  transform: rotate(45deg);
  box-shadow: -1px -1px 1px 0 rgba(0, 0, 0, 0.1);
  background-color: white;
`;

const MenuSection = styled.ul`
  height: 9.375rem;
  border-radius: 12px;
  box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
  background-color: white;
  li {
    height: calc(150px / 3);
    display: flex;
    justify-content: center;
    align-items: center;
  }
`;

const UserInfo = styled.li``;

const ListSection = styled.li`
  gap: 0.3125rem;
  border-top: 1px solid rgba(0, 0, 0, 0.1);
`;

export default HeaderDropdown;


특이한 점이 있다면, Link 대신 기존에 배웠었던 useNavigate를 사용하여
각 페이지 버튼을 누르면 알맞게 페이지 이동이 되도록 구현해주었다.

3. 모든 페이지에서 Header가 노출되도록 구현 & 이슈 해결

단순하게 Header 컴포넌트를 App.js에서 모든 Routes 위에 두었다.

그런데 동시에 문제가 하나 생겼다.
바로 메인 페이지가 헤더를 덮는 것이었다.

const HeaderSection = styled.header`
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  width: 100vw;
  height: 8.3vh;
  background: #fff;
  display: flex;
  align-items: center;
  justify-content: space-between;
  background: #ffffff;
  box-shadow: 0px 8px 8px 0px rgba(0, 0, 0, 0.1);
  z-index: 1;


위 이슈는 header 스타일에 z-index: 1로 설정해서 맨 앞으로 오게 만들어 해결했다.
동시에 position: fixed는 top, left, right를 0으로 둬야한다는 것을 배웠다 !

언뜻 사용했을 때는 position: absolute과 비슷한 것 같은데
어떤 것이 다른지는 Error Note에서 다뤄보도록 하겠다.





import React from "react";
import styled from "styled-components";

const Footer = () => {
  return (
    <FooterSection>
      <div>개인정보 처리방침 | 이용 약관</div>
      <div>All rights reserved @ Codestates</div>
    </FooterSection>
  );
};

const FooterSection = styled.footer`
  width: 100vw;
  height: 3.625rem;
  gap: 9px;
  margin-top: 1.5rem;
  display: flex;
  flex-direction: column;
  justify-content: center;
  text-align: center;
  border-top: 1px solid rgba(0, 0, 0, 0.1);
  background: #fff;
  div {
    color: #888;
    font-family: Inter;
    font-size: 0.75rem;
    font-style: normal;
    font-weight: 400;
    line-height: 0.66rem;
  }
`;

export default Footer;


3. 4개 타입에 대한 컴포넌트 개발 (상품 리스트)



1. App.js에서 axios로 API 가져오기


2. 4개 컴포넌트 개발

import React from "react";
import styled from "styled-components";

const ProductCard = ({ item }) => {
  const {
    type,
    title,
    sub_title,
    brand_name,
    price,
    discountPercentage,
    image_url,
    brand_image_url,
    follower,
  } = item;

  switch (type) {
    case "Product":
      return (
        <CardSection>
          <div className="thumbnail">
            <img src={image_url} alt="thumbnail" />
          </div>
          <div>
            <span className="title">{title}</span>
            <span className="discount">{discountPercentage}%</span>
          </div>
          <div>
            <span></span>
            <span className="price">{price}</span>
          </div>
        </CardSection>
      );
    case "Category":
      return (
        <CardSection>
          <div className="thumbnail">
            <img src={image_url} alt="thumbnail" />
          </div>
          <div>
            <span className="title"># {title}</span>
          </div>
        </CardSection>
      );
    case "Exhibition":
      return (
        <CardSection>
          <div className="thumbnail">
            <img src={image_url} alt="thumbnail" />
          </div>
          <div>
            <span className="title">{title}</span>
          </div>
          <div>
            <span className="sub_title">{sub_title}</span>
          </div>
        </CardSection>
      );
    case "Brand":
      return (
        <CardSection>
          <div className="thumbnail">
            <img src={brand_image_url} alt="thumbnail" />
          </div>
          <div>
            <span className="title">{brand_name}</span>
            <span className="follower">관심고객수</span>
          </div>
          <div>
            <span></span>
            <span className="follower">{follower}</span>
          </div>
        </CardSection>
      );
    default:
  }
};

const CardSection = styled.div`
  width: 16.5rem;
  height: 13.125rem;
  cursor: pointer;
  .thumbnail {
    width: 16.5rem;
    height: 13.125rem;
    img {
      width: 16.5rem;
      height: 13.125rem;
      border-radius: 0.9375rem;
    }
  }
  div {
    display: flex;
    margin-top: 6px;
  }
  span:first-child {
    flex: 1;
  }
  .title {
    font-weight: 800;
    font-size: 1rem;
    line-height: 1.21rem;
  }
  .discount {
    color: #452cdd;
    font-weight: 800;
    line-height: 1.21rem;
  }
  .price {
    font-style: normal;
    font-weight: 500;
    line-height: normal;
  }
  .follower {
    font-weight: 700;
  }
`;

export default ProductCard;


App.js에서 뿌려준 정보를 item으로 받아서,
이를 객체 비구조화 할당(destructuring assignment)으로 요소들을 지정해주었다.

또한, type에 따라서 4가지 컴포넌트로 분류되므로 switch-case 문을 사용해서
나눠서 각각 타입에 맞는 컴포넌트를 구성하고 각각 다른 props를 전달받게 했다.

덕분에 가독성 좋게 컴포넌트를 구성할 수 있었다.


3. View Port (Design-Detail)

저번부터 프로젝트를 제작할 때, 웬만하면 px이 아닌 vw, vh, %, rem 와 같은
뷰포인트로 화면을 표기할 수 있도록 잘은 모르지만 노력하고 있다.

vh는 뷰포트 높이의 1/100을 나타내는 상대적인 단위이다.

피그마를 살펴보면 메인 Section이 footer와 24px 간격을 유지하고 있으므로,
만약 뷰포트의 높이가 1000px라고 가정하면, 24px를 vh로 환산했다.

이번 프로젝트에서는 24px / 1000px * 100vh = 2.4vh로 가정했다.

따라서 Header + MainSection + Footer = MainPage(100vh)로 각각 맞는 vh를 계산해서 넣어줬다.



🔖 북마크 & 모달


1. 코드 구조

도입부에 언급했지만, 나는 Modal → ProductCard → MainPage → App 이런 구조를 만들고 싶었다.

따라서 이렇게 메인 페이지에서 처음에 product를 가져올 때, marked 키를 추가시켰고,
이것도 마찬가지로 기존 ProductCard 객체 비구조화 할당에 추가시켰다.


2. 모달 구현

ProductCard에서 핸들러 함수들을 만들어주고 모달로 전달한다.

이때 isMarked 라는 상태도 전달하여, 모달 창과 북마크 현상태를 공유하고 편집할 수 있게 했다.

// Modal.jsx

import React from "react";
import styled from "styled-components";
import markStar from "../img/markstar.svg";
import unMarkStar from "../img/unmarkstar.svg";

const Modal = ({ item, isMarked, handleModalClose, bookMarkHandler }) => {
  const backgroundImage = item.brand_image_url
    ? `url(${item.brand_image_url})`
    : `url(${item.image_url})`;

  const closeModal = (e) => {
    e.stopPropagation();
    handleModalClose();
  };

  const toggleBookmark = () => {
    bookMarkHandler();
  };

  const getItemTitle = () => {
    if (item.type === "Category") {
      return `# ${item.title}`;
    }
    return item.title || item.brand_name;
  };

  return (
    <ModalSection>
      <div className="background" style={{ backgroundImage }}>
        <button className="closebutton" onClick={closeModal}>
          X
        </button>
        <div className="description">
          <img
            onClick={toggleBookmark}
            className="bookmark"
            src={isMarked ? markStar : unMarkStar}
            alt="bookmark"
          />
          <span>{getItemTitle()}</span>
        </div>
      </div>
    </ModalSection>
  );
};

const ModalSection = styled.div`
  position: fixed;
  top: 0;
  left: 0;
  width: 100vw;
  height: 100vh;
  background: rgba(255, 255, 255, 0.4);
  color: white;
  cursor: grab;
  z-index: 1;
  .background {
    position: absolute;
    background-size: cover;
    background-position: center;
    border-radius: 0.75rem;
    top: 50%;
    left: 50%;
    width: 46.5rem;
    height: 30rem;
    transform: translate(-50%, -50%);
  }
  .closebutton {
    position: absolute;
    right: 1.5rem;
    top: 1.5rem;
    color: white;
    background-color: transparent;
    cursor: pointer;
  }
  .bookmark {
    cursor: pointer;
  }
  .description {
    position: absolute;
    display: flex;
    align-items: center;
    bottom: 1.5rem;
    left: 1.5rem;
    gap: 4px;
    font-size: 1.5rem;
    font-weight: 700;
  }
`;

export default Modal;


모달 역시 타입에 맞는 썸네일과 설명, 북마크 상태를 똑같이 표시한다.

여기에서 모달을 닫는 함수인 closeModal이 실행될 때,
저번에 배웠던 버블링 현상을 방지하기 위해 e.stopPropagation() 메서드를 넣어줬다.


3. 모달 창 외부 클릭 시, 모달이 닫히는 기능 추가

이것 또한 마찬가지로 저번에 사용했던 ref를 활용하여 모달 창 내부 클릭 시, 모달이 닫히지 않게 했다.

4. ProductCard 코드 리팩토링

const renderThumbnail = () => (
    <div className="thumbnail">
      <img
        className="bookmark"
        onClick={bookMarkHandler}
        src={isMarked ? markStar : unMarkStar}
        alt="bookmark"
      />
      {type !== "Brand" ? (
        <img src={image_url} alt="thumbnail" onClick={handleCardClick} />
      ) : (
        <img src={brand_image_url} alt="thumbnail" onClick={handleCardClick} />
      )}
    </div>
  );

  const renderDescription = () => {
    switch (type) {
      case "Product":
        return (
          <>
            
            ...
            
  return (
    <CardSection>
      {renderThumbnail()}
      <DescriptionSection onClick={handleCardClick}>
        {renderDescription()}
      </DescriptionSection>
      {isModalOpen && (
        <Modal
          item={item}
          isMarked={isMarked}
          handleModalClose={handleModalClose}
          bookMarkHandler={bookMarkHandler}
        />
      )}
    </CardSection>
  );      


썸네일은 renderThumbnail로 설명은 renderDescription
중복된 코드를 type에 맞춘 함수로 코드 가독성을 위해 리팩토링하여 return 시켰다.



4. 북마크 리스트


1. 북마크 리스트 Section 구현


2. MainPage와 ProductCard 북마크 marked 연결

MainPage에서 bookMarkHandler 함수를 만든 후, ProductCard로 props로 전달한다.

이때 ProductCard에는 이미 bookMarkHandler 함수가 있어서 이름이 겹치므로,
ProductCard는 handleBookMark라는 이름으로 받게 했다.

그리고 기존 ProductCard의 bookMarkHandler 함수에
handleBookMarkid와 같이 담아 호출해서 다시 리턴해주는 식으로 사용한다.

그러면 북마크 추가 시, 리스트가 정상적으로 생기며 모달창 상태도 서로 공유하며, 실시간 업데이트된다.


3. localStorage로 새로고침 시, 북마크 리스트 초기화되던 문제 해결

useEffect 훅을 사용하여 페이지가 언로드되기 전에
북마크 정보를 localStorage에 저장하는 방식으로 동작하게 된다.

💬 localStorage 구현 순서를 설명해보겠다.

  1. useEffect 훅을 사용하여 컴포넌트가 마운트될 때와 products 배열이 변경될 때마다 실행되는 로직을 작성한다.
  2. handleBeforeUnload 함수를 생성하여 북마크 정보를 가져와서 localStorage에 저장하는 역할을 한다.
  3. window.addEventListener("beforeunload", handleBeforeUnload)을 사용하여 페이지가 언로드되기 전에 handleBeforeUnload 함수가 실행되도록 한다.
  4. return 구문에서 window.removeEventListener("beforeunload", handleBeforeUnload)를 사용하여 이벤트 리스너를 정리한다. 이렇게 함으로써 컴포넌트가 마운트 해제될 때 해당 이벤트 리스너도 정리된다.

이제 페이지가 새로고침되거나 다른 페이지로 이동하기 전에 북마크 정보가 localStorage에 저장되고,
다시 페이지를 로드했을 때 저장된 북마크 정보를 복원할 수 있게 된다.



🍞 Toast UI


1. react-toastify 패키지를 설치

npm install react-toastify

2. 앱의 최상위 컴포넌트에서 ToastContainer를 래핑 (index.js)

import React from 'react';
import ReactDOM from 'react-dom';
import { ToastContainer } from 'react-toastify';
import 'react-toastify/dist/ReactToastify.css';
import App from './App';

ReactDOM.render(
  <React.StrictMode>
    <App />
    <ToastContainer />
  </React.StrictMode>,
  document.getElementById('root')
);

3. Toast UI 구현

import React from 'react';
import { toast } from 'react-toastify';

const MyComponent = () => {
  const handleButtonClick = () => {
    toast.success('토스트 메시지가 표시됩니다.', {
      position: 'top-right',
      autoClose: 3000,
      hideProgressBar: false,
      closeOnClick: true,
      pauseOnHover: true,
      draggable: true,
      progress: undefined,
    });
  };

  return (
    <div>
      <button onClick={handleButtonClick}>토스트 표시하기</button>
    </div>
  );
};

export default MyComponent;


💬 토스트 라이브러리에 대해 설명하겠다.

  • react-toastify 라이브러리에서 제공하는 toast 객체를 사용하여 토스트 메시지를 표시할 때 사용할 수 있는 옵션을 설정하는 예시이다.

  • toast.success 함수는 성공적인 메시지를 표시할 때 사용되는 함수이며, 첫 번째 매개변수로 표시할 메시지를 전달한다. 두 번째 매개변수는 옵션 객체로, 토스트 메시지의 동작 및 스타일을 설정할 수 있다.

  • 여기서 설정된 옵션들은 다음과 같다:

    • position: 토스트 메시지가 표시될 위치를 지정한다. 이 예시에서는 "top-right"로 설정되어 상단 오른쪽에 표시된다.
    • autoClose: 토스트 메시지가 자동으로 닫히는 시간을 지정한다. 이 예시에서는 3000ms(3초)로 설정되어 3초 후에 자동으로 닫힌다.
    • hideProgressBar: 토스트 메시지의 진행바를 숨길지 여부를 지정한다. 이 예시에서는 false로 설정되어 진행바가 표시된다.
    • closeOnClick: 토스트 메시지를 클릭했을 때 메시지가 닫히도록 설정한다.
    • pauseOnHover: 마우스를 올렸을 때 토스트 메시지의 닫힘을 일시 중지할지 여부를 설정한다.
    • draggable: 토스트 메시지를 드래그하여 이동할 수 있는지 여부를 설정한다.
    • progress: 토스트 메시지의 진행 상태를 표시하는 컴포넌트를 커스터마이즈할 수 있는 기능이다. 이 예시에서는 undefined로 설정되어 기본 진행 상태 컴포넌트가 사용된다.
  • 기본적으로 react-toastify 라이브러리는 기본값으로 토스트 메시지를 표시하며, 위의 옵션들은 특정 동작 또는 스타일을 커스터마이즈하기 위해 사용할 수 있는 것들이다. 따라서 해당 옵션을 지정하지 않으면 기본값으로 동작한다.

따라서 이게 기본틀인데, 내 마음대로 커스텀이 불가능해서 다른 방법을 사용했다.

import { toast } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";

const handleBookMark = (id) => {
    bookMarkHandler(id);
    const isMarked = products.find((product) => product.id === id)?.marked;
    const message = isMarked
      ? "상품이 북마크에서 제거되었습니다."
      : "상품이 북마크에 추가되었습니다.";
    const icon = isMarked ? unMarkStar : markStar;

    toast(<CustomToast icon={icon} message={message} />, {
      position: "bottom-right",
      autoClose: 1000,
      hideProgressBar: true,
    });
  };

  const CustomToast = ({ icon, message }) => (
    <ToastSection>
      <img src={icon} alt="checkmark" />
      <div>{message}</div>
    </ToastSection>
  );


바로 CustomToast를 사용하여 커스텀하는 방법이다.

기본 체크표시 아이콘 대신, 내가 원하는 별마크 아이콘을 추가 & 제거에 맞게 문구와 함게 뜨도록 설정했다.
기존에 북마크를 누르면 추가 & 제거가 이뤄졌기 때문에 기존 handleBookMark 함수 안에 넣어줬다.

결과적으로 제대로 구현에 성공했다.


4. 중복 함수명 리팩토링

이번에 토스트 UI를 구현하면서 props를 주고받는 함수명을 통일해야겠다고 생각했다.

기존 ProductCard에서 다시 호출하는 과정을 위한 bookMarkHandler 함수가 이미 있어서,
MainPage에서 bookMarkHandler를 전달해서 사용할 수 없었으므로,
handleBookMark 함수명으로 바꿔 전달하고 있었지만 좋은 가독성이 아니라고 생각했다.

// 💬 기존 코드

<ProductCard
  item={product}
  key={product.id}
  handleBookMark={bookMarkHandler}
/>

// ✅ 구현할 코드

<ProductCard
  item={product}
  key={product.id}
  handleBookMark={handleBookMark}
/>


따라서 MainPage에서 handleBookMark를 따로 지정해줬다.

MainPage에서 bookMarkHandler 라는 함수명으로 id 로직 구현을 따로 빼고,
handleBookMark 에서 bookMarkHandler를 호출하여 id를 전달하는 방식으로 했다.

// ProductCard.jsx

const bookMarkHandler = () => {
    setIsMarked((prevMarked) => !prevMarked);
    handleBookMark(id);
  };

// MainPage.jsx

const bookMarkHandler = (id) => {
    setProducts((prevProducts) =>
      prevProducts.map((product) =>
        product.id === id ? { ...product, marked: !product.marked } : product
      )
    );
  };

const handleBookMark = (id) => {
    bookMarkHandler(id);
...


return (
    <>
      {isLoading ? (
        <LoadingSection>Loading...</LoadingSection>
      ) : (
        <MainSection>
          <ListSection>
            <h2>상품 리스트</h2>
            <ProductSection>
              {products.slice(0, 4).map((product, idx) => {
                return (
                  <ProductCard
                    item={product}
                    key={product.id}
                    handleBookMark={handleBookMark}
                  />
                );
              })}
            </ProductSection>
          </ListSection>
          <ListSection>
            <h2>북마크 리스트</h2>
            <ProductSection>
              {bookmarkedProducts.length ? (
                bookmarkedProducts
                  .slice(0, 4)
                  .map((product) => (
                    <ProductCard
                      item={product}
                      key={product.id}
                      handleBookMark={handleBookMark}
                    />
                  ))
              ) : (
                <h4>북마크한 항목이 없습니다.</h4>
              )}
            </ProductSection>
          </ListSection>
        </MainSection>
      )}
    </>
  );
};


그런데 이 bookMarkHandler 함수명도 중복으로 헷갈릴 수 있으므로,
MainPage.jsx의 bookMarkHandler 함수명을 toggleBookmark으로 바꿔줬다.

const toggleBookmark = (id) => {
    setProducts((prevProducts) =>
      prevProducts.map((product) =>
        product.id === id ? { ...product, marked: !product.marked } : product
      )
    );
  };

  const handleBookMark = (id) => {
    toggleBookmark(id);
...


💬 그렇다면 ProductCard 안에 있는 handleBookMark(id)는 뭐였지?

이거 bookMarkHandler 함수 안에 있는데, MainPage의 bookMarkHandler와 연관되어 있나?

그러면 함수명을 변경하면 안되는거 아닌가?

결론은 아니다.

  • MainPage_bookMarkHandler: product의 id에 따른 북마크 로직 처리
  • ProductCard_bookMarkHandler: 북마크 boolean 값 토글 & MainPage의 handleBookMark 함수를 호출하는 역할

즉, props로 오고가는 handleBookMark만 건드리지 않으면 된다.

MainPage의 bookMarkHandler와 Product의 bookMarkHandler
서로 독립적이며, 상호 연관이 없으므로 가능한 부분이었다.



💬 여기에서 ProductCard_handleBookMark(id)에 대해 추가 설명하겠다.

const bookMarkHandler = () => {
    setIsMarked((prevMarked) => !prevMarked);
    handleBookMark(id);
  };

  • handleBookMark(id)ProductCard 컴포넌트에서 북마크 기능을 토글하는 역할을 한다. 이 함수는 handleBookMark prop으로 전달되어 부모 컴포넌트인 MainPage 컴포넌트에서 정의된 handleBookMark 함수를 호출한다.

  • 이렇게 구현된 이유는 ProductCard 컴포넌트에서 북마크 기능을 수행하고, 해당 상태 변경을 MainPage 컴포넌트에도 알려야하기 때문이다. ProductCard 컴포넌트는 북마크 상태를 관리하고 업데이트하지만, 실제로 북마크에 대한 데이터는 MainPage 컴포넌트가 가지고 있다. 따라서 handleBookMark 함수를 호출하여 MainPage 컴포넌트에 북마크 상태를 전달하고, 해당 상태 변경에 따라 UI를 업데이트할 수 있게 된다.

  • 즉, handleBookMark(id)를 호출함으로써 ProductCard 컴포넌트의 북마크 상태 변경과 MainPage 컴포넌트의 북마크 상태 동기화가 이루어진다.



5. 상품리스트 페이지



1. App.js에서 전체 페이지로 API를 뿌려주기 위한 리팩토링

상품리스트 페이지 구현에 들어갈 때, 딱 느꼈다.

뭔가 내가 선택했던 구조가 효율적이지 않다는 것을.
따라서 기존 MainPage에서 API를 가져와 뿌려주던 구조를 리팩토링했다.

// ✅ refactor - App.js

import React, { useState, useEffect } from "react";
import BookMarkPage from "./pages/BookMarkPage";
import MainPage from "./pages/MainPage";
import ProductListPage from "./pages/ProductListPage";
import Header from "./pages/components/Header";
import Footer from "./pages/components/Footer";
import { HashRouter, Routes, Route } from "react-router-dom";
import styled from "styled-components";
import axios from "axios";
import markStar from "./pages/img/markstar.svg";
import unMarkStar from "./pages/img/unmarkstar.svg";
import { toast } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
import "./App.css";

function App() {
  const [products, setProducts] = useState([]);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    const savedBookmarks = JSON.parse(localStorage.getItem("bookmarks")) || [];

    axios
      .get("http://cozshopping.codestates-seb.link/api/v1/products")
      .then((res) => {
        const updatedProducts = res.data.map((product) => ({
          ...product,
          marked: savedBookmarks.includes(product.id),
        }));
        setProducts(updatedProducts);
        setIsLoading(false);
      })
      .catch((err) => console.log(err));
  }, []);

  useEffect(() => {
    const handleBeforeUnload = () => {
      const bookmarks = products
        .filter((product) => product.marked)
        .map((product) => product.id);
      localStorage.setItem("bookmarks", JSON.stringify(bookmarks));
    };

    window.addEventListener("beforeunload", handleBeforeUnload);

    return () => {
      window.removeEventListener("beforeunload", handleBeforeUnload);
    };
  }, [products]);

  const toggleBookmark = (id) => {
    setProducts((prevProducts) =>
      prevProducts.map((product) =>
        product.id === id ? { ...product, marked: !product.marked } : product
      )
    );
  };

  const handleBookMark = (id) => {
    toggleBookmark(id);
    const isMarked = products.find((product) => product.id === id)?.marked;
    const message = isMarked
      ? "상품이 북마크에서 제거되었습니다."
      : "상품이 북마크에 추가되었습니다.";
    const icon = isMarked ? unMarkStar : markStar;

    toast(<CustomToast icon={icon} message={message} />, {
      position: "bottom-right",
      autoClose: 1000,
      hideProgressBar: true,
    });
  };

  const CustomToast = ({ icon, message }) => (
    <ToastSection>
      <img src={icon} alt="checkmark" />
      <div>{message}</div>
    </ToastSection>
  );

  return (
    <>
      {isLoading ? (
        <LoadingSection>Loading...</LoadingSection>
      ) : (
        <HashRouter>
          <Header />
          <Routes>
            <Route
              path="/"
              element={
                <MainPage products={products} handleBookMark={handleBookMark} />
              }
            />
            <Route
              path="/products/list"
              element={
                <ProductListPage
                  products={products}
                  handleBookMark={handleBookMark}
                />
              }
            />
            <Route path="/bookmark" element={<BookMarkPage />} />
          </Routes>
          <Footer />
        </HashRouter>
      )}
    </>
  );
}

const LoadingSection = styled.div`
  width: 100%;
  height: 100vh;
  display: flex;
  justify-content: center;
  align-items: center;
  font-weight: 300;
  color: black;
`;

const ToastSection = styled.div`
  display: flex;
  justify-content: center;
  align-items: center;
  font-family: Inter;
  font-weight: 700;
  font-size: 1rem;
  line-height: 0.88rem;
`;

export default App;
// ✅ refactor - MainPage.jsx

import React from "react";
import styled from "styled-components";
import ProductCard from "./components/ProductCard";

const MainPage = ({ products, handleBookMark }) => {
  const bookmarkedProducts = products.filter((product) => product.marked);

  return (
    <MainSection>
      <ListSection>
        <h2>상품 리스트</h2>
        <ProductSection>
          {products.slice(0, 4).map((product, idx) => {
            return (
              <ProductCard
                item={product}
                key={product.id}
                handleBookMark={handleBookMark}
              />
            );
          })}
        </ProductSection>
      </ListSection>
      <ListSection>
        <h2>북마크 리스트</h2>
        <ProductSection>
          {bookmarkedProducts.length ? (
            bookmarkedProducts
              .slice(0, 4)
              .map((product) => (
                <ProductCard
                  item={product}
                  key={product.id}
                  handleBookMark={handleBookMark}
                />
              ))
          ) : (
            <h4>북마크한 항목이 없습니다.</h4>
          )}
        </ProductSection>
      </ListSection>
    </MainSection>
  );
};

const MainSection = styled.div`
  position: relative;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  width: 100vw;
  height: 82.6vh;
  margin-top: 8.3vh;
  background-color: white;
  h2 {
    font-size: 1.5rem;
    font-weight: 600;
    margin-bottom: 0.75rem;
  }
`;
const ListSection = styled.div`
  display: flex;
  flex-direction: column;
  justify-content: center;
  height: 41.3vh;
`;
const ProductSection = styled.div`
  display: flex;
  justify-content: center;
  align-items: center;
  gap: 1.5rem;
  width: 70.5rem;
  height: 13.125rem;
`;

export default MainPage;


코드에서 보이다시피, 메인페이지에는 상품들을 가져와서 필터하는 작업을 제외하고는
API 가져오기, 북마크 관련작업은 모두 App.js 코드로 옮겼다.

모달은 따로 ProductCard에서 뿌려주므로, 관련이 없어서
이 부분은 코드를 잘 짰다는 생각을 했다.

이렇게 전체적으로 리팩토링을 마치니, App.js에서 전체적으로 props만 전달해주면
모든 작업이 원활하게 돌아가니, 당연히 다른 페이지에 대해서도 장애물이 없었다.


2. Filter 컴포넌트 구현

다음은 상품리스트를 본격적으로 구현하기 전에, 필요한 컴포넌트를 먼저 구현할 것이다.

상품 type에 따른 필터를 걸쳐서 페이지에 뿌려줄 것이므로,
바로 그 필터 역할을 할 컴포넌트이다.

이 필터 버튼은 hover 기능도 구현해야 하고,
소프트웨어적으로 클릭 시, type에 맞춰 필터 작업이 필요해보인다.

import React from "react";
import styled from "styled-components";
import filterAll from "../img/filterAll.svg";
import filterProduct from "../img/filterProduct.svg";
import filterCategory from "../img/filterCategory.svg";
import filterExhibition from "../img/filterExhibition.svg";
import filterBrand from "../img/filterBrand.svg";

const Filter = ({ filter, filterHandler }) => {
  const buttons = [
    { filter: "All", image: filterAll, text: "전체" },
    { filter: "Product", image: filterProduct, text: "상품" },
    {
      filter: "Category",
      image: filterCategory,
      text: "카테고리",
    },
    {
      filter: "Exhibition",
      image: filterExhibition,
      text: "기획전",
    },
    { filter: "Brand", image: filterBrand, text: "브랜드" },
  ];

  return (
    <FilterSection>
      {buttons.map((button) => (
        <button
          className={button.filter === filter ? "active" : ""}
          onClick={() => filterHandler(button.filter)}
          key={button.filter}
        >
          <img src={button.image} alt={button.filter} />
          <span>{button.text}</span>
        </button>
      ))}
    </FilterSection>
  );
};

const FilterSection = styled.div`
  display: flex;
  justify-content: center;
  align-items: center;
  width: 34.625rem;
  height: 7.625rem;
  gap: 2.25rem;
  button {
    display: flex;
    flex-direction: column;
    align-items: center;
    font-size: 1rem;
    gap: 0.4063rem;
    background-color: inherit;
  }
  img {
    width: 5.125rem;
    height: 5.125rem;
  }
  button.active span {
    color: #412dd4;
    border-bottom: 0.125rem solid #412dd4;
    font-family: Inter;
    font-weight: 700;
  }
`;

export default Filter;


props로 전달받은 filter, filterHandler를 설명하기 위해서
바로 상품리스트 페이지 코드 구현으로 넘어가겠다.


3. 상품리스트 페이지 구현

import React from "react";
import styled from "styled-components";
import Filter from "./components/Filter";
import { useState } from "react";
import ProductCard from "./components/ProductCard";

const ProductListPage = ({ products, handleBookMark }) => {
  const [filter, setFilter] = useState("All");

  const filterHandler = (filter) => {
    setFilter(filter);
  };

  return (
    <ProductListSection>
      <Filter filter={filter} filterHandler={filterHandler} />
      <ProductSection>
        {products
          .filter((product) => product.type === filter || filter === "All")
          .slice(0, 12)
          .map((product) => (
            <ProductCard
              item={product}
              key={product.id}
              handleBookMark={handleBookMark}
            />
          ))}
      </ProductSection>
    </ProductListSection>
  );
};

const ProductListSection = styled.div`
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.75rem;
  width: 100vw;
  height: 100vh;
  margin-top: 10.7vh;
`;

const ProductSection = styled.div`
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: 4.5rem;
  max-width: 80vw;
  margin: 0 auto;
`;

export default ProductListPage;


이번 구현에 가장 핵심은 필터이므로, filter로 상태관리시키면서
type을 감지해서 분류되도록 함수를 지정해주었다.

return 틀 안쪽은 기존 메인 페이지와 거의 같아서 부담이 없었다.


4. 무한 스크롤링 기능 구현

...

const ProductListPage = ({ products, handleBookMark }) => {
  const [filter, setFilter] = useState("All");
  const [displayedProducts, setDisplayedProducts] = useState([]);
  const [visibleProductCount, setVisibleProductCount] = useState(12);
  
  // 무한 스크롤링을 처리하는 함수
  const handleScroll = () => {
    const isBottom =
      window.innerHeight + window.scrollY >= document.body.offsetHeight;
    if (isBottom) {
      // 사용자가 페이지 하단에 도달하면 더 많은 상품을 불러온다.
      setVisibleProductCount((prevCount) => prevCount + 12);
    }
  };

  useEffect(() => {
    // 컴포넌트가 마운트될 때 스크롤 이벤트 리스너를 추가한다.
    window.addEventListener("scroll", handleScroll);
    return () => {
      // 컴포넌트가 언마운트될 때 스크롤 이벤트 리스너를 제거한다.
      window.removeEventListener("scroll", handleScroll);
    };
  }, []);

  useEffect(() => {
    // 필터를 적용하고 보여줄 상품 개수를 제한한다.
    const filteredProducts = products.filter(
      (product) => product.type === filter || filter === "All"
    );
    const slicedProducts = filteredProducts.slice(0, visibleProductCount);
    setDisplayedProducts(slicedProducts);
  }, [products, filter, visibleProductCount]);
  
  ...


수정이 있었던 부분만 가져왔고, 주석으로 부가적인 설명을 달아두었다.

이번 솔로프로젝트를 기반으로 처음으로 무한 스크롤링을 구현해봤는데,
특히 window를 직접 가져와 다루는 부분이 개인적으로 좋은 경험이었다고 생각한다.

이렇게 스크롤을 내리면 서버에 있는 상품 개수에 한에서, 상품들이 계속 사용자에게 노출이 된다.
이 부분에서 확실히 매끄러운 사용자 경험을 줄 수 있겠다는 생각이 들었다.

유튜브 Shorts(쇼츠)도 이와 같은 무한한(?) 마르지 않는(?) 컨텐츠를 제공하지 않는가.
스크롤만 쓱쓱 내리면서 보다보면 시간이 정말정말 훅훅 간다.. 이와 같은 개념이겠지 ㅎ..

다음은 마지막인 북마크 페이지 구현이다.



6. 북마크 페이지


import React, { useState, useEffect } from "react";
import styled from "styled-components";
import Filter from "./components/Filter";
import ProductCard from "./components/ProductCard";

const BookMarkPage = ({ products, handleBookMark }) => {
  const [filter, setFilter] = useState("All");
  const [displayedProducts, setDisplayedProducts] = useState([]);
  const [visibleProductCount, setVisibleProductCount] = useState(12);

  const handleScroll = () => {
    const isBottom =
      window.innerHeight + window.scrollY >= document.body.offsetHeight;
    if (isBottom) {
      setVisibleProductCount((prevCount) => prevCount + 12);
    }
  };

  useEffect(() => {
    window.addEventListener("scroll", handleScroll);
    return () => {
      window.removeEventListener("scroll", handleScroll);
    };
  }, []);

  useEffect(() => {
    const filteredProducts = products.filter(
      (product) => product.type === filter || filter === "All"
    );
    const bookmarkedProducts = filteredProducts.filter(
      (product) => product.marked
    );
    setDisplayedProducts(bookmarkedProducts.slice(0, visibleProductCount));
  }, [products, filter, visibleProductCount]);

  const filterHandler = (filter) => {
    setFilter(filter);
  };

  return (
    <BookmarkListSection>
      <Filter filter={filter} filterHandler={filterHandler} />
      <ProductSection>
        {displayedProducts.length ? (
          displayedProducts.map((product) => (
            <ProductCard
              item={product}
              key={product.id}
              handleBookMark={handleBookMark}
            />
          ))
        ) : (
          <h4>북마크한 항목이 없습니다.</h4>
        )}
      </ProductSection>
    </BookmarkListSection>
  );
};

const BookmarkListSection = styled.div`
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.75rem;
  width: 100vw;
  margin-top: 10.7vh;
`;

const ProductSection = styled.div`
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: 4.5rem;
  max-width: 80vw;
  margin: 0 auto;
  margin-bottom: 10.7vh;
  h4 {
    display: flex;
    justify-content: center;
    align-items: center;
    height: 55vh;
  }
`;

export default BookMarkPage;


북마크 페이지 구현 코드는 상품리스트 페이지와 크게 다른 점이 없었다.

상품이 marked 되어있는지, 체크하는 작업이 있다는 점이 차이점이라 할 수 있다.
bookmarkedProducts 라는 함수를 만들어 위 작업을 수행하도록 했다.

당연히 상품리스트 페이지와 마찬가지로 모달, 북마크 모든 기능이 정상적으로 돌아갔다.

그럼 드디어 프로젝트 끝이다 :)



✅ 결과 코드 & 화면




🔥 Code Review


⚙️ 코드 엔지니어 리뷰 & 피드백


💬 1차

💬 2차

➕ ) 메인 페이지 구현까지는 위와 같은 고민을 했었으나, 추후에 다른 페이지를 구현하면서 자연스럽게 해결되었다.

💡 더 나아가 다음과 같은 작업을 추가하면 좋겠다고 생각했다.

  1. 스켈레톤 UI로 모든 페이지 Loading 구현
  2. 메인 페이지를 제외한, 모든 페이지에 뒤로 가기 버튼 추가
  3. 전체적인 디자인 뷰포트에 맞게 재구현
    (다양한 디바이스 기기에 맞춘)
  4. Redux로 상태관리


💡 Error Note.


💡 position: fixed, absolute, relative

  1. position: fixed: 이 값은 요소를 뷰포트 기준으로 고정한다. 즉, 스크롤을 내려도 요소는 항상 화면 상에 고정되어 있다. fixed로 지정된 요소는 다른 요소의 배치에 영향을 주지 않는다. 주로 헤더나 사이드바와 같이 항상 화면에 보이는 요소를 만들 때 사용된다.
  • 특징:
    • 뷰포트를 기준으로 배치되므로 스크롤에 영향을 받지 않는다.
    • 다른 요소의 위치에 상대적으로 배치되지 않고, 독립적으로 고정된다.
    • top, right, bottom, left 속성을 사용하여 정확한 위치를 지정할 수 있다.

  1. position: absolute: 이 값은 요소를 가장 가까운 "위치 지정 조상"에 상대적으로 배치한다. 위치 지정 조상은 부모 요소 중에서 position 속성 값이 relative, absolute, fixed, sticky인 요소를 의미한다. 만약 위치 지정 조상을 찾을 수 없다면, 문서(body)가 위치 지정 조상이 된다.
  • 특징:
    • 다른 요소에 대한 상대적인 위치를 지정한다.
    • 요소가 원래 있어야 할 위치에서 벗어나서 배치된다.
    • 스크롤에 따라 위치가 변하지 않는다.
    • top, right, bottom, left 속성을 사용하여 위치를 지정한다.

  1. position: relative: 이 값은 요소를 원래 위치를 기준으로 상대적으로 배치한다.
    relative로 지정된 요소는 자신의 원래 위치를 차지하면서 top, right, bottom, left 속성을 사용하여 상대적인 위치를 조정할 수 있다.
  • 특징:
    • 자신의 원래 위치를 차지하면서 상대적인 위치를 조정할 수 있다.
    • 다른 요소에 영향을 주지 않고 배치된다.
    • 스크롤에 따라 위치가 변하지 않는다.
    • top, right, bottom, left 속성을 사용하여 위치를 지정한다.

💬 요약하면, fixed는 뷰포트에 상대적으로 요소를 고정시키고,
absolute는 가장 가까운 위치 지정 조상에 상대적으로 요소를 배치하며,
relative는 자신의 원래 위치를 기준으로 요소를 상대적으로 배치한다.

profile
아이디어가 넘치는 프론트엔드를 꿈꿉니다 🔥

0개의 댓글