Streaming SSR과 Selective Hydration — 서버 렌더링의 미래
SSR에서 데이터베이스 쿼리가 3초 걸리는 컴포넌트가 하나 있다면, 전체 페이지의 HTML 전송도 3초 후에야 시작될까요?
전통적인 SSR에서는 "전부 아니면 전무(all-or-nothing)"였습니다. 하나의 느린 데이터 소스가 전체 페이지의 렌더링을 블로킹했습니다. React 18의 Streaming SSR과 Selective Hydration은 이 한계를 근본적으로 해결합니다.
전통 SSR의 4단계 병목
기존 SSR은 네 가지 단계가 순차적으로 진행되며, 각 단계가 전체를 블로킹합니다.
1단계: 서버에서 모든 데이터 페칭
서버: Header 데이터 fetch (0.1s) ✓
서버: 메인 콘텐츠 fetch (0.3s) ✓
서버: 댓글 fetch (2.5s) ← 여기서 모든 것이 멈춤
서버: Footer 데이터 fetch (대기 중...)
2단계: 전체 HTML 렌더링 및 전송
서버: 전체 HTML 렌더링 → 3초 후에야 첫 바이트 전송
클라이언트: 3초 동안 빈 화면
3단계: 모든 JavaScript 로드
클라이언트: main.js (200KB) 다운로드 + 파싱
클라이언트: 모든 JS가 로드될 때까지 hydration 불가
4단계: 전체 Hydration
클라이언트: 전체 트리를 한 번에 hydrate
클라이언트: hydration 완료될 때까지 인터랙션 불가
이 모든 단계에서 "전부 완료될 때까지 다음으로 진행 불가" 라는 제약이 있었습니다.
Streaming SSR — renderToPipeableStream
// 서버
import { renderToPipeableStream } from 'react-dom/server';
app.get('/', (req, res) => {
const { pipe } = renderToPipeableStream(
<App />,
{
bootstrapScripts: ['/main.js'],
onShellReady() {
// 셸(Suspense fallback 포함)이 준비되면 즉시 전송 시작
res.setHeader('Content-Type', 'text/html');
pipe(res);
},
onError(error) {
console.error(error);
},
}
);
});
서버 Suspense
function App() {
return (
<html>
<body>
<Header /> {/* 즉시 렌더링 */}
<MainContent /> {/* 즉시 렌더링 */}
<Suspense fallback={<CommentsSkeleton />}>
<Comments /> {/* 데이터 로딩이 느림 */}
</Suspense>
<Footer /> {/* 즉시 렌더링 */}
</body>
</html>
);
}
스트리밍 동작
[0.1초] 서버 → 클라이언트:
<html>
<body>
<header>...</header>
<main>...</main>
<div id="comments-placeholder">
<!-- CommentsSkeleton -->
<div class="skeleton">로딩 중...</div>
</div>
<footer>...</footer>
<!-- JavaScript 로드 시작 -->
<script src="/main.js"></script>
</body>
</html>
사용자: 0.1초 만에 페이지의 대부분을 볼 수 있음!
[2.5초] 서버 → 클라이언트 (추가 스트리밍):
<div hidden id="comments-content">
<!-- 실제 댓글 HTML -->
<div class="comment">좋은 글이네요!</div>
<div class="comment">감사합니다</div>
</div>
<script>
// fallback을 실제 콘텐츠로 교체
document.getElementById('comments-placeholder').replaceWith(
document.getElementById('comments-content')
);
</script>
핵심: 댓글이 준비되기 전에 나머지 페이지가 먼저 표시됩니다. 댓글 데이터가 준비되면 인라인 스크립트와 함께 스트리밍되어 스켈레톤을 실제 콘텐츠로 교체합니다.
Selective Hydration
기존: 전체 Hydration
HTML 도착 → JS 로드 → 전체 hydrate → 인터랙션 가능
(500ms 블로킹)
개선: 선택적 Hydration
HTML 도착 → JS 로드 → Header hydrate ✓ (즉시 인터랙션 가능)
→ MainContent hydrate ✓
→ Comments: HTML 아직 미도착, 건너뜀
→ Footer hydrate ✓
댓글 HTML 도착 → Comments hydrate ✓
각 Suspense 바운더리가 독립적으로 hydrate됩니다. 댓글의 HTML이 아직 도착하지 않아도, 나머지 부분은 이미 인터랙티브합니다.
사용자 인터랙션 기반 우선순위
function App() {
return (
<>
<Suspense fallback={<NavSkeleton />}>
<Nav />
</Suspense>
<Suspense fallback={<SidebarSkeleton />}>
<Sidebar /> {/* 사용자가 여기를 클릭! */}
</Suspense>
<Suspense fallback={<ContentSkeleton />}>
<HeavyContent />
</Suspense>
</>
);
}
React는 Nav, Sidebar, HeavyContent 순서로 hydrate하려 했지만, 사용자가 Sidebar를 클릭하면:
- React가
Sidebar의 hydration 우선순위를 높입니다 Sidebar를 먼저 hydrate합니다- 클릭 이벤트를 처리합니다
- 나머지 컴포넌트를 이어서 hydrate합니다
사용자 인터랙션이 hydration 순서를 동적으로 변경합니다.
전체 그림
전통 SSR:
데이터 페칭 ▓▓▓▓▓▓▓▓▓▓▓▓▓
HTML 렌더링 ▓▓▓▓▓
전송 ▓▓
JS 로드 ▓▓▓▓
Hydration ▓▓▓▓
인터랙션 가능 ✓
12초
Streaming SSR + Selective Hydration:
셸 렌더링 ▓▓
전송 시작 ▓→→→→ (스트리밍)
JS 로드 ▓▓▓▓
부분 Hydration ▓▓
인터랙션 가능 (부분) ✓
느린 데이터 ▓▓▓▓▓▓▓
나머지 스트리밍 ▓
나머지 Hydration ▓
전체 인터랙션 가능 ✓
5초
Next.js에서의 활용
Next.js App Router는 이 모든 기능을 기본으로 활용합니다.
// app/page.js
import { Suspense } from 'react';
export default function Page() {
return (
<main>
<h1>대시보드</h1>
<Suspense fallback={<StatsSkeleton />}>
<Stats /> {/* 서버에서 스트리밍 */}
</Suspense>
<Suspense fallback={<ChartSkeleton />}>
<Chart /> {/* 별도로 스트리밍 */}
</Suspense>
</main>
);
}
// Stats는 async 서버 컴포넌트
async function Stats() {
const data = await fetchStats(); // 서버에서 데이터 페칭
return <div>{data.total} 사용자</div>;
}
기존 SSR과의 비교
| 특성 | renderToString | renderToPipeableStream |
|---|---|---|
| 전송 시작 | 전체 완료 후 | 셸 준비 시 즉시 |
| 느린 컴포넌트 | 전체 블로킹 | Suspense로 분리 |
| Hydration | 전체 일괄 | Suspense 단위 선택적 |
| 인터랙션 우선순위 | 없음 | 사용자 행동에 따라 동적 |
| TTFB | 느림 | 빠름 |
정리
Streaming SSR과 Selective Hydration은 SSR의 "전부 아니면 전무" 한계를 해결합니다.
- Streaming SSR: 준비된 HTML을 즉시 전송하고, 느린 부분은 나중에 스트리밍합니다
- ** 서버 Suspense**: fallback을 먼저 보내고, 데이터 준비 후 실제 콘텐츠로 교체합니다
- Selective Hydration: Suspense 단위로 독립적으로 hydrate하여, 일부가 느려도 나머지는 인터랙티브합니다
- ** 사용자 인터랙션 우선순위 **: 사용자가 클릭한 영역의 hydration을 우선 처리합니다
renderToPipeableStream이renderToString을 대체하는 새 표준입니다
주의할 점
Suspense 바운더리의 배치가 성능을 결정
Suspense를 너무 넓게 감싸면 작은 데이터 하나가 느려도 큰 영역이 fallback으로 교체됩니다. 느릴 수 있는 데이터 의존성만 ** 정확히 감싸야** 나머지 영역이 즉시 인터랙티브해집니다.
renderToString을 계속 사용하면 Streaming의 이점을 누릴 수 없음
renderToString은 전체 HTML이 완성될 때까지 기다리므로, Streaming SSR의 점진적 전송 이점이 없습니다. renderToPipeableStream으로 전환해야 합니다.
이 모든 최적화의 핵심은 Suspense 바운더리를 어디에 배치하느냐입니다. 느릴 수 있는 데이터 의존성을 Suspense로 감싸면, React가 나머지를 자동으로 최적화합니다.