happyso
study with happyso
happyso
전체 방문자
오늘
어제
  • 분류 전체보기 (302)
    • GIT (3)
    • 컴퓨터 기본 개념 (29)
    • 알고리즘 (125)
      • 알고리즘 문제 (115)
      • 알고리즘 개념 (10)
    • Go (2)
    • 클라우드 (54)
      • DevOps (4)
      • Kubernetes(쿠버네티스) (33)
      • AWS (6)
      • CKA (8)
    • 리눅스(Linux) (18)
      • 컨테이너(Container) (8)
    • Front (22)
      • JavaScript (2)
      • React (20)
    • Python (21)
      • Python 웹 크롤링 (11)
      • Django (7)
      • MachineLearning (3)
    • 데이터베이스 (6)
      • MariaDB (2)
      • MongoDB (4)
    • C언어 (5)
    • Trouble Shooting (2)
    • 네트워크 (8)
      • CCNA (5)
    • 보안 (1)

블로그 메뉴

  • 홈
  • 태그
  • 방명록

공지사항

인기 글

태그

  • Patch
  • 18
  • 15
  • apply
  • edit
  • replace
  • kubernetes

최근 댓글

최근 글

티스토리

hELLO · Designed By 정상우.
happyso

study with happyso

[React] 서버 사이드 렌더링
Front/React

[React] 서버 사이드 렌더링

2021. 1. 24. 17:57

1. 서버 사이드 렌더링의 이해

  • UI를 서버에서 렌더링하는 것
  • CRA로 프로젝트를 생성하고 개발 서버를 실행해 본다.

$ yarn create react-app ssr-recipe

$ cd ssr-recipe

$ yarn start

 

root 엘리먼트가 비어있다.

즉, 이 페이지는 처음에 빈 페이지

그 이후에 자바스크립트가 실행되고 리액트 컴포넌트가 렌더링되면서 우리에게 보이는 것

 

서버사이드 렌더링을 구현하면 사용자가 웹 서비스에 방문했을 때 서버 쪽에서 초기 렌더링을 대신해 준다.

그리고 사용자가 html을 전달받을 때 그 내부에 렌더링된 결과물이 보인다.

 

1.1. 서버 사이드 렌더링의 장점

  • 구글, 네이버, 다음 등의 검색 엔진이 우리가 만든 웹 애플리케이션의 페이지를 원활하게 수집할 수 있다.
    • 리액트로 만든 SPA는 검색 엔진 크롤러 봇처럼 자바스크립트가 실행되지 않는 환경에서는 페이지가 제대로 나타나지 않는다.
    • 따라서 서버에서 클라이언트 대신 렌더링을 해주면 검색 엔진이 페이지의 내용을 제대로 수집해 갈 수 있다.
    • 구글 검색 엔진은 다른 검색 엔진과 달리 검색 엔진에서 자바스크립트를 실행하는 기능이 탑재되어 있으므로 제대로 페이지를 크롤링해 갈 때도 있지만, 모든 페이지에 대해 자바스크립트를 실행해 주지 않는다.
    • 따라서 웹 서비스의 검색 엔진 최적화를 위해서라면 서버 사이드 렌더링을 구현해 주는 것이 좋다.
  • 서버 사이드 렌더링을 통해 초기 렌더링 선능을 개선할 수 있다.
    • 예를 들어 서버 사이드 렌더링이 구현되지 않은 웹 페이지에 사용자가 방문하면, 자바스크립트가 로딩되고 실행될 때까지 사용자는 비어 있는 페이지를 보며 대기해야 한다.
    • 여기에 API까지 호출해야 한다면 사용자의 대기 시간이 더더욱 길어진다.
    • 반명 서버 사이드 렌더링을 구현한 웹 페이지라면 자바스크립트 파일 다운로드가 완료되지 않은 시점에서도 html상에 사용자가 볼 수 있는 콘텐츠가 있기 때문에 대기 시간이 최소화되고, 이로 인해 사용자 경험도 향상된다.

