Nodejs

React Server Side Rendering

La.place 2017. 4. 30. 18:26


 

+ 추가

SSR에 대해 아티클을 작성한지 어느덧 2년이 되어간다. 그동안 Webpack, Babel, React 버전이 올라감에 따라 프로젝트의 설정방법과 몇몇 API가 변경되었다. 따라서 가장 최신으로 SSR 프로젝트를 구성하는 방법으로 다시 글을 작성했다. Next.js 같은 좋은 프레임워크도 있지만 프레임워크에는 커스터마이징에 한계가 있으므로, 이 글에서는 직접 만들 경우엔 어떻게 하는지에 초점을 맞춘다. 소스코드는 Github에 올려두었으므로 참고하길 바라며, 사용한 라이브러리와 툴은 아래와 같다.


  • Babel 7
  • Webpack 4
  • React 16.7.x



 작년과 올해 들어 가장 각광받는 JavaScript Library를 뽑아보면 React도 그 안에 들어갈 것이다. React의 장점을 생각해보면 Virtual DOM, JSX, Component 그리고 Flux 등 여러가지가 있을 것이다. 그 중에서 이번엔 서버 랜더링에 대해 알아보고 정리해 보도록 하자. React는 설계 단계부터 Server-Side Rendering(SSR)을 고려하여 설계되었다.  그렇다면 Server-Side Rendering이란 무엇이고, 어떤 장점이 있는 것일까? 이에 대해서는 다음 아티클을 읽어보도록 하자. '왜 React와 서버 사이드 렌더링인가?' 


 정리해 보자면 기존 웹어플리케이션은 javscript의 로딩이 모두 완료된 후 View를 그리기 시작한다. 그러나 서버 랜더링을 통해 미리 View를 만들어 내려주면 js가 로드되기 이전에도 사용자는 View를 볼 수 있다. 실제 사용자가 처음 UI를 보게 되는 시간이 js 로딩 후 렌더링되는 시간만큼 줄어드는 것이다. 또 js가 로딩되기 전엔 빈 화면만 보여주기 때문에, 크롤러(웹 사이트의 문서를 읽어와서 검색엔진에 저장해주는 bot)들이 접속했을 때 정보를 긁어갈 수 없으므로 검색 엔진에 노출되는데 불리한 점이 있다.(물론 구글같이 js를 실행해서 완성된 문서를 크롤링하는 곳도 있다.)


 그렇다면 한가지 의문이 떠오른다. 서버에서 랜더링을 해서 내려준 후에 클라이언트에서 다시 한번 랜더링하면 비효율적인게 아닌가? 결론부터 말하면 그렇지 않다. React의 경우는 하나의 소스로 Server-Side Rendering과 Client-Side Rendering 둘다 가능하다. React로 만들어진 코드는 서버에서 랜더링 된 후 클라이언트에서 React 소스가 실행되면 Checksum을 통해 로딩됬을때 바뀌어야 할 부분만 다시 랜더링 해준다. 


Project Setting

먼저 React를 빌드 할 수 있는 환경을 만들어 보도록 하자. 여기서는 Webpack4 와 Babel 7을 이용해서 빌드할 것이다. 먼저 npm을 이용해서 Webpack을 설치하자.

$ npm install --save-dev webpack webpack-cli babel-loader

다음으로 Babel을 설치하도록 하자. Babel은 ES6문법과 React를 함께 빌드해주는 역할을 한다.

$ npm install --save-dev @babel/core @babel/preset-env @babel/preset-react

이제 React를 설치하자.

$ npm install --save react react-dom


 이제 Webpack build 설정을 해보도록 하자. 프로젝트 루트 디렉토리에 webpack.config.js 파일을 만든 후 다음과 같이 작성한다. 중요한 부분은 module 부분이다. babel-loader를 통해서 webpack에서 build할때 babel을 사용할 수 있다. option으로 앞에서 설치한 ES2015(env)과 react를 주면 빌드될 때 자동으로 ES5코드로 트랜스파일링 된다.


webpack.config.js


const { resolve } = require('path');

module.exports = {
context : resolve(__dirname, 'client'),
entry : ['./index.js'],
output : {
filename : 'client.bundle.js', // output filename
path : resolve(__dirname, 'build'), // output path
publicPath : '/'
},

module : {
rules : [
{
test : /\.jsx?$/,
exclude : /node_modules/,
use : {
loader : 'babel-loader',
options : {
presets : ['@babel/preset-env', '@babel/preset-react']
},
}
}
],
}
};


