관리 메뉴

거니의 velog

(6) 서버 사이드 렌더링 6 본문

React_백엔드 프로그래밍

(6) 서버 사이드 렌더링 6

Unlimited00 2023. 12. 16. 17:23

5. 서버 사이드 렌더링과 코드 스플리팅

* 이제 서버 사이드 렌더링을 구현한 프로젝트에 코드 스플리팅을 도입해 볼 차례이다. 일단 리액트에서 공식적으로 제공하는 코드 스플리팅 기능인 React.lazy와 Suspense는 서버 사이드 렌더링을 아직 지원하지 않는다. 2019년 4월 기준에서는 리액트 공식 메뉴얼에서도 서버 사이드 렌더링과 코드 스플리팅을 함께 사용할 때는 Loadable Components를 사용할 것을 권장하고 있다.

* Loadable Components에서는 서버 사이드 렌더링을 할 때 필요한 서버 유틸 함수와 웹팩 플러그인, babel 플러그인을 제공해 준다. 일단 yarn을 사용하여 Loadable Components를 설치해 보자.

$ yarn add @loadable/component @loadable/server @loadable/webpack-plugin @loadable/babel-plugin

* 지난 장에서는 @loadable/component만 설치했는데, 이번에는 설치하는 패키지가 꽤 많다. 각각 서버 사이드 렌더링 시 중요한 역할을 하는 라이브러리이다.


(1) 라우트 컴포넌트 스플리팅하기

* 현재 프로젝트에서 라우트를 위해 사용하고 있는 BluePage, RedPage, UserPage를 스플리팅해 줄 것이다.

[App.js]

import React from "react";
import { Route } from "react-router-dom";
import Menu from "./components/Menu";
import loadable from "@loadable/component";
const RedPage = loadable(() => import("./pages/RedPage"));
const BluePage = loadable(() => import("./pages/BluePage"));
const UsersPage = loadable(() => import("./pages/UsersPage"));

const App = () => {
  return (
    <div>
      <Menu />
      <hr />
      <Route path="/red" component={RedPage} />
      <Route path="/blue" component={BluePage} />
      <Route path="/users" component={UsersPage} />
    </div>
  );
};

export default App;

* 여기까지 작성한 뒤 프로젝트를 빌드하고 서버 사이드 렌더링 서버도 재시작하자. 그리고 크롬 개발자 도구의 Network 탭에서 인터넷 속도를 Slow 3G로 선택한 후 아래 주소로 들어갔을 때 어떤 현상이 발생하는지 확인해 보자.

- http://localhost:5000/users/1

* 페이지가 처음에 나타났다가, 사라졌다가, 다시 나타날 것이다. 바로 깜박임 현상인데, 빠른 인터넷 환경을 이용하는 사용자는 느끼지 못할 수도 있지만, 느린 인터넷 환경의 사용자에게는 불쾌한 사용자 경험을 제공할지도 모른다. 깜빡임 현상을 확인했다면 다시 Slow 3G를 Online으로 돌려 놓자.


(2) 웹팩과 babel 플러그인 적용

* Loadable Components에서 제공하는 웹팩과 babel 플러그인을 적용하면 깜박임 현상을 해결할 수 있다.

* 먼저 babel 플러그인을 적용해 보겠다. package.json을 열어서 babel을 찾은 뒤, 그 안에 다음과 같이 plugins를 설정하자.

[ package.json - babel ]

  "babel": {
    "presets": [
      "react-app"
    ],
    "plugins": [
      "@loadable/babel-plugin"
    ]
  }

* webpack.config.js를 열어서 상단에 LoadablePlugin을 불러오고, 하단에는 plugins를 찾아서 해당 플러그인을 적용하자.

[ webpack.config.js ]

const LoadablePlugin = require('@loadable/webpack-plugin');

