Next.js 13 - streaming SSR + Suspense

GY·2023년 9월 2일
0

Next.js

목록 보기
7/7
post-thumbnail

오늘은 아래 내용에 대해 정리해보겠습니다.

  • Next.js 13에서 새로 도입되었던 streaming SSR은 어떤 원리로 구현되었고, 왜 도입된 건지
  • Next.js에서 SSR을 적용할 때 사용하던 방법이 실제로는 어떻게 동작하는지

streaming SSR이 왜 필요했던 걸까

이전 포스팅에서 streaming SSR에 대해 언급했었습니다.
왜 갑자기 기존 SSR을 잘 쓰다가 streaming SSR을 도입할 필요성을 느꼈던 걸까요?

  • streaming되지 않은 채로 서버에서 html을 받아오는 기존 SSR방식은 그 용량이 크면 렌더링 속도가 지연된다는 단점이 있었습니다.
  • 렌더링 속도가 지연되는 주요한 이유는 이 SSR이 동기적으로 진행되었기 때문입니다.

이게 무슨 말인지 더 자세히 알아보기 위해, 기존 SSR이 어떻게 구현되어 있었는지부터 살펴보겠습니다.


SSR이 진행되는 과정

Next.js 12

Next.js 12버전에서는 getServerSideProps 메서드를 작성하면 SSR을 적용할 수 있습니다.
처음 Next.js 접했을 때 '좀 희한하네?'라고 생각했었는데, 이렇게 작성만 해놓으면 어떻게 Next.js에서 SSR을 구현한다는 건지 전혀 감이 잡히지 않았기 때문이었습니다.

어떤 컴포넌트는 SSR을 적용하여

  • 서버에서 미리 특정 엔드포인트로 데이터를 호출한 다음
  • 이 data를 컴포넌트로 넘겨주고
  • 컴포넌트에서 이 데이터의 title값을 받아 텍스트를 화면에 렌더링한다고 가정해보겠습니다.
function Page({ data }) {
  <p>{data.title}</p>
  //...
}

export async function getServerSideProps() {
  const res = await fetch(`url`)
  const data = await res.json()
  return { props: { data } }
}

export default Page

Page 컴포넌트를 서버사이드 렌더링하기 위해서는 getServerSideProps함수를 같은 모듈안에 함께 작성해주어야 합니다. 반드시 getServerSideProps라는 이름이어야 하고, 이것은 컴포넌트에서 렌더링할 때 필요한 데이터를 리턴해주는 형태여야 합니다. 더군다나 우리는 이 함수를 컴포넌트 내부에서 사용하지도 않는데 export까지 해주어야 합니다.

???
왜 이렇게 해야하는 걸까요?


Next.js에서 getServerSideProps를 사용해 pre rendering을 하는 로직을 보겠습니다.

