Next.js에서 SSR을 사용하면 SEO와 초기 콘텐츠 표시에서 분명한 이점을 얻을 수 있습니다. 하지만 데이터 fetching 방식에 따라 전체 로드 성능이 오히려 저하될 수 있다는 점을 경험해보셨나요? 특히 여러 API를 호출해야 하는 복잡한 페이지에서는 더욱 그렇습니다. 이 글에서는 Next.js + React Query 조합의 SSR 환경에서 데이터 의존적인 페이지의 초기 렌더링 속도 개선을 위한 여정을 공유하고자 합니다.
처음에는 useSuspenseQuery
를 여러 개 사용하여 데이터를 불러왔습니다.
function ExamplePage() {
const { data: data1 } = useSuspenseQuery({
queryKey: ['data1'],
queryFn: fetchData1
});
const { data: data2 } = useSuspenseQuery({
queryKey: ['data2'],
queryFn: fetchData2
});
const { data: data3 } = useSuspenseQuery({
queryKey: ['data3'],
queryFn: fetchData3
});
// ...
}
function Example() {
return (
<Suspense fallback={<>로딩중</>}>
<ExamplePage />
</Suspense>
)
}
이 방식의 핵심 문제는 여러 API 호출이 필요한 경우 ‘폭포수(Waterfall)’ 현상이 발생한다는 점입니다.
즉 첫 번째 API 호출이 완료된 뒤에야 두 번째 API 호출이 시작되고 이어서 세 번째가 시작되는 식으로 순차적으로 네트워크 요청이 이루어집니다.
이러한 순차적 데이터 fetching은 초기 렌더링 시간이 API 호출 시간의 합만큼 늘어나는 결과를 가져왔습니다.
이 현상은 React의 Suspense 동작 원리와 밀접하게 연관되어 있습니다.
Suspense는 해당 컴포넌트가 필요한 데이터가 준비될 때까지 렌더링을 일시 중단(suspend)합니다.
ExamplePage
컴포넌트가 렌더링을 시작합니다.useSuspenseQuery
가 실행되고 아직 데이터가 없으면 Promise를 throw합니다.ExamplePage
컴포넌트의 렌더링을 중단합니다.useSuspenseQuery
가 실행되고 같은 과정이 반복됩니다.이처럼 각 쿼리마다 컴포넌트 전체를 다시 렌더링하면서 순차적으로 API를 호출하게 되어 폭포수 현상이 발생합니다. 즉 첫 번째 API 호출이 완료되어야 두 번째 API 호출이 시작되고 두 번째가 완료되어야 세 번째가 시작되는 식입니다.
폭포수 문제를 해결하기 위해 useSuspenseQueries
를 사용해 병렬로 fetcing했습니다. 이 방식은 여러 API 호출을 병렬로 실행할 수 있게 해줍니다.
function ExamplePage() {
const [{ data: data1 }, { data: data2 }, { data: data3 }] = useSuspenseQueries({
queries: [
{ queryKey: ['data1'], queryFn: fetchData1 },
{ queryKey: ['data2'], queryFn: fetchData2 },
{ queryKey: ['data3'], queryFn: fetchData3 },
]
});
// ...
}
function Example() {
return (
<Suspense fallback={<>로딩중</>}>
<ExamplePage />
</Suspense>
)
}
병렬 데이터 fetching을 통해 초기 렌더링 시간이 크게 단축되었습니다.
useSuspenseQueries
는 여러 쿼리를 병렬로 실행할 수 있게 해주는 훅입니다.
병렬 API 호출: useSuspenseQueries
는 내부적으로 모든 쿼리를 동시에 시작합니다. 첫 번째 쿼리가 완료되기를 기다리지 않고 모든 네트워크 요청이 병렬로 시작됩니다.
한 번의 Suspend: 모든 쿼리가 동시에 시작된 후 컴포넌트는 한 번만 suspend됩니다.
이 방식은 같은 Suspense boundary를 사용하면서도 폭포수 현상을 방지할 수 있습니다. 네트워크 관점에서 모든 API 호출이 병렬로 이루어지므로 총 로딩 시간은 가장 오래 걸리는 단일 API 호출의 시간과 비슷해집니다.
여전히 모든 데이터가 준비될 때까지 사용자는 아무것도 볼 수 없다는 문제가 있었습니다. 예를 들자면 data1, data2는 빠르게 로드되지만 data3는 좀 더 많은 시간을 기다려야 했습니다. 이런 상황에서는 useSuspenseQueries
는 반쪽짜리 해결책이 되는 것입니다.
결국 이 문제가 발생하는 이유는 단일 Suspense boundary에 모든 데이터 의존적인 컴포넌트가 포함되어 있기 때문입니다. 여기까지 너무너무 좋지만, 쪼끔 더 세밀하게 Suspense boundary를 조절할 필요가 생겼습니다.
Streaming SSR이란, React에서 컴포넌트를 렌더링할 때 Node.js의 스트림을 이용해 전체 HTML을 한 번에 전송하는 대신, 작은 조각(chunk) 단위로 브라우저에 순차적으로 보내는 방식입니다.
자, 이제 문제를 다시 정의해봅시다.
사용자는 모든 데이터가 준비될 때까지 기다려야 합니다.
"모든 데이터의 suspend가 완료되어야 사용자에게 데이터를 보여줄 필요가 있을까?" 라는 문제 인식에 기인했습니다. 데이터 fetching이 필요하지 않은 정적인 부분들을 먼저 보여주고, 그리고 빨리 준비되는 데이터를 먼저 보여주며 순차적으로 보여주면 되는 것입니다. 각각의 데이터에 대해 독립적인 Suspense boundary를 부여한다면 먼저 준비되는 데이터부터 순차적으로 보여줄 수 있게 될 것입니다. 결국 이것이 Streaming SSR의 동작 방식인 것입니다.
Suspense
boundary를 사용하여 데이터를 불러오는 컴포넌트를 감쌉니다.function Example() {
return (
<div>
<StaticDataComponent1 /> {/* 데이터 의존성 없는 컴포넌트 */}
<StaticDataComponent2 /> {/* 데이터 의존성 없는 컴포넌트 */}
<Suspense fallback={<Data1Skeleton />}>
<Data1Component /> {/* data1에 의존하는 컴포넌트 */}
</Suspense>
<Suspense fallback={<Data2Skeleton />}>
<Data2Component /> {/* data2에 의존하는 컴포넌트 */}
</Suspense>
<Suspense fallback={<Data3Skeleton />}>
<Data3Component /> {/* data3에 의존하는 컴포넌트 */}
</Suspense>
</div>
)
}
function Data1Component() {
const { data: data1 } = useSuspenseQuery({
queryKey: ['data1'],
queryFn: fetchData1
});
return <>{/* data1에 의존적인 UI */}</>;
}
// Data2Component, Data3Component도 유사한 설계로 가져갑니다.
먼저 준비된 UI가 보여지고 suspend되는 것들은 fallback UI(지정했다면)가 보여지게 됩니다. 이렇게 사용자는 전체 페이지가 완전히 로드되기 전에도 준비된 UI를 먼저 볼 수 있고 일부 기능을 사용할 수 있게 됩니다.
그리고 HTML이 준비되면 청크 단위로 순차적으로 전송되는 것을 확인할 수 있습니다.
이렇게 청크 단위로 HTML이 전송되면 First Contentful Paint(FCP) 시간이 크게 개선됩니다.
실제로 0.8초에서 0.3초로 무려 62.5%나 개선되는 효과를 확인할 수 있었습니다. 회사와 같이 서버 규모가 큰 환경에서는 보다 더 유의미한 FCP 개선을 기대해볼 수 있겠습니다.
비교군이 "하나의 Suspense boundary 안에 useSuspenseQueries의 병렬 fetching을 활용한 상황"이 아닌, "하나의 Suspense boundary 안에 useSuspenseQuery를 여러 개 사용한 상황"이었다면 더 드라마틱한 개선을 확인할 수 있었을 겁니다.
이렇게 Next.js와 React Query 환경에서 서버 데이터에 의존적인 페이지의 초기 렌더링 속도를 개선하는 방법에 대해 알아봤습니다.
폭포수 현상으로 인한 순차적 데이터 로딩 문제를 해결하기 위해 useSuspenseQuery
에서 useSuspenseQueries
로 전환하고 결국 Streaming SSR을 효과적으로 활용함으로써 초기 페이지 로드 속도를 개선할 수 있었네요. 그리고 "모두 준비되기 전까지는 아무것도 보여주지 않는" 접근법에서 벗어나 전체 페이지가 로드되기를 기다릴 필요 없이 준비된 부분부터 먼저 볼 수 있고 상호작용할 수 있게 되었습니다.
이처럼 Streaming SSR을 잘 활용한다면 "Next.js SSR 왜 사용하나요? CSR보다 초기 렌더링 속도가 느리지 않나요?"라는 의문점을 풀어나갈 수 있을 것 같습니다. 감사합니다.