Post Detail Page

tech

useInfiniteQuery - 무한스크롤 Offset 페이지네이션 오류 해결하기

useInfiniteQuery

TanStack Query v5로 업데이트되면서 useInfiniteQuery와 관련된 변경사항이 있는지 확인해보면, 초기 페이지 매개변수를 설정해야 하고 프리패치 기능이 추가된 것을 볼 수 있다.

TanStack Query v5

  • initialPageParam: 초기 페이지 매개 변수를 설정
  • maxPages : 무한 스크롤이 요청하는 최대 페이지 설정
  • prefetch
  • React 18 이상
  • TypeScript 4.7 이상

공식문서 사용법

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,
});

두 번째 페이지부터 데이터를 불러오지 못하는 문제

📝 useGetMessages.ts

import { useInfiniteQuery } from '@tanstack/react-query';
import { getMessages } from '@/api/queryFunctions';

const MESSAGES_PER_PAGE = 6;

export const useGetMessages = (boardId: number) => {
  const {
    data: messageData,
    fetchNextPage,
    hasNextPage,
    isFetching,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['messages', boardId],
    queryFn: ({ pageParam }) => getMessages({ boardId, offset: pageParam }),
    initialPageParam: 0,
    getNextPageParam: (lastPage) => (lastPage.length < MESSAGES_PER_PAGE ? undefined : lastPage.nextCursor),
    select: (data) => ({
      pageParams: data.pageParams,
      pages: data.pages.flatMap((page) => page),
    }),
  });

  return { messageData, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage };
};

콘솔 확인하기

useInfiniteQuery-image

☹︎ ERROR
콘솔에 받아온 데이터를 확인해 보면 첫번째 페이지에 대한 6개의 메시지 데이터만 나온다.

  • pageParams: [0, 6, 12, ...]와 같이 6씩 증가하고 page 배열에는 지금까지 불러온 모든 페이지의 데이터가 들어올 것이라고 예상했지만, 첫 번째 페이지의 6개의 데이터만 로드되고 이후 데이터가 로드되지 않는 문제 발생
  • lastPage.length가 6보다 작은 경우에만 undefined를 반환하여 다음 페이지가 없음을 명시해 두었는데, 뒤에 무려 12개의 데이터가 있음에도 불구하고 로드되지 않음
  • getNextPageParam의 값이 undefined으로 다음 페이지로 넘어가지 않음

매개변수에 따른 데이터 반환값 확인하기

 getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) => lastPage.nextCursor

위의 공식문서를 참고해서 각각의 매개변수를 반영했을 때 어떤 데이터가 나오는지 확인해보려고 한다.

useInfiniteQuery-image
매개변수별 반환값
  • lastPage: 마지막으로 가져온 페이지의 데이터 (마지막 fetch 요청에서 반환된 데이터)
  • allPages: 지금까지 가져온 모든 페이지의 데이터를 포함하는 배열
  • lastPageParam: 마지막 페이지의 파라미터 값
  • allPageParams: 모든 페이지의 파라미터 값을 포함하는 배열

반환된 lastPage 값에는 nextCursor가 없기 때문에 undefined가 반환돼서 다음 페이지를 불러올 수 없었다.
스웨거를 다시 확인해 보니 nextprevious가 반환되는데, 여기에 offset이 포함된 URL이 반환된다.
next로 반환되는 URL의 offset 파라미터를 꺼내와서 적용하는 방법과 allPageslastPageParam을 적용하는 방법을 사용해 보려고 한다.

접근 방식 1: URL의 offset 파라미터 사용

💡 반환된 다음 페이지의 URL에서 offset 파라미터를 추출하여 페이지네이션을 관리하는 방법 적용

📝 useGetMessages.ts

import { useInfiniteQuery } from '@tanstack/react-query';
import { getMessages } from '@/api/queryFunctions';

export const useGetMessages = (boardId: number) => {
  const {
    data: messageData,
    fetchNextPage,
    hasNextPage,
    isFetching,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ['messages', boardId],
    queryFn: ({ pageParam }) => getMessages({ boardId, offset: pageParam }),
    initialPageParam: 0,
    getNextPageParam: (lastPage) => {
      const nextUrl = new URL(lastPage.next);
      const offset = nextUrl.searchParams.get('offset');
      return offset ? +offset : null;
    },
    select: (data) => ({
      pageParams: data.pageParams,
      pages: data.pages.flatMap((page) => page.results),
    }),
  });

  return { messageData, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage };
};
  • lastPage
    useInfiniteQuery가 불러온 마지막 페이지의 데이터로 다음 페이지에 대한 정보를 포함하는 next URL 사용
  • new URL(lastPage.next)
    lastPage.next는 다음 페이지를 요청할 수 있는 URL 문자열로 해당 문자열을 URL 객체로 변환하여 쿼리 문자열에 접근
  • searchParams.get(‘offset’)
    searchParams는 URL의 쿼리 문자열 부분을 다루는 인터페이스로 offset이라는 매개변수의 값을 가져옴