(...)

      plugins: [
        new LoadablePlugin(),
        // Generates an `index.html` file with the <script> injected.
        new HtmlWebpackPlugin(
        (...)
     ].filter(Boolean)
     
     (...)

* 수정 사항을 저장한 후에 yarn build 명령어를 한 번 더 실행해 보자. 그리고 build 디렉터리에 loadable-stats.json이라는 파일이 만들어졌는지 확인해 보자.

{
  "errors": [],
  "warnings": [],
  "version": "4.29.6",
  "hash": "8b2be0a1dba3fb47ebee",
  "publicPath": "/",
  "outputPath": "C:\\Users\\PC_23\\Desktop\\server-side-rendering\\build",
  "assetsByChunkName": {
    "main": [
      "static/css/main.34de6062.chunk.css",
      "static/js/main.5a1b8a49.chunk.js",
      "static/js/main.5a1b8a49.chunk.js.map"
    ],
    "pages-BluePage": [
      "static/css/pages-BluePage.927229b2.chunk.css",
      "static/js/pages-BluePage.59edcc5a.chunk.js",
      "static/js/pages-BluePage.59edcc5a.chunk.js.map"
    ],
    (...)

* 이 파일은 각 컴포넌트의 코드가 어떤 청크(chunk) 파일에 들어가 있는지에 대한 정보를 가지고 있다. 서버 사이드 렌더링을 할 때 이 파일을 참고하여 어떤 컴포넌트가 렌더링되었는지에 따라 어떤 파일들을 사전에 불러와야 할지 설정할 수 있다.


(3) 필요한 청크 파일 경로 추출하기

* 서버 사이드 렌더링 후 브라우저에서 어떤 파일을 사전에 불러와야 할지 알아내고 해당 파일들의 경로를 추출하기 위해 Loadable Components에서 제공하는 ChunkExtractor와 ChunkExtractorManager를 사용한다.

* 서버 엔트리 코드는 다음과 같이 수정해 보자.

* 이제 Loadable Components를 통해 파일 경로를 조회하므로 기존에 asset-manifest.json을 확인하던 코드는 지워 준다.

[index.server.js]

import React from 'react';
import ReactDOMServer from 'react-dom/server';
import express from 'express';
import { StaticRouter } from 'react-router-dom';
import App from './App';
import path from 'path';
import { createStore, applyMiddleware } from 'redux';
import { Provider } from 'react-redux';
import thunk from 'redux-thunk';
import createSagaMiddleware from 'redux-saga';
import rootReducer, { rootSaga } from './modules';
import PreloadContext from './lib/PreloaderContext';
import { END } from 'redux-saga';
import { ChunkExtractor, ChunkExtractorManager } from '@loadable/server';

// asset-manifest.json 에서 파일 경로들을 조회합니다.
const statsFile = path.resolve('./build/loadable-stats.json');

function createPage(root, tags) {
  return `<!DOCTYPE html>
  <html lang="en">
  <head>
    <meta charset="utf-8" />
    <link rel="shortcut icon" href="/favicon.ico" />
    <meta
      name="viewport"
      content="width=device-width,initial-scale=1,shrink-to-fit=no"
    />
    <meta name="theme-color" content="#000000" />
    <title>React App</title>
    ${tags.styles}
    ${tags.links}
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root">
      ${root}
    </div>
    ${tags.scripts}
  </body>
  </html>
    `;
}
const app = express();

// 서버사이드 렌더링을 처리 할 핸들러 함수입니다.
const serverRender = async (req, res, next) => {
  // 이 함수는 404가 떠야 하는 상황에 404를 띄우지 않고 서버사이드 렌더링을 해줍니다.

  const context = {};
  const sagaMiddleware = createSagaMiddleware();

  const store = createStore(
    rootReducer,
    applyMiddleware(thunk, sagaMiddleware)
  );

  const sagaPromise = sagaMiddleware.run(rootSaga).toPromise();

  const preloadContext = {
    done: false,
    promises: []
  };

  // 필요한 파일 추출하기 위한 ChunkExtractor
  const extractor = new ChunkExtractor({ statsFile });

  const jsx = (
    <ChunkExtractorManager extractor={extractor}>
      <PreloadContext.Provider value={preloadContext}>
        <Provider store={store}>
          <StaticRouter location={req.url} context={context}>
            <App />
          </StaticRouter>
        </Provider>
      </PreloadContext.Provider>
    </ChunkExtractorManager>
  );

  ReactDOMServer.renderToStaticMarkup(jsx); // renderToStaticMarkup 으로 한번 렌더링합니다.
  store.dispatch(END); // redux-saga 의 END 액션을 발생시키면 액션을 모니터링하는 saga 들이 모두 종료됩니다.
  try {
    await sagaPromise; // 기존에 진행중이던 saga 들이 모두 끝날때까지 기다립니다.
    await Promise.all(preloadContext.promises); // 모든 프로미스를 기다립니다.
  } catch (e) {
    return res.status(500);
  }
  preloadContext.done = true;
  const root = ReactDOMServer.renderToString(jsx); // 렌더링을 합니다.
  // JSON 을 문자열로 변환하고 악성스크립트가 실행되는것을 방지하기 위해서 < 를 치환처리
  // https://redux.js.org/recipes/server-rendering#security-considerations
  const stateString = JSON.stringify(store.getState()).replace(/</g, '\\u003c');
  const stateScript = `<script>__PRELOADED_STATE__ = ${stateString}</script>`; // 리덕스 초기 상태를 스크립트로 주입합니다.

  // 미리 불러와야 하는 스타일 / 스크립트를 추출하고
  const tags = {
    scripts: stateScript + extractor.getScriptTags(), // 스크립트 앞부분에 리덕스 상태 넣기
    links: extractor.getLinkTags(),
    styles: extractor.getStyleTags()
  };

  res.send(createPage(root, tags)); // 결과물을 응답합니다.
};

const serve = express.static(path.resolve('./build'), {
  index: false // "/" 경로에서 index.html 을 보여주지 않도록 설정
});

app.use(serve); // 순서가 중요합니다. serverRender 전에 위치해야 합니다.
app.use(serverRender);

// 5000 포트로 서버를 가동합니다.
app.listen(5000, () => {
  console.log('Running on http://localhost:5000');
});

(4) loadableReady와 hydrate

* Loadable Components를 사용하면 성능을 최적화하기 위해 모든 자바스크립트 파일을 동시에 받아 온다. 모든 스크립트가 로딩되고 나서 렌더링하도록 처리하기 위해서는 loadableReady라는 함수를 사용해 주어야 한다. 추가로 리액트에서는 render 함수 대신에 사용할 수 있는 hydrate 라는 함수가 있다. 이 함수는 기존에 서버 사이드 렌더링된 결과물이 이미 있을 경우 새로 렌더링하지 않고 기존에 존재하는 UI에 이벤트만 연동하여 애플리케이션을 초기 구동할 때 필요한 리소스를 최소화함으로써 성능을 최적화해 준다. 이를 적용하려면 index.js를 다음과 같이 수정해 주자.

import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import * as serviceWorker from "./serviceWorker";
import { BrowserRouter } from "react-router-dom";
import { createStore, applyMiddleware } from "redux";
import { Provider } from "react-redux";
import thunk from "redux-thunk";
import createSagaMiddleware from "redux-saga";
import rootReducer, { rootSaga } from "./modules";
import { loadableReady } from "@loadable/component";

const sagaMiddleware = createSagaMiddleware();

const store = createStore(
  rootReducer,
  window.__PRELOADED_STATE__, // 이 값을 초기상태로 사용함
  applyMiddleware(thunk, sagaMiddleware)
);

sagaMiddleware.run(rootSaga);

// 같은 내용을 쉽게 재사용 할 수 있도록 렌더링 할 내용을 하나의 컴포넌트로 묶음
const Root = () => {
  return (
    <Provider store={store}>
      <BrowserRouter>
        <App />
      </BrowserRouter>
    </Provider>
  );
};

const root = document.getElementById("root");

// 프로덕션 환경 에서는 loadableReady 와 hydrate 를 사용하고
// 개발 환경에서는 기존 하던 방식으로 처리
if (process.env.NODE_ENV === "production") {
  loadableReady(() => {
    ReactDOM.hydrate(<Root />, root);
  });
} else {
  ReactDOM.render(<Root />, root);
}

serviceWorker.unregister();

* 이제 모든 작업을 마쳤다! 프로젝트를 빌드하고 아래 주소로 들어가서 새로고침을 해 보자.

- http://localhost:5000/users

* 다음 화면과 같이 렌더링 결과물에 청크 파일이 제대로 주어 지는가?

코드 스플리팅 + 서버 사이드 렌더링 완료


6. 서버 사이드 렌더링의 환경 구축을 위한 대안

* 서버 사이드 렌더링 자체만 놓고 보면 꽤나 간단한 작업이지만 데이터 로딩, 코드 스플리팅까지 하면 참 번거로운 작업이다. 만약 이러한 설정을 하나하나 직접 하는 것이 귀찮다고 느껴진다면 다른 대안도 있다.


(1) Next.js

* Next.js라는 리액트 프레임워크를 사용하면 이 작업을 최소한의 설정으로 간단하게 처리할 수 있다. 그 대신 몇 가지 제한이 있다. 가장 대표적인 것으로는 리액트 라우터와 호환되지 않는다는 점을 꼽을 수 있다. 리액트 관련 라우터 라이브러리 중에서는 리액트 라우터가 점유율이 가장 높은데 호환되지 않는 것은 꽤나 치명적인 단점이다. 호환되지 않기 때문에 이미 작성된 프로젝트에 적용하는 것은 매우 까다롭다. 그리고 리액트 라우터는 컴포넌트 기반으로 라우트를 설정하는 반면에 Next.js는 파일 시스템에 기반하여 라우트를 설정한다. 컴포넌트 파일의 경로와 파일 이름을 사용하여 라우트를 설정하는 것이다. 그 외에도 복잡한 작업들을 모두 Next.js가 대신해 주기 때문에 실제 작동 원리를 파악하기 힘들어질 수도 있다. 흔히 이런 것을 '마법'이라고 부르기도 한다.

* 코드 스플리팅, 데이터 로딩, 서버 사이드 렌더링을 가장 쉽게 적용하고 싶다면 Next.js를 사용하는 것을 추천한다. 하지만 Next.js의 라우팅 방식보다 리액트 라우터의 라우팅 방식을 더 좋아하거나, 기존의 프로젝트에 적용해야 하거나, 혹은 작동 원리를 제대로 파악하면서 구현하고 싶다면 직접 구현하는 것이 가장 좋다.

https://nextjs.org/

 

Next.js by Vercel - The React Framework

Next.js by Vercel is the full-stack React framework for the web.

nextjs.org


(2) Razzle

* Razzle 또한 Next.js 처럼 서버 사이드 렌더링을 쉽게 할 수 있도록 해 주는 도구이며, 프로젝트 구성이 CRA와 매우 유사하다는 장점이 있다. 그렇기 때문에 프로젝트의 구조를 여러분 마음대로 설정할 수 있으며, 리액트 라우터와도 잘 호환된다.

* 2019년 4월 시점에서는 코드 스플리팅 시 발생하는 깜박임 현상을 해결하기 어렵다는 단점이 있다. 또한, 이 프로젝트에서 Loadable Components를 적용하는 것이 불가능하지는 않지만, 최신 버전의 Loadable Components가 기본 설정으로는 작동하지 않아서 적용하기가 까다롭다.

https://github.com/jaredpalmer/razzle

 

GitHub - jaredpalmer/razzle: ✨ Create server-rendered universal JavaScript applications with no configuration

✨ Create server-rendered universal JavaScript applications with no configuration - GitHub - jaredpalmer/razzle: ✨ Create server-rendered universal JavaScript applications with no configuration

github.com


7. 정리

* 서버 사이드 렌더링은 프로젝트를 만들 때 꼭 해야 하는 작업은 아니다. 하지만 여러분이 만든 서비스를 사용하는 사람이 많아지면, 또 검색 엔진 최적화 및 사용자 경험을 향상시키길 원한다면 도입을 고려해 볼 만한 가치가 있는 기술이다. 단, 이를 도입하면 프로젝트가 조금 복잡해질 수는 있다.