renderToReadableStream

Chaerin Kim·2023년 12월 19일

renderToReadableStream은 React 트리를 Readable Web Stream으로 렌더링함.

const stream = await renderToReadableStream(reactNode, options?)

Note

이 API는 Web Streams에 따라 달라짐. Node.js의 경우 renderToPipableStream을 사용할 것.


Reference

renderToReadableStream(reactNode, options?)

renderToReadableStream을 호출하여 React 트리를 HTML로 Readable Web Stream으로 렌더링할 수 있음.

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' },
  });
}

클라이언트에서 hydrateRoot를 호출하여 서버에서 생성된 HTML을 상호작용이 가능하도록 할 수 있음.

Parameters

  • reactNode: HTML로 렌더링하려는 React 노드. 예를 들어, <App />과 같은 JSX 요소. 이 요소는 전체 문서를 나타내야 하므로, App 컴포넌트는 <html> 태그를 렌더링해야 합니다.

  • options (optional): 스트리밍 옵션이 있는 객체.

    • bootstrapScriptContent (optioanl): 지정하면 이 문자열이 인라인 <script> 태그에 배치됨.
    • bootstrapScripts (optioanl): 페이지에 표시할 <script> 태그의 문자열 URL 배열. hydrateRoot를 호출하는 <script>를 포함하려면 사용. 클라이언트에서 React를 전혀 실행하지 않으려면 생략.
    • bootstrapModules (optioanl): bootstrapScripts와 비슷하지만, 대신 <script type="module">을 내보냄.
    • identifierPrefix (optioanl): React가 useId에 의해 생성된 ID에 사용하는 문자열 접두사. 같은 페이지에서 여러 루트를 사용할 때 충돌을 피하는 데 유용함. hydrateRoot에 전달된 것과 동일해야 함.
    • namespaceURI (optioanl): 스트림의 루트 네임스페이스 URI가 포함된 문자열. 기본값은 일반 HTML. SVG의 경우 'http://www.w3.org/2000/svg', MathML의 경우 'http://www.w3.org/1998/Math/MathML'을 전달.
    • nonce (optioanl): script-src Content-Security-Policy에 대한 스크립트를 허용하는 nonce 문자열.
    • onError (optional): 복구가 가능하든 가능하지 않든, 서버 오류가 발생할 때마다 실행되는 callback. 기본적으로 console.error만 호출. 이 함수를 override 하여 크래시 리포트를 기록하는 경우, console.error를 계속 호출할 수 있어야 함. Shell이 실행되기 전에 state 코드를 조정하는 데 사용할 수도 있음.
    • progressiveChunkSize: 청크의 바이트 수.
    • signal (optional): 서버 렌더링을 중단하고 나머지는 클라이언트에서 렌더링할 수 있는 중단 신호.

Returns

renderToReadableStream은 Promise를 반환함:

반환된 스트림에는 추가 property가 있음:

  • allReady: Shell과 모든 추가 콘텐츠를 포함한 모든 렌더링이 완료될 때 resolve 되는 Promise. 크롤러 및 static generation을 위한 응답을 반환하기 전에 await stream.allReady 할 수 있음. 이렇게 하면 프로그레시브 로딩이 발생하지 않음. 스트림에는 최종 HTML이 포함됨.

Usage

Rendering a React tree as HTML to a Readable Web Stream

renderToReadableStream을 호출하여 React 트리를 HTML로 Readable Web Stream으로 렌더링할 수 있음:

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' },
  });
}

루트 컴포넌트(위 코드에서는 <App />)와 함께 부트스트랩 <script> 경로 목록(위 코드에서는 ['/main.js'])을 제공해야 함. 루트 컴포넌트는 루트 <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"></link>
        <title>My app</title>
      </head>
      <body>
        <Router />
      </body>
    </html>
  );
}

React는 doctype부트스트랩 <script> 태그를 결과 HTML 스트림에 삽입함:

<!DOCTYPE html>
<html>
  <!-- ... HTML from your components ... -->
</html>
<script src="/main.js" async=""></script>

클라이언트에서 부트스트랩 스크립트는 hydrateRoot를 호출하여 전체 document를 hydrate 해야 함:

import { hydrateRoot } from 'react-dom/client';
import App from './App.js';

hydrateRoot(document, <App />);

