본문 바로가기

리액트

리액트 스트리밍 SSR 직접 구현

쿼리 파라미터로 받은 값을 3초 후 그대로 화면에 출력하는 매우 간단한 앱을 스트리밍 SSR로 구현해 보겠습니다.

로딩은 Suspense로 처리하고, Promise 정보를 서버와 클라이언트가 공유하는 과정까지 포함됩니다.

참고로 서버와 클라이언트가 데이터를 공유하는 과정은 그냥 이런 방법이 있다는 정도로 넘어가면 될 것 같습니다.

이 과정을 포함한 이유는 대부분의 앱에서 서버와 클라이언트간 데이터 공유가 필요할 텐데, 이러한 과정이 그냥 되는 것은 아니라는 것을 보여드리고 싶어서 입니다.

 

예제 코드

 

GitHub - JoonDong2/react-streaming-ssr

Contribute to JoonDong2/react-streaming-ssr development by creating an account on GitHub.

github.com

 

흐름도

streaming ssr

 

각 번호가 언제 실행되는지 코드 주석에 설명해 놓겠습니다.

분석

먼저 서버 사이드 랜더링을 위해 renderToPipeableStream을 사용합니다.

https://ko.react.dev/reference/react-dom/server/renderToPipeableStream

 

onShellReady는 최초 랜더링이 완료되었을 때 호출되는 콜백 함수입니다.

 

Promise를 수신한 Suspensefallback을 랜더링하고 있습니다.

 

pipe를 통해 해당 결과가 스트리밍됩니다.

 

그리고 Promiseresolve 또는 reject되었을 때, 이를 DOM에 반영하고 hydration을 트리거하는 스크립트가 pipe를 통해 다시 스트리밍됩니다.

 

노드 서버 라이브러리인 http, express, fastify의 응답 객체는 모두 http.ServerResponse를 기반으로 하며, http.ServerResponseWritable 스트림의 구현체입니다.

더보기

http.ServerResponse는 Writable 스트림을 구현합니다.

https://github.com/DefinitelyTyped/DefinitelyTyped/blob/0.1.450/types/node/http.d.ts#L146

 

https://github.com/DefinitelyTyped/DefinitelyTyped/blob/0.1.450/types/node/http.d.ts#L122

 

expressResponse 객체는 http.ServerResponse를 상속합니다.

https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/express/index.d.ts#L120-L123

 

https://github.com/DefinitelyTyped/DefinitelyTyped/blob/master/types/express-serve-static-core/index.d.ts#L701-L705

 

fastify도 비슷합니다.

 

fastify는 콜백함수에 Reply 객체를 받는데, 내부에 http.ServerResponse 객체를 포함하고, http.ServerResponse 기능을 확장합니다.

https://fastify.dev/docs/latest/Reference/Reply/#raw

따라서 두 스트림을 연결하는 것만으로 pipe에 출력되는 데이터가 클라이언트로 전송됩니다.

import { renderToPipeableStream } from "react-dom/server";
import express from "express";

const app = express();

app.get("/", (req, res) => {
  const key = String(req.query.key);
  const { pipe } = renderToPipeableStream(
    <Suspense fallback="loading...">
      <App queryKey={key} />
    </Suspense>,
    {
      bootstrapScripts: ['/index.js'],
      onShellReady() {
        pipe(res);
      }
    }
  );
});

app.use(express.static("build/client")); // host 'build/client' to '/'

https://github.com/JoonDong2/react-streaming-ssr/blob/main/server/index.tsx

 

레퍼런스 코드:

https://ko.react.dev/reference/react-dom/server/renderToPipeableStream#reference

더보기

스트리밍 SSR을 직접 구현해 보기 전까진 HTTP 연결이 연결이 유지되지 않는 "비연결성"이 있는줄 알았습니다.

아마 "무상태성"이랑 헤깔린 것 같습니다.

하지만 HTTP에 지속 연결과 파이프라이닝 개념이 있다는 것을 알았습니다.
https://developer.mozilla.org/ko/docs/Web/HTTP/Overview#http%EC%99%80_%EC%97%B0%EA%B2%B0

