인피니트 스크롤링 구현하기(react-query, next.js)

김지환·2024년 4월 10일
post-thumbnail

Next.js App Router의 환경에서
react-query
react-interseciton-observer
mock-service-worker
를 활용해 인피니트 스크롤링을 구현하고자 한다.

우선 본 글에서는 인피니트 스크롤링으로 로드되는 게시글의 id가 오름차순 정렬되어
새로운 게시글의 id는 이전 게시글의 id보다 크다고 가정한다.

인피니트 스크롤링에 앞서
가장 우선적으로 알고 지나가야할 개념이 cursor이다.

1. cursor의 개념

마지막 게시글의 id를 뜻한다.

cursor 값을 쿼리 스트링 등에 담아 백엔드 호출을 하면, 이를 기반으로 다음 스크롤에 로드될 게시글의 id는 cursor보다 큰 값을 가질것이다.

2. react-query

2.1 prefetchInfiniteQuery

react-query 공식문서에서는 아래의 코드를 예시로 소개한다.

const prefetchProjects = async () => {
  // The results of this query will be cached like a normal query
  await queryClient.prefetchInfiniteQuery({
    queryKey: ['projects'],
    queryFn: fetchProjects,
    initialPageParam: 0,
    getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
    pages: 3, // prefetch the first 3 pages
  })
}
  • initialPageParam : 인피니트 스크롤링으로 로드될 게시글이 아닌, 페이지에 접속 후 최초의 게시글을 가지고 올 때 전달할 cursor의 값을 뜻한다.
  • getNextPageParam : 인피니트 스크롤이 적용됨에 따라 새로운 게시글이 로드될 때 다음 게시글을 불러올 때 사용할 cursor를 지정하는데 사용된다. lastPage에는 직전에 가져온 게시글이 담겨있다. pages에는 지금까지 가져온 모든 게시글이 담겨있다.
    queryFn에 전달될 cursor 값 단 1개를 반환해야만 한다.
    null이나 undefined가 반환될 경우 다음에 불러올 page가 더 없다는 뜻이다.
  • pages : 처음에 가지고 올 페이지의 개수를 뜻한다.

필자의 경우는 다음과 같이 설정했다.

다음 게시글을 불러올 때 사용할 cursor를 lastPage의 게시글 중 마지막 게시글의 id로 설정했다.(getNextPageParam)

페이지의 접속 후 5페이지의 게시글을 불러올 것이다.(pages)
(MSW를 활용해 요청 당 5개의 게시글을 받기 때문에 25개의 게시글이 불러와진다.)


import { QueryClient, HydrationBoundary, dehydrate } from "@tanstack/react-query";
import queryFunction from "somewhere"

export default async function Home() {
  const queryClient = new QueryClient();
  await queryClient.prefetchInfiniteQuery({
    queryKey: ["사용할 쿼리키 1", "사용할 쿼리키 2"],
    queryFn: queryFunction,
    initialPageParam: 0,
    getNextPageParam: (lastPage, pages) => lastPage.at(-1)?.postId,
    pages: 5,
  });

  return (
    <Components>
     	...
    </Components>
  );
}

2.2 useInfiniteQuery

react-query 공식문서에서는 아래의 코드를 예시로 소개한다.

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

필수 options

  • queryFn : 데이터를 요청할 때 사용되는 함수.(fetch 동작을 수행)
  • initialPageParam : 앞선 설명과 동일하다.
  • getNextPageParam : 앞선 설명과 동일하다.

Returns

  • fetchNextPage : 함수이고, 다음 페이지를 로드하라고 명령을 내린다.
    스크롤이 어느정도 위치에 도달하면, 이 함수를 실행한다.
  • hasNextPage : boolean 값. true라면, 다음에 가져올 page가 있다는 것이다.
  • isFetchingNextPage : boolean 값, fetchNextPage가 실행 중일때 true.

위의 설명 이외에도 상황에 맞게 다양한 option과 return을 사용할 수 있다.


필자의 경우는 다음과 같이 설정했다.

"use client";

import { useInfiniteQuery } from "@tanstack/react-query";

import queryFunciton from "somewhere";

export default function RecommendPosts() {
  const { isFetching, fetchNextPage, hasNextPage, data } 
  = useInfiniteQuery({
    queryKey: ["사용할 쿼리키 1", "사용할 쿼리키 2"],
    queryFn: queryFunction,
    initialPageParam: 0,
    getNextPageParam: (lastPage) => lastPage.at(-1)?.postId,
  });

사실 initialPageParam을 위와 같이 설정했지만, 페이지가 처음 랜더링 될 때 게시물을 가져오는 것은 서버 컴포넌트, 즉 2.1prefetchInfiniteQuery가 담당하기 때문에 위의 initialPageParam은 크게 의미가 있는 것은 아니다.

하지만, getNextPageParam에서 반환하는 cursor의 값은 스크롤이 됨에 따라 클라이언트 컴포넌트, 즉 2.2useInfiniteQuerygetNextPageParam이 반환하는 cursor의 값을 따른다.

3. react-interseciton-observer

react-interseciton-observer를 활용하면 어떤 요소가 화면에 보이기 시작할 때 특정 동작을 수행할 수 있다.

우리는 어떤 요소가 화면에 보인다면, fetchNextPage를 호출할 것이다.

아래의 코드와 같이 어떤 html 요소에 useInView 훅으로 만든 ref를 설정한다.

useEffect를 활용해, inView가 바뀔 때, 즉 ref가 설정된 html 요소가 화면에 보일 때 fetchNextPage를 실행한다.

"use client";

import { useEffect } from "react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useInView } from "react-intersection-observer";

import queryFunciton from "somewhere";

export default function RecommendPosts() {
  const { isFetching, fetchNextPage, hasNextPage, data } = useInfiniteQuery<IPost[], Object, IPost[], [_1: string, _2: string], number>({
    queryKey: ["사용할 쿼리키 1", "사용할 쿼리키 2"],
    queryFn: queryFunciton,
    initialPageParam: 0,
    getNextPageParam: (lastPage) => lastPage.at(-1)?.postId,
  });

  const { ref, inView } = useInView({
    /* Optional options */
    threshold: 0,
  });

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

  if (!data) return null;

  return (
    <>
      <게시글들> ... </게시글들>
      <div ref={ref} style={{ height: "100px" }}></div>
    </>
  );
}

여기까지 해도 인피니트 스크롤 구현은 가능하다.

하지만 react-query 공식문서에서 fetchNextPage와 같은 함수는 다른 refetch 동작과 충돌이 일어나 최신 데이터를 못 가지고 올 수도 있다고 한다.

이를 방지하기 위해 hasNextPage && !isFetching 조건을 추가해서
사용자 호출에 의한 동작에만 데이터를 가지고 오도록 할 수 있다고 한다.

 useEffect(() => {
    if (inView) !isFetching && hasNextPage && fetchNextPage();
  }, [inView, isFetching, hasNextPage, fetchNextPage]);
profile
세상의 문제 해결을 즐기는 프론트엔드 개발자

0개의 댓글