애플리케이션 성능 분야는 광대하고 복잡하다. 그리고 리액트 쿼리는 API를 빠르게 해주진 않으며 리액트 쿼리를 사용할 때 최적의 성능을 보장하기 위해 몇가지 고려해야할 점이 있다.
리액트 쿼리를 포함한 어떠한 데이터 패칭 라이브러리를 쓰던 가장 좋지 않은 건 컴포넌트 내에서 데이터 패칭을 하는 것이다. 이는 request waterfalls 을 일으킨다. 이 장에서는 request waterfalls가 무엇이고, 어떻게 찾으며, 이 현상을 피하기 위해 어떻게 재구조화할 지에 대해 서술한다.
Prefetching & Router Integrtion guide
에서는 이를 기반으로 애플리케이션이나 API를 재구성할 수 없거나 불가능한 경우 데이터를 미리 prefetching 하는 법을 알려준다.
Server Rendering & Hydration guide 에서는 서버에서 어떻게 데이터를 프리패칭하고 클라이언트로 데이터를 내려서 재패칭하지 않도록 하는지 알려준다.
Advanced Server Rendering guide 는 더 나아가 서버컴포넌트와 서버렌더링에서 어떻게 이러한 패턴들을 적용하는지 알려준다.
request waterfall은 리소스(code, css, image, data)에 대한 요청이 '다른 리소스에 대한 요청이 끝날 때까지' 시작하지 않을 때 일어난다.
웹페이지에 대해 생각해보자. CSS, JS같은 것을 로드하기 위해서는 브라우저에서 markup을 처음으로 로드해주어야 한다. 이게 request waterfall이다.
|-> Markup
|-> CSS
|-> JS
|-> Image
만약 CSS 파일을 JS파일 내부에 패치했다면 이중 waterfall이 발생한다.
|-> Markup
|-> JS
|-> CSS
만약 CSS가 배경이미지를 쓴다면 삼중 waterfall이 발생한다.
|-> Markup
|-> JS
|-> CSS
|-> Image
request waterfall을 발견하고 분석할 수 있는 최고의 방법은 브라우저의 개발자도구에서 Network 탭을 까보는 것이다.
각각의 waterfall은 로컬에 자원이 캐싱되어 있지 않다면 서버까지 최소 한번 왕복을 보인다. (현실에선, 몇몇의 waterfall들은 한번보다 많은 왕복을 보인다. 왜냐하면 브라우저는 연결 설정을 위해 앞뒤로 연결(아마 핸드세이킹을 말하는 듯.)을 필요로 하기 때문이다. 일단 여기서는 고려하지 않음.)
이 때문에 request waterfalls은 user latency에 안 좋은 영향을 끼친다.(user latency: 사용자가 어떤 작업을 수행했을 때 그 작업이 사용자에게 표시되기까지의 걸린시간)
사실상 서버와 4번의 왕복이 있는 삼중 waterfall에 대해 생각해보자. 3g나 안좋은 네트워크 상태에서 250ms latency가 있을 때, 네트워크 지연시간만 고려해도 4*250= 1000ms이 소요된다.
이를 왕복 2회로 줄일 수 있다면 500ms 정도를 절약하여 배경 이미지를 절반의 시간으로 로드할 수 있다.
자 이제 리액트 쿼리에 대해 생각해보자. 먼저, 서버렌더링 케이스는 배제하고 생각해본다. 우리가 쿼리를 만들 수 있더라도 JS를 먼저 로드해야 한다. 따라서 데이터를 보여주기 전에 두 번의 waterfall이 발생한다.
|-> Markup
|-> JS
|-> Query

