Search

06_Streaming_SSR

Streaming SSR: 기다리지 않고 보내기

앞 장에서 직렬화의 한계를 봤습니다. 함수는 전송할 수 없고, 이벤트 핸들러는 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을 위해 전체 번들이 필요
이 한계를 해결하는 것이 React Server Components입니다. 다음 장에서 다룹니다.

부록: 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)