이제 간단한 Client와 Server Source를 작성해 보도록 하자. 우리는 Server에서 랜더링 될 땐 'Server-Side Rendering' 글자가 보이고, Client 로딩이 끝났을 땐 'Client Loaded' 글자가 보이는 코드를 만들어 볼 것이다. 다음은 전체적인 폴더 구조이다.

Root
|-client
| |- app.js
| |- index.js
|-server
| |- Html.js
| |- index.js
|- .babel.rc
|- package.json
|- webpack.config.js

Client Source

 이제 간단한 Client Source를 작성해 보도록하자. index.js 파일을 만든 후 React를 DOM에 바인딩 하는 소스를 적어주자. App.js에서는 텍스트를 출력하는 Component를 만들어 주자. App은 text라는 state를 가지고 있으며 이를 <h1>태그로 출력해준다. ComponentDidMount() 함수는 React에서 제공하는 Lifecycle 함수이며, 컴포넌트가 Mount되었을 때 호출되는 함수이다. 지금은 비워두도록 하자.

client/index.js

import React from 'react';
import ReactDOM from 'react-dom';
import App from './App'

ReactDOM.render(<App/>, document.getElementById('root'));


client/App.js


import React, {Component} from 'react';

class App extends Component {

constructor(props) {
super(props);
this.state = {
text: 'Server-Side Rendering'
}
}

render() {
return (
<div>
<h1>{this.state.text}</h1>
</div>
);
}
}

export default App;

 

다음과 같이 작성 후 다음 명령어를 실행해 보자.

$ npx webpack

루트에 build라는 폴더가 생성되고 그 안에 client.bundle.js라고 빌드된 번들 파일이 생성된 것을 확인할 수 있다. 제대로 작동하는지 확인해 보기 위해선 html파일을 생성한 후 bundle 파일을 실행시켜보면 된다. 다음과 같은 html을 생성한 후에 로컬 서버에 띄어보도록 하자. Server-Side Rendering 글자가 나타나면 제대로 build 된 것이라 할 수 있다.


text.html

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>React Server-Side Rendering</title>
</head>
<body>
<div id="root"></div>
</body>
<script src="build/client.bundle.js"></script>
</html>

[그림] 실행 결과


Server Source

 이제 Server Source를 작성해 보도록 하자. 간단한 개발 환경도 설정해 볼 것이다. 먼저 nodejs에서 많이 사용하는 express framework를 설치해 보도록하자.

$ npm install express --save

 자 이제 간단한 웹 서버 코드를 만들 것이다. http://localhost:3000/ 으로 접속하면 'Hello React Server-Side Rendering 글자를 보여주게 하자.  server 폴더 하위에 index.js 파일을 만든 후 다음과 같이 작성한다.


server/index.js


const express = require('express');

const app = express();
const port = 3000;

app.get('/', function (req, res, next) {
res.send('<h1>Hello React Server-Side Rendering</h1>');
});

app.listen(port, () => {
console.log('http://localhost:3000')
});


이제 terminal에서 다음 명령어를 실행시켜 준 후에 http://localhost:3000 으로 접속해 보도록 하자.

$ node server/index.js

[그림] 서버 실행 결과



본격적으로 Server-Side Rendering 소스를 작성하기에 앞서 간단한 서버 개발 환경을 만들어 보도록 하자. 소스코드 수정 후 일일이 서버를 재시작하며 확인하기란 여간 귀찮은 일이 아닐 수 없다. 따라서 js를 watch하고 있다가 자동적으로 reload해주는 nodemon이란 모듈을 사용해 보도록 하자. nodemon에 관해 더 자세한 내용은 npm-nodemon을 참고하도록 하자. 또 import나 jsx파싱을 위해서 여기서는 간단하게 babel-node를 사용할 것이다.

$ npm install --save-dev nodemon @babel/node

babel-node는 node를 바벨로 컴파일하여 실행시켜주는 역할을 한다. babel-node의 설정은 .babelrc 파일에 정의해주면 된다. 실제로 프로젝트에는 사용하는것을 권장하지 않으므로 개발 모드에서만 사용하도록 하자. .babelrc파일을 만들어 다음과 같이 설정한다.


.babelrc

{
"presets": ["@babel/preset-env", "@babel/preset-react"]
}


설치 후 다음과 같이 실행한다. (node에서는 폴더명을 지정하면 폴더의 index.js를 찾아 실행시켜 준다.)

$ npx nodemon --exec babel-node ./server

