Docs
GUIDES & CONCEPTS
Performance & Request Waterfalls

Performance & Request Waterfalls

어플리케이션 성능은 광범위하고 복잡한 영역입니다. React Query가 API를 더 빠르게 만들 수는 없지만, React Query를 사용하는 방식에 따라 최상의 성능을 보장하기 위해 신경 써야 할 부분들이 있습니다.

React Query 또는 컴포넌트 내에서 데이터를 가져오는 모든 데이터 패칭 라이브러리에서 가장 큰 성능 문제 중 하나는 요청 워터폴(Request Waterfall)입니다. 이 페이지에서는 요청 워터폴이 무엇인지, 이를 감지하는 방법, 그리고 어플리케이션이나 API를 재구성하여 이를 피할 수 있는 방법에 대해 설명합니다.

Prefetching & Router Integration 가이드에서는 어플리케이션이나 API를 재구성하는 것이 불가능하거나 비현실적인 경우, 미리 데이터를 가져오는 방법에 대해 설명합니다.

Server Rendering & Hydration 가이드에서는 서버에서 데이터를 미리 가져와 클라이언트로 전달하여 다시 가져오지 않도록 하는 방법을 다룹니다.

Advanced Server Rendering 가이드에서는 이러한 패턴을 Server Components 및 Streaming Server Rendering에 적용하는 방법을 더 깊이 있게 다룹니다.

What is a Request Waterfall?

요청 워터폴은 리소스(코드, CSS, 이미지, 데이터)에 대한 요청이 다른 리소스에 대한 요청이 완료된 후에야 시작되는 경우 발생합니다.

웹 페이지를 고려해 보세요. CSS, JS 등을 로드하기 전에 브라우저는 먼저 마크업을 로드해야 합니다. 이것이 요청 워터폴입니다.

1. |-> Markup
2.   |-> CSS
2.   |-> JS
2.   |-> Image

만약 CSS를 JS 파일 내에서 가져오면, 이제는 이중 워터폴이 발생합니다:

1. |-> Markup
2.   |-> JS
3.     |-> CSS

그 CSS가 배경 이미지를 사용한다면, 삼중 워터폴이 됩니다:

1. |-> Markup
2.   |-> JS
3.     |-> CSS
4.       |-> Image

요청 워터폴을 발견하고 분석하는 가장 좋은 방법은 일반적으로 브라우저의 개발자 도구 "Network" 탭을 여는 것입니다.

각 워터폴은 적어도 하나의 서버 왕복을 나타내며, 리소스가 로컬 캐시된 경우를 제외하고는 그렇습니다(실제로는 브라우저가 연결을 설정하는 데 약간의 왕복이 필요하므로 일부 워터폴이 여러 왕복을 나타낼 수 있습니다). 요청 워터폴의 부정적인 영향은 사용자의 지연 시간에 크게 의존합니다. 삼중 워터폴 예제를 고려해 보세요. 실제로는 4번의 서버 왕복을 나타내며, 3G 네트워크나 나쁜 네트워크 조건에서 250ms의 지연 시간이 일반적일 경우, 총 시간은 4 * 250 = 1000ms 지연 시간만 계산합니다. 이를 두 번째 예제처럼 첫 번째 예제로 평탄화할 수 있다면, 500ms로 줄어들어 배경 이미지를 두 배 빠르게 로드할 수 있습니다!

Request Waterfalls & React Query

이제 React Query를 살펴보겠습니다. 먼저 서버 렌더링 없이 경우를 살펴보겠습니다. 쿼리를 시작하기 전에 먼저 JS를 로드해야 하므로 데이터를 화면에 표시하기 전에 이중 워터폴이 발생합니다:

1. |-> Markup
2.   |-> JS
3.     |-> Query

이러한 기반을 가지고, React Query에서 요청 워터폴이 발생할 수 있는 몇 가지 패턴과 이를 피하는 방법을 살펴보겠습니다.

  • 단일 컴포넌트 워터폴 / 직렬 쿼리
  • 중첩된 컴포넌트 워터폴
  • 코드 분할

Single Component Waterfalls / Serial Queries

하나의 컴포넌트가 먼저 하나의 쿼리를 가져온 다음 다른 쿼리를 가져오는 경우, 이는 요청 워터폴입니다. 이는 종속 쿼리가 있는 경우 발생할 수 있습니다. 즉, 첫 번째 쿼리의 데이터가 있어야 두 번째 쿼리를 가져올 수 있는 경우입니다.

// 사용자 가져오기
const { data: user } = useQuery({
  queryKey: ["user", email],
  queryFn: getUserByEmail,
});
 