1.2. 서버 사이드 렌더링의 단점

  • 원래 브라우저가 해야 할 일을 서버가 대신 처리하는 것이므로 서버 리소스가 사용된다.
  • 갑자기 수많은 사용자가 동시에 웹페이지에 접속하면 서버에 과부하가 발생할 수 있다.
  • 사용자가 많은 서비스라면 캐싱과 로드 밸런싱을 통해 성능을 최적화해 주어야 한다.
  • 프로젝트 구조가 좀 더 복잡해질 수 있다.
  • 데이터 미리 불러오기, 코드 스플리팅과의 호환 등 고려해야 할 사항이 많아져 개발이 어려워질 수도 있다.

 

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

  • 서버 사이드 렌더링과 코드 스플리팅을 함께 적용하면 작업이 꽤 까다롭다.
  • 별도의 호환 작업 없이 두 기술을 함께 적용하면, 다음과 같은 흐름으로 작동하면서 페이지에 깜빡임이 발생한다.
    1. 서버 사이드 렌더링된 결과물이 브라우저에 나타남
    2. 자바스크립트 파일 로딩 시작
    3. 자바스크립트가 실행되면서 아직 불러오지 않은 컴포넌트를 null로 렌더링함
    4. 페이지에서 코드 스플리팅된 컴포넌트들이 사라짐
    5. 코드 스플리팅된 컴포넌트들이 로딩된 이루 제대로 나타남
  • 이러한 이슈를 해결하려면 라우트 경로마다 코드 스플리팅된 파일 중에서 필요한 모든 파일을 브라우저에서 렌더링하기 전에 미리 불러와야 한다.
  • Loadable Components 라이브러리에서 제공하는 기능을 써서 서버사이드 렌더링 후 필요한 파일의 경로를 추출하여 렌더링 결과에 스크립트/스타일 태그를 삽입해 주는 방법으로도 해결 가능하다.

 

2. 프로젝트 준비하기

서버 사이드 렌더링을 진행하기 전에 리액트 라우터를 사용하여 라우팅하는 간단한 프로젝트를 만들어보자.

 

$ yarn add react-router-dom

 

2.1. 컴포넌트 만들기