nodemon이 실행된 후 서버 소스코드를 수정해보자. index.js에서 느낌표 두개를 더 붙여보았다. 그 후 서버가 재시작되면서 변경된 부분이 반영되는 것을 확인할 수 있다.


res.send('<h1>Hello React Server-Side Rendering !!</h1>');


Server-Side Rendering Code

이제 본격적으로 서버에서 React를 Rendering하는 코드를 작성해보도록 하자. server-side에서 component를 rendering하기 위해서는 react-dom에 있는 renderToString() Method를 사용한다. server에서 View를 그릴 때 초기값을 prealoadedState라 할때, client에서도 똑같은 props를 넘겨주어야 한다. 따라서 DOM이 모두 그려졌을때 window객체에 임시로 props를 전달한 후 이를 App의 props에 전달한다.


server/index.js


// Express
import express from 'express';

// React
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import App from '../client/App';
import Html from './Html';

const app = express();
const port = 3000;

app.get('/', function (req, res, next) {

let preloadState = {
text: 'Server-Side Rendering'
};

let renderProps = {
preloadState: `window.__PRELOADED_STATE__ =${JSON.stringify(preloadState).replace(/</g, '\\u003c')}`,
script: '/build/client.bundle.js'
};

ReactDOMServer.renderToNodeStream(
<Html {...renderProps}>
<App data={preloadState}/>
</Html>
).pipe(res);
});

app.listen(port, () => {
console.log('http://localhost:3000')
});

renderToStaticMarkup() method를 이용해서 html파일을 생성하도록하자. server폴더 밑에다가 html.js 파일을 만든 후 다음과 같이 적어주도록 하자. 정적 html파일에 미리 rendering된 App Component를 넣어준 후 res.send() method를 통해 클라이언트에게 응답을 보내준다.


server/Html.js


import React from 'react';

const Html = (props) => (
<html lang="en">
<head>
<meta charSet="utf-8"/>
<title>React Server-Side Rendering</title>
<script dangerouslySetInnerHTML={{ __html: props.preloadState }}></script>
</head>
<body>
<div id="root">{props.children}</div>
<script src={props.script}></script>
</body>
</html>
);

export default Html;


이제 preloadState를 이용해서 서버에서 랜더링된 결과엔 'Server-Side Rendering', 클라이언트가 로드 된 후엔 'Client Loaded' 글자가 보이도록 할 것이다. 먼저 View에서 보이는 글자를 props로 관리하도록 변경할 것이다. 먼저 클라이언트 App Component에 props를 전달해 주어야 하므로 client/index.js를 수정하도록 하자. 우리는 서버에서 랜더링 할때 prealodState를 window객체를 통해서 전달하였으므로, App의 props에 넣어주도록 하자. 그리고 ReactDOM의 render method를 v16의 새로운 API인 hydrate로 변경한다.


client/index.js


import React from 'react';
import ReactDOM from 'react-dom';
import App from './App'

ReactDOM.hydrate(<App data={window.__PRELOADED_STATE__}/>, document.getElementById('root'));


다음으로 App.js에서 보여주는 글자는 state로 관리하고 있었다. constructor부분을 다음과 같이 수정한다. 다음으로 componentDidMount() 함수에서 setState함수를 이용해서 text state를 변경하도록 하자. 여기서 한가지 알아둬야 할 사항이 있다. server에서 rendering할 땐 React Lifecycle에서 comonentDidMount 직전까지 랜더링 된다. 따라서 client에서 js가 로드된 후에 checksum이 실행되고 이상이 없으면 componentDidMount() 함수가 실행이 된다. 정리하면 componentDidMount() 함수는 오직 client에서만 실행이 되는 것이다. 따라서 ajax call이나 rendering이 완료된 후에 실행할 로직들은 componentDidMount()에서 실행시켜야 한다.


client/App.js


import React, { Component } from 'react';

class App extends Component {

constructor(props){
super(props);
this.state = {
text : this.props.data.text
}
}

render() {
return (
<div>
<h1>{this.state.text}</h1>
</div>
);
}

componentDidMount(){
this.setState({
text : 'Client Loaded'
})
}
}

export default App;

이제 webpack 명령어를 실행해서 client를 빌드한 후에 다시 서버를 실행시켜 보자. 서버를 띄운 후 브라우저로 확인해보면 아직 Server-Side Rendering 글자만 보인다는 것을 알 수 있다. inspector창을 열면 client.bundle.js 경로를 찾이못해 404 Not Found가 발생했다는 에러를 볼 수 있다. 로컬 경로의 client.bundle.js 경로를 잡아주어도 되지만 여기서는 webpack을 이용해서 client source도 자동으로 빌드 한 후 http://localhost:3000/build/client.bundle.js 경로에 직접 올려 보도록 하자.


