renderToString와 renderToNodeStream

renderToString과 renderToNodeStream은 React SSR 서버를 Node.js에서 직접 구성할 때 핵심으로, renderToString과 renderToNodeStream은 둘 다 서버 측에서 HTML을 생성하는 데 사용되지만, 렌더링 방식과 성능 특성이 크게 다릅니다.

🧑‍💻 예제 비교

▶️ renderToString 사용 예시

// ✅ 전체 렌더링 완료 후 HTML을 문자열로 받은 다음 클라이언트에 한 번에 전달
import { renderToString } from 'react-dom/server';
import App from './App';

app.get('/', (req, res) => {
  const html = renderToString(<App />);
  res.send(`
    <!DOCTYPE html>
    <html>
      <body>
        <div id="root">${html}</div>
        <script src="/bundle.js"></script>
      </body>
    </html>
  `);
});

▶️ renderToNodeStream 사용 예시

// ✅ 초기 HTML을 클라이언트로 전송해, 사용자 입장에서 더 빠른 체감 속도(FCP)를 제공
import { renderToNodeStream } from 'react-dom/server';
import App from './App';

app.get('/', (req, res) => {
  res.write('<!DOCTYPE html><html><body><div id="root">');
  const stream = renderToNodeStream(<App />);
  stream.pipe(res, { end: false });
  stream.on('end', () => {
    res.write('</div><script src="/bundle.js"></script></body></html>');
    res.end();
  });
});

renderToString은 React 트리를 한 번에 문자열로 렌더링해 응답하기 때문에 구현은 간단하지만, 페이지가 복잡하거나 무거우면 초기 응답이 늦어질 수 있습니다. 반면 renderToNodeStream은 React 요소를 스트리밍 방식으로 클라이언트에 보내기 때문에 초기 HTML 일부라도 먼저 전달할 수 있어, FCP 개선이나 퍼포먼스 향상에 유리하다는 장점이 있습니다.

⚡️ renderToPipeableStream (React 18~) ⚡️

renderToString은 전체 HTML을 한 번에 생성했기 때문에 초기 응답이 늦고, 큰 페이지에서는 성능 병목이 발생했습니다.

renderToNodeStream은 스트리밍이 가능해지면서 초기 응답은 빨라졌지만, React의 Suspense나 fallback 렌더링은 지원하지 않았습니다.

React 18에서 도입된 renderToPipeableStream은 이를 해결한 API로, Suspense boundary 단위로 부분 렌더링이 가능하고, 타임아웃 설정 및 오류 fallback 처리도 유연하게 제어할 수 있어 대규모, 고성능 SSR에서 가장 권장되는 방식입니다.

▶️ renderToPipeableStream 사용 예시

import { renderToPipeableStream } from 'react-dom/server';

const { pipe, abort } = renderToPipeableStream(<App />, {
  onShellReady() {
    res.statusCode = 200;
    res.setHeader('Content-Type', 'text/html');
    pipe(res); // HTML 일부부터 스트리밍 시작
  },
  onError(err) {
    console.error(err);
  },
  onAllReady() {
    // suspense 영역까지 렌더 완료 시점
  }
});

// 타임아웃 후 중단도 가능
setTimeout(() => abort(), 5000);

▶️ Suspense + renderToPipeableStream 사용 예시

- server.js
- App.tsx
- components/
  - ProductList.tsx
  - ProductListSkeleton.tsx

🧱 components/ProductList.tsx

import React from 'react';

