무한스크롤 구현하기(useInfiniteQuery, 커서 기반 페이지네이션)

RuLu·2023년 9월 14일
14

Etc.

목록 보기
7/13

팀바팀의 팀 피드 기능에 무한스크롤 기능이 있어서 구현해보았다. 사실 이 기능은 레벨3때 구현했는데 레벨4와서 요구사항이 바뀌는 바람에.. 정방향 무한스크롤(아래로 스크롤 할 때 무한스크롤)과 역방향 무한스크롤(위로 스크롤 할 때 무한스크롤) 둘다 구현하는 경험을 가져서 글을 작성하게 되었다.

무한 스크롤 구현

나는 useInfiniteQuery와 IntersectionObserver 사용하여 구현했다. 사실 스크롤 이벤트를 이미 사용하고 있기 때문에 스크롤 이벤트로 구현해도 되긴 하는데 계속 요구사항이 바뀌고 있어 현재 스크롤 이벤트를 사용하는 컴포넌트가 삭제가 될 수 있다는 점과 다음 fetch를 진행해야하는 트리거로 사용하기에 IntersectionObserver 가 좀 더 나에겐 편해서 사용하게 되었다.

추가적으로 Intersection Observer API 는 루트 요소와 타겟 요소의 교차점을 관찰한다. 그리고 타겟 요소가 루트 요소와 교차하는지 아닌지를 구별하는 기능을 제공해 페이지의 과부하를 줄일 수 있다.

공통 부분

무한스크롤은 tanstack-query에서 제공하는 useInfiniteQuery를 사용하면 굉장히 쉽다.

const {
  fetchNextPage,
  fetchPreviousPage,
  hasNextPage,
  hasPreviousPage,
  isFetchingNextPage,
  isFetchingPreviousPage,
  ...result
} = useInfiniteQuery({
  queryKey,
  queryFn: ({ pageParam = 1 }) => fetchPage(pageParam),
  ...options,
  getNextPageParam: (lastPage, allPages) => lastPage.nextCursor,
  getPreviousPageParam: (firstPage, allPages) => firstPage.prevCursor,
})

tastack-query에서 제공하는 정보이다. queryFn의 pageParam은

Query Functions | TanStack Query Docs

에서 알 수 있듯 현재 페이지를 가져오는 데 사용되는 페이지 매개변수이다. getNextPageParam을 사용하여 다음 호출시에 사용될 pageParam을 정할 수 있다.

우리 팀은 무한스크롤을 호출할 때 page단위가 아닌 직전 호출한 리스트 중 마지막 항목의 id 값을 기준으로 요청해야했다. 따라서 처음 요청시에는 마지막 항목의 id값이 없어서 다음과 값이 호출 api를 구현했다.

호출 API

export const fetchThreads = (teamPlaceId: number, lastThreadId?: number) => {
  const query = lastThreadId
    ? `last-thread-id=${lastThreadId}&size=${THREAD_SIZE}`
    : `size=${THREAD_SIZE}`;

  return http.get<ThreadsResponse>(
    `/api/team-place/${teamPlaceId}/feed/threads?${query}`,
  );
};

useInfiniteQuery

useInfiniteQuery를 사용한 부분은 다음과 같다.

export const useFetchThreads = (teamPlaceId: number) => {
  const {
    data: threadPages,
    hasNextPage,
    fetchNextPage,
  } = useInfiniteQuery(
    ['threadData', teamPlaceId],
    ({ pageParam = undefined }) => fetchThreads(teamPlaceId, pageParam),
    {
      enabled: teamPlaceId > 0,
      getNextPageParam: (lastPage) => {
        if (lastPage.threads.length !== THREAD_SIZE) return undefined;
        return lastPage.threads[THREAD_SIZE - 1].id;
      },
    },
  );

  return { threadPages, hasNextPage, fetchNextPage };
};

getNextPageParam의 return 값은 다음 호출시 별도로 설정해주지 않아서 자동적 pageParam에 값이 들어가 다음을 호출한다. 만약 getNextPageParam이 없다면 undefined을 무조건 반환해야한다. (공식문서에서 정해준거라 의문을 가져도 별 수 없다.)

우리팀은 말했듯 직전 아이디를 기준으로 요청을 보내면 백엔드에서 알아서 처리해주기로 해서 이렇게 구현했다.

enabled: teamPlaceId > 0 이 설정은 해당 쿼리가 돌아가는 조건이다. 우리는 teamPlaceId가 0인 경우는 팀이 선택되지 않았기 때문에 쿼리를 요청하면 안되었다. 그래서 teamPlaceId가 0이하 일 때 작동하지 않도록 했다.

IntersectionObserver

무한스크롤의 핵심 IntersectionObserver은 다른 컴포넌트에서도 사용하기 때문에 훅으로 분리했다.

import { useEffect, type RefObject, useRef } from 'react';

export const useIntersectionObserver = <T extends HTMLElement>(
  targetRef: RefObject<T>, //관찰하는 요소
  onIntersect: IntersectionObserverCallback, //관찰 되었을 때 실행하고 싶은 함수
  hasNextPage: boolean | undefined, //무한 스크롤로 더 불러올 요소가 있는지
) => {
  const observer = useRef<IntersectionObserver>();
  useEffect(() => {
    if (targetRef && targetRef.current) {
      observer.current = new IntersectionObserver(onIntersect, {
        root: null,
        rootMargin: '0px',
        threshold: 1.0,
      });

      if (!hasNextPage) { //다음 페이지의 유무로 관찰하고 있는 항목을 관찰 취소한다.
        observer.current?.unobserve(targetRef.current);
        return;
      }

      observer.current.observe(targetRef.current);
    }

    return () => observer && observer.current?.disconnect();
  }, [targetRef, onIntersect]);
};