이렇게 하면 서버에서 생성된 HTML에 이벤트 리스너가 첨부되어 상호작용이 가능해짐.

DEEP DIVE: Reading CSS and JS asset paths from the build output

최종 에셋 URL(예: JavaScript 및 CSS 파일)은 빌드 후 해시 처리되는 경우가 많음. 예를 들어 styles.css 대신 최종적으로 styles.123456.css이 될 수 이씀. 정적 에셋 파일명을 해싱하면 동일한 에셋의 모든 개별 빌드에서 다른 파일명을 갖게 됨. 이렇게 하면 특정 이름의 파일은 콘텐츠를 변경하지 않으므로, 정적 에셋에 대한 장기 캐싱을 안전하게 활성화할 수 있어 유용함.

하지만 빌드가 끝날 때까지 에셋 URL을 모르는 경우 이를 소스 코드에 넣을 방법이 없음. 예를 들어, 앞서와 같이 JSX에 "/styles.css"를 하드코딩하면 작동하지 않음. 소스 코드에 포함되지 않도록 하려면 루트 컴포넌트가 prop으로 전달된 map에서 실제 파일명을 읽을 수 있음:

export default function App({ assetMap }) {
  return (
    <html>
      <head>
        ...
        <link rel="stylesheet" href={assetMap['styles.css']}></link>
        ...
      </head>
      ...
    </html>
  );
}

서버에서 <App assetMap={assetMap} />을 렌더링하고 에셋 URL과 함께 assetMap을 전달:

// You'd need to get this JSON from your build tooling, e.g. read it from the build output.
const assetMap = {
  'styles.css': '/styles.123456.css',
  'main.js': '/main.123456.js'
};

async function handler(request) {
  const stream = await renderToReadableStream(<App assetMap={assetMap} />, {
    bootstrapScripts: [assetMap['/main.js']]
  });
  return new Response(stream, {
    headers: { 'content-type': 'text/html' },
  });
}

서버에서 <App assetMap={assetMap} />를 렌더링하고 있으므로, 클라이언트에서도 assetMap을 사용하여 렌더링해야 hydration 오류를 방지할 수 있음. 다음과 같이 assetMap을 serialize하여 클라이언트에 전달할 수 있음:

// You'd need to get this JSON from your build tooling.
const assetMap = {
  'styles.css': '/styles.123456.css',
  'main.js': '/main.123456.js'
};

async function handler(request) {
  const stream = await renderToReadableStream(<App assetMap={assetMap} />, {
    // Careful: It's safe to stringify() this because this data isn't user-generated.
    bootstrapScriptContent: `window.assetMap = ${JSON.stringify(assetMap)};`,
    bootstrapScripts: [assetMap['/main.js']],
  });
  return new Response(stream, {
    headers: { 'content-type': 'text/html' },
  });
}

위의 예시에서 bootstrapScriptContent 옵션은 클라이언트에서 window.assetMap 변수를 설정하는 추가 인라인 <script> 태그를 추가함. 이렇게 하면 클라이언트 코드가 동일한 assetMap을 읽을 수 있음:

import { hydrateRoot } from 'react-dom/client';
import App from './App.js';

hydrateRoot(document, <App assetMap={window.assetMap} />);

클라이언트와 서버 모두 동일한 assetMap prop으로 App을 렌더링하므로 hydration 오류가 발생하지 않음.

Streaming more content as it loads

스트리밍을 사용하면 모든 데이터가 서버에 로드되기 전에도 사용자가 콘텐츠를 볼 수 있음. 예를 들어 표지, 친구 및 사진이 있는 사이드바, 게시물 목록이 표시되는 프로필 페이지가 있다면:

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

<Posts />에 대한 데이터를 로드하는 데 시간이 걸린다고 가정하면, 이상적으로는 게시물을 기다리지 않고 나머지 프로필 페이지 콘텐츠를 사용자에게 표시하고 싶을 것. 이렇게 하려면 Posts<Suspense> boundary로 감싸면 됨:

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

이는 Posts가 데이터를 로드하기 전에 HTML 스트리밍을 시작하도록 React에게 지시함. React는 로딩 fallback(PostsGlimmer)을 위한 HTML을 먼저 전송한 다음, Posts가 데이터 로딩을 완료하면 로딩 fallback을 해당 HTML로 대체하는 인라인 <script> 태그와 함께 나머지 HTML을 전송함. 사용자 입장에서는 페이지가 먼저 PostsGlimmer로 표시되고 나중에 Posts가 대체됨.