const userId = user?.id;
 
// 그런 다음 사용자의 프로젝트 가져오기
const {
  status,
  fetchStatus,
  data: projects,
} = useQuery({
  queryKey: ["projects", userId],
  queryFn: getProjectsByUser,
  // userId가 존재할 때까지 쿼리 실행되지 않음
  enabled: !!userId,
});

최적의 성능을 위해서는 API를 재구성하여 두 쿼리를 단일 쿼리로 가져오는 것이 좋습니다. 위의 예제에서는 getUserByEmail을 먼저 가져와야 getProjectsByUser를 할 수 있는 대신, 새로운 getProjectsByUserEmail 쿼리를 도입하여 워터폴을 평탄화할 수 있습니다.

API를 재구성하지 않고 종속 쿼리를 완화하는 또 다른 방법은 서버에서 워터폴을 이동시키는 것입니다. 이것이 Advanced Server Rendering guide에서 다루는 Server Components의 아이디어입니다.

또 다른 예는 React Query와 Suspense를 사용하는 경우입니다:

function App () {
  // 다음 쿼리들은 직렬로 실행되어 별도의 서버 왕복을 발생시킵니다:
  const usersQuery = useSuspenseQuery({ queryKey: ['users'], queryFn: fetchUsers })
  const teamsQuery = useSuspenseQuery({ queryKey: ['teams'], queryFn: fetchTeams })
  const projectsQuery = useSuspenseQuery({ queryKey: ['projects'], queryFn: fetchProjects })
 
  // 위의 쿼리들은 모두 Suspend 렌더링을 수행하므로, 모든 쿼리가 완료될 때까지 데이터가 렌더링되지 않습니다
  ...
}

일반 useQuery에서는 쿼리가 병렬로 실행됩니다.

이 문제를 쉽게 해결할 수 있습니다. 여러 개의 서스펜스 쿼리가 있는 컴포넌트에서는 항상 useSuspenseQueries 훅을 사용하는 것이 좋습니다.

const [usersQuery, teamsQuery, projectsQuery] = useSuspenseQueries({
  queries: [
    { queryKey: ["users"], queryFn: fetchUsers },
    { queryKey: ["teams"], queryFn: fetchTeams },
    { queryKey: ["projects"], queryFn: fetchProjects },
  ],
});

Nested Component Waterfalls

중첩된 컴포넌트 워터폴은 부모와 자식 컴포넌트 모두 쿼리를 포함하고 부모가 쿼리가 완료될 때까지 자식을 렌더링하지 않는 경우입니다. 이는 useQueryuseSuspenseQuery 모두에서 발생할 수 있습니다.

부모의 데이터에 따라 자식이 조건부로 렌더링되거나, 자식이 부모로부터 전달받은 데이터 일부를 사용하여 쿼리를 수행하는 경우, 이는 종속 중첩 컴포넌트 워터폴입니다.

먼저 자식이 부모에 종속되지 않은 경우를 살펴보겠습니다.

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,
  })
 
  ...
}

<Comments>는 부모로부터 id를 전달받지만, 해당 id<Article>이 렌더링될 때 이미 사용 가능하므로 댓글을 기사와 동시에 가져올 수 있습니다. 실제 어플리케이션에서는 자식이 부모로부터 멀리 중첩되어 있을 수 있으며, 이러한 워터폴을 발견하고 수정하는 것이 더 어려울 수 있습니다. 하지만 예제에서는 워터폴을 평탄화할 수 있는 방법 중 하나는 댓글 쿼리를 부모로 이동시키는 것입니다:

function Article({ id }) {
  const { data: articleData, isPending: articlePending } = useQuery({
    queryKey: ["article", id],
    queryFn: getArticleById,
  });
 
  const { data: commentsData, isPending: commentsPending } = useQuery({
    queryKey: ["article-comments", id],
    queryFn: getArticleCommentsById,
  });
 
  if (articlePending) {
    return "Loading article...";
  }
 
  return (
    <>
      <ArticleHeader articleData={articleData} />
      <ArticleBody articleData={articleData} />
      {commentsPending ? (
        "Loading comments..."
      ) : (
        <Comments commentsData={commentsData} />
      )}
    </>
  );
}

이제 두 쿼리가 병렬로 가져옵니다. 서스펜스를 사용하는 경우에는 두 쿼리를 단일 useSuspenseQueries로 결합하는 것이 좋습니다.

다음으로 종속 중첩 컴포넌트 워터폴을 살펴보겠습니다.

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} />
      })}
    </>
  )
}
 
