React SSR 아키텍처 - Render Server
Tags: react, ssr
Published: 2020-12-31 02:50:38

SSR을 할 경우 리액트 빌드 서버(또는 클라이언트용 웹팩)와 별개로 사용자 요청을 받아 알맞는 리액트 앱을 응답하는 렌더링용 서버가 필요하다.

렌더링용 서버에선 `ReactDOMServer.renderToString` 등의 기본 메서드를 통해 리액트를 간단히 SSR할 수 있었지만, 이는 Node.js(또는 JavaScript runtime) 서버 위에서 작동한다.

즉, React SSR을 할 경우 Node.js 서버가 가장 알맞은 선택일 것이다.

그렇다면 효율적인 SSR 서버 구현에 대해 생각해보자.

<center><br></center>

## 계층화

SSR 웹 앱을 백엔드 서버와 렌더링 서버로 분리할 수 있다고 보자.

이렇게 계층화를 이룰 경우, 렌더링 서버를 추상화 및 스케일아웃할 수 있으며, 백엔드의 여러 서비스를 프록시를 통해 적재적소에 이용할 수 있다.

<center>
![계층화](https://dev-to-uploads.s3.amazonaws.com/i/1v2hjqipinli7999zx7f.png)
\<SSR 앱 계층화\>
<br>
</center>

그렇다면 렌더링 서버에 어떤 기능을 포함해야 할까? 막상 계층화를 이뤘다고 해도, 백엔드 서버와 렌더링 서버의 경계는 모호할 수밖에 없다.

먼저 관심사 분리를 명확히 한 렌더링 서버는 오로지 *rendering*에 집중한다고 가정하자.

```jsx
// Express.js 예시

function renderer(req) {
  const App = <MyApp />;

  const content = renderToString(App);

  return `
    <html>
      <body>
        <div id="app">${content}</div>
        <script src="bundle.js"></script>
      </body>
    </html>
  `;
}

const app = express();

// 사용자가 요청할 `bundle.js`의 경로
app.use(express.static('public'));

// 모든 path에 대한 동일 `renderer` 처리한다: `req` 오브젝트를 사용해 렌더링한다.
app.get('*', (req, res) => {
  const content = renderer(req);

  res.send(content);
});
```

위 예시처럼 모든 path(`'*'`)에 대해 같은 콜백을 넘겨주자. 이는 `req` 객체만을 사용해 렌더링하겠다는 의미이며, 콜백 내부에 렌더링 로직이 들어간다.

물론 MPA일 경우 페이지별로 라우터를 관리할 수도 있지만, 라우팅은 추후 `react-router`이 관여하므로 모든 path에 대한 처리를 동일하게 한다.

마지막으로 `express.static('public')` 미들웨어를 사용하는 이유는 지난 포스트에서 다룬 것과 같이 SSR 후 사용자가 `bundle.js`를 추가적으로 요청할 때, 미리 번들링한 파일을 넘겨주는 용도이다.

<center><br></center>

## 라우팅

`react-router` 라이브러리의 [`BrowserRouter`](https://reactrouter.com/web/api/BrowserRouter)은 브라우저의 location 변화를 감지하여 지정된 라우터에 따라 컴포넌트를 렌더링해준다. 특이한 점은 `history.pushState`를 사용하여 브라우저 location을 리로딩 없이 변경해준다는 것이다. 즉, 브라우저 상태변화에 따라 새로운 요청이나 리로딩 없이 UI를 변화시켜준다.

하지만 서버에선 브라우저와 달리 location 변화가 일어나지 않으며, 오로지 요청(`req` 객체의 path)에 대한 라우팅만 가능하다. 또한 location 변화에는 유저 interaction이 필요하며, 서버에서 `req`만으로 interaction을 감지할 수는 없다.

앞서 다뤘듯 렌더링 서버는 `app.get('*', () => {})`을 통해 모든 path에 대한 같은 렌더링 로직을 사용한다. 이런 구조를 변경하여 path에 따라 일일이 렌더링 로직 및 리액트 앱을 관리하기엔 isomorphic 구조를 유지하기도 어렵고 비효율적이다.

그렇다고 `BrowserRouter` 컴포넌트를 바로 보낸다는 것은 클라이언트에게 렌더링을 맡긴다는 의미이다. 브라우저 location 변화를 기준으로 UI를 관리하는 `BrowserRouter`는 결국 리액트 앱의 일부분일 뿐이며 `bundle.js`에 포함된다.

그렇다면 원하는 URL에 따라 서버에서 렌더링하여 사용자에게 줄 수 있는 방법이 필요할 것이다. 이 때 사용하는 SSR용 라우터가 바로 [`StaticRouter`](https://reactrouter.com/web/api/StaticRouter)이다.

다음 예시를 보자.

```jsx
// 렌더링 서버 코드

function renderer(/* Express Request */ req) {
  // `StaticRouter`은 제공된 경로(req.path)를 기반으로 렌더링한다.
  const App = (
    <StaticRouter location={req.path}>
      <MyRoutes />
    </StaticRouter>
  );

  const content = renderToString(App);

  return `
    <html>
      <body>
        <div id="app">${content}</div>
        <script src="bundle.js"></script>
      </body>
    </html>
  `;
}
```

`StaticRouter`은 `req.path`를 기반으로 리액트 앱을 빌드한다. 이를 이용하면 location 변화에 따른 UI 업데이트가 아닌 처음부터 URL 입력값을 기준으로 렌더링된 HTML을 만들어 줄 수 있다.

하지만 여전히 클라이언트는 `BrowserRouter`을 사용하는 것이 타당하다. 이미 렌더링된 앱에선 새로운 페이지를 로드하는 것이 아닌 상태 변화에 따라 UI를 변경하는 것이 더 효율적이기 때문이다.

<center>
![Router](https://dev-to-uploads.s3.amazonaws.com/i/lsiy9ocj2cwnemzd82vh.png)
\<BrowserRouter vs. StaticRouter\>
<br>
</center>

리소스(`bundle.js`)는 앱 로딩 후 이미 가져온 상태이기 때문에 `BrowserRouter`의 사용제한이 없으며 클라이언트가 렌더링을 담당하게 된다.

```jsx
// 클라이언트 코드

const App = (
  <BrowserRouter>
    <MyRoutes />
  </BrowserRouter>
);

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

`ReactDOM.hydrate`는 이런 차이를 인지하고, Router 내부의 코드를 알맞게 hydration하므로 `StaticRouter`(서버)과 `BrowserRouter`(클라이언트)을 교차 사용하는 것이 가능하다.

<center><br></center>

## 번들링

실제로 여기까지 시도해봤다면 서버에서 JSX를 렌더링하지 못한다는 것을 알 수 있다.

하지만 이는 클라이언트도 마찬가지다. 클라이언트에서 JSX를 '사용'할 수 있는 것처럼 보이는 것은 사실 번들러가 Babel을 이용해 JSX를 트랜스파일하기 때문이다.

그렇다면 서버의 코드도 마찬가지로 번들러와 Babel을 통해 Node.js가 인식할 수 있는 코드로 번들링되어야 한다.

이 때, 서버의 웹팩은 `target: 'node'`이 되어있어야 하며, isomorphic한 리액트앱을 위해 JSX에 관여하는 Babel 설정은 서버와 클라이언트가 동일해야 한다. [[참고](https://webpack.js.org/concepts/targets/#multiple-targets)]

<center>
![렌더링 서버와 라우터](https://dev-to-uploads.s3.amazonaws.com/i/jd6q1yn9ltvsofgtgvog.png)
\<렌더링 서버와 라우터\>
<br>
</center>

위와 같이 렌더링 서버 코드는 클라이언트 코드와 함께 번들링된다. 그 후 브라우저에서 페이지요청을 보내면 렌더 서버에서 `StaticRouter`를 통해 해당 path에 대한 앱(HTML)을 렌더링한 후 응답한다.

브라우저는 렌더링된 HTML을 받은 후, `bundle.js`를 Public에서 받아 hydration을 진행한다. 그리고 앱의 location을 바꿀 때 `bundle.js`의 `BrowserRouter`을 통해 UI를 업데이트한다.