React 작업의 순서(3): 코드 스플리팅, 서버사이드렌더링, 회원 인증 등

리린·2021년 8월 23일
0

React

목록 보기
17/47

코드 스플리팅

  • 정의: 빌드시 모든 게 한꺼번에 불러와지지 않도록 웹팩을 가져와 나누는 것
  1. 빌드시 기본 탑재된 SplitChunks 기능의 문제점:
    효율적인 캐싱 효과만 있음
    a 페이지 방문시 b, c 컴포넌트 정보까지 전부 저장되는 약점

  2. 해결방법: 코드 비동기 로딩(처음에는 불러오지 않지만, 필요한 시점에 불러와서 사용하기)

자바스크립트 함수 비동기 로딩

(notfy.js)

export default function notify(){
	alert('안녕하세요')
   }
  1. 비동기 로딩 x 케이스
    (app.js)
  • import 를 먼저 하고
  • 뒤에서 불러오기
import logo from './logo.svg';
import './App.css';
import notify from './notify';

function App() {
  const onClick = () => {
    notify();
  };
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p onClick={onClick}>Hello React!</p>
      </header>
    </div>
  );
}

export default App;
  
  1. 비동기 로딩 o 케이스
  • import('모듈주소').then(result => result.default());
    (src/App.js)
import logo from './logo.svg';
import './App.css';

function App() {
  const onClick = () => {
  import('./notify').then(result => result.default());
  };
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p onClick={onClick}>Hello React!</p>
      </header>
    </div>
  );
}

export default App;
  
  • 효과: 프로미스를 반환한다.
  • 지원: 웹팩에서 지원하고 있으므로 별도 설정 없이 프로젝트에 바로 적용 가능

코드 스플리팅: React.lazy와 Suspense 를 사용

  • React.lazy: 컴포넌트를 렌더링하는 시점에서 비동기적으로 로딩할 수 있게 해주는 유틸 함수( 맨 위에 사용 가능)

  • 사용할 컴포넌트
    (SplitMe.js )

const SplitMe = () => {
  return <div>SplitMe</div>;
};

export default SplitMe;
  1. 모듈 가져오기
import { Suspense } from 'react';
  1. React.lazy 사용(React에 내장되어 있으므로 다른 모듈 불러올 필요 없다)
const SplitMe = React.lazy(() => import('./SplitMe'));
  1. 로딩이 끝나지 않았을 때 보여줄 ui 설정
import { Suspense } from 'react';

(...)
<Suspense fallback={<div>loading...</div>}>
  <SplitMe />
</Suspense>

3.app.js 전체 코드
1) 자바스크립트 비동기 로딩으로 가져옴 + React.lazy 사용
2) fallback 함수 설정하여 아직 못가져왔을 때 보여줄 jsx 설정
3) visible 이 true 시 컴포넌트 렌더링될 수 있도록 작성

import { useState, Suspense } from 'react';
import logo from './logo.svg';
import './App.css';
const SplitMe = React.lazy(() => import('./SplitMe'));

function App() {
  const [visible, setVisible] = useState(false);
  const onClick = () => {
    setVisible(true);
  };
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p onClick={onClick}>Hello React!</p>
        <Suspense fallback={<div>loading...</div>}>
          {visible && <SplitMe />}
        </Suspense>
      </header>
    </div>
  );
}

export default App;

코드 스플리팅: loadable Components 사용

  • 정의: 코드 스플리팅을 편하게 해주는 서드파티 라이브러리. 서버사이드 렌더링도 사용 가능한 모듈
  • 서버사이드 렌더링을 할 경우 loadable Components 로 사용하도록 공식 문서에서 권장 중.
  1. 모듈 설치
yarn add @loadable/component 
  1. 모듈 불러오기 & 모듈 적용하며 다른 모듈 불러오기
import loadable from '@loadable/component';
const SplitMe = loadable(() => import('./SplitMe'));
  1. (로딩 중 다른 ui 보여주고 싶을 경우)
