3-2. 서버사이드 렌더링

서버사이드 렌더링이란, 브라우저에서 리액트를 불러와서 컴포넌트를 렌더링을 하는것이 아니라, 서버쪽에서 미리 렌더링을 하여 HTML 을 생성하여 브라우저한테 전달해주는 것을 말합니다. 그러면, 브라우저는 자바스크립트를 호출하게 될 때, 처음부터 렌더링하는 것이 아니라, 기존에 렌더링된 결과를 유지 하면서 필요한 이벤트들을 적용시키는 작업을 합니다.

우선, 서버사이드 렌더링이 왜 필요한지 알아보기 위하여 클라이언트 위주 렌더링의 문제점들을 살펴봅시다.

클라이언트 렌더링의 문제점

리액트로 만들어진 프로젝트는, 사용자에게 보여지는 뷰가 브라우저 상에서 자바스크립트가 실행되며 만들어집니다. 따라서, 만약에 자바스크립트가 실행되지 않는다면, 비어있는 페이지가 보여지게 됩니다. 한번 개발서버에서 보여지는 페이지에서 우클릭을 하여 소스보기를 눌러보세요.

소스보기에선, 자바스크립트가 실행되지 않아서, 리액트 컴포넌트들이 렌더링 되야 할 곳이 비어있습니다.

이 때문에, 자바스크립트 라이브러리 / 프레임워크 를 사용하여 뷰를 만들게 되었을때는 다음 문제들이 발생 할 수 있습니다.

  1. 검색엔진에서는 자바스크립트가 실행되지 않기 때문에, 제대로 페이지를 수집 할 수 없게 됩니다.
  2. 자바스크립트 파일을 모두 불러 올 때 까지 페이지는 비어있는 상태를 보여주게 됩니다.

검색엔진 최적화

구글 검색엔진의 경우에는, 페이지 수집 봇에 자바스크립트 엔진을 내장하고 있기 때문에, 별도의 처리를 하지 않아도 제대로 크롤링을 해갑니다.

따라서, 서버사이드 렌더링을 꼭 하지 않아도, 구글 검색엔진에서 페이지를 검색 할 수 있게 되는데요, 필자의 경험 상, 모든 페이지에 한해서 제대로 수집을 해 가지는 않습니다.

위 차트는 웹서비스 라프텔에서 서버사이드 렌더링을 하기 전후의 페이지 수집현황을 보여줍니다. 이 서비스에선 초반부에는 서버사이드 렌더링을 지원하지 않았는데요, 구글이 페이지를 수집을 하긴 했지만, 실제로 서비스에서 제공하는 페이지의 수에 비하여 너무 낮은 수치였습니다.

빨간 점이 있는 시점에서 서버사이드 렌더링을 구현하였더니, 구글에 나타나는 페이지의 수가 기하급수적으로 늘어났습니다.

구글 검색엔진이 실제로 어떻게 작동하는지는 구글만이 알고 있기 때문에, 필자가 주관적으로 추정을 해보자면, 구글 페이지 수집 봇은 특정 조건이 만족해야지만 자바스크립트를 실행하고, 내부에서 모든 API 들이 호출이 완료될 때 까지 기다리는 것으로 판단됩니다.

추가적으로, 네이버, 다음 등의 국내 검색 서비스에서도 페이지가 잘 나타나지기 위해선, 서버사이드 렌더링이 필요합니다.

유저 경험 개선

서버사이드 렌더링을 함으로서, 처음 들어온 유저가 자바스크립트를 모두 불러오고, 서비스에서 호출하는 API 가 완료 될 때 까지 기다릴 필요 없이, 이를 서버측에서 미리 실행시켜서 유저에게 제공해줄 수 있습니다. 따라서, 이를 통하여 초기 로딩 속도가 조금 더 빨라 질 수 있습니다.

서버사이드 렌더링의 단점

서버사이드 렌더링을 한다고 해서 무조건 좋은 것은 아닙니다. 클라이언트에서 처리되어야 하는 작업을, 서버가 하는 것이기 때문에, 결국 서버의 자원이 소모됩니다. 따라서, 서버가 저사양일 경우에는 서버사이드 렌더링을 구현하는것을 권장하지 않습니다.

그리고, 서버에 유저 유입이 순간적으로 늘어나게 되면 서버의 성능에 무리가 갈 수 있으므로, 동일한 페이지는 특정 기간동안 캐싱을 하여 성능을 최적화 할 수있습니다., 추가적으로 서버사이드 렌더링이 유의미 할 때는 검색 봇이 접근 할 때와, 처음 들어오는 유저가 사용 할 때 이기에, 개인화된 데이터들은 서버사이드 렌더링을 하지 않는 것도 상황에 따라 적합 할 수 있습니다 (예: 로그인 상태일 경우에는 서버사이드 렌더링하지 않음).

또 다른 단점은 서버사이드 렌더링을 구현하는 것은 구조가 꽤 복잡합니다. 단순히 문자열로 렌더링하는것은 쉽지만, 라우터와 연동을 하고, 리덕스도 사용하면서, API 를 사용하는 경우 미리 호출하고, 코드 스플리팅도 제대로 작동하게 하기 위해선, 준비해야 하는것이 다양합니다. 하지만 걱정하지 마세요. 이 책에서 나오는 가이드를 읽어가면서 구현을 하게된다면, 여러분들이 혼자 진행하게 된다면 매우 복잡한 작업을 어렵지 않게 구현 할 수 있습니다.

서버사이드 렌더링 준비하기

서버사이드 렌더링을 구현하기 위해선, Node.js 에서 우리가 클라이언트에서 준비한 컴포넌트 코드들을 사용해야 합니다. JSX 와 ES6 을 사용해야 된다는 것인데, 현재 Node 8 버전 기준 ES6 의 import / export 는 작동하지 않으며, JSX 는 당연히 사용 할 수 없습니다.

결국, 리액트 관련 코드를 불러오기 위해선, babel 을 사용해야 합니다. 서버쪽에서 babel 을 설정하여 일반 자바스크립트에서 지원되지 않는 문법들을 사용해야 하는데요, 이를 진행하기 위해선 여러가지 방법들이 있습니다. babel-node 를 사용하여 babel preset 과 plugin 을 적용하여 바로 코드를 실행하는 방법이 있는데, 이를 사용 시 불필요한 메모리가 추가적으로 사용되기 때문에 권장되지 않습니다.

