리액트 서버사이드 렌더링 구현시 주의 사항

이 튜토리얼은 react-ssr-sample 프로젝트를 살펴보면서 리액트 서버사이드 렌더링의 원리를 파악해보는 튜토리얼입니다. 다음 명령어를 통하여 한번 여러분의 컴퓨터에서도 직접 돌려보세요:

$ git clone https://github.com/vlpt-playground/react-ssr-sample.git
$ cd react-ssr-sample
$ yarn
$ yarn build && yarn build:server
$ yarn start:ssr

1. 서버를 위한 웹팩 설정

/config/webpack.config.server.js

서버를 위한 웹팩 설정을 합니다. 기존 webpack.config.production.js 를 기반으로 만들어지는데 주의 할 부분들은

  target: 'node',
  output: {
    path: paths.ssrBuild,
    filename: 'server.js',
    chunkFilename: 'chunks/[name].[chunkhash:8].chunk.js',
    libraryTarget: 'commonjs2'
  },

브라우저에서 사용 용도가 아닌, node 에서 바로 실행 할 수 있는 빌드 파일을 생성

이것을 위해 config/path.js 도 다음 필드가 추가됩니다.

  ssrIndexJs: resolveModule(resolveApp, 'src/server'),
  ssrBuild: resolveApp('dist'),

file-loader 와 url-loader 에서는 emitFile 값을 false 로 설정하는것이 중요합니다. 파일을 위한 경로는 생성하지만 실제 빌드 할 때 파일을 따로 저장시키지는 않습니다.

{
  test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
  loader: require.resolve('url-loader'),
  options: {
    limit: 10000,
    emitFile: false, // 실제로 파일을 생성하지 않음
    name: 'static/media/[name].[hash:8].[ext]'
  }
},
{
  loader: require.resolve('file-loader'),
  exclude: [/\.(js|mjs|jsx|ts|tsx)$/, /\.html$/, /\.json$/],
  options: {
    emitFile: false, // 실제로 파일을 생성하지 않게 함
    name: 'static/media/[name].[hash:8].[ext]'
  }
}

css-loader 과 sass-loader 에서 중요한점은 css-loader/locals 를 사용한다는 점 입니다. 이를 통하여 코드내에서 import 하는 것은 문제가 없지만, 빌드 과정에서 새로 css 파일을 만들어내지 않습니다.

{
  test: /\.css$/,
  loader: require.resolve('css-loader/locals')
  // 뒤에 /locals 를 붙여줘야 실제로 파일을 생성하지 않음.
  // postcss-loader 같은건 생략해도됨.
},
{
  test: /\.scss$/,
  use: [
    require.resolve('css-loader/locals'), // 여기도 locals
    require.resolve('sass-loader')
  ]
},

babel-loader 에선 cacheCompressioncompact 속성을 false 로 합니다. (참고) production 에서는 compact: true 설정을을 통해 코드에 있는 공백들을 없애주는데 서버사이드 렌더링에서는 불필요한 작업이니 이 값을 false 로 설정하여 번들링 속도를 개선합니다. cacheCompression 은 빌드시 사용되는 캐시를 압축하는건데 이 또한 불필요하니 비활성화합니다.

{
  test: /\.(js|mjs|jsx|ts|tsx)$/,
  include: paths.appSrc,

  loader: require.resolve('babel-loader'),
  options: {
    customize: require.resolve(
      'babel-preset-react-app/webpack-overrides'
    ),

    plugins: [
      [
        require.resolve('babel-plugin-named-asset-import'),
        {
          loaderMap: {
            svg: {
              ReactComponent: '@svgr/webpack?-prettier,-svgo![path]'
            }
          }
        }
      ]
    ],
    cacheDirectory: true,
    cacheCompression: false,
    compact: false
  }
},

2. 서버 전용 엔트리 파일

서버 전용 엔트리 파일에서는 express 를 사용하여 웹서버를 만듭니다.

src/server.js

만약에 코드스플리팅과 데이터 로딩도 없다면 다음과 같은 구조로 이뤄집니다:

import fs from 'fs';
import path from 'path';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import express from 'express';
import App from './App';

const app = express();
// 이미 만들어진 indexHtml 을 기반으로 수정 할 것이기에
// 미리 불러온다.
const indexHtml = fs.readFileSync(
  path.resolve(__dirname, '../build/index.html'),
  'utf8'
);

// index.html 에 변화를 일으킨다.
function createPage(rootHtml) {
  let html = indexHtml;

  // 렌더링 결과를 root 안에 집어넣기
  html = html.replace(
    '<div id="root"></div>',
    `<div id="root">${rootHtml}</div>`
  );

  return html;
}

// 서버렌더링 함수
const serverRender = async (req, res) => {
  const rootHtml = ReactDOMServer.renderToString(
    <StaticRouter location={req.url}>
      <App />
    </StaticRouter>
  );

  res.send(createPage(rootHtml));
};

// / 경로에 들어왔을때도 똑같은 서버사이드 렌더링 작업
// index.html 을 사용하는것을 방지하는것임
app.get('/', (req, res) => {
  return serverRender(req, res);
});

// build 경로를 정적 디렉토리로 사용
app.use(express.static(path.resolve(__dirname, '../build')));

// 404가 발생하는 경우 서버사이드 렌더링
app.use((req, res, next) => {
  if (!req.route) {
    return serverRender(req, res);
  }
  return next();
});

app.listen(4000, () => {
  console.log('Running on http://localhost:4000/');
});

Create-react-app v2 부터는 html 을 생성 할 때 템플릿 형태로 내용을 전부 자바스크립트 안에 넣는것보다 사전에 만들어진 html 을 기반으로 필요한 부분을 replace 하는 형태로 구현하는것이 편합니다. 빌드된 index.html 쪽에는 추후 비동기 자원 로딩에 필요한 스크립트가 적용되는데 이는 빌드 될 때 만들어지는 chunkhash 값에 따라 가변적입니다.

또 주의해야 할 부분은 / 경로에도 서버사이드 렌더링 적용하는것입니다. 만약에 이걸 안하면 index.html 로 연결이 자동으로 되서 서버사이드 렌더링이 호출되지 않습니다.

추가적으로, 서버 내에서 404 를 띄워줘야 하는 상황에, 404를 띄우지 않고 서버 사이드렌더링으로 연결해줍니다. 실제 404 가 발생하면 리액트쪽에서 관리하도록 할 수 있습니다. (참고)

그리고, API 서버가 따로 있을텐데, 무조건 API 서버와 서버사이드 렌더링 서버를 통합 할 필요는 없습니다. 두 서버를 다른 인스턴스에서 구동을 하셔도 되고, 다른 포트로 따로따로 연 다음에, 다음과 같이 nginx 를 사용하여 프록시를 설정하면 됩니다.

server {
  listen 80 default_server;
    listen [::]:80 default_server;
  server_name _;
  root /var/www/html;

  # 일단 모든 경로는 SSR 서버로 하는데
  location / {
    proxy_pass http://localhost:5000/;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection 'upgrade';
    proxy_set_header Host $host;
    proxy_set_header X-Origin $http_origin;
    proxy_cache_bypass $http_upgrade;
  }

  # api 면 api 서버로 (이게 더 우선권을 가짐)
  location ^~ /api/ {
    proxy_pass http://localhost:8080/api/;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection 'upgrade';
    proxy_set_header Host $host;
    proxy_set_header X-Origin $http_origin;
    proxy_cache_bypass $http_upgrade;
  }

  # static 파일은 Node 보다 nginx 로 제공하는게 성능이 좋음
  # https://stackoverflow.com/questions/9967887/node-js-itself-or-nginx-frontend-for-serving-static-files
  location ^~ /static/ {
    alias /home/ubuntu/react-ssr/build/static/;
  }
}

3. 코드 스플리팅

코드 스플리팅은 아직까진 서버사이드 렌더링과 함께 한다면 react-loadable 을 사용하는게 가장 좋습니다, 리액트의 lazy 함수가 서버사이드 까지 지원될 때 까지는요!

