본문 바로가기
react

React18 Suspense SSR 아키텍쳐

by 새우하이 2022. 2. 17.

용어

성능

  • TTFB: Time to First Byte (첫 번째 바이트까지의 시간) - 링크를 클릭한 후 처음으로 들어오는 콘텐츠 비트 사이의 시간을 나타냅니다.
  • FP: First Paint - 픽셀이 처음으로 사용자에게 표시되는 시점.
  • FCP: First Contentful Paint - 요청 콘텐츠(기사 본문 등)가 표시되는 시점
  • TTI: Time To Interactive - 페이지가 상호작용 가능하게 될 때까지의 시간 (이벤트 발생 등).

New Suspense SSR Architecture in React 18 #37

리액트 16.6.0React.lazy와 함께 Suspense가 처음 등장했습니다.

Suspense는 비동기 로딩의 필요성을 감지하고 대체 로드 UI를 렌더링 하는 것입니다. 이게 전부였으면 그냥 Loading Spinner를 예쁘게 쓸 수 있게 해주는 syntactic sugar에 불과했을 겁니다.

대부분의 React App들은 Webpack, Rollup, Browserify 같은 번들링 툴(이하 번들러)을 통해 번들파일을 웹페이지에 포함해서 한 번에 전체 앱을 로드했습니다.

번들링이란?

이는 흩어져 있는 많은 자원들을 하나의 파일로 병합하고 압축해주는 훌륭한 기능이지만, 앱이 커진다면 load 시간도 길어질 수 밖에 없습니다. 이를 방지하기 위해 code splitting을 통해 번들 파일을 분할하고 필요에 따라 동적으로 불러오는 방식을 앞서 언급한 다양한 번들러들이 지원하고 있습니다.

code splitting은 동적 import를 통해 도입할 수 있습니다.

그리고 React.lazy는 동적 import를 사용해 컴포넌트를 렌더링할 수 있고 Suspense는 lazy 컴포넌트가 로드되길 기다리는 동안 로딩화면 등을 보여줄 수 있게 합니다.

즉 이런 기능들은 분할된 번들파일을 동적으로 로드해서 사용자에게 향상된 성능을 제공해줄 수 있고 코드의 양을 줄이지 않아도 사용자에게 필요한 페이지까지 로딩되었을 때 사용자의 요청을 처리할 수 있으므로 초기 로딩에 대한 비용을 줄여줄 수 있습니다.

지금까진 React.lazySuspense는 서버사이드 렌더링을 할 수 없었습니다. 그래서 Loadable Components 등을 사용했어야 했습니다.

이제 리액트 18에서는 Suspense를 이용한 새로운 SSR 아키텍쳐로 기존 SSR 동작 방식의 한계를 극복하고 React.lazy가 SSR과 함께 동작할 수 있는지 살펴보겠습니다.

Server Side Rendering (SSR) ?

SSR vs CSR

SSR을 살펴보기 전 CSR과의 차이점을 간단히 살펴봤습니다.

CSR 서비스는 브라우저가 웹서버로 받아온 HTML을 살펴보면 실제로 그려진 DOM구조와는 완전히 다른 것을 볼 수 있습니다.

View를 누가 그리느냐의 차이입니다. CSR과 SSR의 차이는 여기서 시작됩니다. CSR은 빈 HTML 파일과 CSS, JS 링크만 받아서 클라이언트(브라우저) 사이드에서 렌더링을 하는 방식이고,

SSR은 React Component를 서버에서 HTML로 렌더링 해서 클라이언트 쪽으로 보내줍니다. 이때 HTML은 link나 form 같은 간단한 내장된 상호작용 말고는 할 수 있는 게 별로 없긴 하지만 사용자는 Javascript 코드가 완전히 로딩되는 동안 적어도 뭔가(서버로부터 받은 기본적인 HTML)를 볼 수는 있습니다.

CSR에 대한 자세한 내용은 새 문서에서.

SSR 이란

다시 SSR로 돌아와서 SSR은 서버에서 아주 제한된 웹 인터랙션만 가능한 HTML 파일을 만들어서 브라우저에 뱉어줍니다.

💡 참고
회색 빗금은 아직 상호작용(interaction)이 불가능한 element
녹색 빗금은 상호작용이 가능한 element

하지만 상호작용이 불가능하더라도

이렇게 빈 페이지를 보여주는 것보다는 나을 것입니다.

이후에 브라우저는 이 정적인 HTML과 Store를 JS를 실행해서 동적인 리액트 컴포넌트 트리와 Store로 변환하는 과정이 일어나고 이를 hydrate라고 합니다. 즉 브라우저는 React와 애플리케이션 코드가 모두 load 되었을 때 이미 서버로부터 받아온 HTML에 이벤트 핸들러를 붙여주고 이 과정을 hydration이라고 합니다.

