DEV Community

Ray Kim
Ray Kim

Posted on • Updated on

React SSR 아키텍처 - Render Server

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

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

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

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


계층화

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

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


계층화
<SSR 앱 계층화>


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

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

// 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);
});
Enter fullscreen mode Exit fullscreen mode

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

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

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


라우팅

react-router 라이브러리의 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이다.

다음 예시를 보자.

// 렌더링 서버 코드

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>
  `;
}
Enter fullscreen mode Exit fullscreen mode

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

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


Router
<BrowserRouter vs. StaticRouter>


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

// 클라이언트 코드

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

ReactDOM.hydrate(App, document.getElementById('app'));
Enter fullscreen mode Exit fullscreen mode

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


번들링

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

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

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

이 때, 서버의 웹팩은 target: 'node'이 되어있어야 하며, isomorphic한 리액트앱을 위해 JSX에 관여하는 Babel 설정은 서버와 클라이언트가 동일해야 한다. [참고]


렌더링 서버와 라우터
<렌더링 서버와 라우터>


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

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

Top comments (0)