Streaming 에서 renderToNodeStream, renderToPipeableStream, renderToReadableStream 이란
Streaming SSR이란?
기존 SSR(Server Side Rendering)은 서버에서 모든 데이터를 준비한 뒤, 완성된 HTML을 한 번에 클라이언트로 전송합니다. 이 방식은 데이터가 많거나 느릴 때, 사용자가 아무것도 보지 못한 채 기다려야 하는 단점이 있습니다.
Streaming SSR은 HTML을 "조각(청크)" 단위로, 준비되는 대로 바로바로 클라이언트에 전송합니다. 즉, 초기 UI(셸, shell)를 빠르게 보여주고, 비동기 데이터가 준비되는 대로 추가 콘텐츠를 점진적으로 렌더링합니다.
이로써 사용자 경험이 향상 될수 있습니다.
renderToNodeStream: 과거의 스트리밍 SSR
개요
renderToNodeStream은 React 16에서 도입된 Node.js 환경용 스트리밍 SSR API입니다. React 트리를 Node.js의 ReadableStream으로 변환해, 서버에서 HTML을 청크 단위로 클라이언트에 전송할 수 있게 해줍니다.
예시
import { renderToNodeStream } from 'react-dom/server';
app.use('/', (req, res) => {
res.setHeader('Content-Type', 'text/html');
const stream = renderToNodeStream(<App />);
stream.pipe(res);
});한계점
- Suspense 미지원: React 16~17의 renderToNodeStream은 Suspense를 통한 비동기 데이터 로딩, shell 분리, 점진적 스트리밍을 지원하지 않습니다.
- 오류 복구/상태코드 제어 한계: shell과 비동기 데이터의 경계가 없어, 오류 발생 시 세밀한 제어나 복구가 어렵습니다.
- 최신 패턴 미지원: shell(레이아웃)과 비동기 데이터의 분리, onShellReady/onAllReady 등 세밀한 콜백이 없습니다.
- React 18 이후: 대부분의 프로젝트가 renderToPipeableStream(서버), renderToReadableStream(Edge)으로 전환되었습니다.
결론
- renderToNodeStream은 React 16~17에서 SSR 스트리밍을 위해 사용되었으나, 최신 React의 Suspense, 점진적 데이터 로딩, shell 분리 등 모던 SSR 패턴을 지원하지 못합니다.
- React 18 이후에는 반드시 renderToPipeableStream(서버) 또는 renderToReadableStream(Edge/Web) API를 사용해야 합니다.
renderToPipeableStream 이란
renderToPipeableStream은 React 트리를 파이프 가능한 Node.js 스트림으로 렌더링하는 API입니다. Node.js 환경에서만 사용 가능하며, Deno/Edge 등 Web Streams API 환경에서는 renderToReadableStream을 사용해야 합니다.
예시
import { renderToPipeableStream } from 'react-dom/server';
const { pipe, abort } = renderToPipeableStream(<App />, {
bootstrapScripts: ['/main.js'],
onShellReady() {
response.setHeader('content-type', 'text/html');
pipe(response);
},
});pipe는 HTML을 Node.js의 writable stream(response 등)으로 출력합니다.abort는 서버 렌더링을 중단하고 나머지는 클라이언트에서 렌더링하도록 할 수 있습니다.
파라미터 및 옵션
자세한 파라미터의 옵션은 공식 문서를 참고해주세요. 핵심 파라미터는 아래와 같습니다.
- reactNode: HTML로 렌더링할 React 노드(예:
<App />). 전체 문서를 나타내므로<html>태그를 포함해야 합니다. - options: 다양한 스트리밍 옵션 객체
onShellReady: 초기 shell이 렌더링된 직후 실행되는 콜백(여기서 pipe 호출)onAllReady: shell과 모든 추가 콘텐츠가 렌더링 완료된 후 실행(크롤러/정적생성용)onError: 서버 오류 발생 시 실행되는 콜백(상태코드 조정, crash report 등)onShellError: shell 렌더링 중 오류 발생 시 실행(폴백 HTML 전송 등)
루트 컴포넌트 구조 예시
export default function App() {
return (
<html>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/styles.css" />
<title>My app</title>
</head>
<body>
<Router />
</body>
</html>
);
}React는 doctype과 bootstrap <script> 태그를 결과 HTML 스트림에 자동으로 삽입합니다.
<!DOCTYPE html>
<html>
<!-- ... 컴포넌트의 HTML ... -->
</html>
<script src="/main.js" async=""></script>클라이언트 하이드레이션
클라이언트에서는 bootstrap 스크립트가 hydrateRoot를 호출해 전체 document를 하이드레이트합니다.
import { hydrateRoot } from 'react-dom/client';
import App from './App.js';
hydrateRoot(document, <App />);이렇게 하면 서버에서 생성된 HTML에 이벤트 리스너가 붙고, 상호작용이 가능해집니다.
Suspense와 shell, 스트리밍의 관계
- shell:
<Suspense>경계 밖에 있는 부분(레이아웃, 커버 등). 사용자가 가장 먼저 보게 되는 뼈대 UI. - Suspense 경계: 비동기 데이터가 필요한 컴포넌트(예: Posts 등)를 감싸 fallback UI(스피너, 글리머 등)를 먼저 보여줌.
- 스트리밍: shell이 준비되는 즉시 pipe로 스트리밍 시작. 내부 Suspense 경계의 컴포넌트는 fallback UI로 대체되어 먼저 렌더링됨. 데이터가 준비되면 점진적으로 실제 콘텐츠로 교체됨.
예시
function ProfilePage() {
return (
<ProfileLayout>
<ProfileCover />
<Sidebar>
<Friends />
<Photos />
</Sidebar>
<Suspense fallback={<PostsGlimmer />}>
<Posts />
</Suspense>
</ProfileLayout>
);
}- shell:
<ProfileLayout>,<ProfileCover>,<Sidebar>,<Friends>,<Photos>,<PostsGlimmer /> - 데이터 준비 후:
<Posts />가<PostsGlimmer />를 대체
더 세분화된 Suspense 경계 예시
function ProfilePage() {
return (
<ProfileLayout>
<ProfileCover />
<Suspense fallback={<BigSpinner />}>
<Sidebar>
<Friends />
<Photos />
</Sidebar>
<Suspense fallback={<PostsGlimmer />}>
<Posts />
</Suspense>
</Suspense>
</ProfileLayout>
);
}- shell:
<ProfileLayout>,<ProfileCover>,<BigSpinner /> - 내부 Suspense 경계의 데이터 준비에 따라 점진적으로 UI가 완성됨
오류 처리 및 상태 코드 설정
- onError: 서버 오류 발생 시 실행. crash report 기록, 상태코드 조정 등 가능
- onShellError: shell 렌더링 중 오류 발생 시 실행. 폴백 HTML 전송 등
- 상태코드: shell이 준비되기 전까지는 상태코드 조정 가능. 스트리밍 시작 후에는 불가
const { pipe } = renderToPipeableStream(<App />, {
bootstrapScripts: ['/main.js'],
onShellReady() {
response.statusCode = 200;
response.setHeader('content-type', 'text/html');
pipe(response);
},
onShellError(error) {
response.statusCode = 500;
response.setHeader('content-type', 'text/html');
response.send('<h1>Something went wrong</h1>');
},
onError(error) {
console.error(error);
logServerCrashReport(error);
},
});- shell 외부(즉, Suspense 경계 안쪽)에서 오류가 발생해도 React는 렌더링을 멈추지 않고, 클라이언트에서 복구를 시도합니다.
- shell 생성 중 오류가 발생하면 onShellError와 onError가 모두 실행됩니다.
Suspense, shell, 스트리밍의 관계
- shell은 최대한 완성된 레이아웃(스켈레톤 등)으로 설계해 사용자에게 빠른 피드백 제공
- Suspense 경계를 적절히 배치해 점진적 로딩 UX 구현
- onShellReady, onAllReady, onError, onShellError 등 콜백을 적극 활용해 상태코드, 오류 처리, 크롤러 대응 등 세밀하게 제어
- 스트리밍 시작 후에는 상태코드 변경 불가
- Suspense를 지원하는 데이터 소스(Next.js, Relay, lazy, use 등)만 진짜로 Suspense와 연동됨
renderToReadableStream (Edge/Web 환경)
주요 특징
- Web Streams API를 지원하는 환경(Edge Functions, Deno, Cloudflare Workers 등)에서 사용
- 반환값이 Promise이므로
await필요 - 옵션은
renderToPipeableStream과 유사
예시
import { renderToReadableStream } from 'react-dom/server';
async function handler(request) {
const stream = await renderToReadableStream(<App />, {
bootstrapScripts: ['/main.js'],
onError(error) {
console.error(error);
},
});
return new Response(stream, {
headers: { 'content-type': 'text/html' },
});
}실제 사용 예시: renderToReadableStream을 이용한 React 트리 HTML 렌더링
import { renderToReadableStream } from 'react-dom/server';
async function handler(request) {
const stream = await renderToReadableStream(<App />, {
bootstrapScripts: ['/main.js'],
});
return new Response(stream, {
headers: { 'content-type': 'text/html' },
});
}root 컴포넌트와 함께, bootstrap
<script>경로 리스트를 제공해야 합니다. 제공된 root 컴포넌트는 최상위<html>태그를 포함한 전체 문서를 반환해야 합니다.
예시:
export default function App() {
return (
<html>
<head>
<meta charSet="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="stylesheet" href="/styles.css" />
<title>My app</title>
</head>
<body>
<Router />
</body>
</html>
);
}React는 doctype과 bootstrap <script> 태그들을 결과 HTML 스트림에 자동으로 주입합니다.
<!DOCTYPE html>
<html>
<!-- ... 사용자가 직접 작성한 컴포넌트의 HTML ... -->
</html>
<script src="/main.js" async=""></script>클라이언트에서는, 추가된 bootstrap 스크립트가 hydrateRoot를 호출해 document 전체를 hydrate 해야 합니다.
import { hydrateRoot } from 'react-dom/client';
import App from './App.js';
hydrateRoot(document, <App />);셸(shell) 설계와 Suspense 경계
앱에서 <Suspense> 경계 밖에 있는 부분을 shell이라고 부릅니다. shell은 사용자가 최초로 보게 되는 레이아웃, 즉 페이지의 뼈대가 되는 부분입니다.
예를 들어, 아래와 같은 구조를 생각해봅시다:
function ProfilePage() {
return (
<ProfileLayout>
<ProfileCover />
<Suspense fallback={<BigSpinner />}>
<Sidebar>
<Friends />
<Photos />
</Sidebar>
<Suspense fallback={<PostsGlimmer />}>
<Posts />
</Suspense>
</Suspense>
</ProfileLayout>
);
}이 구조에서 shell은 <ProfileLayout>과 <ProfileCover>, 그리고 <BigSpinner />(Suspense의 fallback)로 구성됩니다. 즉, 사용자는 데이터가 준비되기 전까지 레이아웃과 커버, 그리고 큰 스피너를 먼저 보게 됩니다.
만약 <Suspense> 경계를 root에 걸어 앱 전체를 감싼다면, shell은 spinner만을 보여주게 됩니다. 하지만 이는 실제 레이아웃이 나타나는 것보다 더 느리고, 사용자가 답답함을 느낄 수 있습니다. 따라서, shell은 전체 페이지의 뼈대와 주요 레이아웃이 먼저 보이도록 설계하는 것이 UX에 더 좋습니다.
shell과 Suspense의 UX 차이 예시
// shell이 spinner만인 경우 (비추천)
<App>
<Suspense fallback={<BigSpinner />}>
{/* 전체 앱 내용 */}
</Suspense>
</App>
// shell이 레이아웃+스피너인 경우 (추천)
<ProfileLayout>
<ProfileCover />
<BigSpinner />
</ProfileLayout>Streaming SSR에서 shell의 렌더링 흐름
renderToReadableStream(또는 renderToPipeableStream)을 비동기 호출하면, shell이 준비되는 즉시 스트림이 시작됩니다. 이때, shell(레이아웃, 커버 등)은 바로 렌더링되어 클라이언트에 전송되고, 내부의 <Suspense> 경계 안에 있는 컴포넌트들은 데이터가 준비될 때까지 fallback UI(스피너, 글리머 등)로 대체됩니다.
async function handler(request) {
const stream = await renderToReadableStream(<App />, {
bootstrapScripts: ['/main.js'],
});
return new Response(stream, {
headers: { 'content-type': 'text/html' },
});
}stream이 반환되었을 때, 중첩된 내부의 <Suspense> 경계의 컴포넌트는 아직 데이터를 로딩중일 수도 있습니다. 이때 fallback UI가 먼저 렌더링되고, 데이터가 준비되면 실제 콘텐츠로 점진적으로 교체됩니다.
전체 흐름 요약
- shell(레이아웃, 커버 등)과 Suspense fallback이 먼저 렌더링되어 스트리밍 시작
- 내부 Suspense 경계의 비동기 컴포넌트는 fallback UI로 대체
- 데이터가 준비되면, 해당 부분이 실제 콘텐츠로 교체되어 점진적으로 완성
- 클라이언트에서는 bootstrap 스크립트가 hydrateRoot를 호출해 상호작용을 붙임
마치며
- Suspense 경계를 적절히 배치해 shell이 최대한 완성된 레이아웃을 보여주도록 설계해야 합니다.
- Streaming SSR은 shell을 빠르게 스트리밍하고, 비동기 데이터가 준비되는 대로 점진적으로 UI를 완성합니다.
- UX 관점에서 shell 설계가 매우 중요하며, 공식문서의 예시처럼 Suspense 경계를 뼈대 바깥에 두는 것이 좋습니다.
Reference
댓글 로딩 중...