추가적으로, 런타임에서 코드를 변환하여 실행하는 것이 아니라, 미리 babel 로 변환하여 실행하는 방법도 있습니다. 이 방법은 성능상으로는 큰 문제는 없지만, 단점으로는 리액트 프로젝트에서 필요한 node_modules 들을 동일하게 필요로 하기 때문에 똑같이 패키지를 설치를 해주어야 합니다. 따라서, 서버 파일을 리액트 프로젝트 내부에 작성하는 것이 아니라면 그렇게 좋은 방법은 아닙니다.

우리가 사용 할 방법은, webpack 을 사용하는 것 입니다. 우리는, 리액트 관련 코드를 서버에서 쉽게 불러와서 사용 할 수 있도록 webpack 을 통하여 babel 로더를 적용하여 코드를 변환하고, 하나의 파일로 만들어 줄 것입니다.

서버사이드 렌더링용 엔트리 만들기

우리가 클라이언트에서 사용하는 엔트리파일인 src/index.js 에서는, Root 컴포넌트를 불러와서 렌더링 후 id 가 root 인 DOM 을 찾아서 그 안에 넣어줬었습니다.

서버쪽에서도, 비슷한 작업을 하게 되는데요, src/ssr.js 라는 파일을 엔트리 파일로 사용하게되며, 이 파일에서는 render 라는 함수를 만들어서 컴포넌트를 렌더링하게 되는데, 이 때 서버용 렌더링 함수를 사용하게 됩니다. 해당 함수는 컴포넌트를 렌더링하여 문자열로 만들어줍니다.

그리고, 라우터도 브라우저에서 사용하던 BrowserRouter 가 아닌 StaticRouter 를 사용합니다. BrowserRouter 의 경우 브라우저가 지니고있는 HTML5 History API 를 사용하여 그에 따라 렌더링 해주는 반면, StaticRouter 의 경우엔 주소 값을 직접 url 라는 props 로 넣어주어서 이에 따라 렌더링을 해줍니다.

src/ssr.js

import React from 'react';
import ReactDOMServer from 'react-dom/server';
import { StaticRouter } from 'react-router';
import { Provider } from 'react-redux';
import configure from 'store/configure';

import App from 'components/App';

const render = (ctx) => {
  const { url } = ctx; // 요청에서 URL 을 받아옵니다.

  // 요청이 들어올때마다 새 스토어를 만듭니다
  const store = configure();

  // renderToString 은 렌더링된 결과물을 문자열로 만들어줍니다.
  // 서버에서는 BrowserRouter 대신에 StaticRouter 를 사용합니다.
  const html = ReactDOMServer.renderToString(
    <Provider store={store}>
      <StaticRouter location={url}>
        <App/>
      </StaticRouter>
    </Provider>
  );

  return html;
}

export default render;

서버전용 Webpack 환경설정

그 다음 단계에서는 서버용도로 빌드를 하기 위하여 서버전용 Webpack 환경설정을 만들어주어야 합니다. 설정 파일을 만들기 전에, 서버용 엔트리 파일 경로와, 결과물 경로를 지정해 주기위하여 config/path.js 파일의 하단부분을 다음과 같이 수정하세요.

config/paths.js - 하단

// config after eject: we're in ./config/
module.exports = {
  (...)
  ssrJs: resolveApp('src/ssr.js'),
  ssrBuild: resolveApp('../blog-backend/src/ssr')
};

그 다음에는, 서버전용 웹팩 환경설정 파일 webpack.config.server.js 파일을 생성하겠습니다. 주석을 읽어가면서 다음 파일을 작성해보세요.

config/webpack.config.server.js

const path = require('path');
const webpack = require('webpack');
const paths = require('./paths');
const getClientEnvironment = require('./env');

// 환경변수를 설정하기 위한 설정
const publicPath = paths.servedPath;
const publicUrl = publicPath.slice(0, -1);
const env = getClientEnvironment(publicUrl);

module.exports = {
entry: paths.ssrJs,
  target: 'node', // node 전용으로 번들링 한다는것을 명시합니다.
  output: {
    path: paths.ssrBuild,
    filename: 'render.js',
    // Node.js 에서require 로 불러올 수 있게 함
    libraryTarget: 'commonjs2'
  },
  module: {
    // 각 파일들을 불러오게 될 때 설정
    rules: [
      {
        // oneOf 는 내부의 모든 로더를 시도해보고, 해당되는 것이 없다면
        // 최하단의 file-loader 를 실행시킵니다
        oneOf: [
          {
            // 자바스크립트 파일은 바벨을 사용하여 변환합니다
            test: /\.(js|jsx)$/,
            include: paths.appSrc,
            loader: require.resolve('babel-loader'),
            options: {
              cacheDirectory: true
            },
          },
          /* css 와 scss 파일을 불러 올 때에는, 
          css-loaders/locals 를 실행하는것이 중요합니다. 
          파일을 따로 만들어내지 않기 때문이죠 */
          {
            test: /\.css$/,
            loader: require.resolve('css-loader/locals'),
          },
          /* scss 의 경우엔, CSS Module 이 제대로 작동 하도록,
          production 과 동일하게 설정 하되, 
          여기에서도 css-loader/locals 를 적용합니다 */
          {
            test: /\.scss$/,
            use: [
              {
                loader: require.resolve('css-loader/locals'),
                options: {
                  importLoaders: 1,
                  modules: true,
                  localIdentName: '[name]__[local]___[hash:base64:5]'
                },
              },
              {
                loader: require.resolve('sass-loader'),
                options: {
                  includePaths: [paths.globalStyles]
                }
              }
            ]
          },
          // 만약에 자바스크립트도, 스타일도 아니라면, 파일로 취급합니다.
          // 여기서 emitFile: false 설정이 중요합니다.
          {
            loader: require.resolve('file-loader'),
            exclude: [/\.js$/, /\.html$/, /\.json$/],
            options: {
              name: 'static/media/[name].[hash:8].[ext]',
              // 경로만 만들고, 실제로 파일을 따로 저장하지는 않습니다.
              emitFile: false
            },
          }
        ]
      }
    ]
  },
  resolve: {
    // NODE_PATH 가 제대로 작동하도록, production 에서 사용한 설정을
    // 그대로 넣어줬습니다.
    modules: ['node_modules', paths.appNodeModules].concat(
      // It is guaranteed to exist because we tweak it in `env.js`
      process.env.NODE_PATH.split(path.delimiter).filter(Boolean)
    )
  },
  // 여기서는 환경 변수 관련 플러그인만 적용해주면 됩니다.
  plugins: [
    new webpack.DefinePlugin(env.stringified),
  ],
};

파일을 생성 후, 이를 실행하기 위한 서버 빌드용 스크립트도 만들어주겠습니다. 서버 빌드용 스크립트는 기존의 scripts/build.js 파일에 기반하여 만들어집니다. 해당 파일의 사본을 만든 후, build.server.js 로 이름을 변경하세요.

