useInfiniteQuery, IntersectionObserver로 무한스크롤 구현하는 방법

LEEJAEJUN·2024년 2월 7일
0

fasttime-2024

목록 보기
1/4

백엔드 API 예시

💡 /api/v2/articles?title=제목&nickname=닉네임&likeCount=10&page=0&pageSize=10&orderBy=likeCount&isAscending=true

어떻게 요청할 것인가?

나는 Axios 인스턴스를 만들어서 사용하고 있었고, AxiosRequestConfig의 params로 page와 pageSize를 넘겨줄 수 있게 API가 설계되어 있었다.

  • pageSize → 한 번의 요청당 가져올 게시글의 수
  • page → pageSize에 따른 요청 시 가져올 게시글의 페이지 넘버
  • 해당 API는 0페이지부터 시작하도록 설계되어 있었으므로, 예를 들면 아래와 같다.
    ex) pageSize = 10, page = 00 페이지의 10개 게시물을 요청

useSuspenseInfiniteQuery 먼저 뜯어보기

  • 우선 getArticles라는 함수에서 page와 pageSize라는 매개변수를 받게 해서 내부적으로 axios 코드가 동작하도록 설계했다. (ArticleService 클래스 내부 메소드로 구현)
    getArticles = async ({ page, pageSize }: { page: number; pageSize: number }) => {
        const response = await instance.get<{ data: ArticleList }>(this.endpoint, {
          params: {
            page,
            pageSize,
          },
        });
    
        return response.data.data;
      };
  • 아래에서 보게 될 코드의 핵심은 queryFn, getNextPageParam 이 두 가지이다.
    1. queryFn: (context: QueryFunctionContext) => Promise<TData>

      queryFn은 기본적으로 context를 매개변수로 받는데, 이는 QueryFunctionContext로 여러가지를 가지지만 특히 pageParam을 갖고 있다. pageParam은 현재 페이지를 fetch하기 위해 필요한 아주 중요한 정보다. 이를 getArticles의 매개변수로 넘겨준다. 내가 받고 싶은 게시글이 몇 번째 페이지인지 알아야 하니까!!

    2. getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => TPageParam | undefined | null

      이름만 봐도 알 수 있듯이 다음 페이지 파라미터를 받는 함수다. 매개변수로는 lastPage와 allPages가 주로 쓰이는데, lastPage에는 마지막으로 fetch한 페이지의 게시글 목록이 배열 형태로 담겨있다. allPages는 지금까지 받은 모든 데이터를 배열 형태로 가지고 있다. 예를 들어, 아래와 같다.

      // page 1
      [ item1, item2 ]
      
      // page 2 (lastPage)
      [ item3, item4 ]
      
      // allPages
      [[ item1, item2 ], [ item3, item4 ]]

      그리고 여기서 return 되는 값이 위 pageParam으로 넘겨진다. 여기서 값이 undefined이나 null이면 다음 페이지가 없다는 의미이다.

나의 경우는 어떨까?

useQuery의 <>에 타입을 선언하지 말 것..?

export const useArticles = ({ pageSize }: { pageSize: number }) => {
  return useSuspenseInfiniteQuery<
    ArticleList,
    AxiosError,
    InfiniteData<ArticleList>,
    typeof ARTICLES_KEY,
    number
  >({
    queryKey: ARTICLES_KEY,
    queryFn: ({ pageParam }) => api.getArticles({ page: pageParam, pageSize }),
    initialPageParam: 0,
    getNextPageParam: (lastPage, allPages) => {
      if (lastPage.length < 10) return undefined;
      return allPages.length;
    },
  });
};
  1. API 상에 다음 페이지 유무를 알 수 있는 방법이 없었다.
    1. 이 부분이 고민이 되었는데, 만약 마지막으로 가져온 게시글 목록의 개수가 10개보다 적다면 다음 페이지에는 하나도 없는 것이기 때문에 undefined를 리턴하도록 만들었다. (회의 때 게시글을 10개씩 받아오기로 결정이 되어서)
    2. 만약 pageSize마다 다른 값으로 주려면 10 자리에 pageSize를 넣으면 될 것 같다.
  2. 첫 페이지가 0이었다.
    1. allPages.length가 곧, 다음 페이지 넘버이다.
      (0번 째 페이지를 가져오면, 다음 페이지는 1번이니까)

IntersectionObserverAPI

