Project/SeSAC 2차 팀 프로젝트

[새싹x코딩온] 웹 개발자 부트캠프 과정 2차 팀프로젝트 회고 #3 | 상품 목록과 검색 기능 및 페이지네이션

다니니니 2024. 9. 20. 01:27
728x90

 

 

원래 처음 계획에서는 내가 맡은 부분은 아니었지만 페이지 별로 담당을 나누다보니 내가 맡은 부분이 적은 거 같기도 하고... 장바구니와 결제 부분을 어느 정도 마무리하고 나서 팀 전체의 진행도를 보았을때, 상품의 목록을 보여주는 부분을 먼저 끝내야겠다는 생각이 들었다.

 

사실상 아이템 목록을 보여주는 페이지는 READ밖에 없기 때문에 쉽게 끝낼 수 있다는 생각도 들었다.

상품 목록 페이지
검색 결과 페이지

우선 상품 목록 페이지와 검색 결과 페이지는 사용자 친화성을 고려하여 비슷한 스타일로 페이지를 구현했다. 너무 다르면 또 유저 입장에서 적응이 안되고 친화적이지 않다고 느꼈기 때문이다.

그리고 상품 목록 페이지와 검색 결과 페이지의 아이템을 뿌려주는 부분이 같아서 ItemList 라는 컴포넌트를 만들어서 사용해서 중복 코드를 줄이고 재사용성을 높였다. 페이지네이션 부분도 공통 컴포넌트를 만들어서 유지보수를 용이하게 했다.

 

1. 상품 목록 페이지

상품 목록 페이지에서는 세세하게 나누면 4가지 기능이 있다.

1. 헤더의 카테고리 버튼을 누르면 그 카테고리에 해당되는 아이템만 정렬되서 보여줌(카테고리 필터링)

2. 상품은 최신순/낮은가격순/높은가격순으로 정렬 가능

3. 각 페이지에는 20개씩의 아이템이 보여지는 페이지네이션

4. 상품을 누르면 해당되는 상품의 상세 페이지로 이동

 

 

1. 헤더의 카테고리 버튼을 누르면 해당되는 아이템 정렬(카테고리 필터링)

 

카테고리의 버튼은 리액트 라우터의 NavLink 를 사용했다.

기능은 Link와 같지만, NavLink는 현재 활성화 된 페이지에 해당하는 link에 active라는 클래스 속성을 준다.

그럼 css 를 이용해서 active 클래스를 가지고 있는 link 컴포넌트에 대한 스타일을 원하는 스타일을 주면 된다.

나는 active 클래스를 가지고 있는 것은 색깔을 다르게 해서 사용자에게 지금 어떤 페이지에 있는지 표시해주었다.

      {categoryData.map((value) => (
        <li key={value.id}>
          <NavLink
            to={value.path}
            className={() => {
              return categoryId === value.id ? 'active' : '';
            }}
            onClick={() => {
              handleScrollToTop();
              closeMobileSideMenu();
            }}
            end
          >
            {value.category}
          </NavLink>
        </li>
      ))}

 

 

상품 목록 페이지의 url은 이렇게 되어있다.

/posts/list/1/0?order=latest

 

저기서 posts/list까지는 상품목록페이지의 공통적인 경로다 그 뒤의 /1은 페이지 번호, 

0은 카테고리 테이블의 pk다. 즉 카테고리 넘버다.

카테고리를 선택했을 때, useParams을 이용해서 카테고리 넘버에 해당하는 params를 읽어오고, 

이를 서버에 전달했다. 

const getPostLists = (page, categoryId, order) => {
  return axios.get(`${url}/posts/list/${page}/${categoryId}?order=${order}`);
};

그러면 서버에서는 해당되는 카테고리 넘버에 해당되는 아이템들의 정보를 클라이언트에 전달한다.

전달받은 데이터를 useState를 이용해서 상태 변수에 저장하고, 그 데이터들을 map 메서드를 활용해서 렌더링했다.

<ol>
  {listData ? (
    listData.length > 0 ? (
      listData.map((item, idx) => (
        <ItemList key={item.postId} item={item} />
      ))
    ) : (
      <li className="no-item">상품이 없습니다.</li>
    )
  ) : (
    <li className="no-item">상품이 없습니다.</li>
  )}
</ol>

ItemList의 컴포넌트는 검색페이지에서 활용되는 컴포넌트이기 때문에 ItemList에서 직접 데이터를 받지 않고,

props를 이용해서 데이터를 전달했다. 

 

 

2. 최신순/낮은가격순/높은가격순 으로 정렬

 

사용자가 원하는 아이템을 빨리 찾게 하기 위한 기능 중 하나인 정렬 기능을 넣었다.