<Suspense> boundary를 더 중첩하여 보다 세분화된 로딩 시퀀스를 만들 수 있음:

function ProfilePage() {
  return (
    <ProfileLayout>
      <ProfileCover />
      <Suspense fallback={<BigSpinner />}>
        <Sidebar>
          <Friends />
          <Photos />
        </Sidebar>
        <Suspense fallback={<PostsGlimmer />}>
          <Posts />
        </Suspense>
      </Suspense>
    </ProfileLayout>
  );
}

이 예시에서 React는 페이지 스트리밍을 더 일찍 시작할 수 있음. <Suspense> boundary로 감싸져 있지 않기 때문에 ProfileLayoutProfileCover는 먼저 렌더링을 완료해야 함. 그러나 Sidebar, Friends 또는 Photos에서 일부 데이터를 로드해야 하는 경우 React는 대신 BigSpinner fallback을 위한 HTML을 전송함. 그런 다음, 더 많은 데이터를 사용할 수 있게 될수록 모든 데이터가 표시될 때까지 더 많은 콘텐츠가 계속 표시됨.

스트리밍은 브라우저에서 React 자체가 로드되거나 앱이 상호작용이 가능해질 때까지 기다릴 필요가 없음. 서버의 HTML 콘텐츠는 <script> 태그가 로드되기 전에 점진적으로 표시됨.

Note

Suspense-enabled한 데이터 소스만 Suspense 컴포넌트를 활성화함. 여기에는 다음이 포함됨:

  • RelayNext.js와 같은 Suspense-enabled 프레임워크를 사용한 데이터 fetching
  • lazy를 사용한 Lazy-loading 컴포넌트 코드
  • use를 사용한 Prokise 값 읽기

Suspense는 Effect 또는 이벤트 핸들러 내부에서 데이터를 가져오는 시점을 감지하지 못함.

위의 Posts 컴포넌트에서 데이터를 로드하는 정확한 방법은 프레임워크에 따라 다름. Suspense-enabled 프레임워크를 사용하는 경우, 해당 프레임워크의 데이터 fetching 문서에서 자세한 내용을 확인할 수 있음.

Suspense-enabled 프레임워크를 사용하지 않는 Suspense-enabled 데이터 fetching은 아직 지원되지 않음. Suspense-enabled 데이터 소스를 구현하기 위한 요구 사항은 불안정하고 문서화되어 있지 않음. 데이터 소스를 Suspense와 통합하기 위한 공식 API는 향후의 React 버전에서 출시될 예정임.

Specifying what goes into the shell

<Suspense> boundary를 벗어난 앱의 일부를 shell이라고 함:

function ProfilePage() {
  return (
    <ProfileLayout> // ✅
      <ProfileCover /> // ✅
      <Suspense fallback={<BigSpinner />}> // ✅
        <Sidebar>
          <Friends />
          <Photos />
        </Sidebar>
        <Suspense fallback={<PostsGlimmer />}>
          <Posts />
        </Suspense> // ✅
      </Suspense> // ✅
    </ProfileLayout>
  );
}

이는 사용자가 볼 수 있는 가장 빠른 로딩 상태를 결정함:

<ProfileLayout>
  <ProfileCover />
  <BigSpinner />
</ProfileLayout>

전체 앱을 <Suspense> boundary로 감싸면, shell에는 해당 스피너만 포함됨. 하지만 화면에 큰 스피너가 표시되는 것은 조금 더 기다려서 실제 레이아웃을 보는 것보다 느리고 성가시게 느껴질 수 있으므로 사용자 경험에 좋지 않음. 그렇기 때문에 일반적으로 shell이 최소한이지만 완성된 느낌을 주도록 (예: 전체 페이지 레이아웃의 skeleton) <Suspense> boundary를 배치하여 하는 것이 좋음.

renderToReadableStream에 대한 비동기 호출은 전체 shell이 렌더링되는 즉시 stream으로 resolve 됨. 보통은 해당 stream으로 응답을 생성하고 반환함으로써 스트리밍을 시작함:

