무한 스크롤 구현(feat. Tanstack Query, react-intersection-observer)

도현수·2024년 11월 22일
0

리스트를 불러올 때 사용자 경험 개선을 위해 무한 스크롤을 구현하기로 결정했다.

무한 스크롤은 리스트의 끝에 도달하면 다음 리스트를 가져오며 마지막까지 무한정으로 스크롤이 길어지는데, 사용자 경험의 증대를 이룰 수 있다(콘텐츠 감상을 위해 버튼을 누를 필요가 없음).

(Tanstack Query 의 공식문서에서 관련 내용 참고.)

무한 스크롤에서 고려해야 할 사항을 크게 2가지로 나누었다.

  1. (페이지)데이터 불러오기
  2. 스크롤 감지하기

데이터 불러오기 - useInfiniteQuery

다행히 Tanstack Query에서는 이런 상황을 위한 버전의 useQueryuseInfiniteQuery 를 제공한다. 무한 스크롤 구현을 위해 다음 사항들을 기억해두자.

  • useInfiniteQuery 가 제공하는 data 는 객체이다.
    • data.pages 는 가져올 데이터들이 들어있다.
    • data.pageParams 는 페이지를 가져오기 위해 사용된 페이지 파라미터가 있는 배열이다.
  • initialPageParam 에서 첫 페이지를 설정해준다.(보통은 1)
  • getNextPageParam 에 를 통해 더 가져올 데이터가 있는지 판단한다. 만약 더 가져올 데이터가 없다면 null 을 반환함.
  • fetchnextPage 는 다음 페이지를 가져오는 함수.
  • isFetchingNextPage 는 다음 페이지를 가져오고 있는지 판단하는 불리언
  • hasNextPage 는 다음 페이지가 있는지 알려주는 boolean . getNextPageParam 에 의해 결정되는데, 값이 있으면 true , null 이나 undefined 이면 false 이다.

내가 제공한 옵션은 다음과 같다.

useInfiniteQuery<{ content: GameHistoryType; pageInfo: PageInfoProps }>({
    queryKey: ["myReservations", param],
    queryFn: async ({ pageParam }) => {
      const res = await axiosInstance.get(`reservation?status=${param}&page=${pageParam}`);
      return res.data;
    },
    initialPageParam: 1,
    getNextPageParam: (lastPage) => {
      return lastPage.pageInfo.totalPages > lastPage.pageInfo.page
        ? lastPage.pageInfo.page + 1
        : null;
    },
  });
}

제공받는 초기 페이지가 1이기에 initialPageParam 을 1로 설정했으며, 만약 현재 페이지가 전체 페이지보다 작다면 현재 페이지에 1씩 증가한 페이지를 다음 페이지로 가져오고, 아니라면 null 을 전달해 더 이상 가져올 페이지가 없음을 알린다. 이를 사용해 요소를 다음과 같이 렌더링했다.

        {data ? (
          data.pages.map((page, pageIndex) => (
            <React.Fragment key={pageIndex}>
              {page.content.map((item) => (
                <InformationCard content={item} key={item.reservationId} />
              ))}
            </React.Fragment>
          ))
        ) : (
          <NoTicketHistory />
        )}

스크롤 감지하기 - react-intersection-observer

그럼 이제 언제 페이지를 가져와야 할 지를 생각해보자. 무한 스크롤이라면 보통 컨텐츠를 끝까지 다 내리거나 컨텐츠가 거의 다 끝나면 다음 컨텐츠가 나오는데… 그럼 컨텐츠가 끝에 도달했는지를 어떻게 감지하지? 를 고민하고 검색하다 react-intersection-observer 라는 라이브러리를 발견했다. 해당 라이브러리는 특정 요소가 뷰포트(=시야)에 들어왔는지 아닌지를 구분해 이벤트를 실행시킬 수 있다.

간단한 사용법은 다음과 같은데

const { ref, inView, entry } = useInView(options);

// 배열도 상관 없음.
const [ref, inView, entry] = useInView(options);

각각

  • ref = 관찰할 요소의 주소
  • inView = 지금 시야에 있는지?
  • entry = 현재 관찰중인 요소에 대한 정보들

이다. 기본적으로 관찰하고 싶은 요소의 ref에 저 ref 를 할당해 사용하며, 이 중 refinView 만 사용해도 구현이 가능하다.

관찰할 요소를 하나 맨 밑에 생성하고, ref를 지정했다.

        {data ? (
          data.pages.map((page, pageIndex) => (
            <React.Fragment key={pageIndex}>
              {page.content.map((item) => (
                <InformationCard content={item} key={item.reservationId} />
              ))}
            </React.Fragment>
          ))
        ) : (
          <NoTicketHistory />
        )}
      <div ref={ref} />

