🔥 성능과 요청 폭포 효과

1677자
20분

애플리케이션 성능은 광범위하고 복잡한 주제입니다. React Query가 API 속도를 직접 높일 순 없지만, React Query를 사용할 때 최상의 성능을 보장하기 위해 주의해야 할 점들이 있습니다.

React Query나 컴포넌트 내에서 데이터를 가져올 수 있게 해주는 다른 데이터 패칭 라이브러리를 사용할 때 가장 큰 성능 저하 요인은 '요청 폭포 효과'입니다. 이 페이지에서는 요청 폭포 효과가 무엇인지, 어떻게 발견할 수 있는지, 그리고 애플리케이션이나 API를 어떻게 재구성하여 이를 피할 수 있는지 설명하겠습니다.

프리패칭 및 라우터 통합 가이드는 이 내용을 바탕으로 애플리케이션이나 API 재구성이 불가능하거나 실현 가능하지 않을 때 미리 데이터를 가져오는 방법을 알려줍니다.

서버 렌더링 및 하이드레이션 가이드에서는 서버에서 데이터를 미리 가져와 클라이언트로 전달하여 다시 가져오지 않아도 되게 하는 방법을 배울 수 있습니다.

고급 서버 렌더링 가이드는 이러한 패턴을 서버 컴포넌트와 스트리밍 서버 렌더링에 적용하는 방법을 더 자세히 알려줍니다.

요청 폭포 효과란?

요청 폭포 효과는 한 리소스(코드, CSS, 이미지, 데이터 등)에 대한 요청이 다른 리소스에 대한 요청이 끝날 때까지 시작되지 않는 현상을 말합니다.

웹 페이지를 예로 들어보겠습니다. CSS, JS 등을 불러오기 전에 브라우저는 먼저 마크업을 불러와야 합니다. 이것이 바로 요청 폭포 효과입니다.

1. |-> 마크업
2.   |-> CSS
2.   |-> JS
2.   |-> 이미지

text

만약 JS 파일 안에서 CSS를 불러온다면, 이중 폭포 효과가 발생합니다:

1. |-> 마크업
2.   |-> JS
3.     |-> CSS

text

그 CSS가 배경 이미지를 사용한다면, 삼중 폭포 효과가 됩니다:

1. |-> 마크업
2.   |-> JS
3.     |-> CSS
4.       |-> 이미지

text

요청 폭포 효과를 파악하고 분석하는 가장 좋은 방법은 대개 브라우저의 개발자 도구에서 "Network" 탭을 확인하는 것입니다.

각 폭포는 리소스가 로컬에 캐시되어 있지 않는 한 최소 한 번의 서버 왕복을 의미합니다. (실제로는 브라우저가 연결을 설정하는 데 약간의 왕복이 필요할 수 있지만, 여기서는 그 부분은 무시하겠습니다.) 이 때문에 요청 폭포 효과의 부정적인 영향은 사용자의 지연 시간에 크게 좌우됩니다.

삼중 폭포 효과의 예를 살펴봅시다. 이는 실제로 4번의 서버 왕복을 나타냅니다. 3G 네트워크나 열악한 네트워크 환경에서 흔히 볼 수 있는 250ms의 지연 시간을 가정해보면, 지연 시간만 계산했을 때 총 시간은 4 * 250=1000ms가 됩니다. 만약 이를 첫 번째 예제처럼 2번의 왕복으로 줄일 수 있다면, 500ms로 줄어들어 배경 이미지를 절반의 시간에 불러올 수 있습니다!

Request Waterfalls(요청 폭포)와 React Query

이제 React Query를 고려해 보겠습니다. 우선, 서버 렌더링이 없는 경우에 초점을 맞추겠습니다. 쿼리를 시작하기 전에 자바스크립트를 로드해야 합니다. 즉, 데이터를 화면에 표시하기 전에 이중 폭포(double waterfall)가 발생합니다:

1. |-> 마크업
2.   |-> 자바스크립트
3.     |-> 쿼리

text

이를 기반으로 React Query에서 Request Waterfalls를 유발할 수 있는 몇 가지 다른 패턴과 이를 피하는 방법을 살펴보겠습니다.

  • 단일 컴포넌트 폭포 / 직렬 쿼리
  • 중첩 컴포넌트 폭포
  • 코드 분할