어차피 OSI 계층의 어플리케이션 계층인 HTTP가 보낸 요청 메세지 1회성 요청이든 스트림 응답이든 TCP에서 세그먼트로, IP에서 패킷으로 쪼개져서  수신측에선 (다시 패킷->세그먼트->) 소켓(스트림)을 통해 HTTP 응답 메세지로 병합됩니다.

이 부분에 대한 정확한 자료는 찾지 못했지만, 일반 응답과 스트림 응답의 차이는 어플리케이션 계층에서 메세지를 한 번에 만들 것인지, 여러 번에 걸쳐서 만들 것인지 차이인 것 같습니다.

참고 자료: https://www.youtube.com/watch?v=p6ASAAMwgd8&list=PLXvgR_grOs1BFH-TuqFsfHqbh-gpMbFoy&index=7

bootstrapScripts에 있는 문자열들은 <script src='/index.js'></script>로 변환하여 같이 스트림으로 전송합니다.

 

index.js는 클라이언트 사이드에서 실행될 번들링 파일입니다.

import React, { Suspense } from "react";
import { hydrateRoot } from "react-dom/client";
import App from "./App";
import { SuspenderProvider } from "./suspender";

const root = document.getElementById("root")!;

hydrateRoot(
  root,
  <Suspense fallback="loadding..">
    <App
      queryKey={
        globalThis.window
          ? new URLSearchParams(globalThis.window.location.search).get("key")
          : undefined
      }
    />
  </Suspense>
);

https://github.com/JoonDong2/react-streaming-ssr/blob/main/client/index.tsx

 

클라이언트 사이드 번들링 파일은 /build/client 경로에 저장되고, 해당 경로는 / 경로에서 호스팅됩니다.

app.use(express.static("build/client")); // host 'build/client' to '/'

https://github.com/JoonDong2/react-streaming-ssr/blob/e86daa1722fbfbc946458f5619c36d5bf0244346/server/index.tsx#L15

 

package.json

{
  "scripts": {
    "build:client": "esbuild src/index.tsx --bundle --outfile=build/client/index.js --loader:.js=jsx",
  },
}

https://github.com/JoonDong2/react-streaming-ssr/blob/e86daa1722fbfbc946458f5619c36d5bf0244346/package.json#L7

 

클라이언트 사이드에서는 root DOM을 찾아서 hydration을 시도하고 있기 때문에, 서버에선 레퍼런스 예제처럼 그냥 App 컴포넌트만 랜더링해서 보내면 안됩니다.

 

서버에서도 root DOM을 미리 만들어 놓고, 그 안에 App 컴포넌트를 랜더링해야 합니다.

 

편의를 위해 아래 html 파일을 {{CONTENTS}}로 쪼개고 그 사이에 랜더링된 결과를 삽입하겠습니다.

 

src/index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  </head>
  <body>
    <div id="root">{{CONTENTS}}</div>
  </body>
</html>

https://github.com/JoonDong2/react-streaming-ssr/blob/main/src/index.html

 

inxex.html 파일을 문자열로 읽어서 {{CONTENTS}}를 기준으로 header와 footer로 분할합니다.

 

그리고 headerfooter를 전송하는 부분 사이에 응답 객체에 스트림을 연결합니다.

 

onShellReady 콜백 함수에서 이미 랜더링된 결과가 준비된 상태기 때문에, 연결 즉시 데이터가 전송됩니다.

import { renderToPipeableStream } from "react-dom/server";
import { Writable } from "stream";

const template = fs.readFileSync(path.resolve("./src/index.html"), "utf-8");
const [header, footer] = template.split("{{CONTENTS}}");

app.get("/", (req, res) => {
  const key = String(req.query.key);
  const { pipe } = renderToPipeableStream(
    <Suspense fallback="loading...">
      <App queryKey={key} />
    </Suspense>,
    {
      onShellReady() {
        res.write(header); // 흐름도 1번 부분
        pipe(res); // 요기 !! 흐름도 2, 4번 부분
        res.write(footer); // 흐름도 3번 부분
      },
    }
  );
});

