본문으로 건너뛰기

프리페치 및 Router 통합

특정 데이터가 필요할 것임을 알거나 예상하는 경우, 프리페치를 사용하여 해당 데이터로 캐시를 미리 채우면 더 빠른 사용 경험을 제공할 수 있습니다.

프리페치 패턴에는 몇 가지가 있습니다:

  1. 이벤트 핸들러에서
  2. 컴포넌트에서
  3. 라우터 통합을 통해
  4. 서버 렌더링 중(라우터 통합의 또 다른 형태)

이 가이드에서는 처음 세 가지를 살펴보며, 네 번째는 서버 렌더링 및 하이드레이션 가이드고급 서버 렌더링 가이드에서 자세히 다룹니다.

프리페치의 한 가지 구체적인 용도는 요청 워터폴을 방지하는 것이며, 이에 관한 심층적인 배경과 설명은 성능 및 요청 워터폴 가이드를 참조하세요.

query를 사용하여 프리페치하기

[!NOTE] 이 팁은 이제 더 이상 사용되지 않는 prefetchQueryensureQueryData 메서드의 사용을 대체합니다. 이 가이드의 이전 버전을 사용했다면 해당 메서드가 TanStack Query의 다음 메이저 버전에서 제거된다는 점에 유의하세요

쿼리 프리페치에는 query 메서드를 사용합니다. 이 메서드는 기본적으로

  • 쿼리 함수 실행하기
  • 결과 캐싱하기
  • 해당 쿼리의 결과를 반환합니다.
  • 오류가 하나라도 발생하면 오류를 발생시킵니다

프리페치할 때는 보통 다음 기본값을 수정해야 합니다:

  • query는 기본적으로 캐시의 기존 데이터가 최신 상태인지 또는 다시 가져와야 하는지를 판단하기 위해 queryClient에 구성된 기본 staleTime을 사용합니다
  • 다음과 같이 특정 staleTime을 전달할 수도 있습니다: query({ queryKey: ['todos'], queryFn: fn, staleTime: 5000 })
    • staleTime은 해당 쿼리 가져오기에만 사용되며, 모든 useQuery 호출에도 이를 설정해야 합니다
    • 기본 staleTime과 관계없이 캐시에 데이터가 있으면 항상 반환하려면 staleTime"static"을 전달할 수 있습니다.
    • 팁: 서버에서 프리페치하는 경우 각 프리페치 호출에 특정 staleTime을 전달하지 않아도 되도록 해당 queryClient의 기본 staleTime0보다 높게 설정합니다.
  • 프리페치된 쿼리에 useQuery 인스턴스가 나타나지 않으면 gcTime에 지정된 시간이 지난 후 삭제되고 가비지 컬렉션됩니다
  • 프리페치가 중요하지 않은 데이터를 위한 것이라면 void로 Promise를 폐기하고 .catch(noop)을 사용하여 오류를 무시할 수 있습니다. 쿼리는 일반적으로 useQuery에서 다시 가져오기를 시도하므로 적절하고 안정적인 대체 수단이 됩니다.

query를 사용하여 프리페치하는 방법은 다음과 같습니다:

import { noop } from '@tanstack/react-query'

const prefetchTodos = async () => {
await queryClient
.query({
queryKey: ['todos'],
queryFn: fetchTodos,
// Swallow errors here, because usually they will fetch again in `useQuery`
})
.catch(noop)
}

Infinite Queries는 일반 Queries처럼 프리페치할 수 있습니다. 기본적으로 Query의 첫 페이지만 프리페치되어 지정된 QueryKey 아래에 저장됩니다. 둘 이상의 페이지를 프리페치하려면 pages 옵션을 사용할 수 있으며, 이 경우 getNextPageParam 함수도 제공해야 합니다:

import { noop } from '@tanstack/react-query'

const prefetchProjects = () => {
await queryClient
.infiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
initialPageParam: 0,
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
pages: 3, // prefetch the first 3 pages
})
.catch(noop)
}

다음으로, 다양한 상황에서 이를 사용하는 방법과 그 밖의 프리페치 방법을 살펴봅니다.

이벤트 핸들러에서 프리페치하기

프리페치의 간단한 형태는 사용자가 무언가와 상호작용할 때 수행하는 것입니다. 이 예제에서는 onMouseEnter 또는 onFocus에서 프리페치를 시작하기 위해 queryClient.query를 사용합니다.