WebpackDevMiddleware

 WebpackDevMiddleware는 nodejs express에서 Webpack Build를 자동화 해주는 middleware이다. npm을 이용해서 간단하게 설치할 수 있다.

$ npm install webpack-dev-middleware --save-dev


server폴더 아래에 WDM.js란 파일을 만든 후 다음과 같이 적어주도록 하자. WebpackDevMiddleware에 대한 자세한 사용법에 대해선 github/webpack-dev-middleware을 참고하도록 하자. 이번 포스팅에서는 간단하게만 설명하도록 하겠다. 이 미들웨어를 사용하면 client source를 watch하고 있다가 request가 들어오거나 소스가 변경되면 자동으로 webpack.config.js파일을 읽어 Webpack 설정대로 빌드하게 된다. 또 빌드된 파일은 메모리에 올려 따로 저장하지 않는다.


server/WDM.js


import webpack from 'webpack';
import webpackDevMiddleware from 'webpack-dev-middleware';
import webpackConfig from '../webpack.config.js';

const webpackCompiler = webpack(webpackConfig);

export default webpackDevMiddleware(webpackCompiler,{

// lazy가 true일 경우, request가 있을 때만 reload.
lazy: false,

// watch options (only lazy: false)
watchOptions: {
aggregateTimeout: 150,
poll: true
},

// 빌드된 소스가 올라갈 memory path
publicPath: '/build',

stats: {
colors: true
},
serverSideRender: true,
});


자 이제 server/index.js 에 다음과 같이 WDB을 불러온 후 middleware에 추가해 주도록 하자.


import WDM from './WDM'
app.use(WDM);


server/index.js


// Express
import express from 'express';

// React
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import App from '../client/App';
import Html from './Html';
import WDM from './WDM' // WDM 추가

const app = express();
const port = 3000;

app.use(WDM); // WDM 추가

app.get('/', function (req, res, next) {

let preloadState = {
text: 'Server-Side Rendering'
};

let renderProps = {
preloadState: `window.__PRELOADED_STATE__ =${JSON.stringify(preloadState).replace(/</g, '\\u003c')}`,
script: '/build/client.bundle.js'
};

ReactDOMServer.renderToNodeStream(
<Html {...renderProps}>
<App data={preloadState}/>
</Html>
).pipe(res);
});

app.listen(port, () => {
console.log('http://localhost:3000')
});


이제 서버를 재시작한 후에 locahost로 접속해 보도록 하자. 서버에서 랜더링 된 직후와 js가 로드됬을 때 글자가 업데이트 되는 것을 볼 수 있다.



[그림] Server-Side Rendering이 반영된 모습


마치며

처음 Server-Side Rendering 코드를 만들 땐 관련 예제를 찾기가 쉽지 않았다. 문서가 있어도 버전이 달라서 안되는 부분이 많았고, webpack이라던가 babel등 연동할때도 많은 삽질이 필요했다. 그러나 요즘엔 관련 포스팅이 많으므로 예전보단 쉽게 개발 환경을 만들 수 있지 않을까 한다. 위에서 만든 개발환경은 정말 초 간단 버전이므로 실제 개발 환경에서는 좀 더 추가적인 설정이 필요하다(react-hot-reload 라던가 좀 더 좋은 모듈들이 많이 존재한다. babel-node도 production에선 사용하면 안된다). 해당 부분에 대해서는 직접 찾아보길 바란다. 또 React하면 빼놓을 수 없는게 Redux인데 Redux를 사용한 Server-Side Rendering 방법은 다음 포스팅을 읽어보도록 하자(Redux Server Rendering). 엄청 친절하게 잘 써놓았다. 아무튼 이번 포스팅을 하면서 React와 Server Rendering에 관한 부분을 확실하게 정리할 수 있어서 뭔가 개운하다.


React v15를 사용하는 사람은 다음 소스를 참고하도록 하자 -> https://github.com/haegul/study_example/tree/react/server-side-rendering

'Nodejs' 카테고리의 다른 글

Hello Nestjs  (1) 2019.04.04
nodejs error handling  (0) 2017.04.23
LINE Bot 만들기 - 3. Nodejs 서버 만들기  (4) 2016.10.29
LINE Bot 만들기 - 2. AWS 서버 & SSL 인증서  (1) 2016.10.29
LINE Bot 만들기 - 1. 시작하기  (1) 2016.10.22