Next.js 14에서 Streaming SSR과 Component 사용하기
Streaming SSR이란
Streaming Server-Side Rendering (SSR)은 Next.js가 제공하는 렌더링 방식으로, 기존 SSR 방식의 한계를 극복하고 개선된 사용자 경험을 제공하기 위해 등장했습니다.
Streaming SSR이 등장하게 된 배경
전통적인 SSR 방식은 서버에서 페이지의 모든 데이터를 패칭하고 HTML을 완전히 렌더링한 후에 클라이언트로 응답하는 방식으로 작동합니다. 이는 다음과 같은 한계점을 가지고 있습니다:
- 긴 초기 로딩 시간: 데이터 패칭에 시간이 오래 걸리는 경우, 사용자가 콘텐츠를 보기까지 상당한 지연이 발생합니다.
- 페이지 차단 현상: 페이지 내에 여러 데이터 종속적인 컴포넌트가 존재하는 경우, 모든 데이터가 준비될 때까지 전체 페이지가 차단됩니다.
- 큰 번들 사이즈: 전통적인 SSR은 React Server Components와 달리 클라이언트 측에서 모든 JavaScript 코드가 필요하기 때문에 번들 사이즈를 효과적으로 줄이지 못합니다. 이는 특히 모바일 기기나 느린 네트워크 환경에서 성능 저하를 야기할 수 있습니다.
Streaming SSR은 이러한 전통적인 SSR의 한계를 해결하기 위해 도입되었습니다. 페이지를 작은 청크(Chunk)로 나누어 렌더링하고, 데이터가 준비되는 대로 순차적으로 클라이언트에 전달하는 방식입니다. 이를 통해 사용자는 전체 페이지의 로딩이 완료될 때까지 기다릴 필요 없이, 준비된 부분부터 빠르게 볼 수 있게 됩니다.
Streaming SSR이 가지는 장점
Streaming SSR은 다음과 같은 주요 장점을 제공합니다.
- 사용자 경험 개선: 사용자는 페이지의 주요 구조 (레이아웃, 헤더, 푸터 등)를 빠르게 볼 수 있으며, 데이터 로딩이 필요한 부분은 로딩 상태를 먼저 보여준 후 점진적으로 콘텐츠를 채워나갑니다. 이는 초기 로딩 속도가 크게 개선된 것처럼 느껴지게 합니다.
- TTFB (Time To First Byte) 단축: 전체 페이지를 렌더링 완료 후 응답하는 대신, 먼저 렌더링된 청크부터 클라이언트로 보내기 시작하므로 TTFB를 줄일 수 있습니다.
- Non-Blocking UI: 데이터 패칭으로 인해 전체 페이지 렌더링이 멈추는 현상을 방지하여, 사용자는 페이지의 다른 부분과 먼저 상호작용할 수 있게 됩니다.
- Search Engine Optimization: 초기 HTML에 주요 콘텐츠가 빠르게 포함되어 검색 엔진 크롤러가 페이지 내용을 더 빠르게 인식하고 인덱싱하는 데 도움이 될 수 있습니다.
Streaming SSR과 서버 컴포넌트
Next.js 13 버전 이후 도입된 서버 컴포넌트를 Streaming SSR에 활용할 수 있습니다. 서버 컴포넌트는 서버에서 렌더링되며, 서버 컴포넌트의 JavaScript 코드는 클라이언트로 전송되지 않기 때문에 클라이언트 측으로 전달되는 번들 사이즈를 줄일 수 있습니다.
Suspense와 서버 컴포넌트의 렌더링 과정
<Suspense>로 감싸진 서버 컴포넌트는 다음과 같은 순서로 렌더링됩니다:
-
초기 렌더링
- 서버는 먼저
<Suspense>의fallbackprop으로 전달된 로딩 UI를 렌더링합니다. - 이 초기 HTML이 즉시 클라이언트로 전달되어 사용자에게 표시됩니다.
- 서버는 먼저
-
데이터 패칭
- 서버에서
<Suspense>내부의 서버 컴포넌트가 데이터를 패칭합니다. - 이 시점에서 클라이언트는 이미 로딩 UI를 보고 있습니다.
- 서버에서
-
컴포넌트 렌더링
- 데이터 패칭이 완료되면, 서버는 해당 데이터를 사용하여 서버 컴포넌트를 렌더링합니다.
- 렌더링된 결과는 RSC Payload 형태로 변환됩니다.
-
fallback UI 교체
- 렌더링된 서버 컴포넌트는 RSC Payload 형태로 클라이언트에 전달됩니다.
- 클라이언트에서는 React가 이 RSC Payload를 받아 기존 로딩 UI를 새로운 콘텐츠로 교체합니다.
이러한 과정을 통해 서버 컴포넌트는 데이터 의존성이 있는 부분을 효율적으로 처리하면서도, 사용자에게 즉각적인 피드백을 제공할 수 있습니다.
공식 문서에 따르면, 각 청크는 두 단계를 거쳐 렌더링됩니다.
- React는 서버 컴포넌트를 React Server Component Payload (RSC Payload) 라는 데이터 포맷으로 렌더링합니다.
- Next.js는 이 RSC Payload와 클라이언트 컴포넌트 JavaScript Instructions을 사용하여 HTML을 서버에서 렌더링합니다.
이후 클라이언트에서는 다음과 같은 과정이 진행됩니다.
- 렌더링된 HTML은 초기 페이지 로드 시 빠르게 non-interactive한 미리보기를 보여주는 데 사용됩니다.
- React Server Components Payload는 클라이언트 및 서버 컴포넌트 트리를 reconcile하고 DOM을 업데이트하는 데 사용됩니다.
- JavaScript Instructions는 클라이언트 컴포넌트를 hydrate하고 애플리케이션을 상호작용 가능하게 만드는 데 사용됩니다.
예시:
// app/page.js (서버 컴포넌트)
import { Suspense } from "react";
import ProductList from "./components/ProductList";
import LoadingProducts from "./components/LoadingProducts";
export default function HomePage() {
return (
<div>
<Suspense fallback={<LoadingProducts />}>
<ProductList />
</Suspense>
</div>
);
}
// app/components/ProductList.js (서버 컴포넌트 - 데이터 패칭 필요)
import { getProducts } from "@/lib/api";
async function ProductList() {
const products = await getProducts();
return (
<div className="product-list">
<h2>Featured Products</h2>
{products.map((product) => (
<div key={product.id}>{product.name}</div>
))}
</div>
);
}
// app/components/LoadingProducts.js (서버 컴포넌트 - 로딩 UI)
function LoadingProducts() {
return <div>Loading products...</div>;
}위 코드에서 <Suspense>로 감싸진 <ProductList />는 별도의 청크로 처리됩니다. 초기 로드 시에는 <LoadingProducts />가 먼저 렌더링되어 HTML에 포함되고, 데이터 패칭이 완료되면 <ProductList />의 RSC Payload가 클라이언트로 전송되어 해당 부분을 업데이트합니다.
Streaming SSR과 클라이언트 컴포넌트
클라이언트 컴포넌트도 서버에서 초기 HTML이 생성되지만, 이후 처리 과정이 서버 컴포넌트와는 다릅니다:
- 초기 HTML 생성: 서버에서 클라이언트 컴포넌트의 초기 HTML을 생성합니다.
- JavaScript 번들 전송: 컴포넌트의 JavaScript 코드가 클라이언트로 전송됩니다.
- Hydration: 브라우저에서 JavaScript가 실행되면서 이벤트 핸들러 연결 등 상호작용이 가능한 상태로 전환됩니다.
반면 서버 컴포넌트는:
- 서버 렌더링: 서버에서 완전히 렌더링되어 HTML 생성
- 최소한의 JavaScript: 컴포넌트 코드가 클라이언트로 전송되지 않음
- RSC Payload: 렌더링 결과만 특별한 형식으로 전송
따라서 Streaming SSR 환경에서 <Suspense>를 활용하면 두 컴포넌트 모두 점진적 렌더링이 가능하며, 폴백 UI를 먼저 보여주고 실제 컨텐츠로 교체하는 방식으로 사용자 경험을 개선할 수 있습니다.
예시:
// app/page.js (서버 컴포넌트)
import { Suspense } from "react";
import ClientCounter from "./components/ClientCounter";
import LoadingCounter from "./components/LoadingCounter";
export default async function HomePage() {
return (
<div>
<h1>Welcome!</h1>
<Suspense fallback={<LoadingCounter />}>
<ClientCounter />
</Suspense>
</div>
);
}
// app/components/ClientCounter.js (클라이언트 컴포넌트)
("use client");
import { useState, useEffect } from "react";
function ClientCounter() {
const [count, setCount] = useState(0);
useEffect(() => {
console.log("Client Counter Mounted");
}, []);
return (
<div>
<h2>Client Counter</h2>
<p>Count: {count}</p>
<button onClick={() => setCount(count + 1)}>Increment</button>
</div>
);
}
// app/components/LoadingCounter.js (서버 컴포넌트 - 로딩 UI)
function LoadingCounter() {
return <p>Loading counter...</p>;
}위 예시에서 <ClientCounter />는 클라이언트 컴포넌트입니다. <Suspense>로 감싸져 있기 때문에, 초기 로딩 시 서버는 <LoadingCounter /> (서버 컴포넌트)를 먼저 렌더링하여 전달합니다. 클라이언트 측에서 JavaScript가 로드되고 ClientCounter가 hydration된 후에 실제 카운터 UI가 나타납니다. 이는 사용자가 페이지의 다른 부분을 먼저 볼 수 있도록 하여 초기 로딩 경험을 개선합니다.
결론
결론적으로, Streaming SSR은 SSR이 가지는 한계점을 해결하고, Next.js 애플리케이션의 성능과 사용자 경험을 향상시키는 렌더링 전략입니다. 서버 컴포넌트와 <Suspense>의 사용을 통해 개발자는 데이터 패칭으로 인한 렌더링 지연을 효과적으로 관리하고, 사용자에게 빠르고 점진적인 콘텐츠 제공을 통해 향상된 웹 경험을 선사할 수 있습니다.