https://github.com/JoonDong2/react-streaming-ssr/blob/e86daa1722fbfbc946458f5619c36d5bf0244346/server/index.tsx#L28-L45

 

streaming ssr flow

 

그리고 서버에서 Promiseresolve되면, 동일한 스트림으로 부터 원래 children을 랜더링한 DOM과 해당 DOMfallback과 교체하고, hydration을 수행하는 스크립트 데이터가 출력되면서 연결된 응답 객체를 통해 자동으로 클라이언트로 전송됩니다.

 

localhost:3000?key=1로 접속하면, 처음엔 아래와 같이 응답을 받습니다.

 

Suspense는 주석으로, 자식은 fallback으로 채워져 있습니다.

response

 

Promiseresolve되면서 Suspensechildren을 그릴 수 있게 되면, 원래 children과 이것을 fallback과 교체하는 스크립트pipe를 통해 추가되는 것을 확인할 수 있습니다.

response after 3 seconds

 

12번 라인의 코드를 풀어보면 다음과 같이 되어 있습니다.

<div hidden id="S:0">
  <div>key: 1</div>
  <!--$?--><template id="B:1"></template>test<!--/$-->
</div>
<script>
  function $RC(a, b) {
    a = document.getElementById(a);
    b = document.getElementById(b);
    b.parentNode.removeChild(b);
    if (a) {
      a = a.previousSibling;
      var f = a.parentNode,
        c = a.nextSibling,
        e = 0;
      do {
        if (c && 8 === c.nodeType) {
          var d = c.data;
          if ("/$" === d)
            if (0 === e) break;
            else e--;
          else ("$" !== d && "$?" !== d && "$!" !== d) || e++;
        }
        d = c.nextSibling;
        f.removeChild(c);
        c = d;
      } while (c);
      for (; b.firstChild; ) f.insertBefore(b.firstChild, c);
      a.data = "$";
      a._reactRetry && a._reactRetry();
    }
  }
  $RC("B:0", "S:0"); // 요기서 실행 !!
</script>

 

a는 기존 fallback이 랜더링된 DOM입니다.

 

b는 원래 chilren이 랜더링된 DOM입니다.

 

ab와 교체(children은 화면에 표시되지만 dehydrated 상태)한 다음, a 부분에 대하여 _reactRetry를 시도하는데, 해당 부분을 리랜더링하면서 hydration을 수행하는 함수입니다.

더보기

_reactRetry로 등록하는 함수

https://github.com/facebook/react/blob/v18.3.1/packages/react-dom/src/client/ReactDOMHostConfig.js#L772-L777

 

retry 함수를 _reactRetry 등록

https://github.com/facebook/react/blob/v18.3.1/packages/react-reconciler/src/ReactFiberBeginWork.new.js#L2715-L2716

 

retryDehydratedSuspenseBoundary

Suspense fiber 리랜더링(hydration) 예약

https://github.com/facebook/react/blob/v18.3.1/packages/react-reconciler/src/ReactFiberWorkLoop.new.js#L2721-L2728

 

retryTimedOutBoundary

boundaryFiberroot를 찾아서(enqueueConcurrentRenderForLane), rootpendingLanesLane을 새기고(markRootUpdated), 랜더링을 예약합니다. (ensureRootIsScheduled)

https://github.com/facebook/react/blob/v18.3.1/packages/react-reconciler/src/ReactFiberWorkLoop.new.js#L2714-L2717

 

enqueueConcurrentRenderForLane > enqueueUpdate

dispatchSetState로 업데이트할 땐 root.pendingLanes 뿐만 아니라, root까지 모든 부모 fiberchildLanes를 기록하지만, 이 경우엔 하지 않습니다.

https://github.com/facebook/react/blob/v18.3.1/packages/react-reconciler/src/ReactFiberConcurrentUpdates.new.js#L95-L96

 

