이번에 오픈마켓 프로젝트를 하면서 무한 스크롤 기능 구현에 도전해보았다.

상품 목록 불러오기 api를 사용하면 한번에 15개 상품씩만 불러와지는데, 스크롤이 최하단으로 이동할 때 마다 다음 상품목록을 api로 불러오게 하고 싶었다.

 

마침 이번 프로젝트에서는 리액트 쿼리를 쓰고 있었는데, 리액트 쿼리로 무한 스크롤 구현이 가능하다!

 

이 에는 크게 리액트 쿼리의 두 가지 기술이 쓰이고, 역할은 다음과 같다.

 

 

 

1. useInfiniteQuery

- 특정한 조건이 충족되면 계속해서 api 통신으로 데이터를 불러온다. 이 때 한 번 api 통신이 이뤄질 때마다 page에 담긴다.

예를 들어, 상품목록이 15개씩 불러와진다면, 첫 15개는 page1에 담기고, 그 다음 16번째 부터 시작하는 15개 상품목록은 page2에 담긴다.

 

2. react-intersection-observer

- inView라는 훅을 제공한다. ref 속성을 내가 지정한 곳이 화면에 보이는지, 안보이는지 감지하는 역할을 한다. Boolean값을 반환한다. 내가 지정한 요소가 화면에 보이면 true, 보이지 않으면 false를 반환한다.

 

 

 

우선 터미널에서 다음과 같이 입력하여 react-intersection-observer를 추가해준다.

npm install react-intersection-observer

그리고 무한 스크롤을 구현할 컴포넌트에 useInfiniteQuery와 react-intersection-observer의 inView훅을 불러와준다.

useQueryClient는 무시해주셔도 됩니다.

 

 

 

우선, useInfiniteQuery를 사용해보자.

useInfiniteQuery사용법은 다음과 같다. 

useInfiniteQuery(쿼리키, ({pageParam = 1}) => 쿼리함수(pageParam), { ...옵션})

pageParam = 1로 설정하면 첫페이지부터 불러오고, 2를 설정해주면 page2부터 데이터를 불러오기 시작한다.

옵션은 getNextPageParam, getPreviousParam이 있다. 자세한 설명은 아래 공식문서를 참조.

https://tanstack.com/query/v4/docs/reference/useInfiniteQuery

 

 

 

 

위 방법을 적용해서 아래와 같이 상품목록 데이터를 불러오는 코드를 작성했다.

const { data, status, fetchNextPage, hasNextPage, isFetchingNextPage } =
    useInfiniteQuery("products", ({ pageParam = 1 }) => getProduct(pageParam));

  async function getProduct(pageParam) {
    const res = await axios.get(url + "products/?page=" + pageParam);
    const result = res.data;
    return {
      result: result.results,
      nextPage: pageParam + 1,
      isLast: !res.data.next,
    };
  }

 

 

우선 async 함수 getProduct를 실행하고 pageParam = 1일 때 불러와진 res.data는 아래와 같다

 

총 55개 상품 중에서 15개가 불러와졌다.

 

데이터가 불러와지면, return값으로 필요한 데이터인 results(15개 상품목록)을 result의 value로 넣어주고, nextPage는 pageParam + 1로 설정해준다. (페이지를  1장씩 뛰어넘고 싶으면 pageParam + 2를 하면 된다.)

isLast는 이 데이터가 마지막 데이터인지 아닌지 설정해주는 것인데, 나는 받아온 data가 마지막이 아니면 next값이 다음 page url이고, 마지막 페이지면 null이기 때문에  !res.data.next로 설정했다. 

 

 

 

우선, 아무런 옵션 없이 pageParam이 1일 때 useInfiniteQuery로 받아온 데이터는 다음과 같은 형식으로 리턴된다.

getProduct함수에서 return한 값이 잘 들어가 있다. 그리고 이제 옵션을 이용하여 다음 페이지 데이터를 불러올 때 필요한 getNextPageParam에 nextPage값(2)을 넣어주어야 한다.

 

 

const { data, status, fetchNextPage, hasNextPage, isFetchingNextPage } =
    useInfiniteQuery("products", ({ pageParam = 1 }) => getProduct(pageParam), {
      getNextPageParam: (lastPage, allPages) => {
        if (!lastPage.isLast) {
          return lastPage.nextPage;
        } else {
          return undefined;
        }
      },
    });

  console.log(data);
  async function getProduct(pageParam) {
    const res = await axios.get(url + "products/?page=" + pageParam);
    const result = res.data;
    return {
      result: result.results,
      nextPage: pageParam + 1,
      isLast: !res.data.next,
    };
  }

