React Server Components의 스트리밍과 Suspense: 진짜 점진적 렌더링의 시대
2024년 11월 15일
reactserver-componentssuspensestreamingssrperformance
React Server Components의 스트리밍과 Suspense 통합: 진짜 점진적 렌더링의 시대
1. 서론: 흰 화면의 공포
// 전통적인 SSR
export async function getServerSideProps() {
const user = await fetchUser();
const posts = await fetchPosts();
const recommendations = await fetchRecommendations();
const analytics = await fetchAnalytics();
// 모든 데이터가 준비될 때까지 사용자는 흰 화면을 본다
return { props: { user, posts, recommendations, analytics } };
}
사용자는 얼마나 기다려야 할까? 가장 느린 API가 페이지 전체의 로딩 시간을 결정한다.
이것이 전통적인 SSR의 "폭포수(Waterfall)" 문제다:
- 서버에서 모든 데이터를 가져온다
- 서버에서 모든 HTML을 생성한다
- 클라이언트가 모든 HTML을 다운로드한다
- 클라이언트가 모든 JavaScript를 로드한다
- 클라이언트가 모든 컴포넌트를 hydrate한다
"모든"이라는 단어가 문제다. 하나라도 느리면 전체가 느려진다.
React 18과 Server Components는 이 문제를 근본적으로 다시 생각했다: "왜 모든 걸 기다려야 하지?"
2. Streaming SSR: HTML을 조각으로 보내기
전통적 SSR vs Streaming SSR
// 전통적 SSR: 한 번에 모든 것을
app.get('/', async (req, res) => {
const html = await renderToString(<App />);
res.send(`
<!DOCTYPE html>
<html>
<body>
<div id="root">${html}</div>
</body>
</html>
`);
});
// Streaming SSR: 준비되는 대로
app.get('/', (req, res) => {
const stream = renderToPipeableStream(<App />, {
bootstrapScripts: ['/main.js'],
onShellReady() {
res.setHeader('Content-Type', 'text/html');
stream.pipe(res);
}
});
});
스트리밍의 마법: Transfer-Encoding: chunked
HTTP/1.1 200 OK
Transfer-Encoding: chunked
1a\r\n
<html><body><div id="root">
\r\n
2f\r\n
<nav>...</nav><main><div class="spinner">Loading...</div>
\r\n
45\r\n
<!--$?--><template id="B:0"></template><div hidden id="S:0">
\r\n
브라우저는 청크를 받는 즉시 렌더링을 시작한다!
3. Suspense와의 완벽한 결합
Server Components에서 Suspense 사용하기
// app/page.js (Server Component)
export default async function HomePage() {
return (
<div>
<Header /> {/* 즉시 렌더링 */}
<Suspense fallback={<PostsSkeleton />}>
<Posts /> {/* 데이터를 기다리며 스트리밍 */}
</Suspense>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar /> {/* 독립적으로 스트리밍 */}
</Suspense>
</div>
);
}
// Posts는 비동기 Server Component
async function Posts() {
const posts = await fetch('/api/posts');
return <PostList posts={posts} />;
}
실제로 무슨 일이 일어나는가?
- 초기 Shell 전송
<div>
<header>...</header>
<div class="posts-skeleton">Loading...</div>
<div class="sidebar-skeleton">Loading...</div>
</div>
- Posts 데이터 준비 완료 시
<script>
// React가 이해하는 특별한 스크립트
$RC("B:0", "S:0", <PostList posts={...} />)
</script>
- 브라우저에서 자동 교체
- Skeleton → 실제 컨텐츠로 부드럽게 전환
- 다른 부분은 계속 로딩 중
4. RSC의 스트리밍 프로토콜 분석
RSC Wire Format 들여다보기
// 실제 RSC 페이로드 예시
0:["$","div",null,{"children":[
["$","header",null,{"children":"My App"}],
["$","$Suspense",null,{
"fallback":["$","div",null,{"children":"Loading..."}],
"children":"$L1"
}]
]}]
1:["$","div",null,{"children":["Post 1","Post 2"]}]
RSC 프로토콜의 핵심:
0:
- 청크 ID$
- React 엘리먼트 마커$Suspense
- 특별한 Suspense 마커$L1
- 나중에 스트리밍될 참조
점진적 하이드레이션
// React가 생성하는 인라인 스크립트
<script>
function $RC(a, b, c) {
// a: 대체할 template ID
// b: Suspense boundary ID
// c: 새로운 컨텐츠
const template = document.getElementById(a);
const suspenseBoundary = document.getElementById(b);
// 실제 DOM으로 변환
const newContent = hydrate(c);
// Suspense boundary 교체
suspenseBoundary.replaceWith(newContent);
}
</script>
5. 실전 패턴: 우선순위 기반 스트리밍
중요한 것 먼저, 덜 중요한 것은 나중에
export default async function ProductPage({ params }) {
return (
<>
{/* 핵심 정보는 즉시 */}
<ProductHeader productId={params.id} />
{/* 구매 결정에 중요한 정보는 빠르게 */}
<Suspense fallback={<PriceSkeleton />}>
<ProductPrice productId={params.id} />
</Suspense>
{/* 부가 정보는 천천히 */}
<Suspense fallback={<DetailsSkeleton />}>
<ProductDetails productId={params.id} />
</Suspense>
{/* 추천 상품은 가장 나중에 */}
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations productId={params.id} />
</Suspense>
</>
);
}
병렬 데이터 페칭
// ❌ 순차적 - Waterfall
async function BadProduct({ id }) {
const product = await fetchProduct(id);
const reviews = await fetchReviews(id);
const related = await fetchRelated(id);
return <ProductView {...{product, reviews, related}} />;
}
// ✅ 병렬 - 각자 독립적으로
function GoodProduct({ id }) {
return (
<>
<Suspense fallback={<ProductInfoSkeleton />}>
<ProductInfo id={id} />
</Suspense>
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews id={id} />
</Suspense>
<Suspense fallback={<RelatedSkeleton />}>
<RelatedProducts id={id} />
</Suspense>
</>
);
}
6. 고급 패턴: 스트리밍 최적화
1. 중첩된 Suspense로 점진적 개선
function ArticlePage() {
return (
<Suspense fallback={<ArticleSkeleton />}>
<Article>
{/* 텍스트는 빠르게 로드 */}
<ArticleContent />
{/* 이미지는 별도로 스트리밍 */}
<Suspense fallback={<ImagePlaceholder />}>
<ArticleImages />
</Suspense>
{/* 댓글은 가장 나중에 */}
<Suspense fallback={<CommentsLoading />}>
<Comments />
</Suspense>
</Article>
</Suspense>
);
}
2. 조건부 스트리밍
async function ConditionalContent({ user }) {
return (
<>
<WelcomeMessage user={user} />
{user.isPremium ? (
<Suspense fallback={<PremiumContentLoading />}>
<PremiumContent userId={user.id} />
</Suspense>
) : (
<Suspense fallback={<StandardContentLoading />}>
<StandardContent />
</Suspense>
)}
</>
);
}
3. 에러와 함께 스트리밍
// error.js
'use client';
export default function Error({ error, reset }) {
return (
<div className="error-boundary">
<h2>Something went wrong!</h2>
<button onClick={reset}>Try again</button>
</div>
);
}
// 컴포넌트에서 에러 발생 시
async function RiskyComponent() {
const data = await fetchDataThatMightFail();
if (!data) {
throw new Error('Failed to load data');
}
return <DataDisplay data={data} />;
}
7. 성능 측정과 최적화
스트리밍 메트릭스
// 각 청크의 타이밍 측정
export function measureStreamingPerformance() {
if (typeof window === 'undefined') return;
// Navigation Timing API
const navigation = performance.getEntriesByType('navigation')[0];
// 첫 바이트까지의 시간 (TTFB)
const ttfb = navigation.responseStart - navigation.requestStart;
// 각 청크 도착 시간 추적
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.name.includes('chunk')) {
console.log(`Chunk ${entry.name} arrived at ${entry.startTime}ms`);
}
}
});
observer.observe({ entryTypes: ['resource'] });
}
실제 성능 개선 사례
// Before: 모든 데이터 대기
// Time to First Byte: 2.3s
// First Contentful Paint: 2.5s
// Largest Contentful Paint: 2.8s
// After: 스트리밍 적용
// Time to First Byte: 0.3s
// First Contentful Paint: 0.5s
// Largest Contentful Paint: 1.2s
8. 주의사항과 한계
1. SEO 고려사항
// 중요한 SEO 컨텐츠는 Suspense 밖에
export default function ProductPage() {
return (
<>
{/* SEO 크리티컬 컨텐츠 */}
<h1>{product.title}</h1>
<meta name="description" content={product.description} />
{/* 나머지는 스트리밍 가능 */}
<Suspense fallback={<ReviewsSkeleton />}>
<Reviews />
</Suspense>
</>
);
}
2. 네트워크 오버헤드
// 너무 많은 작은 청크는 오히려 해로울 수 있다
// ❌ Bad: 너무 세분화
<Suspense><TinyComponent1 /></Suspense>
<Suspense><TinyComponent2 /></Suspense>
<Suspense><TinyComponent3 /></Suspense>
// ✅ Good: 의미있는 단위로 그룹화
<Suspense>
<RelatedComponents>
<TinyComponent1 />
<TinyComponent2 />
<TinyComponent3 />
</RelatedComponents>
</Suspense>
9. 디버깅과 개발 도구
React DevTools에서 스트리밍 추적
// Chrome DevTools Console에서
__REACT_DEVTOOLS_GLOBAL_HOOK__.onCommitFiberRoot = (id, root) => {
const suspenseBoundaries = [];
function traverse(fiber) {
if (fiber.tag === 19) { // SuspenseComponent
suspenseBoundaries.push({
name: fiber.elementType,
state: fiber.memoizedState,
fallback: fiber.memoizedProps.fallback
});
}
if (fiber.child) traverse(fiber.child);
if (fiber.sibling) traverse(fiber.sibling);
}
traverse(root.current);
console.log('Active Suspense boundaries:', suspenseBoundaries);
};
10. 미래: React의 스트리밍 로드맵
곧 올 기능들
-
Selective Hydration 개선
- 사용자 상호작용 기반 우선순위
- 뷰포트 기반 하이드레이션
-
Server Components Streaming 최적화
- HTTP/3 지원
- 더 스마트한 청킹 알고리즘
-
개발자 경험 향상
- 더 나은 에러 메시지
- 스트리밍 성능 프로파일러
11. 결론: 진짜 점진적 웹의 시작
// 과거: All or Nothing
const html = await renderEverything();
res.send(html);
// 현재: 준비되는 대로
<Suspense>
<ShowWhenReady />
</Suspense>
// 미래: 사용자가 필요한 것부터
<PrioritizedSuspense priority="high">
<CriticalContent />
</PrioritizedSuspense>
React Server Components와 Suspense의 통합은 단순한 기능 추가가 아니다. 웹 애플리케이션이 콘텐츠를 전달하는 방식의 패러다임 전환이다.
더 이상 사용자를 기다리게 하지 마라. 준비된 것부터 보여주고, 나머지는 따라오게 하라.
이것이 진짜 "점진적 향상(Progressive Enhancement)"이다.
"The best time to show content is as soon as it's ready."