이 튜토리얼은 3개의 포스트로 나뉘어진 이어지는 강좌입니다. 목차를 확인하시려면 여기를 참고하세요.
3장. 서버사이드 렌더링
이번 장에서는 리액트 어플리케이션을 서버사이드 렌더링 하는 방법을 알아보겠습니다. 여러분이 리액트를 통해 어플리케이션을 개발하게 될 때, 서버사이드 렌더링을 구현 할 수도, 하지 않을 수도 있습니다. 필수작업은 아니기 때문이지요. 서버사이드 렌더링을 구현하기 전에, 먼저 서버사이드 렌더링이 실제로 여러분의 프로젝트에 정말 필요한지 고려해볼 필요가 있습니다.
확실히, 서버사이드렌더링은 멋집니다. 어떤 상황에서는 필수일수도 있지요. 하지만 단점 또한 존재합니다.
서버사이드 렌더링을 통하여 얻을 수 있는 이점
검색엔진 최적화
서버사이드 렌더링을 통하여 얻을 수 있는 가장 큰 이점은 검색엔진 최적화 입니다. 리액트, 혹은 다른 자바스크립트 라이브러리/프레임워크로 만들어져 뷰 렌더링이 자바스크립트 위주로 돌아가는 프로젝트는, 자바스크립트 엔진이 돌아가지 않으면 원하는 정보를 표시해주지 않습니다.
한번, 리액트로 만든 프로젝트를 브라우저로 열어서 페이지 우클릭을하여 소스보기를 해보세요.
내용이 비어있습니다.
따라서, 이렇게 클라이언트 렌더링만 될 경우엔 검색엔진 크롤러가 여러분의 어플리케이션이 지닌 데이터들을 제대로 수집하지 못합니다. 하지만 그렇다고 해서 너무 걱정하지는 마세요. 검색엔진중 가장 중요한 구글 검색엔진은 크롤러에 자바스크립트 엔진이 내장되어 있어서 우리가 별도로 작업을 하지 않아도, 우리가 준비한 데이터를 제대로 렌더링해줍니다.
하지만, 네이버, 다음 등의 검색엔진이 사이트를 제대로 크롤링 하게 지원해야한다면, 서버사이드 렌더링을 따로 구현하셔야 합니다.
성능 개선
서버사이드 렌더링을 하게되면, 첫 렌더링된 html 을 클라이언트에게 전달을 해주기때문에 초기로딩속도를 많이 줄여줄 수 있습니다. 자바스크립트 파일을 불러오고, 렌더링 작업이 완료되기 전에도, 유저가 사이트의 컨텐츠를 이용 할 수 있게 됩니다.
서버사이드 렌더링의 단점
프로젝트의 복잡도
서버사이드 렌더링을 구현하게 된다면, 프로젝트의 구조가 많이 복잡해지게 됩니다. 단순히 렌더링만 하는것은 그렇게 큰 문제는 아니지만, 리액트 라우터, 리덕스 등의 라이브러리와 함께 연동해서 사용하면서, 서버에서 데이터를 가져와서 렌더링을 해줘야하는 경우엔 조금 어려워질수도 있습니다.
하지만… 걱정하지마세요! 이번 3장에서 나오는 가이드를 따라하면 시간을 많이 낭비하지 않고 라우터와 데이터 가져오기까지 큰 수고 없이 구현 할 수 있게 될 것입니다.
성능의 악화 가능성
장점이 성능 개선이라고 했는데, 성능이 악화될수도 있다니 이게 무슨소리인가 싶지요? 클라이언트에서 초기렌더링을 서버측에서 대신 해주니까, 클라이언트의 부담을 없애주기는 하지만, 그 부담은 서버가 가져가게 됩니다. 서버사이드 렌더링을 하게 될 때는, ReactDOMServer.renderToString
함수를 사용하게 되는데요, 이 함수는 동기적으로 작동합니다. 그래서, 렌더링하는동안은 이벤트루프가 막히게 됩니다. 그 뜻은, 렌더링이 되는 동안은, 다른 작업을 못한다는 뜻입니다. 만약에 여러분의 프로젝트를 렌더링하는데 250ms 가 걸린다면 1초에 4개의 요청밖에 처리하지 못한다는 의미입니다. 치명적이죠.
비동기식 렌더링
하지만.. 걱정하지마세요! 써드파티 라이브러리를 통하여 비동기식으로 작동하게끔 코드를 작성 할 수 있습니다. 이를 가능케 하는 라이브러리는 여러가지가 있는데요:
- rapscallion: renderToString 을 비동기로 해주며, Promise 기반으로 작동하고, 컴포넌트 단위로 캐싱을 할 수 있습니다
- hypernova: airbnb 에서 만든 도구로서, 렌더링을 위한 서버를 따로 열어서
cluster
를 사용하여 여러 프로세스를 생성하여 렌더링을하고, 운영서버에서 렌더링서버로 결과물을 요청을 하는 방식으로 작동합니다. - react-router-server: react-router v4 를 위해 만들어진 서버사이드 렌더링 라이브러리로서, Promise 비동기식 렌더링을 지원해주고, 깔끔한 방식으로 데이터를 불러올 수도 있습니다. 우리는 이 라이브러리를 통하여 서버사이드 렌더링을 구현 할 것입니다.
최적화
성능의 악화를 막기위한 또 다른 방법은, 서버사이드 렌더링을 촤적화 시키는 것 입니다. 서버의 성능이 그렇게 좋지 않는데 네이버/다음 등의 검색엔진을 지원해야한다면, 요청이 들어왔을 때, 검색엔진 크롤러 봇인 경우에만 서버사이드 렌더링을 하는 방식으로 구현 할 수 도 있습니다.
추가적으로, 개인화된 데이터는 서버사이드 렌더링하는것을 피하고, (예: 로그인된 사용자의 뷰) 모든 유저에게 같은 형식으로 보여지는 뷰들을 캐싱하는 것도 좋은 방안입니다.
또 다른 대안
메타 태그만 넣어주기
리액트 어플리케이션을 렌더링하지는 않고, 서버쪽에서 라우트에 따라 필요한 메타태그만 넣어주는 것 입니다. 그러면, 크롤러에선 해당 페이지에 대한 기본 정보는 얻어 갈 수 있게 됩니다. SNS 공유를 할 때 내용이 잘 전달되게 하기 위한것이라면 매우 적합한 방식입니다.
Prerender
만약에 검색엔진 최적화 때문에 서버사이드 렌더링을 구현해야하는데, 프로젝트의 구조가 복잡해지는게 싫고, 또 성능이 받쳐주지 않는다면, 또 다른 대안이 있습니다. 바로, prerender 라는 서비스를 이용하는 것 입니다.
Prerender 는 리액트 코드를 문자열 형태로 변환을 하는게 아니라, 아예 자바스크립트 렌더링 엔진을 가지고 있어서, 자바스크립트 코드를 실행시켜 뷰를 렌더링한 결과값을 반환합니다. 렌더링 속도는 그렇게 빠르지 않기 때문에, 이 서비스는 오직 검색엔진 최적화를 위해서만 사용됩니다. 크롤러 봇일 경우에만 대신 렌더링을 해줘서 반환을 해주는것이죠.
이 서비스는 유료 서비스인데, 페이지의 수, 그리고 캐싱 주기에 따라 가격이 달라집니다. 250개의 페이지까지는 무료인데요, 그 이상부턴 유료로 전환됩니다. 20000 개 정도의 페이지를 7일 주기로 캐싱하는것은 매달 $15 로 이용 할 수 있으며, 페이지 수가 더 많아지면 가격이 더 비싸집니다.
더욱 멋진것은, Prerender 는 오픈소스로 공개가 되어서, 여러분의 서버에서 직접 무료로 돌릴수도 있다는 점 입니다. 매뉴얼에서는 검색엔진 최적화는 특권이 아닌 권리이기 때문에 오픈소스로 공개했다고 하는데요. 참 멋진 서비스이죠?
3-1. Koa 사용하기
소개
서버사이드 렌더링을 시작하기에 앞서, 우리가 웹서버를 열기 위해서 사용 할 웹프레임워크 Koa 를 설치하고 서버를 열어보도록 하겠습니다. Koa 는 Node.js 의 가장 인기있는 웹프레임워크인 Express 를 창시했던 개발팀이 만든 더 가벼운 웹프레임워크입니다. (Express 는 StrongLoop 으로 소유권이 이전되었습니다) 이 프레임워크는 최신 Node.js 버전에 맞춰서 설계되었으며, async
await
문법을 사용 할 수있고, 에러 관리하기가 쉬워서 비동기적 작업을 하다가 코드 구조가 매우 복잡해져 버리는 콜백지옥이 생겨나지 않습니다.
설치 및 서버 작성
자, 그럼 설치를 해줍시다.
$ yarn add koa
그리고, 서버 파일을 작성해보세요.
server/index.js
const Koa = require('koa');
const app = new Koa();
app.use(ctx => {
ctx.body = 'Hello World';
});
app.listen(3001);
서버를 작성하셨으면 다음 명령어를 통하여 서버를 실행하세요.
$ node server
브라우저에서 http://localhost:3001/ 에 접속해보세요.
Hello World 가 잘 나타나나요?
정적 파일 제공하기
서버사이드 렌더링을 구현하기에 앞서, 먼저 js 파일 및 css 파일들을 서버에서 제공하는 코드를 작성하겠습니다. 정적 파일들을 제공할 때에는, koa-static
미들웨어를 사용하시면 됩니다.
이를 설치해주세요.
$ yarn add koa-static
그 다음엔, 서버에서 koa-static 을 불러온 다음에 미들웨어를 적용하세요. 웹 요청이 들어왔을 때, build 디렉토리에 알맞는 파일이 있으면 해당 파일을 반환하고, 그렇지 않으면 Hello World 가 반환됩니다.
server/index.js
const Koa = require('koa');
const serve = require('koa-static');
const path = require('path');
const app = new Koa();
app.use(serve(path.resolve(__dirname, '../build/')));
app.use(ctx => {
ctx.body = 'Hello World';
});
app.listen(3001);
이제 리액트 앱을 한번 빌드하고, 서버를 재시작하세요.
$ yarn build
$ node server
그리고 다시 브라우저에서 http://localhost:3001/ 에 접속해보세요.
3001 포트로도 뷰가 잘 렌더링 될 것입니다. 이제 about 페이지에 들어가서 새로고침을 해보세요. 잘 되죠? 하지만 이건 create-react-app 에서 적용된 서비스 워커 때문에 되는 것 입니다. 한번 개발자 도구를 열은 다음 브라우저 왼쪽 상단의 새로고침 버튼을 우클릭 하여 캐시 비우기 및 강력 새로고침을 해보세요.
Hello World 가 뜰 것입니다.
클라이언트 라우팅이 제대로 작동하게 하려면, 서버측에서 준비되지 않은 요청이 들어 왔을 시, 리액트 어플리케이션이 띄워져있는 index.html 의 내용을 보여주어야 합니다.
Node.js 내장 모듈인 fs
를 통하여 index.html 을 불러온 다음에, 내용을 반환하도록 설정하겠습니다.
server/index.js
const Koa = require('koa');
const serve = require('koa-static');
const path = require('path');
const fs = require('fs');
const app = new Koa();
const indexHtml = fs.readFileSync(path.resolve(__dirname, '../build/index.html'), { encoding: 'utf8' });
app.use(serve(path.resolve(__dirname, '../build/')));
app.use(ctx => {
ctx.body = indexHtml;
});
app.listen(3001);
이제 캐시를 비워도 제대로 작동 할 것입니다.
지금까지 한 작업은 서버에서 리액트 어플리케이션을 단순히 전송만 한 것이며, 서버사이드 렌더링은 아직 구현하지 않은 상태 입니다.
리액트를 렌더링 하려면, 서버측에서 리액트 컴포넌트들을 불어와야 하는데요, Node.js 에서는 기본적으로는 jsx 를 불러올 수 없습니다. 추가적으로, import 를 통하여 파일을 불러 올 수도 없죠. 따라서, babel 을 사용해야하는데요. 이 방법은 크게 4가지로 나뉘어집니다.
- babel-node 를 통해 런타임에서 babel 사용하기: 이 방법은 프로덕션에선 좋지 않는 방식입니다. 서버에서 코드를 변환하기 위하여 불필요한 자원이 사용되기 때문이죠.
- babel-register 를 서버에서 불러와서 사용하기: 이 방법 또한 런타임에서 코드를 변환하여 사용하는 방식이며 1번과 같은 이유로 추천되지 않습니다.
- babel 을 통하여 서버를 빌드한 다음에 사용하기: 서버를 실행하기전에 사전에 코드를 트랜스파일 하여 서버를 실행하는 것 입니다. 서버를 수정 할 때마다 다시 트랜스파일링을 해야하므로, 개발중엔 코드를 수정할때마다 중간 중간 딜레이가 발생하는 단점이 있습니다.
- webpack 을 통하여 리액트 관련 코드만 미리 빌드해서 사용하기: 이 방법이 성능상에서, 그리고 개발 흐름상에서 가장 괜찮은 방법입니다. 결국엔 babel 은 리액트쪽 코드에서만 사용되는거니까, 리액트 관련 코드를 미리 webpack 으로 번들링 한 것을 그냥 require 로 불러와서 사용하는 방식입니다.
다음 섹션에서는, 방금 소개된 4번째 방법을 통하여 개발을 진행하겠습니다.
3-2. 서버사이드 렌더링 준비하기
서버용 엔트리 생성
자, 이제 본격적으로 서버사이드 렌더링을 준비해도록 하겠습니다. 클라이언트에서의 엔트리 파일은 src/index.js
였지요? 이 파일에서는 브라우저 상에서 id 가 root 인 DOM 을 찾아서 렌더링을 해주었습니다.
서버사이드 렌더링을 할 때에는, 서버를 위한 엔트리 파일을 따로 만들어주어야합니다.
다음 파일을 생성하세요:
src/server/render.js
import React from 'react';
import ReactDOMServer from 'react-dom/server';
import { StaticRouter } from 'react-router';
import App from 'shared/App';
const render = (location) => ReactDOMServer.renderToString(
<StaticRouter location={location}>
<App/>
</StaticRouter>
);
export default render;
ReactDOMServer.renderToString
은 JSX 를 HTML 문자열로 변환을 해줍니다. 위 코드의 render 함수는 서버에서 전달받은 요청의 경로인 location 을 전달받아서 renderToString 을 실행시켜줍니다.
그리고 default 로 내보내기를 했지요.
이제 이것을 webpack 으로 번들링 해주면 됩니다.
서버용 웹팩 환경설정 생성
먼저 config 디렉토리의 paths.js 파일을 열어서 하단의 배열에 다음 두 값을 추가하세요.
config/paths.js
– module.exports
module.exports = {
/* 생략 */
serverRenderJs: resolveApp('src/server/render.js'), // 서버용 엔트리 경로
server: resolveApp('server/render') // 서버렌더링용 모듈 번들링 후 저장 경로
};
서버 엔트리의 위치와, 번들링 후 저장 할 경로를 지정해주었습니다.
서버용 웹팩설정은 webpack.config.prod.js 를 기반으로 만듭니다. 차이점은, 불필요한 웹팩 플러그인을 모두 없앴고, 로더 또한, js, jsx, json 확장자를 제외한 파일들은 모두 무시하도록 설정됩니다.
불러오는 자바스크립트 이외의 파일들은 무시하려면, ignore-loader
를 설치해야합니다.
$ yarn add ignore-loader --dev
설치 후에는, 다음 웹팩 설정 파일을 생성하세요. 기존 webpack.config.prod.js 에서 불필요한 코드를 지우고 조금 수정을 해준 환경설정입니다. 주요 부분은 주석이 되어있으니 참고하세요.
config/webpack.config.server.js
'use strict';
const path = require('path');
const webpack = require('webpack');
const CaseSensitivePathsPlugin = require('case-sensitive-paths-webpack-plugin');
const WatchMissingNodeModulesPlugin = require('react-dev-utils/WatchMissingNodeModulesPlugin');
const getClientEnvironment = require('./env');
const paths = require('./paths');
const publicUrl = '';
const env = getClientEnvironment(publicUrl);
module.exports = {
entry: paths.serverRenderJs,
// Node.js 내장 모듈과 충돌이 일어나지 않으며 require 로 불러올 수 있는 형태로 번들링합니다
target: 'node',
output: {
// 정해준 서버 경로에 render.js 라는 파일명으로 저장합니다
path: paths.server,
filename: 'render.js',
libraryTarget: 'commonjs2' // node 에서 불러올 수 있도록, commonjs2 스타일로 번들링 합니다
},
resolve: {
modules: ['node_modules', paths.appNodeModules].concat(
process.env.NODE_PATH.split(path.delimiter).filter(Boolean)
),
extensions: ['.js', '.json', '.jsx'],
},
module: {
strictExportPresence: true,
rules: [
// 자바스크립트 이외의 파일들을 무시합니다.
{
exclude: [
/\.(js|jsx)$/,
/\.json$/
],
loader: 'ignore',
},
// 자바스크립트는 Babel 을 통하여 트랜스파일링합니다
{
test: /\.(js|jsx)$/,
include: paths.appSrc,
loader: require.resolve('babel-loader'),
options: {
cacheDirectory: true,
},
}
],
},
plugins: [
// 필수 플러그인만 넣어줍니다
new webpack.DefinePlugin(env.stringified),
new CaseSensitivePathsPlugin(),
new WatchMissingNodeModulesPlugin(paths.appNodeModules),
]
};
서버용 빌드 스크립트 생성
이제 빌드를 위한 스크립트도 만들어주겠습니다. 이 파일 또한 scripts 디렉토리의 build.js 를 기반으로 조금 수정하고, 불필요한 코드를 삭제하여 만들어진 코드입니다.
scripts/build.server.js
'use strict';
process.env.BABEL_ENV = 'production';
process.env.NODE_ENV = 'production';
/* 나중에 클라이언트쪽 코드에서 process.env.APP_ENV 값을 통하여
서버일때만, 혹은 브라우저일때만 특정한 작업을 하도록 설정 할 수 있습니다. */
process.env.APP_ENV = 'server';
process.on('unhandledRejection', err => {
throw err;
});
require('../config/env');
const webpack = require('webpack');
const config = require('../config/webpack.config.server'); // 서버용 환경설정을 지정
const paths = require('../config/paths');
const checkRequiredFiles = require('react-dev-utils/checkRequiredFiles');
const formatWebpackMessages = require('react-dev-utils/formatWebpackMessages');
if (!checkRequiredFiles([paths.serverRenderJs])) {
process.exit(1);
}
function build() {
console.log('Creating an server production build...');
let compiler = webpack(config);
return new Promise((resolve, reject) => {
compiler.run((err, stats) => {
if (err) {
return reject(err);
}
const messages = formatWebpackMessages(stats.toJson({}, true));
if (messages.errors.length) {
return reject(new Error(messages.errors.join('\n\n')));
}
return resolve({
stats,
warnings: messages.warnings,
});
});
});
}
build();
그 다음엔, 웹팩에서 APP_ENV
를 인식 할 수 있도록, config/env.js
파일의 getClientEnvironment
함수에서 다음과 같이 APP_ENV 값을 넣어주세요.
config/env.js
– getClientEnvironment
function getClientEnvironment(publicUrl) {
const raw = Object.keys(process.env)
.filter(key => REACT_APP.test(key))
.reduce(
(env, key) => {
env[key] = process.env[key];
return env;
},
{
NODE_ENV: process.env.NODE_ENV || 'development',
PUBLIC_URL: publicUrl,
// APP_ENV 추가
APP_ENV: process.env.APP_ENV || 'browser'
}
);
// (...)
NPM 스크립트 생성
방금 만든 서버용 빌드스크립트를 실행시키는 NPM 스크립트를 만들어주겠습니다. package.json 의 script 부분을 다음과 같이 수정하세요.
package.json
"scripts": {
"start": "cross-env NODE_PATH=src node scripts/start.js",
"start:server": "node server",
"build": "cross-env NODE_PATH=src node scripts/build.js",
"build:server": "cross-env NODE_PATH=src node scripts/build.server.js",
"test": "node scripts/test.js --env=jsdom"
},
서버를 실행하는 start:server
스크립트와, 서버용 빌드를 만드는 build:server
스크립트를 생성하였습니다.
이제 yarn build:server 를 실행하여 서버용 빌드를 진행하세요.
$ yarn build:server
작업이 완료되엇으면, server/render 경로에 render.js 파일이 생성되었는지 확인하세요. 잘 되었다면, 서버쪽 코드를 본격적으로 작성해봅시다!
3-3. 서버쪽 코드 작성하기
서버사이드 렌더링 미들웨어 작성
우선, 서버사이드 렌더링을 위한 미들웨어를 작성하겠습니다.
server/render/index.js
const fs = require('fs');
const path = require('path');
const render = require('./render').default; // ES6 형식으로 만들어진 모듈이므로, 뒤에 .default 를 붙여주어야합니다.
// html 내용을 해당 상수에 저장합니다
const template = fs.readFileSync(path.join(__dirname, '../../build/index.html'), { encoding: 'utf8'});
module.exports = (ctx) => {
// 요청이 들어올 때 현재 경로를 render 함수에 전달시켜서 문자열을 생성합니다
const location = ctx.path;
const rendered = render(location);
// 해당 문자열을, 템플릿에 있는 '<div id="root"></div> 사이에 넣어줍니다.
const page = template.replace('<div id="root"></div>', `<div id="root">${rendered}</div>`);
// 렌더링된 페이지를 반환합니다.
ctx.body = page;
}
이 코드는, html 파일을 불러온 다음에, 해당 파일의 루트 엘리먼트가 위치한 곳에 렌더링된 문자열을 넣어주고 이를 반환해줍니다.
그리고, module.exports
를 통해 이 파일을 불러와서 사용 할 수 있도록 했지요.
서버사이드 렌더링 미들웨어 사용
server/index.js
const Koa = require('koa');
const serve = require('koa-static');
const path = require('path');
const fs = require('fs');
const app = new Koa();
const render = require('./render');
// 경로가 / 일시에 index.html 을 전달하는게 아닌, 서버사이드 렌더링 작업을 합니다
app.use((ctx, next) => {
if(ctx.path === '/') return render(ctx);
return next();
});
// 파일을 요청 받으면 build 내부의 파일들을 반환합니다
app.use(serve(path.resolve(__dirname, '../build/')));
// 요청받은 경로가 파일들이 아니라면, 해당 경로를 위한 서버사이드 렌더링을 해줍니다
app.use(render);
app.listen(3001);
방금 만든 미들웨어를 불러와서, 기존에 Hello World 가 있던 자리에 넣어주었습니다. 그리고, 경로가 / 일때도, 서버사이드 렌더링을 하도록 설정했습니다. (koa-static 미들웨어가 먼저 실행되서 index.html 이 기본적으로 반환되었기 때문입니다)
작업을 마치셨다면, 서버를 재시작해보세요. 아까전에 start:server
를 만들었으니, 다음 명령어로 실행하시면 됩니다.
$ yarn start:server
그리고, Postman 으로 http://localhost:3001/ 에 웹요청을 해보세요.
서버사이드 렌더링이 성공적으로 되었습니다!
하지만 아직 끝이 아닙니다. 지금은 서버사이드 렌더링이 동기적으로 되고있기 때문에 동시에 하나의 요청밖에 처리하질 못합니다. 추가적으로, 아직 데이터 로딩이 이뤄지지 않았습니다. 다음 섹션에선 프로젝트에 리덕스를 적용하고 REST API 를 통하여 데이터를 불러오는걸 서버쪽에서도 이뤄지게끔 구현을 해보겠습니다.
3-4. Redux 적용하기
의존 모듈 설치
프로젝트에 redux 를 적용하기 위해 필요한 의존 모듈을 설치하세요. 웹요청도 할 것이기에, axios 도 설치하세요. 비동기 액션 관리는 redux-pender 를 사용하도록 하겠습니다.
$ yarn add redux react-redux redux-actions redux-pender axios
api.js 작성
우리는 테스트를 위하여 JSONPlaceholder 의 테스트용 REST API 를 사용하겠습니다.
lib 디렉토리에 api.js 파일을 다음과 같이 작성하세요.
src/lib/api.js
import axios from 'axios';
export const getUsers = () => axios.get('https://jsonplaceholder.typicode.com/users');
users 모듈 작성
리덕스의 디렉토리 구조는 Ducks 구조를 사용하도록 하겠습니다 (액션, 액션생성자, 리듀서를 모두 한 파일에 넣고 관리하는 구조이죠)
src/redux/modules/users.js
import { createAction, handleActions } from 'redux-actions';
import { pender } from 'redux-pender';
import * as api from 'lib/api';
// 액션 타입
const GET_USERS = 'users/GET_USERS';
// 액션 생성자
export const getUsers = createAction(GET_USERS, api.getUsers);
// 초기 상태
const initialState = {
data: []
};
export default handleActions({
...pender({
type: GET_USERS,
onSuccess: (state, action) => {
return {
data: action.payload.data
}
}
})
}, initialState);
리듀서 합치기
우리는 redux-pender 를 사용하니, penderReducer 와 방금 만든 users 리듀서를 combineReducers
를 사용하여 합쳐주겠습니다.
src/redux/modules/index.js
import { combineReducers } from 'redux';
import users from './users';
import { penderReducer } from 'redux-pender';
export default combineReducers({
users,
pender: penderReducer
});
configureStore.js 만들기
redux 의 스토어가 이제 서버쪽에서 생성 될 수도 있고, 클라이언트쪽에서 생성 될 수도 있으니, 스토어를 생성하는 함수를 따로 만들어서 파일로 저장하겠습니다.
src/redux/configureStore.js
import { createStore, applyMiddleware, compose } from 'redux'
import penderMiddleware from 'redux-pender';
import modules from './modules';
const isDevelopment = process.env.NODE_ENV === 'development'; // 환경이 개발모드인지 확인합니다
// 개발모드에서만 리덕스 개발자도구 적용
const composeEnhancers = isDevelopment ? (window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose) : compose;
const configureStore = (initialState) => {
const store = createStore(modules, initialState, composeEnhancers(
applyMiddleware(penderMiddleware())
));
// hot-reloading 를 위한 코드
if(module.hot) {
module.hot.accept('./modules', () => {
const nextRootReducer = require('./modules').default;
store.replaceReducer(nextRootReducer);
});
}
return store;
}
export default configureStore;
그리고, 핫 리로딩이 제대로 작동하게 하기 위해서, store.js 라는 파일을 따로 생성해주겠습니다. 만약에, configureStore 호출하는것을 Root.js 를 하게되면, 코드가 다시 불러와질때마다 새 스토어가 만들어지므로 이렇게 파일을 따로 생성합니다. 이 파일은, 클라이언트쪽에서만 사용합니다. 추후, 이 파일에서 서버쪽에서 initialState 를 받아와서 생성하는 작업을 진행하겠습니다.
redux/store.js
import configureStore from './configureStore';
// 클라이언트에서만 사용됨
export default configureStore();
어플리케이션에 리덕스 연동 (클라이언트)
이제 어플리케이션의 클라이언트 사이드에서 리덕스와 연동을 하겠습니다. Root 컴포넌트에서 BrowserRouter 내부에서 App 컴포넌트를 Provider 로 감싸시면 됩니다.
src/client/Root.js
import React from 'react';
import { BrowserRouter } from 'react-router-dom';
import App from 'shared/App';
import store from 'redux/store';
import { Provider } from 'react-redux';
const Root = () => (
<BrowserRouter>
<Provider store={store}>
<App/>
</Provider>
</BrowserRouter>
);
export default Root;
자, 이제 리덕스 연동은 어느정도 끝났습니다. 다음 섹션에서는 클라이언트에서도, 서버쪽에서도 작동하는 데이터로딩을 구현하겠습니다.
3-5. 데이터 로딩 (클라이언트)
서버사이드 렌더링을 하면서, 데이터 로딩을 하는것은, 여러가지 방법이 있고 딱 정해진 방법은 없습니다. 데이터 로딩 관련 로직을 서버와 클라이언트 따로따로 작성하는 방식도 있고, 한번 작성해서 로직을 공유하는 방식도 있습니다.
우리는, react-router-server 에 포함된 도구들을 사용하여, 최대한 컴포넌트의 복잡도를 줄이면서도, 컴포넌트쪽에서 코드를 한번 작성하고 서버쪽에서는 별도의 작업없이 그 코드를 사용하게하고, 또 리덕스와 호흡을 잘 맞추면서 구현을 해보도록 하겠습니다.
먼저 react-router-server 를 설치하세요.
$ yarn add react-router-server
Users 라우트 생성
유저목록을 불러와서 렌더링해주는, Users 라우트를 생성하겠습니다. 데이터를 로딩 할 때, componentDidMount 가 아닌, componentWillMount 에서 작업을 호출하는데요, 그 이유는 componentWillMount 는 서버쪽에서도 호출이 되기 때문입니다. (componentDidMount 는 서버쪽에선 실행되지 않습니다) 그리고, react-router-server 에서 제공된 withDone
함수를 불러와서 사용하였는데요, 리덕스의 connect 함수처럼, 컴포넌트를 내보내기전에 한번 감싸주면 됩니다.
그렇게하면, 컴포넌트에 done 이란 함수가 props 로 전달이 되는데, 이 함수가 호출 될 때 까지 렌더링을 지연시킵니다. 따라서, 서버렌더링을 할 때 다음과 같은 흐름으로 진행이 됩니다:
componentWillMount 실행 → 데이터로딩 → done 함수가 호출 될 때까지 렌더링을 지연시킴 → done 이 호출됨 → render 실행
클라이언트에서는 렌더링이 지연되지 않습니다:
componentWillMount 실행 → 데이터 로딩 → 데이터 로딩중에 render 됨 → 값이 업데이트됨 → 다시 render 하여 업데이트
우리는, 컴포넌트를 리덕스에 연결시키고, componentWillMount 에서 데이터가 비어있을때만 요청을 하도록 했습니다. 그 이유는, 서버사이드 렌더링된 결과물을 브라우저가 받고 난 다음에, 컴포넌트 라이프싸이클은 똑같이 흐르기 때문입니다. 데이터가 이미 있는데 굳이 중첩적으로 요청을 할 필요는 없겠지요? 이를 방지하기위해 저렇게 조건을 넣었습니다.
UsersActions.getUsers 가 호출되면, Promise 를 반환하는데요, 이게 성공하거나 실패 했을 시, done
을 호출합니다. 서버쪽에선, 그 다음에 데이터가 생긴상태에서 첫 렌더링을 하니, 우리가 원하는 데이터가 제대로 나타나겠지요.
src/pages/Users.js
import React, { Component } from 'react';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import * as usersActions from 'redux/modules/users';
import { withDone } from 'react-router-server';
class Users extends Component {
componentWillMount() {
// 서버사이드에서도 데이터 로딩이 작동하기 위해서, 데이터 불러오는 작업을 componentWillMount 에서 호출합니다.
const { UsersActions, data, done } = this.props;
if(data.length !== 0) return false; // 데이터가 이미 존재하면 재요청 하지 않음
UsersActions.getUsers().then(done, done); // Promise 가 성공했을때, 혹은 실패했을때 done() 호출
}
render() {
const { data } = this.props;
// 유저 이름 목록을 생성합니다
const userList = data.map(
user => <li key={user.id}>{user.name}</li>
);
return (
<div>
<ul>
{userList}
</ul>
</div>
);
}
}
// withDone 으로 감싸주면, done 이 호출될때까지 렌더링을 미룹니다
export default withDone(connect(
(state) => ({
data: state.users.data
}),
(dispatch) => ({
UsersActions: bindActionCreators(usersActions, dispatch)
})
)(Users));
Users 라우트 적용
작업이 완료되었다면, 페이지 인덱스에 Users 를 추가하고, 라우트도 설정한다음에 메뉴에서도 연결시키세요.
src/pages/index.js
export { default as Home } from './Home';
export { default as About } from './About';
export { default as Posts } from './Posts';
export { default as Post } from './Post';
export { default as Users } from './Users';
src/pages/index.async.js
import asyncRoute from 'lib/asyncRoute';
export const Home = asyncRoute(() => import('./Home'));
export const About = asyncRoute(() => import('./About'));
export const Post = asyncRoute(() => import('./Post'));
export const Posts = asyncRoute(() => import('./Posts'));
export const Users = asyncRoute(() => import('./Users'));
src/shared/App.js
import React, { Component } from 'react';
import { Route, Switch } from 'react-router-dom';
import { Home, About, Posts, Users } from 'pages';
import Menu from 'components/Menu';
class App extends Component {
render() {
return (
<div>
<Menu/>
<Route exact path="/" component={Home}/>
<Route path="/posts" component={Posts}/>
<Switch>
<Route path="/about/:name" component={About}/>
<Route path="/about" component={About}/>
</Switch>
<Route path="/users" component={Users}/>
</div>
);
}
}
export default App;
src/components/Menu.js
– render – ul
<li><NavLink to="/users" activeStyle={activeStyle}>Users</NavLink></li>
위 코드를 <ul>...</ul>
내부에 추가하세요.
이렇게 하고 난 다음엔, 브라우저로 개발서버에 들어가서 Users 페이지를 들어가보세요.
잘 되었나요? 그러면 이제 서버사이드에서도 렌더링을 해보겠습니다.
3-6. 데이터 로딩 (서버)
이제, 서버 렌더링 쪽에서 데이터 로딩도 제대로하고, 렌더링을 비동기로 작동하게 하기 위해서, react-router-server 의 renderToString 을 사용하도록 하겠습니다. 기존의 ReactDOMServer.renderToString 은 이 파일에서 더 이상 사용하지 않으니 해당 코드를 지워주셔도 됩니다.
render.js 를 다음과 같이 수정하세요.
src/server/render.js
import React from 'react';
import { StaticRouter } from 'react-router';
import App from 'shared/App';
import configureStore from 'redux/configureStore';
import { Provider } from 'react-redux';
/* react-router-server 의 renderToString 은 비동기로 작동하며,
데이터 로딩도 관리해줍니다. */
import { renderToString } from 'react-router-server';
const render = async (location) => {
// 서버사이드에선, 매 요청마다 새 store 를 생성해주어야 합니다.
const store = configureStore();
const { html } = await renderToString(
<StaticRouter location={location}>
<Provider store={store}>
<App/>
</Provider>
</StaticRouter>
);
// 스토어와, 렌더링된 문자열 결과물을 반환합니다
return {
html,
state: store.getState()
};
}
export default render;
render 함수가 호출 될 때마다 store 를 생성하도록 했습니다. 만약에 스토어를 하나만 만들어서 사용한다면, 요청마다 상태가 초기화되지 않고 유지가 되어서 상태를 공유하게되는 현상이 발생하기 때문입니다.
renderToString 은 Promise 를 반환하기 때문에 async
, await
문법을 통해 해당 작업을 함수 내에서 비동기식으로 기다리도록 하였습니다.
그리고, 렌더링을 하고 난 다음엔 html 문자열 뿐만 아니라 현재의 상태를 클라이언트에게 전달을 해주어야 하므로, store.getState()
값을 html 와 같이 객체에 넣어서 리턴하여 서버쪽에서 사용 할 수 있게 해주었습니다.
작업을 완료하셨으면, 다음명령어들을 통하여 빌드하세요. (클라이언트쪽 코드도 바뀌었으니 다시 빌드합니다.)
$ yarn build:server
$ yarn build
이제 render 함수는 문자열을 리턴하는게 아니라 store 상태와 함께 객체를 리턴합니다. 이제 이에 맞춰서, 서버쪽 코드를 수정해야합니다.
html 값은 root 엘리먼트안에 넣고, 그 다음 부분에 <script>
를 입력하여 리덕스 상태를 window.__PRELOADED_STATE__
에 집어 넣으세요.
server/render/index.js
const fs = require('fs');
const path = require('path');
const render = require('./render').default; // ES6 형식으로 만들어진 모듈이므로, 뒤에 .default 를 붙여주어야합니다.
// html 내용을 해당 상수에 저장합니다
const template = fs.readFileSync(path.join(__dirname, '../../build/index.html'), { encoding: 'utf8'});
module.exports = (ctx) => {
// 요청이 들어올 때 현재 경로를 render 함수에 전달시켜서 문자열을 생성합니다
const location = ctx.path;
return render(location).then(
({html, state}) => {
// html 을 넣어주고, state 를 window.__PRELOADED_STATE__ 값으로 설정
const page = template.replace('<div id="root"></div>', `<div id="root">${html}</div><script>window.__PRELOADED_STATE__=${JSON.stringify(state)}</script>`);
ctx.body = page;
}
);
}
만약에 Node v7 이상 버전을 사용한다면 async
await
을 사용하여 다음과 같이 작성 할 수도 있습니다.
// (...)
module.exports = async (ctx) => {
const location = ctx.path;
const { html, state } = await render(location);
const page = template.replace('<div id="root"></div>', `<div id="root">${html}</div><script>window.__PRELOADED_STATE__=${JSON.stringify(state)}</script>`);
ctx.body = page;
}
이제, 서버를 다시 시작하고 Postman 을 통하여 http://localhost:3001/users 에 GET 요청을 해보세요.
이제, 클라이언트에서 window.__PRELOADED_STATE__
를 리덕스 스토어의 초기 상태로 지정해주면되는데요, 이렇게 JSON.stringify
만 사용하면, 보안적으로 취약 할 수 있습니다. 만약에 상태에 태그를 닫고, 악성스크립트가 들어있는 문자열이 있다면 그게 그대로 실행될수도 있기 때문이죠.
따라서, 보안을 위하여 JSON 객체를 안전하게 변환 하여 사용 할 수 있게 해주는 serialize-javascript
를 설치해서 사용하도록 하겠습니다. 이 라이브러리는, JSON 을 문자열로 변환하는 과정에서 문자열을 이스케이핑 해주어 스크립트 태그가 닫히지 않게 해주고, 또 정규식과 함수도 문자열로 전달 할 수 있게 해줍니다.
$ yarn add serialize-javascript
설치 후에는, JSON.stringify
대신에 serialize-javascript
를 불러와서 사용해주면 됩니다.
server/render/index.js
const fs = require('fs');
const path = require('path');
const render = require('./render').default; // ES6 형식으로 만들어진 모듈이므로, 뒤에 .default 를 붙여주어야합니다.
var serialize = require('serialize-javascript');
// html 내용을 해당 상수에 저장합니다
const template = fs.readFileSync(path.join(__dirname, '../../build/index.html'), { encoding: 'utf8'});
module.exports = (ctx) => {
// 요청이 들어올 때 현재 경로를 render 함수에 전달시켜서 문자열을 생성합니다
const location = ctx.path;
return render(location).then(
({html, state}) => {
// html 을 넣어주고, state 를 window.__PRELOADED_STATE__ 값으로 설정
const page = template.replace('<div id="root"></div>', `<div id="root">${html}</div><script>window.__PRELOADED_STATE__=${serialize(state)}</script>`);
ctx.body = page;
}
);
}
이제 서버쪽 코드는 어느정도 마무리가 되었습니다. 클라이언트에서 방금 전달해준 리덕스 상태를 받아오게 코드를 작성해봅시다.
리덕스 초기상태 지정하기
리덕스 초기상태를 넣어주는것은 매우 간단합니다. 그냥, store.js 파일에서 configureStore
의 인자로 window.__PRELOADED_STATE__
을 넣으세요.
src/redux/store.js
import configureStore from './configureStore';
// 클라이언트에서만 사용됨
export default configureStore(window.__PRELOADED_STATE__);
이제 서버사이드 렌더링은 거의 끝났습니다! 렌더링도 잘 되고 데이터 로딩도 잘 됩니다. 마지막으로, SEO 에 있어서 정말 중요한 페이지 타이틀 제목 및 meta 태그 설정 방법과, 이를 서버렌더링쪽에서도 하는 방법을 알아보겠습니다.
3-7. react-helmet 을 통한 페이지 헤드 정보 설정
리액트로 만든 어플리케이션의 페이지에 페이지 제목과 meta 태그를 설정하는것은, 리액트에서 관리되는것이 아니기에, 다음과 같은 형식의 코드를 직접 입력해야하죠.
document.title = 'something';
var meta = document.createElement('meta');
meta.httpEquiv = "X-UA-Compatible";
meta.content = "IE=edge";
document.getElementsByTagName('head')[0].appendChild(meta);
react-helmet 소개
각 라우트마다 이렇게 설정하는것은, 보기에도 그렇게 좋지 않고, 번거로울 수도 있습니다. 이 작업을 매우 용이하게 해주는 라이브러리가있는데요, 바로 react-helmet
입니다. 이 라이브러리는 페이지의 head 설정을 컴포넌트 렌더링하듯이 JSX 에서 할 수 있게 해주는 아주 유용한 라이브러리입니다.
이 라이브러리를 사용하면, 다음과 같은 형식으로 head 설정을 할 수 있게 됩니다.
import React from "react";
import {Helmet} from "react-helmet";
class Application extends React.Component {
render () {
return (
<div className="application">
<Helmet>
<meta charSet="utf-8" />
<title>My Title</title>
<link rel="canonical" href="http://mysite.com/example" />
</Helmet>
...
</div>
);
}
};
Helmet 을 통해 설정한 값들은 DOM 트리의 더 깊숙히 위치하는것이 우선권을 가집니다. 예를들어, 다음과 같은 코드가 있다면,
<Parent>
<Helmet>
<title>My Title</title>
<meta name="description" content="Helmet application" />
</Helmet>
<Child>
<Helmet>
<title>Nested Title</title>
<meta name="description" content="Nested component" />
</Helmet>
</Child>
</Parent>
페이지의 타이틀은 Nested Title
으로 설정됩니다.
설치와 사용
이제 이 라이브러리를 설치하고 적용을 해보겠습니다.
$ yarn add react-helmet
설치를 하고나서, 먼저 App 컴포넌트에서 사용해봅시다.
src/shared/App.js
import React, { Component } from 'react';
import { Route, Switch } from 'react-router-dom';
import { Home, About, Posts, Users } from 'pages';
import { Helmet } from "react-helmet";
import Menu from 'components/Menu';
class App extends Component {
render() {
return (
<div>
<Helmet>
<title>React Router & SSR</title>
</Helmet>
<Menu/>
<Route exact path="/" component={Home}/>
<Route path="/posts" component={Posts}/>
<Switch>
<Route path="/about/:name" component={About}/>
<Route path="/about" component={About}/>
</Switch>
<Route path="/users" component={Users}/>
</div>
);
}
}
export default App;
타이틀이 설정 되었습니다!
이제, About 페이지에도 타이틀 설정을 해주겠습니다.
src/pages/About.js
import React from 'react';
import queryString from 'query-string';
import { Helmet } from 'react-helmet';
const About = ({location, match}) => {
const query = queryString.parse(location.search);
const detail = query.detail === 'true';
const { name } = match.params;
return (
<div>
<Helmet>
<title>{`About ${name ? name : ''}`}</title>
</Helmet>
<h2>About {name}</h2>
{detail && 'detail: blahblah'}
</div>
);
};
export default About;
Helmet 을 사용하실 때 주의 하실 점은, 내용을 하나의 문자열로 해야 한다는 점 입니다. 만약에 예를들어 여러분이 <title>About {name}</title>
이런식으로 하게 된다면, 실제로 <title>
이 받게되는 children
은 ["About ", name]
형태의 배열인데 Helmet 에서 이를 허용하지 않습니다. 따라서 내용에 변수를 넣어야 할 때는 전체를 {}
로 감싸서 넣어주셔야합니다.
이제, About 페이지도 타이틀이 제대로 붙었는지 확인해보세요.
;
더 내부에 있는 Helmet 정보가 우선순위를 가져서 About 페이지의 타이틀이 제대로 나타났습니다.
서버사이드 렌더링
타이틀 및 메타정보 설정은 검색엔진 최적화를 위하여 해주는 작업인데, 서버사이드 렌더링이 빠질 수 없지요.
Helmet 정보를 서버쪽에 전달 해 줄때는, Helmet.renderStatic
함수를 사용합니다. 이 함수를 실행하여 만들어진 인스턴스는 다음 값들을 지니고있습니다:
- base
- bodyAttributes
- htmlAttributes
- link
- meta
- noscript
- script
- style
- title
서버 렌더용 코드를 다음과 같이 수정하세요:
src/server/render.js
import React from 'react';
import { StaticRouter } from 'react-router';
import App from 'shared/App';
import configureStore from 'redux/configureStore';
import { Provider } from 'react-redux';
/* react-router-server 의 renderToString 은 비동기로 작동하며,
데이터 로딩도 관리해줍니다. */
import { renderToString } from 'react-router-server';
import { Helmet } from 'react-helmet';
const render = async (location) => {
// 서버사이드에선, 매 요청마다 새 store 를 생성해주어야 합니다.
const store = configureStore();
const { html } = await renderToString(
<StaticRouter location={location}>
<Provider store={store}>
<App/>
</Provider>
</StaticRouter>
);
// helmet 정보를 가져옵니다
const helmet = Helmet.renderStatic();
// 스토어 상태와, 렌더링된 문자열 결과물, 그리고 helmet 정보를 반환합니다
return {
html,
state: store.getState(),
helmet
};
}
export default render;
helmet 정보를 렌더링된 문자열과, 스토어 상태와 함께 전달해주었습니다.
helmet 태그들도, 우리가 렌더링된 문자열을 넣어주었던것처럼 html 코드에 삽입해주어야하는데요, 우선 삽입 할 위치를 지정해줍시다.
public 디렉토리의 index.html 을 열어서 <head>
태그의 맨 윗부분에 다음 코드를 넣어주세요.
public/index.html
<!doctype html>
<html lang="en">
<head>
<meta helmet>
(...)
그리고 <head>
의 하단에 있는 <title>React App</title>
을 지우세요. html 을 수정하고난다음엔, 다시 코드를 빌드하세요.
$ yarn build
$ yarn build:server
Helmet 정보 html 에 삽입하기
helmet 객체 안에있는 title, meta, link 를 toString()
을 해주면 태그형태로 문자열변환이 됩니다. <meta helmet>
을 helmet 태그들로 치환하세요.
server/render/index.js
const fs = require('fs');
const path = require('path');
const render = require('./render').default; // ES6 형식으로 만들어진 모듈이므로, 뒤에 .default 를 붙여주어야합니다.
var serialize = require('serialize-javascript');
// html 내용을 해당 상수에 저장합니다
const template = fs.readFileSync(path.join(__dirname, '../../build/index.html'), { encoding: 'utf8'});
module.exports = (ctx) => {
// 요청이 들어올 때 현재 경로를 render 함수에 전달시켜서 문자열을 생성합니다
const location = ctx.path;
return render(location).then(
({html, state, helmet}) => {
// html 을 넣어주고, state 를 window.__PRELOADED_STATE__ 값으로 설정
const page = template.replace('<div id="root"></div>', `<div id="root">${html}</div><script>window.__PRELOADED_STATE__=${serialize(state)}</script>`)
.replace('<meta helmet>', `${helmet.title.toString()}${helmet.meta.toString()}${helmet.link.toString()}`);
ctx.body = page;
}
);
}
서버를 실행하고, Postman 으로 http://localhost:3001/ 에 요청을 해보세요.
title 이 잘 나타났나요?
마치면서
수고하셨습니다. 리액트 라우터를 통해 여러 페이지를 관리하는것부터 시작해서, 코드스플리팅, 그리고 대망의 서버사이드 렌더링까지, 성공적으로 해내셨습니다.
프로젝트에 코드스플리팅과 서버사이드 렌더링까지 붙고나면, 구조가 조금은 복잡해집니다. 이 두가지 기술은 프로젝트 개발에 있어서 필수사항은 아니지만, 경우에 따라 구현해놓으면 더 좋을 수도 있습니다. 하지만, 프로젝트 개발을 할 때, 이 작업들은 프로젝트를 마무리하는 시점에서 진행하기를 권고합니다. 그 이유는, 이 작업은 시간이 좀 들어가는 작업이고, 이 때문에 프로젝트 개발이 지연될 수 있기 때문입니다. 기능 개발이 더 우선순위이기 때문에, 여러분의 어플리케이션이 작동할 수 있게 만든 그 다음작업으로 해도 상관없습니다.
이번에 이렇게 적용을 해보았고, 원리와 방식을 이해하면, 추후에 여러분의 프로젝트에서도 적용 할 수 있을겁니다. 이 두가지 기술은 다가가기엔 어려워보일수도있지만 이해를 하고나면 생각보다 그렇게 어렵지는 않습니다. 그리고 이 두가지는 프로젝트 개발에 있어서 필수사항은 아닙니다. 하지만 경우에 따라 필요해 질 수도 있지요.