getNextPageParam옵션 함수에 parameter로 들어가는 lastPage는 최근에 부른 데이터 페이지를 의미한다. allPage는 말 그대로 지금까지 불러온 모든 페이지 데이터를 의미한다.

 

 

lastPage에서 isLast가 false이면 nextPage값(2)를 반환하고 isLast가 true이면 undefined를 반환하도록 작성해준다.

 

여기서 useInfiniteQuery의 역할은 끝났다.

남은건 특정 조건이 충족될 때(스크롤바가 최하단으로 이동했을 때) 계속해서 다음 페이지를 불러오도록 fetchNextPage 함수를 실행시키는 것이다.

이 조건을 설정해 주기 위해 inView를 사용한다.

 

컴포넌트에서 ref(다른이름 대체 가능)와 inView를 정의해준다.

 

 

그리고 특정 요소에 ref를 지정해 주어야 하는데, 나같은 경우에는 상품이 15개씩 불러와 지니까 15번째 상품에 ref를 지정해주어 화면에 보일 때 true값을 반환할 수 있도록 하였다.

 

참고로 이 때 ref를 쓰기위해 useRef를 불러오지 않아도 되며, 태그 요소가 아닌 컴포넌트 파일 요소에도 ref를 전달할 수 있다!

 

 

나는 useInfiniteQuery를 사용하는 컴포넌트에서 하위컴포넌트로 상품 리스트가 렌더링되게 하였다.

 

 

 

<ProductList/> 컴포넌트 구조는 다음과 같다.

function ProductList({ listdata, lastItemRef }) {
  const navigate = useNavigate();
  return (
    <section>
      <ul>
        {listdata.map((list, idx) => {
          return (
            <li key={list.product_id}>
              <div
                onClick={() =>
                  navigate(`/products/${list.product_id}`)} />          
             <p>
                {list.store_name}
              </p>
              <p>
                {list.product_name}
              </p>
              <span
                ref={idx === listdata.length - 1 ? lastItemRef : null}
                >
                {list.price.toLocaleString()}
              </span>
              <span>
                원
              </span>
            </li>
          );
        })}
      </ul>
    </section>
  );
}

 

 

아래의 사진과 같이 하나의 상품이 하나의 <li>태그 요소이기 때문에 데이터의 마지막(15번째)일 때 ref를 전달하기 위해서는 

인덱스가 listdata.length - 1일 때 (index가 14이면 15번째 데이터란 뜻이니까) "lastItemRef"를 리턴하도록 하였다.

ref는 상품의 가격 span태그에 지정하였다.

 

 

이렇게 설정한 후, 다시 상위 컴포넌트로 돌아와서 console.log(inView)를 해보면, 15번째 데이터가 보일 때 마다 true값이 콘솔창에 찍히는 것을 확인할 수 있다. 그럼 이제 true값일 때, useInfiniteQuery로 다음 페이지 데이터를 불러오게 해주면 된다. 이는 useEffect로 쉽게 구현할 수 있다.

 

  useEffect(() => {
    if (inView && hasNextPage) {
      fetchNextPage();
    }
  }, [inView, hasNextPage]);

inView와 hasNextPage가 true일 때, fetchNextPage를 실행시켜 주면 끝!

 

 

 

이처럼 스크롤 할 때마다 데이터가 pages에 담기는 것을 볼 수 있다

 

 

이 때, pages에서 실제 필요한 상품목록만이 담긴 result를 모아서 배열로 담는 법이 궁금하다면 더보기 클릭

더보기

처음에는 상품목록만 가져오기 위해 data.pages.map((item, idx)=>item.result) 이렇게 코드를 썼지만 다음과 같이 배열안에 또 배열로 담겼다.

이 때,  아래와 같이 flat()메소드를 사용해주면 된다.

flat()메소드 설명 => https://developer.mozilla.org/ko/docs/Web/JavaScript/Reference/Global_Objects/Array/flat

 

flat()메소드로 배열의 depth를 한꺼풀(?) 벗기면 아래와 같이 모든 데이터가 한 배열에 담긴 것을 확인할 수 있다!!

 

 

 

 

 

무한 스크롤은 다른 방법으로도 구현할 수 있지만, 리액트 쿼리로 무한스크롤을 생각보다 간단하게 구현할 수 있다 ^___^