특정 데이터가 필요할 것으로 예상되는 경우 Prefetching을 사용해 캐시를 해당 데이터로 미리 채우면 더 빠른 환경을 제공할 수 있다.
몇 가지 프리페칭 패턴들이 있다:
1. 이벤트 핸들러 내
2. 컴포넌트 내
3. 라우터 통합
4. 서버렌더링 중 (라우터 통합의 또 다른 형태)
이 가이드에서는 3번까지 알아보고 4번은 서버 렌더링& 하이드레이션 가이드와 서버렌더링 최적화 가이드 에서 다룰 예정이다.
특정 프리페칭 패턴들을 보기 전에, prefetchQuery와 prefetchInfiniteQuery 함수를 살펴보자.
이 함수들은 데이터를 서버에서 가져와 로컬 캐시에 저장하여, 나중에 컴포넌트에서 이 데이터를 사용할 때 빠르게 접근할 수 있게 한다.
<함수 관련 기본 사항>
1. staleTime과 캐시 관리
prefetchQuery와 같은 함수는 queryClient에 구성된 기본 staleTime을 사용해 캐시의 기존 데이터가 최신인지(fresh) 또는 다시 가져와야 하는지 여부를 결정한다.staleTime을 직접 설정 가능: prefetchQuery({queryKey:['todos'], queryFn: fn, staleTime: 500})staleTime은 프리페칭할 때만 사용되며, 모든 useQuery 호출에도 설정해야 한다.ensureQueryData는 캐시에 있는 데이터를 우선적으로 반환하도록 설정할 수 있는 함수다. 이 함수는 staleTime 설정을 무시하고, 항상 캐시에 있는 데이터를 우선적으로 반환하도록 설정할 수 있다.
staleTime: 데이터가fresh->stale상태로 변경되는데 걸리는 시간. 데이터를 fresh 상태로 유지하는 시간. 이 시간 동안 캐시에 있는 데이터는 유효한 것으로 간주. 데이터가 한번 페치된 후 staleTime이 지나지 않았다면 unmount 후 mount 되어도 fetch가 일어나지 않는다.
2. 서버 사이드 렌더링(SSR)에서의 프리패칭
staleTime을 설정해주지 않으면 기본적으로 queryCLient에 설정된 staleTime이 적용된다.staleTime을 적용하려면 queryClient 의 기본 staleTime을 0보다 크게 설정하자. 3. 프리패치된 쿼리의 가비지 콜렉션
prefetchQuery로 데이터를 미리 가져왔는데 이를 사용하는 useQuery가 존재하지 않으면, 이 쿼리는 일정 시간이 지나면 삭제되거나 gcTime(가비지 콜렉션)에 의해 메모리에서 제거된다.prefetchQuery와 prefetchInfiniteQuery는 Promise<void>를 반환한다. 즉, 이 함수들은 query 데이터를 반환하지 않는다. 만약 데이터 반환이 필요한거라면 fetchQuery/ fetchInfiniteQuery를 대신 사용해야 한다.useQuery로 패치를 다시 시도하기 때문이다. 만약 에러를 잡아야 한다면 fetchQuery/fetchInfiniteQuery를 사용하자.prefetchQuery 사용 예시:
const prefetchTodos = async () => {
// The results of this query will be cached like a normal query
await queryClient.prefetchQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
})
}
prefetchInfiniteQuery 사용예시:
const prefetchProjects = async () => {
// The results of this query will be cached like a normal query
await queryClient.prefetchInfiniteQuery({
queryKey: ['projects'],
queryFn: fetchProjects,
initialPageParam: 0,
getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
pages: 3, // prefetch the first 3 pages
})
}
Inifinit 쿼리도 일반 쿼리처럼 미리 가져올 수 있다. 기본적으로 쿼리의 첫 페이지만 프리페치되며 지정된 QueryKey에 저장된다. 만약 한 페이지 이상 프리페치하려면 pages 옵션을 사용해야 하며, 이 경우 getNextPageParam함수도 제공해야 한다.
가장 직관적인 프리패칭의 형태는 유저가 어떤 것과 상호작용했을 때 프리패칭하는 것이다. 이 예시에서는 queryClient.prefetchQuery를 사용해 onMouseEnter이나 onFocus 시 프리패치를 시작하도록 한다.
function ShowDetailsButton() {
const queryClient = useQueryClient()
const prefetch = () => {
queryClient.prefetchQuery({
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,
})
}
return (
<button onMouseEnter={prefetch} onFocus={prefetch} onClick={...}>
Show Details
</button>
)
}
자식 또는 자손 컴포넌트가 특정 데이터가 필요할 때, 컴포넌트 라이프사이클에서 프리패칭하면 유용하다. 하지만 다른 쿼리가 로딩을 마치기 전까지 우리는 렌더링하지 못한다.
이를 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()
이와 같은 request watrerfall을 보인다. 이 waterfall을 평평하게 만들기 위해서는 getArticleCommentsById를 부모로 호이스팅하고 결과를 prop으로 넘겨주는 것이다.
근데 만약 현실에선 컴포넌트들이 관련되어 있지않고 여러 단계가 사이에 껴있다면 어떻게 할까? (데이터를 바로 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는 즉시 패칭되고 waterfall을 평탄화시킨다.
1. |> getArticleById()
1. |> getArticleCommentsById()
만약 prefetch와 함께 Suspense를 사용하고 싶을 때
useSusepenseQuries를 프리패치하는데 쓸 수 없다. 프리패칭 과정이 완료될 때까지 컴포넌트가 렌더링되지 않기 때문이다.useQuery를 프리패치로 쓸 수 없다 왜냐면 Suspense가 끝나기 전까지 프래패칭이 지연되기 때문에 원하는 시점에 프리패칭을 시작할 수 없다. usePrefetchQuery 또는 usePrefetchInfiniteQuery를 쓸 수 있다.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>
))
}
usePrefetchQuery를 사용해 articles 데이터를 미리 가져온다.articles에 데이터가 미리 캐시에 저장되어, 이후에 이 데이터를 사용하는 컴포넌트가 빠르게 접근할 수 있다.Suspense를 사용해 <Articles/> 컴포넌트를 감싼다.Articles 컴포넌트는 useSuspenseQuery를 사용해 articles데이터를 가져온다.usePrefetchQuery로 미리 가져왔기에 캐시에서 즉시 사용 가능하여 렌더링이 빠르게 이뤄진다.또 다른 방법은 query function 내에 프리패칭하는 것이다. 이 방식은 특정 데이터를 가져올 때, 그 데이터와 관련된 다른 데이터를 미리 가져오는 것이 유리할 때 사용된다. 예를 들어 article를 가져올 때 그와 관련한 comments도 자주 필요하다면 articles를 가져올 때 comments도 프리패칭할 수 있다. 이때 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 내에서도 프리패칭을 사용할 수 있다. 하지만 useSuspenseQuery가 같은 컴포넌트 내에 있다면 쿼리가 끝날 때까지 이펙트가 실행되지 않을 수 있다.
const queryClient = useQueryClient()
useEffect(() => {
queryClient.prefetchQuery({
queryKey: ['article-comments', id],
queryFn: getArticleCommentsById,
})
}, [queryClient, id])
컴포넌트 라이프사이클 동안 프리패치하고 싶다면 몇 가지 방법이 있다.
| 프리패칭 방법 | 설명 | 적합한 상황 |
|---|---|---|
| Suspense 경계 전에 프리패칭 | usePrefetchQuery 또는 usePrefetchInfiniteQuery 훅을 사용하여, Suspense 경계 전에 데이터를 미리 가져온다. | 컴포넌트가 렌더링되기 전에 데이터가 필요하고, 이 데이터가 렌더링에 영향을 주지 않도록 하려는 경우에 적합. |
useQuery/useSuspenseQueries 사용 | useQuery나 useSuspenseQueries를 사용하여 데이터를 가져오지만, 그 결과를 사용하지 않고 캐시에만 저장. | 데이터를 가져오면서도 컴포넌트의 렌더링에 영향을 미치지 않도록 하고 싶은 경우에 유용. |
| 쿼리 함수 내부에서 프리패칭 | 쿼리 함수 내부에서 다른 데이터를 프리패칭. 예: 기사를 가져올 때 해당 기사에 대한 댓글 데이터를 미리 가져오기 | 특정 데이터를 가져올 때, 관련된 데이터도 자주 필요할 것으로 예상되는 경우에 적합. |
| Effect 안에서 프리패칭 | useEffect 훅 내부에서 queryClient.prefetchQuery를 사용하여 특정 조건에서 데이터를 미리 가져오기. | 특정 이벤트나 조건에 따라 데이터를 미리 가져와야 하는 경우에 적합. |
때때로 다른 패치 결과에 따라 조건부로 프리패칭을 하고 싶을 때를 고려해보자.
다음 예시는 성능& Request Waterfalls 가이드에서 가져온 예시다.
// This lazy loads the GraphFeedItem component, meaning
// it wont 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,
})
...
}
위 예시는 두 번의 request waterfall이 있다
1. |> getFeed()
2. |> JS for <GraphFeedItem>
3. |> getGraphDataById()
만약 우리가 API를 재구성해 getFeed()가 필요할 때 getGraphDataById() 데이터도 반환하도록 할 수 없다면 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. |> JS for <GraphFeedItem>
2. |> getGraphDataById()
이렇게 데이터가 병렬적으로 된다. 하지만 트레이드 오프가 있다. getGraphDataById는 JS for <GraphFeedItem> 대신 부모 번들을 포함하게 됐다. 이는 상황에 따라 트레이드 오프를 잘 생각해보아야 한다.
컴포넌트 트리 자체에서 데이터 패칭을 하는건 쉽게 request waterfalls를 일으킬 수 있고, 이에 대한 다양한 수정사항이 애플리케이션 전체에 누적되면 번거로울 수 있다.
프리패칭을 매력적이게 수행할 방법은 라우터 수준에서 통합시키는 것이다.
이 접근법은 각 라우터마다 어떤 데이터가 필요한지 명시적으로 미리 선언하는 것이다. 서버 렌더링은 기본적으로 렌더링이 시작하기 전에 모든 데이터가 필요로 하기 때문에 이 방식은 SSR에 지배적으로 사용되어왔다.
여기서는 클라이언트사이드 케이스에 맞추고 Tanstack Router를 이용해 어떻게 만들 수 있는지 살펴보자. 전체 리액트 예제는 여기서 확인 가능하다.
라우터 수준에서 통합할 때 2가지 방식
1. 모든 데이터가 나타날 때까지 렌더링을 차단
2. 프리패치를 시작하되 결과를 기다리지 않기
3. 위 두가지를 섞고, 일부 중요한 데이터는 기다리고 모든 보조 데이터 로드가 완료되기 전에 렌더링을 시작하기
위 3번의 경우이다.
/article 라우터가 article data가 로딩을 마칠 때까지 렌더하지 않기.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
queryClient.prefetchQuery(commentsQueryOptions)
// Don't render the route at all until article has been fetched
await queryClient.prefetchQuery(articleQueryOptions)
},
component: ({ useRouteContext }) => {
const { articleQueryOptions, commentsQueryOptions } = useRouteContext()
const articleQuery = useQuery(articleQueryOptions)
const commentsQuery = useQuery(commentsQueryOptions)
return (
...
)
},
errorComponent: () => 'Oh crap!',
})