[React 19] 공식문서 톺아보기 - React DOM 서버 API

Study
서버에서 미리 html을 구성하여 전달하는 api에 대해서 공부했습니다.

renderToPipeableStream

renderToPipeableStream은 React 트리를 파이프 가능한 Node.js 스트림으로 렌더링합니다.

const { pipe, abort } = renderToPipeableStream(reactNode, options?)
const { pipe, abort } = renderToPipeableStream(reactNode, options?)
  • 이 API는 Node.js 전용입니다. Deno 및 최신 엣지 런타임과 같은 Web 스트림이 있는 환경에서는 renderToReadableStream을 대신 사용하세요.
  • pipe는 HTML을 제공된 쓰기 가능한 Node.js 스트림으로 출력합니다. 스트리밍을 활성화하려면 onShellReady에서, 크롤러와 정적 생성을 사용하려면 onAllReady에서 pipe를 호출하세요.
  • abort를 사용하면 서버 렌더링을 중단하고 나머지는 클라이언트에서 렌더링할 수 있습니다.

사용예시

React 트리를 HTML로 Node.js 스트림에 렌더링하기

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

const { pipe } = renderToPipeableStream(<App />, {
  bootstrapScripts: ['/main.js'],
  onShellReady() {
    response.setHeader('content-type', 'text/html');
    pipe(response);
  },
});
import { renderToPipeableStream } from 'react-dom/server';

const { pipe } = renderToPipeableStream(<App />, {
  bootstrapScripts: ['/main.js'],
  onShellReady() {
    response.setHeader('content-type', 'text/html');
    pipe(response);
  },
});
  • bootstrapScripts의 역할
    1. 하이드레이션은 클라이언트에서 발생하지만, 서버는 필요한 스크립트 경로를 HTML에 포함시켜야 합니다.
    2. bootstrapScripts는 "이 스크립트를 로드해야 하이드레이션이 가능해요!"라고 알려주는 역할입니다.
    3. bootstrapScripts: 페이지에 표시할 <script> 태그에 대한 문자열 URL 배열입니다. 이를 사용하여 hydrateRoot를 호출하는 <script>를 포함하세요.

콘텐츠가 로드되는 동안 더 많은 콘텐츠 스트리밍하기

function ProfilePage() {
  return (
    <ProfileLayout>
      <ProfileCover />
      <Sidebar>
        <Friends />
        <Photos />
      </Sidebar>
      <Posts />
    </ProfileLayout>
  );
}
function ProfilePage() {
  return (
    <ProfileLayout>
      <ProfileCover />
      <Sidebar>
        <Friends />
        <Photos />
      </Sidebar>
      <Posts />
    </ProfileLayout>
  );
}

컴포넌트 구조에서 <Posts/> 의 렌더링 시간이 오래걸린다고 할때

<Suspense fallback={<PostsGlimmer />}>
  <Posts />
</Suspense>
<Suspense fallback={<PostsGlimmer />}>
  <Posts />
</Suspense>

<Suspense> 로 감싸주게되면 초기 렌더링에는 포함되지 않게 된다.

function ProfilePage() {
  return (
    <ProfileLayout>
      <ProfileCover />
      <Sidebar>
        <Friends />
        <Photos />
      </Sidebar>
      <PostsGlimmer />
    </ProfileLayout>
  );
}
function ProfilePage() {
  return (
    <ProfileLayout>
      <ProfileCover />
      <Sidebar>
        <Friends />
        <Photos />
      </Sidebar>
      <PostsGlimmer />
    </ProfileLayout>
  );
}

Suspense 가 활성화 되는 조건

  • lazy를 활용한 지연 로딩 컴포넌트.
  • use를 사용해서 Promise 값 읽기.

그 외 방법으로 데이터를 가져오는 경우 Suspense가 감지하지 못한다.

Shell

앱의 <Suspense> 경계 밖에 있는 부분을 이라고 합니다. 사용자가 볼 수 있는 가장 빠른 로딩 상태를 결정합니다.

