[Tanstack Query v5] Prefetching & Router Integration 번역 _ 서버 렌더링 최적화를 위한 학습여정

모아나·2024년 8월 19일
0

Prefetching & Router Integration

특정 데이터가 필요할 것으로 예상되는 경우 Prefetching을 사용해 캐시를 해당 데이터로 미리 채우면 더 빠른 환경을 제공할 수 있다.

몇 가지 프리페칭 패턴들이 있다:
1. 이벤트 핸들러 내
2. 컴포넌트 내
3. 라우터 통합
4. 서버렌더링 중 (라우터 통합의 또 다른 형태)

이 가이드에서는 3번까지 알아보고 4번은 서버 렌더링& 하이드레이션 가이드와 서버렌더링 최적화 가이드 에서 다룰 예정이다.

prefetchQuery & prefetchInfiniteQuery

특정 프리페칭 패턴들을 보기 전에, prefetchQueryprefetchInfiniteQuery 함수를 살펴보자.

이 함수들은 데이터를 서버에서 가져와 로컬 캐시에 저장하여, 나중에 컴포넌트에서 이 데이터를 사용할 때 빠르게 접근할 수 있게 한다.


<함수 관련 기본 사항>

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)에서의 프리패칭

  • 서버에서 데이터를 미리 가져올 때 (SSR), 여러 번의 프리패칭이 호출될 수 있다. 이때 각각의 프리패칭에 staleTime을 설정해주지 않으면 기본적으로 queryCLient에 설정된 staleTime이 적용된다.
  • 팁: 서버에서 데이터를 프리페칭할 때, 모든 프리페칭 호출에 동일한 staleTime을 적용하려면 queryClient 의 기본 staleTime을 0보다 크게 설정하자.

3. 프리패치된 쿼리의 가비지 콜렉션

  • prefetchQuery로 데이터를 미리 가져왔는데 이를 사용하는 useQuery가 존재하지 않으면, 이 쿼리는 일정 시간이 지나면 삭제되거나 gcTime(가비지 콜렉션)에 의해 메모리에서 제거된다.
  • prefetchQueryprefetchInfiniteQueryPromise<void>를 반환한다. 즉, 이 함수들은 query 데이터를 반환하지 않는다. 만약 데이터 반환이 필요한거라면 fetchQuery/ fetchInfiniteQuery를 대신 사용해야 한다.
  • 프리패치 함수들은 절대 에러를 throw하지 않는다. 왜냐하면 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함수도 제공해야 한다.

Prefetch in event handlers

가장 직관적인 프리패칭의 형태는 유저가 어떤 것과 상호작용했을 때 프리패칭하는 것이다. 이 예시에서는 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>
  )
}

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()

이와 같은 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>
  ))
}
  • App컴포넌트에서 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 사용useQueryuseSuspenseQueries를 사용하여 데이터를 가져오지만, 그 결과를 사용하지 않고 캐시에만 저장.데이터를 가져오면서도 컴포넌트의 렌더링에 영향을 미치지 않도록 하고 싶은 경우에 유용.
쿼리 함수 내부에서 프리패칭쿼리 함수 내부에서 다른 데이터를 프리패칭. 예: 기사를 가져올 때 해당 기사에 대한 댓글 데이터를 미리 가져오기특정 데이터를 가져올 때, 관련된 데이터도 자주 필요할 것으로 예상되는 경우에 적합.
Effect 안에서 프리패칭useEffect 훅 내부에서 queryClient.prefetchQuery를 사용하여 특정 조건에서 데이터를 미리 가져오기.특정 이벤트나 조건에 따라 데이터를 미리 가져와야 하는 경우에 적합.

Dependent Queries & Code Splitting

때때로 다른 패치 결과에 따라 조건부로 프리패칭을 하고 싶을 때를 고려해보자.
다음 예시는 성능& 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()

이렇게 데이터가 병렬적으로 된다. 하지만 트레이드 오프가 있다. getGraphDataByIdJS for <GraphFeedItem> 대신 부모 번들을 포함하게 됐다. 이는 상황에 따라 트레이드 오프를 잘 생각해보아야 한다.

Router Integration

컴포넌트 트리 자체에서 데이터 패칭을 하는건 쉽게 request waterfalls를 일으킬 수 있고, 이에 대한 다양한 수정사항이 애플리케이션 전체에 누적되면 번거로울 수 있다.
프리패칭을 매력적이게 수행할 방법은 라우터 수준에서 통합시키는 것이다.

이 접근법은 각 라우터마다 어떤 데이터가 필요한지 명시적으로 미리 선언하는 것이다. 서버 렌더링은 기본적으로 렌더링이 시작하기 전에 모든 데이터가 필요로 하기 때문에 이 방식은 SSR에 지배적으로 사용되어왔다.

여기서는 클라이언트사이드 케이스에 맞추고 Tanstack Router를 이용해 어떻게 만들 수 있는지 살펴보자. 전체 리액트 예제는 여기서 확인 가능하다.

라우터 수준에서 통합할 때 2가지 방식
1. 모든 데이터가 나타날 때까지 렌더링을 차단
2. 프리패치를 시작하되 결과를 기다리지 않기
3. 위 두가지를 섞고, 일부 중요한 데이터는 기다리고 모든 보조 데이터 로드가 완료되기 전에 렌더링을 시작하기

예제

위 3번의 경우이다.

  • /article 라우터가 article data가 로딩을 마칠 때까지 렌더하지 않기.
  • comments가 프리패칭 가능할 때 바로 하기.
  • comments 로딩이 아직 완료되지 않은 경우 렌더링을 차단하지 않기.
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!',
})
profile
Make things

0개의 댓글