const SplitMe = loadable(() => import('./SplitMe'), {
  fallback: <div>loading...</div>
});
  1. (마우스를 올리기만 해도 로딩시키고 싶을 경우 )
const onMouseOver = () => {
    SplitMe.preload();
  };
  1. app.js 작성
import React, { useState } from 'react';
import logo from './logo.svg';
import './App.css';
import loadable from '@loadable/component';
const SplitMe = loadable(() => import('./SplitMe'), {
  fallback: <div>loading...</div>
});

function App() {
  const [visible, setVisible] = useState(false);
  const onClick = () => {
    setVisible(true);
  };
  const onMouseOver = () => {
    SplitMe.preload();
  };
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p onClick={onClick} onMouseOver={onMouseOver}>
          Hello React!
        </p>
        {visible && <SplitMe />}
      </header>
    </div>
  );
}

export default App;

서버사이드 렌더링

  • 정의: ui를 서버에서 렌더링하는 것

  • 목적: 검색 엔진이 웹의 페이지를 수집할 수 있도록 하는 것

  • 단점:
    1) 서버 리소스가 사용됨 ->캐싱과 로드밸런싱으로 성능 최적화 필요
    2) 프로젝트의 구조가 복잡해질 수 있음
    3) 데이터 미리 불러오기 / 코드 스플리팅 호환 등 개발이 어려워질 수 있음

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

  • 해결방법: Loadable Components 사용. 라우트 경로마다 코드 스플리팅된 파일 중 필요한 모든 파일을 브라우저에서 렌더링하기 전에 미리 불러와야 함 (필요한 파일의 경로를 추출하여 렌더링 결과에 스크립트/스타일링 태그를 삽입)

  1. 컴포넌트/페이지 컴포넌트 만들기

  2. 1을 app.js에 구동하기

import { Route } from 'react-router-dom';
import Menu from './components/Menu';
import RedPage from './pages/RedPage';
import BluePage from './pages/BluePage';

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

export default App;
  1. index.js에서 app.js를 불러와 라우터로 감싸기
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import { BrowserRouter } from 'react-router-dom';

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

----------------(서버사이드 렌더링 본격 구현하기) -----
4. 깃허브 추가 및 웹팩 설정 꺼내기

git add . 
git commit -m'Commit before eject'
yarn eject 
  1. 서버사이드 렌더링용 엔트리 만들기
  • 역할: 서버를 위한 엔트리 파일.
    (src/index.server.js)
import ReactDOMServer from 'react-dom/server';

const html = ReactDOMServer.renderToString(
  <div>Hello Server Side Rendering!</div>
);

console.log(html);
  1. 서버사이드 렌더링 전용 웹팩 환경 설정하기
  • config/path.js 수정
    하단에 다음과 같이 추가
ssrIndexJs: resolveApp('src/index.server.js'), // 서버 사이드 렌더링 엔트리 파일의 경로.
  ssrBuild: resolveApp('dist') // 웹팩 처리 후 결과물 저장 경로
  publicUrlOrPath,

(전체파일)

(...)
module.exports = {
  dotenv: resolveApp('.env'),
  appPath: resolveApp('.'),
  appBuild: resolveApp('build'),
  appPublic: resolveApp('public'),
  appHtml: resolveApp('public/index.html'),
  appIndexJs: resolveModule(resolveApp, 'src/index'),
  appPackageJson: resolveApp('package.json'),
  appSrc: resolveApp('src'),
  appTsConfig: resolveApp('tsconfig.json'),
  yarnLockFile: resolveApp('yarn.lock'),
  testsSetup: resolveModule(resolveApp, 'src/setupTests'),
  proxySetup: resolveApp('src/setupProxy.js'),
  appNodeModules: resolveApp('node_modules'),
  swSrc: resolveModule(resolveApp, 'src/service-worker'),
  ssrIndexJs: resolveApp('src/index.server.js'), // 서버 사이드 렌더링 엔트리
  ssrBuild: resolveApp('dist') // 웹팩 처리 후 저장 경로
  publicUrlOrPath,
};

