본문으로 건너뛰기

무한 쿼리

기존 데이터 세트에 데이터를 추가로 "더 불러올" 수 있거나 "무한 스크롤"할 수 있는 목록을 렌더링하는 것 역시 매우 일반적인 UI 패턴입니다. TanStack Query는 이러한 유형의 목록을 쿼리하기 위해 useInfiniteQuery라고 하는 유용한 useQuery 버전을 지원합니다.

useInfiniteQuery를 사용하면 몇 가지 사항이 다르다는 것을 알 수 있습니다:

  • 이제 data는 무한 쿼리 데이터를 포함하는 객체입니다:
  • data.pages 가져온 페이지를 포함하는 배열
  • 페이지를 가져오는 데 사용된 페이지 매개변수를 포함하는 data.pageParams 배열
  • 이제 fetchNextPagefetchPreviousPage 함수를 사용할 수 있습니다(fetchNextPage가 필요합니다).
  • 이제 초기 페이지 매개변수를 지정하기 위해 initialPageParam 옵션을 사용할 수 있으며 필수입니다
  • getNextPageParamgetPreviousPageParam 옵션은 불러올 데이터가 더 있는지 판단하고 해당 데이터를 가져오는 데 필요한 정보를 결정하는 데 모두 사용할 수 있습니다. 이 정보는 쿼리 함수에 추가 매개변수로 제공됩니다
  • 이제 hasNextPage boolean을 사용할 수 있으며, getNextPageParamnull 또는 undefined 이외의 값을 반환하면 true입니다
  • 이제 hasPreviousPage boolean을 사용할 수 있으며, getPreviousPageParamnull 또는 undefined 이외의 값을 반환하면 true입니다
  • 이제 isFetchingNextPageisFetchingPreviousPage 불리언을 사용하여 백그라운드 새로 고침 상태와 추가 로딩 상태를 구분할 수 있습니다

참고: 옵션 initialData 또는 placeholderDatadata.pagesdata.pageParams 속성을 포함하는 동일한 객체 구조를 따라야 합니다.

예시

cursor 인덱스를 기반으로 projects를 한 번에 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: [...] }

이 정보를 사용하면 다음과 같이 "더 불러오기" 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>Loading...</p>
) : status === 'error' ? (
<p>Error: {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 || isFetching}
>
{isFetchingNextPage
? 'Loading more...'
: hasNextPage
? 'Load More'
: 'Nothing more to load'}
</button>
</div>
<div>{isFetching && !isFetchingNextPage ? 'Fetching...' : null}</div>
</>
)
}

가져오기가 진행 중일 때 fetchNextPage를 호출하면 백그라운드에서 이루어지는 데이터 새로 고침을 덮어쓸 위험이 있다는 점을 반드시 이해해야 합니다. 이 상황은 목록을 렌더링하는 동시에 fetchNextPage를 트리거할 때 특히 중요해집니다.

InfiniteQuery에는 진행 중인 가져오기가 하나만 존재할 수 있다는 점을 기억하세요. 모든 페이지가 단일 캐시 항목을 공유하므로 동시에 두 번 가져오려고 하면 데이터를 덮어쓸 수 있습니다.

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

충돌 없이 원활한 쿼리 프로세스를 보장하려면, 특히 사용자가 해당 호출을 직접 제어하지 않는 경우 쿼리가 isFetching 상태가 아닌지 확인하는 것이 매우 권장됩니다.

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

무한 쿼리를 다시 가져와야 할 때는 어떻게 되나요?

무한 쿼리가 stale 상태가 되어 다시 가져와야 할 때는 첫 번째 그룹부터 각 그룹을 sequentially 가져옵니다. 이를 통해 기반 데이터가 변경되더라도 stale 상태의 커서를 사용하여 중복 데이터를 가져오거나 레코드를 건너뛰는 일을 방지합니다. 무한 쿼리의 결과가 queryCache에서 제거되면 페이지네이션은 초기 그룹만 요청하는 초기 상태에서 다시 시작됩니다.

양방향 무한 목록을 구현하려면 어떻게 해야 하나요?

getPreviousPageParam, fetchPreviousPage, hasPreviousPageisFetchingPreviousPage 속성과 함수를 사용하여 양방향 목록을 구현할 수 있습니다.

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

페이지를 역순으로 표시하려면 어떻게 해야 하나요?

페이지를 역순으로 표시하려는 경우도 있습니다. 이 경우 select 옵션을 사용할 수 있습니다:

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

무한 쿼리를 수동으로 업데이트하려면 어떻게 해야 하나요?

첫 번째 페이지를 수동으로 제거하기:

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

개별 페이지에서 단일 값을 수동으로 제거하기:

const newPagesArray =
oldPagesArray?.pages.map((page) =>
page.filter((val) => val.id !== updatedId),
) ?? []

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

첫 페이지만 유지합니다:

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

항상 pages와 pageParams의 데이터 구조를 동일하게 유지해야 합니다!

페이지 수를 제한하려면 어떻게 해야 하나요?

일부 사용 사례에서는 성능과 UX를 개선하기 위해 쿼리 데이터에 저장되는 페이지 수를 제한할 수 있습니다:

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

해결책은 "제한된 무한 쿼리"를 사용하는 것입니다. maxPages 옵션을 getNextPageParamgetPreviousPageParam과 함께 사용하면 필요할 때 양방향으로 페이지를 가져올 수 있습니다.

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

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

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

추가 자료

Infinite Query가 내부적으로 작동하는 방식을 더 잘 이해하려면 Infinite Query의 작동 방식 문서를 참조하세요.