상세 컨텐츠

본문 제목

[리액트를 다루는 기술] 20장 서버 사이드 렌더링

21-22/21-22 리액트 마스터

by 도리에몽 2022. 1. 31. 13:00

본문

728x90

20.1 서버 사이드 렌더링의 이해

  • 클라이언트 사이드 렌더링 : UI 렌더링을 브라우저에서 모두 처리하는 것
    ⇒ 리액트는 기본적으로 클라이언트 사이드 렌더링!
  • 서버 사이드 렌더링 : UI를 서버에서 렌더링하는 것

[서버 사이드 렌더링의 장점] 

  1. 웹 서비스의 검색 엔진 최적화
  2. 초기 렌더링 성능 개선
    자바스크립트 파일이 다운로드되지 않은 시점에도 html 상에 사용자가 볼 수 있는 콘텐츠가 있어 대기 시간 최소화, 사용자 경험 향상
    클라이언트 사이드 렌더링은 로딩되고 실행될 때까지 빈 페이지를 보며 대기해야 함

[서버 사이드 렌더링의 단점]

  1. 서버 리소스가 사용된다
    ⇒ 사용자가 동시에 몰릴 시, 서버에 과부하 발생
  2. 고려사항이 많아 개발 어려움

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

서버 사이드 렌더링과 코드 스플리팅을 함께 적용하면 페이지에 깜빡임이 발생

  • 💡 왜 생기나요?
    1. 서버 사이드 렌더링 된 결과물이 브라우저에 나타남
    2. 자바스크립트 파일 로딩 시작
    3. 자바스크립트가 실행되면서 아직 불러오지 않은 컴포넌트를 null로 렌더링함
    4. 페이지에서 코드 스플리팅 된 컴포넌트들이 사라짐
    5. 코드 스플리팅 된 컴포넌트들이 로딩된 이후 제대로 나타남

🚧 해결 방법 : 라우트 경로마다 코드 스플리팅 된 파일 중에서 필요한 모든 파일을 브라우저에서 렌더링하기 전에 미리 불러와야 함

  책에서는 ⇒ Lodable Components 라이브러리에서 제공하는 기능을 써서 서버 사이드 렌더링 후 필요한 파일의 경로를 추출하여 렌더링 결과에 스크립트/스타일 태그를 삽입해 주는 방법 사용

20.2 프로젝트 준비하기

▶코드

더보기
components/Red.js
import React from 'react';
import './Red.css';

const Red = () => {
  return <div className="Red">Red</div>;
};
export default Red;
components/Red.css
.Red{
  background:red;
  font-size:1.5rem;
  color:white;
  width:128px;
  height:128px;
  display:flex;
  align-items:center;
  justify-content:center;
}
components/Blue.js
import React from 'react';
import './Blue.css';

const Blue = () => {
  return <div className="Blue">Blue</div>;
};
export default Blue;
components/Blue.css
.Blue{
  background:blue;
  font-size:1.5rem;
  color:white;
  width:128px;
  height:128px;
  display:flex;
  align-items:center;
  justify-content:center;
}
components/Menu.js
import React from 'react';
import { Link } from 'react-router-dom';
const Menu = () => {
  return (
    <ul>
      <li>
        <Link to="/red">Red</Link>
      </li>
      <li>
        <Link to="/blue">Blue</Link>
      </li>
    </ul>
  );
};
export default Menu;
pages/RedPage.js
import React from 'react';
import Red from '../components/Red';

const RedPage = () => {
  return <Red />;
};

export default RedPage;
pages/BluePage.js
import React from 'react';
import Blue from '../components/Blue';

const BluePage = () => {
  return <Blue />;
};

export default BluePage;
App.js
import React from 'react';
import { Route } from 'react-router-dom';
import Menu from './components/Menu';
import RedPage from './pages/RedPage';
import BluePage from './pages/BluePage';

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

export default App;
index.js
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import reportWebVitals from './reportWebVitals';
import { BrowserRouter } from 'react-router-dom';