정렬은 최신 순, 낮은 가격 순, 높은 가격 순으로 정렬하는 기능을 넣었고 디폴트 값은 최신순으로 했다.

/posts/list/1/0?order=latest

 

useLocation 훅을 이용해서 url의 쿼리스트링에 해당하는 order=latest 의 값을 읽어와서 서버에 전달한 뒤,

서버로부터 전달받은 데이터를 기반으로 다시 렌더링하는 방식으로 구현했다. 

그리고 버튼 클릭 이벤트를 처리하여 해당 버튼의 값을 읽어온 후, useSearchParams 훅을 사용해서 URL 파라미터를 동적으로 업데이트했다. 

 // 클릭 시 정렬 기능
  const sortData = (e) => {
    const orderName = e.target.getAttribute('data-order');
    setSearchParams({ order: orderName });
  };
  // 쿼리 스트링(정렬순)
  const location = useLocation();
  const queryString = location.search;
  const [searchParams, setSearchParams] = useSearchParams();
  const order = searchParams.get('order');

  useEffect(() => {
    fetchListData(categoryId, order);
    // 정렬 버튼 색깔
    btnRef.current.forEach((el, idx, arr) => {
      el.classList.remove('active');
      if (order === 'latest') arr[0].classList.add('active');
      else if (order === 'priceLow') arr[1].classList.add('active');
      else if (order === 'priceHigh') arr[2].classList.add('active');
    });
  }, [categoryId, pageNum, order]);

useEffect를 이용해서 order에 해당하는 쿼리스트링의 내용이 변경될 때마다 axios 호출을 해서 

해당 정렬 기준에 맞는 데이터를 서버에 가져오도록 구현했다.

 

3. 페이지네이션 기능

페이지네이션은 서버 사이드에서 처리했다. 

데이터 양이 많을 때 클라이언트에서 처리하게 되면 최적화적인 측면에서 비효울적인 수도 있기 떄문이다.

페이지 당 필요한 데이터를 클라이언트로 전송해서 초기 로딩 시간을 단축시키고 불필요한 데이터 전송을 줄였다.

서버에서 필요한 페이지 정보를 미리 계산해서 보내줘서 클라이언트에서는 그 데이터를 받아서 렌더링하는데 집중할 수 있었다. 

서버에서 보내준 페이지네이션 정보(총 페이지 수, 현재 페이지, 페이지 당 아이템 수) 는 리덕스를 사용해서 관리했다.

 

페이지네이션 또한 Pagination이라는 컴포넌트를 별도로 분리해서 구현했다. 상품 목록 페이지와 검색 결과 페이지 모두에서 사용할 수 있도록 재사용성을 고려했다.

 

  useEffect(() => {
    fetchListData(categoryId, order);
    // 정렬 버튼 색깔
    btnRef.current.forEach((el, idx, arr) => {
      el.classList.remove('active');
      if (order === 'latest') arr[0].classList.add('active');
      else if (order === 'priceLow') arr[1].classList.add('active');
      else if (order === 'priceHigh') arr[2].classList.add('active');
    });
  }, [categoryId, pageNum, order]);

  // axios 연결
  const fetchListData = useCallback(
    async (categoryId, order) => {
      try {
        const res = await getPostLists(pageNum, categoryId, order);
        const { postList, postCount, pageSize, totalPages, currentPage } =
          res.data;

        // 상품들 데이터
        setListData([...postList]);
        // 페이지네이션 세팅
        dispatch(
          setPages({
            totalItems: postCount,
            limit: pageSize,
            totalPages,
            currentPage,
          }),
        );
      } catch (err) {
        console.error(err);
        alert('페이지를 불러 올 수 없습니다.');
      }
    },
    [categoryId, order, pageNum],
  );

서버로부터 데이터를 성공적으로 전달받으면 리덕스로 정의한 setPages라는 액션을 호출해서 페이지네이션 정보를 저장했다. 

// 리덕스
const pageSlice = createSlice({
  name: 'page',
  initialState: {
    limit: 20, // 한 페이지에 보여줄 아이템 갯수
    totalPages: 0, // 총 페이지수
    currentPage: 1, // 현재 페이지
    totalItems: 0, // 총 상품갯수
    pageCount: 5, // 보여줄 페이지 갯수
  },
  reducers: {
    setPages: (state, action) => {
      const { limit, totalPages, currentPage, totalItems } = action.payload;
      state.limit = limit;
      state.totalPages = totalPages;
      state.currentPage = currentPage;
      state.totalItems = totalItems;
    },
  },
});

페이지네이션 컴포넌트에서 Redux에 저장된 값을 활용하여 페이지네이션 기능을 렌더링했다.

페이지네이션 기획 시, 모든 페이지를 한 번에 보여주는 것이 아닌, 5페이지씩 끊어서 표시하기로 결정했다.

