React-query를 이용한 무한 스크롤 구현하기

Byeonghyeon·2025년 1월 9일
0

TRipTo

목록 보기
1/3

프로젝트를 진행하면서 무한 스크롤로 페이지를 구성해보고 싶다는 생각이 들었다.

무한 스크롤을 구현하면 사용자는 '다음' 페이지로 이동하지 않고 한 페이지에서 콘텐츠들을 전부 확인할 수 있고, 또 모바일 페이지에 적합하기 때문이었다.

마침 프로젝트에서 React Query를 사용하고 있었기에 React Query의 useInfiniteQuery 훅을 사용해 구현해보기로 했다.

const {
  isFetching,
  fetchNextPage,
  data,
  refetch
} = useInfinteQuery(
  queryKey,
  queryFn: ({ pageParam = 1 }) => fetchPage(pageParam),
  {
    getNextPageParam: (lastPage, allPages) => lastPage.nextCursor
  }
)
  • queryKey : 쿼리를 구별하여 캐시를 관리하기 위한 key
  • queryFn : 쿼리가 데이터를 요청하는데 사용할 함수
  • getNextPageParams : 다음 페이지를 받아오기 위한 파라미터를 정의하는 역할
  • lastPage : 마지막으로 받은 페이지의 데이터

lastPage.nextCursor를 통해 다음 페이지를 요청할 때 필요한 커서나 페이지 번호를 가져온다.

API에서 nextCursor를 반환하면 이 값을 getNextPageParam에서 반환하여 다음 페이지를 요청하는 데 사용한다.

 const { data, fetchNextPage, isLoading, isError, isFetchingNextPage } =
    useInfiniteQuery(
      ["searchPlace", keyword],
      ({ pageParam = 0 }) => fetchPlace({ page: pageParam, keyword: keyword }),
      {
        getNextPageParam: (lastPage) => {
          const nextPage = lastPage.page + 1;
          return lastPage.hasNextPage ? nextPage : undefined;
        },
        enabled: keyword !== "", // tag가 빈 문자열일 때 쿼리를 실행하지 않음
      }
    );

내가 작성한 코드이다.

검색 결과 페이지인데, pageParam과 검색어 keyword를 넘겨서 데이터를 fetch한다.

이 때 pageParam의 초기값은 0이다.

API에서 다음 페이지의 존재 유무를 hasNextPage라는 인자로 전달하면, 이를 확인해 pageParam을 1씩 증가시켜 다시 데이터를 요청하는 구조이다.

const [rows] = await connection.execute(
      `SELECT *
       FROM TouristSpot
       WHERE name LIKE CONCAT('%', ?, '%')
          OR area LIKE CONCAT('%', ?, '%')
          OR subarea LIKE CONCAT('%', ?, '%')
          OR JSON_CONTAINS(tags, ?)
       LIMIT ?, ?`,
      [keyword, keyword, keyword, JSON.stringify([keyword]), page * take, take]
    );

    const returnAttraction = rows as Array<attraction>;
	
    // 다음 페이지 존재 여부
    const hasNextPage =
    returnAttraction.length === 6 &&
    returnAttraction.length < totalCount;

    return NextResponse.json(
      {
        attractions: returnAttraction,
        hasNextPage: hasNextPage, // 다음 페이지 존재 여부
        page: page,
      },
      { status: 200 }
    );

해당 코드는 API Routes와 MySQL을 활용한 API 코드의 예시이다.

주목할 점은 page * take와 take이다.

take는 한 번에 가져올 데이터의 수이다. 나는 6으로 설정했다.

page * take는 데이터의 시작 지점이다.

즉 위의 useInfiniteQuery의 초기 page는 0이므로 0x6 = 0, 첫번째 데이터부터 시작하며, 이후 스크롤이 이뤄져 1페이지, 2페이지가 되면 각각 1x6 = 6, 2x6 = 12가 되어 6번째 데이터, 12번째 데이터부터 시작한다.

결과적으로 page가 2이고 take가 6이라면 2x6 = 12번째 데이터부터 6개를 가져온다는 의미이다.

이렇게 API로부터 데이터를 가져오는데 성공했다면 Intersection Observer API를 이용해 스크롤이 화면의 끝에 닿았음을 감지해 데이터 페칭을 다시 시도하는 방식으로 코드를 작성하였다.

최종 코드

/// 데이터 요청
const fetchPlace = async ({
  page,
  keyword,
}: {
  page: number;
  keyword: string;
}) => {
  const response = await axios.get("/api/search", {
    params: {
      page: page,
      keyword: decodeURIComponent(keyword),
    },
  });
  return response.data;
};


function SearchPageClient({keyword}: {keyword: string}) {
  const router = useRouter();

  const { ref, inView } = useInView();

	
    /// infiniteQuery
  const { data, fetchNextPage, isLoading, isError, isFetchingNextPage } =
    useInfiniteQuery(
      ["searchPlace", keyword],
      ({ pageParam = 0 }) => fetchPlace({ page: pageParam, keyword: keyword }),
      {
        getNextPageParam: (lastPage) => {
          const nextPage = lastPage.page + 1;
          return lastPage.hasNextPage ? nextPage : undefined;
        },
        enabled: keyword !== "", // tag가 빈 문자열일 때 쿼리를 실행하지 않음
      }
    );

/// 스크롤 감지
  useEffect(() => {
    if (inView) {
      fetchNextPage();
    }
  }, [inView, fetchNextPage]);

  if (isLoading) return <Loading />;
  if (isError) return <Error />;

 
  return (
    <div>
      
      <div
        className="grid grid-cols-2 lg:grid-cols-2 gap-12 mt-24"
        key={data?.pages[0].name}
      >
        {data?.pages.map((page) =>
          page.attractions.map((place: attraction) => (
            <div key={`${place.id}`} onClick={() => cardClick(place.id)}>
              <AttractionCard attraction={place} />
            </div>
          ))
        )}
      </div>

      {/* 무한 스크롤을 위한 감지 요소 */}
      {isFetchingNextPage ? <Loading /> : <div ref={ref} className="h-10" />}
    </div>
  );
}

export default SearchPageClient;

0개의 댓글

관련 채용 정보