React와 Node.js를 활용한 도서 쇼핑몰 만들기

창우·2024년 5월 28일
0

ToyProject

목록 보기
3/7
post-thumbnail

개요

온라인 도서 정보 쇼핑몰 ( BookStore )

  • 데브코스 스프린트2 과정에서, Node.js를 활용하여 도서 쇼핑몰 백엔드 API를 구현하였다
  • API만 구현하고, 기능 확인을 전부 postman을 통해 진행했기에, 프론트엔드 단에서 활용하지 못했던 부분이 아쉬웠다
  • 땀 뻘뻘 흘려가며 작성한 API가 너무 아깝기도, 프론트-백 통신구조에 대한 이해력도 키우기도 할 겸 이를 활용한 웹을 구현하기로 했다
  • ( 알고보니까 이후 커리큘럼에 포함 되어있었다..ㅠ 조금만 더 기다릴껄 )

구현 목표

  • Redux-Tool-Kit을 활용하여 중앙 저장소를 통한 상태 관리
  • Emotion을 활용하여 CSS in JS 방식의 스타일 관리
  • 작성한 API를 별다른 수정 없이 그대로 활용할 수 있는 프론트엔드 구조 설계
  • 재사용성을 고려한 컴포넌트 설계

구현

1. 기획 및 설계

  • 늘 그렇듯, Figma를 통해 UI 디자인을 우선적으로 설계하였다
  • 컴포넌트 재사용을 위해 관련된 기능의 인터페이스를 최대한 통일시키려고 노력하였다
  • 해당 단계에서부터 구조를 대강 잡아두니, 레이아웃 구현에 있어서는 큰 어려움이 없었다

  • 데이터베이스는 스프린트2 API 명세서를 바탕으로 설계한 구조와 동일하다
  • 다만, 리뷰기능을 개별적으로 구현하고싶어 리뷰 테이블을 추가하였다
  • 이미 구현된 데이터베이스와 API를 활용해야하기에 OpenAPI 데이터가 아닌,데이터 삽입이 필요했다.
  • Cheerio 라이브러리를 활용하여 yes24 도서 정보를 추출할 수 있었다
  • 해당 내용은 node.js환경에서 cheerio를 활용한 웹 스크래핑 에서 정리하였다

2. 컴포넌트 구현

  • emotion 라이브러리를 활용해 JS IN CSS 방식으로 스타일을 구현하였다.
  • 전역으로 관리하지 않아서, css명 설계에 대해 고민하지 않아 유용했다.
  • 특히 컴포넌트 패턴이기에, props를 받아 스타일을 관리하는것이 상태 변수에 따른 컴포넌트 출력함에 아주 편했다.
  • redux 학습 목적이 프로젝트의 목표이기에, redux를 조금 과할정도로 활용해보기로 했다.
  • 어떤 상태 변수를 중앙 저장소를 통해 관리 해야하는지 프로젝트 내내 고민했다.
  • 모달창이나, 스낵 메세지같은 공용 컴포넌트를 리덕스를 통해 재사용하는등 다양한 활용법을 익힐 수 있었다!

3. 로그인 및 회원가입

  • lodash 라이브러리의 debounce 메소드를 활용해서 실시간으로 아이디 중복 검사를 할 수 있도록 했다
  • 로그인 성공 시, 회원 전용 컴포넌트(네비게이션 변경, 버튼 활성화 등)들을 전역 변수를 통해 식별하고 관리하려고 설계하였다
  • 하지만 리덕스 상태는 브라우저 메모리에 저장되기에 새로고침 또는 새 탭이 열릴시 상태가 초기화되어지는 문제점을 발견했다
  • 쿠키를 통한 JWT 통신으로 서버에서 회원 식별은 가능하지만, 컴포넌트가 회원 식별하는데에 어려움이 있었다
  • 네비게이션 컴포넌트가 렌더링 될 때, 서버로부터 현재 유효한 쿠키를 가지고 있는지 식별하는 API를 구현하여 로그인 상태를 파악하도록 하여 해결했다.
  • 하지만 이 경우에 사용자가 로그아웃을 통해 서버로 요청을 보내서 임의로 쿠키를 제거하지 않는 경우 로그인이 컴포넌트가 계속 출력된다는 한계점이 있다
  • AccessToken-Refresh 토큰 학습의 필요성을 느끼게 되었다