현재 구현 중인 서비스의 성능을 측정했을 때 query요청에 대한 로드가 늦게 이뤄짐이 확인된다.
위를 기반으로 리액트 쿼리에서 request waterfall을 만드는 몇가지 패턴을 봐보자.
싱글 컴포넌트에서 하나의 쿼리를 패치한 다음 다른 쿼리를 가져오는 경우 이를 request waterfall이라고 한다.
이는 두 번째 쿼리가 Dependent 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 쿼리를 도입하는게 waterfall을 없앤다.
API를 재구성할 필요없이 dependent queries를 옮기는 방법은 latency가 낮은 서버로 waterfall을 옮기는 것이다. 이는 서버 컴포넌트를 기반으로 하며 Advanced Server Rendering guide에서 다룰 예정이다.
Serial Queries의 또다른 예시로는 Suspense와 함께 리액트 쿼리를 사용할 때다.
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
...
}
Note. 보통의 useQuery라면 위의 경우 병렬적으로 발생한다.
위의 경우는 useSuspenseQueries를 이용해 간단히 고칠 수 있다.
const [usersQuery, teamsQuery, projectsQuery] = useSuspenseQueries({
queries: [
{ queryKey: ['users'], queryFn: fetchUsers },
{ queryKey: ['teams'], queryFn: fetchTeams },
{ queryKey: ['projects'], queryFn: fetchProjects },
]
}
```
## Nested Component Waterfalls
중첩 컴포넌트 wataerfall은 부모, 자식 컴포넌트 모두 쿼리를 가지고 있고 부모 컴포넌트의 쿼리가 끝날 때까지 자식이 렌더링되지 않는 현상이다.
이 현상은 `useQuery`와 `useSuspenseQuery`를 사용할 때 발생할 수 있다.
만약 자식 컴포넌트가 부모의 데이터에 기반해 조건적으로 렌더링되거나 부모에서 prop으로 넘어오는 결과로 쿼리를 만든다면 `dependent nested component waterfall`을 가지게 된다.
### ex: not dependent on the parent
```javascript
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,
})
...
}
가 prop으로 id를 부모에서 받지만 이 id는
이 렌더될 때부터 사용이 가능하다. 따라서 article과 같은 시간에 comments를 패치하면된다. 하지만 현실에서는 자식 컴포넌트의 중첩이 아주 깊을 수도 있다. 이 경우 데이터 패칭에 waterfall이 발생할 수 있다. 이 예제를 토대로 보여주면 부모 컴포넌트에서 패칭을하고 자식 컴포넌트로 전달 할 수 있다.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를 사용한다면 useSupenseQueries를 사용하는걸 추천한다.
또 다른 waterfall 해결법은 comments를 프리패칭하거나 comments와 articles 같은 라우터 페이지에서 패칭하는 것이다. 이는 Prefetching & Router Integration 가이드에서 다룬다.
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가 필요하다.
|> getFeed()
|> getGraphDataById()
이 예시에서는 쿼리를 부모로 올리거나 프래패칭을 추가하는 것만으로 waterfall을 없앨 수 없다. 첫 번째 예시였던 dependent query처럼 API가 getFeed 안에 그래프 데이터를 가지도록 리팩토링하는 옵션이 있다.
또 다른 솔루션은 서버 컴포넌트를 활용해 waterfall 지연 시간이 더 짧은 서버로 이동시키는 것이다. (자세한 내용은 Advanced Server Rendering guide)에서!
여기저기서 몇 가지 쿼리 워터폴이 발생해도 좋은 성능을 유지할 수 있으며, 일반적인 성능 문제라는 것을 알고 이에 유의하자.
JS-code를 작은 청크들로 나누거나 필요한 부분만 로딩하는 것은 좋은 성능으로 가는데 중요한 스탭이다. 하지만 request 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()
이 예시에서는 두 번의 waterfall이 발생한다. 하지만 이는 예제 코드를 살펴본 것일 뿐, 이 페이지의 첫 페이지 로드가 어떻게 보이는지 생각해보면 실제로 그래프를 렌더링하기 전에 서버를 5번 왕복해야 한다.
1. |> Markup
2. |> JS for <Feed>
3. |> getFeed()
4. |> JS for <GraphFeedItem>
5. |> getGraphDataById()
서버 렌더링 시 약간 다르게 표시되므로 서버 렌더링 및 하이드레이션 가이드에서 자세히 살펴본다. 또한 가 포함된 경로가 코드 분할되어 또 다른 hop이 축될 수 있는 경우도 있다.
code split의 경우, getGraphDataById 쿼리를 컴포넌트로 호이스팅하고 조건부로 만들거나 조건부 프래피치를 추가하는 게 도움될 수 있다. 그럼 그 쿼리가 코드와 함께 병렬적으로 패치될 수 있다.
1. |> getFeed()
2. |> getGraphDataById()
2. |> JS for <GraphFeedItem>
하지만 이 방식은 장단점이 있다. 이제 와 동일한 번들에 getGraphById에 대한 데이터 가져오기 코드를 포함하므로 자신의 케이스에 어떤게 적합한지 평가하자.
장단점
거의 사용하지 않더라도 메인 번들에 데이터 패칭을 모두 포함한다.
코드 스플릿 번들에 데이터 패칭 코드를 넣는 대신 request waterfall이 있다.
서버 컴포넌트에선 두 경우를 모두 피할 수 있다. Advanced Server Rendering Guide를 볼 것!
Request Waterfalls는 아주 흔하고 복잡한 성능 문제이며 많은 트레이드오프를 가지고 있다. 실수로 애플리케이션에 워터폴이 도입되는 경우가 다양하다.
부모 컴포넌트가 쿼리를 가지고 있음을 모르고, 자식 컴포넌트에 쿼리를 추가하기.
자식 컴포넌트가 쿼리를 가지고 있음을 모르고, 부모 컴포넌트에 쿼리 추가하기.
쿼리가 있는 자손을 가진 컴포넌트를 쿼리를 가진 조상이 있는 새 부모로 이동하는 경우.
이렇게 예기치 못한 워터폴로 복잡해지기에 워터폴을 염두해 애플리케이션을 정기적으로 살피는 게 좋다. 가끔씩 네트워크 탭을 살펴보기!!
좋은 성능을 위해 모든 워터폴을 없앨 필요는 없지만 영향력이 큰 것은 주의하자. 다음은 프리패칭 및 라우터 통합을 활용한 워터폴 잠재우는 방법을 알아볼 것이다!