function ProfilePage() {
  return (
    <ProfileLayout>
      <ProfileCover />
      <Suspense fallback={<BigSpinner />}>
        <Sidebar>
          <Friends />
          <Photos />
        </Sidebar>
        <Suspense fallback={<PostsGlimmer />}>
          <Posts />
        </Suspense>
      </Suspense>
    </ProfileLayout>
  );
}
function ProfilePage() {
  return (
    <ProfileLayout>
      <ProfileCover />
      <Suspense fallback={<BigSpinner />}>
        <Sidebar>
          <Friends />
          <Photos />
        </Sidebar>
        <Suspense fallback={<PostsGlimmer />}>
          <Posts />
        </Suspense>
      </Suspense>
    </ProfileLayout>
  );
}
<ProfileLayout>
  <ProfileCover />
  <BigSpinner />
</ProfileLayout>
<ProfileLayout>
  <ProfileCover />
  <BigSpinner />
</ProfileLayout>
  • 일반적으로 셸이 전체 페이지 레이아웃의 스켈레톤처럼 최소한의 완전함을 느낄 수 있도록 <Suspense> 경계를 배치하는 것이 좋습니다.
  • 전체 셸이 렌더링되면 onShellReady 콜백이 실행됩니다. 보통 이때 스트리밍이 시작됩니다.
    • 그러니까 스트리밍이라고 하는건 셸이 로드되고 내부 컴포넌트가 차례로 단계적으로 진행되는것을 의미한다.
    • 중첩된 Suspense는 각각 독립적인 스트리밍 단위로 작동하며, 기본적으로 셸(1) + Suspense 경계 수(3) = 총 4개의 청크로 나뉘어 전송됩니다.

만약에 renderToPipeableStream를 사용하지만 Suspense는 사용하지 않았을 경우

분할되어 빠른 첫 렌더링을 지원하는게 아니라 전체 로딩후 한번에 표시된다.

스트리밍 SSR의 핵심은 Suspense를 통한 "조각 렌더링(partial rendering)"이 가능하다는 점이다. 즉, 일부 UI만 먼저 클라이언트에 보내고, 나머지는 준비되는 대로 보내는 거지. 그런데 이런 스트리밍의 효과가 없다고 할수 있다.

  • 따라서 스트리밍 SSR을 제대로 활용하기 위해서는 Suspense를 적극적으로 사용해야한다.
  • next 프레임워크에서는?
    • page router의 경우는 스트리밍 ssr을 지원하지 않음 suspense를 추가해도 효과가 없다.
    • App router에서는 loading.js와 Suspense를 통해 스트리밍 ssr를 지원한다.
      • app router에서도 역시 loading.js와 Suspense를 사용하지 않으면 일반 ssr로 동작한다.

  1. 셸(Shell)
    • 최초에 한 번만 렌더링 → 클라이언트로 즉시 전송됩니다.
    • 예: 레이아웃, 기본 구조 (<html><head>, 로딩 UI 자리 표시자).
  2. 나머지 콘텐츠
    • 백그라운드에서 점진적으로 렌더링 → 준비되는 대로 청크(chunk)로 전송됩니다.
    • 예: 데이터 fetching이 필요한 컴포넌트, Suspense로 감싼 부분.

1. 셸(Shell)의 HTML 생성 및 전송

  • 서버가 <App />의 최상위 구조를 먼저 렌더링합니다.

    <!-- 셸 예시 (초기 전송되는 HTML) -->
    <html>
      <head>
        ...
      </head>
      <body>
        <div id="root">
          <!-- Suspense fallback 자리 표시 -->
          <div class="loading-spinner">🌀</div>
        </div>
      </body>
    </html>
    <!-- 셸 예시 (초기 전송되는 HTML) -->
    <html>
      <head>
        ...
      </head>
      <body>
        <div id="root">
          <!-- Suspense fallback 자리 표시 -->
          <div class="loading-spinner">🌀</div>
        </div>
      </body>
    </html>
  • onShellReady 콜백 실행 → 클라이언트에 첫 번째 청크로 전송됩니다.

2. 나머지 콘텐츠의 HTML 생성 및 전송

  • 서버는 백그라운드에서 나머지 컴포넌트를 계속 렌더링합니다.
    • 데이터 fetching이 완료되면 해당 부분의 HTML을 생성합니다.
    <!-- 두 번째 청크 (예: 데이터가 준비된 컴포넌트) -->
    <script>
      // 리액트가 DOM을 동적으로 업데이트하는 코드
      document.getElementById('content').innerHTML = '<div>실제 데이터</div>';
    </script>
    <!-- 두 번째 청크 (예: 데이터가 준비된 컴포넌트) -->
    <script>
      // 리액트가 DOM을 동적으로 업데이트하는 코드
      document.getElementById('content').innerHTML = '<div>실제 데이터</div>';
    </script>
  • 준비되는 대로 청크를 추가 전송 → 클라이언트는 이를 실시간으로 적용합니다.