5. 도서 정보 출력

  • 동일한 API에도 요청값을 조금씩 바꾸어 다른 종류의 도서들을 호출하도록 구현하였다.
  • 베스트 셀러의 경우, 회원 좋아요를 많이 받은 도서 순으로, 신간 안내의 경우 BETWEEN DATE_SUB(NOW(), INTERVAL 1 MONTH) 쿼리문을 통해 한 달 이내의 도서만을 호출하도록 하였다.
  • 카테고리 및 비슷한 장르의 책은, 카테고리 버튼을 클릭하거나 또는 상세 페이지에 들어갔을 때 카테고리 값을 식별하여 해당하는 도서만 호출하도록 구현하였다.

한 페이지에서, 섹션마다 여러 데이터 호출 동작을하다보니까 too many connection 오류를 겪었다. 서버가 동시에 처리할 수 있는 데이터베이스의 최대 연결 수를 초과할 때 발생하는 문제였다. 서버의 connection 로직을 수정할 수 있었지만 최대한 그대로 활용하고싶어 MySQL의 경우에는 my.cnf 파일에서 max_connections 값을 증가시킴으로 해결할 수 있었다.

도서 상세페이지에서 동일한 페이지의 다른 도서 정보로 이동하는 경우, 페이지내에서 리렌더링이 일어나야한다. 하지만 이동하는 이벤트를 가지는 컴포넌트가 아주 깊은 자식이어서 부모의 리렌더링을 촉발시키기 어려웠던 문제가 있었다. 이 경우 리덕스를 통해 자식에서 부모 상태를 변경하는 트리거 변수를 생성하여 어렵지 않게 해결할 수 있었다.

6. 도서 장바구니/주문

  • 장바구니에 담은 데이터들을 API 요청을 통해 불러와, 체크박스를 통해 주문할 도서들을 다시 식별하도록 구현 하였다.
  • 장바구니에 데이터의 구현은 독립되어진 배송 컴포넌트 및 수량 체크 컴포넌트 등 다양한 컴포넌트에서 중복되어 사용되어져 중앙 저장소 관리의 유용함을 느낄 수 있었다

  • 배송지 내부 스크롤(자식 스크롤)이 끝나면 페이지 외부(부모 스크롤)로 포커스가 맞춰져 스크롤이 내려가는 문제가 있었다.
  • css에서 overscroll-behavior 속성의 디폴트값인 auto가 아닌, contain으로 수정함으로 해결할 수 있었다.

7. 다크모드

  • 전역 상태 관리와 컴포넌트 전역 스타일을 결합하니 아주 간단하게 다크모드를 구현할 수 있었다
  • 이전까지는 다크모드 css를 수동으로 전부 관리해서 여간 귀찮았는데 이런식으로 전역 스타일을 활용하니 시간이 매우 절약되었다
  • 다크모드는 모든 서비스에서 이제는 필수적으로 들어가는 기능이다보니 앞으로 유용하게 사용할 수 있을 것 같다.
  • 역시 매번 느끼지만 설계가 가장 중요하다
/* GlobalStyles.jsx */
import { Global, css } from "@emotion/react";
import { useSelector } from "react-redux";

const GlobalStyles = () => {
  const isDark = useSelector((state) => state.user.isDark);
  const globalStyles = css`
    :root {
      --mainBG: ${isDark ? "#23272b" : "#ffffff"};
      --subBG: ${isDark ? "#282C30" : "#f9f9f9"};
      --fontColor : ${isDark ? "#ffffff" : "#000000"};
      --outLine: ${isDark ? "#35393d" : "#e1e1e1"};
      --reverseFontColor : ${isDark ? "#000000" : "#ffffff"};
      --reverseMainBG : ${isDark ? "#f9f9f9" : "#282C30"}; 
      // var(--MainBG);
      // var(--SubBG);
      // var(--fontColor);
      // var(--outline);
    }
  `;

  return <Global styles={globalStyles} />;
};
export default GlobalStyles;

8. 리뷰

  • 스프린트 과정에서 작성하지는 않았지만 있으면 좋을 것 같아 리뷰 기능을 구현하였다.
  • useRef를 통해 dom에 직접 접근하여 가장 하단 스크롤로 내리는 구조이다.
  • reivew 데이터인 reivews를 의존성으로 하는 useEffect를 생성하여 변화 감지(새 리뷰 추가)시 동작하도록하였다