📌 문제 상황

useInfiniteQuery-image

☹︎ ERROR
20개의 데이터 중 18개까지만 반환되고 남은 2개의 데이터는 불러와지지 않는 문제 발생

📌 원인

  • lastPage.next가 null일 때 URL 객체를 생성하려고 시도하기 때문에 오류 발생
  • next가 null인 경우(다음 페이지가 없는 경우)를 처리해야 한다.

📌 해결

  getNextPageParam: (lastPage) => {
    if (!lastPage.next) return;  // next가 null일 경우 처리
    const nextUrl = new URL(lastPage.next);
    const offset = nextUrl.searchParams.get('offset');
    return offset ? +offset : null;
  },
  • if (!lastPage.next) return
    lastPage.next가 null인 경우 = 더 이상 다음 페이지가 없는 경우, undefined를 반환하여 useInfiniteQuery에게 더 이상의 페이지가 없음을 명시하여 해결
무한스크롤-구현-gif
mingle | 보드 상세보기 - 무한스크롤 구현

접근 방식 2: allPages 사용

💡 allPages - 배열의 전체 길이를 활용하여 다음 페이지의 offset 값으로 적용

  const MESSAGES_PER_PAGE = 6;

  getNextPageParam: (lastPage, allPages) => {
    const nextPageParam = allPages.length;
    return lastPage.length < MESSAGES_PER_PAGE ? undefined : nextPageParam;
  },

📌 문제 상황

useInfiniteQuery-image

☹︎ ERROR
데이터를 6개씩 불러오는데, 두 번째 페이지의 데이터가 첫 번째 페이지의 1번 인덱스부터 6개의 데이터를 중복으로 불러오고, 다음 페이지는 두 번째 페이지의 2번 인덱스부터 6개의 데이터를 불러오는 문제 발생

📌 allPages 개선하기

  • 개선 1
    콘솔을 확인해 보면 allPages는 [Array(6), Array(6), Array(6)] 형태로 반환된다.
    이중 배열을 flat() 메서드를 사용해 하나의 배열로 변환한 후, length를 nextPageParam으로 넘겨주도록 변경했다.

    const MESSAGES_PER_PAGE = 6;
    
    getNextPageParam: (lastPage, allPages) => {
      const nextPageParam = allPages.flat().length;
      return lastPage.length < MESSAGES_PER_PAGE ? undefined : nextPageParam;
    },
  • 개선 2
    단일 배열로 만들지 않고 allPages의 길이페이지당 항목 수를 곱하여 다음 페이지의 offset 값으로 사용하는 방법으로 개선할 수 있다.

    const MESSAGES_PER_PAGE = 6;
    
    getNextPageParam: (lastPage, allPages) => {
        const nextPageParam = allPages.length * MESSAGES_PER_PAGE;
        return lastPage.length < MESSAGES_PER_PAGE ? undefined : nextPageParam;
    },

📌 해결

두 방법 모두 데이터가 중복되지 않고 로드되는 것을 볼 수 있다.

useInfiniteQuery-image

접근 방식 3: lastPageParam 사용

💡 마지막으로 사용된 페이지 파라미터 값을 기반으로 다음 페이지의 offset 계산하여 적용

  const MESSAGES_PER_PAGE = 6;

  getNextPageParam: (lastPage, _, lastPageParam) => {
    const nextPageParam = lastPageParam + MESSAGES_PER_PAGE;
    return lastPage.length < MESSAGES_PER_PAGE ? undefined : nextPageParam;
  },

lastPageParam은 마지막으로 사용된 페이지 파라미터 값으로, 현재 페이지의 데이터를 가져오기 위해 사용된 직전 페이지의 offset이다. 이 값을 기반으로 6을 더해서 다음 페이지의 offset 값을 계산할 수 있다.

  • 첫 번째 페이지: offset = 0, limit = 6
  • 두 번째 페이지: offset = 6, limit = 6
  • 세 번째 페이지: offset = 12, limit = 6

마무리하며

초기에는 allPages의 모든 페이지 데이터를 flat() 메서드로 평탄화한 후 길이를 계산하여 보다 정확한 결과를 넘겨줄 수 있을 것이라고 생각해 접근 방식 2를 적용했었다. 그러나 배열의 수가 많아질 경우, n개의 배열을 평탄화하는 과정에서 성능 저하 문제가 발생할 수 있다는 판단이 들어 next로 반환되는 URL을 활용하여 offset 값을 넘겨주는 접근 방식 1로 변경하여 적용했다.