// ...
if (isSSR && !cachedHTML) {
  const result = ReactDOM.renderToString(<Page />);
  const initialData = { name: "ssr" };
  const preRenderedProps = await Page.getServerSideProps(initialData);

  indexHTML = indexHTML
    .replace(
      '<div id="root"></div>', 
      `<div id="root">${result}</div>` 
    )
    .replace("__DATA_FROM_SERVER__", JSON.stringify(preRenderedProps));

1. 컴포넌트 직렬화

SSR을 적용하고 캐싱된 내용이 없을 경우, 해당 컴포넌트를 renderToString()함수로 직렬화합니다.

2. getServerSideProps()에 정의된 데이터를 서버에서 fetch

그리고 prerendering할 때 이 컴포넌트 파일에 export된 getServerSideProps()함수를 실행해 서버 사이드 렌더링에 필요한 데이터를 서버에서 불러옵니다.

3. 서버에서 불러온 데이터로 교체

html 문자열에서 DATA_FROM_SERVER 문자열을 이 pre rendering된 데이터로 교체해 새로 업데이트할 html 문서를 만듭니다.

쉽게 말하면, getServerSideProps()라는 메서드 안에 서버에서 실행이 필요한 로직을 넣어놓으면, Next.js에서 이 함수로 접근해 대신 서버에서 실행 후 렌더링할 수 있도록 하는 겁니다.

SSR이 어떤식으로 동작하는지 간단하게 정리해보았습니다.
이제 여기에서 어떤 부분이 동기적으로 실행되어 메인 스레드를 블로킹하는지 알아볼게요.


우선 위 예시에서 언급된 renderToString() 동기 함수를 살펴보겠습니다.


기존 SSR의 문제점

ReactDomServer.renderToString()

프로젝트 규모가 크고 서버 사이드 렌더링을 해야할 컴포넌트의 크기가 방대하다고 가정해보겠습니다.
위의 예시에서 SSR을 위해 가장 먼저 호출하는 renderToString()은 동기함수이므로 처리 속도가 오래걸리게 되면 그 다음 작업 역시 지연됩니다.

이와 관련한 Next.js github issue를 살펴볼게요.
Low concurrent users cap with SSR

리포트된 이슈:
We were stress testing our next.js app and we're running into a rather low cap for the number of concurrent connections. For the initial GET request we're using next.js and express to serve up a server side rendered index page.
We're implementing some analytics at the moment to try and get a better idea about what our bottleneck is but I was wondering if there was anyone who has experienced a similar problem.

  • next.js 앱에 대한 부하 테스트 결과 동시 연결 한도가 다소 낮았음
  • 초기 GET 요청의 경우 next.js와 express를 사용하여 서버 측 렌더링된 인덱스 페이지를 제공하는 상태
  • 병목 현상의원인과 해결책에 대한 질문

이에 대한 논의 내용을 살펴보자면
서버 사이드 렌더링이 동기식으로 진행하는 것을 원인으로 정의하고 있고, 서버 사이드렌더링 항목을 줄이거나 캐싱하는 등의 여러가지 방법을 해결책으로 제안했습니다.

The server render is a synchronous task, because of that it block the event loop of node.js, you must run multiple instances of your next app to serve many users, you can also avoid rendering some parts to reduce the time it takes to server render and set a cache.
In the example folder there are examples of progressive render (avoid rendering some parts) and cache.

  • 서버 렌더링은 동기식 작업이므로 node.js의 이벤트 루프를 차단함
  • 많은 사용자에게 서비스를 제공하려면 앱의 여러 인스턴스를 실행하거나
  • 서버에 걸리는 시간을 줄이기 위해 일부 부분 렌더링을 캐싱해 진행하는 방법이 있음

I think is not a side effect of next.js, it's more a side effect of React server render being synchronous. Maybe you can try using Preact for production which is supposed to render faster than React.
In my experience mounting a basic React server render you can take 200ms or more for rendering a normal page. So to improve that you can:
render less things server side
cache your server side rendered pages
have more instances of your server and use a load balancer
use Preact or Inferno to render faster

  • next.js의 부작용이 아니라 React 서버 렌더링이 동기식으로 발생하는 부작용에 가깝다는 의견
  • 개선책으로 다음 항목을 제안
    • 서버 사이드 렌더링 항목 줄이기
    • 서버 측 렌더링된 페이지 캐싱
    • 더 많은 서버 인스턴스와 로드 밸런서 사용
    • 더 빠르게 렌더링을 위한 Preact 또는 Inferno를 사용

이렇듯 기존 SSR의 동기적인 작동으로 인해 다음과 같은 단점이 있었습니다.

  1. 사용자에게 페이지를 표시하기 위해서는 모든 데이터를 전부 가져와야 합니다.
  2. hydration을 시작하기 전 모든 html과 js를 로드해야합니다.
  3. hydration은 한번에 이루어지므로 react는 해당 작업이 완료될 때까지 멈추지 않습니다. 따라서 모든 컴포넌트가 상호작용하기 전에 모든 컴포넌트가 수화될 때까지 기다려야 합니다.

React 18에서의 Concurrency 도입

여러 가지 시도에도 불구하고 동기 처리로 인한 근본적인 문제해결은 불가피했으므로, React는 18버전에서 '동시성(Concurrency)'에 대한 개념을 도입하게 됩니다.


그리고 18버전에서 이 문제를 근본적으로 해결하기 위해 도입한 개념은 크게 2가지 입니다.

New Suspense SSR Architecture in React 18

  1. streaming HTML
    • renderTosTring() 메서드 대신 renderToPipableStream()메서드로 전환했습니다.
  2. selective hydration
    • 로 컴포넌트를 랩핑해 적용합니다.

Streaming HTML lets you start emitting HTML as early as you’d like, streaming HTML for additional content together with the <script> tags that put them in the right places.

(streaming HTML은 빠르게 첫 HTML을 내보낸 다음 <script> tag와 함께 추가 콘텐츠를 스트리밍해 필요한 곳에 재배치합니다.)

Selective Hydration lets you start hydrating your app as early as possible, before the rest of the HTML and the JavaScript code are fully downloaded. It also prioritizes hydrating the parts the user is interacting with, creating an illusion of instant hydration.

(Selective Hydration은 자바스크립트와 html이 모두 로드되기 전 가능한 영역부터 먼저 하이드레이션하는 것을 말합니다. 또한 즉각적인 하이드레이션처럼 유저가 느끼도록 유저의 인터렉션이 필요한 부분부터 우선적으로 하이드레이션합니다.)


그리고 이 기능들은 Suspense와 함께 사용됩니다.

streaming HTML이라니.. 어떻게 HTML이 스트리밍 된다는 거죠?
HTML은 모두 완성된 채로 전송되기 때문에 SSR 역시 새로고침을 해야 다시 새로운 html을 만들어 리렌더링할 수 있는 것 아니었나요?


Streaming HTML

이전 포스팅에서 RSC를 알아보면서 streaming SSR이 무엇인지에 대해서 알아보았습니다.

그렇다면 streaming SSR은 어떻게 가능한 걸까요?
html을 streaming할 수 있어야 합니다. streaming HTML은 이렇게 작동합니다.

streaming에 사용되는 HTTP를 살펴보면,

HTTP/1.1 스펙의 header 값 중 Transfer-Encoding: chunked
(HTTP/2에서는 기본 지원)

Transfer-Encoding

MDN - Transfer-Encoding

  • Transfer-Encoding 헤더값은 페이로드 바디를 안전하게 전송하기 위한 인코딩 형태를 정의합니다.

  • chunk directive는 데이터가 일련의 chunk로 전송된다는 것을 의미합니다. Content-Length헤더값은 이 경우 생략됩니다.

  • Content-Length는 일반적으로 응답에 함께 보내어 전송되는 콘텐츠의 길이를 알려주는데, 스트리밍 요청은 그 길이를 처음부터 알려줄 수 없습니다.
    브라우저는 남은 chunk의 길이가 0이 될 때까지 커넥션을 닫지 않고 기다리면서 TCP/IP 핸드쉐이크 비용을 절약합니다.


그렇다면 streaming HTML을 사용해서 streaming SSR을 어떻게 구현했을까요?

streaming되는 chunk에는 어떤 데이터가 들어갈까요?

이전 포스팅에서 미리 알아봤던 내용이 있었습니다.

streaming SSR을 사용한 React Server Component (RSC)는 이런식으로 로드합니다
- 첫번째 chunk 로드: 미리 렌더링 된 HTML
- 후속 chunk 로드: Suspense 경계로 인해 추가 스트리밍된 HTML
- Hydration: CSR 동작을 위한 자바스크립트 번들

이제 슬슬 Suspense도 함께 알아볼 때가 되었습니다.
SSR을 적용한 컴포넌트에서 Suspense로 감싼 하위 컴포넌트를 렌더링한다고 가정해보겠습니다.

function ComponentA () {
  
  return (
    // 다른 요소들도 많이 있다고 가정
    <div>
    //...
    <Suspense fallback={<div>loading...</div>}
    	<ComponentB/>
    </Suspense>

모든 데이터가 준비되지 않아도 부분적으로 렌더링 가능

이전처럼 SSR이 동기적으로 작동한다면 모든 요소들이 HTML로 완성되기 전까지 페이지가 초기 렌더링될 수 없습니다.
하지만 streaming SSR에서는 Suspense로 랩핑된 컴포넌트를 기다리지 않고 fallback UI로 대체한뒤, 나머지 요소들을 먼저 스트리밍으로 받아오게됩니다.
이후 Suspense로 랩핑된 컴포넌트가 준비되면 <script> 태그와 함께 이 추가적인 HTML을 스트림으로 전송합니다.

Selective Hydration: 가능한 컴포넌트 먼저 인터렉티브하게

이전에는 한번에 hydration이 진행되어야 했지만,
Suspense로 랩핑된 컴포넌트 이외의 요소들부터 hydration되므로 해당 컴포넌트가 로드되기 전에 필요한 부분부터 hydration할 수 있습니다.

Suspense로 랩핑된 컴포넌트가 로드되어 hydration중이더라도 이미 hydration이 끝난 요소들은 유저와의 상호작용이 가능합니다.

어, 그런데 아까 위에서 봤던 내용 중 이런 부분이 있었습니다.

Selective Hydration lets you start hydrating your app as early as possible, before the rest of the HTML and the JavaScript code are fully downloaded. It also prioritizes hydrating the parts the user is interacting with, creating an illusion of instant hydration.

(Selective Hydration은 자바스크립트와 html이 모두 로드되기 전 가능한 영역부터 먼저 하이드레이션하는 것을 말합니다. 또한 즉각적인 하이드레이션처럼 유저가 느끼도록 유저의 인터렉션이 필요한 부분부터 우선적으로 하이드레이션합니다.)

이건 무슨 말일까요?
Suspense로 랩핑된 컴포넌트가 2개가 있다고 가정하겠습니다.

  <div>
    <Suspense fallback={<div>loading...</div>}
    	<ComponentB/>
    </Suspense>
    <Suspense fallback={<div>loading...</div>}
    	<ComponentC/>
    </Suspense>

사용자가 만약 ComponentC와 인터렉션을 시작한다면, React는 이 영역에 대한 hydration이 우선적으로 필요하다고 가정하고 먼저 인터렉티브하게 만듭니다.

즉 먼저 hydration하고 이후에 나머지 ComponentB를 hydration합니다. selective hydration에서는 hydration에 대한 우선순위를 재조정하는 것입니다.


Suspense는 어떻게 동작하는 걸까?

그렇다면 Suspense는 어떻게 이것을 가능하게 하는 걸까요?

Suspense는 비동기 상태를 선언적으로 처리할 수 있도록 도와줍니다.
즉, 직접 비동기 상태를 개발자가 일일히 제어하지 않아도 선언에 따라 적절히 작업을 수행합니다.

구체적으로는 상위 컴포넌트로 Promise를 throw하여 컴포넌트가 로딩 상태임을 전파합니다.
무한 루프를 돌면서 throw된 것들을 감지하고, 그 중 Promise의 인스턴스가 있다면 resolve를 시도합니다.
그 동안은 다른 컴포넌트들을 먼저 렌더링합니다.

그렇다면 후속 응답으로 들어오는 chunk는 먼저 렌더링된 HTML 위에 어떻게 덧붙여지는 걸까요?

Suspense로 감싸진 컴포넌트는 초기 HTML에 포함되지 않는다고 했습니다.
이 때 로드가 된 후 해당 컴포넌트가 렌더링 될 자리를 미리 표시해두는데, 이를 placeholder라 합니다.

이 placeholder는 다음과 같은 형식으로 생성됩니다.

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

이것은 리액트에서 정의한 매직 리터럴로, Suspense 경계를 식별하는데 사용됩니다.

react github - ReactFiberConfigDOM.js

const SUSPENSE_START_DATA = '$';
const SUSPENSE_END_DATA = '/$';
const SUSPENSE_PENDING_START_DATA = '$?';
const SUSPENSE_FALLBACK_START_DATA = '$!';

이렇게 정의한 Placeholder를 넣는 로직은 이런 방식입니다.
react github - ReactFizzConfigDOM.js

// Suspense boundaries are encoded as comments.
const startCompletedSuspenseBoundary = stringToPrecomputedChunk('<!--$-->');
const startPendingSuspenseBoundary1 = stringToPrecomputedChunk(
  '<!--$?--><template id="',
);
const startPendingSuspenseBoundary2 = stringToPrecomputedChunk('"></template>');

그리고 이것으로 식별한 자리에 알맞은 추가 로드된 내용을 교체해 넣습니다.

이렇게 suspense경계에 따라 fallback으로 표시하고 나머지 완성된 HTML을 렌더링하는 것입니다.


Reference

profile
Why?에서 시작해 How를 찾는 과정을 좋아합니다. 그 고민과 성장의 과정을 꾸준히 기록하고자 합니다.

0개의 댓글