module.exports.moduleFileExtensions = moduleFileExtensions;
  1. 웹팩 환경설정파일 작성
    (config/webpack.config.server.js)
const paths = require('./paths');

module.exports = {
  mode: 'production', // 프로덕션 모드로 설정하여 최적화 옵션들을 활성화
  entry: paths.ssrIndexJs, // 엔트리 경로
  target: 'node', // node 환경에서 실행될 것이라는 점을 명시
  output: {
    path: paths.ssrBuild, // 빌드 경로
    filename: 'server.js', // 파일 이름
    chunkFilename: 'js/[name].chunk.js', // 청크 파일 이름
    publicPath: paths.publicUrlOrPath, // 정적 파일이 제공될 경로

  }
};
  1. 웹팩의 로더 작성
  • babel: 트랜스파일링
  • css : 모든 css코드를 결합
  • 이미지 파일: 파일을 다른 경로에 저장하고 그 경로를 js에서 참조할 수 있게 해줌
  • (css나 이미지 파일은 그다지 중요하지 않으나 무시할 수도 없다)
  • (로더에서 별도로 설정하여 처리하지만 따로 결과물에 포함되지 않도록 구현하기)
  • config/webpack.config.server.js 구현하기
const paths = require('./paths');
const getCSSModuleLocalIdent = require('react-dev-utils/getCSSModuleLocalIdent');

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', // node 환경에서 실행될 것이라는 점을 명시
  output: {
    path: paths.ssrBuild, // 빌드 경로
    filename: 'server.js', // 파일 이름
    chunkFilename: 'js/[name].chunk.js', // 청크 파일 이름
    publicPath: paths.publicUrlOrPath, // 정적 파일이 제공될 경로
  },
  module: {
    rules: [
      {
        oneOf: [
          // 자바스크립트를 위한 처리
          // 기존 webpack.config.js 를 참고하여 작성
          {
            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']
  }
};
  • resolve~ 설정으로 인해 라이브러리를 import 구문으로 불러오면 node_modules에서 찾아 사용된다. 또한 빌드할 때 결과물 파일 안에 해당 라이브러리 관련 코드가 함께 번들링된다.
  • 브라우저: 결과물 파일에 리액트 라이브러리와 애플리케이션에 관한 코드가 공존한다.
  • 서버: 결과물 파일에 리액트 라이브러리가 들어있을 필요 없다(node_module을 통해 불러와 사용 가능하므로)
    즉, 서버를 위해 번들링할 때는 node_modules에서 불러오는 것을 제외하고 번들링해야 한다.
  1. 모듈 설치하기
yarn add webpack-node-externals 

10 . 모듈 적용하기

const nodeExternals = require('webpack-node-externals');
(...)

module.exports = {
  (...)
  resolve: {
    modules: ['node_modules']
  },
  externals: [nodeExternals()]
};
  1. 환경변수 주입하기
const nodeExternals = require('webpack-node-externals');
const paths = require('./paths');
const getCSSModuleLocalIdent = require('react-dev-utils/getCSSModuleLocalIdent');
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 = {
  (...)
  resolve: {
    modules: ['node_modules'],
  },
  externals: [
    nodeExternals({
      allowlist: [/@babel/],
    }),
  ],
}
  1. 빌드 스크립트 작성하기
    (build.js -클라이언트에서 사용할 빌드 파일을 만듬)
    (이와 비슷하게 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();
  1. 빌드 명령어 실행하기
node scripts/build.server.js
  1. 정상 실행 확인 명령어 실행하기 2
node dist/server.js
  1. package.json 에 추가하기
"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"
  },
  1. express 모듈 설치
yarn add express 
  1. 서버 코드 작성하기
    (index.server.js)
import ReactDOMServer from 'react-dom/server';
import express from 'express';
import { StaticRouter } from 'react-router-dom';
import App from './App';

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

  const context = {};
  const jsx = (
    <StaticRouter location={req.url} context={context}>
      <App />
    </StaticRouter>
  );
  const root = ReactDOMServer.renderToString(jsx); // 렌더링을 하고
  res.send(root); // 클라이언트에게 결과물을 응답합니다.
};

app.use(serverRender);

// 5000 포트로 서버를 가동합니다.
app.listen(5000, () => {
  console.log('Running on http://localhost:5000');
});
  • StaticRouter: 서버사이드 렌더링 용도로 사용되는 라우터. location 값에 따라 라우팅해 주며, context를 통해 렌더링한 컴포넌트에 따라 http 상태 코드를 설정해줄 수 있다.

express에 내장되어 있는 static 미들웨어 사용하여 서버를 통해 build에 있는 js, css 정적 파일에 접근하기

  1. index.server.js 에 다음과 같은 코드 추가하기
const serve = express.static(path.resolve('./build'), {
  index: false // "/" 경로에서 index.html을 보여 주지 않도록 설정
});

app.use(serve); // 순서가 중요합니다. serverRender 전에 위치해야 합니다.
app.use(serverRender);
  1. yarn build 명령어 실행 뒤 파일 열기
    (build/asset-manifest.json)
{
  "files": {
    "main.css": "/static/css/main.b5fbe955.chunk.css",
    "main.js": "/static/js/main.4706fa80.chunk.js",
    "main.js.map": "/static/js/main.4706fa80.chunk.js.map",
    "runtime-main.js": "/static/js/runtime-main.c5541365.js",
    "runtime-main.js.map": "/static/js/runtime-main.c5541365.js.map",
    "static/js/2.7980f885.chunk.js": "/static/js/2.7980f885.chunk.js",
    "static/js/2.7980f885.chunk.js.map": "/static/js/2.7980f885.chunk.js.map",
    "index.html": "/index.html",
    "precache-manifest.b4b36e28ec1848575e2868cbda9aa15b.js": "/precache-manifest.b4b36e28ec1848575e2868cbda9aa15b.js",
    "service-worker.js": "/service-worker.js",
    "static/css/main.b5fbe955.chunk.css.map": "/static/css/main.b5fbe955.chunk.css.map"
  }  
}
  • 위에서 main.css, main.js, runtime-main.js, static/js/2.780f885.chunk.js 를 html 내부에 삽입해 주어야 한다.
    (index.server.js 를 다음과 같이 수정)
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) {
  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 = (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');
});
  • 효과: css 파일이 적용된다. (다른 링크 클릭시 네트워크 요청 추가 발생x)

데이터 로딩

  • 문제상황: 일반 클라이언트측에서는 api 요청 및 응답시 자동 리렌더링 되지만, 서버에서는 그딴 거 없다.
    해결방법: redux-thunk로 api 호출 환경에서 서버사이드 렌더링 하기
  1. redux-thunk 코드 준비하기
import axios from 'axios';

const GET_USERS_PENDING = 'users/GET_USERS_PENDING';
const GET_USERS_SUCCESS = 'users/GET_USERS_SUCCESS';
const GET_USERS_FAILURE = 'users/GET_USERS_FAILURE';

const getUsersPending = () => ({ type: GET_USERS_PENDING });
const getUsersSuccess = payload => ({ type: GET_USERS_SUCCESS, payload });
const getUsersFailure = payload => ({
  type: GET_USERS_FAILURE,
  error: true,
  payload
});

export const getUsers = () => async dispatch => {
  try {
    dispatch(getUsersPending());
    const response = await axios.get(
      'https://jsonplaceholder.typicode.com/users'
    );
    dispatch(getUsersSuccess(response));
  } catch (e) {
    dispatch(getUsersFailure(e));
    throw e;
  }
};

const initialState = {
  users: null,
  user: null,
  loading: {
    users: false,
    user: false
  },
  error: {
    users: null,
    user: null
  }
};

function users(state = initialState, action) {
  switch (action.type) {
    case GET_USERS_PENDING:
      return { ...state, loading: { ...state.loading, users: true } };
    case GET_USERS_SUCCESS:
      return {
        ...state,
        loading: { ...state.loading, users: false },
        users: action.payload.data
      };
    case GET_USERS_FAILURE:
      return {
        ...state,
        loading: { ...state.loading, users: false },
        error: { ...state.error, users: action.payload }
      };
    default:
      return state;
  }
}

export default users;
  • 여기서 getUser는 thunk 함수이다.
  1. api 응답 구조 확인하기
[
  {
    "id": 1,
    "name": "Leanne Graham",
    "username": "Bret",
    "email": "Sincere@april.biz",
    "address": {
      "street": "Kulas Light",
      "suite": "Apt. 556",
      "city": "Gwenborough",
      "zipcode": "92998-3874",
      "geo": {
        "lat": "-37.3159",
        "lng": "81.1496"
      }
    },
    "phone": "1-770-736-8031 x56442",
    "website": "hildegard.org",
    "company": {
      "name": "Romaguera-Crona",
      "catchPhrase": "Multi-layered client-server neural-net",
      "bs": "harness real-time e-markets"
    }
  },
  (...)
]
  
  1. 루트 리듀서 만들기
    (modules/index.js )
import { combineReducers } from 'redux';
import users from './users';

const rootReducer = combineReducers({ users });
export default rootReducer;
  1. 스토어 만들고 thunk와 reducer 적용하기
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import { BrowserRouter } from 'react-router-dom';
import { createStore, applyMiddleware } from 'redux';
import { Provider } from 'react-redux';
import thunk from 'redux-thunk';
import rootReducer from './modules';

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

ReactDOM.render(
  <Provider store={store}>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </Provider>,
  document.getElementById('root')
);
  1. 컨테이너 만들기
import React, { useEffect } from 'react';
import Users from '../components/Users';
import { connect } from 'react-redux';
import { getUsers } from '../modules/users';

const UsersContainer = ({ users, getUsers }) => {
  // 컴포넌트가 마운트되고 나서 호출
  useEffect(() => {
    if (users) return; // users가 이미 유효하다면 요청하지 않음
    getUsers();
  }, [getUsers, users]);
  return <Users users={users} />;
};

export default connect(
  state => ({
    users: state.users.users
  }),
  {
    getUsers
  }
)(UsersContainer);
  
  • 서버사이드 렌더링을 할 때는 이미 있는 정보를 재요청하지 않는 것이 중요하다.
  1. 페이지 만들기
    (pages/UsersPage.js)
import UsersContainer from '../containers/UsersContainer';

const UsersPage = () => {
  return <UsersContainer />;
};

export default UsersPage;
  
  1. App.js 만들기
    (App.js)
import { Route } from 'react-router-dom';
import Menu from './components/Menu';
import RedPage from './pages/RedPage';
import BluePage from './pages/BluePage';
import UsersPage from './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;
  1. components/Menu.js
import { Link } from 'react-router-dom';
const Menu = () => {
  return (
    <ul>
      <li>
        <Link to="/red">Red</Link>
      </li>
      <li>
        <Link to="/blue">Blue</Link>
      </li>
      <li>
        <Link to="/users">Users</Link>
      </li>
    </ul>
  );
};

export default Menu;
  1. PreloadContext.js 만들기
    (lib/PreloadContext.js)
    1) 모듈 가져오기
    2) createContext(null)로 PreloadContext 만들기/보내기
    3)
