Streaming SSR: 기다리지 않고 보내기
Streaming SSR이란?
Streaming SSR은 전체 HTML이 완성될 때까지 기다리지 않고,
준비된 부분부터 즉시 브라우저로 전송하는 렌더링 방식이에요.
핵심 아이디어: “다 만들고 보내기” → “만드는 대로 보내기”
React 내부에서 이 역할을 담당하는 것이 Fizz 렌더러예요.
참고: Fizz와 Flight
- Fizz: Streaming SSR 렌더러 (HTML 스트리밍) ← 이 장의 주제
- Flight: RSC 렌더러 (가상돔 직렬화) ← 다음 장의 주제
둘은 별개의 메커니즘이에요. — Fizz & Flight
왜 필요한가?
앞 장에서 직렬화의 한계를 봤어요. 함수는 전송할 수 없고, 이벤트 핸들러는 Hydration으로 붙여야 한다는 것. 하지만 직렬화에는 또 다른 문제가 있어요.
// 전통적인 SSR
const html = renderToString(<App />); // ← 완료될 때까지 기다림
res.send(html); // ← 그 다음에야 전송
JavaScript
복사
renderToString은 동기 함수예요. 전체 HTML이 완성될 때까지 아무것도 보내지 않아요. 만약 페이지에 느린 데이터 요청이 있다면?
async function Page() {
const user = await fetchUser(); // 100ms
const posts = await fetchPosts(); // 500ms
const comments = await fetchComments(); // 300ms
return (
<div>
<Header user={user} />
<PostList posts={posts} />
<Comments comments={comments} />
</div>
);
}
JavaScript
복사
총 900ms를 기다린 후에야 HTML 전송이 시작돼요. 사용자는 그동안 빈 화면을 봐요.
청크 단위 전송의 아이디어
해결책은 단순해요. 준비된 부분부터 먼저 보내자.
전통적 SSR: [──────대기──────] → [전송]
900ms 한 번에
Streaming SSR: [전송][──전송──][──전송──]
즉시 준비되면 준비되면
Plain Text
복사
HTTP/1.1의 Transfer-Encoding: chunked를 사용하면 응답 크기를 미리 알려주지 않고도 데이터를 조각(청크) 단위로 전송할 수 있어요.
HTTP/1.1 200 OK
Transfer-Encoding: chunked
1a
<html><body><header>
1f
<div id="content">Loading...
0
Plain Text
복사
각 청크는 크기(16진수) + 데이터로 구성돼요. 크기가 0이면 전송 완료예요.
renderToString의 한계
renderToString이 동기 함수인 건 우연이 아니에요. 2015년 React SSR이 처음 나왔을 때, JavaScript에는 아직 async/await가 없었어요. 그리고 더 근본적인 문제가 있어요.
function App() {
const [data, setData] = useState(null);
useEffect(() => {
fetch('/api/data').then(res => setData(res));
}, []);
return<div>{data ?<Content data={data} /> : 'Loading...'}</div>;
}
JavaScript
복사
이 컴포넌트를 서버에서 렌더링하면 어떻게 될까요?
1.
useState(null) → data는 null
2.
useEffect는 서버에서 실행되지 않음
3.
결과: <div>Loading...</div>
서버에서는 “Loading…” 상태만 렌더링돼요. 데이터를 기다릴 방법이 없으니까요.
Node.js는 싱글 스레드예요. renderToString이 실행되는 동안 다른 요청은 모두 대기해요.
요청 A → [──renderToString 500ms──]
요청 B → [──renderToString 500ms──]
요청 C → [──...
Plain Text
복사
트래픽이 몰리면 대기열이 급격히 늘어나요. 이것이 “SSR 서버가 느리다”는 평판의 구조적 원인이에요.
React 18: renderToPipeableStream
React 18은 renderToPipeableStream을 도입했어요. 이름에서 알 수 있듯이, 스트림(stream)으로 렌더링해요.
import { renderToPipeableStream } from 'react-dom/server';
app.get('/', (req, res) => {
const { pipe } = renderToPipeableStream(<App />, {
bootstrapScripts: ['/main.js'],
onShellReady() {
res.setHeader('Content-Type', 'text/html');
pipe(res);
}
});
});
JavaScript
복사
핵심 차이점:
- renderToString: 완료될 때까지 블로킹 → 한 번에 전송
- renderToPipeableStream: 준비된 부분부터 → 점진적 전송
Suspense: 비동기의 일급 처리
스트리밍이 가능하려면 “무엇이 준비되었고, 무엇이 아직 기다리는 중인지” 표현할 방법이 필요해요. 이것이 Suspense예요.
function Page() {
return (
<div>
<Header /> {/* 즉시 렌더링 */}
<Suspense fallback={<Loading />}>
<SlowComponent /> {/* 느림 - 나중에 */}
</Suspense>
</div>
);
}
JavaScript
복사
Suspense는 “이 부분은 기다려야 할 수도 있다”고 선언해요. 스트리밍 SSR에서:
1.
Shell(껍데기): Suspense 바깥 부분 → 즉시 전송
2.
Fallback: Suspense 내부의 로딩 상태 → 즉시 전송
3.
Content: 실제 컨텐츠 → 준비되면 나중에 전송
응답 분석: 세 개의 청크
아래 섹션들은 Streaming SSR의 내부 동작을 설명해요.
실무에서 직접 다룰 일은 거의 없지만, 원리를 이해하고 싶은 분을 위해 포함했어요.
건너뛰어도 돼요. 실제 개발할 때는 <Suspense>만 사용하면 React가 알아서 처리해요.
Next.js App Router로 Streaming SSR 응답을 분석하면 대략 이런 구조예요.
첫 번째 청크 (즉시)
<!DOCTYPE html>
<html>
<head>...</head>
<body>
<div id="root">
<header>Welcome</header>
<!--$?-->
<template id="B:0"></template>
<div>Loading...</div>
<!--/$-->
</div>
</body>
</html>
HTML
복사
•
<!--$?-->: Suspense 경계 시작 표시
•
<template id="B:0">: 나중에 컨텐츠가 들어갈 위치
•
Loading...: fallback 컨텐츠
두 번째 청크 (데이터 준비 후)
<div hidden id="S:0">
<article>
<h1>실제 포스트 제목</h1>
<p>실제 포스트 내용...</p>
</article>
</div>
<script>
$RC("B:0", "S:0")
</script>
HTML
복사
•
<div hidden id="S:0">: 실제 컨텐츠 (처음에는 숨김)
•
$RC("B:0", "S:0"): “B:0 위치에 S:0 컨텐츠를 넣어라”
세 번째 청크 (완료)
<script>
// Hydration 시작
// 이벤트 핸들러 연결
</script>
HTML
복사
$RC: 플레이스홀더 교체 로직
$RC는 React의 내부 함수예요. 단순화하면 이렇게 동작해요:
function $RC(boundaryId, contentId) {
// 1. 플레이스홀더 찾기
const template = document.getElementById(boundaryId);
const boundary = template.previousSibling; // <!--$?-->
// 2. 실제 컨텐츠 가져오기
const content = document.getElementById(contentId);
// 3. 로딩 상태 제거
const loading = template.nextSibling;
loading.remove();
// 4. 실제 컨텐츠 삽입
boundary.parentNode.insertBefore(content, template);
content.hidden = false;
// 5. 정리
template.remove();
boundary.remove();
// 6. Hydration 트리거
if (boundary._reactRetry) {
boundary._reactRetry();
}
}
JavaScript
복사
핵심은 서버에서 보낸 스크립트가 DOM을 조작한다는 점이에요. 클라이언트 JavaScript가 로드되기 전에도 컨텐츠 교체가 일어나요.
Suspense의 동작 원리
Suspense가 어떻게 “기다림”을 감지할까요? 답은 Promise를 throw하는 거예요.
실제 개발할 때는 이 코드를 직접 작성할 필요가 없어요.
그냥 <Suspense>로 감싸면 React가 알아서 처리해요.
원리를 이해하고 싶은 분만 읽어보세요. 건너뛰어도 Streaming SSR을 사용하는 데 문제없어요.
// 개념적인 의사코드 (React 내부 구현을 단순화한 것)
let cache = new Map();
let pending = new Map();
function fetchData(url) {
// 이미 캐시에 있으면 반환
if (cache.has(url)) {
return cache.get(url);
}
// 대기 중인 Promise가 있으면 throw
if (pending.has(url)) {
throw pending.get(url); // ← Promise를 throw!
}
// 새로운 요청 시작
const promise = fetch(url)
.then(res => res.json())
.then(data => {
pending.delete(url);
cache.set(url, data);
});
pending.set(url, promise);
throw promise; // ← Promise를 throw!
}
JavaScript
복사
“Promise를 throw한다고?” — 이상하게 보이지만, 이건 React 팀이 선택한 내부 메커니즘이에요. 에러 처리와 비슷하게 동작하지만, 에러가 아니라 “아직 준비 안 됨” 신호로 사용돼요.
React의 렌더링 루프 (역시 내부 구현):
async function renderWithSuspense(component) {
for (;;) {
try {
return render(component); // 렌더링 시도
} catch (x) {
if (x instanceof Promise) {
await x; // Promise면 기다림
// 그리고 다시 렌더링 시도
} else {
throw x; // 일반 에러면 전파
}
}
}
}
JavaScript
복사
이 패턴 덕분에:
1. 데이터가 없으면 → Promise throw → fallback 렌더링
2. Promise 해결되면 → 다시 렌더링 → 실제 컨텐츠
핵심: 개발자는 <Suspense>만 사용하면 돼요. 내부적으로 어떻게 동작하는지 몰라도 괜찮아요.
Fizz: React의 Streaming SSR 엔진
React 팀은 내부적으로 이 Streaming SSR 엔진을 Fizz라고 불러요.
Fizz의 역할:
- 컴포넌트 트리를 HTML 스트림으로 변환
- Suspense 경계를 플레이스홀더로 렌더링
- Promise가 해결되면 후속 청크 생성
- 클라이언트 측 교체 스크립트 삽입
[React 컴포넌트 트리]
↓
[Fizz]
↓
[HTML 청크 스트림] → 네트워크 → 브라우저
Plain Text
복사
Fizz는 서버 전용이 아니에요. 클라이언트에서도 $RC 같은 교체 로직을 처리해요.
Streaming SSR의 장점
1. Time To First Byte (TTFB) 개선
renderToString: [──전체 렌더링──] → [전송 시작]
500ms
renderToPipeableStream: [전송 시작] → [──점진적 전송──]
50ms
Plain Text
복사
Shell이 준비되면 즉시 전송을 시작해요. 느린 데이터를 기다리지 않아요.
2. 서버 부하 분산
동기 renderToString은 완료될 때까지 메인 스레드를 점유해요. 스트리밍은 청크 사이에 다른 요청을 처리할 수 있어요.
Streaming:
요청 A: [청크1] [청크2] [청크3]
요청 B: [청크1] [청크2] [청크3]
Plain Text
복사
3. 점진적 사용자 경험
사용자는 빈 화면 대신 로딩 상태를 보고, 점진적으로 컨텐츠가 채워지는 걸 봐요.
0ms: [헤더] [로딩...] [로딩...]
200ms: [헤더] [포스트들] [로딩...]
500ms: [헤더] [포스트들] [댓글들]
Plain Text
복사
Selective Hydration
Streaming SSR의 또 다른 이점은 Selective Hydration이에요.
기존 Hydration:
HTML 로드 → JS 로드 → 전체 Hydration → 인터랙션 가능
Plain Text
복사
Selective Hydration:
HTML 로드 → JS 로드 → 부분 Hydration → 부분 인터랙션 가능
↓
나머지 Hydration → 전체 인터랙션 가능
Plain Text
복사
Suspense 경계별로 Hydration이 가능해져요. 그리고 사용자가 클릭한 부분을 우선적으로 Hydration해요.
<div>
<Header /> {/* 즉시 Hydration */}
<Suspense>
<SideNav /> {/* 나중에 Hydration */}
</Suspense>
<Suspense>
<MainContent /> {/* 사용자가 클릭하면 우선 Hydration */}
</Suspense>
</div>
JavaScript
복사
비교: 렌더링 방식들
방식 | TTFB | FCP | TTI | 서버 부하 |
CSR | 빠름 | 느림 | 느림 | 낮음 |
Static SSR (renderToString) | 느림 | 빠름 | 중간 | 높음 |
Streaming SSR | 빠름 | 빠름 | 중간 | 중간 |
실제 사용: Next.js App Router
Next.js App Router는 기본적으로 Streaming SSR을 사용해요.
// app/page.tsx
import { Suspense } from 'react';
async function Posts() {
const posts = await fetch('/api/posts').then(r => r.json());
return<PostList posts={posts} />;
}
export default function Page() {
return (
<div>
<Header />
<Suspense fallback={<PostsSkeleton />}>
<Posts />
</Suspense>
</div>
);
}
JavaScript
복사
이 코드는 자동으로:
1. Header를 즉시 스트리밍
2. PostsSkeleton을 fallback으로 전송
3. Posts 데이터가 준비되면 실제 컨텐츠 스트리밍
4. 클라이언트에서 Selective Hydration
정리: Streaming SSR이 해결하는 것
1.
기다림의 문제: 모든 데이터가 준비될 때까지 기다리는 대신, 준비된 것부터 전송
2.
블로킹의 문제: 렌더링이 메인 스레드를 오래 점유하는 대신, 청크 단위로 분할
3.
사용자 경험: 빈 화면 대신 점진적으로 채워지는 UI
하지만 Streaming SSR도 근본적인 한계가 있어요. 여전히:
- 모든 컴포넌트 코드가 클라이언트에 전송되어야 함
- Hydration을 위해 전체 번들이 필요
Fizz의 한계: 번들은 그대로
Fizz가 해결한 것과 해결하지 못한 것을 구분해야 해요.
Fizz (2018) - HTML 스트리밍:
✅ TTFB 개선 (준비된 것부터 전송)
✅ 서버 부하 분산 (청크 단위 처리)
✅ 점진적 사용자 경험
❌ 번들 크기는 그대로
❌ 모든 컴포넌트 코드가 클라이언트로 전송
❌ 서버 전용 로직은 여전히 컴포넌트 밖에
Plain Text
복사
Fizz는 전송 방식을 개선했지, 전송 내용을 줄이지는 못했어요.
// 이 컴포넌트는 서버에서 렌더링되지만...
function ProductPage({ productId }) {
const product = await db.query(`SELECT * FROM products WHERE id = ?`, productId);
return<div>{product.name}</div>;
}
// ...컴포넌트 코드 자체는 클라이언트 번들에 포함됨
// (Hydration을 위해 필요)
JavaScript
복사
이 한계를 해결하는 것이 Flight(2020)이에요.
Flight - 가상돔 직렬화:
✅ 서버 컴포넌트 코드는 번들에 포함 안 됨
✅ 서버 전용 로직을 컴포넌트 안에서 직접 사용
✅ RSC(React Server Components)의 핵심 메커니즘
Plain Text
복사
구분 | Fizz | Flight |
역할 | HTML 스트리밍 | 가상돔 직렬화 |
출력 | HTML 청크 | RSC Payload |
번들 | 모든 컴포넌트 포함 | 서버 컴포넌트 제외 |
서버 전용 로직 | 컴포넌트 밖에서만 | 컴포넌트 안에서 가능 |
06-3_RSC에서 Flight와 RSC를 자세히 다뤄요.
부록: Fizz 마커 정리
마커 | 의미 |
<!--$--> | Suspense 경계 시작 (완료됨) |
<!--$?--> | Suspense 경계 시작 (대기 중) |
<!--$!--> | Suspense 경계 시작 (에러) |
<!--/$--> | Suspense 경계 끝 |
<template id="B:0"> | 컨텐츠 삽입 위치 |
<div hidden id="S:0"> | 대기 중인 실제 컨텐츠 |
$RC("B:0", "S:0") | 컨텐츠 교체 스크립트 |
부록: 관련 React 내부 패키지
패키지 | 역할 |
react-dom/server | 서버 렌더링 API |
react-server | Fizz와 Flight 구현 |
Fizz | HTML 스트리밍 (Streaming SSR) |
Flight | RSC Payload 스트리밍 (RSC) |