react-loadable 를 쓰면서 서버 사이드 렌더링을 하게 되는 경우엔 우선 babel 설정을 해줘야합니다.

이는 package.json 에서 이뤄져있습니다.

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

그리고, webpack.config.prod.js 에서는 ReactLoadablePlugin 이 적용됩니다.

const ReactLoadablePlugin = require('react-loadable/webpack')
  .ReactLoadablePlugin;

...
 new ReactLoadablePlugin({
      filename: './build/react-loadable.json'
    })

이를 통하여 각 청크들에서 어떤 모듈을 사용하는지 알 수있는 파일이 생성됩니다.

예시: https://gist.github.com/velopert/c6753ee23a709b8ca1fce425ae64036e

그리고나서, 서버쪽에서는 서버가 시작 될 때 우선 스플리팅된 파일들을 모두 불러오도록 하고:

(참고)

// 스플리팅된 코드들을 모두 불러오고 난 다음에 서버 가동
Loadable.preloadAll().then(() => {
  app.listen(4000, () => {
    console.log('Running on http://localhost:4000/');
  });
});

서버사이드 렌더링 함수에는 Loadable.Capture 컴포넌트를 통해서 렌더링 되는 과정에서 어떤 모듈들이 로딩 됐는지 알 수 있습니다.

const modules = [];
const rootHtml = ReactDOMServer.renderToString(
  <Loadable.Capture report={moduleName => modules.push(moduleName)}>
    <Provider store={store}>
      <StaticRouter location={req.url}>
        <App />
      </StaticRouter>
    </Provider>
  </Loadable.Capture>
);

여기서 가져온 modules 는 이런 형태를 가지고있습니다:

[ './pages/ArticlesPage' ]

그 다음 하는 작업은 이 배열과 우리가 ReactLoadablePlugin 를 통해서 만든 파일과 비교하여 현재 어떤 파일들을 불러와야 하는지 알아내는 것 입니다.

import { getBundles } from 'react-loadable/webpack';
import stats from '../build/react-loadable.json';

// ...

const bundles = getBundles(stats, modules);

여기서 bundles 는 이런 형태를 가지고있습니다.


{ id: 26,
  name: './src/pages/AboutPage.js',
  file: 'static/css/2.918a5411.chunk.css',
  publicPath: '/static/css/2.918a5411.chunk.css' },
{ id: 26,
  name: './src/pages/AboutPage.js',
  file: 'static/js/2.a2a093b9.chunk.js',
  publicPath: '/static/js/2.a2a093b9.chunk.js' },

그럼 우리는 이 데이터를 토대로 html 에 포함시키죠. 이는 replace 로 구현합니다.

function createPage(rootHtml, bundles, state) {
  let html = indexHtml;

  // 스플리팅된 자바스크립트
  const chunkScripts = bundles
    .filter(bundle => bundle.file.match(/.js$/))
    .map(bundle => `<script src="${bundle.publicPath}"></script>`)
    .join('\n');

  // 스플리팅된 스타일
  const chunkStyles = bundles
    .filter(bundle => bundle.file.match(/.css$/))
    .map(bundle => `<link href="${bundle.publicPath}" rel="stylesheet">`)
    .join('\n');

  // 렌더링 결과를 root 안에 집어넣기
  html = html.replace(
    '<div id="root"></div>',
    `<div id="root">${rootHtml}</div>`
  );

  // 메타 태그 설정 (+ 스플리팅된 스타일 로딩)
  html = html.replace('</head>', `${chunkStyles}</head>`);

  // 커스텀 스크립트 정의
  const customScripts = `<script>
    window.ssr = true;
  </script>`;

  // 커스텀스크립트 적용 + 스플리팅된 스크립트 로딩
  const mainScript = html.match(
    /<script src="\/static\/js\/main..*.chunk.js"><\/script>/
  )[0];
  html = html.replace(
    mainScript,
    `${customScripts}${chunkScripts}${mainScript}`
  );

  return html;
}