const ReviewContents = ({ id }) => {
  const [initialRender, setInitialRender] = useState(true);
  const [reviews, setReviews] = useState([]);
  const reviewsEndRef = useRef(null);

  const fetchReviews = async () => {
    try {
      const response = await API.get("/reviews", {
        params: { bookId: id },
      });
      setReviews(response.data);
    } catch (err) {
      console.error(err);
    }
  };

  useEffect(() => {
    fetchReviews();
    setInitialRender(true)
  }, [id]);

  useEffect(() => {
    if (!initialRender) {
      scrollToBottom();
    }
  }, [reviews]);

  const handleAddComment = (event) => {
    setInitialRender(false);
    setInputComment("");
    API.post("/reviews", {
      bookId: id,
      comment: inputComment,
    })
      .then((res) => {
        fetchReviews();
      })
      .catch((err) => {
        console.log(err);
      });
  };

  const scrollToBottom = () => {
    reviewsEndRef.current?.scrollIntoView({ behavior: "smooth" });
  };
.
,

reivews를 의존성 배열로한다. 하지만 처음 렌더링 할 떄 서버로부터 리뷰를 호출한 후 출력하기 위해 reviews 상태를 변경하는데 이 때문에 페이지에 접근하면 리뷰 항목에 가장 하단으로 스크롤이 내려지는 문제점이 발생했다. 이 경우 처음 렌더링을 구분하는 initialRender state를 만들어 true인 경우 scrollBottom 동작이 호출되어지지 않고, 이후 addComment를 통해 review를 변경하는 경우에만 호출하도록 하여 문제를 해결하였다.

9. 검색

  • 회원가입과 마찬가지로 디바운싱 기능을 활용해서 실시간으로 쿼리문을 통한 데이터베이스 검색이 가능하도록 하였다
  • 엔터키를 눌러 해당 페이지에 접근하면 검색한 항목과 동일한 이름 명을 강조하도록 하였다
  • 페이지네이션에 따른 제한된 호출이 API에 구현되어있었지만 시간이 조금 부족해 이를 활용하지 못한게 아쉽다

마치며

  • 쇼핑몰은 단순 CRUD 반복이라고 생각하고 아주 가벼운 마음으로 접근했다. 하지만 생각보다 많은 시간이 들었다;
  • 처음 설계와 달라진 부분도 많았고, 예정한 기능을 완벽하게 구현하지 못한 부분도 많아 조금 아쉬운 느낌이 든다.
  • 페이지 네이션에 따른호출, 무한 스크롤, 반응형 웹 구현 등 프로젝트를 일시적으로 끝냈지만 다시 수정하고 추가적으로 구현할 부분이 많이 남았다고 생각한다.
  • 그래도 해당 토이프로젝트를 진행하면서 전역 상태 관리에 대한 이해를 직접 구현하며 배울 수 있었고, 다양한 부가적인 기능들을 학습할 수 있었던 아주 값진 시간이었다.
  • 추가적인 학습을 한 후 돌아와야겠다.

수정 및 코드 리팩토링

1. 쿼리스트링을 통한 요청 방식

  • 기존의 GET 요청 방식은 주로 params를 통해 통신하였다.
  • 충분히 효율적이었지만 동일한 페이지에서 분할된 기능을 수행하기에 유연성이 부족하다는 느낌이 들었다. ( 가령 검색 페이지에서 사용자 정의 검색과 카테고리별 검색에 대한 출력과 같은 경우 )
  • 그렇기에 쿼리스트링을 통해 요청에 따른 데이터를 명확하게 구분지을 수 있었다.
  • 하나의 엔드포인트에 다양한 요청을 보내도록 리팩토링하여 코드의 양을 줄이고 가독성을 향상시켰다
/* Search.jsx */

useEffect(() => {
    window.scrollTo(0, 0);
    const searchParams = new URLSearchParams(location.search);
    const query = searchParams.get("query");
    const categoryId = parseInt(searchParams.get("categoryId"));
    const page = searchParams.get("page");

    if (query) {
      setResultKeyword(query);      
    }

    if (categoryId !== null && !isNaN(categoryId)) {
      const categoryName = category.find(item => item.category_id === categoryId)?.category_name;
      setResultKeyword(null);
      setCategoryName(categoryName);
      if (!page) {
        dispatch(
          getSearchCategory({
            categoryId: categoryId,
            currentPage: 1,
            totalCount: true,
          })
        );
      }
    }
  }, [location.search]);

2. 검색 페이지네이션

  • 서버에서는 구현하였지만 이전엔 활용하지못한 offset-limit를 활용하여 offset 방식의 페이지네이션을 추가로 구현하였다.
/* pagination.jsx */

const Pagination = ({ totalCount }) => {
  const pageButtonRender = () => {
    const startPage = (currentPageGroup - 1) * 5 + 1; 
    const endPage = Math.min(5 * currentPageGroup, pages);
    const pageNumbers = [];
    for (let i = startPage; i <= endPage; i++) {
      pageNumbers.push(
        <li key={i}>
          <button 
          style={{backgroundColor : currentPage === i ? "var(--primaryHover)" : "var(--primary" }}
          onClick={() => handleClickPage(i)}>{i}</button>
        </li>
      );
    }
    return pageNumbers;
  };
  • 처음 검색 요청을 보낼 때, 전체 검색 결과의 수를 요청하고, 이를 토대로 원하는 수 만큼의 페이지네이션 섹션을 구성한다.
  • 구성된 페이지 섹션을 클릭하면 이에 맞게 쿼리 스트링을 수정하여 재 렌더링하고 컴포넌트는 해당하는 쿼리스트링에 따라 다시 요청을 보내도록하였다.

3. 좋아요 기능

  • 단순한 기능이었지만 버튼 클릭시 서버 요청 및 UI 즉시 반영, 이후 상세페이지에서 이전 좋아요를 식별하는 데이터를 받아오는 것 까지 생각보다 구현할 것들이 많았다.
  • 낙관적 업데이트 패턴을 적용하였다.
/* DetailCard.jsx */

  useEffect(() => { // 렌더링 시 좋아요 상태 확인 통신
    API.get(`/likes/${id}`).then((response) => {
        if (response.data.liked) {
          setLikesCheck(true);
        } else {
          setLikesCheck(false);
        }
      })
      .catch((err) => {
        console.log(err);
      }); 
  }, []);

  const likeHandler = () => {
    if (likesCheck) { // 이미 좋아요가 눌린 상태일 때,
	// 낙관적 업데이트를 반영한 좋아요 삭제 통신 및 UI 동기화
      dispatch(likesCount({ type: "decrease" }));
      setLikesCheck(false);
      API.delete(`/likes/${id}`)
        .then((response) => {
          if (response.status !== 200) {
            dispatch(likesCount({ type: "increase" }));
            setLikesCheck(true);
          }
        })
        .catch((err) => {
          console.log(err);
          dispatch(likesCount({ type: "increase" }));
          setLikesCheck(true);
        });
    } else {
      dispatch(likesCount({ type: "increase" }));
      setLikesCheck(true);
  
      API.post("/likes", { id: id })
      // 좋아요가 눌리지 않은 상태일 때,
	// 낙관적 업데이트를 반영한 좋아요 추가 통신 및 UI 동기화
        .then((response) => {
          if (response.status !== 200) {
            dispatch(likesCount({ type: "decrease" }));
            setLikesCheck(false);
          }
        })
        .catch((err) => {
          console.log(err);
          dispatch(likesCount({ type: "decrease" }));
          setLikesCheck(false);
        });
    }
  };
  • 사용자의 요청에 따라 그 결과에 상관없이 UI에 선 반영한 후, 이후 통신 요청에 따라 UI를 재조정하는 방식으로 빠른 UI 반영으로 사용자에게 좋은 경험을 제공한다
  • 해당 프로젝트에서는 체감이 잘 안됐지만.. 뭐... 좋은게 좋은거니까

4. 반응형 웹

  • 특정 해상도 (브레이크 포인트) 에 맞춰 컴포넌트를 카드를 재배치 하도록하였다
  • 배치를 변경하는 것은 어렵지 않았지만 처음부터 스타일 설계에 반응형을 고려하지 못해 모든 해상도에서 정상 출력하도록 구현하지 못했다.
    (각 브레이크 해상도 마다 컴포넌트를 최적화 하지만 애매한 해상도 모두에 맞춰지지 않는다)
  • 결과적으로 이번 프로젝트에서 가장 아쉬운 부분이다.
  • '모바일 우선 설계' 의 강조를 자주 들었었는데 직접 겪어보니 정말 맞는 말인 것 같다

다방면으로 정말 많은 실력과 경험 키울 수 있었던 프로젝트였다.
또 아직 배워야 할 길은 멀구나.. 새삼 느낀다

profile
물을 줘야지😂

0개의 댓글