[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;

 

2.2. 페이지 컴포넌트 만들기

[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(
  <BrowserRouter>
    <App />
  </BrowserRouter>,
  document.getElementById('root')
);

reportWebVitals();

 

[결과]

 

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

  • 서버 사이드 렌더링을 구현하려면 웹팩 설정을 커스터마이징해 주어야 한다.
  • CRA로 만든 프로젝트에서는 웹팩 관련 설정이 기본적으로 모두 숨겨져 있으니 yarn eject명령어를 실행하여 밖으로 꺼내주어야 한다.

$ git add .

$ git commit -m 'Commit before eject'

$ yarn eject

 

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

  • 엔트리는 웹팩에서 프로젝트를 불러올 때 가장 먼저 불러오는 파일
    • 예를들어 현재 작성 중인 리액트 프로젝트에서는 index.js를 엔트리 파일로 사용
    • 이 파일부터 시작하여 내부에 필요한 다른 컴포넌트와 모듈을 불러온다.
  • 서버 사이드 렌더링을 할 때는 서버를 위한 엔트리 파일을 따로 생성해야 한다.

[index.server.js]

import React from 'react';
import ReactDOMServer from 'react-dom/server';

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

console.log(html)
  • 기본적인 코드만 작성
  • 서버에서 리액트 컴포넌트를 렌더링할 때는 ReactDOMServer의 renderToString이라는 함수 사용
  • 이 함수에 JSX를 넣어서 호출하면 렌더링 결과를 문자열로 반환한다.

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

  • 작성한 엔트리 파일을 웹팩으로 불러와서 빌드하려면 서버 전용 환경설정을 만들어주어야 한다.
  • config/path.js 파일을 열어 스크롤을 맨 아래로 내린 후 module.exports 부분에 다음과 같이 두 줄을 추가한다.

[config/path.js]

(...)
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'),
  appJsConfig: resolveApp('jsconfig.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;
  • ssrIndexJs는 불러올 파일의 경로
  • ssrBuild는 웹팩으로 처리한 뒤 결과물을 저장할 경로
  • 다음으로 웹팩 환경 설정 파일을 작성

[config/webpack.config.server.js]

const paths = reuqire('./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, // 정적 파일이 제공될 경로
    }
};
  • 웹팩 기본 설정
  • 빌드할 때 어떤 파일에서 시작해 파일을 불러오는지, 어디에 결과물을 저장할지 정해준다.
  • 다음으로 로더를 설정한다.
    • 웹팩의 로더는 파일을 불러올 때 확장자에 맞게 필요한 처리를 해준다.
    • 예를들어 자바스크립트는 babel을 사용하여 트랜스파일링 해주고, css는 모든 css코드를 결합해주고, 이미지 파일은 파일을 다른 경로에 따로 저장하고 그 파일에 대한 경로를 자바스크립트에서 참조할 수 있게 해준다.
  • 서버사이드 렌더링을 할 때 css혹은 이미지 파일을 그다지 중요하지 않다.
    • 그렇다고 완정히 무시할 순 없다.
    • 가끔 자바스크립트 내부에서 파일에 대한 경로가 필요하거나 css module처럼 로컬 classname을 참조해야 할 수도 있기 때문
    • 그래서 해당 파일을 로더에서 별도로 설정하여 처리하지만 따로 결과물에 포함되지 않도록 구현할 수 있다.

[config/webpack.config.server.js]

const paths = reuqire('./paths');
const getCSSModuleLocalIdent = require('react-dev-utils/getCSSModuleLocalIdent'); // CSS Module의 고유 className을 만들 때 필요한 옵션
const cssRegex = /\.css$/;
const cssModuleRegex = /\.module\.css$/;
const sassRegex = /\.(scss|sass)$/;
const sassModuleRegex = /\.module\.(scss|sass)$/;

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'
                            ),
                            plugins: [
                                [
                                    require.resolve('babel-plugin-named-asset-import'),
                                    {
                                        loaderMap: {
                                            svg: {
                                                ReactComponent: '@svgr/webpack?-svgo![path]'
                                            }
                                        }
                                    }
                                ]
                            ],
                            cacheDirectory: true,
                            cacheCompression: false,
                            compact: false
                        }
                    },
                    // CSS를 위한 처리
                    {
                        test: cssRegex,
                        exclude: cssModuleRegex,
                        // exportOnlyLocals: true 옵션을 설정해야 실제 CSS 파일을 생성하지 않습니다.
                        loader: require.resolve('css-loader'),
                        options: {
                            onlyLocals: true
                        }
                    },
                    // CSS Module을 위한 처리
                    {
                        test: cssModuleRegex,
                        loader: require.resolve('css-loader'),
                        options: {
                            modules: true,
                            onlyLocals: true,
                            getLocalIdent: getCSSModuleLocalIdent
                        }
                    },
                    // Sass를 위한 처리
                    {
                        test: sassRegex,
                        exclude: sassModuleRegex,
                        use: [
                            {
                                loader: require.resolve('css-loader'),
                                options: {
                                    onlyLocals: true
                                }
                            },
                            require.resolve('sass-loader')
                        ]
                    },
                    // Sass + CSS Module을 위한 처리
                    {
                        test: sassRegex,
                        exclude: sassModuleRegex,
                        use: [
                            {
                                loader: require.resolve('css-loader'),
                                options: {
                                    modules: true,
                                    onlyLocals: 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']
    }
};
  • react, react-dom/server 같은 라이브러리를 import 구문으로 불러오면 node_modules에서 찾아 사용한다.
  • 라이브러리를 불러오면 빌드할 때 결과물 파일 안에 해당 라이브러리 관련 코드가 함께 번들링 된다.
  • 브라우저에서 사용할 때는 결과물 파일에 리액트 라이브러리와 우리의 애플리케이션에 관한 코드가 공존해야 하는데, 서버에서는 굳이 결과물 파일 안에 리액트 라이브러리가 들어 있지 않아도 된다.
    • node_modules를 통해 바로 불러와서 사용할 수 있기 때문에
  • 따라서 서버를 번들링할 때는 node_modules에서 불러오는 것을 제외하고 번들링하는 것이 좋다.
  • 이를 위해 webpack-node-externals 라는 라이브러리를 사용해야 한다.

$ yarn add webpack-node-externals

 

이 라이브러리를 상단에 불러와서 설정에 적용

 

[config/webpack.config.server.js]

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

module.exports = {
    (...)
    resolve: {
        modules: ['node-modules']
    },
    externals: [nodeExternals()]
};

마지막으로 환경변수를 주입하겠다.

 

[config/webpack.config.server.js]

const nodeExternals = require('webpack-node-externals')
const paths = reuqire('./paths');
const getCSSModuleLocalIdent = require('react-dev-utils/getCSSModuleLocalIdent'); // CSS Module의 고유 className을 만들 때 필요한 옵션
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 = {
    (...)
    externals: [nodeExternals()],
    plugins: [
        new webpack.DefinePlugin(env.stringified) // 환경변수를 주입해 준다.
    ]
};
  • 환경변수를 주입하면, 프로젝트 내에서 process.env.NODE_ENV 값을 참조하여 현재 개발 환경인지 아닌지를 알 수 있다.

 

3.3. 빌드 스크립트 작성하기

  • 방금 만든 환경 설정을 사용하여 웹팩으로 프로젝트를 빌드하는 스크립트를 작성해 보겠다.
  • scripts 경로를 열어 보면 build.js라는 파일이 있다.
  • 이 스크립트는 클라이언트에서 사용할 빌드 파일을 만드는 작업을 한다.
  • 이 스크립트와 비슷한 형식으로 서버에서 사용할 빌드 파일을 만드는 build.server.js 스크립트를 작성해 본다.

[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, status) => {
            if (err) {
                console.log(err);
                return;
            }
            console.log(status.toString());
        });
    });
}