function GraphFeedItem({ feedItem }) {
  const { data, isPending } = useQuery({
    queryKey: ['graph', feedItem.id],
    queryFn: getGraphDataById,
  })
 
  ...
}

두 번째 쿼리 getGraphDataById는 두 가지 방법으로 부모에 종속되어 있습니다. 첫째, feedItem이 그래프일 때만 실행되며, 둘째, 부모로부터 id를 필요로 합니다.

1. |> getFeed()
2.   |> getGraphDataById()

이 예제에서는 단순히 쿼리를 부모로 이동하거나 미리 가져오는 것으로 워터폴을 평탄화할 수 없습니다. 첫 번째 쿼리 getFeed에 그래프 데이터를 포함하도록 API를 리팩토링하는 것이 하나의 옵션입니다. 또 다른 고급 솔루션은 Advanced Server Rendering guide에서 다루는 Server Components를 활용하여 서버에서 워터폴을 이동시키는 것입니다. 하지만 이는 매우 큰 아키텍처 변경이 될 수 있습니다.

약간의 쿼리 워터폴이 있어도 성능이 좋을 수 있지만, 이러한 문제를 염두에 두고 주의하는 것이 좋습니다. 특히 코드 분할이 포함된 경우에는 특히 교활할 수 있습니다. 이를 살펴보겠습니다.

Code Splitting

어플리케이션의 JS 코드를 더 작은 청크로 나누고 필요한 부분만 로드하는 것은 일반적으로 좋은 성능을 달성하는 데 중요한 단계입니다. 그러나 코드 분할에는 단점이 있으며, 종종 요청 워터폴을 초래합니다. 코드 분할된 코드에 쿼리가 포함되어 있는 경우 문제는 더욱 악화됩니다.

다음은 약간 수정된 Feed 예제입니다.

// 이 코드는 <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,
  })
 
  ...
}

이 예제는 이중 워터폴을 가지며, 페이지 첫 로드 시 실제로 5번의 서버 왕복을 완료해야 렌더링할 수 있습니다:

1. |> Markup
2.   |> JS for <Feed>
3.     |> getFeed()
4.       |> JS for <GraphFeedItem>
5.         |> getGraphDataById()

이 경우에는 getGraphDataById 쿼리를 <Feed> 컴포넌트로 이동시키고 조건부로 추가하거나 조건부 프리패치를 추가하는 것이 도움이 될 수 있습니다. 이렇게 하면 이 쿼리를 코드와 병렬로 가져와 다음과 같이 됩니다:

1. |> getFeed()
2.   |> getGraphDataById()
2.   |> JS for <GraphFeedItem>

이는 매우 큰 트레이드오프입니다. 이제 getGraphDataById의 데이터 패칭 코드가 <Feed>와 동일한 번들에 포함되므로, 이 경우 무엇이 최상의 선택인지 평가하는 것이 중요합니다. 이를 수행하는 방법에 대해 더 자세히 알아보려면 Prefetching & Router Integration guide를 참조하십시오.

코드 데이터 패칭 코드를 기본 번들에 포함할지, 요청 워터폴이 있는 코드 분할 번들에 포함할지에 대한 트레이드오프는 좋지 않습니다. 이는 Server Components의 동기 중 하나입니다. Server Components를 사용하면 두 가지 모두 피할 수 있으므로, Advanced Server Rendering guide에서 React Query에 어떻게 적용되는지에 대해 알아보십시오.

Summary and takeaways

요청 워터폴은 매우 일반적이며 복잡한 성능 문제로, 많은 트레이드오프가 있습니다. 어플리케이션에 우연히 요청 워터폴을 도입할 수 있는 많은 방법이 있습니다:

  • 자식에게 쿼리를 추가하고 부모가 이미 쿼리를 가진 것을 인식하지 못함
  • 부모에게 쿼리를 추가하고 자식이 이미 쿼리를 가진 것을 인식하지 못함
  • 쿼리를 가진 컴포넌트를 새 부모로 이동시키는 것
  • 기타 등등

이러한 우발적 복잡성으로 인해 워터폴에 주의하고 어플리케이션을 정기적으로 검사하는 것이 좋습니다(좋은 방법은 네트워크 탭을 가끔 확인하는 것입니다!). 모든 워터폴을 평탄화할 필요는 없지만, 높은 영향을 미치는 워터폴을 주의 깊게 살펴보는 것이 좋습니다.

다음 가이드에서는 Prefetching & Router Integration을 활용하여 워터폴을 평탄화하는 방법을 살펴보겠습니다.