Streaming Server Side Rendering

서버 렌더링을 할 때 현재 탐색된 웹페이지를 위한 전체 HTML을 만들어내지 않고 서버에서 앱의 컨텐츠를 스트리밍 처리하도록 하여 여러 조각을 나눌 수 있다. Node의 Stream 모듈은 응답에 데이터 스트림을 포함시킬 수 있다. 이는 클라이언트에게 지속적인 데이터를 전달할 수 있다는 이야기다. 클라이언트가 나눠진 코드들을 받으면 렌더링을 시작한다.

React의 renderToNodeStream을 사용하면 앱을 여러 부분으로 나누어 전송할 수 있게 된다. 클라이언트는 데이터를 수신하면서도 UI를 그려낼 수 있기 때문에 좋은 성능을 이끌어낼 수 있다. 서버로부터 받은 DOM노드들에 대해 hydrate를 호출하여 각각에 대응하는 이벤트 핸들러를 바인딩해 인터렉트 가능하도록 만들 수 있다.

아래 예제는 고양이에 대한 사실 1000개를 사용자에게 보여주는 앱이다.

App 컴포넌트는 renderToNodeStream 메서드를 통해 스트림 데이터를 렌더링하고 있다. 초기에 렌더링 될 HTML은 App 컴포넌트의 코드와 함께 응답 객체로 전송된다.

<!DOCTYPE html>
<html>
  <head>
    <title>Cat Facts</title>
    <link rel="stylesheet" href="/style.css" />
    <script type="module" defer src="/build/client.js"></script>
  </head>
  <body>
    <h1>Stream Rendered Cat Facts!</h1>
    <div id="approot"></div>
  </body>
</html>

이 응답 객체에는 앱 내의 컨텐츠에 필요한 제목이나 스타일시트를 포함하고 있다. 만약 서버에서 App 컴포넌트를 렌더링하기 위해 renderToString을 사용한다면 사용자는 앱이 데이터를 로드하고 메타데이터를 처리하기 전에 모든 데이터를 받을때까지 기다려야 한다. 이를 빠르게 처리하기 위해 renderToNodeStream 을 사용하면 App 컴포넌트의 분리된 코드를 받자마자 곧바로 데이터 로드 및 처리를 시작할 수 있다.

점진적 hydration과 서버 렌더링에 대한 추가 예제를 보려면 이 깃헙 리포지토리를 확인바란다.

Styled-componets가 stylesheet 전달 최적화를 위해 어떻게 스트림 렌더되는지 확인해 보자.

컨셉

점진적 hydration과 같이 스트리밍도 별개의 렌더링 메커니즘으로 SSR성능 향상을 위해 사용할 수 있다. 이름 그대로 스트리밍은 노드 서버에서 HTML을 여러 조각으로 나누어 생성되는대로 클라이언트에 전달한다. 페이지의 마크업 사이즈가 크더라도 HTML의 일부를 미리 받아 그릴 수 있어 TTFB 시간이 상대적으로 감소한다. 메이저 브라우저들은 스트림 된 컨텐츠나 부분적인 응답을 파싱하고 점진적으로 렌더링하여 FP와 FCP까지의 시간을 감소시킨다.

스트리밍은 네트워크 상태에 따라 유연하게 동작한다. 네트워크가 막혀 더 이상 추가적인 바이트를 전송할 수 없게 되면 렌더러는 신호를 받아 상태가 정리될 때까지 스트리밍을 멈춘다. 따라서 서버는 적은 메모리를 사용하며 I/O 컨디션에 따라 유동적으로 동작한다. 이로 인해 Node.js 서버는 여러 요청을 동시에 처리할 수 있고. 무거운 요청이 가벼운 요청의 응답을 방해하는 것을 막을 수 있다. 그 결과 사이트는 비교적 좋지 않은 상황에서도 좋은 응답성을 유지할 수 있다.

React에서 스트리밍 하기