ensureRootIsScheduled로 예약된 performConcurrentWorkOnRoot > renderRootConcurrent가 실행될 때, prepareFreshStack > finishQueueingConcurrentUpdates에서 업데이트가 발생한 fiber부터 root까지 childLanesLane을 새깁니다.

https://github.com/facebook/react/blob/v18.3.1/packages/react-reconciler/src/ReactFiberWorkLoop.new.js#L1774

 

https://github.com/facebook/react/blob/v18.3.1/packages/react-reconciler/src/ReactFiberWorkLoop.new.js#L1489

 

childLanes를 새기는 것은 중요한데, beginWork로 리액트 트리를 타고 내려갈 때 props가 동일하더라도 childLanes가 현재 Lane에 포함되어 있다면 bailout할 때 null이 아니라 child를 반환하여 계속 트리를 타고 내려갈 수 있기 때문입니다.

https://github.com/facebook/react/blob/v18.3.1/packages/react-reconciler/src/ReactFiberBeginWork.new.js#L3727-L3750

 

https://github.com/facebook/react/blob/v18.3.1/packages/react-reconciler/src/ReactFiberBeginWork.new.js#L3696

 

https://github.com/facebook/react/blob/v18.3.1/packages/react-reconciler/src/ReactFiberBeginWork.new.js#L3383-L3403

AppSuspender 저장소로 부터 Suspender 객체를 얻어와 읽기를 시도하는데, 이때 서버 데이터와 동기화시키지 않는다면, 클라이언트 사이드에서 동일한 컴포넌트(App)를 랜더링하면서 데이터가 없다고 판단하고 Prosmie를 다시 던집니다.

import React, { useEffect } from "react";
import { Suspender } from "./suspender";

interface Props {
  queryKey?: string | null;
}

const App = ({ queryKey }: Props) => {
  const suspender = Suspender.get(queryKey);
  const data = suspender.read(); // throwable Promise

  useEffect(() => {
    console.log("App from client", queryKey, data);
  }, [queryKey, data]);

  return (
    <div
      onClick={() => {
        alert(data);
      }}
    >{`key: ${data}`}</div>
  );
};

https://github.com/JoonDong2/react-streaming-ssr/blob/e86daa1722fbfbc946458f5619c36d5bf0244346/src/App.tsx#L9-L10

 

 

이를 방지하기 위해 renderToPipeableStream이 종료되었을 때, 서버의 상태를 스트림으로 전달해 줍니다.

import { renderToPipeableStream } from "react-dom/server";
import { Writable } from "stream";

app.get("/", (req, res) => {
  const key = String(req.query.key);
  const { pipe } = renderToPipeableStream(
    <Suspense fallback="loading...">
      <App queryKey={key} />
    </Suspense>,
    {
      onShellReady() {
        res.write(header); // 흐름도 1번 부분

        const passThough = new Writable({ 
          write(chunk, encodong, callback) { // 맨 위 그림의 2,4번 부분
            res.write(chunk);
            callback();
          },
          final(callback) { // 요기 !! 흐름도 5번 부분
            // 서버 상태 전달
            const stringifiedSuspenderCache = JSON.stringify(
              Suspender.values()
            );
            res.write(
              `<script>window.__SUSPENDER_CACHE__=${stringifiedSuspenderCache}</script>`
            );
            res.end();
            callback();
          },
        });

        pipe(passThough);

        res.write(footer); // 흐름도 1번 부분
      },
    }
  );
});

https://github.com/JoonDong2/react-streaming-ssr/blob/e86daa1722fbfbc946458f5619c36d5bf0244346/server/index.tsx#L28-L45

 

streaming ssr flow

 

보면  Writable 스트림 객체를 새로 만들어서 그대로 응답 (스트림) 객체로 전달하고 있습니다.

 

단지 pipe 스트림이 종료되었을 때(final 메서드), 서버 상태를 스트림에 보내는 부분만 추가되어 있습니다.

 

res.on('finish', () => {...})처럼 응답 스트림을 직접 사용하지 않은 이유는 응답 스트림은 pipe로 부터 flush 요청을 받으면, 스트림이 아직 열려 있고, 이후 스트림에 데이터가 추가되더라도, 추가된 데이터를 클라이언트로 전송하지 않았기 때문입니다.

 

