🔥 무한 쿼리

1175자
14분

"더 불러오기" 기능이나 "무한 스크롤"과 같이 기존 데이터 집합에 추가로 데이터를 불러오는 목록을 표시하는 것은 매우 흔한 UI 패턴입니다. TanStack Query는 이런 종류의 목록을 조회하기 위해 useQuery의 유용한 버전인 useInfiniteQuery를 지원합니다.

useInfiniteQuery를 사용할 때 몇 가지 다른 점을 발견할 수 있습니다:

  • 데이터는 이제 무한 쿼리 데이터를 포함하는 객체입니다:
    • data.pages 배열에는 가져온 페이지들이 들어 있습니다
    • data.pageParams 배열에는 페이지를 가져오는 데 사용된 페이지 매개변수들이 들어 있습니다
  • fetchNextPagefetchPreviousPage 함수를 사용할 수 있습니다 (fetchNextPage는 필수입니다)
  • initialPageParam 옵션을 사용해 초기 페이지 매개변수를 지정할 수 있습니다 (필수)
  • getNextPageParamgetPreviousPageParam 옵션을 사용해 더 불러올 데이터가 있는지 판단하고 해당 데이터를 가져올 정보를 제공할 수 있습니다. 이 정보는 쿼리 함수에 추가 매개변수로 제공됩니다
  • hasNextPage 불리언 값은 getNextPageParamnull이나 undefined가 아닌 값을 반환하면 true입니다
  • hasPreviousPage 불리언 값은 getPreviousPageParamnull이나 undefined가 아닌 값을 반환하면 true입니다
  • isFetchingNextPageisFetchingPreviousPage 불리언 값을 사용해 배경 새로 고침 상태와 더 불러오는 상태를 구분할 수 있습니다

참고: initialData나 placeholderData 옵션은 data.pages와 data.pageParams 속성을 가진 객체와 같은 구조를 따라야 합니다.

예제

커서 인덱스를 기반으로 한 번에 3개의 프로젝트 페이지를 반환하고, 다음 그룹의 프로젝트를 가져올 수 있는 커서를 제공하는 API가 있다고 가정해 봅시다:

fetch('/api/projects?cursor=0')
// { data: [...], nextCursor: 3}
fetch('/api/projects?cursor=3')
// { data: [...], nextCursor: 6}
fetch('/api/projects?cursor=6')
// { data: [...], nextCursor: 9}
fetch('/api/projects?cursor=9')
// { data: [...] }
 
typescript

이 정보를 바탕으로 "더 불러오기" UI를 다음과 같이 만들 수 있습니다:

  • useInfiniteQuery가 기본적으로 첫 번째 데이터 그룹을 요청하기를 기다립니다
  • getNextPageParam에서 다음 쿼리에 필요한 정보를 반환합니다
  • fetchNextPage 함수를 호출합니다
import { useInfiniteQuery } from '@tanstack/react-query'
 
function Projects() {
  const fetchProjects = async ({ pageParam }) => {
    const res = await fetch('/api/projects?cursor=' + pageParam)
    return res.json()
  }
 
  const {
    data,
    error,
    fetchNextPage,
    hasNextPage,
    isFetching,
    isFetchingNextPage,
    status,
  } = useInfiniteQuery({
    queryKey: ['projects'],
    queryFn: fetchProjects,
    initialPageParam: 0,
    getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
  })
 
  return status === 'pending' ? (
    <p>불러오는 ...</p>
  ) : status === 'error' ? (
    <p>오류: {error.message}</p>
  ) : (
    <>
      {data.pages.map((group, i) => (
        <React.Fragment key={i}>
          {group.data.map((project) => (
            <p key={project.id}>{project.name}</p>
          ))}
        </React.Fragment>
      ))}
      <div>
        <button
          onClick={() => fetchNextPage()}
          disabled={!hasNextPage || isFetchingNextPage}
        >
          {isFetchingNextPage
            ? '더 불러오는 중...'
            : hasNextPage
              ? '더 불러오기'
              : '더 이상 불러올 내용이 없습니다'}
        </button>
      </div>
      <div>{isFetching && !isFetchingNextPage ? '가져오는 중...' : null}</div>
    </>
  )
}
 
typescript