이제 처음으로 렌더링 된 요소들 다음에 저 div 가 렌더링되며 관찰된다. 사용자가 콘텐츠를 마지막까지 보고 저 div 에 도달하면 다음 콘텐츠를 서버에서 가져오도록 구현하면 무한 스크롤의 큰 그림이 완성된다. 그 전에, div 의 어느 부분까지 도달해야 할 지 설정해주자. useInView 에 전달되는 옵션 중 threshold 를 사용해 설정하는데, 0~1 의 숫자를 전달한다. 1이면 요소 전체가 들어왔을 때 inViewtrue 가 된다.

  const { ref, inView } = useInView({
    threshold: 1,
  });

구현하기

새로운 데이터를 불러와야 하는 조건은 다음과 같다.

  • 요소가 시야에 들어왔을 경우 (= inViewtrue 일 경우)
  • 불러올 데이터가 남아있는 경우(= hasNextPagetrue 일 경우)
  • 데이터를 불러오고 있지 않은 경우(= isFetchingNextPagefalse 인 경우). 이 경우를 특히 조심하자. 맨 마지막 요소에 도달해 데이터를 불러오고 있을 때 스크롤을 위로 올렸다 다시 내린다면 그 다음 데이터를 불러오는데 그렇다면 데이터가 겹쳐지게 된다. 관련 내용에 대해서는 다음과 같이 나와있다.
💡

It's essential to understand that calling fetchNextPage while an ongoing fetch is in progress runs the risk of overwriting data refreshes happening in the background. This situation becomes particularly critical when rendering a list and triggering fetchNextPage simultaneously.

Remember, there can only be a single ongoing fetch for an InfiniteQuery. A single cache entry is shared for all pages, attempting to fetch twice simultaneously might lead to data overwrites.

If you intend to enable simultaneous fetching, you can utilize the { cancelRefetch: false } option (default: true) within fetchNextPage.

To ensure a seamless querying process without conflicts, it's highly recommended to verify that the query is not in an isFetching state, especially if the user won't directly control that call.


<List onEndReached={() => !isFetchingNextPage && fetchNextPage()} />




fetchNextPage를 호출할 때 진행 중인 페치가 있는 경우 데이터 새로 고침이 백그라운드에서 진행되는 상황을 덮어쓸 위험이 있습니다. 이 상황은 목록을 렌더링하고 동시에 fetchNextPage를 트리거할 때 특히 중요해집니다.

무한 쿼리에 대해 단일 캐시 항목만 존재하므로, 무한 쿼리에 대해 두 번 동시에 페치하려고 하면 데이터가 덮어써질 수 있습니다.

동시 페치를 활성화하려면 `fetchNextPage` 내에서 `{ cancelRefetch: false }` 옵션을 사용할 수 있습니다 (기본값: `true`).

특히 사용자가 해당 호출을 직접 제어하지 않는 경우 쿼리가 `isFetching` 상태가 아닌지 확인하여 원활한 쿼리 프로세스를 보장하는 것이 매우 권장됩니다.



<List onEndReached={() => !isFetchingNextPage && fetchNextPage()} />

위의 세가지 항목에 변동사항이 있을 때 데이터를 불러올 수 있게끔 useEffect 를 사용해 다음과 같이 구현했다. 더이상 불러올 데이터가 없는 경우엔 toast를 사용해 사용자에게 알린다.

 <const { data, fetchNextPage, isFetchingNextPage, hasNextPage } = useMyTicketHistory();
 const { ref, inView } = useInView({
    threshold: 1,
  });
  useEffect(() => {
    if (inView && hasNextPage && !isFetchingNextPage) {
      onLoadMore();
    } else if (!hasNextPage) {
      alertToast("마지막 자료입니다!", "info", "bottom");
    }
  }, [inView, onLoadMore, hasNextPage, isFetchingNextPage]);
  
return (
    <>
      <section >
        {data ? (
          data.pages.map((page, pageIndex) => (
            <React.Fragment key={pageIndex}>
              {page.content.map((item) => (
                <InformationCard content={item} key={item.reservationId} />
              ))}
            </React.Fragment>
          ))
        ) : (
          <NoTicketHistory />
        )}
      </section>
      {isFetchingNextPage && <Loading />}
      <div className="h-80" ref={ref} />
    </>
  );
}

(+ 서스펜스를 사용하고 있기 때문에 useSuspenseQuery 로 바꿔주었습니다… 코드는 바뀐 부분이 거의 없어요)

마무리

저번 프로젝트에서 지식이 짧아 구현하지 못했던 무한 스크롤을 구현하며 그래도 스스로 조금은 더 나아진걸까 생각이 들었다. 댓글 목록과 같은 리스트를 구현할 때 사용하면 좋을 듯.

참고

Tanstack Query - Infinite Queries
react-intersection-observer

0개의 댓글

관련 채용 정보