그림으로 보면 아래와 같습니다.

pipe model

 

참고로 원래 레퍼런스 코드의 흐름은 다음과 같습니다.

original pipe model

 

아무튼 이를 통해 pipe 스트림이 종료되면 13번 라인이 스트림으로 전송됩니다.

added server dats to exist pipe

 

13번 라인을 풀어보면 다음과 같습니다.

<script>
  window.__SUSPENDER_CACHE__ = [["1", {
      "value": "1",
      "status": "fulfilled"
  }]]
</script>

 

App에서 Suspender.get으로 Suspender 객체를 얻어올 때, 스트림을 통해 추가된 window.__SUSPENDER_CACHE__를 사용해 Suspender 저장소를 초기화합니다.

class Suspender<T> {
  private static cache = new Map<
    string | undefined | null,
    Suspender<unknown>
  >();

  static get(key?: string | null, delay = 3000) {
    const suspenderCache = (globalThis.window as any)?.__SUSPENDER_CACHE__;
    if (suspenderCache && Suspender.prevRaw !== suspenderCache) {
      Suspender.init(suspenderCache);
      Suspender.prevRaw = suspenderCache;
    }
    // ...
  }
}

https://github.com/JoonDong2/react-streaming-ssr/blob/9db907d2980729946fbab470d64bfa180a2a7287/src/suspender.ts#L17-L21

 

이제 SuspenderProvider의 children인 App 컴포넌트는 Suspender 저장소(Suspender.cache)로 부터 서버로 부터 전송된 Suspender 객체를 얻을 수 있고, Suspender는 이미 resolved되었기 때문에, 서버에서 랜더링된 결과와 동일한 내용을 반환하며 hydration이 성공하게 됩니다.

만약 서버에서 랜더링된 결과와 다르다면 hydration 오류가 발생하면서, children이 새로 생성됩니다.
const App = ({ queryKey }: Props) => {
  const suspender = Suspender.get(queryKey);
  const data = suspender.read(); // 데이터 반환 (not Promise)

  useEffect(() => {
    console.log("App from client", queryKey, data);
  }, [queryKey, data]);

  return (
    <div
      onClick={() => {
        alert(data);
      }}
    >{`key: ${data}`}</div>
  );
};

https://github.com/JoonDong2/react-streaming-ssr/blob/e86daa1722fbfbc946458f5619c36d5bf0244346/src/App.tsx#L10

 

이것은 여러가지 방법 중 하나입니다.

 

react-querySSR에서 사용할 때 비슷한 설정을 하는 것으로 보입니다.

https://tanstack.com/query/v4/docs/framework/react/guides/ssr#on-the-server

 

하지만 이런 방법은 사용자 정보가 너무 쉽게 노출되고, XSS 공격의 대상이 될 수 있습니다.

 

만약 악의적인 사용자가 어떤 스크립트를 추가해서 window.__SUSPENDER_CACHE__를 주기적으로 지운다면, 리랜더링마다 App 컴포넌트가 계속 로딩 화면을 띄울 것입니다.

 

서버 상태를 컴포넌트의 props로 넘기는게 방법도 있습니다.

https://tanstack.com/query/v5/docs/framework/react/guides/ssr#serialization

 

더욱이 위와 같은 방법은 서버 데이터 전체를 보내기 때문에 네트워크 낭비도 심합니다.

느낀점

SSR로 제품까지 만들어 본 적은 없지만, 공부 삼아서 NestJS를 사용해 본 적은 있었습니다.

 

CSR에선 쉽게 사용할 수 있었던 styled-componentsreact-queryNextJS에서 사용하려니까 뭔가 설정할 게 많고, 시행착오도 많이 겪었던 것으로 기억합니다.

 

NextJS에서 streaming ssr을 사용할 땐 또 다른 설정이 필요한 것 같습니다.

https://tanstack.com/query/latest/docs/framework/react/examples/nextjs-suspense-streaming

 