진행 중인 가져오기 작업이 있는 동안 fetchNextPage를 호출하면 배경에서 일어나는 데이터 새로 고침을 덮어쓸 위험이 있습니다. 이는 목록을 렌더링하면서 동시에 fetchNextPage를 실행할 때 특히 중요합니다.

InfiniteQuery에서는 한 번에 하나의 가져오기 작업만 진행할 수 있습니다. 모든 페이지가 하나의 캐시 항목을 공유하므로, 동시에 두 번 가져오기를 시도하면 데이터가 덮어쓰일 수 있습니다.

동시 가져오기를 허용하려면 fetchNextPage 내에서 { cancelRefetch: false } 옵션(기본값: true)을 사용할 수 있습니다.

충돌 없이 원활한 쿼리 과정을 보장하려면, 특히 사용자가 직접 그 호출을 제어하지 않는 경우, 쿼리가 isFetching 상태가 아닌지 확인하는 것이 매우 중요합니다.

<List onEndReached={() => !isFetching && fetchNextPage()} />
 
javascript

무한 쿼리의 동작 원리와 양방향 무한 목록 구현

무한 쿼리가 다시 가져와야 할 때는 어떻게 될까요?

무한 쿼리가 오래되어 다시 데이터를 가져와야 할 때, 시스템은 첫 번째 그룹부터 시작해 순차적으로 각 그룹을 가져옵니다. 이런 방식으로 데이터를 가져오면 기본 데이터가 변경되더라도 오래된 커서를 사용하지 않아 중복을 피하고 기록을 건너뛰지 않습니다.

무한 쿼리의 결과가 쿼리 캐시에서 사라지면, 페이지 매김은 처음 상태로 돌아가 초기 그룹만 요청하는 상태로 재시작합니다.

양방향 무한 목록을 만들려면 어떻게 해야 할까요?

양방향 목록을 구현하려면 다음과 같은 속성과 함수를 사용하면 됩니다:

  • getPreviousPageParam
  • fetchPreviousPage
  • hasPreviousPage
  • isFetchingPreviousPage

이 기능들을 활용하면 앞뒤로 무한하게 스크롤할 수 있는 목록을 만들 수 있습니다.

다음은 양방향 무한 쿼리를 구현하는 코드 예제입니다:

useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: fetchProjects,
  initialPageParam: 0,
  getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
  getPreviousPageParam: (firstPage, pages) => firstPage.prevCursor,
})
 
typescript

이 코드에서 getNextPageParamgetPreviousPageParam 함수는 각각 다음 페이지와 이전 페이지의 매개변수를 결정합니다. nextCursorprevCursor는 각 페이지에서 제공하는 값으로, 다음 또는 이전 페이지를 가져올 때 사용됩니다.

이렇게 설정하면 사용자가 목록을 위아래로 스크롤할 때 새로운 데이터를 자연스럽게 불러올 수 있습니다. 이는 소셜 미디어 피드나 긴 채팅 기록과 같이 양방향으로 콘텐츠를 탐색해야 하는 애플리케이션에서 특히 유용합니다.

무한 쿼리 심화

페이지를 역순으로 보여주고 싶다면?

때로는 페이지를 역순으로 표시해야 할 경우가 있습니다. 이런 상황에서는 select 옵션을 활용할 수 있습니다.

useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: fetchProjects,
  select: (data) => ({
    pages: [...data.pages].reverse(),
    pageParams: [...data.pageParams].reverse(),
  }),
})
 
typescript

이 코드는 useInfiniteQuery를 사용하여 프로젝트 데이터를 가져오고, select 옵션을 통해 페이지와 페이지 매개변수를 역순으로 정렬합니다. 이렇게 하면 가장 최근에 가져온 페이지가 먼저 표시됩니다.

무한 쿼리를 수동으로 업데이트하려면?

첫 번째 페이지 제거하기

첫 번째 페이지를 수동으로 제거하려면 다음과 같이 queryClient.setQueryData를 사용할 수 있습니다:

queryClient.setQueryData(['projects'], (data) => ({
  pages: data.pages.slice(1),
  pageParams: data.pageParams.slice(1),
}))
 
typescript

이 코드는 첫 번째 페이지와 해당 페이지 매개변수를 제거하고 나머지 데이터를 유지합니다.

개별 페이지에서 특정 값 제거하기