hydration이 끝나면 이제야 애플리케이션의 요소들은 사용자와 상호작용이 가능합니다. 그리고 이제야 컴포넌트들은 상태 값을 변경하거나 클릭에 반응할 수 있습니다.

이런 방식의 동작은 느린 환경의 유저에게 최소한의 콘텐츠를 볼 수 있게 만들어주고, 애플리케이션의 인터랙션 속도가 빨라지는 것은 아니지만 좀 더 빠르도록 느끼게 만드는 것입니다.

SSR의 몇 가지 문제점

하지만 이런 SSR도 몇 가지 문제점이 있습니다.

→ server-side에서 페이지에 필요한 데이터를 fetch 하고

→ 데이터를 처리 후 HTML을 response로 뱉어줍니다.

→ 클라이언트는 HTML을 받고 필요한 JS코드를 받습니다.

→ 그리고 Server-side에서 rendering 된 HTML 파일에 JS파일을 연결(hydration)시킵니다.

이런 과정이 synchronous 하게 진행되며 Top-Bottom, Waterfall 모델의 형태를 띠고 있습니다.

그래서 서버에서 컴포넌트 중 일부만 늦게 처리가 돼도 모두 기다려야 하는 비효율적인 시간낭비가 발생합니다.

1. 서버 측에서의 문제

  • fetch가 끝나야 뭐라도 보여줄 수 있음.

댓글을 포함한 게시물 예로 들면 server-side HTML에 댓글 데이터를 포함시켜 뱉고 싶지만 DB나 API 속도처럼 우리가 제어할 수 없는 수준의 문제에서 선택의 기로에 놓이게 됩니다. HTML에 이 요소를 제외하고 내보낸다면 클라이언트 측에서 Javascript 파일이 모두 로딩이 될 때까지 볼 수 없을 것이고, 포함시켜 내뱉는다면 댓글을 모두 불러올 때 까지 HTML 파일을 response 하는 것을 지연시켜야 할 것입니다.

2. 클라이언트 측에서의 문제

  • hydration 하기 전까지 자바스크립트를 모두 로드해야 함.

자바스크립트 코드가 로드되면, 리액트에게 “HTML을 hydrate 해!” 라고 해야 페이지는 상호작용이 가능한 상태가 됩니다. 그럼 React는 컴포넌트를 렌더링 하는 동안 서버에서 생성한 HTML을 순회하며 이벤트 핸들러를 HTML에 연결합니다. 근데 이 작업을 수행하기 위해서는 브라우저가 컴포넌트를 기반으로 생성된 tree와 서버에서 생성된 tree가 일치해야 합니다. 그렇지 않으면 React가 이를 맞추지 못하게 됩니다. 따라서 자바스크립트를 모두 로딩할 때까지 기다려야하고 만약 이 자바스크립트 로딩이 오래걸린다면 다시 어려운 선택을 해야합니다.

3. Hydration 문제

  • 상호작용이 가능하기 전까지 모든 hydration을 마쳐야 함.

이 hydration 자체에도 비슷한 문제가 있습니다. hydration을 시작하면(컴포넌트 함수를 호출하면) 트리 전체에 hydration을 마칠 때까지 멈출 수 없습니다. 따라서 모든 컴포넌트가 hydration을 할 때까지 기다려야만 상호작용이 가능합니다. 심지어 일단 hydration이 시작되면 전체 트리가 완전 hydration을 마치기 전까지 아무런 컴포넌트와 상호작용할 수 없음은 물론 네비게이션의 경우 유저가 로딩이 완료되기 전에 페이지를 떠나는 것도 불가능합니다.

뭐 일반적인 상황에서는 잘 동작할지도 모르지만, 사양이 낮은 디바이스 환경 등에서는 결코 저렴한 비용의 로직이 아니므로 충분히 hydration delay를 발생시킬 수 있습니다.

해결하려면..

위에서 언급된 문제들은 비슷한 점이 있습니다.

다른 작업들이 블로킹돼도 그냥 수행하게 하거나,

수행을 미루는 방법.

UX에 악영향을 미치는 두 가지 선택지 밖에 없는 것입니다.

이런 이유는 위에서 언급했듯 전통적인 SSR 과정은 Top-Bottom, Waterfall 모델의 형태를 띠고 있기 때문입니다. 그래서 이전 단계가 완료되기 전까지는 뭘 할 수가 없습니다. 그래서 리액트는 애플리케이션 전체에 걸쳐 hydration을 수행하는 것이 아니라 화면의 각 부분이 단계별로 수행하도록 분리하는 것입니다.