export default function ProductList() {
  const products = getProducts(); // 서버 비동기 호출로 가정
  return (
    <ul>
      {products.map(p => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  );
}

🧱 App.tsx

import React, { Suspense } from 'react';
import ProductList from './components/ProductList';
import ProductListSkeleton from './components/ProductListSkeleton';

export default function App() {
  return (
    <div>
      <h1>상품 목록</h1>
      <Suspense fallback={<ProductListSkeleton />}>
        <ProductList />
      </Suspense>
    </div>
  );
}

🧱 server.js (Express + renderToPipeableStream)

import express from 'express';
import { renderToPipeableStream } from 'react-dom/server';
import App from './App';

const app = express();

app.get('*', (req, res) => {
  const { pipe, abort } = renderToPipeableStream(<App />, {
    onShellReady() {
      res.statusCode = 200;
      res.setHeader('Content-Type', 'text/html');
      res.write(`<!DOCTYPE html><html><body><div id="root">`);
      pipe(res);
    },
    onAllReady() {
      res.write(`</div><script src="/bundle.js"></script></body></html>`);
      res.end();
    },
    onError(err) {
      console.error('SSR error', err);
    }
  });

  // 비정상 지연 시 중단
  setTimeout(() => abort(), 5000);
});

app.listen(3000, () => {
  console.log('SSR server on http://localhost:3000');
});

📌 Next.js의 getServerSideProps와의 차이점

항목 renderToPipeableStream getServerSideProps (Next.js)
프레임워크 Express + React (커스텀 SSR) Next.js 내장
렌더링 방식 서버 렌더링 + 점진적 스트리밍 SSR, 정적 HTML 완성 후 전달
Suspense 지원 ✅ React 18 기준 완전 지원 ❌ 서버 측 Suspense 미지원
유연성 매우 높음 (직접 제어) Next.js 규칙 내에서만 가능
데이터 페칭 자유로운 위치에서 가능 페이지 단위에서만 작동
초기 데이터 주입 자동 props 전달 (context 제공)

🔍 정리:

  • getServerSidePropsNext.js의 SSR 페이지 전용 함수로,
    • 요청마다 데이터를 서버에서 받아와 HTML을 완성한 후 클라이언트에 전달
    • 완성된 HTML만 보냄 → 스트리밍이나 Suspense는 불가
  • 반면, renderToPipeableStream서버 자체에서 React 렌더링을 스트리밍으로 직접 제어할 수 있음
    • React 18 기준 Suspense 사용 가능, HTML을 점진적으로 보내 성능 개선

SSR 렌더링 방식 비교

┌─────────────────────────────┐
│         요청 수신             │
└─────────────────────────────┘
                ↓
────────────────────────────────────────────
          SSR 방식별 처리 흐름 비교
────────────────────────────────────────────

(1) renderToString
┌──────────────┐
│ 전체 렌더링     │ ← 모든 React 트리를 메모리 내에서 동기 렌더링
└──────┬───────┘
       ↓
┌──────────────┐
│ HTML 완성 후   │ ← 완료되기 전까지 사용자 대기
└──────┬───────┘
       ↓
┌──────────────┐
│  응답 전송     │
└──────────────┘

(2) renderToNodeStream
┌──────────────┐
│  React 트리   │
│  스트리밍 렌더  │ ← React 16에서 도입
└──────┬───────┘
       ↓
┌──────────────┐
│ HTML 일부 전송 │ ← 초기 사용자 반응 빨라짐
└──────────────┘

(3) renderToPipeableStream (React 18)
┌──────────────┐
│ Suspense 지원 │
│ 점진 렌더링     │ ← fallback → 실제 콘텐츠 순차 렌더
└──────┬───────┘
       ↓
┌──────────────┐
│  Shell 전송   │ ← 초기 HTML부터 빠르게 응답
└──────┬───────┘
       ↓
┌──────────────┐
│  데이터 도착 후 │
│ 본 콘텐츠 렌더  │ ← onAllReady()
└──────────────┘

Summary

항목 renderToString renderToNodeStream renderToPipeableStream (React 18+)
도입 시기 React 16 React 16 React 18
렌더링 방식 동기 (문자열 전체 생성) 스트리밍 (Node.js Stream) 스트리밍 (pipeable, more control)
응답 시점 전체 렌더링 완료 후 일부부터 전송 가능 더 빠르게, 더 유연하게 전송 가능
Suspense 지원 ✅ 완전 지원
에러 fallback 전체 실패 전송 중단 또는 수동 처리 부분 fallback 처리 가능 (Suspense boundary)
중단 및 타임아웃 제어 ✅ abort() 가능
추천 용도 단순한 SSR 대용량 콘텐츠 초기 응답 최적화 최신 React + 점진적 렌더링 필요 시

🔗 참조

📌 renderToPipeableStream :: React Official Docs