특정 ID를 가진 항목을 모든 페이지에서 제거하려면 다음과 같이 할 수 있습니다:

const newPagesArray =
  oldPagesArray?.pages.map((page) =>
    page.filter((val) => val.id !== updatedId),
  ) ?? []
 
queryClient.setQueryData(['projects'], (data) => ({
  pages: newPagesArray,
  pageParams: data.pageParams,
}))
 
typescript

이 코드는 모든 페이지를 순회하면서 특정 ID를 가진 항목을 제거한 새로운 배열을 만들고, 이를 쿼리 데이터로 설정합니다.

첫 번째 페이지만 유지하기

첫 번째 페이지만 남기고 나머지를 제거하려면 다음과 같이 할 수 있습니다:

queryClient.setQueryData(['projects'], (data) => ({
  pages: data.pages.slice(0, 1),
  pageParams: data.pageParams.slice(0, 1),
}))
 
typescript

이 코드는 첫 번째 페이지와 해당 페이지 매개변수만 유지하고 나머지는 제거합니다.

주의할 점은 항상 pagespageParams의 데이터 구조를 일관되게 유지해야 한다는 것입니다. 이 구조를 변경하면 무한 쿼리의 동작에 문제가 생길 수 있습니다.

이러한 수동 업데이트 방법들을 사용하면 무한 쿼리의 데이터를 더 세밀하게 제어할 수 있습니다. 하지만 이 작업들을 수행할 때는 항상 데이터의 일관성을 유지하고 애플리케이션의 상태를 정확히 반영하도록 주의해야 합니다.

무한 쿼리에 관한 고찰

페이지 수를 제한하고 싶다면?

때로는 쿼리 데이터에 저장되는 페이지 수를 제한해야 할 필요가 있습니다. 이는 성능과 사용자 경험을 개선하는 데 도움이 됩니다. 특히 다음과 같은 상황에서 유용합니다:

  1. 사용자가 많은 페이지를 로드할 수 있을 때 (메모리 사용량 관리)
  2. 수십 개의 페이지가 포함된 무한 쿼리를 다시 가져와야 할 때 (네트워크 사용량 관리: 모든 페이지를 순차적으로 가져옴)

이 문제를 해결하기 위해 "제한된 무한 쿼리"를 사용할 수 있습니다. 이 방법은 maxPages 옵션을 getNextPageParamgetPreviousPageParam과 함께 사용하여 필요할 때 양방향으로 페이지를 가져올 수 있게 합니다.

다음 예제에서는 쿼리 데이터의 pages 배열에 오직 3개의 페이지만 유지됩니다. 다시 가져오기가 필요한 경우에도 3개의 페이지만 순차적으로 다시 가져옵니다.

useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: fetchProjects,
  initialPageParam: 0,
  getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
  getPreviousPageParam: (firstPage, pages) => firstPage.prevCursor,
  maxPages: 3,
})
 
typescript

API가 커서를 반환하지 않으면 어떻게 해야 할까?

API가 커서를 반환하지 않는 경우, pageParam을 커서로 사용할 수 있습니다. getNextPageParamgetPreviousPageParam은 현재 페이지의 pageParam도 받기 때문에, 이를 이용해 다음/이전 페이지 매개변수를 계산할 수 있습니다.

return useInfiniteQuery({
  queryKey: ['projects'],
  queryFn: fetchProjects,
  initialPageParam: 0,
  getNextPageParam: (lastPage, allPages, lastPageParam) => {
    if (lastPage.length === 0) {
      return undefined
    }
    return lastPageParam + 1
  },
  getPreviousPageParam: (firstPage, allPages, firstPageParam) => {
    if (firstPageParam <= 1) {
      return undefined
    }
    return firstPageParam - 1
  },
})
 
typescript

이 방법을 사용하면 API가 명시적인 커서를 제공하지 않더라도 페이지 기반 데이터를 효과적으로 관리할 수 있습니다. 페이지 번호를 커서처럼 사용하여 다음 페이지나 이전 페이지를 가져올 수 있게 됩니다.

이러한 접근 방식은 특히 API가 단순히 페이지 번호만을 사용하여 데이터를 제공하는 경우에 유용합니다. 개발자는 이를 통해 기존 API 구조를 크게 변경하지 않고도 무한 스크롤과 같은 기능을 구현할 수 있습니다.