이렇게 스타일은 스타일로 필터링, 자바스크립트는 자바스크립트로 필터링하고 태그형태로 변환한다음에 필요한 곳에 삽입해주면 코드 스플리팅이 제대로 작동하게 됩니다.

4. 데이터 로딩

이 프로젝트에서는 redux-thunk 를 사용하여 데이터를 로딩했습니다. 꼭 redux-thunk 가 아니여도, redux-pender, redux-promise-middleware 같은걸로도 동일한 방식으로 구현 가능합니다.

API 는 따로 만들지 않고, 마치 API 처럼 작동하는 비동기 함수들을 fakeApi.js에 만들었습니다:

const delay = ms => new Promise(resolve => setTimeout(resolve, ms));

const fakeData = [
  {
    id: 1,
    title: 'hello',
    body: 'hello, hello, hello!'
  },
  {
    id: 2,
    title: 'world',
    body: 'what a beautiful world'
  },
  {
    id: 3,
    title: 'hello ssr!',
    body: 'ssr? ssr!'
  },
  {
    id: 4,
    title: 'hello code splitting!',
    body: 'code splitting + ssr = complex'
  }
];

export const getArticles = async () => {
  console.log('getting articles');
  await delay(1000);
  return fakeData.map(article => ({ id: article.id, title: article.title }));
};

export const getArticle = async id => {
  console.log('getting article', id);
  await delay(400);
  return fakeData.find(article => article.id === id);
};

그리고 이런 유틸 함수를 만들었구요:

/**
 * creates thunk from promiseCreator
 * @param {string} actionType
 * @param {() => Promise<*>} promiseCreator
 */
export default function createPromiseThunk(actionType, promiseCreator) {
  return (...params) => {
    return async dispatch => {
      // promise begins
      dispatch({ type: `${actionType}_PENDING` });
      try {
        const response = await promiseCreator(...params);
        dispatch({
          type: `${actionType}_SUCCESS`,
          payload: response
        });
        return response;
      } catch (e) {
        dispatch({
          type: `${actionType}_ERROR`,
          payload: e
        });
        throw e;
      }
    };
  };
}

이를 통하여 리덕스 모듈도 구현되었습니다:

import { handleActions } from 'redux-actions';
import * as fakeApi from '../lib/fakeApi';
import createPromiseThunk from '../lib/createPromiseThunk';

const GET_ARTICLES = 'articles/GET_ARTICLES';
const GET_ARTICLES_SUCCESS = 'articles/GET_ARTICLES_SUCCESS';

const GET_ARTICLE = 'articles/GET_ARTICLE';
const GET_ARTICLE_SUCCESS = 'articles/GET_ARTICLE_SUCCESS';

export const getArticles = createPromiseThunk(
  GET_ARTICLES,
  fakeApi.getArticles
);
export const getArticle = createPromiseThunk(GET_ARTICLE, fakeApi.getArticle);

const initialState = {
  list: null,
  item: null
};

export default handleActions(
  {
    [GET_ARTICLES_SUCCESS]: (state, action) => ({
      ...state,
      list: action.payload
    }),
    [GET_ARTICLE_SUCCESS]: (state, action) => ({
      ...state,
      item: action.payload
    })
  },
  initialState
);

컨테이너 컴포넌트를 개발 할 땐 우선 서버쪽에서 사용하는것을 고려하지 않고 그냥 평상시처럼 개발을 하고:

prefetchConfig.js 라는 파일을 만들어서 여기서 특정 라우트에서 어떤 액션을 발생시킬지 정리합니다.

import * as articleActions from './modules/articles';
import { bindActionCreators } from 'redux';

const prefetchConfig = [
  {
    path: '/articles',
    prefetch: store => {
      const ArticleActions = bindActionCreators(articleActions, store.dispatch);
      return ArticleActions.getArticles();
    }
  },
  {
    path: '/articles/:articleId',
    prefetch: (store, params) => {
      const ArticleActions = bindActionCreators(articleActions, store.dispatch);
      return ArticleActions.getArticle(parseInt(params.articleId));
    }
  }
];

