← 목차2 / 7

02

스트리밍으로 바꾸기

renderToPipeableStream과 onShellReady

renderToPipeableStream

앞 장에서 renderToString이 Suspense를 지원할 수 없는 이유를 봤습니다. string을 반환하기 때문에 “아직 없음”을 표현할 수 없었습니다.

그러면 어떻게 해야 스트리밍을 적용할 수 있을지 찾아보는 과정에서 renderToPipeableStream이라는 함수를 알게 되었습니다.

renderToString이 React 트리를 문자열로 바꿔 한 번에 돌려준다면, renderToPipeableStream은 스트림으로 렌더링합니다. 준비된 부분부터 흘려보내고, 나머지는 나중에 이어서 보냅니다.

반환 타입도 달랐습니다.

function renderToPipeableStream(element, options): { pipe; abort };

문자열이 아니라 스트림을 조작하는 도구를 돌려줍니다. 그리고 options로 콜백을 받습니다.

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

  // 모든 Suspense가 해결된 뒤에 불린다. 5장에서 다룹니다.
  onAllReady() { ... },
});

콜백은 “언제 무엇을 해라”를 미리 등록해두는 것입니다. 시간을 담을 자리가 여기서 생깁니다. renderToString에는 이 자리가 없었습니다.

인자 반환 기다림을 표현할 수 있나
renderToString element string ❌
renderToPipeableStream element + 콜백 { pipe, abort } ✅

shell이라는 개념

위의 콜백에서 본 onShellReady의 shell이 무엇인지 궁금했습니다. shell은 Suspense 바깥 영역을 말합니다.

<div id="root">
  <h1>Todo List</h1>                           {/* shell */}
  <Suspense fallback={<div>loading...</div>}>  {/* fallback도 shell */}
    <TodoList />                               {/* 이것만 shell 아님 */}
  </Suspense>
</div>

<h1>은 기다릴 것이 없으니 즉시 만들 수 있습니다. TodoList만 2초가 걸립니다. onShellReady는 기다릴 필요 없는 부분이 다 준비된 시점에 불립니다. 이때 응답을 내보내기 시작하면, 사용자는 2초를 기다리지 않고, 화면을 볼 수 있습니다.

서버에 붙여보기

Node 스트림을 웹 스트림으로 옮겨 응답에 싣습니다.

app.get("/", () => {
  // React가 쓸 Node 스트림
  const passThrough = new PassThrough();

  // Node 스트림 → 웹 스트림.
  // React는 Node 스트림에 쓰고, Response는 웹 스트림을 받는다.
  // 둘 사이를 잇는 어댑터가 필요하다.
  const reactStream = Readable.toWeb(passThrough) as ReadableStream<Uint8Array>;

  const { pipe, abort } = renderToPipeableStream(createElement(App), {
    // shell이 준비되는 즉시 흘려보내기 시작한다.
    // 이 시점에 TodoList는 아직 완료되지 않았다.
    onShellReady() {
      pipe(passThrough);
    },

    onError(error) {
      console.error(error);
      passThrough.destroy();
      abort(error);
    },
  });

  // renderToPipeableStream 호출은 렌더링을 시작시킬 뿐이다.
  // 스트림을 Response에 실어 반환하는 것은 별개의 일이다.
  return new Response(reactStream, {
    headers: { "Content-Type": "text/html" },
  });
});
Node 스트림과 웹 스트림은 왜 다른가

Node.js의 스트림 API와 웹 표준 스트림은 서로 다른 인터페이스입니다. React의 renderToPipeableStream은 Node 스트림에 쓰고, Response는 웹 스트림을 받으므로 변환이 필요합니다.

PassThrough와 Readable.toWeb은 스트림 형식을 맞추는 배관입니다. 눈여겨볼 곳은 onShellReady입니다. 여기서 pipe를 부르기 때문에 shell이 준비되는 즉시 응답이 나가기 시작합니다.

renderToPipeableStream 호출은 렌더링을 시작시킬 뿐 응답을 만들지 않습니다. 스트림을 Response에 실어 반환하는 것은 별개의 일입니다.

결과

curl -N -w '\nTTFB: %{time_starttransfer}s / Total: %{time_total}s\n' localhost:3001
TTFB: 0.006660s / Total: 2.017178s

renderToString과 비교하면 이렇습니다.

TTFB Total 목록
renderToString 0.005s 0.005s 없음
renderToPipeableStream 0.006s 2.017s 있음

TTFB와 Total이 차이가 벌어졌습니다. 스트리밍이 동작했다는 의미입니다. 첫 바이트가 0.006s에 나갔고, 응답은 약 2초에 걸쳐 열려 있었습니다. renderToString은 두 값이 같았습니다. 기다린 적이 없기 때문입니다.

첫 번째 0.006s

<div>
  <h1>Todo List</h1>
  <!--$?--><template id="B:0"></template>
  <div>loading...</div>
  <!--/$-->
</div>

loading...이 있고, <template id="B:0">라는 빈 태그가 있습니다. 나중에 채울 자리로 이해하고 넘어가면 됩니다.

두 번째 2.017s

<div hidden id="S:0">
  <ul>
    <li>Buy groceries</li>
    ...
  </ul>
</div>

목록이 도착했습니다. hidden이 붙어 있어 화면에는 보이지 않습니다. 문서 맨 아래에 도착했습니다.

세 번째 2.017s

<script>
  ...$RC("B:0","S:0")
</script>

B:0 자리에 S:0의 내용을 옮기라는 명령입니다.

왜 이런 구조인가

HTML은 위에서 아래로만 흐릅니다. 이미 보낸 부분을 고칠 수 없습니다. 그런데 스트리밍이 해야 하는 일은 위쪽의 loading...을 나중에 실제 내용으로 바꾸는 것입니다. 이것은 HTML만으로는 불가능합니다. 그래서 우회합니다. 완성된 조각을 문서 끝에 숨겨서 보내고, 작은 스크립트로 제자리에 옮깁니다. 이 구조는 6장에서 자세히 다룹니다. 그전에 걸리는 것이 하나 있었습니다. 응답이 <div>부터 시작한다는 점입니다.