import { createContext, useContext } from 'react';

// 클라이언트 환경: null
// 서버 환경: { done: false, promises: [] }
const PreloadContext = createContext(null);
export default PreloadContext;

// resolve는 함수 타입입니다.
export const Preloader = ({ resolve }) => {
  const preloadContext = useContext(PreloadContext);
  if (!preloadContext) return null; // context 값이 유효하지 않다면 아무것도 하지 않음
  if (preloadContext.done) return null; // 이미 작업이 끝났다면 아무것도 하지 않음

  // promises 배열에 프로미스 등록
  // 설령 resolve 함수가 프로미스를 반환하지 않더라도, 프로미스 취급을 하기 위해
  // Promise.resolve 함수 사용
  preloadContext.promises.push(Promise.resolve(resolve()));
  return null;
};
  1. containers/UserContainer.js 에서 Preloader 사용하기
import Users from '../components/Users';
import { connect } from 'react-redux';
import { getUsers } from '../modules/users';
import { Preloader } from '../lib/PreloadContext';

const { useEffect } = React;

const UsersContainer = ({ users, getUsers }) => {
  // 컴포넌트가 마운트되고 나서 호출
  useEffect(() => {
    if (users) return; // users가 이미 유효하다면 요청하지 않음
    getUsers();
  }, [getUsers, users]);
  return (
    <>
      <Users users={users} />
      <Preloader resolve={getUsers} />
    </>
  );
};

