React Suspense와 스트리밍 SSR 내부 동작: `renderToPipeableStream`이 HTML 청크를 분할·전송하고 클라이언트가 재조합하는 원리
페이지 응답이 3초씩 걸리는 대시보드를 맡게 된 적이 있었습니다. 처음엔 단순히 API가 느린 거라고 생각해서 캐싱부터 손봤는데, 실제 문제는 다른 곳에 있었습니다. renderToString이 서버에서 전체 React 트리를 메모리에 다 그려낼 때까지 응답을 쥐고 있었던 거죠. 빠른 섹션이 이미 준비되어 있어도, 3초짜리 API 하나가 전부를 묶어버렸습니다. 같은 상황에서 빈 화면만 보다가 포기하는 사용자를 보며, 스트리밍 SSR을 진지하게 들여다보기 시작했습니다.
Next.js App Router를 쓰고 있다면 이미 이 문제에 대한 해법 위에서 코드를 짜고 있는 겁니다. React 18부터 도입된 renderToPipeableStream과 <Suspense>가 그 핵심인데, 솔직히 저도 처음엔 "그냥 로딩 스피너 보여주는 API 아닌가?" 하고 넘어갔습니다. 실제로 어떻게 돌아가는지 들여다보니, React가 HTTP 스트림을 열어두고 거기에 HTML을 조각 조각 덧붙이는 꽤 정교한 방식을 쓰고 있었습니다.
이 글에서는 <Suspense> 경계가 HTML을 어디서 어떻게 잘라내는지, 그 조각들이 클라이언트에서 어떻게 붙어 완성된 페이지가 되는지 내부 동작 수준에서 살펴봅니다. 핵심은 "새로운 네트워크 요청 없이 하나의 열린 HTTP 스트림으로 모든 것이 이루어진다"는 점입니다. 이 원리를 이해하면 Suspense 경계를 어디에 놓아야 할지, 에러를 어디서 잡아야 할지 훨씬 명확하게 판단할 수 있습니다. <Suspense> 기본 개념은 알고 있다고 가정하고 진행합니다.
핵심 개념
전통적 SSR vs 스트리밍 SSR
기존 renderToString 방식의 문제는 단순합니다. 서버가 전체 React 트리를 메모리 안에서 다 렌더링한 뒤에야 HTML 문자열을 클라이언트로 보냅니다. 느린 데이터 소스 하나가 있으면, 빠른 콘텐츠도 같이 묶여서 기다려야 하죠.
[ 전통적 SSR ]
서버: [===== 데이터 로딩 + 전체 렌더링 =====] → HTML 전송 → 클라이언트 수신
↑
3초짜리 API 하나가
전체를 블로킹스트리밍 SSR은 이 순서를 뒤집습니다. 준비된 것부터 먼저 보내고, 나머지는 스트림을 열어둔 채로 나중에 덧붙입니다.
[ 스트리밍 SSR ]
서버: [Shell 렌더링] → 즉시 전송 → [데이터 로딩 중...] → 청크 추가 전송
클라이언트: HTML 수신 → 파싱·렌더링 시작 → 추가 청크 수신 → DOM 업데이트용어: Shell —
<Suspense>경계 바깥에 있는 정적 콘텐츠 영역. 데이터 없이도 즉시 렌더링 가능한 HTML 골격으로, 스트리밍의 첫 번째 청크가 됩니다.
renderToPipeableStream의 청크 분할 메커니즘
<Suspense> 경계가 스트림이 분기되는 "솔기(seam)" 역할을 합니다. 서버 내부 동작을 단계별로 따라가 보면 이렇습니다.
클라이언트 서버
| |
|<-- ① Shell HTML + 스켈레톤 ------| onShellReady에서 pipe(res) 호출 시 즉시
| |
| | ② 비동기 데이터 로딩 중 (서버 내부)
| |
|<-- ③ 실제 콘텐츠 HTML 청크 -------| 데이터 준비되면 같은 스트림으로 추가
|<-- ④ <script>로 DOM 교체 명령 ---| 폴백을 실제 콘텐츠로 스왑이게 처음엔 좀 신기했는데, 실제로 클라이언트에 도착하는 HTML을 보면 하나의 응답 안에 텍스트가 계속 append되는 걸 확인할 수 있습니다. Chrome DevTools Network 탭에서 해당 HTML 응답을 선택하고 "Response" 패널을 열어두면, 응답이 끊기지 않고 내용이 실시간으로 늘어나는 것을 직접 볼 수 있습니다.
<!-- ① 먼저 도착: Shell + 폴백 (스켈레톤) -->
<!DOCTYPE html>
<html>
<body>
<header>...</header>
<!--$?--><template id="B:0"></template>
<div><!-- 스켈레톤 UI --></div><!--/$-->
</body>
</html>
<!-- ③④ 나중에 같은 TCP 연결로 추가 도착 -->
<div hidden id="S:0">
<article>실제 블로그 포스트 목록...</article>
</div>
<script>$RC("B:0","S:0")</script>$RC는 React가 전역에 심어둔 함수로, id="B:0" 폴백 자리에 id="S:0" 숨겨진 콘텐츠를 교체합니다. 새로운 fetch나 WebSocket이 아니라, 처음에 열린 HTTP 응답 스트림에 텍스트를 계속 이어붙이는 방식입니다.
왜 이게 중요한가? HTTP 상태 코드는 응답 헤더에 있고, 헤더는 스트리밍이 시작되는 순간(Shell 전송 시) 이미 전송됩니다. 그래서 스트리밍을 시작한 뒤에는 상태 코드를 바꿀 수 없습니다. Shell 이후 에러가 발생하면 200 응답 안에 에러 UI를 담아야 합니다. 이 부분은 주의사항에서 자세히 다룹니다.
심화: Selective Hydration과 클라이언트 재조합
기본 흐름을 이해한 다음에 살펴봐도 충분한 내용입니다. HTML 청크가 도착한 뒤 React는 단순히 DOM을 업데이트하는 데서 끝나지 않습니다. 하이드레이션 과정도 스트리밍에 맞게 진화했습니다.
| 개념 | 설명 |
|---|---|
| Progressive Hydration | 각 <Suspense> 청크가 도착할 때마다 독립적으로 하이드레이션. 전체 페이지를 블로킹하지 않음 |
| Selective Hydration | 사용자가 클릭·스크롤한 컴포넌트를 하이드레이션 큐에서 우선 처리 |
| 이벤트 보존 | 하이드레이션 전에 발생한 클릭 등의 이벤트는 큐에 보존되어 하이드레이션 후 재실행 |
Selective Hydration이 특히 인상적인 이유는, 무거운 컴포넌트가 하이드레이션되는 도중에 사용자가 다른 곳을 클릭하면 React가 하이드레이션 순서를 즉석에서 바꾼다는 점입니다. 사용자 인터랙션이 "하이드레이션 우선순위 신호"가 되는 거죠.
심화: React Flight Protocol (RSC를 쓴다면)
RSC(React Server Components)를 사용하는 경우에는 HTML이 아닌 별도의 포맷이 스트리밍됩니다. Network 탭에서 text/x-component content-type으로 응답되는 스트림을 열어보면 확인할 수 있습니다.
# Flight 포맷 예시 (실제 스트림 텍스트)
0:["$","div",null,{"children":["$","ul",null,...]}]
1:I["./ClientComponent",[...]]
2:{"id":1,"title":"Hello World"}ID:타입:데이터 형태의 라인이 스트리밍되고, 클라이언트는 이를 조각 참조로 재조립해 React 트리를 구성합니다. 2025년 12월에 이 Flight Payload 역직렬화 과정에서 보안 취약점(CVE-2025-55182)이 발견되어 패치가 이루어졌습니다. RSC를 사용 중이라면 CVE 상세 페이지에서 영향받는 React 버전 범위와 패치 버전을 확인하고 업데이트해두시기 바랍니다.
실전 적용
예시 1: Next.js App Router — loading.tsx로 자동 Suspense 경계
가장 접근하기 쉬운 시작점입니다. 파일 하나 추가로 Suspense 경계가 자동 생성됩니다. "이렇게 간단해?" 싶을 정도라서 저도 처음엔 반신반의했는데, 실제로 효과가 있습니다. 체감 효과 대비 투자 비용이 가장 낮은 방법입니다.
// app/dashboard/loading.tsx
// 이 파일이 존재하는 것만으로 page.tsx 전체가 Suspense로 감싸집니다
export default function Loading() {
return <DashboardSkeleton />;
}// app/dashboard/page.tsx
export default async function Page() {
// 이 fetch가 3초 걸려도 Shell은 이미 클라이언트에 전송된 상태입니다
const data = await fetchSlowData();
return <Dashboard data={data} />;
}3초짜리 API를 가진 페이지라도 사용자는 레이아웃과 스켈레톤을 즉시 보게 됩니다.
예시 2: 컴포넌트 단위 Suspense 경계 — 독립적 스트리밍
대시보드처럼 여러 데이터 소스가 독립적인 경우에 특히 유용합니다. 저도 처음엔 페이지 전체를 하나의 <Suspense>로 감쌌다가 효과가 반감된 경험이 있었는데, 각 섹션을 분리하는 게 핵심이었습니다.
LCP(Largest Contentful Paint) 대상이 되는 히어로 이미지나 메인 타이틀은 반드시 <Suspense> 바깥, 즉 Shell에 포함시켜야 합니다. 스켈레톤 UX를 설계하다 보면 "이것도 로딩 중에 스켈레톤 보여주자"는 생각에 모든 것을 Suspense로 감싸게 되는데, 그렇게 하면 LCP 요소가 나타나는 시점이 지연되면서 오히려 Core Web Vitals 점수가 나빠집니다.
// app/dashboard/page.tsx
export default function Page() {
return (
<>
{/* Shell에 포함 — 데이터 없이 즉시 전송 */}
<Header />
<Navigation />
<HeroSection /> {/* LCP 요소 — Suspense 바깥에 배치 */}
{/* 각각 독립적으로 스트리밍 — 빠른 것이 먼저 나타남 */}
<Suspense fallback={<RecentPostsSkeleton />}>
<RecentPosts />
</Suspense>
<Suspense fallback={<RecommendationsSkeleton />}>
<Recommendations />
</Suspense>
</>
);
}| 컴포넌트 | 전송 시점 | 이유 |
|---|---|---|
<Header />, <Navigation /> |
즉시 (Shell) | 데이터 불필요 |
<HeroSection /> |
즉시 (Shell) | LCP 요소 보호 |
<RecentPosts /> |
데이터 준비 후 | Suspense 내부 |
<Recommendations /> |
데이터 준비 후 (독립) | 별도 Suspense 경계 |
예시 3: Express + renderToPipeableStream 직접 구성
프레임워크 없이 직접 구성할 때의 핵심은 onShellReady 콜백 타이밍과 에러 처리 분기입니다. 콜백이 많아서 복잡해 보이지만, 각 콜백이 언제 호출되는지 알면 오히려 명확합니다.
import { renderToPipeableStream } from 'react-dom/server';
app.get('/', (req, res) => {
let didError = false;
const { pipe, abort } = renderToPipeableStream(<App />, {
bootstrapScripts: ['/main.js'],
onShellReady() {
// pipe() 호출 이후에는 헤더 변경이 불가능합니다
// 모든 헤더 설정은 이 시점에 완료해야 합니다
res.statusCode = didError ? 500 : 200;
res.setHeader('Content-Type', 'text/html');
pipe(res);
},
onShellError(err) {
// Shell 자체가 실패한 경우 — 아직 응답을 보내지 않았으므로 500 처리 가능
res.statusCode = 500;
res.send('<h1>Something went wrong</h1>');
},
onError(err) {
// Shell 이후 청크에서 에러 — 이미 200이 진행 중
didError = true;
console.error(err);
},
});
// 이걸 빼먹으면 서버 메모리가 서서히 채워집니다
setTimeout(abort, 10000);
});onShellReady와 onShellError를 구분하는 게 중요합니다. Shell 렌더링 자체가 실패하면(onShellError) 아직 응답을 보내지 않았으므로 상태 코드를 500으로 설정할 수 있습니다. 반면 Shell 이후 청크에서 에러가 나면(onError), 이미 200 응답이 진행 중이라 상태 코드 변경이 불가능합니다.
예시 4: Error Boundary로 스트리밍 에러 처리
스트리밍 SSR에서 Shell 이후 에러가 발생하면 HTTP 상태 코드는 이미 200이지만, 클라이언트 Error Boundary가 해당 청크의 에러를 잡아 폴백 UI를 보여줄 수 있습니다. Suspense와 Error Boundary는 실무에서 항상 쌍으로 쓰게 됩니다. 서버 에러 핸들링만 신경 쓰다가 클라이언트 Error Boundary를 빠뜨리면, Shell 이후 에러가 그대로 노출되는 상황이 생깁니다.
// components/ErrorBoundary.tsx
'use client';
import { Component, ReactNode } from 'react';
interface Props {
fallback: ReactNode;
children: ReactNode;
}
interface State {
hasError: boolean;
}
class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError(): State {
return { hasError: true };
}
render() {
if (this.state.hasError) {
return this.props.fallback;
}
return this.props.children;
}
}
export default ErrorBoundary;// app/dashboard/page.tsx
import ErrorBoundary from '@/components/ErrorBoundary';
export default function Page() {
return (
<ErrorBoundary fallback={<p>데이터를 불러오지 못했습니다.</p>}>
<Suspense fallback={<PostsSkeleton />}>
<RecentPosts />
</Suspense>
</ErrorBoundary>
);
}예시 5: React 19의 use() Hook으로 Suspense 통합
React 19부터는 use() Hook을 사용해 Promise를 직접 Suspense와 연결할 수 있습니다. 서버 컴포넌트에서는 async/await로 데이터를 받고, 클라이언트 컴포넌트에서 use()로 소비하는 패턴이 가장 자연스럽습니다. use()를 서버 컴포넌트에서 쓰면 async/await가 더 직관적이니, 클라이언트 경계를 명확히 구분해두는 것이 중요합니다.
// app/users/[id]/page.tsx (서버 컴포넌트)
interface User {
id: string;
name: string;
}
async function fetchUserData(id: string): Promise<User> {
const res = await fetch(`/api/users/${id}`);
return res.json();
}
export default function Page({ params }: { params: { id: string } }) {
// 서버 컴포넌트에서 Promise를 만들어 클라이언트로 전달합니다
const userPromise = fetchUserData(params.id);
return (
<Suspense fallback={<UserSkeleton />}>
<UserProfile userPromise={userPromise} />
</Suspense>
);
}// components/UserProfile.tsx (클라이언트 컴포넌트)
'use client';
import { use } from 'react';
interface User {
id: string;
name: string;
}
function UserProfile({ userPromise }: { userPromise: Promise<User> }) {
// Promise가 pending이면 가장 가까운 Suspense를 트리거합니다
const user = use(userPromise);
return <div>{user.name}</div>;
}
export default UserProfile;장단점 분석
장점
| 항목 | 내용 |
|---|---|
| TTFB 개선 | 데이터 완료를 기다리지 않고 Shell을 즉시 전송해 첫 바이트 지연 감소 |
| FCP 향상 | 브라우저가 Shell HTML 수신 즉시 파싱·렌더링 시작 |
| 독립적 로딩 | 느린 데이터 소스가 빠른 콘텐츠 영역을 블로킹하지 않음 |
| Selective Hydration | 사용자 인터랙션 영역을 우선 하이드레이션해 TTI 개선 |
| SEO 호환 | 크롤러가 스트리밍 HTML을 점진적으로 읽기 가능 |
| 백프레셔 대응 | 네트워크가 혼잡하면 렌더러가 자동으로 속도를 조절해 서버 메모리 낭비를 줄임 |
용어: 백프레셔 (Backpressure) — 클라이언트가 데이터를 처리하는 속도보다 서버가 더 빨리 데이터를 생성할 때, 생산 속도를 자동으로 조절하는 Node.js Streams 메커니즘입니다. 스트리밍 SSR에서는 클라이언트 네트워크가 느릴 때 서버가 렌더링 속도를 줄여 불필요한 메모리 소비를 막는 이점으로 이어집니다.
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| HTTP 상태 코드 제한 | Shell 전송 후엔 상태 코드 변경 불가 | onShellError와 onError를 구분 처리; Shell 이후 에러는 클라이언트 Error Boundary로 처리 |
| CSS-in-JS 충돌 | styled-components 등 런타임 라이브러리에서 FOUC 발생 가능 | CSS Modules 또는 zero-runtime CSS(Linaria 등) 사용 권장 |
| Suspense 경계 설계 | 경계가 너무 많으면 reflow 빈번, 너무 적으면 스트리밍 효과 감소 | 데이터 소스 단위로 경계를 구분하는 것을 권장 |
| 스켈레톤 크기 불일치 | 폴백 교체 시 레이아웃 재흐름 발생 | 스켈레톤의 치수를 실제 콘텐츠와 유사하게 설계 |
| 서버 메모리 | 동시 스트림이 많을 경우 메모리 사용량 증가 | 스트림 타임아웃(abort()) 설정 필수 |
| 구현 복잡도 | renderToString 대비 에러 핸들링·헤더 타이밍 등 고려 사항 증가 |
가능하면 Next.js App Router 등 프레임워크 추상화 활용 |
용어: FOUC (Flash of Unstyled Content) — 스타일이 늦게 적용되어 순간적으로 스타일 없는 HTML이 보이는 현상. 런타임에 CSS를 생성하는 라이브러리가 스트리밍과 충돌할 때 발생할 수 있습니다.
실무에서 가장 흔한 실수
-
LCP 요소를
<Suspense>안에 배치하는 것. 히어로 이미지나 메인 타이틀 같은 LCP 대상은 반드시 Shell에 포함시켜야 합니다. 스켈레톤 UX 설계에 집중하다 보면 모든 것을 Suspense로 감싸게 되는데, 이 과정에서 LCP 요소까지 안으로 들어가면 Core Web Vitals 점수가 크게 떨어집니다. -
Shell 전송 이후에
res.setHeader()를 호출하는 것.pipe(res)이후에는 헤더 변경이 불가능합니다. Content-Type을 포함한 모든 헤더 설정은onShellReady콜백 안에서,pipe()호출 이전에 이루어져야 합니다. 에러 핸들링을 나중에 추가하다가 헤더 설정 타이밍을 놓치는 경우가 꽤 있습니다. -
스트림 타임아웃을 설정하지 않는 것. 서버에서 데이터를 영영 가져오지 못하는 경우, 스트림이 무한정 열려 있으면 서버 메모리를 계속 잡아먹습니다. 조용히 서버가 느려지는 원인이 되기도 하니,
setTimeout(() => abort(), 10000)한 줄을 빠뜨리지 않는 것을 권장합니다.
마치며
renderToPipeableStream과 <Suspense>는 "전체를 만들어 보내는" 패러다임을 "준비된 것부터 보내는" 패러다임으로 전환시키며, TTFB·FCP·TTI 지표 전반에 걸쳐 실질적인 개선을 가져옵니다. 처음 소개한 3초짜리 대시보드에 이 구조를 적용했을 때, 사용자가 체감하는 첫 화면 노출 시간이 수백 밀리초대로 줄었습니다. 데이터는 여전히 3초 후에 도착하지만, 그 3초 동안 사용자는 빈 화면이 아닌 레이아웃과 스켈레톤을 봅니다.
지금 바로 시작해볼 수 있는 3단계입니다.
-
Next.js App Router 프로젝트의 느린 페이지에
loading.tsx를 추가해보시면 좋습니다.loading.tsx를 만들고 스켈레톤 컴포넌트를 반환하는 것만으로 스트리밍 SSR이 활성화됩니다. Chrome DevTools Network 탭에서 해당 HTML 응답의 Response 패널을 열어두고 페이지를 새로고침해보면, 응답이 실시간으로 늘어나는 것을 눈으로 확인할 수 있습니다. -
데이터 소스가 다른 컴포넌트들을 각각 별도의
<Suspense>로 분리해보시면 좋습니다. 빠른 섹션이 느린 섹션을 기다리지 않고 먼저 나타나는 것을 확인하면, 경계를 어디에 두어야 할지 감이 바로 옵니다. -
React DevTools Profiler 탭에서 하이드레이션 타이밍을 시각화해보시면 좋습니다. Selective Hydration이 실제로 어떤 순서로 진행되는지 확인하고, 우선순위가 낮은 컴포넌트는
<Suspense>경계를 더 깊이 배치해 초기 하이드레이션 비용을 줄여볼 수 있습니다.
이후 글이 발행되면 알림을 받고 싶다면 RSS 구독을 활용해보시기 바랍니다.
참고 자료
- renderToPipeableStream – React 공식 문서
- <Suspense> – React 공식 문서
- New Suspense SSR Architecture in React 18 – reactwg/react-18 Discussion #37
- React 19.2 공식 블로그
- React 19.2 Brings Suspense Batching to Server Rendering – Medium
- Streaming SSR with React 18 – LogRocket Blog
- Streaming Server-Side Rendering – patterns.dev
- React renderToPipeableStream with Express: A Deep Dive – DEV Community
- Guides: Streaming – Next.js 공식 문서
- How Suspense + Streaming + Selective Hydration Drive Next-Level Page Speed – Makers Den
- Demystifying Hydration and Streaming in React 19 – The Frontend Dev (Medium)
- React Flight Protocol – DeepWiki
- Selective Hydration – patterns.dev
- React Server Components Streaming Performance Guide 2026 – SitePoint