observer.current?.unobserve(targetRef.current); 이건 꼭 해줘야한다. 처음에 unobserve를 하지 않았었는데 마지막 항목에서 계속 요소를 관찰하고 있어 fetch가 제대로 일어나지 않는 에러가 발생했기 때문이다.

https://github.com/woowacourse-teams/2023-team-by-team/pull/529

정방향

정방향은 무한스크롤을 넣고 싶은 컴포넌트에 이렇게 관찰 할 요소를 달아주면 된다.

컴포넌트의 최하단에 관찰할 요소인

<div ref={observeRef} /> 를 넣어주고 해당 요소가 화면에 노출되면 실행하는 함수를 아래와 같이 작성한다.

 const onIntersect: IntersectionObserverCallback = ([entry]) => {
    if (entry.isIntersecting && teamPlaceId > 0) {
      fetchNextPage();
    }
  };

전체 코드는 아래와 같다.

const ThreadList = (props: ThreadListProps) => {
	...
  const { threadPages, hasNextPage, fetchNextPage } =
    useFetchThreads(teamPlaceId);
  const observeRef = useRef<HTMLDivElement>(null);

  const onIntersect: IntersectionObserverCallback = ([entry]) => {
    if (entry.isIntersecting && teamPlaceId > 0) {
      fetchNextPage();
    }
  };

  useIntersectionObserver(observeRef, onIntersect, hasNextPage);

  return (
    <>
      {threadPages?.pages.map((page) =>
        page.threads.map((thread) => {
          //무한스크롤로 불러온 요소 랜더링
          );
        }),
      )}
      {!hasNextPage &&
        threadPages &&
        threadPages.pages[0].threads.length > 0 && (
          <Text size="lg" css={S.lastThreadText}>
            마지막 스레드 입니다.
          </Text>
        )}
      <div ref={observeRef} /> //관찰할 요소
    </>
  );
};

export default ThreadList;

역방향

역방향은 정방향과 큰 차이는 없다. 단지 조금 귀찮을 뿐..

일단 <div ref={observeRef} /> 를 관찰할 컴포넌트 최상단으로 올린다. 또한 요소를 렌더링 할 때 역순으로 해야하기 때문에 위에서 작성한 코드중 return 문을 아래와 같이 변경해준다.

<>
      <div ref={observeRef} />
      
      {!hasNextPage &&
        threadPages &&
        threadPages.pages[0].threads.length > 0 && (
          <Text size="lg" css={S.lastThreadText}>
            마지막 스레드 입니다.
          </Text>
        )}
      {threadPages?.pages
        .slice()
        .reverse()
        .map((page) =>
          page.threads
            .slice()
            .reverse()
            .map((thread) => {
              //무한스크롤로 불러온 요소 렌더링
        )}
    </>

이렇게하면 끝일까? 아니다. 문젠 스크롤이 최상단에 위치해있기 때문에 계속 <div ref={observeRef} /> 를 관찰해서 무한으로 api요청을 보낸다. 그렇기 때문에 우리는 컴포넌트가 첫 렌더링이 될 때 스크롤을 아래로 보내야한다.

ThreadList 컴포넌트의 코드에 아래와 같이 추가한다.

const [scrollHeight, setScrollHeight] = useState(0);
const containerRef= useRef<HTMLDivElement>(null);

useEffect(() => {
    if (!containerRef) return;

    if (containerRef.current) {
      const scrollTop = containerRef.current.scrollHeight - scrollHeight;
      containerRef.current.scrollTop = scrollTop;
      setScrollHeight(containerRef.current.scrollHeight);
    }
  }, [threadPages?.pages.length]);

scrollTop은 스크롤이 현재 위치해있는 위치이고 scrollHeight는 총 content의 높이이다. 즉 스크롤 최하단으로 가려면 scrollHeight으로 가면 된다. 위 코드처럼 작성하면 처음 시도에는 최하단으로, 이후 fetch요청이 일어나면 직전 보고있던 스크롤 위치를 기억해 새로운 데이터가 불러와져도 보고있는 내용을 볼 수 있도록 한다.

그리고 observeRef와 렌더링 요소를 묶는 가장 상위에 containerRef를 연결한다.

<div ref={containerRef}>
      <div ref={observeRef} />
    //렌더링하는 로직
</>

전체코드는 아래 커밋에서 확인할 수 있다. 다만 우리팀 요구사항에는 공지를 ThreadList 최상단에 붙여야 하기때문에 ThreadList의 상위 컴포넌트에 containerRef를 연결하고 props로 받아오는 방식으로 구현했다.

https://github.com/woowacourse-teams/2023-team-by-team/commit/e2d72ce59b6e6fc7062ce431a0ede69d3238cd30

profile
프론트엔드 개발자 루루

2개의 댓글

comment-user-thumbnail
2023년 9월 14일

저희팀도 스와이프 기능이 있어서 재미있게 잘 봤습니다~ㅎㅎ
tanstack query 탐나네요

1개의 답글