chapter 05 / 06
Fizz Engine ( 서버 스트리밍 렌더러 )
Fizz는 React 서버 사이드 렌더링 엔진의 이름이다. 우리가 앞서 봤던
renderToPipeableStream, renderToReadableStream, prerender*, resume*이 Fizz 엔진
위에서 돌아간다.
React의 3대 엔진 코드네임

| 코드네임 | 역할 | 관련 API |
|---|---|---|
| Fiber | 클라이언트 재조정(reconciler) — 화면을 그리고 업데이트 | createRoot, hydrateRoot |
| Fizz | 서버 렌더러 — HTML을 스트리밍으로 생성 | renderToPipeableStream, renderToReadableStream, prerender* |
| Flight | React Server Components 직렬화 | RSC (서버 컴포넌트) |
즉 서버에서 HTML을 만드는 건 Fizz, 브라우저에서 그 HTML을 살리는(hydration) 건 Fiber다. SSR 한 사이클은 Fizz → (네트워크) → Fiber로 이어진다.
왜 Fizz가 등장했나
Fizz 이전의 서버 렌더러(renderToString 계열)는 한계가 뚜렷했다.
- 동기 블로킹: 트리 전체를 한 번에 문자열로 만들어야 해서 다 끝나기 전엔 첫 바이트가 안 나갔다 → TTFB 나쁨
- 서버 Suspense 미지원: 컴포넌트가 중단(suspend)되면 기다려주지 못하고 fallback으로 확정
- 블로킹: 동기 함수라 렌더링 중에 이벤트 루프를 놓지 않는다.
React 18에서 이걸 새로 설계한 게 Fizz다. 핵심 목표는 스트리밍 SSR과 서버 Suspense 지원이었다.
Fizz의 핵심 개념
1. 셸(Shell)과 경계(Boundary)

Fizz는 트리를 통짜로 보지 않고 <Suspense>를 기준으로 셸과 경계로 나눈다.
- 셸(shell):
<Suspense>바깥의, 즉시 렌더 가능한 부분. 준비되면 가장 먼저 내보낸다. (onShellReady시점) - 경계(boundary):
<Suspense>안의, 데이터를 기다려야 하는 부분. 준비될 때까지 fallback으로 두었다가 나중에 실제 내용으로 채운다.
2. 세그먼트(Segment)와 태스크(Task)
Fizz는 렌더 작업을 세그먼트/태스크라는 단위로 쪼갠다. 각 Suspense 경계는 별도의 작업 단위가 되어 서로 독립적으로 완료된다. 그래서 하나가 느려도 나머지는 먼저 나간다.
3. 순서에 상관없는 스트리밍 (out-of-order)
경계들은 준비되는 순서대로 스트림에 실린다. 먼저 fallback이 자리를 잡고 있다가 데이터가 풀린 경계부터 실제 HTML이 도착한다. 도착 순서가 화면 순서와 달라도 된다.
이때 HTML에는 경계를 표시하는 주석 마커와 자리표시자가 들어간다:
<!--$-->…<!--/$-->: Suspense 경계의 시작/끝 마커<!--$?-->: 아직 fallback 상태인 (보류된) 경계<template>+ 인라인$RC(...)스크립트 : 나중에 도착한 실제 내용을 fallback 자리로 바꿔치기하는 초소형 런타임
그래서 브라우저는 JS 프레임워크 없이도 Fizz가 심어둔 이 작은 스크립트만으로 fallback을 실제 내용으로 교체한다.
4. 선택적 hydration (Selective Hydration)
Fizz가 경계별로 HTML을 쪼개 보내기 때문에, 클라이언트(Fiber)도 경계 단위로, 순서에 상관없이 hydration할 수 있다. 사용자가 클릭한 부분을 우선적으로 hydration하는 것도 가능하다. 스트리밍 SSR과 선택적 hydration은 이렇게 Fizz ↔ Fiber 협업으로 완성된다.
5. 백프레셔 (Backpressure)
Fizz는 호스트 플랫폼의 스트림에 맞춰 데이터를 내보낸다. 소비자(네트워크/응답)가 느리면 그만큼 생산도 늦춰 메모리 폭증을 막는다. 통짜 문자열을 메모리에 다 들고 있던 legacy 방식과 여기서 갈린다.
Fizz의 구조
2018년 Fizz Renderer(https://github.com/react/react/pull/14144) 초기 구현 로직을 한번 살펴보자.

// packages/react-stream/src/ReactFizzStreamer.js (2018.11)
import {
scheduleWork, beginWriting, writeChunk,
completeWriting, flushBuffered, close,
} from './ReactFizzHostConfig';
import {formatChunk} from './ReactFizzFormatConfig';
import {REACT_ELEMENT_TYPE} from 'shared/ReactSymbols';
type OpaqueRequest = {
destination: Destination,
children: ReactNodeList,
completedChunks: Array<Uint8Array>,
flowing: boolean,
};
export function createRequest(children, destination): OpaqueRequest {
return {destination, children, completedChunks: [], flowing: false};
}
function performWork(request: OpaqueRequest): void {
let element = (request.children: any);
request.children = null;
if (element && element.$$typeof !== REACT_ELEMENT_TYPE) {
return;
}
let type = element.type;
let props = element.props;
if (typeof type !== 'string') {
return;
}
request.completedChunks.push(formatChunk(type, props));
if (request.flowing) {
flushCompletedChunks(request);
}
flushBuffered(request.destination);
}
function flushCompletedChunks(request: OpaqueRequest) {
let destination = request.destination;
let chunks = request.completedChunks;
request.completedChunks = [];
beginWriting(destination);
try {
for (let i = 0; i < chunks.length; i++) {
writeChunk(destination, chunks[i]);
}
} finally {
completeWriting(destination);
}
close(destination);
}
export function startWork(request: OpaqueRequest): void {
request.flowing = true;
scheduleWork(() => performWork(request));
}
export function startFlowing(request: OpaqueRequest, desiredBytes: number): void {
request.flowing = false;
flushCompletedChunks(request);
}- createRequest : destination(목적지), children(어떤 요소), completedChunks(완성된 chunks 배열), flowing(push/pull) 객체를 만든다.
- startWork : flowing=true로 켜고 scheduleWork에 performWork를 예약한다.
- performWork : 실제 렌더링을 맡는다.
request.children을 꺼내null로 비우고 엘리먼트인지·host 타입인지 확인한 뒤formatChunk(type, props)로 바이트 조각 하나를 만들어completedChunks에 넣는다.flowing이 켜져 있으면 바로 flush한다. - flushCompletedChunks :
performWork가 JSX를 문자열로 만들고 바이트로 바꿔completedChunks에 쌓으면flushCompletedChunks가 그걸destination으로 내보낸다. 그 바이트가 HTTP 응답에 실려 브라우저로 간다. - startFlowing : 소비자(Stream)의 버퍼에 여유가 생겼을 때 호출된다. flowing = false로
바꾸고
flushCompletedChunks를 부른다.
참고 자료