기존의 build.js 에선, 코드 압축 전후의 파일 크기를 계산하고, 또 이전 빌드와 현재 빌드의 파일 크기를 비교하는 로직이 들어있습니다. 서버쪽 빌드 환경설정에선 이러한 기능이 필요 없으니 이 로직에 해당하는 코드들을 지워주겠습니다.

다음 코드와 build.server.js 파일을 비교해 가면서, 나타나지 않는 코드들을 지우고, 새로 바뀐 부분은 코드를 수정해주세요. 새로 바뀐 부분들에는 주석이 달려있습니다.

scripts/build.server.js

'use strict';

process.env.BABEL_ENV = 'production';
process.env.NODE_ENV = 'production';
process.env.APP_ENV = 'server'; // 서버 환경임을 명시

process.on('unhandledRejection', err => {
  throw err;
});

require('../config/env');

const chalk = require('chalk');
const webpack = require('webpack');
const config = require('../config/webpack.config.server'); // 환경설정 파일 변경
const paths = require('../config/paths');
const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages');

if (!checkRequiredFiles([paths.appHtml, paths.appIndexJs])) {
  process.exit(1);
}

function build() { // 파라미터 제거
  console.log('Creating server build...'); // 메시지 변경

  let compiler = webpack(config);
  return new Promise((resolve, reject) => {
    compiler.run((err, stats) => {
      if (err) {
        return reject(err);
      }
      const messages = formatWebpackMessages(stats.toJson({}, true));
      if (messages.errors.length) {
        // Only keep the first error. Others are often indicative
        // of the same problem, but confuse the reader with noise.
        if (messages.errors.length > 1) {
          messages.errors.length = 1;
        }
        return reject(new Error(messages.errors.join('\n\n')));
      }
      if (
        process.env.CI &&
        (typeof process.env.CI !== 'string' ||
          process.env.CI.toLowerCase() !== 'false') &&
        messages.warnings.length
      ) {
        console.log(
          chalk.yellow(
            '\nTreating warnings as errors because process.env.CI = true.\n' +
              'Most CI servers set it automatically.\n'
          )
        );
        return reject(new Error(messages.warnings.join('\n\n')));
      }
      return resolve({
        stats,
        warnings: messages.warnings,
      });
    });
  });
}

build(); // build 호출

이 스크립트를 만들고 나서, package.json 에서 위 스크립트를 실행시키는 명령어를 추가하세요.

package.json - scripts

  "scripts": {
    "start": "node scripts/start.js",
    "build": "node scripts/build.js",
    "build:server": "node scripts/build.server.js",
    "test": "node scripts/test.js --env=jsdom"
  },

이제 터미널에서 다음 명령어를 실행하면 서버용 웹팩빌드가 blog-backend/ssr/render.js 에 저장 될 것입니다.

$ yarn build:server
yarn build:server v0.27.5
$ node scripts/build.server.js
Creating server build...
Done in 4.95s.

그 다음엔, 백엔드 쪽의 ssr 디렉토리에 index.js 를 다음과 같이 만드세요.

src/ssr/index.js

const render = require('./render').default;

module.exports = async (ctx) => {
  const rendered = render(ctx);
  ctx.body = rendered; // 임시코드; 추후 구현예정
};

우리가 기존에 프론트엔드쪽에 만들었떤 ssr.js 에서 export default 를 사용하여 내보내주었습니다. Node.js 에서 그렇게 내보내기 한 것을 불러오기 위해선 require() 를 한다음에 뒤에 .default 를 붙여주어야 합니다.

여기서, 렌더링 처리 함수를 만들어주고, 내보내주었습니다. 이 함수의 기능은 나중에 구현하겠습니다. 우선, 이렇게 만든 함수를 src/index.js 파일에서 불러와서 / 경로와, 그 외의 경로에 적용해보세요.

src/index.js

(...)
const ssr = require('./ssr');

(...)

// 라우터 설정
router.use('/api', api.routes()); // api 라우트 적용
router.get('/', ssr);

(...)
app.use(ssr); // 일치하는 것이 없으면 ssr 을 실행합니다

app.listen(port, () => {
  console.log('listening to port', port);
});

이렇게 코드를 저장하고 서버를 실행하면, 다음과 같은 오류가 뜰 것입니다:

window.Prism = __WEBPACK_IMPORTED_MODULE_4_prismjs___default.a;
^

ReferenceError: window is not defined

클라이언트에서 사용하는 라이브러리중에선, 브라우저에서만 존재하는 window, document, navigator 등의 값을 사용하게 될 때가 있습니다. 필요 할 때만 호출되는 함수에서 이 객체들을 조회하는 것은 문제가 없지만, 로딩 과정에서 이 객체들을 조회하게 된다면, 이 값들이 undefined 이므로, 이렇게 오류가 발생하게 됩니다.

이 문제는 두가지 방법으로 해결 할 수 있습니다.

첫번째 방식은, Node.js 에서 브라우저에서만 존재하는 객체들을 조회해도 오류가 나지 않도록 가상의 브라우저 환경을 설정해주는 라이브러리인 browser-env 를 사용하는 것 입니다.

$ yarn add browser-env

이렇게 모듈을 설치하고 난 다음엔, 리액트 관련 코드를 불러오는 ssr/index.js 파일의 최상단에 다음과 같이 넣으세요:

src/ssr/index.js

require('browser-env')();
const render = require('./render').default;

(...)

이렇게 하면 아주 간편하게 오류를 방지 할 수 있습니다.

두번째 방법은, 서버 쪽에서 실행 환경에 따라 특정 라이브러리를 불러오지 않거나, 특정 코드를 실행하지 않는 것 입니다.

우선, 이 방법을 알아보기 위하여 방금 설정한 browser-env 적용 코드를 주석처리 하세요.

src/ssr/index.js

// require('browser-env')();

우리가 상단 섹션에서 서버 전용 빌드 스크립트를 만들 때, APP_ENV 를 명시해 주었었습니다. 우리는 코드상에서 이 값에 따라 다른 작업을 할 것인데요, 코드상에서 이 값을 조회하려면, config/env.js 파일을 수정해야합니다.

config/env.js