이전 회사에서 어드민 웹 사이트를 리액트로 개발해 본 적은 있는데 번들 파일이 20~30메가 정도여서 처음 로딩이 조금 느렸지만, 어차피 관계자만 사용하는 사이트고, 회사 인터넷 속도가 빨라서 쿨하게 넘긴 적이 있었습니다.

 

물론 외부 사용자에게 제공되는 서비스였다면 많은 고민을 했겠지만, 이런 고민을 좀 더 높은 수준에서 해준 리액트 팀이 놀랍습니다.

 

그리고 이번 예제는 정말 간단한 예제인데, 스트림 데이터를 추가하고, 서버와 클라이언트의 데이터를 동기화하기 위해 고민을 좀 했었습니다.

 

실제 서비스에선 훨씬 복잡할 것 같은데, 그만한 가치가 있는지 아직 잘 모르겠습니다.

 

실제 서비스에선 1ms도 중요하다고 하는데, 아직 잘 와닿지는 않는습니다.

https://www.builder.io/blog/streaming-is-it-worth-it

 

두 번째로 놀라운 점은 Concurrent 모드입니다.

 

우선순위 기반으로 랜더링을 처리하는 것보다 해당 기능을 위해, 현재 랜더링을 중지하고, 높은 우선순위의 랜더링을 실행하거나, 다른 랜더링과 병합하려면, 각 랜더링에서 완전한 상태를 유지할 수 있어야 하는데, 덕분에 Suspense를 경계로 selective hydration을 할 수 있게 되었습니다.

 

사실 Concurrent 모드가 정식으로 도입되기 전부터 selective hydration이 개발되고 있었습니다.

https://github.com/facebook/react/pull/14717

 

Sync Mode에서 selective hydration을 하는 방법은 부분 hydration될 때마다 랜더링을 "완료"하는 것입다.

 

하지만 hydraion이 완료되기 전에 리랜더링이 발생해서 클라이언트 사이드의 구조가 변경되면, "hydrate API는 서버 사이드 트리와 클라이언트 사이드 트리가 일치해야 한다"는 가정이 깨져버리고, root부터 다시 랜더링해야 하는 문제가 있었다고 합니다.

 

이것은 리액트18의 Concurrent 모드에서도 동일하게 문제가 되지만, Concurrent 모드 각 랜더링에서 완전한 상태를 유지할 수 있기 때문에, 리랜더링 범위를 Suspense로 한정할 수 있게 되었습니다.

 

앱 전체를 AppWrapper로 감싼 다음,

import React from "react";
import { PropsWithChildren } from "react";

const AppWrapper = ({ children }: PropsWithChildren) => {
  console.log("AppWrapper");
  return <div>{children}</div>;
};

https://github.com/JoonDong2/react-streaming-ssr/blob/d8cdd3f39edebbb34a3d67d018a34c432d927785/src/App.tsx#L17-L19

 

App에서 다음과 같이 강제로 hydration을 실패해 보면,

const App = ({ queryKey }: Props) => {
  console.log("App");

  const suspender = Suspender.get(queryKey);
  const data = suspender.read();

  /*
   * Forces hydration failure.
   * Excludes AppWrapper from re-rendering and starts re-rendering from App.
   */
  if (globalThis.window) {
    return <div>boundary test !!</div>;
  }

  // ...
};

https://github.com/JoonDong2/react-streaming-ssr/blob/d8cdd3f39edebbb34a3d67d018a34c432d927785/src/App.tsx#L17-L19

 

App 컴포넌트만 리랜더링되는 것을 확인할 수 있습니다.

 

renderToString에선 애초에 서버 사이드에서 Suspense를 지원하지 않기 때문에 리액트17에선 테스트해 볼 필요도 없을 것 같습니다.

 

서버 사이드에서 renderToString에 입력된 트리에 Suspense가 있으면 오류가 발생합니다.

https://ko.react.dev/reference/react-dom/server/renderToString#when-a-component-suspends-the-html-always-contains-a-fallback