🔥 미리 가져오기(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: 쿼리 캐시 시딩하기를 살펴보세요.
서버 사이드 라우터 및 프레임워크와 통합하는 것은 방금 본 내용과 매우 유사합니다. 추가로 서버에서 클라이언트로 데이터를 전달해서 클라이언트의 캐시에 하이드레이션해야 합니다. 자세한 내용은 서버 렌더링과 하이드레이션 가이드에서 계속 알아보세요.