Search

06-2_Streaming_SSR

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하는 거예요.
개발자가 알아야 할 것: 아래는 React 내부 구현이에요.
실제 개발할 때는 이 코드를 직접 작성할 필요가 없어요. 그냥 <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)