(...)
function getClientEnvironment(publicUrl) {
  (...)
      {
        NODE_ENV: process.env.NODE_ENV || 'development',
        PUBLIC_URL: publicUrl,
        APP_ENV: process.env.APP_ENV || 'browser'
      }

이제, 이 APP_ENV 에 따라 서버쪽에서 충돌을 일으키는 Prism.js 를 import 하지 않도록 코드를 작성해봅시다.

src/components/common/MarkdownRender/MarkdownRender.js

import React, { Component } from 'react';
import styles from './MarkdownRender.scss';
import classNames from 'classnames/bind';

import marked from 'marked';

// prism 관련 코드 불러오기
import 'prismjs/themes/prism-okaidia.css';

// 브라우저일때만 로딩
let Prism = null;
const isBrowser = process.env.APP_ENV === 'browser';
if(isBrowser) {
  Prism = require('prismjs');
  require('prismjs/components/prism-bash.min.js');
  require('prismjs/components/prism-javascript.min.js');
  require('prismjs/components/prism-jsx.min.js');
  require('prismjs/components/prism-css.min.js');
}

(...)

그리고, CodeMirror 또한 충돌을 일으키게 되니, 브라우저에서만 사용하도록 설정해보세요.

src/components/editor/EditorPane/EditorPane.js

import React, { Component } from 'react';
import styles from './EditorPane.scss';
import classNames from 'classnames/bind';

// CodeMirror 를 위한 CSS 스타일
import 'codemirror/lib/codemirror.css';
import 'codemirror/theme/monokai.css';

// 브라우저일때만 로딩
let CodeMirror = null;
const isBrowser = process.env.APP_ENV === 'browser';
if(isBrowser) {
  CodeMirror = require('codemirror');
  require('codemirror/mode/markdown/markdown');
  require('codemirror/mode/javascript/javascript');
  require('codemirror/mode/jsx/jsx');
  require('codemirror/mode/css/css');
  require('codemirror/mode/shell/shell');
}

이제 서버에서 충돌하는 코드들이 모두 무력화 되었습니다. yarn build:server 를 통하여 서버 빌드를 다시 만든 후, 브라우저 상에서 http://localhost:4000/ 에 들어가보세요. 그럼, 이러한 상태로 페이지가 나타납니다.

현재, 페이지에 스타일링도 안되어있고, 포스트 목록도 비어있습니다. 이 부분은 우리가 차차 구현해나갈 부분입니다.

정적 파일 제공하기

우리의 백엔드 서버에서 CSS 파일과 JS 파일들을 불러오려면, 정적 파일들을 제공하는 작업들을 해야합니다.

정적 파일들을 제공 할 때에는, koa-static 라이브러리를 사용합니다. 이 라이브러리를 yarn 을 통하여 설치하세요.

$ yarn add koa-static

그리고, 프론트엔드 디렉토리의 build 디렉토리를 파라미터에 넣어서 그 안에 있는 파일들을 웹서버를 통해 접근 할 수 있게 해주겠습니다.

src/index.js

(...)
const path = require('path');
const serve = require('koa-static');

const staticPath = path.join(__dirname, '../../blog-frontend/build');

(...)

app.use(serve(staticPath)); // 주의: serve 가 ssr 전에 와야 합니다
app.use(ssr);

이 작업을 완료하고 난 다음엔, 프론트엔드쪽 코드를 한번 빌드 해 줄건데요, 그 전에 해야 할 작업이 한가지 있습니다.

Create-react-app 으로 만든 프로젝트에는 페이지를 불러오고 난 다음엔 이를 브라우저의 로컬 캐시에 등록하여 다음부터 페이지에 접속하게 될 땐 서버에서 가져오는것이 아니라 로컬에서 꺼내 사용하며, 파일이 업데이트 될 떈 이를 반영 후 다음 방문시 새 파일로 보여주게 하는 Service Worker 기능이 기본적으로 등록되어있습니다.

우리가 서버사이드 렌더링을 하고, 제대로 되는지 테스트 해보게 될 때 이 기능이 방해가 되므로 (시도를 할 때마다 계속 캐시를 날려주어야 합니다) 우리는 이 기능을 비활성화 하겠습니다. 비활성화를 하기 위해선, 클라이언트의 엔트리인 index.js 의 맨 마지막 줄에 registerServiceWorker(); 를 주석처리하면 됩니다.

src/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import Root from './Root';
import registerServiceWorker from './registerServiceWorker';
import 'styles/base.scss';

ReactDOM.render(<Root />, document.getElementById('root'));
// registerServiceWorker();

그리고나서 다음 명령어를 통해 프로젝트를 빌드하세요.

# yarn build

자, 이제 build 디렉토리의 index.html 파일을 열어서 해당 내용을 전부 복사하세요. 이 복사한 문자열을, 다음 섹션에서 HTML 템플릿을 만드는 부분에서 사용하게 됩니다.

HTML 템플릿 만들기

현재 백엔드 서버에서 나타나고 있는 페이지에는, 서버렌더링된 HTML 만 보여지고 있기 때문에, 자바스크립트와 CSS 파일들을 불러오지 않은 상태입니다. 방금 복사한 HTML 을 사용하여 서버렌더링된 결과를 html 코드의 root 엘리먼트 내부에 넣어주는 함수를 만들어보겠습니다.

src/ssr/index.js

const render = require('./render').default;

function buildHtml(rendered) {
  return `
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
  <meta name="theme-color" content="#000000">
  <link rel="manifest" href="/manifest.json">
  <link rel="shortcut icon" href="/favicon.ico">
  <title>React App</title>
  <link href="/static/css/app.339db884.css" rel="stylesheet">
</head>

<body>
  <noscript>You need to enable JavaScript to run this app.</noscript>
  <div id="root">${rendered}</div>
  <script type="text/javascript" src="/static/js/vendor.b3ede41e.js"></script>
  <script type="text/javascript" src="/static/js/app.758be88c.js"></script>
</body>

</html>`;
}

module.exports = async (ctx) => {
  const rendered = render(ctx);
  ctx.body = buildHtml(rendered);
};

여기서, app.css, vendor.js, app.js 가 들어가는 부분은, 코드가 바뀔때마다 해쉬 값이 변경됩니다. build 경로의 asset-manifest.json 파일을 보면 다음과 같이 각 파일에 대한 경로가 들어있는데요:

build/asset-manifest.json

{
  "app.css": "static/css/app.d5e1de22.css",
  "app.css.map": "static/css/app.d5e1de22.css.map",
  "app.js": "static/js/app.a23d5e40.js",
  "app.js.map": "static/js/app.a23d5e40.js.map",
  (...)

빌드를 할 때마다 HTML을 수정하는 일이 없도록, 이 파일을 불러와서 내부의 값을 참고하여 값을 넣어주겠습니다.

src/ssr/index.js

const manifest = require('../../../blog-frontend/build/asset-manifest.json');

function buildHtml(rendered) {
  return `
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
  <meta name="theme-color" content="#000000">
  <link rel="manifest" href="/manifest.json">
  <link rel="shortcut icon" href="/favicon.ico">
  <title>React App</title>
  <link href="/${manifest['app.css']}" rel="stylesheet">
</head>

<body>
  <noscript>You need to enable JavaScript to run this app.</noscript>
  <div id="root">${rendered}</div>
  <script type="text/javascript" src="/${manifest['vendor.js']}"></script>
  <script type="text/javascript" src="/${manifest['app.js']}"></script>
</body>

</html>`;
}

(...)

자, 이제 페이지를 http://localhost:4000/ 에 접속하여 띄워보면 다음과 같이 잘 나타날 것입니다.

하지만, 아직 다 끝난 것이 아닙니다. 이는 자바스크립트가 실행되면서 API 까지 호출한 결과입니다. 한번 .js 파일을 불러오는 부분을 주석처리 해보세요:

HTML 상에서 주석을 처리 할 때에는 <!-- --> 를 사용하면 됩니다.

src/ssr/index.js - script 태그 주석

  <!--
  <script type="text/javascript" src="/${manifest['vendor.js']}"></script>
  <script type="text/javascript" src="/${manifest['app.js']}"></script>
  -->

이렇게 저장을 하고 새로고침을 하면 다음과 같이 포스트 목록이 빈상태로 보여질 것입니다:

포스트 목록들을 보여지게 하기 위해선, 라우터에 따라서 API 호출 관련 액션을 미리 디스패치 하고, 호출이 완료될 때 까지 기다린 다음에 렌더링을 해야 합니다.

서버사이드 렌더링 시 데이터 불러오기

서버사이드 렌더링 시 데이터를 불러오기 위해선, 데이터 로딩을 필요로 하는 라우트에 preload 라는 static 함수를 만들어야 합니다. 이 함수는, 라우트의 파라미터인 params 와 리덕스 스토어의 dispatch 를 파라미터로 받아옵니다. (필요에 따라서 query 등 다른 값을 받아와도 됩니다. 이 부분은 리액트 라이브러리가 지닌 스펙이 아닌 우리가 앞으로 구현하여 호출 하는 부분이기 때문에 형식은 자유롭게 작성해도 됩니다)

preload 함수에서는, 라우트에서 디스패치해야 할 액션들을 설정해줍니다. 그리고, 만약에 비동기 작업을 하게 되어 해당 작업이 끝날 때 까지 서버쪽에서 기다려야 한다면 프로미스 객체를 return 해 주어야 합니다.

다음은, ListPage 에 preload 함수를 구현 한 예제입니다.

src/pages/ListPage.js

import React from 'react';
import PageTemplate from 'components/common/PageTemplate';
import ListWrapper from 'components/list/ListWrapper';
import ListContainer from 'containers/list/ListContainer';
import * as listActions from 'store/modules/list';
import { bindActionCreators } from 'redux';

const ListPage = ({match}) => {
  (...)
};

ListPage.preload = (dispatch, params) => {
  const { page = 1, tag } = params;
  const ListActions = bindActionCreators(listActions, dispatch);
  return ListActions.getPostList({
    page, tag
  });
}

export default ListPage;

위 코드에는 편의상 bindActionCreators 를 통하여 dispatch 와 액션 생성 함수를 바인드 해주었습니다. bindActionCreators 를 사용하지 않는다면, dispatch(listActions.getPostList({ … }) 의 형식으로 해도 됩니다.

그 다음에, PostPage 도 비슷한 구조로 preload 함수를 생성하세요.

src/pages/PostPage.js

import React from 'react';
import PageTemplate from 'components/common/PageTemplate';
import Post from 'containers/post/Post';
import AskRemoveModalContainer from 'containers/modal/AskRemoveModalContainer';
import * as postActions from 'store/modules/post';
import { bindActionCreators } from 'redux';

const PostPage = ({ match }) => {
  (...)
};

PostPage.preload = (dispatch, params) => {
  const { id } = params;
  const PostActions = bindActionCreators(postActions, dispatch);
  return PostActions.getPost(id);
}

export default PostPage;

지금의 경우엔, 각 라우트마다 호출하는 API 가 한 종류이기 때문에 프로미스 기반 액션 생성 함수를 호출하여 그대로 리턴을 해주었는데요, 만약에 API 의 종류가 여러개가 된다면, return Promise.all([action1(), action2()]) 의 형식으로 작성하시면 됩니다.

데이터 로딩이 필요한 라우트는 이렇게 두가지입니다. 이제, 어떤 주소로 들어오느냐에 따라 방금 만든 함수에 dispatch 와 params 를 넣어서 호출을 해주어야 하는데요, 이 작업을 하려면 라우트의 설정이 담겨있는 객체를 만들어주어야 합니다.

src/routes.js

import { ListPage, PostPage, EditorPage } from 'pages';

export default [
  {
    path: '/',
    exact: true,
    component: ListPage
  },
  {
    path: '/post/:id',
    component: PostPage
  },
  {
    path: '/page/:page',
    component: ListPage
  },
  {
    path: '/tag/:tag/:page?',
    component: ListPage
  },{
    path: '/editor',
    component: EditorPage
  }
];

EditorPage 라우트의 경우엔 preload 함수가 없으므로 지금 당장은 생략을 해도 됩니다. 하지만, 나중에 서버사이드 렌더링과 코드 스플리팅이 둘 다 제대로 작동하기 위해서 비동기 컴포넌트를 모두 불러온다음에 리액트 렌더링 작업을 수행하기 위하여, EditorPage 도 이 설정파일에 넣어주었습니다.

이제, 우리가 이전에 작성한 서버사이드 렌더링 함수에서 방금 만든 배열을 불러와서 반복문을 통하여 요청의 주소와 일치하는 라우트를 찾고, 해당 라우트가 가르키는 컴포넌트의 preload 를 호출하도록 코드를 작성해보겠습니다.

src/ssr.js

import React from 'react';
import ReactDOMServer from 'react-dom/server';
import { StaticRouter, matchPath } from 'react-router';
import { Provider } from 'react-redux';
import configure from 'store/configure';
import routes from './routes';

import App from 'components/App';

const render = async (ctx) => {
  const { url } = ctx; // 요청에서 URL 을 받아옵니다.

  // 요청이 들어올때마다 새 스토어를 만듭니다
  const store = configure();

  const promises = [];
  // 라우트 설정에 반복문을 돌려서 일치하는 라우트를 찾습니다
  routes.forEach(
    route => {
      const match = matchPath(url, route);
      if(!match) return; // 일치하지 않으면 무시
      // match 가 성공하면, 해당 라우트의 컴포넌트의 preload 를 호출
      // 그리고, 파싱된 params 를 preload 함수에 전달
      const { component } = route;
      const { preload } = component;
      if(!preload) return; // preload 없으면 무시
      const { params } = match; // Route 의 props 로 받는 match 와 동일한 객체
      // preload 를 통해 얻은 프로미스를 promises 배열에 등록
      const promise = preload(store.dispatch, params);
      promises.push(promise);
    }
  );

  try {
    // 등록된 모든 프로미스를 기다립니다.
    await Promise.all(promises);
  } catch (e) {

  }

  // renderToString 은 렌더링된 결과물을 문자열로 만들어줍니다.
  // 서버에서는 BrowserRouter 대신에 StaticRouter 를 사용합니다.
  const html = ReactDOMServer.renderToString(
    <Provider store={store}>
      <StaticRouter location={url}>
        <App/>
      </StaticRouter>
    </Provider>
  );

  return html;
}

export default render;

이렇게 하고 나면, preload 함수가 모두 끝나고 나서 렌더링을 하게 되어 데이터를 받아온 상태로 렌더링을 하게 됩니다.

여기서 추가적으로 해줘야 하는 작업이 있는데요, 우리가 기존에 작성한 API 함수들은 API 요청을 하게 될 때 현재의 호스트에 기반하여 요청이 되도록 “/api/posts” 와 같은 주소에 요청을 하였습니다. 하지만, 서버에서 이 코드가 실행 될때에는 호스트가 자동으로 지정되지가 않는데요, 이를 위한 작업도 해주어야 합니다.

이를 해결하기 위해서 axios 의 baseURL 을 설정해주어야 하는데요, 설정하는 방법은 다음과 같습니다.

src/ssr.js

(...)
import axios from 'axios';

import App from 'components/App';

const render = async (ctx) => {
  const { url, origin } = ctx;

  axios.defaults.baseURL = origin;

이렇게 하면, 요청의 Origin 값 (http://localhost:4000 와 같은 형태) 을 baseURL 로 설정하게 되어 API 요청이 정상적으로 이뤄집니다.

우리가 방금 만든 render 함수는 async/await 을 사용하므로, 백엔드에서 이 함수를 호출하게 될 때 await 을 해주어야 합니다.

src/ssr/index.js

(...)

module.exports = async (ctx) => {
  try {
    const rendered = await render(ctx);
    ctx.body = buildHtml(rendered);
  } catch (e) {
    // 에러가 발생하면 일반 html 응답
    ctx.body = buildHtml({});
  }
};

데이터 로딩 부분은 이제 어느정도 완성이 되었습니다. 서버 빌드를 새로 만들고, 다시 페이지를 새로고침하여 제대로 작동하는지 확인해보세요.

이제 데이터 로딩까지 끝났습니다. 클라이언트에서 자바스크립트가 실행되지 않았음에도 불구하고 데이터들이 잘 보여지고 있지요?

포스트를 클릭해서 포스트 내용이 잘 나타나는지도 확인해보세요. 현재는 자바스크립트를 불러온 상태가 아니기 때문에 클라이언트 라우팅이 아닌 서버 라우팅을 하게 되면서 다시 한번 서버사이드 렌더링이 발생하여 포스트 내용을 보여주게 됩니다.

이제 서버사이드 렌더링 부분은 거의 끝나가고 있는데요, 추가 작업으로, 클라이언트에서 만약 이미 서버사이드 렌더링을 통하여 데이터를 불러왔을 때에는 다시 API 요청을 하지 않도록 방지해주어야 합니다. 이미 데이터를 불러왔는데 똑같은 내용을 받아오는 API를 요청하게 된다면 낭비겠죠?

우선, 이전에 <script> 부분에 주석처리 했던 것을 해제해주세요.

그리고, 페이지를 새로고침하면, 서버렌더링을 통하여 이미 데이터를 들고 있음에도 불구하고, 또 다시 API 요청을 하는 것을 확인 하실 수 있습니다.

문제를 발견했으니, 해결해볼까요?

리덕스 상태 HTML 안에 넣기

API 중복요청을 막기 위해선 서버사이드 렌더링이 되었을 때의 리덕스 상태를 그대로 클라이언트에 전달을 해주어야 합니다.

리덕스 스토어의 상태를 가져올 때에는 store.getState() 를 하시면 됩니다. 이 값을 서버사이드 렌더링을 하게 될 때 에 감싸서 페이지의 글로벌 변수로 지정하고, 자바스크립트가 실행 될 때 해당 변수의 값을 리덕스의 초기 상태로 사용하여 상태를 그대로 유지 할 수 있습니다.

우리는 Immutable 을 사용하기 때문에, getState 호출하여 그대로 넣어 줄 수는 없습니다. 그 이유는, HTML 에 로 넣어주기 위해선 객체를 문자열로 변환해주어야 하는데, Immutable 인스턴스는 그 작업이 되지 않기 때문이죠.

이를 해결하기 위해선 Immutable 객체를 먼저 일반 JSON 형태로 바꾸고, 그 다음에 문자열로 바꾸는 작업을 진행해야 합니다. 스토어 내부에는 일반 객체인 것도 있고, Immutable 인스턴스인것도 있는데요, 이를 수작업으로 코드를 작성하여 형태에 따라 시리얼라이징해도 되지만, 우리는 이러한 작업을 손쉽게 자동화해주는 라이브러리인 transit 과 transit-immutable-js 를 설치하여 사용하겠습니다.

해당 라이브러리를 yarn 을 통하여 설치하세요.

$ yarn add transit-js transit-immutable-js

그 다음엔, ssr.js 에서 위 라이브러리들을 불러온 다음에, 서버사이드 렌더링 함수에서 객체 안에 렌더링된 html 결과물과, store 상태를 문자열로 변환한 값을 넣어서 반환하세요.

src/ssr.js

(...)
import transit from 'transit-immutable-js';

const render = async (ctx) => {
  (...)

  const preloadedState = JSON.stringify(transit.toJSON(store.getState()))
                            .replace(/</g, '\\u003c');

  return { html, preloadedState };
}

export default render;

Immutable 인스턴스들이 들어있는 객체를 JSON 으로 변환 할 때는, transit.toJSON 을 사용하며, JSON.stringify 를 사용하여 이를 문자열로 변환합니다.

이 과정에서, < 문자는 보안을 위하여 유니코드 문자 \u003c 로 치환을 해주어야 합니다. 해당 문자열을 치환하지 않게 된다면, 객체 내부에 있는 값 중 다음과 같은 값이 있다면: </script><script>alert(‘hello’)</script>, 의도치 않은 악성 스크립트 실행이 가능해집니다. (물론, 우리의 프로젝트는 본인만 글을 작성하니까 상관은 없겠지만, 만약 유저 누구든지 글을 쓸 수 있는 그런 서비스라면 이렇게 치환 작업을 해 주는 것은 필수 입니다.

서버쪽에서도 이에 따라 반영을 해주어야 합니다. 백엔드 쪽에서 buildHtml 함수를 다음과 같이 수정하세요.

src/ssr/index.js - buildHtml

function buildHtml({ html, preloadedState }) {
  return `
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1,shrink-to-fit=no">
  <meta name="theme-color" content="#000000">
  <link rel="manifest" href="/manifest.json">
  <link rel="shortcut icon" href="/favicon.ico">
  <title>React App</title>
  <link href="/${manifest['app.css']}" rel="stylesheet">
</head>

<body>
  <noscript>You need to enable JavaScript to run this app.</noscript>
  <div id="root">${html}</div>
  <script>
    window.__PRELOADED_STATE__ = ${preloadedState}
  </script>
  <script type="text/javascript" src="/${manifest['vendor.js']}"></script>
  <script type="text/javascript" src="/${manifest['app.js']}"></script>
</body>

</html>`;
}

이제 기존의 rendered 부분에 html 값과 preloadedState 를 받게 되니, 이를 비구조화 할당을 통하여 레퍼런스를 만들어주고 템플릿의 각 위치에 넣어주었습니다.

추가적으로, 기존에 주석처리 했었던 vendor, app 스크립트 로딩 부분을 다시 주석 해제 하세요.

백엔드 코드를 다 수정하고 나서, 다시 프론트엔드 쪽으로 돌아와서 Root 컴포넌트에서 window.__PRELOADED_STATE__ 값이 존재한다면 해당 값을 다시 Immutable 인스턴스들이 들어있는 객체로 변환하여 configureStore 의 파라미터로 넣으세요.

src/Root.js

import React from 'react';
import { BrowserRouter } from 'react-router-dom';
import App from 'components/App';
import { Provider } from 'react-redux';
import configure from 'store/configure';
import transit from 'transit-immutable-js';

const preloadedState = window.__PRELOADED_STATE__ && transit.fromJSON(window.__PRELOADED_STATE__);

const store = configure(preloadedState);

(...)

서버에서 전달된 JSON 객체를 Immutable 인스턴스들이 들어있는 객체로 변환을 할 때는 transit.fromJSON 함수를 사용합니다.

preloadedState 를 설정하는 작업을 완료하셨다면, 빌드를 다시 하세요.

$ yarn build
$ yarn build:server

빌드 할 때는, 클라이언트 파일 빌드와 서버 빌드를 둘 다 진행해주어야 합니다.

빌드가 완료된 다음에, http://localhost:4000/ 에 들어가서 개발자모드를 통해 페이지의 script 부분을 확인하면 다음과 같이 문자열 형태로 스토어의 값이 설정되어있을 것 입니다.

스토어의 상태를 잘 받아왔고, 페이지도 잘 보여질 것입니다. 하지만, Network 탭을 열고 REST API 요청들을 볼 수 있는 XHR 를 누른다음에 새로고침을 해보면, 여전히 페이지를 불러오는 API를 호출하고 있는 것을 발견 하실 수 있을겁니다.

이미 페이지의 내용은 불러왔는데, 불필요한 호출이 이뤄지고있지요? 그 이유는 현재 컴포넌트상에서 componentDidMount 가 발생하게 될 때, 어떤 상황에도 API 를 호출하도록 설계되어있기 때문입니다.

서버사이드 렌더링 후 불필요한 API 호출 방지하기

서버사이드 렌더링 후 불필요한 API 호출을 방지하려면, “서버사이드 렌더링을 했을 때” 의 조건에 따라서 요청을 보내지 않으면 되는데, 이를 구현하는 방법은 여러가지의 종류가 있습니다. 예를 들어, 라우터의 상태에 따라서 현재 페이지가 첫 페이지라면 API 호출을 막는 방법이 있고, 또는 리덕스의 상태에 따라서 요청을 무시를 하는 알고리즘을 작성 할 수도 있습니다. 또는, 글로벌 변수를 설정하여 이를 구현 할 수도 있습니다.

여러가지 방법 중에서, 우리는 이를 관리하는 간단한 모듈을 만드는 방식으로 해결을 해보겠습니다.

src/lib/shouldCancel.js

let cancel = process.env.APP_ENV === 'browser' && !!window.__PRELOADED_STATE__;

export const inform = () => {
  cancel = false;
}

export default () => {
  return cancel;
}

이 모듈에서는 cancel 변수를 선언했습니다. 자바스크립트 코드가 브라우저에서 실행 될 때, 리덕스의 초기값이 서버사이드 렌더링을 통해 주어졌을 경우 이 값은 true 로 설정됩니다.

그리고, inform 함수는 클라이언트 쪽에서의 첫 렌더링이 완료되었다고 알리는 함수로서, cancel 값을 false 로 변경해줍니다.

이 모듈에서 default 로 내보내는 shouldCancel 함수는, 현재 이 모듈이 지니고 있는 cancel 값을 반환해줍니다.

여기서 그냥 cancel 값 자체를 내보내지 않은 이유는, 그렇게 했을 땐 cancel 값이 바뀌어도, 내보내는 값이 함께 변하는 것이 아니라, 프로젝트가 초기화 될 당시의 값이 내보내지게 되어 이전의 값만 나타나기 때문입니다.

그럼, 이 함수들을 이용하여 서버사이드렌더링 후 데이터를 중복적으로 요청하는 문제를 해결해봅시다!

먼저 App 에서 렌더링되는 Base 컴포넌트의 componentDidMount 에서 inform 함수를 호출하세요.

src/containers/common/Base.js

(...)
import { inform } from 'lib/shouldCancel';

class Base extends Component {
  (...)
  componentDidMount() {
    this.initialize();
    inform();
  }
  (...)

이렇게 하면, 서버사이드 렌더링이 되었을 땐, 클라이언트 첫 렌더링이 마치기 전엔 cancel 값이 true 고, 그 이후엔 false 로 됩니다.

이제 API 요청을 하는 부분에서 shouldCancel 함수를 통하여 현재의 cancel 값에 따라 API 요청을 방지해봅시다.

src/containers/list/ListContainer.js

(...)
import shouldCancel from 'lib/shouldCancel';

class ListContainer extends Component {
  getPostList = () => {
    if(shouldCancel()) return;
    // 페이지와 태그 값을 부모로서부터 받아옵니다.
    const { tag, page, ListActions } = this.props;
    ListActions.getPostList({
      page,
      tag
    });
}
(...)

ListContainer 에서 shouldCancel 을 불러온 다음, getPostList 에서 이를 호출하여 값이 true 인 경우에 작업을 중지하도록 코드 입력하세요.

그 다음엔, Post.js 컨테이너 컴포넌트도 마찬가지로 동일한 작업을 진행해주세요

src/containers/post/Post.js

(...)
import shouldCancel from 'lib/shouldCancel';

class Post extends Component {
  initialize = async () => {
    if(shouldCancel()) return;

자, 벌써 작업이 완성되었습니다. 꽤 간단했지요? 아직 이 문제가 완전히 해결 된 것은 아닙니다. 이론상으로는, Post 혹은 ListContainer 컴포넌트의 componentDidMount 가 Base 의 componentDidMount 보다 늦게 호출이 되므로 문제없이 작동해야하는 것이 맞지만, 빌드를 하게 되어 localhost:4000 에서 페이지를 열게되면 Base 의 componentDidMount 가 먼저 호출이 됩니다.

왜 그럴까요? 아직 우리가 고려하지 않은 변수가 존재합니다. 바로, 코드 스플리팅이죠. 코드 스플리팅을 하게 되면서, 라우트 관련 컴포넌트는 비동기적으로 불러오도록 설계되었습니다. 따라서, 프로젝트가 로딩 될 때는, 라우트 관련 컴포넌트는 나중에 파일을 불러와서 렌더링 되기 때문에, 컨테이너 컴포넌트들의 componentDidMount 가 더 늦게 호출되어서 우리가 원하는대로 작동하지 않습니다.

서버사이드 렌더링과 코드스플리팅 충돌 해결하기

지금 발생하는 문제 말고도, 코드 스플리팅과 서버사이드 렌더링을 동시에 하게 되었을 때 발생하는 문제가 한가지 더 있습니다. asyncComponent 함수에서 render 부분을 보면 다음과 같은 내용이 있습니다:

src/lib/asyncComponent.js - render

    render() {
      const { Component } = this.state
      if (Component) {
        return <Component {...this.props} />
      }
      return null
    }

컴포넌트가 아직 불러오지 않은 상태라면 null 이 보여지고 있죠? 서버사이드 렌더링을 하게 되면, 컴포넌트를 불러오지 않은 상태에서도 전체 HTML 을 렌더링 해줍니다. 그리고, 리액트 코드가 실행되면 정해진 컴포넌트 구조에 따라 다시 렌더링을 하게 되는데요, 이 과정에서 다음과 같은 현상이 발생합니다

초반엔 서버에서 렌더링된 HTML 이 보여졌다가, app.js 가 로딩 되었을 때, asyncComponent 가 작동하면서 라우트 관련 코드가 아직 불러와지지 않았기 때문에 null 이 렌더링되어 비어있는 페이지가 렌더링되고, 그다음에 1.chunk.js 가 로딩되면서 제대로 렌더링 되는 현상입니다.

네트워크가 빠른 환경에선 이를 감지 할 수는 없지만, 만약에 외부 네트워크를 통하여 페이지를 방문하게 되면 유저들은 위와 같은 현상을 겪을 수 있습니다. 직접 확인을 해보려면, 느린 인터넷 속도를 크롬 개발자 도구를 통하여 느린 인터넷 환경을 시뮬레이션 할 수 있습니다 Fast 3G 로 설정하고 새로고침을 해보세요.

이를 해결하려면, 클라이언트 코드의 엔트리 파일 index.js 에서 ssr.js 에서 사용했었던 matchPath 를 사용하여 사용자가 접속한 주소에 기반하여 필요한 리액트 컴포넌트를 미리 받아온 다음에 렌더링 하면 됩니다.

서버사이드 렌더링에서 했었던 것 처럼 routes.js 를 불러와서 각 라우트를 반복문을 통하여 일치하는 라우트 객체를 찾은 뒤, 해당 라우트에 설정된 컴포넌트의 getComponent 함수를 실행하도록 하고, 이 작업이 끝난 다음에 렌더 함수를 실행하도록 코드를 작성해보세요.

src/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import Root from './Root';
import registerServiceWorker from './registerServiceWorker';
import routes from './routes';
import { matchPath } from 'react-router';
import 'styles/base.scss';

const render = async () => {
  if(process.env.NODE_ENV === 'development') {
    // 개발 모드에서는 바로 렌더링을 합니다
    return ReactDOM.render(<Root />, document.getElementById('root'));
  }

  // 프로덕션 모드에서는 일치하는 라우트를 찾아 미리 불러온 다음에 렌더링을 합니다
  const getComponents = [];
  const { pathname } = window.location;

  routes.forEach(
    route => {
      // 일치하는 라우트를 찾고, getComponent 를 호출하여 getComponents 에 넣습니다.
      const match = matchPath(pathname, route);
      if(!match) return;
      const { getComponent } = route.component;
      if(!getComponent) return;
      getComponents.push(getComponent());
    }
  );
  // getComponents 가 끝날 때 까지 기다립니다
  await Promise.all(getComponents);
  // render 가 아닌 hydrate 를 사용합니다. (설명 참조)
  ReactDOM.hydrate(<Root />, document.getElementById('root'));
}

render(); // render 호출
// registerServiceWorker();

render 함수 내부에서, 개발모드가 아닐 때에는 ReactDOM.render 가 아닌 ReactDOM.hydrate 를 사용했습니다. Hydrate 는 서버사이드 렌더링된 HTML 마크업을 그대로 유지하면서 이벤트만 등록을 해줍니다. 따라서, 초기 렌더링 성능이 더욱 개선됩니다.

이렇게 하면 코드 스플리팅과 서버 사이드 렌더링의 충돌 현상이 모두 고쳐집니다. 서버와 클라이언트 코드를 새로 빌드하고:

$ yarn build
$ yarn build:server

localhost:4000 로 들어가서 Network 의 네트워크 제한을 Fast 3G 로 설정 한 다음에 페이지 깜박임 현상이 사라졌는지 확인해보세요. 그리고 또 Network 에서 XHR 요청만 나타나도록 필터를 설정하고, 첫 렌더링시 불필요한 요청 (포스트 요청 혹은 포스트 목록 요청) 이 생략되었는지 확인해보세요.

이렇게 check API 만 보여진다면 정상적으로 작동하는 것 입니다.

results matching ""

    No results matching ""