build();

코드를 다 작성한 뒤에 다음 명령어를 실행하여 빌드가 잘되는지 확인해 보자

 

$ node scripts/build.server.js

[결과]

이어서 다음 명령어를 실행하여 작성한 결과물이 잘 작동하는지 확인

$ node dist/server.js

[결과]

 

매번 빌드하고 실행할 때마다 파일 경로를 입력하는 것이 번거로울 수 있으니, package.json에서 스크립트를 생성하여 더 편하게 명령어를 입력할 수 있도록 하자

 

[package.json]

[결과]

 

3.4. 서버 코드 작성하기

  • 서버 사이드 렌더링을 처리할 서버를 작성해보자
  • Express라는 Node.js 웹 프레임워크를 사용하여 웹 서버를 만들겠다.
    • 이 과정은 꼭 Express가 아니어도 상관없으며 Koa, Hapi 또는 connect 라이브러리를 사용하면 구현할 수 있다.
    • 여기서 Express를 사용한 이유는 해당 프레임워크가 사용율이 가장 높고, 추후 정적 파일들을 호스팅할 때도 쉽게 구현할 수 있기 때문
  • $ yarn add express

[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';

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라는 컴포넌트가 사용되었다.
    • 이 라우터 컴포넌트는 주로 서버 사이드 렌더링 용도로 사용되는 라우터
    • props로 넣너주는 location값에 따라 라우팅
    • 지금은 req.url이라는 값을 넣어주었다.
  • StaticRouter에 context라는 props도 넣어주었다.
    • 이 값을 사용하여 나중에 렌더링한 컴포넌트에 따라 HTTP 상태 코드를 설정해 줄 수 있다.
  • JS 파일과 CSS파일을 웹 페이지에 불러오는 것은 생략하고, 리액트 서버 사이드 렌더링을 통해 만들어진 결과만 보여주도록 하겠다.
    • $ yarn build:server
    • $ yarn start:server

 

[결과]

  • 서버 사이드 렌더링이 제대로 이루어졌는지 확인하기 위해 Network탭 확인
  • 새로고침 뒤blue의 response를 눌러보면 컴포넌트 렌더링 결과가 문자열로 잘 전달되었다.

 

3.5. 정적 파일 제공하기

  • 이번에는 Express에 내장되어 있는 static 미들웨어를 사용하여 서버를 통해 build에 있는 JS, CSS 정적 파일들에 접근할 수 있다.

[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';

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); // 클라이언트에게 결과물을 응답한다.
};

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

  •  그 다음 JS와 CSS 파일을 불러오도록 html에 코드를 삽입해 주어야 한다.
  • 불러와야 하는 파일 이름은 매번 빌드할 때마다 바뀌기 때문에 빌드하고 나서 만들어지는 asset-manifest.json파일을 참고하여 불러오도록 작성한다.
  • 한번 yarn build 명령어를 실행한 다음, build 디렉터리의 asset-manifest.json을 열어보자

  • 위 코드에서 밑줄이 그어진 파일을 html 내부에 삽입해 주어야 한다.