그리고 이를 Streaming HTML과 Selective Hydration으로 부릅니다.

HTML Streaming

기존의 방식처럼 renderToString() 을 사용한 SSR을 구현하면 클라이언트(브라우저)에서는 서버에서 보내주는 HTML 페이지를 하나의 파일로 통째로 받았습니다. 하지만 이제 pipeToNodeWritable을 통해 HTML을 작은 청크로 나누어 보낼 수 있습니다.

다시 아까의 상황으로 돌아가 보면

오늘날의 SSR은 HTML을 렌더링과 hydration은 다 하거나 아무것도 안 하거나입니다.

<main>
  <nav>
    <!--NavBar -->
    <a href="/">Home</a>
  </nav>
  <aside>
    <!-- Sidebar -->
    <a href="/profile">Profile</a>
  </aside>
  <article>
    <!-- Post -->
    <p>Hello world</p>
  </article>
  <section>
    <!-- Comments -->
    <p>First comment</p>
    <p>Second comment</p>
  </section>
</main>

이런 코드를 받으면

얘를 먼저 그리고

자바스크립트 코드를 로딩해서 hydration을 마쳐야 상호작용이 가능했습니다.

하지만 리액트 18에서는

<Layout>
  <NavBar />
  <Sidebar />
  <RightPane>
    <Post />
    <Suspense fallback={<Spinner />}>
      <Comments />
    </Suspense>
  </RightPane>
</Layout>

이렇게 오래 걸리는 작업인 Comments를 로 감싸고 해당 컴포넌트가 렌더링 할 준비가 되기 전까지 를 대신 보여달라고 fallback props로 넘긴 것뿐인데.

이런 화면을 볼 수 있게 됩니다.

<main>
  <nav>
    <!--NavBar -->
    <a href="/">Home</a>
   </nav>
  <aside>
    <!-- Sidebar -->
    <a href="/profile">Profile</a>
  </aside>
  <article>
    <!-- Post -->
    <p>Hello world</p>
  </article>
  <section id="comments-spinner">
    <!-- Spinner -->
    <img width=400 src="spinner.gif" alt="Loading..." />
  </section>
</main>

클라이언트가 서버에서 최초로 받은 HTML에도 <Comments/> 와 관련된 코드는 보이지 않죠.

그리고 이제 서버에서 <Comments/> 를 렌더링 할 준비가 완료되면 추가적인 HTML 코드를 스트리밍 해서 fallback element를 대체해 줍니다.

<div hidden id="comments">
  <!-- Comments -->
  <p>First comment</p>
  <p>Second comment</p>
</div>
<script>
  // This implementation is slightly simplified
  document.getElementById('sections-spinner').replaceChildren(
    document.getElementById('comments')
  );
</script>

이 방법을 통해 첫 번째 문제를 해결할 수 있습니다. 이제 모든 데이터를 불러와서 HTML response를 지연시킬 것인지, 해당 파트를 HTML에서 제외할 것인지 선택해야 할 필요가 없습니다. 해당하는 부분만 HTML Streaming을 통해 나중에 렌더링 할 수 있습니다.

그리고 전통적인 HTML Streaming 방식과 다르게 Top-down 순서로 진행될 필요도 없고 <Suspense/> 로 감 싼 곳에 데이터가 필요하면 React가 해당 HTML을 올바른 곳에 위치시킬 <script> 태그와 함께 스트리밍 해줄 것이고, 이 과정은 특별한 순서에 맞춰 로딩될 필요가 없습니다. 그냥 어디에 fallback element가 나타날지 지정해주면 리액트가 나머지 부분을 알아서 처리합니다.

이렇게 준비된 부분부터 보는 것이 가능하다는 것은 TTFB(First Time To Byte) 시간이 줄어든 다는 의미이고 전통적인 방식과 달리 단순한 사용자의 perceived performance가 아니라 정량적인 수치로도 rendering performance의 향상이 있다는 것을 의미합니다. 응답받기에 오래 걸리는 컴포넌트는 <Suspense> 를 사용해 나머지 영역의 초기 렌더링 속도에 영향을 미치지 않을 수 있기 때문입니다.

selective hydrating

이제 HTML Streaming을 통해 복잡하고 용량이 큰 <Comments/> 컴포넌트 때문에 페이지 전체가 FCP에서 손해 보는 상황에서는 빠져나왔습니다. 최초의 HTML을 더 이른 시점에 보낼 수 있지만 그럼에도 여전히 JS 번들이 모두 로드되기 전까지 hydration은 이뤄질 수 없습니다. 코드 규모가 클수록 오래 걸릴 수 있는 작업입니다.