export default prefetchConfig;

만약에 프로미스가 여러개라면 Promise.all() 로 감싸면 됩니다. 여기서 prefetch 함수는 store 와 params 를 파라미터로 받아서 사용합니다.

그리고 서버쪽에서 사용 할 땐 이렇게 씁니다:

import { StaticRouter, matchPath } from 'react-router';
//...
// 스토어 생성
const store = createStore(rootReducer, applyMiddleware(thunk));

// 데이터 미리 불러오기
const promises = [];
prefetchConfig.forEach(route => {
  const match = matchPath(req.path, route);
  if (match) {
    const p = route.prefetch(store, match.params);
    promises.push(p);
  }
});

try {
  await Promise.all(promises);
} catch (e) {}

// Loadable.Capture 는 렌더링 과정에서 어떤 컴포넌트들이 사요되었는지 트래킹함
const modules = [];
const rootHtml = ReactDOMServer.renderToString(
  <Loadable.Capture report={moduleName => modules.push(moduleName)}>
    <Provider store={store}>
      <StaticRouter location={req.url}>
        <App />
      </StaticRouter>
    </Provider>
  </Loadable.Capture>
);

const state = store.getState();

여기서 사용된 matchPath 는 현재 주소와 라우트 경로와 비교를 해서 만약에 일치를 하면 match 객체를 생성해줍니다 (리액트 라우터에서 props 로 받아오게 되는 그 match 랑 동일합니다). 그럼, 주소 안에 파라미터가 있는 경우엔 params 를 받아올 수 있습니다.

현재 경로와 우리가 설정한 경로가 일치한다면, 해당 객체에 있는 prefetch 함수를 호출하도록 합니다. 해당 함수는 Promise 를 리턴하니 이를 Promise.all 로 감싸서 모든 작업이 끝날 때 까지 기다립니다.

그리고나서 페이지를 만들땐 이렇게 커스텀 스크립트 쪽에 현재 리덕스 상태를 변수로 설정합니다.

function createPage(rootHtml, bundles, state) {
  let html = indexHtml;

  // 스플리팅된 자바스크립트
  const chunkScripts = bundles
    .filter(bundle => bundle.file.match(/.js$/))
    .map(bundle => `<script src="${bundle.publicPath}"></script>`)
    .join('\n');

  // 스플리팅된 스타일
  const chunkStyles = bundles
    .filter(bundle => bundle.file.match(/.css$/))
    .map(bundle => `<link href="${bundle.publicPath}" rel="stylesheet">`)
    .join('\n');

  // 렌더링 결과를 root 안에 집어넣기
  html = html.replace(
    '<div id="root"></div>',
    `<div id="root">${rootHtml}</div>`
  );

  // 메타 태그 설정 (+ 스플리팅된 스타일 로딩)
  html = html.replace('</head>', `${chunkStyles}</head>`);

  // 커스텀 스크립트 정의
  const customScripts = `<script>
    window.ssr = true;
    window.__PRELOADED_STATE__ = ${JSON.stringify(state).replace(
      /</g,
      '\\u003c'
    )};
    window.shouldCancel = true;
  </script>`;

  // 커스텀스크립트 적용 + 스플리팅된 스크립트 로딩
  const mainScript = html.match(
    /<script src="\/static\/js\/main..*.chunk.js"><\/script>/
  )[0];
  html = html.replace(
    mainScript,
    `${customScripts}${chunkScripts}${mainScript}`
  );

  return html;
}

여기서 replace(/</g, '\\u003c') 의 용도는 악성 스크립트 삽입 방지이다.

이 작업이 완료되면 프로젝트가 서버사이드 렌더링 될 시 __PRELOADED_STATE__ 가 선언된 상태로 페이지가 로딩되는데, 리액트 단에서는 이 값을 리덕스 기본 값으로 사용하면 됩니다.

const store = createStore(
  rootReducer,
  window.__PRELOADED_STATE__,
  composeEnhancers(applyMiddleware(Thunk))
);

