리액트 서버사이드 렌더링 구현시 주의 사항
이 튜토리얼은 react-ssr-sample 프로젝트를 살펴보면서 리액트 서버사이드 렌더링의 원리를 파악해보는 튜토리얼입니다. 다음 명령어를 통하여 한번 여러분의 컴퓨터에서도 직접 돌려보세요:
$ git clone https://github.com/vlpt-playground/react-ssr-sample.git
$ cd react-ssr-sample
$ yarn
$ yarn build && yarn build:server
$ yarn start:ssr
1. 서버를 위한 웹팩 설정
/config/webpack.config.server.js
서버를 위한 웹팩 설정을 합니다. 기존 webpack.config.production.js 를 기반으로 만들어지는데 주의 할 부분들은
target: 'node',
output: {
path: paths.ssrBuild,
filename: 'server.js',
chunkFilename: 'chunks/[name].[chunkhash:8].chunk.js',
libraryTarget: 'commonjs2'
},
브라우저에서 사용 용도가 아닌, node 에서 바로 실행 할 수 있는 빌드 파일을 생성
이것을 위해 config/path.js 도 다음 필드가 추가됩니다.
ssrIndexJs: resolveModule(resolveApp, 'src/server'),
ssrBuild: resolveApp('dist'),
file-loader 와 url-loader 에서는 emitFile 값을 false 로 설정하는것이 중요합니다. 파일을 위한 경로는 생성하지만 실제 빌드 할 때 파일을 따로 저장시키지는 않습니다.
{
test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
loader: require.resolve('url-loader'),
options: {
limit: 10000,
emitFile: false, // 실제로 파일을 생성하지 않음
name: 'static/media/[name].[hash:8].[ext]'
}
},
{
loader: require.resolve('file-loader'),
exclude: [/\.(js|mjs|jsx|ts|tsx)$/, /\.html$/, /\.json$/],
options: {
emitFile: false, // 실제로 파일을 생성하지 않게 함
name: 'static/media/[name].[hash:8].[ext]'
}
}
css-loader 과 sass-loader 에서 중요한점은 css-loader/locals
를 사용한다는 점 입니다. 이를 통하여 코드내에서 import 하는 것은 문제가 없지만, 빌드 과정에서 새로 css 파일을 만들어내지 않습니다.
{
test: /\.css$/,
loader: require.resolve('css-loader/locals')
// 뒤에 /locals 를 붙여줘야 실제로 파일을 생성하지 않음.
// postcss-loader 같은건 생략해도됨.
},
{
test: /\.scss$/,
use: [
require.resolve('css-loader/locals'), // 여기도 locals
require.resolve('sass-loader')
]
},
babel-loader 에선 cacheCompression
과 compact
속성을 false 로 합니다. (참고) production 에서는 compact: true 설정을을 통해 코드에 있는 공백들을 없애주는데 서버사이드 렌더링에서는 불필요한 작업이니 이 값을 false 로 설정하여 번들링 속도를 개선합니다. cacheCompression 은 빌드시 사용되는 캐시를 압축하는건데 이 또한 불필요하니 비활성화합니다.
{
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?-prettier,-svgo![path]'
}
}
}
]
],
cacheDirectory: true,
cacheCompression: false,
compact: false
}
},
2. 서버 전용 엔트리 파일
서버 전용 엔트리 파일에서는 express 를 사용하여 웹서버를 만듭니다.
만약에 코드스플리팅과 데이터 로딩도 없다면 다음과 같은 구조로 이뤄집니다:
import fs from 'fs';
import path from 'path';
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import express from 'express';
import App from './App';
const app = express();
// 이미 만들어진 indexHtml 을 기반으로 수정 할 것이기에
// 미리 불러온다.
const indexHtml = fs.readFileSync(
path.resolve(__dirname, '../build/index.html'),
'utf8'
);
// index.html 에 변화를 일으킨다.
function createPage(rootHtml) {
let html = indexHtml;
// 렌더링 결과를 root 안에 집어넣기
html = html.replace(
'<div id="root"></div>',
`<div id="root">${rootHtml}</div>`
);
return html;
}
// 서버렌더링 함수
const serverRender = async (req, res) => {
const rootHtml = ReactDOMServer.renderToString(
<StaticRouter location={req.url}>
<App />
</StaticRouter>
);
res.send(createPage(rootHtml));
};
// / 경로에 들어왔을때도 똑같은 서버사이드 렌더링 작업
// index.html 을 사용하는것을 방지하는것임
app.get('/', (req, res) => {
return serverRender(req, res);
});
// build 경로를 정적 디렉토리로 사용
app.use(express.static(path.resolve(__dirname, '../build')));
// 404가 발생하는 경우 서버사이드 렌더링
app.use((req, res, next) => {
if (!req.route) {
return serverRender(req, res);
}
return next();
});
app.listen(4000, () => {
console.log('Running on http://localhost:4000/');
});
Create-react-app v2 부터는 html 을 생성 할 때 템플릿 형태로 내용을 전부 자바스크립트 안에 넣는것보다 사전에 만들어진 html 을 기반으로 필요한 부분을 replace 하는 형태로 구현하는것이 편합니다. 빌드된 index.html 쪽에는 추후 비동기 자원 로딩에 필요한 스크립트가 적용되는데 이는 빌드 될 때 만들어지는 chunkhash 값에 따라 가변적입니다.
또 주의해야 할 부분은 / 경로에도 서버사이드 렌더링 적용하는것입니다. 만약에 이걸 안하면 index.html 로 연결이 자동으로 되서 서버사이드 렌더링이 호출되지 않습니다.
추가적으로, 서버 내에서 404 를 띄워줘야 하는 상황에, 404를 띄우지 않고 서버 사이드렌더링으로 연결해줍니다. 실제 404 가 발생하면 리액트쪽에서 관리하도록 할 수 있습니다. (참고)
그리고, API 서버가 따로 있을텐데, 무조건 API 서버와 서버사이드 렌더링 서버를 통합 할 필요는 없습니다. 두 서버를 다른 인스턴스에서 구동을 하셔도 되고, 다른 포트로 따로따로 연 다음에, 다음과 같이 nginx 를 사용하여 프록시를 설정하면 됩니다.
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
root /var/www/html;
# 일단 모든 경로는 SSR 서버로 하는데
location / {
proxy_pass http://localhost:5000/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Origin $http_origin;
proxy_cache_bypass $http_upgrade;
}
# api 면 api 서버로 (이게 더 우선권을 가짐)
location ^~ /api/ {
proxy_pass http://localhost:8080/api/;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Origin $http_origin;
proxy_cache_bypass $http_upgrade;
}
# static 파일은 Node 보다 nginx 로 제공하는게 성능이 좋음
# https://stackoverflow.com/questions/9967887/node-js-itself-or-nginx-frontend-for-serving-static-files
location ^~ /static/ {
alias /home/ubuntu/react-ssr/build/static/;
}
}
3. 코드 스플리팅
코드 스플리팅은 아직까진 서버사이드 렌더링과 함께 한다면 react-loadable 을 사용하는게 가장 좋습니다, 리액트의 lazy 함수가 서버사이드 까지 지원될 때 까지는요!
react-loadable 를 쓰면서 서버 사이드 렌더링을 하게 되는 경우엔 우선 babel 설정을 해줘야합니다.
이는 package.json 에서 이뤄져있습니다.
"babel": {
"presets": [
"react-app"
],
"plugins": [
"react-loadable/babel"
]
}
그리고, webpack.config.prod.js 에서는 ReactLoadablePlugin 이 적용됩니다.
const ReactLoadablePlugin = require('react-loadable/webpack')
.ReactLoadablePlugin;
...
new ReactLoadablePlugin({
filename: './build/react-loadable.json'
})
이를 통하여 각 청크들에서 어떤 모듈을 사용하는지 알 수있는 파일이 생성됩니다.
예시: https://gist.github.com/velopert/c6753ee23a709b8ca1fce425ae64036e
그리고나서, 서버쪽에서는 서버가 시작 될 때 우선 스플리팅된 파일들을 모두 불러오도록 하고:
// 스플리팅된 코드들을 모두 불러오고 난 다음에 서버 가동
Loadable.preloadAll().then(() => {
app.listen(4000, () => {
console.log('Running on http://localhost:4000/');
});
});
서버사이드 렌더링 함수에는 Loadable.Capture 컴포넌트를 통해서 렌더링 되는 과정에서 어떤 모듈들이 로딩 됐는지 알 수 있습니다.
const modules = [];
const rootHtml = ReactDOMServer.renderToString(
<Loadable.Capture report={moduleName => modules.push(moduleName)}>
<Provider store={store}>
<StaticRouter location={req.url}>
<App />
</StaticRouter>
</Provider>
</Loadable.Capture>
);
여기서 가져온 modules 는 이런 형태를 가지고있습니다:
[ './pages/ArticlesPage' ]
그 다음 하는 작업은 이 배열과 우리가 ReactLoadablePlugin 를 통해서 만든 파일과 비교하여 현재 어떤 파일들을 불러와야 하는지 알아내는 것 입니다.
import { getBundles } from 'react-loadable/webpack';
import stats from '../build/react-loadable.json';
// ...
const bundles = getBundles(stats, modules);
여기서 bundles 는 이런 형태를 가지고있습니다.
{ id: 26,
name: './src/pages/AboutPage.js',
file: 'static/css/2.918a5411.chunk.css',
publicPath: '/static/css/2.918a5411.chunk.css' },
{ id: 26,
name: './src/pages/AboutPage.js',
file: 'static/js/2.a2a093b9.chunk.js',
publicPath: '/static/js/2.a2a093b9.chunk.js' },
그럼 우리는 이 데이터를 토대로 html 에 포함시키죠. 이는 replace 로 구현합니다.
function createPage(rootHtml, bundles, state) {
let html = indexHtml;
// 스플리팅된 자바스크립트
const chunkScripts = bundles
.filter(bundle => bundle.file.match(/.js$/))
.map(bundle => `<script src="${bundle.publicPath}"></script>`)
.join('\n');
// 스플리팅된 스타일
const chunkStyles = bundles
.filter(bundle => bundle.file.match(/.css$/))
.map(bundle => `<link href="${bundle.publicPath}" rel="stylesheet">`)
.join('\n');
// 렌더링 결과를 root 안에 집어넣기
html = html.replace(
'<div id="root"></div>',
`<div id="root">${rootHtml}</div>`
);
// 메타 태그 설정 (+ 스플리팅된 스타일 로딩)
html = html.replace('</head>', `${chunkStyles}</head>`);
// 커스텀 스크립트 정의
const customScripts = `<script>
window.ssr = true;
</script>`;
// 커스텀스크립트 적용 + 스플리팅된 스크립트 로딩
const mainScript = html.match(
/<script src="\/static\/js\/main..*.chunk.js"><\/script>/
)[0];
html = html.replace(
mainScript,
`${customScripts}${chunkScripts}${mainScript}`
);
return html;
}
이렇게 스타일은 스타일로 필터링, 자바스크립트는 자바스크립트로 필터링하고 태그형태로 변환한다음에 필요한 곳에 삽입해주면 코드 스플리팅이 제대로 작동하게 됩니다.
4. 데이터 로딩
이 프로젝트에서는 redux-thunk 를 사용하여 데이터를 로딩했습니다. 꼭 redux-thunk 가 아니여도, redux-pender, redux-promise-middleware 같은걸로도 동일한 방식으로 구현 가능합니다.
API 는 따로 만들지 않고, 마치 API 처럼 작동하는 비동기 함수들을 fakeApi.js에 만들었습니다:
const delay = ms => new Promise(resolve => setTimeout(resolve, ms));
const fakeData = [
{
id: 1,
title: 'hello',
body: 'hello, hello, hello!'
},
{
id: 2,
title: 'world',
body: 'what a beautiful world'
},
{
id: 3,
title: 'hello ssr!',
body: 'ssr? ssr!'
},
{
id: 4,
title: 'hello code splitting!',
body: 'code splitting + ssr = complex'
}
];
export const getArticles = async () => {
console.log('getting articles');
await delay(1000);
return fakeData.map(article => ({ id: article.id, title: article.title }));
};
export const getArticle = async id => {
console.log('getting article', id);
await delay(400);
return fakeData.find(article => article.id === id);
};
그리고 이런 유틸 함수를 만들었구요:
/**
* creates thunk from promiseCreator
* @param {string} actionType
* @param {() => Promise<*>} promiseCreator
*/
export default function createPromiseThunk(actionType, promiseCreator) {
return (...params) => {
return async dispatch => {
// promise begins
dispatch({ type: `${actionType}_PENDING` });
try {
const response = await promiseCreator(...params);
dispatch({
type: `${actionType}_SUCCESS`,
payload: response
});
return response;
} catch (e) {
dispatch({
type: `${actionType}_ERROR`,
payload: e
});
throw e;
}
};
};
}
이를 통하여 리덕스 모듈도 구현되었습니다:
import { handleActions } from 'redux-actions';
import * as fakeApi from '../lib/fakeApi';
import createPromiseThunk from '../lib/createPromiseThunk';
const GET_ARTICLES = 'articles/GET_ARTICLES';
const GET_ARTICLES_SUCCESS = 'articles/GET_ARTICLES_SUCCESS';
const GET_ARTICLE = 'articles/GET_ARTICLE';
const GET_ARTICLE_SUCCESS = 'articles/GET_ARTICLE_SUCCESS';
export const getArticles = createPromiseThunk(
GET_ARTICLES,
fakeApi.getArticles
);
export const getArticle = createPromiseThunk(GET_ARTICLE, fakeApi.getArticle);
const initialState = {
list: null,
item: null
};
export default handleActions(
{
[GET_ARTICLES_SUCCESS]: (state, action) => ({
...state,
list: action.payload
}),
[GET_ARTICLE_SUCCESS]: (state, action) => ({
...state,
item: action.payload
})
},
initialState
);
컨테이너 컴포넌트를 개발 할 땐 우선 서버쪽에서 사용하는것을 고려하지 않고 그냥 평상시처럼 개발을 하고:
prefetchConfig.js 라는 파일을 만들어서 여기서 특정 라우트에서 어떤 액션을 발생시킬지 정리합니다.
import * as articleActions from './modules/articles';
import { bindActionCreators } from 'redux';
const prefetchConfig = [
{
path: '/articles',
prefetch: store => {
const ArticleActions = bindActionCreators(articleActions, store.dispatch);
return ArticleActions.getArticles();
}
},
{
path: '/articles/:articleId',
prefetch: (store, params) => {
const ArticleActions = bindActionCreators(articleActions, store.dispatch);
return ArticleActions.getArticle(parseInt(params.articleId));
}
}
];
export default prefetchConfig;
만약에 프로미스가 여러개라면 Promise.all() 로 감싸면 됩니다. 여기서 prefetch 함수는 store 와 params 를 파라미터로 받아서 사용합니다.
그리고 서버쪽에서 사용 할 땐 이렇게 씁니다:
import { StaticRouter, matchPath } from 'react-router';
//...
// 스토어 생성
const store = createStore(rootReducer, applyMiddleware(thunk));
// 데이터 미리 불러오기
const promises = [];
prefetchConfig.forEach(route => {
const match = matchPath(req.path, route);
if (match) {
const p = route.prefetch(store, match.params);
promises.push(p);
}
});
try {
await Promise.all(promises);
} catch (e) {}
// Loadable.Capture 는 렌더링 과정에서 어떤 컴포넌트들이 사요되었는지 트래킹함
const modules = [];
const rootHtml = ReactDOMServer.renderToString(
<Loadable.Capture report={moduleName => modules.push(moduleName)}>
<Provider store={store}>
<StaticRouter location={req.url}>
<App />
</StaticRouter>
</Provider>
</Loadable.Capture>
);
const state = store.getState();
여기서 사용된 matchPath 는 현재 주소와 라우트 경로와 비교를 해서 만약에 일치를 하면 match 객체를 생성해줍니다 (리액트 라우터에서 props 로 받아오게 되는 그 match 랑 동일합니다). 그럼, 주소 안에 파라미터가 있는 경우엔 params 를 받아올 수 있습니다.
현재 경로와 우리가 설정한 경로가 일치한다면, 해당 객체에 있는 prefetch 함수를 호출하도록 합니다. 해당 함수는 Promise 를 리턴하니 이를 Promise.all 로 감싸서 모든 작업이 끝날 때 까지 기다립니다.
그리고나서 페이지를 만들땐 이렇게 커스텀 스크립트 쪽에 현재 리덕스 상태를 변수로 설정합니다.
function createPage(rootHtml, bundles, state) {
let html = indexHtml;
// 스플리팅된 자바스크립트
const chunkScripts = bundles
.filter(bundle => bundle.file.match(/.js$/))
.map(bundle => `<script src="${bundle.publicPath}"></script>`)
.join('\n');
// 스플리팅된 스타일
const chunkStyles = bundles
.filter(bundle => bundle.file.match(/.css$/))
.map(bundle => `<link href="${bundle.publicPath}" rel="stylesheet">`)
.join('\n');
// 렌더링 결과를 root 안에 집어넣기
html = html.replace(
'<div id="root"></div>',
`<div id="root">${rootHtml}</div>`
);
// 메타 태그 설정 (+ 스플리팅된 스타일 로딩)
html = html.replace('</head>', `${chunkStyles}</head>`);
// 커스텀 스크립트 정의
const customScripts = `<script>
window.ssr = true;
window.__PRELOADED_STATE__ = ${JSON.stringify(state).replace(
/</g,
'\\u003c'
)};
window.shouldCancel = true;
</script>`;
// 커스텀스크립트 적용 + 스플리팅된 스크립트 로딩
const mainScript = html.match(
/<script src="\/static\/js\/main..*.chunk.js"><\/script>/
)[0];
html = html.replace(
mainScript,
`${customScripts}${chunkScripts}${mainScript}`
);
return html;
}
여기서 replace(/</g, '\\u003c')
의 용도는 악성 스크립트 삽입 방지이다.
이 작업이 완료되면 프로젝트가 서버사이드 렌더링 될 시 __PRELOADED_STATE__
가 선언된 상태로 페이지가 로딩되는데, 리액트 단에서는 이 값을 리덕스 기본 값으로 사용하면 됩니다.
const store = createStore(
rootReducer,
window.__PRELOADED_STATE__,
composeEnhancers(applyMiddleware(Thunk))
);
그리고, 서버사이드 렌더링을 하게 될 땐 ReactDOM.render 이 아닌 hydrate 를 합니다.
const app = (
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>
);
const rootElement = document.getElementById('root');
if (window.ssr) {
ReactDOM.hydrate(app, rootElement);
} else {
ReactDOM.render(app, rootElement);
}
hydrate 는 렌더링 결과를 다시 렌더링하는것이 아니라, 기존에 렌더링된 결과에 이벤트만 붙여줍니다.
서버사이드 렌더링을 하실 때 정말 주의 할 점은 서버쪽에서 만드는 결과물과 클라이언트쪽에서 만드는 결과물이 동일해야 한다는 것 입니다.
만약에 동일하지 않으면 페이지가 깨질 수가 있습니다. 때문에, 예를 들어서 페이지의 width 에 따라 다른것을 렌더링해야 하는 경우에는 DOM 구조가 흐트러지지 않도록 자바스크립트 단에서 관리를 하는게 아니라 CSS 를 사용하여 관리하는것이 더욱 좋습니다.
5. 데이터가 이미 있을때 중복 API 요청 방지
이 부분도 꽤 중요한 부분인데요, 만약에 서버사이드 렌더링을 통하여 이미 데이터를 받아온 상태라면 클라이언트에서 초기 요청을 하는것을 방지시켜주어야 합니다. 이 작업을 하지 않으면 불필요한 트래픽과 로딩이 발생 할 수 있습니다. 혹은, 깜박임 현상이 발생 할 수 있습니다. (페이지가 렌더링 될 때 처음엔 데이터가 있는 상태인데 재요청시 초기화되서 해당 요청이 완료될 때 까지 공백이 뜨는 현상)
이를 방지 하기위해선 단순히 특정 값이 null 일때만 요청을 시작하도록 해도 되지만:
componentDidMount() {
if (this.props.list !== null) return;
this.props.getArticles();
}
그 대신에 앱내 전역적으로 초기 요청을 취소할지말지 정하는 것도 좋은 방안입니다.
componentDidMount() {
if (window.shouldCancel) return;
this.props.getArticles();
}
근데, 이 값은 페이지를 라우팅 할 때는 값이 false 로 바뀌어야겠죠?
이 작업은 RouterListener 컴포넌트를 만들어서 다른 페이지로 한번이라도 이동을 하면 값을 false 로 바꾸게끔 작업을 수행 할 수 있습니다.
import React from 'react';
import { withRouter } from 'react-router-dom';
class RouterListener extends React.Component {
componentDidMount() {
const { history } = this.props;
this.unlisten = history.listen(this.handleChange);
}
handleChange = location => {
console.log('Route has changed!');
console.log(location);
window.shouldCancel = false;
};
componentWillUnmount() {
this.unlisten();
}
render() {
return null;
}
}
export default withRouter(RouterListener);
만들어서 App 에 렌더링하면 됩니다.
정리
코드 스플리팅동하고, 서버사이드 렌더링도하고, 데이터도 미리 불러오는 작업은 정말 번거로운 작업입니다. 하지만 원리를 이해하고 나면, 추후 구현이 필요 할 때 이 예시 프로젝트를 참고하여 구현하시면 충분히 구현 할 수 있을 것입니다.
만약에 이 작업이 너무 불편하면 Next.js 를 사용 할 수 있습니다. 하지만 Next.js 의 단점은 라우터가 react-router 가 아니고, 프로젝트 구조도 Next.js 가 의도한대로 만들어야만 합니다. 자유롭질 못합니다.
또 다른 대안은 Razzle입니다. 이건 조금 더 자유로운 구조로 사용 할 수 있습니다. 다만 우리가 직접 볼 수 없는 라이브러리단에서 이뤄지는게 너무 많아 커스터마이징 하기 힘들 수도 있습니다.