단일 컴포넌트 폭포 / 직렬 쿼리

단일 컴포넌트가 먼저 한 쿼리를 가져온 다음 다른 쿼리를 가져올 때 요청 폭포가 발생합니다. 이는 두 번째 쿼리가 Dependent Query인 경우, 즉 가져올 때 첫 번째 쿼리의 데이터에 의존하는 경우에 발생할 수 있습니다:

// 사용자를 가져옵니다.
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,
})
 
typescript

항상 가능한 것은 아니지만, 최적의 성능을 위해서는 API를 재구성하여 이 둘을 단일 쿼리로 가져올 수 있도록 하는 것이 좋습니다. 위의 예제에서 getUserByEmail을 먼저 가져와 getProjectsByUser를 가져오는 대신, 새로운 getProjectsByUserEmail 쿼리를 도입하면 폭포를 완화할 수 있습니다.

API를 재구성하지 않고 종속 쿼리를 완화하는 다른 방법은 대기 시간이 낮은 서버로 폭포를 이동하는 것입니다. 이것이 바로 고급 서버 렌더링 가이드에서 다루는 서버 컴포넌트의 아이디어입니다.

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 })
 
  // 위의 쿼리는 렌더링을 중단하므로 모든 쿼리가 완료될 때까지 데이터가 렌더링되지 않습니다.
  ...
}
 
typescript

일반적인 useQuery를 사용하면 이들은 병렬로 실행될 것입니다.

다행히도, 이것은 컴포넌트에 여러 개의 중단 가능한 쿼리가 있을 때마다 항상 useSuspenseQueries훅을 사용해 쉽게 수정할 수 있습니다.

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

중첩 컴포넌트 폭포

중첩 컴포넌트 폭포는 부모 컴포넌트와 자식 컴포넌트 모두 쿼리를 포함하고, 부모 컴포넌트가 쿼리가 완료될 때까지 자식 컴포넌트를 렌더링하지 않을 때 발생합니다. 이는 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,
  })
 
  ...
}
 
typescript

<Comments> 컴포넌트가 부모로부터 id prop을 전달받긴 하지만, 그 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} />
      )}
    </>
  )
}
 
typescript

이제 두 쿼리가 병렬로 가져오게 됩니다. 중단(suspense)을 사용하는 경우에는 이 두 쿼리를 단일 useSuspenseQueries로 결합하는 것이 좋습니다.

폭포를 완화할 수 있는 또 다른 방법은 <Article> 컴포넌트에서 댓글을 프리페치하거나, 페이지 로드 또는 페이지 탐색 시 라우터 레벨에서 이 두 쿼리를 프리페치하는 것입니다. 이에 대한 자세한 내용은 프리페칭 및 라우터 통합 가이드를 참조하세요.

다음으로, _종속 중첩 컴포넌트 폭포_를 살펴보겠습니다.

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

두 번째 쿼리 getGraphDataById는 두 가지 다른 방식으로 부모에 의존합니다. 첫째, feedItem이 그래프인 경우에만 발생하며, 둘째, 부모로부터 id를 필요로 합니다.

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

이 예제에서는 쿼리를 부모로 끌어올리거나 프리페칭을 추가하여 폭포를 간단히 완화할 수 없습니다. 가이드 초반의 종속 쿼리 예제와 마찬가지로, 한 가지 옵션은 그래프 데이터를 getFeed 쿼리에 포함하도록 API를 리팩토링하는 것입니다. 또 다른 고급 솔루션은 대기 시간이 낮은 서버로 폭포를 이동하기 위해 서버 컴포넌트를 활용하는 것입니다(고급 서버 렌더링 가이드 참조). 그러나 이는 매우 큰 아키텍처 변경이 될 수 있습니다.

여기저기 몇 개의 쿼리 폭포가 있어도 좋은 성능을 얻을 수 있습니다. 다만 그것들이 일반적인 성능 문제라는 것을 알고 주의를 기울여야 합니다. 특히 코드 분할이 관련된 경우에는 더욱 그렇습니다. 다음으로 이 부분을 살펴보겠습니다.

코드 분할

