Prefetching & Router Integration
데이터가 필요할 것으로 예상되거나 데이터가 필요할 것 같을 때, 사전에 캐시에 데이터를 미리 채워 넣는 '프리패칭(prefetching)'을 사용할 수 있습니다. 이렇게 하면 더 빠른 사용자 경험을 제공할 수 있습니다.
프리패칭에는 몇 가지 패턴이 있습니다:
- 이벤트 핸들러에서
- 컴포넌트에서
- 라우터 통합을 통해
- 서버 렌더링 중 (라우터 통합의 또 다른 형태)
이 가이드에서는 첫 번째부터 세 번째까지의 패턴을 살펴보겠습니다. 네 번째 패턴인 서버 렌더링 중의 프리패칭에 대해서는 서버 렌더링 및 하이드레이션 가이드와 고급 서버 렌더링 가이드에서 자세히 다룰 예정입니다.
프리패칭의 특정 용도 중 하나는 요청 폭포(Request Waterfalls)를 피하는 것입니다. 이에 대한 자세한 배경과 설명은 성능 및 요청 폭포 가이드를 참조해 주세요.
prefetchQuery & prefetchInfiniteQuery
다양한 프리패칭 패턴을 살펴보기 전에, prefetchQuery
와 prefetchInfiniteQuery
함수에 대해 기본적인 내용을 살펴보겠습니다.
- 기본적으로, 이 함수들은
queryClient
에 설정된 기본staleTime
을 사용하여 캐시된 데이터가 fresh한지 다시 가져와야 하는지를 결정합니다. - 특정
staleTime
을 전달할 수도 있습니다. 예를 들어:prefetchQuery({ queryKey: ['todos'], queryFn: fn, staleTime: 5000 })
- 이
staleTime
은 프리패칭에만 사용되며,useQuery
호출 시에도 별도로 설정해야 합니다. staleTime
을 무시하고 캐시에서 데이터가 항상 사용 가능하면 반환되도록 하려면,ensureQueryData
함수를 사용할 수 있습니다.- 팁: 서버에서 프리패칭을 수행할 때는, 각 프리패칭 호출에 대해 특정
staleTime
을 전달하는 대신queryClient
의 기본staleTime
을 0보다 큰 값으로 설정하는 것이 좋습니다.
- 이
- 프리패칭된 쿼리에 대한
useQuery
인스턴스가 없으면, 지정된gcTime
후에 삭제되어 가비지 컬렉션됩니다. - 이 함수들은
Promise<void>
를 반환하므로 쿼리 데이터를 반환하지 않습니다. 데이터가 필요한 경우fetchQuery
/fetchInfiniteQuery
를 대신 사용하세요. - 프리패칭 함수는 오류를 발생시키지 않습니다. 일반적으로
useQuery
에서 다시 가져오려고 시도하기 때문에, 오류를 포착할 필요가 있으면fetchQuery
/fetchInfiniteQuery
를 사용하세요.
다음은 prefetchQuery
를 사용하는 방법입니다:
const prefetchTodos = async () => {
// 이 쿼리의 결과는 일반 쿼리처럼 캐시됩니다
await queryClient.prefetchQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
});
};
무한 쿼리(Infinite Queries)도 일반 쿼리처럼 프리패칭할 수 있습니다. 기본적으로는 쿼리의 첫 페이지만 프리패칭되어 주어진 쿼리 키(QueryKey) 아래에 저장됩니다. 여러 페이지를 프리패칭하려면 pages
옵션을 사용해야 하며, 이 경우 getNextPageParam
함수도 제공해야 합니다.
다음은 무한 쿼리를 프리패칭하는 예시입니다:
const prefetchProjects = async () => {
// 이 쿼리의 결과는 일반 쿼리처럼 캐시됩니다
await queryClient.prefetchInfiniteQuery({
queryKey: ["projects"],
queryFn: fetchProjects,
initialPageParam: 0,
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
pages: 3, // 처음 3페이지를 프리패칭
});
};
이제 이러한 방식과 다른 상황에서의 프리패칭 방법을 살펴보겠습니다.
Prefetch in event handlers
사용자가 어떤 작업을 수행할 때 프리패칭을 하는 것은 매우 직관적인 방법입니다. 다음 예제에서는 queryClient.prefetchQuery
를 사용하여 onMouseEnter
또는 onFocus
이벤트가 발생할 때 프리패칭을 시작하는 방법을 보여줍니다:
function ShowDetailsButton() {
const queryClient = useQueryClient()
const prefetch = () => {
queryClient.prefetchQuery({
queryKey: ['details'],
queryFn: getDetailsData,
// 프리패칭은 데이터가 `staleTime`보다 오래된 경우에만 실행됩니다.
// 이 경우에는 `staleTime`을 설정하는 것이 좋습니다.
staleTime: 60000,
})
}
return (
<button onMouseEnter={prefetch} onFocus={prefetch} onClick={...}>
Show Details
</button>
)
}
Prefetch in components
컴포넌트 라이프사이클 동안 프리패칭을 사용하는 것은 자식 컴포넌트나 하위 컴포넌트가 특정 데이터를 필요로 하는 경우 유용합니다. 이 데이터가 다른 쿼리가 로드될 때까지 렌더링할 수 없는 경우가 있습니다. 다음은 Request Waterfall
가이드에서 빌린 예제입니다:
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,
})
// 프리패칭
useQuery({
queryKey: ['article-comments', id],
queryFn: getArticleCommentsById,
// 이 쿼리가 변경될 때 렌더링을 방지하기 위한 선택적 최적화:
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
를 사용하여 프리패칭을 할 수는 없습니다. 왜냐하면 프리패칭이 컴포넌트의 렌더링을 차단하기 때문입니다. 또한 useQuery
를 사용해서 프리패칭을 하면, Suspense 쿼리가 해결된 후에 프리패칭이 시작됩니다. 이런 상황에서는 라이브러리에서 제공하는 usePrefetchQuery
또는 usePrefetchInfiniteQuery
훅을 사용할 수 있습니다.
프리패칭은 컴포넌트에서 실제로 데이터를 필요로 하는 useSuspenseQuery
와 함께 사용할 수 있습니다. 나중에 이 컴포넌트를 <Suspense>
경계로 감싸서 우리가 프리패칭하는 "보조" 쿼리가 "주요" 데이터의 렌더링을 차단하지 않도록 할 수 있습니다.
function App() {
usePrefetchQuery({
queryKey: ["articles"],
queryFn: (...args) => {
return getArticles(...args);
},
});
return (
<Suspense fallback="Loading articles...">
<Articles />
</Suspense>
);
}
function Articles() {
const { data: articles } = useSuspenseQuery({
queryKey: ["articles"],
queryFn: (...args) => {
return getArticles(...args);
},
});
return articles.map((article) => (
<div key={article.id}>
<ArticleHeader article={article} />
<ArticleBody article={article} />
</div>
));
}
또 다른 방법은 쿼리 함수 안에서 프리패칭을 하는 것입니다. 이 방법은 매번 기사를 가져올 때 댓글도 필요할 가능성이 높다고 생각할 때 유용합니다. 이를 위해 queryClient.prefetchQuery
를 사용할 수 있습니다:
const queryClient = useQueryClient();
const { data: articleData, isPending } = useQuery({
queryKey: ["article", id],
queryFn: (...args) => {
queryClient.prefetchQuery({
queryKey: ["article-comments", id],
queryFn: getArticleCommentsById,
});
return getArticleById(...args);
},
});
useEffect Hook 에서 프리패칭을 하는 것도 가능하지만, 동일한 컴포넌트에서 useSuspenseQuery
를 사용하는 경우 이 효과는 쿼리가 끝난 후에야 실행되므로 원하는 결과가 아닐 수 있습니다.
const queryClient = useQueryClient();
useEffect(() => {
queryClient.prefetchQuery({
queryKey: ["article-comments", id],
queryFn: getArticleCommentsById,
});
}, [queryClient, id]);
정리하자면, 컴포넌트 라이프사이클 동안 쿼리를 프리패칭하려면 몇 가지 방법이 있으며, 상황에 맞는 방법을 선택할 수 있습니다:
usePrefetchQuery
또는usePrefetchInfiniteQuery
훅을 사용하여 Suspense 경계 전에 프리패칭하기useQuery
또는useSuspenseQueries
를 사용하되 결과를 무시하기- 쿼리 함수 안에서 프리패칭하기
- 효과 안에서 프리패칭하기
다음에는 조금 더 고급적인 경우를 살펴보겠습니다.
Dependent Queries & Code Splitting
때로는 다른 데이터를 기반으로 조건부로 프리패칭을 하고 싶을 때가 있습니다. 다음은 성능 및 요청 워터폴 가이드에서 가져온 예제입니다:
// 이 컴포넌트는 GraphFeedItem을 지연 로딩하므로,
// 실제로 렌더링되기 전까지 로딩이 시작되지 않습니다.
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. |> <GraphFeedItem>의 JS
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') {
queryClient.prefetchQuery({
queryKey: ['graph', feedItem.id],
queryFn: getGraphDataById,
})
}
}
return feed
}
})
...
}
이렇게 하면 코드와 데이터가 병렬로 로드됩니다:
1. |> getFeed()
2. |> <GraphFeedItem>의 JS
2. |> getGraphDataById()
하지만 여기에는 트레이드오프가 있습니다. getGraphDataById
의 코드가 이제 <GraphFeedItem>
의 JS 대신 부모 번들에 포함되므로, 경우에 따라 최적의 성능 트레이드오프를 결정해야 합니다. GraphFeedItem
이 자주 나타날 가능성이 있다면 부모 번들에 코드를 포함시키는 것이 좋을 수 있습니다. 반면에 매우 드물게 나타난다면 포함시키지 않는 것이 좋을 수 있습니다.
Router Integration
컴포넌트 트리에서 데이터를 가져오는 것은 요청 워터폴을 쉽게 초래할 수 있으며, 이러한 문제를 해결하기 위한 다양한 수정 방법이 어플리케이션 전반에 걸쳐 누적될 수 있습니다. 이러한 문제를 방지하기 위해 라우터 레벨에서 프리패칭을 통합하는 것이 매력적인 방법이 될 수 있습니다.
이 접근 방식에서는 각 라우트에 대해 해당 컴포넌트 트리에 필요한 데이터를 미리 선언합니다. 서버 렌더링은 전통적으로 렌더링이 시작되기 전에 모든 데이터를 로드해야 했기 때문에, SSR(서버 사이드 렌더링)된 앱에서 오랜 시간 동안 주요한 접근 방식이었습니다. 현재도 여전히 일반적인 접근 방식이며, 자세한 내용은 서버 렌더링 및 하이드레이션 가이드에서 확인할 수 있습니다.
지금은 클라이언트 측 사례에 집중하여 Tanstack Router (opens in a new tab)와 함께 이를 어떻게 구현할 수 있는지 예제를 살펴보겠습니다. 이 예제는 간결성을 유지하기 위해 많은 설정과 보일러플레이트를 생략하고 있으며, Tanstack Router 문서 (opens in a new tab)에서 전체 React Query 예제를 확인할 수 있습니다.
라우터 레벨에서 통합할 때, 데이터가 모두 준비될 때까지 해당 라우트의 렌더링을 차단하거나, 프리패칭을 시작하되 결과를 기다리지 않을 수 있습니다. 이렇게 하면 가능한 한 빨리 라우트 렌더링을 시작할 수 있습니다. 이 두 가지 접근 방식을 혼합하여 중요한 데이터를 기다리면서도 모든 보조 데이터가 로드되기 전에 렌더링을 시작할 수도 있습니다. 이 예제에서는 /article
라우트를 구성하여 기사 데이터가 로드될 때까지 렌더링을 차단하고, 댓글은 가능한 빨리 프리패칭하지만 댓글이 로드되지 않았더라도 라우트 렌더링을 차단하지 않도록 설정합니다.
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 },
}) => {
// 댓글은 가능한 빨리 프리패칭하되, 렌더링을 차단하지는 않습니다
queryClient.prefetchQuery(commentsQueryOptions)
// 기사가 로드될 때까지 라우트 렌더링을 차단합니다
await queryClient.prefetchQuery(articleQueryOptions)
},
component: ({ useRouteContext }) => {
const { articleQueryOptions, commentsQueryOptions } = useRouteContext()
const articleQuery = useQuery(articleQueryOptions)
const commentsQuery = useQuery(commentsQueryOptions)
return (
...
)
},
errorComponent: () => '오류 발생!',
})
다른 라우터와의 통합도 가능합니다. React Router 예제 (opens in a new tab)에서 또 다른 데모를 확인할 수 있습니다.
Manually Priming a Query
이미 쿼리에 필요한 데이터가 동기적으로 사용 가능한 경우, 프리패칭을 할 필요는 없습니다. 대신, 쿼리 클라이언트의 setQueryData
메서드를 사용하여 쿼리의 캐시된 결과를 키를 통해 직접 추가하거나 업데이트할 수 있습니다.
queryClient.setQueryData(["todos"], todos);
Further reading
쿼리 캐시에 데이터를 가져오는 방법에 대해 더 깊이 알아보고 싶다면, 커뮤니티 리소스의 #17: 쿼리 캐시 시딩을 참조하세요.
서버 사이드 라우터 및 프레임워크와의 통합은 우리가 방금 본 것과 매우 유사하지만, 데이터가 서버에서 클라이언트로 전달되어 클라이언트 측 캐시에 하이드레이션되어야 한다는 추가적인 과정이 필요합니다. 이를 배우려면 서버 렌더링 및 하이드레이션 가이드를 계속 읽어보세요.