클라이언트 컴포넌트와 SSR

  • Next.js의 App Router에서 use client로 정의된 클라이언트 컴포넌트도 서버에서 정적 HTML은 미리 렌더링된다.
  1. 서버 컴포넌트

    • 서버에서 렌더링 → HTML로 변환되어 스트리밍됩니다.
    • Suspense와 연동해 점진적 전송 가능히다.
  2. 클라이언트 컴포넌트 (use client)

    • 서버에서 만들어진 정적 HTML을 클라이언트로 전달한다.
    • 하이드레이션 후 브라우저에서 실제 렌더링됩니다.
  3. 번들러 청크 ≠ 스트리밍 청크:

    • 번들러: JavaScript 파일 분할.
    • 스트리밍 SSR: HTML 콘텐츠 분할. → 네트워크 탭에서 보면 하나의 요청에 대해서 응답이 채워지는것을 확인할 수 있다.

서버 렌더링 중단하기

시간 초과 후 서버 렌더링을 강제로 ‘포기’할 수 있습니다.

const { pipe, abort } = renderToPipeableStream(<App />, {
  // ...
});

setTimeout(() => {
  abort();
}, 10000);
const { pipe, abort } = renderToPipeableStream(<App />, {
  // ...
});

setTimeout(() => {
  abort();
}, 10000);

React는 나머지 로딩 폴백을 HTML로 플러시하고 나머지는 클라이언트에서 렌더링을 시도합니다.

크롤러 및 정적 생성을 위해 모든 콘텐츠가 로드될 때까지 기다리기

  • 크롤러가 페이지를 방문하거나 빌드 시점에 페이지를 생성하는 경우 모든 콘텐츠를 점진적으로 표시하는 대신 모든 콘텐츠를 먼저 로드한 다음 최종 HTML 출력을 생성하는 것이 좋을 수 있습니다.
  • onAllReady 콜백을 사용하여 모든 콘텐츠가 로드될 때까지 기다릴 수 있습니다.
let didError = false;
let isCrawler = // ... 봇 탐지 전략에 따라 달라집니다 ...