[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) {
    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>
    `;
}

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

[바뀐 코드]

github.com/velopert/learning-react/blob/master/corrections.md#2032-pg-549-550-%EC%97%85%EB%8D%B0%EC%9D%B4%ED%8A%B8

 

velopert/learning-react

[길벗] 리액트를 다루는 기술 서적에서 사용되는 코드. Contribute to velopert/learning-react development by creating an account on GitHub.

github.com

[결과]

  • 서버 사이드 렌더링이 잘 되었다.
  • 여기서 링크를 눌러 이동할 때는 클라이언트 렌더링이 되어야 한다. 즉, 다른 링크를 클릭하여 다른 페이지로 이동할 때 네트워크 요청이 추가로 발생하지 않아야 한다.
  • 서버사이드 렌더링을 구현하면 이렇게 첫 번째 렌더링은 서버를 통해 하지만, 그 이후에는 브라우저에서 처리한다.

4. 데이터 로딩

  • 서버 사이드 렌더링을 구현할 때 해결하기 매우 까다로운 문제 중 하나
  • 일반적인 브라우저 환경에서는 API를 요청하고 응답을 받아와서 리액트 state 혹은 리덕스 스토어에 넣으면 자동으로 리렌더링하니 큰 걱정 없음
  • 하지만 서버의 경우 문자열 형태로 렌더링하는 것이므로 state나 리덕스 스토어의 상태가 바뀐다고 해서 자동으로 리렌더링되지 않는다.
  • 대신 renderToString함수를 한 번 더 호출해 주어야 한다.
  • 서버에서는 componentDidMount같은 라이프사이클 API도 사용할 수 없다.
  • redux-thunk 혹은 redux-saga 미들웨어를 사용하여 API를 호출하는 환경에서 서버 사이드 렌더링을 하는 방법을 알아보자

4.1. redux-thunk 코드 준비하기

$ yarn add redux react-redux redux-thunk axios

 

액션 타입, 액션 생성 함수, 리듀서 코드를 한 파일에 넣어서 관리하는 Ducks 패턴을 사용하여 리덕스 모듈을 작성하겠다.

 

[modules/users.js]

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;
  • getUsers라는 thunk 함수를 만들고, 이와 관련된 액션 GET_USERS_PENDING, GET_USERS_SUCCESS, GET_USERS_FAILURE를 사용하여 상태 관리를 해준다.
  • 모듈의 상태에는 loading과 error라는 객체가 들어있다.
  • 로딩 상태와 에러 상태를 이렇게 객체로 만든 이유는 추후 redux-saga를 사용한 서버 사이드 렌더링 방법을 연습할 때 단 하나의 사용자 정보를 가져오는 다른 API를 호출할 것이기 때문
  • 즉, 이 모듈에서 관리하는 API는 한 개 이상이므로 loadingUsers, loadingUser와 같이 각 값에 하나하나 이름을 지어 주는 대신에 loading이라는 객체에 넣어 준 것이다.
  • 모듈을 다 작성한 뒤에 루트 리듀서를 만들고, Provider 컴포넌트를 사용하여 프로젝트에 리덕스를 적용하자

https://jsonplaceholder.typicode.com/users 에서 API 응답 값

 

 

[modules/index.js]

import {combineReducers} from 'redux';
import users from './users';

const rootReducer = combineReducers({users});
export default rootReducer;

 

[src/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';
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')
);

reportWebVitals();

 

4.2. Users, UsersContainer 컴포넌트 준비하기

사용자에 대한 정보를 보여 줄 컴포넌트를 준비하자

 

[components/Users.js]

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

const Users = ({users}) => {
    if(!users) return null; // users가 유효하지 않다면 아무것도 보여주지 않음
    return (
        <div>
            <ul>
                {users.map(user => (
                    <li key={user.id}>
                        <Link to={`/users/${user.id}`}>{user.username}</Link>
                    </li>
                ))}
            </ul>
        </div>
    );
};
export default Users;

 

[containers/UserContainer.js]

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);
  • 서버사이드 렌더링을 할 떄 이미 있는 정보를 재요청하지 않게 처리하는 작업이 중요
  • 이 작업을 하지 않으면 서버 사이드 렌더링 후 브라우저에서 페이지를 확인할 때 이미 데이터를 가지고 있음에도 불구하고 불필요한 API를 호출하게 된다. => 트래픽 낭비, 사용자 경험 저하

컴포넌트를 보여 줄 페이지 컴포넌트를 만들고, 라우트 설정을 해보자

 

[pages/UsersPage.js]

import React from 'react';
import UsersContainer from '../containers/UserContainer';

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

export default UsersPage;

 

[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';
import UsersPage from './pages/UsersPage';

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

export default App;

 

[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>
            <li>
                <Link to='/users'>Users</Link>
            </li>
        </ul>
    );
};

export default Menu;

 

아직 구현이 끝나지 않았지만, 리액트 개발 서버에서 방금 구현한 데이터 로딩 기능이 잘 작동하는지 확인해보자

 

[중간 결과]

 

4.3. PreloadContext 만들기

  • 현재 getUsers 함수는 UsersContainer의 useEffect 부분에서 호출된다.
    • 이를 클래스 형에서 작성했다면 componentDidMount에서 호출 했을 것이다.
  • 서버 사이드 렌더링을 할 때는 useEffect나 componentDidMount에서 설정한 작업이 호출되지 않는다.
  • 렌더링하기 전에 API를 요청한 뒤 스토어에 데이터를 담아야 하는데, 서버 환경에서 이러한 작업을 하려면 클래스형 컴포넌트가 지니고 있는 constructor 메소드를 사용하거나 render 함수 자체에서 처리해야 한다. 그리고 요청이 끝날 때까지 대기했다가 다시 렌더링해 주어야 한다.
  • 우리는 이 작업을 PreloadContext를 만들고, 이를 사용하는 preloader 컴포넌트를 만들어 처리해보겠다.

[lib/PreloadContext.js]

import {createContext, useContext} from 'react';

// 클라이언트 환경: null
// 서버 환경: {done: false, promise: []}
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;
};
  • PreloadContext는 서버 사이드 렌더링을 하는 과정에서 처리해야 할 작업들을 실행하고, 만약 기다려야 하는 프로미스가 있다면 프로미스를 수집한다.
  • 모든 프로미스를 수집한 뒤, 수집된 프로미스들이 끝날 때까지 기다렸다가 그다음에 다시 렌더링하면 데이터가 채워진 상태로 컴포넌트들이 나타나게 된다.
  • Preloader컴포넌트는 resolve하는 함수를 props로 받아 오며, 컴포넌트가 렌더링될 때 서버 환경에서만 resolve함수를 호출해 준다.
  • UsersContainer에서 한번 사용해보자.

[containers/UserContainer.js]

import React from 'react';
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);

 

4.4. 서버에서 리덕스 설정 및 PreloadContext 사용하기

  • 이제 서버에서 이덕스를 설정해준다.
  • 서버에서 리덕스틑 설정하는 것은 브라우저에서 할 때와 비교하여 큰 차이가 없다.

[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';
import {createStore, applyMiddleware} from 'redux';
import {Provider} from 'react-redux';
import thunk from 'redux-thunk';
import rootReducer from './modules';

// 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>
    `;
}

