본문 바로가기

Front/React

[React] 서버 사이드 렌더링

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 코드 준비하기