그리고, 서버사이드 렌더링을 하게 될 땐 ReactDOM.render 이 아닌 hydrate 를 합니다.

const app = (
  <Provider store={store}>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </Provider>
);
const rootElement = document.getElementById('root');

if (window.ssr) {
  ReactDOM.hydrate(app, rootElement);
} else {
  ReactDOM.render(app, rootElement);
}

hydrate 는 렌더링 결과를 다시 렌더링하는것이 아니라, 기존에 렌더링된 결과에 이벤트만 붙여줍니다.

서버사이드 렌더링을 하실 때 정말 주의 할 점은 서버쪽에서 만드는 결과물과 클라이언트쪽에서 만드는 결과물이 동일해야 한다는 것 입니다.

만약에 동일하지 않으면 페이지가 깨질 수가 있습니다. 때문에, 예를 들어서 페이지의 width 에 따라 다른것을 렌더링해야 하는 경우에는 DOM 구조가 흐트러지지 않도록 자바스크립트 단에서 관리를 하는게 아니라 CSS 를 사용하여 관리하는것이 더욱 좋습니다.

5. 데이터가 이미 있을때 중복 API 요청 방지

이 부분도 꽤 중요한 부분인데요, 만약에 서버사이드 렌더링을 통하여 이미 데이터를 받아온 상태라면 클라이언트에서 초기 요청을 하는것을 방지시켜주어야 합니다. 이 작업을 하지 않으면 불필요한 트래픽과 로딩이 발생 할 수 있습니다. 혹은, 깜박임 현상이 발생 할 수 있습니다. (페이지가 렌더링 될 때 처음엔 데이터가 있는 상태인데 재요청시 초기화되서 해당 요청이 완료될 때 까지 공백이 뜨는 현상)

이를 방지 하기위해선 단순히 특정 값이 null 일때만 요청을 시작하도록 해도 되지만:

  componentDidMount() {
    if (this.props.list !== null) return;
    this.props.getArticles();
  }

그 대신에 앱내 전역적으로 초기 요청을 취소할지말지 정하는 것도 좋은 방안입니다.

  componentDidMount() {
    if (window.shouldCancel) return;
    this.props.getArticles();
  }

근데, 이 값은 페이지를 라우팅 할 때는 값이 false 로 바뀌어야겠죠?

이 작업은 RouterListener 컴포넌트를 만들어서 다른 페이지로 한번이라도 이동을 하면 값을 false 로 바꾸게끔 작업을 수행 할 수 있습니다.

import React from 'react';
import { withRouter } from 'react-router-dom';

class RouterListener extends React.Component {
  componentDidMount() {
    const { history } = this.props;
    this.unlisten = history.listen(this.handleChange);
  }

  handleChange = location => {
    console.log('Route has changed!');
    console.log(location);
    window.shouldCancel = false;
  };

  componentWillUnmount() {
    this.unlisten();
  }

  render() {
    return null;
  }
}

export default withRouter(RouterListener);

만들어서 App 에 렌더링하면 됩니다.

정리

코드 스플리팅동하고, 서버사이드 렌더링도하고, 데이터도 미리 불러오는 작업은 정말 번거로운 작업입니다. 하지만 원리를 이해하고 나면, 추후 구현이 필요 할 때 이 예시 프로젝트를 참고하여 구현하시면 충분히 구현 할 수 있을 것입니다.

만약에 이 작업이 너무 불편하면 Next.js 를 사용 할 수 있습니다. 하지만 Next.js 의 단점은 라우터가 react-router 가 아니고, 프로젝트 구조도 Next.js 가 의도한대로 만들어야만 합니다. 자유롭질 못합니다.

또 다른 대안은 Razzle입니다. 이건 조금 더 자유로운 구조로 사용 할 수 있습니다. 다만 우리가 직접 볼 수 없는 라이브러리단에서 이뤄지는게 너무 많아 커스터마이징 하기 힘들 수도 있습니다.

results matching ""

    No results matching ""