export default connect(
  state => ({
    users: state.users.users
  }),
  {
    getUsers
  }
)(UsersContainer);
  • 컴포넌트가 렌더링될 때 서버 환경에서만 resolve 함수를 호출해 줌
  1. 서버에서 리덕스 설정 및 PreloadContext 사용하기
  • 요청이 들어올 때마다 새로운 스토어 만들기
    (index.server.js)
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';
import { createStore, applyMiddleware } from 'redux';
import { Provider } from 'react-redux';
import thunk from 'redux-thunk';
import rootReducer from './modules';

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

  const context = {};
  const store = createStore(rootReducer, applyMiddleware(thunk));
  const jsx = (
    <Provider store={store}>
      <StaticRouter location={req.url} context={context}>
        <App />
      </StaticRouter>
    </Provider>
  );
  const root = ReactDOMServer.renderToString(jsx); // 렌더링을 하고
  res.send(createPage(root)); // 결과물을 응답합니다.
};

(...)
  
  1. preloadContext 사용하기
    (index.server.js)
(...)
import PreloadContext from './lib/PreloadContext';

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

  const context = {};
  const store = createStore(rootReducer, applyMiddleware(thunk));

  const preloadContext = {
  done: false,
    promises: []
  };
  const jsx = (
    <PreloadContext.Provider value={preloadContext}>
      <Provider store={store}>
        <StaticRouter location={req.url} context={context}>
          <App />
        </StaticRouter>
      </Provider>
    </PreloadContext.Provider>
  );

  ReactDOMServer.renderToStaticMarkup(jsx); // renderToStaticMarkup으로 한번 렌더링합니다.
  try {
    await Promise.all(preloadContext.promises); // 모든 프로미스를 기다립니다.
  } catch (e) {
    return res.status(500);
  }
  preloadContext.done = true;
  const root = ReactDOMServer.renderToString(jsx); // 렌더링을 합니다.
  res.send(createPage(root)); // 결과물을 응답합니다.
};

(...)
  
  1. 스크립트로 스토어 초기 상태 주입하기
    (index.server.js)
(...)

function createPage(root, stateScript) {
  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['main.css']}" rel="stylesheet" />
  </head>
  <body>
    <noscript>You need to enable JavaScript to run this app.</noscript>
    <div id="root">
      ${root}
    </div>
    ${stateScript}
    <script src="${manifest['runtime~main.js']}"></script>
    ${chunks}
    <script src="${manifest['main.js']}"></script>
  </body>
  </html>
    `;
}
const app = express();

// 서버 사이드 렌더링을 처리할 핸들러 함수입니다.
const serverRender = async (req, res, next) => {
  (...)
  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>`; // 리덕스 초기 상태를 스크립트로 주입합니다.

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

(...)
  1. 브라우저 상태 재사용하기
  • window.PRELOADED_STATE 사용

(index.js)

import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import { BrowserRouter } from 'react-router-dom';
import { createStore, applyMiddleware } from 'redux';
import { Provider } from 'react-redux';
import thunk from 'redux-thunk';
import rootReducer from './modules';

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

ReactDOM.render(
  <Provider store={store}>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </Provider>,
  document.getElementById('root')
);
  1. 명령어 사용하기
profile
개발자지망생

0개의 댓글