async function handler(request) {
  const stream = await renderToReadableStream(<App />, {
    bootstrapScripts: ['/main.js']
  });
  return new Response(stream, {
    headers: { 'content-type': 'text/html' },
  });
}

stream이 반환될 때, 중첩된 <Suspense> boundary에 있는 컴포넌트가 여전히 데이터를 로드하고 있을 수 있음.

Logging crashes on the server

기본적으로 서버의 모든 오류는 콘솔에 기록됨. 이 동작을 재정의하여 크래시 보고서를 로그할 수 있음:

async function handler(request) {
  const stream = await renderToReadableStream(<App />, {
    bootstrapScripts: ['/main.js'],
    onError(error) {
      console.error(error);
      logServerCrashReport(error);
    }
  });
  return new Response(stream, {
    headers: { 'content-type': 'text/html' },
  });
}

사용자 정의 onError 구현을 제공하는 경우, 위와 같이 콘솔에 오류를 기록하는 것을 잊지 말 것.

Recovering from errors inside the shell

다음 예시에서는 shell에 ProfileLayout, ProfileCoverPostsGlimmer가 포함되어 있음:

function ProfilePage() {
  return (
    <ProfileLayout>
      <ProfileCover />
      <Suspense fallback={<PostsGlimmer />}>
        <Posts />
      </Suspense>
    </ProfileLayout>
  );
}

이러한 컴포넌트를 렌더링하는 동안 에러가 발생하면, React는 클라이언트에 보낼 의미 있는 HTML을 갖지 못함. 마지막 수단으로 서버 렌더링에 의존하지 않는 fallback HTML을 보내려면 renderToReadableStream 호출을 try...catch로 감쌀 것:

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를 사용하고, 대체 HTML 문서를 보내려면 catch 블록을 사용할 것. Fallback HTML이 오류 페이지일 필요는 없음. 대신 클라이언트에서만 앱을 렌더링하는 대체 shell을 포함할 수 있음.

Recovering from errors outside the shell

다음 예제에서 <Posts /> 컴포넌트는 <Suspense>로 감싸져 있으므로 shell의 일부가 아님:

function ProfilePage() {
  return (
    <ProfileLayout>
      <ProfileCover />
      <Suspense fallback={<PostsGlimmer />}>
        <Posts />
      </Suspense>
    </ProfileLayout>
  );
}

Posts 컴포넌트 또는 그 내부 어딘가에서 오류가 발생하면 React가 복구를 시도함:

  1. 가장 가까운 <Suspense> boundary(PostsGlimmer)에 대한 로딩 fallback을 HTML에 내보냄.
  2. 더 이상 서버에서 Posts 콘텐츠를 렌더링하는 시도를 "포기"함.
  3. JavaScript 코드가 클라이언트에서 로드되면 React는 클라이언트에서 Posts 렌더링을 다시 시도함.

클라이언트에서도 Posts 렌더링에 실패하면, React는 클라이언트에서 오류를 발생시킴. 렌더링 중에 발생하는 모든 에러와 마찬가지로, 가장 가까운 상위 error boundary에 따라 사용자에게 에러를 표시하는 방법이 결정됨. 실제로는 오류를 복구할 수 없다는 것이 확실해질 때까지 사용자에게 로딩 indicator가 표시된다는 의미.

클라이언트에서 Posts 렌더링에 성공하면, 서버의 로딩 fallback이 클라이언트 렌더링 출력으로 대체됨. 사용자는 서버 오류가 발생했다는 사실을 알 수 없음. 그러나 서버의 onError callback 및 클라이언트의 onRecoverableError callback이 실행되어 오류에 대한 알림을 받을 수 있음.

Setting the status code

스트리밍에는 장단점이 있음. 사용자가 콘텐츠를 더 빨리 볼 수 있도록 가능한 한 빨리 페이지 스트리밍을 시작하고 싶을 수 있음. 하지만 스트리밍을 시작하면 더 이상 응답 상태 코드를 설정할 수 없음.

앱을 shell(모든 <Suspense> boundary 외부)과 나머지 콘텐츠로 나누면 이 문제의 일부를 이미 해결한 것. Shell에서 오류가 발생하면 오류 상태 코드를 설정할 수 있는 catch 블록이 실행됨. 그렇지 않으면 앱이 클라이언트에서 복구될 수 있으므로 "OK"를 보낼 수 있음.

