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. 서버 사이드 렌더링과 코드 스플리팅 충돌
- 서버 사이드 렌더링과 코드 스플리팅을 함께 적용하면 작업이 꽤 까다롭다.
- 별도의 호환 작업 없이 두 기술을 함께 적용하면, 다음과 같은 흐름으로 작동하면서 페이지에 깜빡임이 발생한다.
- 서버 사이드 렌더링된 결과물이 브라우저에 나타남
- 자바스크립트 파일 로딩 시작
- 자바스크립트가 실행되면서 아직 불러오지 않은 컴포넌트를 null로 렌더링함
- 페이지에서 코드 스플리팅된 컴포넌트들이 사라짐
- 코드 스플리팅된 컴포넌트들이 로딩된 이루 제대로 나타남
- 이러한 이슈를 해결하려면 라우트 경로마다 코드 스플리팅된 파일 중에서 필요한 모든 파일을 브라우저에서 렌더링하기 전에 미리 불러와야 한다.
- 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');
});
[바뀐 코드]
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 |