서버 사이드 렌더링은 결국 원래 브라우저가 해야 할 일을 서버가 대신 처리하는 겁니다. 서버 리소스가 많이 사용 된다는 단점이 있으며 갑자기 많은 사용자가 동시에 웹 페이지에 접속하면 서버에 과부화가 발생할 수도 있습니다. 따라서 사용자가 많은 서비스라면 캐싱과 로드 밸런싱을 통해 성능을 최적화 해주어야 합니다.
CRA에서의 서버사이드 렌더링 구현
// src/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);
일단 가장 기본적인 코드를 작성해줍니다.
// 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 env = getClientEnvironment(paths.publicUrlOrPath.slice(0, -1));
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환경에서 실행될 것임 ( 서버가 express서버니까 )
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"),
presets: [
[
require.resolve("babel-preset-react-app"),
{
runtime: "automatic",
},
],
],
plugins: [require.resolve("react-refresh/babel")],
cacheDirectory: true,
cacheCompression: false,
compact: isEnvProduction,
},
},
// CSS를 위한 처리
{
test: cssRegex,
exclude: cssModuleRegex,
loadeR: require.resolve("css-loader"),
options: {
importLoaders: 1,
modules: {
mode: "icss",
exportOnlyLocals: true,
},
},
},
// CSS Module을 위한 처리
{
test: cssModuleRegex,
loader: require.resolve("css-loader"),
options: {
importLoaders: 1,
modules: {
exportOnlyLocals: true,
mode: "local",
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: 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,
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: {
// react, react-dom/server같은 라이브러리르 import구문으로 불러오면 node_modules에서
// 찾아 사용합니다. 라이브러리를 불러오면 빌드할 떄 결과물 파일 안에 해당 라이브러리 관련 코드가 같이 번들링됨
modules: ["node_modules"],
},
externals: [nodeExternals({ allowlist: [/@babel/] })],
};
이는 서버를 번들링할 웹팩 config파일입니다.
빌드할 떄, 어떤 파일에서 시작해 파일들을 불러오는지, 또 어디에 결과물을 저장할지를 정해주었습니다.
또한 웹팩의 로더는 파일을 불러올 떄 확장자에 맞게 필요한 처리를 해줍니다. 예를 들어 자바스크립트 babel을 사용하여 트랜스파일링을 해주고, CSS는 모든 CSS코드를 결합해 주고, 이미지 파일은 파일을 다른 경로에 따로 저장하고, 그 파일에 대한 경로를 자바스크립트에서 참조할 수 있게 해 줍니다.
서버 사이드 렌더링을 할 때 CSS 혹은 이미지 파일은 그다지 중요하지 않습니다. 그렇다고 완전히 무시할 수는 없습니다. 가끔 자바스크립트 내부에서 파일에 대한 경로가 필요하거나 CSS Module처럼 로컬 className을 참조해야 할 수도 있기 때문입니다. 그래서 해당 파일을 로더에서 별도로 설정하여 처리하지만 따로 결과물에 포함되지 않도록 구현할 수 있습니다.
또한 resolve를 사용하여 react, react-dom/server같은 라이브러리를 import구문으로 불러오면, node_modules에서 찾아 사용합니다. 라이브러리를 불러오면 빌드할 때 결과물 파일 안에 해당 라이브러리 관련 코드가 함꼐 번들링됩니다.
브라우저에서 사용할 때는 결과물 파일에 리액트 라이브러리와 우리의 애플리케이션에 관한 코드가 공존해야 합니다. 서버에서는 굳이 결과물 파일 안에 리액트 라이브러리가 들어 있지 않아도 됩니다. node_modules를 통해서 바로 불러와서 사용할 수 있기 때문입니다. 따라서 서버를 위해 번들링 할 때는 node_modules에서 불러오는 것을 제외하고 번들링하는 것이 좋습니다.
< 정적 파일 제공하기 >
build에 있는 js파일과 css정적 파일들에 접근할 수 있도록 해야 하는 작업을 해야 합니다. 그 이유는 js와 css가 안입혀서 serverside rendering이 진행되는데 이러면 사용자 경험이 나빠질 수 있기 때문입니다.
// src/index.server.js
import React from "react";
import ReactDOMServer from "react-dom/server";
import express from "express";
import { StaticRouter } from "react-router-dom/server";
import App from "./App";
import path from "path";
import fs from "fs";
// asset-manifest.json에서 파일 경로들을 조회합니다.
const manifest = JSON.parse(
fs.readFileSync(path.resolve(__dirname, "../build/asset-manifest.json"), "utf8")
);
const chunks = Object.keys(manifest.files)
.filter((key) => /chunk\.js$/.exec(key))
.map((key) => `<script src="${manifest.files[key]}"></script>`)
.join("");
const createPage = (root) => {
return `
<!DOCTYPE html>
<html lang="kr">
<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 serverRenderer = (req, res, next) => {
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(__dirname, "../build"), {
index: false,
});
app.get("/favicon.ico", (req, res, next) => {
res.send("ok");
});
app.use(serve);
app.use(serverRenderer);
app.listen(8080, () => {
console.log("Running on http://localhost:8080");
});
// build/asset-manifest.json
{
"files": {
"main.css": "/static/css/main.2a6a2343.css",
"main.js": "/static/js/main.a55f57a2.js",
"index.html": "/index.html",
"main.2a6a2343.css.map": "/static/css/main.2a6a2343.css.map",
"main.a55f57a2.js.map": "/static/js/main.a55f57a2.js.map"
},
"entrypoints": [
"static/css/main.2a6a2343.css",
"static/js/main.a55f57a2.js"
]
}
Express에 내장되어 있는 static 미들웨어를 사용하여 서버를 통해 build에 있는 Js, Css정적 파일들에 접근할 수 있도록 해 줍니다. 그 다음에는 Js와 Css파일들을 불러올 수 있도록 html에 코드를 삽입해주어야 합니다. 불러와야 하는 파일의 이름은 매번 빌드할 때마다 바뀌기 때문에 빌드하고 나서 만들어지는 asset-manifest.json파일을 참고하여 불러오도록 작성합니다.
서버사이드 렌더링을 구현하면 이렇게 첫 번째 렌더링은 서버를 통해 하지만, 그 이후에는 브라우저에서 처리합니다.
hash와 chunkhash의 공통적인 문제가 있습니다. app.js를 쓰다가 chunkhash를 준 이후부터는 app.[chunkhash].js를 사용해야 합니다. 문제는 청크해시 부분이 어떻게 나올지 미리 예측할 수가 없다는 겁니다. 따라서 webpack-manifest-plugin을 사용하여 자산(assets)들을 관리합니다. 우리는 이 asset-manifest.json을 활용하여 index.html에 정적 자산들을 삽입한 것입니다.
데이터 로딩
데이터 로딩은 서버 사이드 렌더링을 구현할 떄 해결하기가 매우 까다로운 문제 중 하나입니다. 데이터 로딩을 한다는 것은 API요청을 의미합니다. 예를 들어 페이지가 필요로 하는 데이터가 있다면 API를 요청해서 응답을 받아와야 합니다. 일반적인 브라우저 환경에서는 API를 요청하고 응답을 받아 와서 리액트 state혹은 리덕스 스토어에 넣으면 자동으로 리렌더링하니까 큰 걱정은 없습니다. 하지만 서버의 경우 문자열 형태로 렌더링하는 것이므로 state나 리덕스 스토어의 상태가 바뀐다고 해서 자동으로 리렌더링되지 않습니다. 그 대신 우리가 renderToString함수를 한번 더 호출해 주어야 합니다. 게다가 서버에서는 componentDidMount같은 라이프사이클 API도 사용할 수 없습니다.
< redux-thunk >
// src/index.server.js
import React from "react";
import ReactDOMServer from "react-dom/server";
import express from "express";
import { StaticRouter } from "react-router-dom/server";
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 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, 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>
</html>
`;
}
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); // 모든 프로미스를 기다립니다.
console.log("promise");
} catch (e) {
return res.staus(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>`; // 리덕스 초기 상태를 스크립트로 주입합니다.
res.send(createPage(root, stateScript)); // 클라이언트에게 결과물을 응답합니다.
};
const serve = express.static(path.resolve("./build"), {
index: false, // "/" 경로에서 index.html 을 보여주지 않도록 설정
});
app.use(serve); // 순서가 중요합니다. serverRender 전에 위치해야 합니다.
app.use(serverRender);
// 5000포트로 서버를 가동합니다.
app.listen(8888, () => {
console.log("Running on http://localhost:8888");
});
// src/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,
},
};
const 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;
// src/containers/UsersContainer.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);
// src/components/users.js
import React from "react";
import { Link } from "react-router-dom";
const Users = ({ users }) => {
if (!users) {
return null;
}
return (
<div>
<ul>
{users.map((user) => (
<li key={user.id}>
<Link to={`/users/${user.id}`}>{user.username}</Link>
</li>
))}
</ul>
</div>
);
};
export default Users;
// src/lib/PreloadContext.js
import { createContext, useContext } from "react";
// 클라이언트 환경: null
// 서버 환경:{ done: false, promises: [] }
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는 서버사이드 렌더링을 하는 과정에서 처리해야 할 작업들을 실행하고, 만약 기다려야 하는 프로미스가 있다면
// 프로미스를 수집합니다. 모든 프로미스를 수집한 뒤, 수집된 프로미스들이 끝날 때까지 기다렸다가 그다음에 다시 렌더링하면
// 데이터가 채워진 상태로 컴포넌트들이 나타나게 됩니다
// src/index.js
import React from "react";
import ReactDOM from "react-dom";
import App from "./App";
import { BrowserRouter as Router } 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}>
<Router>
<App />
</Router>
</Provider>,
document.getElementById("root")
);
우선 redux-thunk를 사용하여 API 호출 후, 데이터를 가져오는 코드를 작성했습니다. ( Ducks 패턴 )
그 후, getUsers라는 thunk함수를 만들고, 이와 관련된 GET_USERS_PENDING, GET_USERS_SUCCESS, GET_USERS_FAILURE를 사용하여 상태 관리를 해 주고 있습니다.
모듈을 다 작성한 뒤에는 루트 리듀서를 만들고 Provider컴포넌트를 사용하여 프로젝트에 리덕스를 적용해야 합니다.
그 후, 기본적인 Users, UsersContainer컴포넌트를 만들어줍니다.
서버사이드 렌더링을 할 때는 이미 있는 정보를 재요청하지 않게 처리하는 작업이 중요합니다. 이 작업을 하지 않으면 서버 사이드 렌더링 후 브라우저에서 페이지를 확인할 때 이미 데이터를 가지고 있음에도 불필요한 API를 호출하게 됩니다. 그러면 트래픽도 낭비되고 사용자 경험도 저하될 것입니다.
그 후PreloadContext를 만들어야 합니다. getUser함수는 UsesContainer의 useEffect 부분에서 호출됩니다. 이를 클래스형으로 작성했더라면 componentDidMount에서 호출했었어야 합니다. 서버 사이드 렌더링을 할 때는 useEffect나 componentDidMount에서 설정한 작업이 호출되지 않습니다. 렌더링하기 전에 API를 요청한 뒤 스토어에 데이터를 담아야 합니다. 서버 환경에서 이러한 작업을 미리 하려면 클래스형 컴포넌트가 지니고 있는 constructor메서드를 사용하거나 render함수 자체에서 처리해야 합니다. 그리고 요청이 끝날 때까지 대기했다가 다시 렌더링 해주어야 하죠.
우리는 이 작업을 PreloadContext를 만들고, 이를 사용하는 Preloader컴포넌트를 만들어 처리해 보았습니다.
PreloadContext는 서버 사이드 렌더링을 하는 과정에서 처리해야 할 작업들을 실행하고, 만약 기다려야 하는 프로미스(promise)가 있다면 프로미스를 수집합니다. 모든 프로미스를 수집한 뒤, 수집된 프로미스들이 끝날 때까지 기다렸다가 그다음 다시 렌더링하면 데이터가 채워진 상태로 컴포넌트들이 나타나게 됩니다.
굳이 PreloadContext를 사용하지 않아도 index.server.js에서 ./build/index.html에 __DATA_FROM_SERVER__을 Promise.all을 돌려서 replace해도 된다.
Preloader컴포넌트는 resolve라는 함수를 props로 받아 오며, 컴포넌트가 렌더링될 때 서버 환경에서만 resolve 함수를 호출해 줍니다.
그 후에는 서버에서 리덕스를 설정해 주어야 합니다. 서버에서 리덕스를 설정하는 것은 브라우저에서 할 때와 비교하여 큰 차이가 없습니다. 주의할 점은 스토어를 한 번만 만드는 것이 아니라, 요청이 들어올 때마다 새로운 스토어를 만든다는 것입니다.
첫 번째 렌더링을 할 때는 renderToString 대신 renderToStaticMarkup이라는 함수를 사용했습니다. renderToStaticMarkup은 리액트를 사용하여 정적인 페이지를 만들 때 사용합니다. 이 함수로 만든 리액트 렌더링 결과물은 클라이언트 쪽에서 HTML DOM인터랙션을 지원하기 힘듭니다.
지금까지 작성한 코드는 API를 통해 받아 온 데이터를 렌더링 하지만, 렌더링하는 과정에서 만들어진 스토어의 상태를 브라우저에서 재사용하지 못하는 상황입니다. 서버에서 만들어 준 상태를 브라우저에서 재사용하려면, 현재 스토어 상태를 문자열로 변환한 뒤 스크립트를 주입해 주어야 합니다.
브라우저에서 상태를 재사용할 때는 window.__PRELOADED_STATE__를 초깃값으로 사용하면 됩니다.
결과를 확인해 봅시다. localhost:8888/users를 가보면
와 같이 __PRELOADED_STATE__에서 주입된 데이터들이 서버에서 미리 랜더링되어 화면에 표시되어 User 경험을 향상 시키는 것을 볼 수 있습니다.
서버 사이드 렌더링과 코드 스플리팅
이제 서버 사이드 렌더링을 구현한 프로젝트에 코드 스플리팅을 도입해 볼 차례입니다. 일단 리액트에서 공식적으로 제공하는 코드 스플리팅 기능인 React.lazy와 Suspense는 서버 사이드 렌더링을 아직 지원하지 않습니다. 서버 사이드 렌더링과 코드 스플리팅을 함께 사용할 때는 Loadable Components를 사용할 것을 권장하고 있습니다.
Loadable Components에서는 서버 사이드 렌더링을 할 때 필요한 서버 유틸 함수와 웹팩 플러그인, babel 플러그인을 제공해 줍니다.
yarn add @loadable/component @loadable/server @loadable/webpack-plugin @loadable/babel-plugin
// src/App.js
import React from "react";
import { Route, Routes } 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"));
function App() {
return (
<div className="App">
<div>
<Menu />
<hr />
<Routes>
<Route path="/red" element={<RedPage />} />
<Route path="/blue" element={<BluePage />} />
<Route path="/users" element={<UsersPage />} />
</Routes>
</div>
</div>
);
}
export default App;
// package.json
"babel": {
"presets": [
"react-app"
],
"plugins": [
"@loadable/babel-plugin"
]
},
...
// config/webpack.config.js
const LoadablePlugin = require("@loadable/webpack-plugin");
(...)
plugins: [
new LoadablePlugin(),
(...)
].filter(Boolean),
(...)
// build/loadable-stats.json
{
"hash": "9e287c9391d1e49b522e",
"publicPath": "/",
"outputPath": "/Users/ihyeonseo/Desktop/Coding/Web/React/ssr-recipe/build",
"assetsByChunkName": {
"main": [
"static/js/main.f0f73e47.js"
],
"pages-RedPage": [
"static/css/pages-RedPage.113fbfe2.chunk.css",
"static/js/pages-RedPage.9343a78c.chunk.js"
],
"pages-BluePage": [
"static/css/pages-BluePage.14a10ef2.chunk.css",
"static/js/pages-BluePage.bc3050ea.chunk.js"
],
"pages-UsersPage": [
"static/js/pages-UsersPage.b40ec068.chunk.js"
]
},
"assets": [
{
"type": "asset",
"name": "static/js/main.f0f73e47.js",
"size": 197915,
"emitted": false,
"comparedForEmit": false,
"cached": true,
"info": {
"immutable": true,
"contenthash": "f0f73e47",
"javascriptModule": false,
"minimized": true,
"related": {
"license": "static/js/main.f0f73e47.js.LICENSE.txt",
"sourceMap": "static/js/main.f0f73e47.js.map"
}
},
"chunkNames": [
"main"
],
"chunkIdHints": [],
"auxiliaryChunkNames": [],
"auxiliaryChunkIdHints": [],
"filteredRelated": 2,
"chunks": [
179
],
"auxiliaryChunks": []
},
{
"type": "asset",
"name": "static/js/pages-UsersPage.b40ec068.chunk.js",
"size": 931,
"emitted": false,
"comparedForEmit": false,
"cached": true,
"info": {
"immutable": true,
"contenthash": "b40ec068",
"javascriptModule": false,
"minimized": true,
"related": {
"sourceMap": "static/js/pages-UsersPage.b40ec068.chunk.js.map"
}
},
"chunkNames": [
"pages-UsersPage"
],
"chunkIdHints": [],
"auxiliaryChunkNames": [],
"auxiliaryChunkIdHints": [],
"filteredRelated": 1,
"chunks": [
850
],
"auxiliaryChunks": []
},
{
"type": "asset",
"name": "index.html",
"size": 584,
"emitted": false,
"comparedForEmit": false,
"cached": true,
"info": {},
"chunkNames": [],
"chunkIdHints": [],
"auxiliaryChunkNames": [],
"auxiliaryChunkIdHints": [],
"chunks": [],
"auxiliaryChunks": []
},
{
"type": "asset",
"name": "static/js/pages-BluePage.bc3050ea.chunk.js",
"size": 351,
"emitted": false,
"comparedForEmit": false,
"cached": true,
"info": {
"immutable": true,
"contenthash": "bc3050ea",
"javascriptModule": false,
"minimized": true,
"related": {
"sourceMap": "static/js/pages-BluePage.bc3050ea.chunk.js.map"
}
},
"chunkNames": [
"pages-BluePage"
],
"chunkIdHints": [],
"auxiliaryChunkNames": [],
"auxiliaryChunkIdHints": [],
"filteredRelated": 1,
"chunks": [
29
],
"auxiliaryChunks": []
},
{
"type": "asset",
"name": "static/js/pages-RedPage.9343a78c.chunk.js",
"size": 350,
"emitted": false,
"comparedForEmit": false,
"cached": true,
"info": {
"immutable": true,
"contenthash": "9343a78c",
"javascriptModule": false,
"minimized": true,
"related": {
"sourceMap": "static/js/pages-RedPage.9343a78c.chunk.js.map"
}
},
"chunkNames": [
"pages-RedPage"
],
"chunkIdHints": [],
"auxiliaryChunkNames": [],
"auxiliaryChunkIdHints": [],
"filteredRelated": 1,
"chunks": [
645
],
"auxiliaryChunks": []
},
{
"type": "asset",
"name": "static/css/pages-BluePage.14a10ef2.chunk.css",
"size": 197,
"emitted": false,
"comparedForEmit": false,
"cached": true,
"info": {
"immutable": true,
"contenthash": "14a10ef2",
"minimized": true,
"related": {
"sourceMap": "static/css/pages-BluePage.14a10ef2.chunk.css.map"
}
},
"chunkNames": [
"pages-BluePage"
],
"chunkIdHints": [],
"auxiliaryChunkNames": [],
"auxiliaryChunkIdHints": [],
"filteredRelated": 1,
"chunks": [
29
],
"auxiliaryChunks": []
},
{
"type": "asset",
"name": "static/css/pages-RedPage.113fbfe2.chunk.css",
"size": 194,
"emitted": false,
"comparedForEmit": false,
"cached": true,
"info": {
"immutable": true,
"contenthash": "113fbfe2",
"minimized": true,
"related": {
"sourceMap": "static/css/pages-RedPage.113fbfe2.chunk.css.map"
}
},
"chunkNames": [
"pages-RedPage"
],
"chunkIdHints": [],
"auxiliaryChunkNames": [],
"auxiliaryChunkIdHints": [],
"filteredRelated": 1,
"chunks": [
645
],
"auxiliaryChunks": []
}
],
"namedChunkGroups": {
"main": {
"name": "main",
"chunks": [
179
],
"assets": [
{
"name": "static/js/main.f0f73e47.js"
}
],
"filteredAssets": 0,
"assetsSize": null,
"filteredAuxiliaryAssets": 1,
"auxiliaryAssetsSize": null,
"children": {},
"childAssets": {}
},
"pages-RedPage": {
"name": "pages-RedPage",
"chunks": [
645
],
"assets": [
{
"name": "static/css/pages-RedPage.113fbfe2.chunk.css"
},
{
"name": "static/js/pages-RedPage.9343a78c.chunk.js"
}
],
"filteredAssets": 0,
"assetsSize": null,
"filteredAuxiliaryAssets": 2,
"auxiliaryAssetsSize": null,
"children": {},
"childAssets": {}
},
"pages-BluePage": {
"name": "pages-BluePage",
"chunks": [
29
],
"assets": [
{
"name": "static/css/pages-BluePage.14a10ef2.chunk.css"
},
{
"name": "static/js/pages-BluePage.bc3050ea.chunk.js"
}
],
"filteredAssets": 0,
"assetsSize": null,
"filteredAuxiliaryAssets": 2,
"auxiliaryAssetsSize": null,
"children": {},
"childAssets": {}
},
"pages-UsersPage": {
"name": "pages-UsersPage",
"chunks": [
850
],
"assets": [
{
"name": "static/js/pages-UsersPage.b40ec068.chunk.js"
}
],
"filteredAssets": 0,
"assetsSize": null,
"filteredAuxiliaryAssets": 1,
"auxiliaryAssetsSize": null,
"children": {},
"childAssets": {}
}
},
"generator": "loadable-components",
"chunks": [
{
"id": 179,
"files": [
"static/js/main.f0f73e47.js"
]
},
{
"id": 645,
"files": [
"static/css/pages-RedPage.113fbfe2.chunk.css",
"static/js/pages-RedPage.9343a78c.chunk.js"
]
},
{
"id": 29,
"files": [
"static/css/pages-BluePage.14a10ef2.chunk.css",
"static/js/pages-BluePage.bc3050ea.chunk.js"
]
},
{
"id": 850,
"files": [
"static/js/pages-UsersPage.b40ec068.chunk.js"
]
}
]
}
// src/index.server.js
import React from "react";
import ReactDOMServer from "react-dom/server";
import express from "express";
import { StaticRouter } from "react-router-dom/server";
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 from "./lib/PreloadContext";
import { ChunkExtractor, ChunkExtractorManager } from "@loadable/server";
const statsFile = path.resolve("./build/loadable-stats.json");
// asset-manifest.json에서 파일 경로들을 조회합니다.
const manifest = JSON.parse(
fs.readFileSync(path.resolve("./build/asset-manifest.json"), "utf8")
);
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 store = createStore(rootReducer, applyMiddleware(thunk));
const preloadContext = {
done: false,
promises: [],
};
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으로 한번 렌더링합니다.
try {
await Promise.all(preloadContext.promises); // 모든 프로미스를 기다립니다.
console.log("promise");
} catch (e) {
return res.staus(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(8888, () => {
console.log("Running on http://localhost:8888");
});
서버 사이드 렌더링 후 브라우저에서 어떤 파일을 사전에 불러와야 할지 알아내고 해당 파일들의 경로를 추출하기 위해 Loadable Components에서 제공하는 ChunkExtractor와 ChunkExtractorManager를 사용합니다.
Loadable Components를 통해 파일 경로를 조회하므로 기존에 asset-manifest.json을 확인하던 코드는 지워야 합니다.
또한 LoadableComponents를 사용하면 성능을 최적화하기 위해 모든 자바스크립트 파일을 동시에 받아 옵니다. 모든 스크립트가 로딩되고 나서 렌더링하도록 처리하기 위해서는 loadableReady라는 함수를 사용해 주어야 합니다. 추가로 리액트는 hydrate라는 함수가 있는데, 이 함수는 기존에 서버 사이드 렌더링된 결과물이 이미 있을 경우 새로 렌더링하지 않고 기존에 존재하는 UI에 이벤트만 연동하여 애플리케이션을 초기 구동할 때 필요한 리소스를 최소화함으로써 성능을 최적화 해줍니다.
'React > ReactJs' 카테고리의 다른 글
나만의 Webpack 개발환경 만들기 plugin ( React ) (0) | 2022.02.27 |
---|---|
HTML - History API (0) | 2022.02.23 |
React - React.lazy & Suspense & Loadable Components (0) | 2022.02.22 |
React - Redux MiddleWare >> redux-saga (0) | 2022.02.22 |
React - Redux MiddleWare >> default + redux-thunk (0) | 2022.02.22 |