애플리케이션의 자바스크립트 코드를 더 작은 청크로 분할하고 필요한 부분만 로드하는 것은 일반적으로 좋은 성능을 달성하는 데 중요한 단계입니다. 그러나 이는 종종 요청 폭포를 초래한다는 단점이 있습니다. 해당 코드 분할 코드에도 쿼리가 포함되어 있는 경우 이 문제는 더욱 악화됩니다.

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

이 예제에는 다음과 같은 이중 폭포가 있습니다:

1. |> getFeed()
2.   |> <GraphFeedItem>에 대한 자바스크립트
3.     |> getGraphDataById()
text

하지만 이는 예제의 코드만 보고 있는 것입니다. 이 페이지의 첫 페이지 로드가 어떻게 보이는지 고려해 보면, 그래프를 렌더링하기 전에 서버에 5번의 왕복을 완료해야 합니다!

1. |> 마크업
2.   |> <Feed>에 대한 자바스크립트
3.     |> getFeed()
4.       |> <GraphFeedItem>에 대한 자바스크립트
5.         |> getGraphDataById()
text

서버 렌더링을 할 때는 이것이 약간 다르게 보입니다. 이에 대해서는 서버 렌더링 및 하이드레이션 가이드에서 더 자세히 살펴보겠습니다. 또한 <Feed>를 포함하는 경로 자체가 코드 분할되는 경우가 많은데, 이로 인해 또 다른 홉(hop)이 추가될 수 있습니다.

코드 분할의 경우, getGraphDataById 쿼리를 <Feed> 컴포넌트로 끌어올리고 조건부로 만들거나 조건부 프리페치를 추가하는 것이 실제로 도움이 될 수 있습니다. 그러면 해당 쿼리를 코드와 병렬로 가져올 수 있어 예제 부분이 다음과 같이 변경됩니다:

1. |> getFeed()
2.   |> getGraphDataById()
2.   |> <GraphFeedItem>에 대한 자바스크립트
text

그러나 이는 매우 절충적인 선택입니다. 거의 사용하지 않더라도 getGraphDataById에 대한 데이터 가져오기 코드를 <Feed>와 동일한 번들에 포함하고 있기 때문입니다. 따라서 여러분의 경우에 무엇이 최선인지 평가해야 합니다. 이에 대한 자세한 내용은 프리페칭 및 라우터 통합 가이드를 참조하세요.

다음 사이의 절충:

  • 거의 사용하지 않더라도 모든 데이터 가져오기 코드를 메인 번들에 포함
  • 데이터 가져오기 코드를 코드 분할 번들에 넣되, 요청 폭포 발생

이는 좋지 않으며 서버 컴포넌트의 동기 중 하나였습니다. 서버 컴포넌트를 사용하면 둘 다 피할 수 있습니다. 이것이 React Query에 어떻게 적용되는지에 대한 자세한 내용은 고급 서버 렌더링 가이드를 참조하세요.

요약

요청 폭포(Request Waterfall)는 매우 흔하면서도 복잡한 성능 문제입니다. 이 문제는 다양한 상황에서 발생할 수 있어 주의가 필요합니다.

개발자들이 무심코 요청 폭포를 만들어내는 경우를 살펴보겠습니다:

  1. 부모 컴포넌트에 이미 쿼리가 있는데 모르고 자식 컴포넌트에 쿼리를 추가할 때
  2. 자식 컴포넌트에 이미 쿼리가 있는데 모르고 부모 컴포넌트에 쿼리를 추가할 때
  3. 쿼리가 있는 자식 컴포넌트를 다른 쿼리가 있는 부모 컴포넌트 아래로 옮길 때
  4. 그 외 다양한 상황

이렇게 의도치 않게 복잡해지는 상황을 피하려면 요청 폭포에 대해 항상 주의를 기울여야 합니다. 정기적으로 애플리케이션을 점검하는 것이 좋습니다. 네트워크 탭을 자주 확인하는 것도 도움이 됩니다. 모든 폭포를 없앨 필요는 없지만, 성능에 큰 영향을 미치는 중요한 폭포는 반드시 찾아내야 합니다.

다음 가이드에서는 프리페칭과 라우터 통합을 활용해 요청 폭포를 줄이는 더 많은 방법을 알아보겠습니다.