const { pipe } = renderToPipeableStream(<App />, {
  bootstrapScripts: ['/main.js'],
  onShellReady() {
    if (!isCrawler) {
      response.statusCode = didError ? 500 : 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>');
  },
  onAllReady() {
    if (isCrawler) {
      response.statusCode = didError ? 500 : 200;
      response.setHeader('content-type', 'text/html');
      pipe(response);
    }
  },
  onError(error) {
    didError = true;
    console.error(error);
    logServerCrashReport(error);
  }
});
let didError = false;
let isCrawler = // ... 봇 탐지 전략에 따라 달라집니다 ...

const { pipe } = renderToPipeableStream(<App />, {
  bootstrapScripts: ['/main.js'],
  onShellReady() {
    if (!isCrawler) {
      response.statusCode = didError ? 500 : 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>');
  },
  onAllReady() {
    if (isCrawler) {
      response.statusCode = didError ? 500 : 200;
      response.setHeader('content-type', 'text/html');
      pipe(response);
    }
  },
  onError(error) {
    didError = true;
    console.error(error);
    logServerCrashReport(error);
  }
});

renderToReadableStream

renderToReadableStream는 Readable Web Stream을 이용해 React 트리를 그립니다.

const stream = await renderToReadableStream(reactNode, options?)
const stream = await renderToReadableStream(reactNode, options?)
  • renderToReadableStream는 Promise를 반환합니다.
    • shell 렌더링에 성공했다면, 반환된 Promise는 Readable Web Stream으로 해결됩니다.
    • shell 렌더링에 실패하면, 반환된 Promise는 취소됩니다. shell 렌더링에 실패시, 이것을 이용해 실패 결과를 출력하세요.

shell 내부의 오류로부터 회복하기

  • 컴포넌트들을 렌더링하다가 오류가 발생했다면, React는 클라이언트로 보낼 의미있는 HTML을 가지고 있지 않을 것 입니다. 이런 때를 대비해 renderToReadableStreamtry...catch로 감싸 서버 렌더링에 의존하지 않는 대체 HTML을 보낼 수 있도록 하세요.
async function handler(request) {
  try {
    const stream = await renderToReadableStream(<App />, {
      bootstrapScripts: ['/main.js'],
      onError(error) {
        console.error(error);
        logServerCrashReport(error);
      },
    });
    return new Response(stream, {
      headers: { 'content-type': 'text/html' },
    });
  } catch (error) {
    return new Response('<h1>Something went wrong</h1>', {
      status: 500,
      headers: { 'content-type': 'text/html' },
    });
  }
}
async function handler(request) {
  try {
    const stream = await renderToReadableStream(<App />, {
      bootstrapScripts: ['/main.js'],
      onError(error) {
        console.error(error);
        logServerCrashReport(error);
      },
    });
    return new Response(stream, {
      headers: { 'content-type': 'text/html' },
    });
  } catch (error) {
    return new Response('<h1>Something went wrong</h1>', {
      status: 500,
      headers: { 'content-type': 'text/html' },
    });
  }
}
  • shell을 렌더링하면서 오류가 발생한다면, onErrorcatch 블록이 동시에 실행됩니다.
    • onError는 오류를 보고하기 위해 사용
    • catch 블록은 대체 HTML 문서를 보내기 위해 사용

정적 생성과 크롤러를 위해 모든 컨텐츠가 로딩되는 것을 기다리기

  • 크롤러가 이 페이지를 방문했을 때, 혹은 페이지를 빌드했을 때 정적으로 생성한 경우엔 컨텐츠가 점진적으로 드러나는 것이 아니라 모든 컨텐츠가 처음부터 모두 불러와진 다음 최종 HTML 출력물을 생성하는 것을 원할 것입니다.
async function handler(request) {
  try {
    let didError = false;
    const stream = await renderToReadableStream(<App />, {
      bootstrapScripts: ['/main.js'],
      onError(error) {
        didError = true;
        console.error(error);
        logServerCrashReport(error);
      }
    });
    let isCrawler = // ... depends on your bot detection strategy ...
    if (isCrawler) {
      await stream.allReady;
    }
    return new Response(stream, {
      status: didError ? 500 : 200,
      headers: { 'content-type': 'text/html' },
    });
  } catch (error) {
    return new Response('<h1>Something went wrong</h1>', {
      status: 500,
      headers: { 'content-type': 'text/html' },
    });
  }
}
async function handler(request) {
  try {
    let didError = false;
    const stream = await renderToReadableStream(<App />, {
      bootstrapScripts: ['/main.js'],
      onError(error) {
        didError = true;
        console.error(error);
        logServerCrashReport(error);
      }
    });
    let isCrawler = // ... depends on your bot detection strategy ...
    if (isCrawler) {
      await stream.allReady;
    }
    return new Response(stream, {
      status: didError ? 500 : 200,
      headers: { 'content-type': 'text/html' },
    });
  } catch (error) {
    return new Response('<h1>Something went wrong</h1>', {
      status: 500,
      headers: { 'content-type': 'text/html' },
    });
  }
}
  • allReady: 모든 추가 컨텐츠와 shell의 렌더링을 포함한 모든 렌더링이 완료된 Promise의 추가 프로퍼티입니다

서버 렌더링 멈추기

async function handler(request) {
  try {
    // JavaScript에 내장(built-in)된 표준 Web API
    const controller = new AbortController();
    setTimeout(() => {
      controller.abort();
    }, 10000);

    const stream = await renderToReadableStream(<App />, {
      signal: controller.signal, // 이 신호를 통해 중단 가능
      bootstrapScripts: ['/main.js'],
      onError(error) {
        didError = true;
        console.error(error);
        logServerCrashReport(error);
      }
    });
    // ...
  }
}
async function handler(request) {
  try {
    // JavaScript에 내장(built-in)된 표준 Web API
    const controller = new AbortController();
    setTimeout(() => {
      controller.abort();
    }, 10000);

    const stream = await renderToReadableStream(<App />, {
      signal: controller.signal, // 이 신호를 통해 중단 가능
      bootstrapScripts: ['/main.js'],
      onError(error) {
        didError = true;
        console.error(error);
        logServerCrashReport(error);
      }
    });
    // ...
  }
}

renderToPipeableStream vs renderToReadableStream

두 API 모두 동일한 하이드레이션 메커니즘을 사용한다

  • Selective Hydration (필요한 컴포넌트만 먼저 Hydrate)
  • Progressive Loading (점진적 로딩)
  • 성능상 차이는 없음 → 단지 호환 환경이 다를 뿐!

왜 React는 두 API를 분리했을까?

  1. Web 표준(Edge/브라우저)과 Node.js 레거시 시스템의 기술적 차이를 반영. 호환환경 차이가 크다.
    • ReadableStream (Web) vs pipe() (Node)
  2. 점진적 전환을 위해
    • 최신 앱(Edge)은 renderToReadableStream으로,
    • 기존 Node.js 서버는 renderToPipeableStream으로 유지

renderToStaticMarkup

renderToStaticMarkup은 상호작용하지 않는 React 트리를 HTML 문자열로 렌더링합니다.

const html = renderToStaticMarkup(reactNode, options?)
const html = renderToStaticMarkup(reactNode, options?)
import { renderToStaticMarkup } from 'react-dom/server';

const html = renderToStaticMarkup(<Page />);
import { renderToStaticMarkup } from 'react-dom/server';

const html = renderToStaticMarkup(<Page />);
  • 서버에서 renderToStaticMarkup을 호출하여 앱을 HTML로 렌더링합니다.
  • 이는 React 컴포넌트의 상호작용하지 않는 HTML 출력을 생성합니다.

주의사항

  • renderToStaticMarkup의 출력값은 Hydrate 될 수 없다.
    • 하이드레이션 과정
      1. 서버: React는 컴포넌트 트리를 HTML로 렌더링하며, 추가 메타데이터를 삽입합니다.
        • 예: <!--$-->, React 노드의 식별자, 상태 정보 등.
      2. 클라이언트: React는 이 메타데이터를 읽고, 서버와 동일한 컴포넌트 트리를 재구성합니다.
      3. 연결: 생성된 DOM 요소에 이벤트 리스너와 상태를 "부착"하여 상호작용 가능하게 만듭니다.
    • 이러한 하이드레이션에 필요한 메타데이터, suspense 무시해 버린다.
    • 만약 하이드레이션을 시도하게 되면 서버 HTML은 무시되고, 클라이언트에서 전체트리를 처음부터 다시 렌더링하게 되고 깜빡이는 현상이 발생한다.
      • 하이드레이션이 실패하면, React는 컴포넌트 트리를 처음부터 빌드합니다.
      • hydrateRoot → 내부적으로 createRoot + root.render()로 대체됩니다.
    • 이러한 하이드레이션에 필요한 정보를 의도적으로 누락시킴으로서 정적 컨텐츠 생성 속도를 높인다.
    • 메타 데이터가 없기 때문에 SEO가 잘 되지 않는다.
  • 만약 suspense를 만나게 되면 점진적 전환을 보여주지 않고 fallback ui를 렌더링하고 고정한다.
  • 완전히 정적인 페이지를 렌더링할때 유용하고 상호작용이 필요할 경우는 renderToString를 사용한다.

renderToString

renderToString은 React 트리를 HTML 문자열로 렌더링합니다.

const html = renderToString(reactNode, options?)
const html = renderToString(reactNode, options?)
import { renderToString } from 'react-dom/server';

const html = renderToString(<App />);
import { renderToString } from 'react-dom/server';

const html = renderToString(<App />);
  • 클라이언트에서 hydrateRoot를 호출하면 서버에서 생성된 HTML을 상호작용하게 만듭니다.
  • renderToStaticMarkup 와 달리 하이드레이션을 지원하기 위한 메타데이터를 포함한다.
  • Suspense 컴포넌트를 만나면 즉시 fallback UI를 렌더링하고, 실제 데이터는 기다리지 않습니다.
    • 데이터 로드가 되고나서 내부 컴포넌트를 렌더링할때는 그 부분만 새로 그리며, 경고를 띄우게 된다.
      • 서버는 fallback, 클라이드라이언트는 실제 UI를 렌더링 → 불일치 발생.
      • React는 Suspense 영역만 CSR로 재렌더링 (나머지는 하이드레이션 유지).
    • seo 측면에서 크롤러가 볼때도 fallback ui만 보게 된다.
      • 메타 데이터가 있으므로 renderToStaticMarkup 와 비교해서는 덜 불리하다.
읽어주셔서 감사합니다