function ShowDetailsButton() {
const queryClient = useQueryClient()

const prefetch = () => {
void queryClient.query({
queryKey: ['details'],
queryFn: getDetailsData,
// Prefetch only fires when data is older than the staleTime,
// so in a case like this you definitely want to set one
staleTime: 60000,
}).catch(noop)
}

return (
<button onMouseEnter={prefetch} onFocus={prefetch} onClick={...}>
Show Details
</button>
)
}

컴포넌트에서 프리페치하기

컴포넌트 생명주기 중 프리페치는 특정 자식 또는 하위 컴포넌트에 특정 데이터가 필요하다는 것을 알고 있지만 다른 쿼리의 로딩이 완료될 때까지 해당 컴포넌트를 렌더링할 수 없을 때 유용합니다. 이를 설명하기 위해 요청 워터폴 가이드의 예제를 가져오겠습니다:

function Article({ id }) {
const { data: articleData, isPending } = useQuery({
queryKey: ['article', id],
queryFn: getArticleById,
})

if (isPending) {
return 'Loading article...'
}

return (
<>
<ArticleHeader articleData={articleData} />
<ArticleBody articleData={articleData} />
<Comments id={id} />
</>
)
}

function Comments({ id }) {
const { data, isPending } = useQuery({
queryKey: ['article-comments', id],
queryFn: getArticleCommentsById,
})

...
}

그러면 다음과 같은 요청 워터폴이 발생합니다:

1. |> getArticleById()
2. |> getArticleCommentsById()

해당 가이드에서 언급했듯이, 이 워터폴을 완화하고 성능을 개선하는 한 가지 방법은 getArticleCommentsById 쿼리를 부모로 끌어올리고 결과를 prop으로 전달하는 것입니다. 하지만 예를 들어 컴포넌트가 서로 관련이 없고 그 사이에 여러 계층이 있어 이 방법이 실현 가능하지 않거나 바람직하지 않다면 어떻게 해야 할까요?

이 경우에는 대신 부모에서 쿼리를 프리페치할 수 있습니다. 가장 간단한 방법은 쿼리를 사용하되 결과를 무시하는 것입니다:

function Article({ id }) {
const { data: articleData, isPending } = useQuery({
queryKey: ['article', id],
queryFn: getArticleById,
})

// Prefetch
useQuery({
queryKey: ['article-comments', id],
queryFn: getArticleCommentsById,
// Optional optimization to avoid rerenders when this query changes:
notifyOnChangeProps: [],
})

if (isPending) {
return 'Loading article...'
}

return (
<>
<ArticleHeader articleData={articleData} />
<ArticleBody articleData={articleData} />
<Comments id={id} />
</>
)
}

function Comments({ id }) {
const { data, isPending } = useQuery({
queryKey: ['article-comments', id],
queryFn: getArticleCommentsById,
})

...
}

이는 'article-comments' 가져오기를 즉시 시작하고 워터폴을 평탄화합니다:

1. |> getArticleById()
1. |> getArticleCommentsById()

Suspense와 함께 프리페치하려면 조금 다른 방식으로 처리해야 합니다. 프리페치가 컴포넌트의 렌더링을 차단하므로 useSuspenseQueries를 사용하여 프리페치할 수 없습니다. 또한 Suspense 쿼리가 이행된 후에야 프리페치가 시작되므로 useQuery를 프리페치에 사용할 수도 없습니다. 이 시나리오에서는 라이브러리에서 제공하는 usePrefetchQuery 또는 usePrefetchInfiniteQuery 훅을 사용할 수 있습니다.

이제 실제로 데이터가 필요한 컴포넌트에서 useSuspenseQuery를 사용할 수 있습니다. 프리페치하는 "보조" 쿼리가 "기본" 데이터의 렌더링을 차단하지 않도록, 이 후속 컴포넌트를 자체 <Suspense> 경계로 감싸는 것이 좋을 수도 있습니다.

function ArticleLayout({ id }) {
usePrefetchQuery({
queryKey: ['article-comments', id],
queryFn: getArticleCommentsById,
})

return (
<Suspense fallback="Loading article">
<Article id={id} />
</Suspense>
)
}

function Article({ id }) {
const { data: articleData, isPending } = useSuspenseQuery({
queryKey: ['article', id],
queryFn: getArticleById,
})

...
}

또 다른 방법은 쿼리 함수 내부에서 프리페치하는 것입니다. 문서를 가져올 때마다 댓글도 필요할 가능성이 매우 높다는 것을 알고 있다면 이 방법이 적합합니다. 이를 위해 queryClient.query를 사용합니다:

const queryClient = useQueryClient()
const { data: articleData, isPending } = useQuery({
queryKey: ['article', id],
queryFn: (...args) => {
void queryClient
.query({
queryKey: ['article-comments', id],
queryFn: getArticleCommentsById,
})
.catch(noop)

return getArticleById(...args)
},
})

effect에서 프리페치하는 것도 가능하지만, 같은 컴포넌트에서 useSuspenseQuery를 사용한다면 이 effect는 쿼리가 완료된 후에 실행되므로 원하는 동작이 아닐 수 있다는 점에 유의하세요.

const queryClient = useQueryClient()

useEffect(() => {
void queryClient
.query({
queryKey: ['article-comments', id],
queryFn: getArticleCommentsById,
})
.catch(noop)
}, [queryClient, id])

요약하면, 컴포넌트 수명 주기 중에 쿼리를 프리페치하려는 경우 몇 가지 방법이 있으며, 상황에 가장 적합한 방법을 선택하면 됩니다:

  • usePrefetchQuery 또는 usePrefetchInfiniteQuery 훅을 사용하여 suspense boundary 전에 프리페치합니다
  • useQuery 또는 useSuspenseQueries를 사용하고 결과를 무시합니다
  • 쿼리 함수 내부에서 프리페치하기
  • effect에서 프리페치

다음으로 조금 더 고급 사례를 살펴보겠습니다.

종속 쿼리 및 코드 분할

때로는 다른 가져오기 결과에 따라 조건부로 프리페치하려고 합니다. 성능 및 요청 워터폴 가이드에서 가져온 다음 예시를 살펴보세요:

// This lazy loads the GraphFeedItem component, meaning
// it won't start loading until something renders it
const GraphFeedItem = React.lazy(() => import('./GraphFeedItem'))

function Feed() {
const { data, isPending } = useQuery({
queryKey: ['feed'],
queryFn: getFeed,
})

if (isPending) {
return 'Loading feed...'
}

return (
<>
{data.map((feedItem) => {
if (feedItem.type === 'GRAPH') {
return <GraphFeedItem key={feedItem.id} feedItem={feedItem} />
}

return <StandardFeedItem key={feedItem.id} feedItem={feedItem} />
})}
</>
)
}

// GraphFeedItem.tsx
function GraphFeedItem({ feedItem }) {
const { data, isPending } = useQuery({
queryKey: ['graph', feedItem.id],
queryFn: getGraphDataById,
})

...
}

해당 가이드에서 언급했듯이 이 예제는 다음과 같은 이중 요청 워터폴로 이어집니다:

1. |> getFeed()
2. |> JS for <GraphFeedItem>
3. |> getGraphDataById()

필요할 때 getFeed()getGraphDataById() 데이터를 반환하도록 API를 재구성할 수 없다면 getFeed->getGraphDataById 워터폴을 제거할 방법은 없지만, 조건부 프리페치를 활용하면 최소한 코드와 데이터를 병렬로 불러올 수 있습니다. 위에서 설명한 것처럼 이를 수행하는 방법은 여러 가지이지만, 이 예제에서는 쿼리 함수에서 수행합니다:

function Feed() {
const queryClient = useQueryClient()
const { data, isPending } = useQuery({
queryKey: ['feed'],
queryFn: async (...args) => {
const feed = await getFeed(...args)

for (const feedItem of feed) {
if (feedItem.type === 'GRAPH') {
void queryClient.query({
queryKey: ['graph', feedItem.id],
queryFn: getGraphDataById,
}).catch(noop)
}
}

return feed
}
})

...
}

이렇게 하면 코드와 데이터를 병렬로 로드합니다:

1. |> getFeed()
2. |> JS for <GraphFeedItem>
2. |> getGraphDataById()

하지만 getGraphDataById의 코드가 이제 JS for <GraphFeedItem> 대신 상위 번들에 포함된다는 절충점이 있으므로, 사례별로 어떤 성능 절충안이 가장 적합한지 판단해야 합니다. GraphFeedItem의 가능성이 높다면 상위 번들에 코드를 포함할 가치가 있을 것입니다. 극히 드물다면 그렇지 않을 것입니다.

라우터 통합

컴포넌트 트리 자체에서 데이터를 가져오면 요청 폭포 현상이 쉽게 발생할 수 있고 이를 해결하는 여러 방법도 애플리케이션 전체에 누적되면서 번거로워질 수 있으므로, 라우터 수준에서 통합하는 것이 프리페치를 구현하는 매력적인 방법입니다.