const app = express();

// 서버 사이드 렌더링을 처리할 핸들러 함수
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)); // 클라이언트에게 결과물을 응답한다.
};

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

 

  • 여기서 주의할 점은 서버가 실행될 때 스토어를 한 번만 만드는 것이 아니라, 요청이 들어올 때마다 새로운 스토어를 만든다는 것이다.
  • 이제 PreloadContext를 사용하여 프로미스들을 수집하고 기다렸다가 다시 렌더링하는 작업을 수행해 보겠다.

[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';
import {createStore, applyMiddleware} from 'redux';
import {Provider} from 'react-redux';
import thunk from 'redux-thunk';
import rootReducer from './modules';
import PreloadContext, { Preloader } from './lib/PreloadContext';

// 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>
    `;
}

const app = express();

// 서버 사이드 렌더링을 처리할 핸들러 함수
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)); // 클라이언트에게 결과물을 응답한다.
};

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

  • 첫 번째 렌더링을 할 때는 renderToString대신 renderToStaticMarkup이라는 함수를 사용했다.
  • renderToStaticMarkup은 리액트를 사용하여 정적인 페이지를 만들 때 사용한다.
  • 이 함수로 만든 리액트 렌더링 결과물은 클라이언트 쪽에서 HTML DOM 인터랙션을 지원하기 힘들다.
  • 지금 단계에서 renderToString대신 renderToStaticMarkup함수를 사용한 이유는 그저 Preloader로 넣어 주었던 함수를 호출하기 위해서이다. 또 이 함수의 처리 속독가 renderToString보다 좀 더 빠르기 때문이다.

4.5. 스크립트로 스토어 초기 상태 주입하기

  • 지금까지 작성한 코드는 API를 통해 받아 온 데이터를 렌더링하지만, 렌더링하는 과정에서 만들어진 스토어의 상태를 브라우저에서 재사용하지 못하는 상황이다.
  • 서버에서 만들어 준 상태를 브라우저에서 재사용하려면, 현재 스토어 상태를 문자열로 변환한 뒤 스크립트로 주입해 주어야 한다.

[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.files['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.files['runtime-main.js']}"></script>
       ${chunks}
       <script src="${manifest.files['main.js']}"></script>
    </body>
    `;
}

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)); // 클라이언트에게 결과물을 응답한다.
};

(...)
  • 브라우저에서 상태를 재사용할 때는 다음과 같이 스토어 생성 과정에서 window.__PRELOADED_STATE__를 초깃값으로 사용하면 된다.

 

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

reportWebVitals();

 

$ yarn build

$ yarn build:server

$ yarn start:server

 

[결과]

 

4.6. redux-saga 코드 준비하기

 

 

'Front > React' 카테고리의 다른 글

[React] mongoose를 이용한 MongoDB 연동 실습1  (0) 2021.01.26
[React] 백엔드 프로그래밍: Node.js의 Koa 프레임워크  (0) 2021.01.25
[React] 코드 스플리팅  (0) 2021.01.20
[React] 리덕스 미들웨어를 통한 비동기 작업 관리  (0) 2021.01.18
[React] 리덕스를 사용하여 리액트 애플리케이션 상태 관리하기  (0) 2021.01.16
    'Front/React' 카테고리의 다른 글
    • [React] mongoose를 이용한 MongoDB 연동 실습1
    • [React] 백엔드 프로그래밍: Node.js의 Koa 프레임워크
    • [React] 코드 스플리팅
    • [React] 리덕스 미들웨어를 통한 비동기 작업 관리
    happyso
    happyso

    티스토리툴바