React에서 ReactDOMServer객체를 이용하면 컴포넌트를 정적마크업으로 렌더링 할 수 있다. 대체로 Node서버에서 사용한다.
이전 포스팅 SSR과 CSR의 비교에서 포스팅했던 SSR에 대한 내용을 정리해보면
1. 서버에서 먼저 HTML문자열을 전송하게 된다. 이 때는 단순히 볼 수는 있는 화면을 제공한다. 클릭 등 사용자의 액션에 반응하지 않는다.
2. 브라우저는 JS번들을 다운로드해 리액트를 실행시킨다. 사용자는 여전히 브라우저가 그린 화면은 볼 수 있다.
3. 리액트가 가상돔생성과 이벤트주입 과정을 거쳐 인터렉티브한 웹이 만들어진다. 이 과정을 거치고나면 사용자의 액션에 반응하는 앱이 된다.
ReactDOMServer
가 제공하는 함수들은 1번의 과정이다.
ReactDOMServer에서 제공하는 함수들을 알아보자
renderToString()
, renderToStaticMarkup()
은 서버와 브라우저 환경 모두에서 동작한다.
renderToNodeStream()
, renderToStaticNodeStream()
은 서버환경에서만 동작하는데 서버에서만 사용할 수 있는 stream
패키지에 의존성이 있기 때문이다.
renderToStaticMarkup()
, renderToStaticNodeStream()
은 이름에서 보여지는 것과 같이 간단한 정적페이지를 위해 사용한다.
간단한 정적페이지란 data-reactroot와 같이 React에서 내부적으로 사용하는 추가적인 DOM 어트리뷰트를 만들지 않기 때문에 리액트에 의한 상호작용이 없으며, 이 어트리뷰트가 없기 때문에 이 후 ReactDOM.hydrate()
로 상호작용이 가능한 이벤트를 주입할 수 없다.
이 어트리뷰트가 없기 때문에 약간의 바이트를 절약할 수 있다.
대부분의 경우에는 renderToString()
과 ReactDOM.hydrate()
함수를 이용해 HTML문자열을 전달해 화면을 그리고, 이벤트를 주입하게된다.
test-ssr
이라는 폴더를 만들고 프로젝트를 생성한다.
mkdir test-ssr
cd test-ssr
yarn init -y
react-dom은 리액트의 가상돔 생성에 필요하며, 서버렌더러를 포함하고있다.
yarn add react react-dom
yarn add express
yarn add @babel/core
yarn add @babel/cli
yarn add @babel/preset-env
yarn add @babel/preset-react
yarn add @babel/plugin-transform-modules-commonjs
가독성을 위해서 한 줄 씩 작성했지만 아래 명령줄을 복붙해도 된다.
yarn add @babel/core @babel/cli @babel/preset-env @babel/preset-react @babel/plugin-transform-modules-commonjs
@babel/core
는 바벨 컴파일러의 핵심 기능을 담고있다. 바벨을 사용하기 위한 필수 설치항목이다.@babel/cli
는 명령줄 도구다.@babel/preset-env
는 브라우저환경에 맞춰 최신 자바스크립트 문법을 사용할 수 있도록 알아서 폴리필 해주는 모듈이다. 세부설정이 필요없다.@babel/preset-react
는 react에서 편리한 jsx구문을 사용할 수 있도록 자동으로 사전설정 해준다.@babel/plugin-transform-modules-commonjs
서버 환경에서 ems
(ecmascript module system)을 commonjs로 변환해준다. ems모듈 시스템을 사용한 리액트 코드를 노드서버에서 읽을 수 없다. 노드에서는 기본적으로 ems를 사용할 수 없기 때문이다.yarn add webpack webpack-cli clean-webpack-plugin html-webpack-plugin babel-loader
webpack에서 번들링과정에 리소스들을 읽는 loader
와 별개로 plugin
은 번들 후의 과정에 관여한다. 당연히 설정파일에서 설정할 수 있다.
html에 번들파일을 삽입해주는 플러그인과 빌드폴더를 비워주는 플러그인을 함께 설치했다.
html파일, 리액트 컴포넌트, 바벨 설정파일, 웹팩 설정파일를 작성해보자
<!DOCTYPE html>
<html>
<head>
<title>test-ssr</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
import React from 'react';
import ReactDom from 'react-dom';
import App from './App';
ReactDom.hydrate(<App />, document.getElementById('root'));
import React from 'react';
export default function App() {
const onClickButton = () => {
window.alert("clicked !!")
}
return (
<div>
<button onClick={onClickButton}>클릭</button>
</div>
)
}
module.exports = {
presets: ['@babel/preset-react', '@babel/preset-env'],
plugins: [],
};
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
entry: './src/index.js',
output: {
filename: '[name].js',
path: path.resolve(__dirname, 'dist'),
},
module: {
rules: [
{
test: /\.js$/,
use: 'babel-loader'
},
],
},
plugins: [
new HtmlWebpackPlugin({
template: './template/index.html',
}),
],
mode: 'production',
}
yarn webpack
을 실행하면 dist폴더에 main.js
와 index.html
이 생성되어있다.
현재까지 작성한 코드는 첫 페이지만을 전달하고있어 온전한 SSR이라고 하기 어렵다. 특히 hydrate함수로 이벤트 주입도 하지 않고있다.
서버측 코드를 작성 후 마저 수정한다.
import express from 'express';
import fs from 'fs';
import path from 'path';
import {renderToString} from 'react-dom/server';
import React from 'react';
import App from './App';
import * as url from 'url';
const app = express();
const html = fs.readFileSync(
path.resolve(__dirname, '../dist/index.html'),
'utf-8'
);
app.use('/dist', express.static('dist'));
app.get('/favicon.ico', (req,res) => res.sendStatus(204));
app.get("*", (req,res) => {
const parsedUrl = url.parse(req.url, true);
const page = parsedUrl.pathname ? parsedUrl.pathname.substr(1) : 'home';
const renderString = renderToString(<App page="home" />);
const initialData = {page};
const result = html
.replace(
'<div id="root"></div>',
`<div id="root">${renderString}</div>`
).replace('__DATA_FROM_SERVER__', JSON.stringify(initialData))
res.send(result);
});
app.listen(3000);
html
에 fs모듈로 번들링 결과폴더의 index.html
을 utf-8
인코딩 방식으로 읽어 문자열을 저장했다./favicon.ico
부분의 코드는 자동으로 요청하는 파비콘에 대해서는 get *
로 요청이 전달되지 않도록 차단했다.page
는 서버사이드에서 리액트의 라우팅을 위해 url에서 추출한 라우트 이름을 initialData
로 전달한다.renderToString
으로 리액트 최상위컴포넌트인 App
컴포넌트를 html문자열 형태로 바꿨다.응답결과는 문자열 형태의 html이며, 이 후 자바스크립트 번들이 실행되면 리액트가 실행되고 이벤트 등을 주입하게된다.
.replace('__DATA_FROM_SERVER__',JSON.stringify(initialData))
에 대한 설명은 클라이언트 측 코드를 수정하며 이어서 한다.
현재는 바벨이 기본으로 바라보는 설정파일 이름인 babel.config.js
를 사용하고 있다. 하지만 서버측에서 사용할 바벨설정과 클라이언트측 바벨설정이 달라진다.
서버측에서는 노드기반이기 때문에 폴리필 프리셋이 필요없으나 ECMA 모듈시스템을 읽을 수 없다.
클라이언트측엔 반대로 폴리필 프리셋이 필요하고 ECMA 모듈시스템은 사용할 수 있다.
module.exports = {
presets: ['@babel/preset-react'],
plugins: [],
}
const config = require('./.babelrc.common.js');
config.plugins.push('@babel/plugin-transform-modules-commonjs');
module.exports = config;
const config = require('./.babelrc.common.js');
config.presets.push('@babel/preset-env');
module.exports = config;
commonjs와 레거시 자바스크립트 문법으로 작성해야한다.
현재 webpack은 바벨의 기본설정파일명 .babelrc
또는 babel.config.js
만을 읽는다. webpack설정을 바꿔준다.
const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin')
module.exports = {
entry: './src/index.js',
output: {
filename: '[name].js',
path: path.resolve(__dirname, 'dist'),
publicPath: '/dist/',
},
module: {
rules: [
{
test: /\.js$/,
use: {
loader: 'babel-loader',
options: {
configFile: path.resolve(__dirname, '.babelrc.client.js')
}
},
},
],
},
plugins: [
new HtmlWebpackPlugin({
template: './template/index.html',
}),
],
mode: 'production',
}
publicPath는 html파일에서 정의하는 정적리소스, 글꼴 등을 읽는 경로다.
웹팩은 클라이언트측 바벨설정만을 이용하고, 서버측에서 바벨사용은 @babel/cli
를 이용한다.
따라서 package.json
에 스크립트를 추가한다.
"scripts": {
"build-server": "babel src --out-dir dist-server --config-file ./.babelrc.server.js",
"build": "npm run build-server && webpack",
"start": "node dist-server/server.js"
},
import react from 'react';
import ReactDom from 'react-dom';
import App from './App';
ReactDom.hydrate(<App />, document.getElementById('root'));
render()
함수를 hydrate()
로 변경한다.
이렇게 해주어야 이벤트가 정상적으로 동작한다.
render()
를 사용했을 땐 화면은 보여지기만 할 뿐 이벤트가 동작하지 않게된다.
yarn run build && yarn run start
localhost:3000
으로 접속하면 서버측에서 내려준 화면을 볼 수 있다. hydrate()
로 바꿔줬기 때문에 이벤트도 잘 동작한다.
babel.config.js
는 삭제한다.
현재 서버측 코드에서 요청 url
에 따라 __DATA_FROM_SERVER__
를 함께 전달하고 있지만, 클라이언트측에서 사용하는 코드는 없다.
다음 포스팅에서 이 전달하는 데이터를 이용해 라우팅을 구현해보고, 이미지 모듈,styled-components
, material-ui
, redux
를 연결할 계획이다.
그리고, 현재는 NextJS
를 사용하지 않았지만 이 후 포스팅에서는 Nextjs
를 이용해 환경설정하는 방법을 포스팅하고, 실무에 가까운 SEO 최적화를 포스팅할 계획이다.
책 실전리액트 프로그래밍과 Nextjs, React 등 공식문서들을 참고해 작성했습니다.