이 접근 방식에서는 해당 컴포넌트 트리에 어떤 데이터가 필요한지 각 _route_별로 미리 명시적으로 선언합니다. 전통적으로 Server Rendering은 렌더링을 시작하기 전에 모든 데이터를 불러와야 했기 때문에, 오랫동안 SSR된 앱에서는 이 방식이 지배적이었습니다. 이 방식은 여전히 일반적이며 Server Rendering 및 Hydration 가이드에서 자세히 알아볼 수 있습니다.

지금은 클라이언트 측 사례에 집중하여 TanStack Router로 이를 구현하는 방법의 예제를 살펴보겠습니다. 이 예제들은 간결함을 유지하기 위해 많은 설정과 상용구를 생략하며, TanStack Router 문서에서 전체 React Query 예제를 확인할 수 있습니다.

라우터 수준에서 통합할 때는 모든 데이터가 준비될 때까지 해당 경로의 렌더링을 _차단_하거나, 프리페치를 시작하되 결과를 기다리지 않도록 선택할 수 있습니다. 그러면 가능한 한 빨리 경로 렌더링을 시작할 수 있습니다. 이 두 접근 방식을 혼합하여 일부 핵심 데이터는 기다리되, 모든 보조 데이터의 로딩이 완료되기 전에 렌더링을 시작할 수도 있습니다. 이 예제에서는 글 데이터의 로딩이 완료될 때까지 렌더링하지 않도록 /article 경로를 구성하고, 댓글 프리페치는 가능한 한 빨리 시작하되 댓글 로딩이 아직 완료되지 않았더라도 경로 렌더링을 차단하지 않도록 구성합니다.

많은 route loader가 error boundary를 사용하여 오류 fallback을 트리거한다는 점에 유의하세요. 지금까지는 useQuery에서 재시도할 데이터의 오류를 무시하기 위해 .catch(noop)를 사용했지만, 없으면 route가 작동하지 않는 중요한 데이터의 경우에는 noop 없이 Promise를 await하고 try 블록이나 router의 오류 처리(예: TanStack Router의 errorComponent)에서 오류를 처리해야 합니다.

const queryClient = new QueryClient()
const routerContext = new RouterContext()
const rootRoute = routerContext.createRootRoute({
component: () => { ... }
})

const articleRoute = new Route({
getParentRoute: () => rootRoute,
path: 'article',
beforeLoad: () => {
return {
articleQueryOptions: { queryKey: ['article'], queryFn: fetchArticle },
commentsQueryOptions: { queryKey: ['comments'], queryFn: fetchComments },
}
},
loader: async ({
context: { queryClient },
routeContext: { articleQueryOptions, commentsQueryOptions },
}) => {
// Fetch comments asap, but don't block or throw errors
void queryClient.query(commentsQueryOptions).catch(noop)

// Don't render the route at all until article has been fetched
// As this is critical data we want the error component to trigger
// as soon as possible if something goes wrong
await queryClient.query({
...articleQueryOptions,
// If we have the article loaded already, we don't want to block on
// an extra prefetch; fallback on the default useQuery behavior to
// keep the data fresh
staleTime: 'static'
})
},
component: ({ useRouteContext }) => {
const { articleQueryOptions, commentsQueryOptions } = useRouteContext()
const articleQuery = useQuery(articleQueryOptions)
const commentsQuery = useQuery(commentsQueryOptions)

return (
...
)
},
errorComponent: () => 'Oh crap!',
})

다른 라우터와의 통합도 가능합니다. 또 다른 예시는 react-router를 참조하세요.

수동으로 쿼리 미리 채우기

쿼리에 사용할 데이터를 이미 동기적으로 이용할 수 있다면 프리페치할 필요가 없습니다. Query Client의 setQueryData 메서드를 사용하여 키를 기준으로 쿼리의 캐시된 결과를 직접 추가하거나 업데이트하면 됩니다.

queryClient.setQueryData(['todos'], todos)

추가 자료

가져오기를 수행하기 전에 Query Cache에 데이터를 넣는 방법을 자세히 알아보려면 TkDodo의 Query Cache 시딩 문서를 참조하세요.

서버 측 라우터 및 프레임워크와의 통합은 방금 살펴본 내용과 매우 유사하지만, 서버의 데이터를 클라이언트로 전달하여 그곳의 캐시에 하이드레이션해야 한다는 점이 추가됩니다. 방법을 알아보려면 서버 렌더링 및 하이드레이션 가이드를 계속 읽어보세요.