성능 및 요청 워터폴
애플리케이션 성능은 광범위하고 복잡한 영역이며 React Query가 API의 속도를 높여 줄 수는 없지만, 최상의 성능을 보장하려면 React Query를 사용하는 방식에서 여전히 유의해야 할 사항이 있습니다.
React Query 또는 컴포넌트 내부에서 데이터를 가져올 수 있게 하는 다른 데이터 가져오기 라이브러리를 사용할 때 가장 큰 성능상의 함정은 요청 워터폴입니다. 이 페이지의 나머지 부분에서는 요청 워터폴이 무엇인지, 어떻게 발견할 수 있는지, 그리고 이를 피하도록 애플리케이션이나 API를 어떻게 재구성할 수 있는지 설명합니다.
프리페치 및 Router 통합 가이드는 이를 기반으로 하며, 애플리케이션이나 API를 재구성할 수 없거나 그렇게 하는 것이 현실적이지 않을 때 데이터를 미리 프리페치하는 방법을 설명합니다.
서버 렌더링 및 하이드레이션 가이드에서는 서버에서 데이터를 프리페치하고 해당 데이터를 클라이언트로 전달하여 다시 가져올 필요가 없도록 하는 방법을 설명합니다.
고급 서버 렌더링 가이드에서는 이러한 패턴을 Server Components와 스트리밍 서버 렌더링에 적용하는 방법을 더 자세히 설명합니다.
요청 워터폴이란 무엇인가요?
요청 워터폴은 리소스(코드, css, 이미지, 데이터)에 대한 요청이 다른 리소스 요청이 완료된 후에야 시작되는 현상입니다.
웹 페이지를 생각해 보세요. 브라우저가 CSS, JS 등을 로드하려면 먼저 마크업을 로드해야 합니다. 이것이 요청 워터폴입니다.
1. |-> Markup
2. |-> CSS
2. |-> JS
2. |-> Image
JS 파일 내부에서 CSS를 가져오면 이제 이중 워터폴이 발생합니다:
1. |-> Markup
2. |-> JS
3. |-> CSS
해당 CSS가 배경 이미지를 사용한다면 세 번의 워터폴이 발생합니다:
1. |-> Markup
2. |-> JS
3. |-> CSS
4. |-> Image
요청 워터폴을 찾아 분석하는 가장 좋은 방법은 일반적으로 브라우저 DevTools의 "Network" 탭을 여는 것입니다.
각 워터폴은 리소스가 로컬에 캐시되어 있지 않은 한 서버로의 왕복을 최소 한 번 나타냅니다(실제로는 브라우저가 약간의 데이터 교환이 필요한 연결을 설정해야 하므로 이러한 워터폴 중 일부는 한 번보다 많은 왕복을 나타낼 수 있지만, 여기서는 이를 무시하겠습니다). 이 때문에 요청 워터폴의 부정적인 영향은 사용자의 지연 시간에 크게 좌우됩니다. 실제로 서버 왕복 4회를 나타내는 3중 워터폴의 예를 살펴보겠습니다. 3g 네트워크나 네트워크 상태가 좋지 않을 때 드물지 않은 250ms의 지연 시간에서는 지연 시간만 계산해도 총 4*250=1000ms가 걸립니다. 이를 왕복이 2회뿐인 첫 번째 예처럼 평탄화할 수 있다면 대신 500ms가 걸리므로 해당 배경 이미지를 절반의 시간에 로드할 수도 있습니다!
요청 폭포 & React Query
이제 React Query를 살펴보겠습니다. 먼저 Server Rendering을 사용하지 않는 경우에 집중하겠습니다. 쿼리를 만들기 시작하기도 전에 JS를 로드해야 하므로 해당 데이터를 화면에 표시하기 전에 이중 워터폴이 발생합니다:
1. |-> Markup
2. |-> JS
3. |-> Query
이를 바탕으로 React Query에서 요청 워터폴을 유발할 수 있는 몇 가지 패턴과 이를 피하는 방법을 살펴보겠습니다.
- 단일 컴포넌트 워터폴 / 직렬 쿼리
- 중첩된 컴포넌트 폭포
- 코드 분할
단일 컴포넌트 워터폴 / 직렬 쿼리
단일 컴포넌트가 먼저 한 쿼리를 가져온 다음 다른 쿼리를 가져오면 요청 워터폴이 발생합니다. 두 번째 쿼리가 종속 쿼리인 경우, 즉 가져올 때 첫 번째 쿼리의 데이터에 의존하는 경우에 이런 일이 발생할 수 있습니다:
// Get the user
const { data: user } = useQuery({
queryKey: ['user', email],
queryFn: getUserByEmail,
})
const userId = user?.id
// Then get the user's projects
const {
status,
fetchStatus,
data: projects,
} = useQuery({
queryKey: ['projects', userId],
queryFn: getProjectsByUser,
// The query will not execute until the userId exists
enabled: !!userId,
})
항상 가능한 것은 아니지만 최적의 성능을 위해서는 이 두 항목을 단일 쿼리로 가져올 수 있도록 API를 재구성하는 편이 좋습니다. 위 예제에서 getProjectsByUser를 수행하기 위해 먼저 getUserByEmail을 가져오는 대신 새로운 getProjectsByUserEmail 쿼리를 도입하면 워터폴을 평탄화할 수 있습니다.
API를 재구성하지 않고 종속 쿼리를 완화하는 또 다른 방법은 지연 시간이 더 짧은 서버로 워터폴을 이동하는 것입니다. 이것이 고급 서버 렌더링 가이드에서 다루는 Server Components의 기본 개념입니다.
직렬 쿼리의 또 다른 예는 Suspense와 함께 React Query를 사용하는 경우입니다:
function App () {
// The following queries will execute in serial, causing separate roundtrips to the server:
const usersQuery = useSuspenseQuery({ queryKey: ['users'], queryFn: fetchUsers })
const teamsQuery = useSuspenseQuery({ queryKey: ['teams'], queryFn: fetchTeams })
const projectsQuery = useSuspenseQuery({ queryKey: ['projects'], queryFn: fetchProjects })
// Note that since the queries above suspend rendering, no data
// gets rendered until all of the queries finished
...
}
일반 useQuery를 사용하면 이러한 작업이 병렬로 수행된다는 점에 유의합니다.
다행히 이 문제는 컴포넌트에 suspense를 사용하는 쿼리가 여러 개 있을 때 항상 useSuspenseQueries 훅을 사용하면 쉽게 해결할 수 있습니다.
const [usersQuery, teamsQuery, projectsQuery] = useSuspenseQueries({
queries: [
{ queryKey: ['users'], queryFn: fetchUsers },
{ queryKey: ['teams'], queryFn: fetchTeams },
{ queryKey: ['projects'], queryFn: fetchProjects },
],
})
중첩된 컴포넌트 폭포
중첩 컴포넌트 워터폴은 부모 컴포넌트와 자식 컴포넌트가 모두 쿼리를 포함하고, 부모가 자신의 쿼리를 완료할 때까지 자식을 렌더링하지 않는 경우입니다. 이는 useQuery와 useSuspenseQuery 모두에서 발생할 수 있습니다.
자식이 부모의 데이터를 기반으로 조건부 렌더링하거나, 자식이 쿼리를 만들기 위해 부모로부터 prop으로 전달되는 결과의 일부에 의존한다면, 이는 의존적인 중첩 컴포넌트 워터폴입니다.
먼저 자식이 부모에 의존하지 않는 예를 살펴보겠습니다.
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 prop을 받지만, <Article>이 렌더링될 때는 해당 id를 이미 사용할 수 있으므로 문서와 동시에 댓글을 가져오지 못할 이유가 없습니다. 실제 애플리케이션에서는 자식이 부모보다 훨씬 아래에 중첩될 수 있고 이러한 종류의 워터폴은 발견하고 수정하기가 더 까다로운 경우가 많지만, 이 예시에서 워터폴을 평탄화하는 한 가지 방법은 댓글 쿼리를 대신 부모로 끌어올리는 것입니다:
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} />
)}
</>
)
}
이제 두 쿼리를 병렬로 가져옵니다. 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,
})
...
}
두 번째 쿼리 getGraphDataById는 두 가지 방식으로 상위 항목에 의존합니다. 우선 feedItem이 그래프가 아니면 절대 실행되지 않으며, 둘째로 상위 항목의 id가 필요합니다.
1. |> getFeed()
2. |> getGraphDataById()
이 예시에서는 쿼리를 부모로 끌어올리거나 프리페치를 추가하는 것만으로 워터폴을 간단히 평탄화할 수 없습니다. 이 가이드의 시작 부분에 나온 종속 쿼리 예시와 마찬가지로, 한 가지 방법은 API를 리팩터링하여 getFeed 쿼리에 그래프 데이터를 포함하는 것입니다. 더 고급 방법은 Server Components를 활용해 워터폴을 지연 시간이 더 짧은 서버로 옮기는 것입니다(자세한 내용은 고급 서버 렌더링 가이드를 참조하세요). 단, 이는 매우 큰 아키텍처 변경이 될 수 있습니다.
여기저기에 쿼리 워터폴이 몇 개 있더라도 좋은 성능을 얻을 수 있지만, 이것이 흔한 성능 문제라는 점을 알고 주의해야 합니다. 특히 알아차리기 어려운 경우는 Code Splitting이 관련될 때이며, 다음으로 이를 살펴보겠습니다.
코드 분할
애플리케이션의 JS 코드를 더 작은 chunk로 분할하고 필요한 부분만 로드하는 것은 일반적으로 좋은 성능을 달성하기 위한 중요한 단계입니다. 그러나 이렇게 하면 종종 요청 waterfall이 발생한다는 단점이 있습니다. 코드 분할된 코드 내부에 쿼리도 있으면 이 문제는 더욱 악화됩니다.
이를 Feed 예제를 약간 수정한 버전으로 간주합니다.
// 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,
})
...
}
이 예제에는 다음과 같은 이중 워터폴이 있습니다:
1. |> getFeed()
2. |> JS for <GraphFeedItem>
3. |> getGraphDataById()
하지만 이는 예제의 코드만 살펴본 것입니다. 이 페이지를 처음 불러올 때의 모습을 고려하면 그래프를 렌더링하기 전에 실제로 서버와의 왕복을 5번 완료해야 합니다!
1. |> Markup
2. |> JS for <Feed>
3. |> getFeed()
4. |> JS for <GraphFeedItem>
5. |> getGraphDataById()
서버 렌더링 시에는 이것이 약간 다르게 보인다는 점에 유의하세요. 서버 렌더링 및 하이드레이션 가이드에서 이를 더 자세히 살펴보겠습니다. 또한 <Feed>가 포함된 라우트도 코드 분할되는 경우가 드물지 않으며, 이로 인해 또 하나의 단계가 추가될 수 있습니다.
코드 분할의 경우에는 실제로 getGraphDataById 쿼리를 <Feed> 컴포넌트로 끌어올려 조건부로 만들거나, 조건부 프리페치를 추가하는 것이 도움이 될 수 있습니다. 그러면 해당 쿼리를 코드와 병렬로 가져올 수 있으므로 예제 부분은 다음과 같이 바뀝니다:
1. |> getFeed()
2. |> getGraphDataById()
2. |> JS for <GraphFeedItem>
하지만 이는 분명한 절충입니다. 이제 getGraphDataById의 데이터 가져오기 코드를 <Feed>와 동일한 번들에 포함하므로, 사용 사례에 가장 적합한 방식을 평가하세요. 이를 수행하는 방법에 관한 자세한 내용은 프리페치 및 Router 통합 가이드를 참조하세요.
다음 항목 간의 절충점:
- 거의 사용하지 않더라도 모든 데이터 가져오기 코드를 메인 번들에 포함합니다
- 데이터 가져오기 코드를 코드 분할 번들에 넣지만, 요청 워터폴이 발생합니다
좋지 않으며 Server Components가 등장하게 된 동기 중 하나였습니다. Server Components를 사용하면 둘 다 피할 수 있습니다. 이것이 React Query에 어떻게 적용되는지는 고급 서버 렌더링 가이드에서 자세히 알아보세요.
요약 및 핵심 내용
요청 워터폴은 여러 상충 관계가 얽힌 매우 일반적이고 복잡한 성능 문제입니다. 애플리케이션에 실수로 요청 워터폴을 유발하는 방법은 많습니다:
- 부모에 이미 쿼리가 있다는 사실을 모르고 자식에 쿼리 추가하기
- 자식에 이미 쿼리가 있다는 사실을 모르고 부모에 쿼리 추가하기
- 쿼리를 가진 하위 요소가 있는 컴포넌트를 쿼리를 가진 상위 요소가 있는 새 부모로 이동하기
- 기타..
이러한 우발적 복잡성 때문에 waterfall을 유의하고 애플리케이션에서 이를 정기적으로 찾아보는 것이 유용합니다(가끔 Network 탭을 살펴보는 것이 좋은 방법입니다!). 좋은 성능을 얻기 위해 반드시 모든 waterfall을 평탄화할 필요는 없지만, 영향이 큰 waterfall은 주의 깊게 살펴보세요.
다음 가이드에서는 프리페치 및 Router 통합을 활용하여 워터폴을 평탄화하는 더 많은 방법을 살펴봅니다.