React SSR 아키텍처 -  Render Flow
Tags: react, ssr, frontend
Published: 2020-12-18 13:58:50

React SSR은 대체로 Next.js 프레임웍이 담당하는 추세이다. 하지만 좀 더 라이트하게 SSR을 하고 싶을 땐 어떻게 할까?

Next.js는 거대 프로젝트인 만큼 모두의 니즈에 맞출 수는 없으며, 대체로 엔터프라이즈용 기능이 out-of-the-box 탑재되어있다.

소프트웨어의 복잡도와 성능(컴퓨팅 속도의)은 상충관계이므로 가벼운 용도의 SSR 서버를 직접 만들어 보는 것도 좋은 접근일 것이다.

그렇다면 React SSR은 어떻게 설계해야 할까? 먼저 React의 작동 원리부터 생각해보자.

<center><br></center>

## React Virtual DOM

[Virtual DOM](https://reactjs.org/docs/faq-internals.html)은 의미 그대로 가상으로 DOM을 관리하겠다는 말이다. 리액트 앱은 Virtual DOM을 메모리에서 관리하면서 상태 변화(diff)를 감지하여 브라우저 UI에 반영시킨다.

그런데 이 가상 DOM이 메모리에서 관리된다면, 브라우저가 아닌 서버 메모리에서 생성해도 될 것이다.

즉, 요청으로 받은 상태를 기반으로 서버 메모리에서 가상 DOM을 만들고, 이를 기반으로 한 HTML를 응답으로 보낸다면, 사용자는 SSR된 리액트 앱을 사용할 수 있다.

이것이 React SSR의 기본 개념이다.

사실 이 방식은 흔히 쓰이는 템플릿엔진을 이용한 SSR과 같고, 리액트를 템플릿으로 하여 만들어진 DOM을 HTML에 주입시키는 것으로 보면 된다.

<center>
![SSR](https://dev-to-uploads.s3.amazonaws.com/i/wpku7oq5kcb4psmft2cp.png)
\<리액트를 뷰템플릿으로 한 SSR\>
<br>
</center>

이 과정을 그럼 브라우저와 서버의 통신 과정에서 정리해보자.

먼저, 브라우저가 서버에 요청을 보내면, 서버는 브라우저가 제공하는 정보(헤더, 상태 등)을 기반으로 가상DOM을 만든다.

이 DOM은 다음과 같이 서버에서 렌더된 후 그대로 HTML 템플릿에 주입되어 보내진다.

```jsx
// Express.js 서버에서 React SSR을 만드는 과정

const App = <h1>Hello World!</h1>;

const content = renderToString(App); // 가상 DOM을 렌더링 후 string 반환

// 렌더링이 완료된 리액트 요소를 템플릿에 주입
const template = (content) => `
  <html>
    <body>
      <div id="app">
        ${content}
      </div>
      <script src="bundle.js"></script>
    </body>
  </html>
`;

res.send(content); // Express.js response 사용을 가정한다
```

> 리액트는 SSR을 염두해 [`ReactDOMServer.renderToString`](https://reactjs.org/docs/react-dom-server.html#rendertostring) 등의 메서드를 제공하며, 이는 렌더링된 HTML string을 반환한다. (클라이언트의 `ReactDOM.render`을 대체한다)

그 후 실제 브라우저가 받는 응답은 다음과 같다.

```html
<html>
  <body>
    <div id="app">
      <h1>Hello World!</h1>
    <div>
  </body>
  <script src="bundle.js"></script>
</html>
```

보다시피 리액트가 성공적으로 렌더링 된 것을 볼 수 있다!

그런데 `<script>` 번들은 어떻게 만들까?

아무리 서버에서 렌더링이 완료된 HTML를 가져왔어도, interactive한 UI를 사용하려면 당연히 JavaScript가 필요하다.

필수 패키지들을 효율적으로 번들링하여 가져올 수는 있지만, 이 번들에 리액트를 어떻게 포함시켜야 하는지가 관건이다.

즉, 리액트가 제대로 작동하기 위해 리액트 의존성을 번들링하는 것은 문제가 없지만 리액트 컴포넌트를 어떻게 관리할 것이냐에 대한 고민이 생긴다.

<center><br></center>

## Isomorphic App

React SSR을 개발할 때 isomorphic 구조로 컴포넌트를 관리하는 것은 필수적이다.

Isomorohic의 사전적 의미는 '동일'이며 isomorphic 리액트 앱이라 함은 서버와 클라이언트의 컴포넌트 구조를 동일하게 관리하는 형태를 일컫는다.

이렇게 동일 구조를 유지하면 클라이언트에서 `<script>` 번들의 리액트앱을 렌더링 할 경우 `ReactDOM`은 이미 Paint가 완료된 SSR의 HTML과 번들의 가상DOM을 비교하여 리액트JS를 바인딩(또는 [_hydration_](https://reactjs.org/docs/react-dom.html#hydrate))한다.

> hydration 용도로 클라이언트는 `ReactDOM.render` 대신 `ReactDOM.hydrate`를 사용한다.

이 때, isomorphic하게 앱을 관리하지 않는다면 리액트는 진상을 부릴 것이고, 우리의 뜻대로 리액트가 바인딩되지 않을 것이다.

```jsx
// 클라이언트의 리액트

const App = () => {
  // handler와 같은 JS 요소들이 hydration을 통해 corresponding component에 바인딩된다.
  const handler = () => {
    console.log('hydration success!');
  };

  return (
    <>
      <div>
        <h1>Misplaced Component</h1>
        <button onClick={handler}>Click Me!</button>
      </div>
    </>
  );
};

ReactDOM.hydrate(App, document.getElementById('app'));
```

```jsx
// 서버의 리액트

// 클라이언트와 구조가 다르다
const App = (
  <>
    <h1>Misplaced Component</h1>
    <div>
      <button>Click Me!</button>
    </div>
  </>
);

const content = renderToString(App);

res.send(content);
```

위의 예시와 같이 클라이언트와 서버의 리액트 구조가 다르면 경우 컴포넌트(Virtual DOM 포함)가 다시 생성되거나 구조를 멋대로 해석해 기능이 제대로 작동하지 않는다.

미스매치에 대한 `ReactDOM.hydrate`의 대응은 safeguard로써 존재하지만, 이런 버그를 그냥 놔둔다면 퍼포먼스는 더 악화되며 SSR을 사용하는 의미가 없다.

> SSR의 목표는 JS요청을 기다리기 전에 빠르게 HTML을 Paint하겠다는 것이다.
> 응답받은 HTML이 다시 렌더링 된다면 의미가 퇴색된다.

<center>
![Render Flow](https://dev-to-uploads.s3.amazonaws.com/i/qag2zxilrux02srysldz.png)
\<기본적인 Render Flow\>
<br>
</center>

위 그림에서 브라우저는 첫 번째 요청&harr;응답에서 바로 마크업을 받아볼 수 있다.

그 다음 요청인 `<script>` 번들(bundle.js)은 대체로 HTML보다 사이즈가 훨씬 크다. 느린 네트워크 환경에선 먼저 렌더링된 UI부터 보는 것이 사용자경험에 이로울 것이다.

최종적으로 서버는 알맞은 번들을 보내고, 클라이언트는 React app을 바인딩하여 interactive한 기능을 사용할 수 있다.