[서버 사이드 렌더링의 장점]
[서버 사이드 렌더링의 단점]
서버 사이드 렌더링과 코드 스플리팅을 함께 적용하면 페이지에 깜빡임이 발생함
🚧 해결 방법 : 라우트 경로마다 코드 스플리팅 된 파일 중에서 필요한 모든 파일을 브라우저에서 렌더링하기 전에 미리 불러와야 함
책에서는 ⇒ Lodable Components 라이브러리에서 제공하는 기능을 써서 서버 사이드 렌더링 후 필요한 파일의 경로를 추출하여 렌더링 결과에 스크립트/스타일 태그를 삽입해 주는 방법 사용
▶코드
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;
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(
<React.StrictMode>
<BrowserRouter>
<App />
</BrowserRouter>
</React.StrictMode>,
document.getElementById('root')
);
// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();
웹팩 설정 커스터마이징 해줘야 함
CRA(create react-app)로 만든 프로젝트는 웹팩 관련 설정이 숨겨져 있음
⇒ yarn eject 명령어 실행해줘야 함!
엔트리 : 웹팩에서 프로젝트를 불러올 때 가장 먼저 불러오는 파일
(현재, index.js ← 엔트리 파일로 사용 중)
서버 사이드 렌더링을 할 때는 서버를 위한 엔트리 파일을 따로 생성해야 함
⇒ ReactDOMServer의 renderToString 함수 사용
config/path.js의 module.exports 부분에 아래 코드 추가
ssrIndexJs: resolveApp('src/index.server.js'),
ssrBuild: resolveApp('dist')
config/webpack.config.server.js
▶코드
const paths = require('./paths');
const getCSSModuleLocalIdent = require('react-dev-utils/getCSSModuleLocalIdent');
const nodeExternals = require('webpack-node-externals');
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 = {
mode: 'production',
entry: paths.ssrIndexJs,
target: 'node',
output: {
path: paths.ssrBuild,
filename: 'server.js',
chunkFilename: 'js/[name].chunk.js',
publicPath: paths.publicUrlOrPath,
},
module: {
rules: [
{
oneOf: [
// 자바스크립트를 위한 처리
{
test: /\.(js|mjs|jsx|ts|tsx)$/,
include: paths.appSrc,
loader: require.resolve('babel-loader'),
options: {
customize: require.resolve(
'babel-preset-react-app/webpack-overrides'
),
presets: [
[
require.resolve('babel-preset-react-app'),
{
runtime: 'automatic',
},
],
],
plugins: [
[
require.resolve('babel-plugin-named-asset-import'),
{
loaderMap: {
svg: {
ReactComponent:
'@svgr/webpack?-svgo,+titleProp,+ref![path]',
},
},
},
],
],
cacheDirectory: true,
cacheCompression: false,
compact: false,
},
},
// CSS 를 위한 처리
{
test: cssRegex,
exclude: cssModuleRegex,
// exportOnlyLocals: true 옵션을 설정해야 실제 css 파일을 생성하지 않습니다.
loader: require.resolve('css-loader'),
options: {
importLoaders: 1,
modules: {
exportOnlyLocals: true,
},
},
},
// CSS Module 을 위한 처리
{
test: cssModuleRegex,
loader: require.resolve('css-loader'),
options: {
importLoaders: 1,
modules: {
exportOnlyLocals: true,
getLocalIdent: getCSSModuleLocalIdent,
},
},
},
// Sass 를 위한 처리
{
test: sassRegex,
exclude: sassModuleRegex,
use: [
{
loader: require.resolve('css-loader'),
options: {
importLoaders: 3,
modules: {
exportOnlyLocals: true,
},
},
},
require.resolve('sass-loader'),
],
},
// Sass + CSS Module 을 위한 처리
{
test: sassRegex,
exclude: sassModuleRegex,
use: [
{
loader: require.resolve('css-loader'),
options: {
importLoaders: 3,
modules: {
exportOnlyLocals: true,
getLocalIdent: getCSSModuleLocalIdent,
},
},
},
require.resolve('sass-loader'),
],
},
// url-loader 를 위한 설정
{
test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
loader: require.resolve('url-loader'),
options: {
emitFile: false, // 파일을 따로 저장하지 않는 옵션
limit: 10000, // 원래는 9.76KB가 넘어가면 파일로 저장하는데
// emitFile 값이 false 일땐 경로만 준비하고 파일은 저장하지 않습니다.
name: 'static/media/[name].[hash:8].[ext]',
},
},
// 위에서 설정된 확장자를 제외한 파일들은
// file-loader 를 사용합니다.
{
loader: require.resolve('file-loader'),
exclude: [/\.(js|mjs|jsx|ts|tsx)$/, /\.html$/, /\.json$/],
options: {
emitFile: false, // 파일을 따로 저장하지 않는 옵션
name: 'static/media/[name].[hash:8].[ext]',
},
},
],
},
],
},
resolve: {
modules: ['node_modules'],
},
externals: [nodeExternals()],
plugins: [
new webpack.DefinePlugin(env.stringified), // 환경변수를 주입해줍니다.
],
};
웹팩의 로더는 파일을 볼러올 때 확장자에 맞게 필요한 처리를 해줌
서버를 위해 번들링 할 때, node_modules에서 불러오는 것을 제외하고 번들링 하는 것이 좋음
$ yarn add webpack-node-externals
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, stats) => {
if (err) {
console.log(err);
return;
}
console.log(stats.toString());
});
});
}
build();
▶빌드가 잘 되는지 확인하기
$ node scripts/build.server.js
$ node dist/server.js
⇒ 명령어를 더 편하게 입력하기 위해 package.json 코드 scripts 부분 수정
"scripts":{
"start":"node scripts/start.js",
"build":"node scripts/build.js",
"test":"node scripts/test.js",
"start:server":"node dist/server.js",
"build:server":"node scripts/build.server.js"
},
⇒ 이제 다음 명령어로 서버 빌드 가능
$ yarn build:server
$ yarn start:server
Express : Node.js 웹 프레임워크
⇒ Koa, Hapi, connect라이브러리 사용해서 서버 구현해도 가능
$ yarn add express
Express에 내장된 static 미들웨어를 사용하여 서버를 통해 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, tags) {
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="shortcut icon" href="/favicon.ico" />
<meta
name="viewport"
content="width=device-width,initial-scale=1,shrink-to-fit=no"
/>
<meta name="theme-color" content="#000000" />
<title>React App</title>
<link href="${manifest.files['main.css']}" rel="stylesheet" />
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root">
${root}
</div>
<script src="${manifest.files['runtime-main.js']}"></script>
${chunks}
<script src="${manifest.files['main.js']}"></script>
</body>
</html>
`;
}
const app = express();
// 서버사이드 렌더링을 처리 할 핸들러 함수입니다.
const serverRender = async (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');
});
💡서버 사이드 렌더링 구현 시, 첫 번째 렌더링은 서버를 통해 이루어지지만, 이후에는 브라우저에서 처리함
데이터 로딩 = API 요청
일반적인 브라우저 환경에선, API 요청하면 응답을 받아와 state 혹은 리덕스 스토어에 넣으면 자동 리렌더링 됨
서버의 경우, 문자열 형태로 렌더링하는 것이므로 state나 리덕스 스토어의 상태가 바뀐다고 해도 자동으로 리렌더링 되지 않음
⇒ renderToString 함수 한 번 더 호출해줘야 함
🚧 해결 방법
리액트에서 공식 제공하는 코드 스플리팅 기능(React.lazy, Suspense) ⇒ 서버 사이드 렌더링 지원 X
서버 사이드 렌더링+코드 스플리팅 시, Loadable Components 사용 권장
yarn add @loadable/component @loadable/server @loadable/webpack-plugin @loadable/babel-plugin
App.js
import React from 'react';
import { Route } from 'react-router-dom';
import Menu from './components/Menu';
import loadable from '@loadable/component';
const RedPage = loadable(() => import('./pages/RedPage'));
const BluePage = loadable(() => import('./pages/BluePage'));
const UsersPage = loadable(() => import('./pages/UsersPage'));
const App = () => {
return (
<div>
<Menu />
<hr />
<Route path="/red" component={RedPage} />
<Route path="/blue" component={BluePage} />
<Route path="/users" component={UsersPage} />
</div>
);
};
export default App;
💡깜박임 현상을 확인해보자!
개발자 도구의 Network 탭 → Slow 3G 선택
Loadable Components에서 제공하는 웹팩과 babel 플러그인을 적용하면 깜박임 현상 해결 가능!
▶코드
package.json
"babel": {
"presets": [
"react-app"
],
"plugins": [
"@loadable/babel-plugin"
]
}
webpack.config.js
const LoadablePlugin = require('@loadable/webpack-plugin');
plugins:[
new LoadablePlugin(),
new HTMLWebpackPlugin(
].filter(Boolean),
build/loadable-stats.json 파일 생성되었는지 확인
⇒ 각 컴포넌트의 코드가 어떤 청크 파일에 들어가 있는지에 대한 정보 가지고 있음
Loadable Components에서 제공하는 ChunkExtractor와 ChunkExtractorManager 사용
▶코드
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 { createStore, applyMiddleware } from 'redux';
import { Provider } from 'react-redux';
import thunk from 'redux-thunk';
import createSagaMiddleware from 'redux-saga';
import rootReducer, { rootSaga } from './modules';
import PreloadContext from './lib/PreloadContext';
import { END } from 'redux-saga';
import { ChunkExtractor, ChunkExtractorManager } from '@loadable/server';
// asset-manifest.json 에서 파일 경로들을 조회합니다.
const statsFile = path.resolve('./build/loadable-stats.json');
function createPage(root, tags) {
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>
${tags.styles}
${tags.links}
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root">
${root}
</div>
${tags.scripts}
</body>
</html>
`;
}
const app = express();
// 서버사이드 렌더링을 처리 할 핸들러 함수입니다.
const serverRender = async (req, res, next) => {
// 이 함수는 404가 떠야 하는 상황에 404를 띄우지 않고 서버사이드 렌더링을 해줍니다.
const context = {};
const sagaMiddleware = createSagaMiddleware();
const store = createStore(
rootReducer,
applyMiddleware(thunk, sagaMiddleware)
);
const sagaPromise = sagaMiddleware.run(rootSaga).toPromise();
const preloadContext = {
done: false,
promises: [],
};
// 필요한 파일 추출하기 위한 ChunkExtractor
const extractor = new ChunkExtractor({ statsFile });
const jsx = (
<ChunkExtractorManager extractor={extractor}>
<PreloadContext.Provider value={preloadContext}>
<Provider store={store}>
<StaticRouter location={req.url} context={context}>
<App />
</StaticRouter>
</Provider>
</PreloadContext.Provider>
</ChunkExtractorManager>
);
ReactDOMServer.renderToStaticMarkup(jsx); // renderToStaticMarkup 으로 한번 렌더링합니다.
store.dispatch(END); // redux-saga 의 END 액션을 발생시키면 액션을 모니터링하는 saga 들이 모두 종료됩니다.
try {
await sagaPromise; // 기존에 진행중이던 saga 들이 모두 끝날때까지 기다립니다.
await Promise.all(preloadContext.promises); // 모든 프로미스를 기다립니다.
} catch (e) {
return res.status(500);
}
preloadContext.done = true;
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>`; // 리덕스 초기 상태를 스크립트로 주입합니다.
// 미리 불러와야 하는 스타일 / 스크립트를 추출하고
const tags = {
scripts: stateScript + extractor.getScriptTags(), // 스크립트 앞부분에 리덕스 상태 넣기
links: extractor.getLinkTags(),
styles: extractor.getStyleTags(),
};
res.send(createPage(root, tags)); // 결과물을 응답합니다.
};
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');
});
Loadable Components는 성능 최적화를 위해 모든 자바스크립트 파일을 동시에 받아 옴
모든 스크립트가 로딩되고 나서 렌더링하기 위해서는 loadableReady 함수 사용해줘야 함!
⇒ 렌더링 된 결과물 이미 존재 시, 기존에 존재하는 UI에 이벤트만 연동하여 성능 최적화
index.js
import {loadableReady} from '@loadable/component';
const sagaMiddleware = createSagaMiddleware();
const store = createStore(
rootReducer,
window._ _PRELOADEDSTATE _, // 이 값을 초기 상태로 사용함
applyMiddleware(thunk, sagaMiddleware)
);
sagaMiddleware.run(rootSaga);
// 같은 내용을 쉽게 재사용할 수 있도록 렌더링할 내용을 하나의 컴포넌트로 묶음
const Root = () => {
return (
<Provider store={store}>
<BrowserRouter>
<App />
</BrowserRouter>
</Provider>
);
};
const root = document.getElementById('root');
// 프로덕션 환경에서는 loadableReady와 hydrate를 사용하고
// 개발 환경에서는 기존 방식으로 처리
if (process.env.NODE_ENV = = = 'production') {
loadableReady(() => {
ReactDOM.hydrate(<Root />, root);
});
} else {
ReactDOM.render(<Root />, root);
}
19장 코드 스플리팅 (0) | 2022.01.24 |
---|---|
18장 리덕스 미들웨어를 통한 비동기 작업 관리 (0) | 2022.01.24 |
[리액트를 다루는 기술] 16장 리덕스 라이브러리 이해하기 (0) | 2022.01.17 |
[리액트를 다루는 기술] 17장 리덕스를 사용하여 리액트 애플리케이션 상태 관리하기 (0) | 2022.01.17 |
[리액트를 다루는 기술] 15장 Context API (0) | 2022.01.10 |