다음으로 해야 할 일은 스크롤이 특정 지점까지 내려왔을 때, 다음 페이지를 요청하는 것이다. 여러 작은 프로젝트를 거치면서 이때 사용하기 딱 좋은 게 IntersectionObserverAPI라는 생각이 들었다.

처음에는 좀 많이 구리게 썼다..

observe하는 ref에 데이터가 들어오고 나서 어떻게든 강제로 움직임을 일으키는 방법을 찾고자 했으나, 마땅한 해결책을 찾지 못하던 와중에 한 글을 발견하게 되었다. 이래서 기술문서가~

https://tech.kakaoenterprise.com/149

useIntersect hook

import { useCallback, useEffect, useRef } from 'react';

type IntersectHandler = (entry: IntersectionObserverEntry, observer: IntersectionObserver) => void;

const useIntersect = (onIntersect: IntersectHandler, options?: IntersectionObserverInit) => {
  const ref = useRef<HTMLDivElement>(null);
  const callback = useCallback(
    (entries: IntersectionObserverEntry[], observer: IntersectionObserver) => {
      entries.forEach((entry) => {
        if (entry.isIntersecting) onIntersect(entry, observer);
      });
    },
    [onIntersect]
  );

  useEffect(() => {
    if (!ref.current) return;
    const observer = new IntersectionObserver(callback, options);
    observer.observe(ref.current);
    return () => observer.disconnect();
  }, [ref, options, callback]);

  return ref;
};

export default useIntersect;

조금 더 자세히 살펴보자면,

// entry: 타겟과 root(parent) 엘리먼트 사이의 정보
// observer: 엘리먼트를 관찰 중인 observer 인스턴스 (observe, unobserve 관찰 할지 말지 결정)
type IntersectHandler = (entry: IntersectionObserverEntry, observer: IntersectionObserver) => void;

// 옵저버 인스턴스를 생성할 때 사용할 콜백 함수 생성
// 엘리먼트가 뷰포트에 들어올 때 onIntersect 함수 실행
const ref = useRef<HTMLDivElement>(null);
const callback = useCallback(
  (entries: IntersectionObserverEntry[], observer: IntersectionObserver) => {
    entries.forEach((entry) => {
      if (entry.isIntersecting) onIntersect(entry, observer);
    });
  },
  [onIntersect]
);

// ref가 null이 아니면 옵저버를 생성하고 해당 ref에 대한 observe를 실행
useEffect(() => {
    if (!ref.current) return;
    const observer = new IntersectionObserver(callback, options);
    observer.observe(ref.current);
    return () => observer.disconnect();
  }, [ref, options, callback]);

entries

https://ww8007-learn.tistory.com/6

위에서 만든 커스텀 훅을 아래와 같이 사용한다. 그러면 ref가 생성되고, useEffect 내부 로직이 실행되면서 observer가 생성되고, 관찰을 시작하게 된다. 해당 엘리먼트가 뷰포트에 들어왔을 때 기존의 observer는 끊고, 다음 페이지가 있고, fetch 하고있지 않을 때 다음 페이지를 fetch한다. 덕분에 처음 다음 페이지를 fetch할 때도 문제 없이 observer가 정상작동 하는 것을 볼 수 있다.

ArticleList Component

const ref = useIntersect(async (entry, observer) => {
  observer.unobserve(entry.target);
  if (hasNextPage && !isFetching) {
    fetchNextPage();
  }
});

return (
	{/* ... */}
	<div className={styles.observer} ref={ref} />
)
  • 참고 https://github.com/bvaughn/react-window
    1. Intersection Observer은 이미 써보셨고, 스크롤 위치를 감지하는 방법또한 이미 알고 계시지 않을까 싶습니다.
      답글을 출력 중인 댓글의 ID를 element에 어떤 형태로든 연동한다면, 해당 댓글이 스크롤에서 벗어난 것을 감지할 수 있습니다.
      DOM element가 많아지기 시작하면 렌더링에도 꽤 많은 비용이 소모되기에, 화면에 보이지 않는 요소를 제거하는 작업은 대규모 애플리케이션에서 꽤 자주 진행하는 최적화 중 하나이기도 합니다(특히 Infinite Scroll이 있는 애플리케이션이라면 더더욱 필수적입니다).
      아무래도 DOM에 직접 접근할 일도 많은 작업이기에, DOM 조작이나, React의 useRef 등 다양한 hooks에 익숙해지셔야 가능할 작업이긴 합니다.
      아래 라이브러리에 대해 알아보셔도 좋을 것 같습니다.
profile
always be fresh

0개의 댓글

관련 채용 정보