ReactDOM.render(
  <React.StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </React.StrictMode>,
  document.getElementById('root')
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

20.3 서버 사이드 렌더링 구현하기

웹팩 설정 커스터마이징 해줘야 함

  CRA(create react-app)로 만든 프로젝트는 웹팩 관련 설정이 숨겨져 있음

    ⇒ yarn eject 명령어 실행해줘야 함!

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

엔트리 : 웹팩에서 프로젝트를 불러올 때 가장 먼저 불러오는 파일

  (현재, index.js ← 엔트리 파일로 사용 중)

  서버 사이드 렌더링을 할 때는 서버를 위한 엔트리 파일을 따로 생성해야 함

    ⇒ ReactDOMServer의 renderToString 함수 사용

서버 사이드 렌더링 전용 웹팩 환경 설정 작성하기

config/path.js의 module.exports 부분에 아래 코드 추가

ssrIndexJs: resolveApp('src/index.server.js'),
ssrBuild: resolveApp('dist')
  • ssrIndexJs : 불러올 파일의 경로
  • ssrBuild : 웹팩으로 처리한 뒤 결과물을 저장할 경로

config/webpack.config.server.js

▶코드

더보기
const paths = require('./paths');
const getCSSModuleLocalIdent = require('react-dev-utils/getCSSModuleLocalIdent');
const nodeExternals = require('webpack-node-externals');
const webpack = require('webpack');
const getClientEnvironment = require('./env');

const cssRegex = /\.css$/;
const cssModuleRegex = /\.module\.css$/;
const sassRegex = /\.(scss|sass)$/;
const sassModuleRegex = /\.module\.(scss|sass)$/;

const env = getClientEnvironment(paths.publicUrlOrPath.slice(0, -1));

module.exports = {
  mode: 'production',
  entry: paths.ssrIndexJs,
  target: 'node',
  output: {
    path: paths.ssrBuild,
    filename: 'server.js',
    chunkFilename: 'js/[name].chunk.js',
    publicPath: paths.publicUrlOrPath,
  },
  module: {
    rules: [
      {
        oneOf: [
          // 자바스크립트를 위한 처리
          {
            test: /\.(js|mjs|jsx|ts|tsx)$/,
            include: paths.appSrc,
            loader: require.resolve('babel-loader'),
            options: {
              customize: require.resolve(
                'babel-preset-react-app/webpack-overrides'
              ),
              presets: [
                [
                  require.resolve('babel-preset-react-app'),
                  {
                    runtime: 'automatic',
                  },
                ],
              ],
              plugins: [
                [
                  require.resolve('babel-plugin-named-asset-import'),
                  {
                    loaderMap: {
                      svg: {
                        ReactComponent:
                          '@svgr/webpack?-svgo,+titleProp,+ref![path]',
                      },
                    },
                  },
                ],
              ],
              cacheDirectory: true,
              cacheCompression: false,
              compact: false,
            },
          },
          // CSS 를 위한 처리
          {
            test: cssRegex,
            exclude: cssModuleRegex,
            //  exportOnlyLocals: true 옵션을 설정해야 실제 css 파일을 생성하지 않습니다.
            loader: require.resolve('css-loader'),
            options: {
              importLoaders: 1,
              modules: {
                exportOnlyLocals: true,
              },
            },
          },
          // CSS Module 을 위한 처리
          {
            test: cssModuleRegex,
            loader: require.resolve('css-loader'),
            options: {
              importLoaders: 1,
              modules: {
                exportOnlyLocals: true,
                getLocalIdent: getCSSModuleLocalIdent,
              },
            },
          },
          // Sass 를 위한 처리
          {
            test: sassRegex,
            exclude: sassModuleRegex,
            use: [
              {
                loader: require.resolve('css-loader'),
                options: {
                  importLoaders: 3,
                  modules: {
                    exportOnlyLocals: true,
                  },
                },
              },
              require.resolve('sass-loader'),
            ],
          },
          // Sass + CSS Module 을 위한 처리
          {
            test: sassRegex,
            exclude: sassModuleRegex,
            use: [
              {
                loader: require.resolve('css-loader'),
                options: {
                  importLoaders: 3,
                  modules: {
                    exportOnlyLocals: true,
                    getLocalIdent: getCSSModuleLocalIdent,
                  },
                },
              },
              require.resolve('sass-loader'),
            ],
          },
          // url-loader 를 위한 설정
          {
            test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
            loader: require.resolve('url-loader'),
            options: {
              emitFile: false, // 파일을 따로 저장하지 않는 옵션
              limit: 10000, // 원래는 9.76KB가 넘어가면 파일로 저장하는데
              // emitFile 값이 false 일땐 경로만 준비하고 파일은 저장하지 않습니다.
              name: 'static/media/[name].[hash:8].[ext]',
            },
          },
          // 위에서 설정된 확장자를 제외한 파일들은
          // file-loader 를 사용합니다.
          {
            loader: require.resolve('file-loader'),
            exclude: [/\.(js|mjs|jsx|ts|tsx)$/, /\.html$/, /\.json$/],
            options: {
              emitFile: false, // 파일을 따로 저장하지 않는 옵션
              name: 'static/media/[name].[hash:8].[ext]',
            },
          },
        ],
      },
    ],
  },
  resolve: {
    modules: ['node_modules'],
  },
  externals: [nodeExternals()],
  plugins: [
    new webpack.DefinePlugin(env.stringified), // 환경변수를 주입해줍니다.
  ],
};

웹팩의 로더는 파일을 볼러올 때 확장자에 맞게 필요한 처리를 해줌

서버를 위해 번들링 할 때, node_modules에서 불러오는 것을 제외하고 번들링 하는 것이 좋음

    $ yarn add webpack-node-externals

빌드 스크립트 작성하기

scripts/build.server.js

▶코드

더보기
process.env.BABEL_ENV = 'production';
process.env.NODE_ENV = 'production';

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

require('../config/env');
const fs = require('fs-extra');
const webpack = require('webpack');
const config = require('../config/webpack.config.server');
const paths = require('../config/paths');

function build() {
  console.log('Creating server build...');
  fs.emptyDirSync(paths.ssrBuild);
  let compiler = webpack(config);
  return new Promise((resolve, reject) => {
    compiler.run((err, stats) => {
      if (err) {
        console.log(err);
        return;
      }
      console.log(stats.toString());
    });
  });
}

build();

▶빌드가 잘 되는지 확인하기

$ node scripts/build.server.js
$ node dist/server.js

  ⇒ 명령어를 더 편하게 입력하기 위해 package.json 코드 scripts 부분 수정

"scripts":{
	"start":"node scripts/start.js",
	"build":"node scripts/build.js",
	"test":"node scripts/test.js",
	"start:server":"node dist/server.js",
	"build:server":"node scripts/build.server.js"
},

  ⇒ 이제 다음 명령어로 서버 빌드 가능

$ yarn build:server
$ yarn start:server

서버 코드 작성하기

Express : Node.js 웹 프레임워크

  ⇒ Koa, Hapi, connect라이브러리 사용해서 서버 구현해도 가능

$ yarn add express
  • StaticRouter 컴포넌트 props로 넣어 주는 location 값에 따라 라우팅해 줌
    : 주로 서버 사이드 렌더링 용도로 사용되는 라우터
    props로 넣어 주는 location 값에 따라 라우팅해 줌

정적 파일 제공하기

Express에 내장된 static 미들웨어를 사용하여 서버를 통해 build에 있는 정적 파일에 접근하도록 함

매번 빌드할 때마다 불러오는 파일 이름이 바뀌므로 asset-manifest.json 파일을 참고하여 아래 파일들을 html 내부에 삽입해주어야 함

  • main.css
  • main.js
  • runtime~main.js
  • static/js/2.7980f885.chunk.js

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 fs from 'fs';

// asset-manifest.json에서 파일 경로들을 조회합니다.
const manifest = JSON.parse(
  fs.readFileSync(path.resolve('./build/asset-manifest.json'), 'utf8')
);

const chunks = Object.keys(manifest.files)
  .filter(key => /chunk\.js$/.exec(key)) // chunk.js로 끝나는 키를 찾아서
  .map(key => `<script src="${manifest.files[key]}"></script>`) // 스크립트 태그로 변환하고
  .join(''); // 합침

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>
      <link href="${manifest.files['main.css']}" rel="stylesheet" />
    </head>
    <body>
      <noscript>You need to enable JavaScript to run this app.</noscript>
      <div id="root">
        ${root}
      </div>
      <script src="${manifest.files['runtime-main.js']}"></script>
      ${chunks}
      <script src="${manifest.files['main.js']}"></script>
    </body>
    </html>
      `;
}

const app = express();

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

  const context = {};

  const jsx = (
    <StaticRouter location={req.url} context={context}>
      <App />
    </StaticRouter>
  );

  const root = ReactDOMServer.renderToString(jsx); // 렌더링을 하고
  res.send(createPage(root)); // 클라이언트에게 결과물을 응답합니다.
};

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');
});

💡서버 사이드 렌더링 구현 시, 첫 번째 렌더링은 서버를 통해 이루어지지만, 이후에는 브라우저에서 처리함

20.4 데이터 로딩

데이터 로딩 = API 요청

일반적인 브라우저 환경에선, API 요청하면 응답을 받아와 state 혹은 리덕스 스토어에 넣으면 자동 리렌더링 됨

서버의 경우, 문자열 형태로 렌더링하는 것이므로 state나 리덕스 스토어의 상태가 바뀐다고 해도 자동으로 리렌더링 되지 않음

    ⇒ renderToString 함수 한 번 더 호출해줘야 함

🚧 해결 방법

  1. redux-thunk
  2. redux-saga

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

리액트에서 공식 제공하는 코드 스플리팅 기능(React.lazy, Suspense) ⇒ 서버 사이드 렌더링 지원 X

  서버 사이드 렌더링+코드 스플리팅 시, Loadable Components 사용 권장

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

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

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 선택

웹팩과 babel 플러그인 적용

Loadable Components에서 제공하는 웹팩과 babel 플러그인을 적용하면 깜박임 현상 해결 가능!

▶코드

더보기
package.json
"babel": {
    "presets": [
      "react-app"
    ],
    "plugins": [
      "@loadable/babel-plugin"
    ]
  }
webpack.config.js
const LoadablePlugin = require('@loadable/webpack-plugin');

	plugins:[
	new LoadablePlugin(),
	new HTMLWebpackPlugin(
].filter(Boolean),

 

 

build/loadable-stats.json 파일 생성되었는지 확인

  ⇒ 각 컴포넌트의 코드가 어떤 청크 파일에 들어가 있는지에 대한 정보 가지고 있음

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

Loadable Components에서 제공하는 ChunkExtractorChunkExtractorManager 사용

▶코드

더보기
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/PreloadContext';
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');
});
 

loadableReady와 hydrate

Loadable Components는 성능 최적화를 위해 모든 자바스크립트 파일을 동시에 받아 옴

모든 스크립트가 로딩되고 나서 렌더링하기 위해서는 loadableReady 함수 사용해줘야 함!

  ⇒ 렌더링 된 결과물 이미 존재 시, 기존에 존재하는 UI에 이벤트만 연동하여 성능 최적화

index.js

import {loadableReady} from '@loadable/component';

const sagaMiddleware = createSagaMiddleware();
const store = createStore(
  rootReducer,
  window._ _PRELOADEDSTATE _, // 이 값을 초기 상태로 사용함
  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);
}

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

  1. Next.js
    😍 : 작업을 최소한의 설정으로 간단하게 처리 가능!
           복잡한 작업은 모두 Next.js가 대신해 줌!
    😐 : 리액트 라우터와 호환이 되지 않음
           리액트 라우터는 컴포넌트 기반인 반면, Next.js는 파일 시스템에 기반하여 라우트를 설정함
           모두 대신해주기 때문에 실제 작동 원리 파악은 힘듦
  2. Razzle
    😍 : 프로젝트 구성이 CRA와 매우 유사함
           리액트 라우터와도 잘 호환됨
    😐 : 코드 스플리팅 시 발생하는 깜박임 현상 해결하기 어려움
728x90

관련글 더보기