그래서 큰 번들 사이즈를 줄이기 위해 code splitting 이 사용되고 [React.lazy](https://www.notion.so/React-18-fb0edaa35d22419fa915d58ab68d0a74) 를 통해 메인 번들에서 분리시킬 수 있습니다.

import { lazy } from "react";

const Comments = lazy(() => import("./Comments.js"));

// ...

<Suspense fallback={<Spinner />}>
  <Comments />
</Suspense>;

이 방법은 원래 SSR에서 동작하지 않았지만 이제 React 18에서는 선택적 hydration이 가능해졌죠.

유저는 최초에 HTML Streaming 된 상호작용이 불가능한 컨텐츠를 볼 수 있습니다.

<Comments/> 에 해당하는 JS 번들이 아직 덜 불러와졌어도 괜찮습니다.

이제 전통적인 Top-down 방식과는 다르게 JS 번들이 로드된 컴포넌트들은 먼저 hydration을 시작할 수 있습니다.

이게 Selective Hydration입니다.

이제 React는 해당 <Comments/> 섹션이 모두 불러와지면 그 부분만 hydration을 시작하게 됩니다.

만약 <Comments/> 의 용량이 너무 크고 복잡했을 때는 어떻게 할 수 있을까요. 사용자는 언제까지 돌아가는 <Spinner/> 만 보고있을 수 없기 때문입니다

마찬가지로 아직 스트리밍 되지 않은 <Comments/> 에 대해서도 이전과 달리 먼저 로드된 HTML과 JS번들끼리 먼저 hydration을 하면 이제 사이드바나 네비게이션 바가 자신의 역할을 보다 빠르게 수행할 수 있는 상태가 됩니다.

유저는 해당 페이지가 완전 동작하기 전에도 먼저 hydration이 완료된 사이드바와 상호작용할 수 있습니다.

또한 사용자의 인터렉션에 따라 어떤것을 먼저 hydration할 것인지에대한 우선순위를 정할 수 있게 됐습니다.

<Layout>
  <NavBar />
  <Suspense fallback={<Spinner />}>
    <Sidebar />
  </Suspense>
  <RightPane>
    <Post />
    <Suspense fallback={<Spinner />}>
      <Comments />
    </Suspense>
  </RightPane>
</Layout>

이제 <Sidebar /><Comments /><Suspense> 에 감싸졌는데 일반적으로 hydration은 DOM Tree에 배치된 순서대로 진행이될 것입니다.

그래서 Sidebar가 hydating 중인데.

사용자가 <Comments/> 부분을 클릭했습니다 . React는 해당 클릭 이벤트를 기록해서 <Comments/> 의 hydration 우선순위를 높혀서 진행합니다.

이제 <Comments/> 의 hydration이 끝나면 React는 기록된 클릭 이벤트를 다시 처리해주고 별 다른 사항이 없다면 나머지 Sidebar를 hydration 할 것입니다.

이렇게 Selective Hydration 으로 인해 Top-Down의 방식에서 벗어나 사용자가 관심있는 영역부터 인터렉션이 가능한 콘텐츠 제공이 가능해졌습니다. 이는 <Suspense/> 를 더 세분화하고 중첩된 컴포넌트에서 장점이 더 명확해집니다.

위의 예제에서 유저는 hydration이 시작된 후 첫 번째 댓글을 클릭 했습니다. React는 클릭 이벤트가 일어난 요소를 둘러싸고 있는 <Suspense/> 중 최상위 부모요소 부터 hydration을 시작합니다. 그리고 일단 방금 일어난 인터랙션과 관련 없는 형제요소들은 일단 hydration을 건너 뛰고 인터랙션 발생한 요소부터 실행하기 때문에 hydration이 즉각적으로 일어나는 듯한 느낌을 줄 수 있습니다. 그리고 이어서 나머지 부분들을 hydration하게 됩니다.

<Layout>
  <NavBar />
  <Suspense fallback={<BigSpinner />}>
    <Suspense fallback={<SidebarGlimmer />}>
      <Sidebar />
    </Suspense>
    <RightPane>
      <Post />
      <Suspense fallback={<CommentsGlimmer />}>
        <Comments />
      </Suspense>
    </RightPane>
  </Suspense>
</Layout>

이렇게 하면 최초의 HTML은 <NavBar /> 를 포함하지만 나머지는 스트리밍되고 코드가 로드 됨과 동시에 먼저 로드된곳 부터 부분적으로 hydration이 일어나고 유저가 먼저 인터랙션을 일으킨 부분이 우선적으로 hydration이 일어 날 것입니다.

출처

참고자료

댓글