이를 위해 시작 페이지의 상태를 관리하기 위해 useState를 사용하였으며, 페이지의 상태에 따라 동적으로 시작 페이지를 업데이트할 수 있도록 구현했다.

또한, 이전/다음 버튼을 추가하여 사용자 편의성을 높였다.

현재 페이지가 5페이지를 초과할 경우에만 다음 버튼이 나타난다

이를 클릭하면 다음 5페이지로 이동할 수 있고, 이 때 이전 버튼이 활성화 된다.

반대로 이전 버튼을 클릭하면 이전 5페이지로 이동할 수 있다.

이러한 방식으로 페이지 네비게이션을 보다 직관적으로 구성할 수 있었다.

 

import React, { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { Link, NavLink } from 'react-router-dom';
import handleScrollToTop from '../utils/handleScrollToTop';

// 상품목록페이지, 검색결과페이지의 페이지네이션 컴포넌트
export default function Pagination({ pageLocation }) {
  const { limit, totalPages, currentPage, totalItems, pageCount } = useSelector(
    (state) => state.page,
  );

  const [start, setStart] = useState(1); // 시작 페이지
  const noPrev = start === 1; // 이전 페이지가 없는 경우
  const noNext = start + pageCount - 1 >= totalPages; // 다음 페이지가 없는 경우

  const url = (page) => {
    return `/posts/list/${page}`;
  };

  const pageArr = [...Array(pageCount)].map((_, index) => index + 1);

  useEffect(() => {
    if (currentPage === start + pageCount) setStart((prev) => prev + pageCount);
    if (currentPage < start) setStart((prev) => prev - pageCount);
  }, [currentPage, pageCount, start]);

  return (
    <div className="page-wrapper">
      <ul>
        <li className={`move ${noPrev && 'invisible'}`}>
          <Link
            to={`${url(start - 1)}${pageLocation}`}
            onClick={handleScrollToTop}
          >
            이전
          </Link>
        </li>
        {pageArr.map(
          (num, idx) =>
            start + idx <= totalPages && (
              <li
                key={idx}
                className={`${currentPage === start + idx && 'active'}`}
              >
                <NavLink
                  to={`${url(start + idx)}${pageLocation}`}
                  onClick={() => {
                    handleScrollToTop();
                  }}
                >
                  {start + idx}
                </NavLink>
              </li>
            ),
        )}
        <li className={`move ${noNext && 'invisible'}`}>
          <Link
            to={`${url(start + pageCount)}${pageLocation}`}
            onClick={handleScrollToTop}
          >
            다음
          </Link>
        </li>
      </ul>
    </div>
  );
}

 

페이지 번호는 배열로 생성해서 동적으로 렌덩링하고, 현재 페이지 내가 있는 페이지에 해당하는 버튼은

색을 보라색으로 해서 사용자가 어느 페이지에 있는지 알 수 있도록 했다.

각 버튼 클릭 시 스크롤을 최상단으로 이동시키는 기능을 추가해서 사용자의 편의성을 높였다.

 

 

2. 검색 결과 페이지

검색 결과 페이지는 헤더의 검색창에서 검색어를 입력하면 해당되는 상품을 보여지는 식이다.

검색창

 

먼저 검색 기능은 헤더에 있는 Search 컴포넌트에서 실행한다.

사용자가 입력된 키워드 기반으로 검색 결과를 가져오는 역할을 했다. 입력창에 키워드를 입력한 후, 돋보기 버튼을 누르거나 엔터를 치면 searchKeyword 라는 함수가 호출되어 검색어 기반으로 해당하는 url로 이동한다.

(이 때, useNavigate 훅 사용)

  const searchkeyword = () => {
    const keyword = inputRef.current.value.trim();

    if (keyword === '') return simpleAlert('warning', '검색어를 입력해주세요.');
    navigate({
      pathname: `/posts/list/${page}`,
      search: `?postTitle=${keyword}`,
    });
  };

 

이 때, 검색어가 비어 있을 경우 경고 메시지를 띄우도록 하여, 빈 검색어 입력을 막았다.

 

searchPage라는 컴포넌트에서 검색 결과를 렌더링 한다.

useLocation 훅을 사용해서 쿼리스트링에 있는 검색 키워드를 가져오고,

이를 기반으로 서버에서 데이터롤 요청하고 응답을 받아와서 전달받은 데이터를 렌더링해줬다.

 

검색결과 페이지에서도 상품 목록 페이지와 마찬가지로 페이지네이션 기능을 넣어서

사용자 경험을 높이고자 했다.(물론 검색 결과가 적어서 그 기능이 나오지는 않았지만.. ㅎㅎ)

  const [listData, setListData] = useState([]);

  // 페이지 세팅
  const params = useParams();
  const pageNum = Number(params.page);

  // 키워드
  const location = useLocation();
  const queryString = location.search;
  const [searchParams, setSearchParams] = useSearchParams();
  const keyword = searchParams.get('postTitle');

  const dispatch = useDispatch();
  const { totalItems } = useSelector((state) => state.page);

  useEffect(() => {
    fetchListData(keyword);
  }, [pageNum, keyword]);

  const fetchListData = useCallback(
    async (keyword) => {
      try {
        const res = await getSearchLists(pageNum, keyword);
        const { postList, postCount, pageSize, totalPages, currentPage } =
          res.data;

        setListData([...postList]);

        // 페이지네이션 세팅
        dispatch(
          setPages({
            totalItems: postCount,
            limit: pageSize,
            totalPages,
            currentPage,
          }),
        );
      } catch (err) {
        console.error(err);
      }
    },
    [pageNum, keyword],
  );

만약 상품이 없으면 상품이 없다는 표시를 해줘서 사용자 경험을 향상시켰다. 

 

3. 상품 정보 컴포넌트

코드의 중복과 재사용성을 높이기 위해서 상품의 정보를 표시하는 부분을 컴포넌트로 따로 만들었다.

상품에 대한 정보들을 props로 받아서 렌더링했다.

각 아이템들은 Link 컴포넌트로 감싸서 상품을 클릭하면 해당되는 상품의 상세페이지로 이동하게 했다.

// 아이템 정보 출력 컴포넌트
export default function ItemList({ item }) {
  const imgUrl = '';
  return (
    <li className="list-item">
      <Link to={`/posts/page/${item.postId}`} onClick={handleScrollToTop}>
        <figure className="item-img">
          <img
            src={`${imgUrl}${item.Product_Images[0].imgName}`}
            alt={item.postTitle}
          />
          {item.sellStatus === '판매 중' ? null : (
            <div className="img-filter">
              <div className="img-label">{item.sellStatus}</div>
            </div>
          )}
        </figure>
        <h5 className="item-category">{item.Category.categoryName}</h5>
        <h4 className="item-title">{item.postTitle}</h4>
        <p className="item-price">{priceToString(item.productPrice)}원</p>
        <div className="item-time">{elapsedTime(item.createdAt)}</div>
      </Link>
    </li>
  );
}

그리고 상품의 판매 상태를 받아와서 판매 중이 아닌 상품들에 대해서는 필터를 씌워서 사용자가 현재 구매 가능한 상품과 불가능한 상품을 쉽게 구별할 수 있도록 했다. 이것을 통해 사용자 경험을 향상시키고자 했다. 

 

상품의 가격은 DB에 number 타입으로 저장되어 있고, 서버에서 받아올 때도 숫자형이다.

클라이언트에서 사용자가 더 보기 쉽게 하기 위해서 천 단위 콤마를 찍어주는 함수를 구현해서 가격 부분에서 사용했다.

이를 통해 가격 표시가 더 직관적이고 읽기 쉽게 변환되서 사용자 경험을 개선하는데 도움이 될거라 생각했다.

 

 

작성 시간을 표시할 때도 상대 시간으로 할지 절대 시간 중 어떤 방식이 사용자에게 더 유용한지 고민했다.

최종적으로는 상대 시간을 선택했는데, 이는 사용자가 게시물이 언제 작성되었는지를 직관적으로 이해할 수 있게 해주기 때문이다. 작성한지 1분 미만인 것은 방금 전, 1시간 미만인 것은 몇 분전, 1일 미만인 것은 몇 시간전, 이런식으로 표시해서 게시물이 얼마나 최근에 작성되었는지 사용자가 파악할 수 있게 했다. 

그리고 작성한지 7일이 지난 게시물에 대해서는 날짜로 표시하는 절대시간의 방법을 선택했다. 

 

4. 후기

이번 프로젝트를 통해 상품 목록 페이지와 검색 결과 페이지를 구현하면서, 페이지네이션과 사용자 친화적인 데이터 전달 방식에 대해 많은 고민을 했다. 단순히 데이터를 렌더링하는 것에 그치지 않고, 최적화 측면에서 깊이 있는 접근을 시도했다.

서버 사이드 페이지네이션을 채택한 이유는 대용량 데이터를 효율적으로 처리하기 위해서였다.. 이를 통해 클라이언트에서의 초기 로딩 시간을 줄이고, 불필요한 데이터 전송을 방지할 수 있었다. 또한, useCallback 훅을 활용해 메모이제이션을 적용하여 성능을 더욱 개선했다.

특히 대량의 데이터를 보여주는 페이지에서는 이러한 최적화가 필수적임을 느꼈다. 앞으로도 사용자 경험을 고려하며 데이터 처리 방식에 대해 지속적으로 고민해 나가야겠다는 확신을 갖게 되었다.

 

 

728x90