React는 2016에 배포한 16버전에서 스트리밍을 지원하기 시작했다. 아래 API들은 스트리밍 지원을 위해 ReactDOMServer 에 포함되어 있다.

  1. ReactDOMServer.renderToNodeStream(element): 리턴되는 HTML 자체는 ReactDOMServer.renderToString(element) 과 같지만 문자열 대신 Node.js의 readablestream 객체가 반환된다. 이 메서드는 스트림에 HTML을 렌더하기 때문에 서버에서만 실행 가능하다. 클라이언트는 스트림 데이터를 받은 후 ReactDOM.hydrate() 를 호출하여 인터렉트 가능하도록 할 수 있다.

  2. ReactDOMServer.renderToStaticNodeStream(element): 이는 [ReactDOMServer.renderToStaticMarkup(element)](https://reactjs.org/docs/react-dom-server.html#rendertostaticmarkup)에 대응된다. HTML출력은 동일하나 스트림으로 리턴된다. 이는 정적이고 인터렉션이 없는 페이지들에 대해 스트리밍 처리할 수 있다.

두 함수 모두 읽을 수 있는 스트림을 반환하고 클라이언트 측에서 읽기 시작하면 바이트들이 emit되기 시작한다. 이는 읽기 스트림을 응답 객체와 같은 쓰기 스트림에 연결하는 것으로 가능해진다. 응답 객체는 분리된 데이터들을 클라이언트에게 전송하며 그와 동시에 클라이언트에 도착한 부분들은 렌더링된다.

아래는 이를 구현한 예제이며. 스트리밍 서버 사이드 렌더링에 관련된 글을 읽어보아도 좋다.

일반적인 SSR과 스트리밍에 대해 TTFB와 FMP의 차이는 다음의 이미지에서 볼 수 있다.

출처: https://mxstbr.com/thoughts/streaming-ssr/
출처: https://mxstbr.com/thoughts/streaming-ssr/

스트리밍 SSR - 장단점

스트리밍은 SSR과 React의 속도를 증가시키고, 아래의 장점들을 제공한다.

  1. 성능 향상: 서버 렌더링 시작 직후 클라이언트에 즉시 부분적인 데이터가 전송되기 때문에 TTFB가 SSR보다 좋다. 이는 페이지 크기와 상관 없이 일관적으로 유지된다. 클라이언트는 HTML을 즉기 파싱할 수 있기 때문에 FP와 FCP역시 빨리진다.
  2. 네트워크 상태에 영향을 덜 받는다: 스트리밍은 네트워크 상태가 좋지 않거나 정체되는 경우에도 잘 동작한다. 이런 상황에서도 응답성을 유지할 수 있게 된다.
  3. SEO지원: 스트리밍되어 렌더링 된 컨텐츠는 크롤러에서 읽어들일 수 있기 때문에 SEO에도 좋다.

중요한 점은 기존에 renderToString()으로 구현된 부분들을 renderToNodeStream()으로 교체하기가 생각보다 간단하지 않다는 점이다. SSR과 스트리밍이 함께 잘 동작하지 않는 상황들이 있다. 아래는 마이그레이션이 어려운 상황들이다.

  1. SSR처리된 분할 코드들을 받기 전에 여타 다른 이유로 인해 문서에 서버에서 만든 마크업을 미리 추가해야하는 프레임웍을 사용하는 경우다. 예를 들어 특정 프레임워크가 어떤 CSS를 사용해야 하는지 동적으로 결정하여 페이지에 우선적으로 을 확인해 보자.
  2. renderToStaticMarkup은 페이지 템플릿을 만들어내고, renderToString은 데이터가 포함된 동적 컨텐츠를 생성한다. 이 경우 renderToStaticMarkup의 인자에서는 문자열이 올 것으로 기대하기 때문에. renderToString의 컴포넌트를 사용하면 동작하지 않는다. 관련된 내용을 확인해보자.
res.write("<!DOCTYPE html>");

res.write(renderToStaticMarkup(
 <html>
   <head>
     <title>My Page</title>
   </head>
   <body>
     <div id="content">
       { renderToString(<MyPage/>) }
     </div>
   </body>
 </html>);

스트리밍이나 점진적 hydration이나 모두 SSR과 CSR에서 줄 수 있는 사용자 경험의 차이를 줄여준다. 다음 섹션에서는 이제까지 알아보았던 모든 패턴을 비교하여 각기 다른 상황에 대한 적합성에 대해 확인해 보자.