🔥 미리 가져오기(prefetching)와 라우터 통합

1634자
19분

특정 데이터가 필요할 거라 예상될 때는 프리페칭을 통해 미리 캐시에 데이터를 채울 수 있습니다. 이렇게 하면 사용자 경험이 더 빨라지죠.

프리페칭에는 몇 가지 패턴이 있습니다.

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

이 가이드에서는 첫 번째부터 세 번째까지 살펴봅니다. 네 번째는 서버 렌더링과 하이드레이션 가이드고급 서버 렌더링 가이드에서 자세히 다룰 거예요.

프리페칭의 한 가지 용도는 요청 폭포수(Request Waterfalls)를 방지하는 것입니다. 요청 폭포수에 대한 자세한 설명은 성능과 요청 폭포수 가이드를 참고하세요.

prefetchQueryprefetchInfiniteQuery

프리페칭 패턴을 자세히 살펴보기 전에 prefetchQueryprefetchInfiniteQuery 함수에 대해 알아봅시다. 먼저 기본적인 사항 몇 가지를 보겠습니다.

  • 기본적으로 이 함수들은 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

이제 이 함수들을 사용해서 여러 상황에서 어떻게 프리페치할 수 있는지 살펴봅시다.

이벤트 핸들러에서 프리페칭

가장 직관적인 프리페칭 방식은 사용자가 무언가와 상호작용할 때 프리페칭을 하는 것입니다. 이 예제에서는 onMouseEnteronFocus에서 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 쿼리가 해결된 후에야 프리페치가 시작되니까요. 이런 시나리오에서는 라이브러리에 있는 usePrefetchQueryusePrefetchInfiniteQuery 훅을 사용할 수 있습니다.

그런 다음 실제로 데이터가 필요한 컴포넌트에서 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 경계 이전에 usePrefetchQueryusePrefetchInfiniteQuery 훅을 사용해서 프리페칭
  • useQueryuseSuspenseQueries를 사용하되 결과는 무시
  • 쿼리 함수 내에서 프리페칭
  • 이펙트에서 프리페칭

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

의존성 있는 쿼리와 코드 분할

때로는 다른 데이터 가져오기의 결과에 따라 조건부로 프리페칭하고 싶을 때가 있습니다. 성능과 요청 폭포수 가이드에서 빌려온 예제로 설명해 보겠습니다.

// 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: 쿼리 캐시 시딩하기를 살펴보세요.

서버 사이드 라우터 및 프레임워크와 통합하는 것은 방금 본 내용과 매우 유사합니다. 추가로 서버에서 클라이언트로 데이터를 전달해서 클라이언트의 캐시에 하이드레이션해야 합니다. 자세한 내용은 서버 렌더링과 하이드레이션 가이드에서 계속 알아보세요.