🔥 미리 가져오기(prefetching)와 라우터 통합
강의 목차
특정 데이터가 필요할 거라 예상될 때는 프리페칭을 통해 미리 캐시에 데이터를 채울 수 있습니다. 이렇게 하면 사용자 경험이 더 빨라지죠.
프리페칭에는 몇 가지 패턴이 있습니다.
- 이벤트 핸들러에서 프리페칭
- 컴포넌트에서 프리페칭
- 라우터와 통합해서 프리페칭
- 서버 렌더링 중에 프리페칭(라우터 통합의 또 다른 형태)
이 가이드에서는 첫 번째부터 세 번째까지 살펴봅니다. 네 번째는 서버 렌더링과 하이드레이션 가이드와 고급 서버 렌더링 가이드에서 자세히 다룰 거예요.
프리페칭의 한 가지 용도는 요청 폭포수(Request Waterfalls)를 방지하는 것입니다. 요청 폭포수에 대한 자세한 설명은 성능과 요청 폭포수 가이드를 참고하세요.
prefetchQuery와 prefetchInfiniteQuery
프리페칭 패턴을 자세히 살펴보기 전에 prefetchQuery와 prefetchInfiniteQuery 함수에 대해 알아봅시다. 먼저 기본적인 사항 몇 가지를 보겠습니다.
- 기본적으로 이 함수들은
queryClient에 설정된 기본staleTime을 사용해서 캐시의 기존 데이터가 신선한지, 아니면 다시 가져와야 하는지 판단합니다. prefetchQuery({ queryKey: ['todos'], queryFn: fn, staleTime: 5000 })처럼 특정staleTime을 전달할 수도 있습니다.- 이
staleTime은 프리페치에만 사용되므로,useQuery호출에도staleTime을 설정해야 합니다. 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, }) }typescript
무한 쿼리(Infinite Queries)도 일반 쿼리처럼 프리페치할 수 있습니다. 기본적으로는 쿼리의 첫 번째 페이지만 프리페치되고 주어진 쿼리 키에 저장됩니다. 한 페이지 이상을 프리페치하려면 pages 옵션을 사용할 수 있는데, 이때는 getNextPageParam 함수도 제공해야 합니다.
const prefetchProjects = async () => { // 이 쿼리의 결과는 일반 쿼리처럼 캐시됩니다. await queryClient.prefetchInfiniteQuery({ queryKey: ['projects'], queryFn: fetchProjects, initialPageParam: 0, getNextPageParam: (lastPage, pages) => lastPage.nextCursor, pages: 3, // 첫 3페이지를 프리페치합니다. }) }typescript
이제 이 함수들을 사용해서 여러 상황에서 어떻게 프리페치할 수 있는지 살펴봅시다.
이벤트 핸들러에서 프리페칭
가장 직관적인 프리페칭 방식은 사용자가 무언가와 상호작용할 때 프리페칭을 하는 것입니다. 이 예제에서는 onMouseEnter나 onFocus에서 queryClient.prefetchQuery를 사용해 프리페치를 시작합니다.
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> ) }typescript
컴포넌트에서 프리페칭
컴포넌트 생명주기 동안 프리페칭을 하는 것은 자식이나 자손 컴포넌트에서 특정 데이터가 필요할 것이라는 걸 알지만, 다른 쿼리가 로딩을 완료할 때까지 그 자식 컴포넌트를 렌더링할 수 없을 때 유용합니다. 요청 폭포수 가이드에서 가져온 예제로 설명해 보겠습니다.
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, }) ... }typescript
이 코드는 다음과 같은 요청 폭포수를 만듭니다.
1. |> getArticleById() 2. |> getArticleCommentsById()text
그 가이드에서 언급했듯이, 이런 폭포수를 평평하게 만들고 성능을 개선하는 한 가지 방법은 getArticleCommentsById 쿼리를 부모로 끌어올려서 그 결과를 프로퍼티로 전달하는 것입니다. 하지만 컴포넌트들이 서로 관련이 없고 그 사이에 여러 단계가 있는 경우처럼 이게 불가능하거나 바람직하지 않다면요?
그런 경우에는 부모에서 쿼리를 프리페치할 수 있습니다. 가장 간단한 방법은 쿼리를 사용하되 그 결과는 무시하는 것이죠.
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, }) ... }typescript
이렇게 하면 'article-comments' 쿼리를 즉시 가져오기 시작하고 폭포수가 평평해집니다.
1. |> getArticleById() 1. |> getArticleCommentsById()text
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={articleData.id}> <ArticleHeader article={article} /> <ArticleBody article={article} /> </div> )) }typescript
쿼리 함수 내에서 프리페치하는 방법도 있습니다. 기사를 가져올 때마다 댓글도 필요할 가능성이 높다는 걸 알고 있다면 이 방법이 적절하겠죠. 여기서는 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) }, })typescript
이펙트에서 프리페칭하는 것도 가능하지만, 같은 컴포넌트에서 useSuspenseQuery를 사용하고 있다면 이 이펙트는 쿼리가 완료된 후에야 실행된다는 점에 주의하세요. 원하는 동작이 아닐 수 있습니다.
const queryClient = useQueryClient() useEffect(() => { queryClient.prefetchQuery({ queryKey: ['article-comments', id], queryFn: getArticleCommentsById, }) }, [queryClient, id])typescript
요약하자면, 컴포넌트 생명주기 동안 쿼리를 프리페치하는 방법에는 여러 가지가 있습니다. 상황에 맞는 방법을 선택하세요.
- Suspense 경계 이전에
usePrefetchQuery나usePrefetchInfiniteQuery훅을 사용해서 프리페칭 useQuery나useSuspenseQueries를 사용하되 결과는 무시- 쿼리 함수 내에서 프리페칭
- 이펙트에서 프리페칭
다음으로 좀 더 고급 사례를 살펴보겠습니다.
의존성 있는 쿼리와 코드 분할
때로는 다른 데이터 가져오기의 결과에 따라 조건부로 프리페칭하고 싶을 때가 있습니다. 성능과 요청 폭포수 가이드에서 빌려온 예제로 설명해 보겠습니다.
// 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, }) ... }typescript
이 예제는 다음과 같은 이중 요청 폭포수를 만듭니다.
1. |> getFeed() 2. |> <GraphFeedItem>의 JS 3. |> getGraphDataById()text
그 가이드에서 설명했듯이, 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 } }) ... }typescript
이렇게 하면 코드와 데이터가 병렬로 로드됩니다.
1. |> getFeed() 2. |> <GraphFeedItem>의 JS 2. |> getGraphDataById()text
하지만 getGraphDataById의 코드가 이제 부모 번들에 포함되므로 <GraphFeedItem>의 JS에 들어가지 않습니다. 따라서 상황에 맞게 어떤 성능 절충안이 최선인지 판단해야 합니다. GraphFeedItem이 자주 나온다면 부모에 코드를 포함하는 게 좋겠지만, 매우 드물게 나온다면 그렇지 않을 수도 있습니다.
라우터 통합
컴포넌트 트리 자체에서 데이터를 가져오면 요청 폭포수가 쉽게 발생하고, 이를 해결하기 위한 여러 방법이 애플리케이션 전체에 누적되면서 복잡해질 수 있습니다. 이런 문제를 피하기 위한 매력적인 방법은 라우터 수준에서 프리페칭을 통합하는 것입니다.
이 접근 방식에서는 각 경로에 필요한 데이터를 미리 명시적으로 선언합니다. 서버 렌더링에서는 전통적으로 렌더링이 시작되기 전에 모든 데이터를 로드해야 했기 때문에, SSR 앱에서 오랫동안 지배적인 접근 방식이었죠. 여전히 일반적인 방식이며, 서버 렌더링과 하이드레이션 가이드에서 더 자세히 읽어볼 수 있습니다.
지금은 클라이언트 사이드 케이스에 초점을 맞추고, Tanstack Router로 이를 어떻게 구현할 수 있는지 보겠습니다. 이 예제는 간결하게 유지하기 위해 많은 설정과 상용구를 생략했습니다. Tanstack Router 문서에서 전체 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: () => 'Oh crap!', })typescript
다른 라우터와 통합하는 것도 가능합니다. React Router 예제에서 또 다른 데모를 확인해 보세요.
수동으로 쿼리 프라이밍(Priming)하기
이미 쿼리에 필요한 데이터가 동기적으로 사용 가능하다면 프리페칭할 필요가 없습니다. 쿼리 클라이언트의 setQueryData 메서드를 사용해서 키로 쿼리의 캐시된 결과를 직접 추가하거나 업데이트하면 됩니다.
queryClient.setQueryData(['todos'], todos)typescript
더 읽을거리
쿼리 캐시에 데이터를 가져오기 전에 채우는 방법에 대해 자세히 알아보려면 커뮤니티 리소스의 #17: 쿼리 캐시 시딩하기를 살펴보세요.
서버 사이드 라우터 및 프레임워크와 통합하는 것은 방금 본 내용과 매우 유사합니다. 추가로 서버에서 클라이언트로 데이터를 전달해서 클라이언트의 캐시에 하이드레이션해야 합니다. 자세한 내용은 서버 렌더링과 하이드레이션 가이드에서 계속 알아보세요.