async function handler(request) {
  try {
    const stream = await renderToReadableStream(<App />, {
      bootstrapScripts: ['/main.js'],
      onError(error) {
        console.error(error);
        logServerCrashReport(error);
      }
    });
    return new Response(stream, {
      status: 200,
      headers: { 'content-type': 'text/html' },
    });
  } catch (error) {
    return new Response('<h1>Something went wrong</h1>', {
      status: 500,
      headers: { 'content-type': 'text/html' },
    });
  }
}

Shell 외부의 컴포넌트(즉, <Suspense> boundary 내부)에서 에러가 발생해도 React는 렌더링을 멈추지 않음. 즉, onError 콜백이 실행되지만 코드는 catch 블록에 들어가지 않고 계속 실행됨. 이는 위에서 설명한 대로 React가 클라이언트에서 해당 오류를 복구하려고 시도하기 때문.

그러나 원하는 경우 오류가 발생했다는 사실을 사용하여 상태 코드를 설정할 수 있음:

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);
      }
    });
    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' },
    });
  }
}

이 방법은 초기 shell 콘텐츠를 생성하는 동안 발생한 shell 외부의 오류만 포착하므로 완전한 것은 아님. 일부 콘텐츠에서 오류가 발생했는지 여부를 파악하는 것이 중요한 경우 해당 콘텐츠를 shell로 이동하면 됨.

Handling different errors in different ways

자신만의 Error 서브클래스를 생성하고 instanceof 연산자를 사용하여 어떤 에러가 발생하는지 확인할 수 있음. 예를 들어, 사용자 정의 NotFoundError를 정의하고 컴포넌트에서 이를 발생시킬 수 있음. 그런 다음 오류를 onError에 저장하고 오류 유형에 따라 응답을 반환하기 전에 다른 작업을 수행할 수 있음:

async function handler(request) {
  let didError = false;
  let caughtError = null;

  function getStatusCode() {
    if (didError) {
      if (caughtError instanceof NotFoundError) {
        return 404;
      } else {
        return 500;
      }
    } else {
      return 200;
    }
  }

  try {
    const stream = await renderToReadableStream(<App />, {
      bootstrapScripts: ['/main.js'],
      onError(error) {
        didError = true;
        caughtError = error;
        console.error(error);
        logServerCrashReport(error);
      }
    });
    return new Response(stream, {
      status: getStatusCode(),
      headers: { 'content-type': 'text/html' },
    });
  } catch (error) {
    return new Response('<h1>Something went wrong</h1>', {
      status: getStatusCode(),
      headers: { 'content-type': 'text/html' },
    });
  }
}

Shell을 내보내고 스트리밍을 시작하면 상태 코드를 변경할 수 없다는 점에 유의할 것.

Waiting for all content to load for crawlers and static generation

스트리밍은 콘텐츠가 제공될 때 바로 볼 수 있기 때문에 더 나은 사용자 경험을 제공함.

그러나 크롤러가 페이지를 방문하거나 빌드 시점에 페이지를 생성하는 경우, 모든 콘텐츠를 점진적으로 표시하는 대신 모든 콘텐츠를 먼저 로드한 다음 최종 HTML 출력을 생성하는 것이 좋음.

stream.allReady Promise를 awaiting 해서 모든 콘텐츠가 로드될 때까지 기다릴 수 있음:

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' },
    });
  }
}

일반 방문자는 점진적으로 로드되는 콘텐츠 스트림을 받게 됨. 크롤러는 모든 데이터가 로드된 후 최종 HTML 출력을 받게 됨. 그러나 이는 크롤러가 모든 데이터를 기다려야 한다는 것을 의미하며, 그 중 일부는 로드 속도가 느리거나 오류가 발생할 수 있음. 앱에 따라 크롤러에도 shell을 보내도록 선택할 수 있음.

Aborting server rendering

시간 초과 후 서버가 렌더링을 강제로 "포기"하도록 할 수도 있음:

async function handler(request) {
  try {
    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);
      }
    });
    // ...

React는 나머지 로딩 fallback을 HTML로 flush하고 나머지는 클라이언트에서 렌더링을 시도함.

0개의 댓글