March 5th 2020
제가 근무하는 왓챠에서는 별도 프레임워크를 사용하지 않고 SSR을 하고 있어요. 실제 제품에서는 코드가 좀 더 섬세히 구조화 되고 필요한 설정이 추가 되었지만, SSR의 기본적인 흐름은 대부분 유사한 것 같아 아래와 같이 정리해 보아요.
Contents
SSR Sample 만들기
Challenges
서버사이드 렌더링이란 Single Page App을 서버에서 렌더링 후 클라언트 측 자바스크립트 번들이 앱 구동을 이어받도록 하는 기술이에요. Next.js 같은 프레임워크도 있지만, 필요하면 원하는 대로 설정도 하고, 프레임워크를 사용해도 이해를 하고 사용할 수 있도록 SSR 구현 방법에 대해 알아보려 해요. 🙃
Server Side Rendering을 하는 주요 이유는 유저가 우리의 사이트에 접속했을 때 처음 접하는 화면에 필요한 콘텐츠를 가능한 한 빠르게 보여주기 위함이에요. 이유는 반응성이 빠를수록 Conversion Rate 와 유저 만족도가 올라가기 때문입니다. 또한, 검색 엔진이 크롤링을 쉽게 할 수 있게 만들기 때문에 SEO에도 좋습니다.
Server Side Rendering의 일반적인 흐름은 아래와 같습니다. 서버에서 요청을 받으면 서버에서 렌더링 한 React App을 보내주고, 이후 클라이언트 측 자바스크립트 코드가 나머지 작업을 이어받습니다.
React Component의 서버 렌더링을 위해 renderToString 함수를 이용해 html string으로 변경해 주어야 해요. 브라우저는 응답으로 받은 HTML을 UI에 보여주고, 클라이언트 번들을 요청합니다. 클라이언트 번들을 이용해 같은 "div "에 리액트 앱을 렌더합니다. 리액트 앱은 기존 렌더된 앱을 검토하고 이벤트 바인딩과 같은 나머지 필요한 작업을 이어받아 진행하게 돼요.
SSR을 만들기 위해서 두 개의 번들이 필요합니다. 이유는 서버 렌더시 API Key와 같은 민감한 정보나 노출되면 안 되는 특정 로직들이 포함될 수 있기 때문이에요. 클라이언트 리액트 앱을 만들면 리액트의 jsx를 웹팩과 바벨을 이용해 번들링 한 파일을 브라우저에서 사용합니다. 서버 렌더시에도 이 과정을 진행해야 하는데, 이를 위한 웹팩 설정이 필요해요. 즉, 서버용 클라이언트용 별도의 번들을 생성해야 해요. 서버 측과 클라이언트 측에서 구현하는 리액트 앱의 코드가 달라지는데, 이 부분은 포스팅 후반부에 라우터, 스토어 설정 시 좀 더 명확하게 차이점을 인지할 수 있을 거에요.
src
|__actions
| |__index.js
|__client
| |__components
| | |__requireAuth.jsx
| | |__Header.jsx
| |__Pages
| | |__AdminPage.jsx
| | |__HomePage.jsx
| | |__NotFoundPage.jsx
| | |__UsersListPage.jsx
| |__App.jsx
| |__index.jsx
| |__Routes.js
|__helpers
| |__createStore.js
| |__renderer.js
|__reducers
| |__adminReducer.js
| |__articleReducer.js
| |__authReducer.js
| |__index.js
| |__usersReducer.js
|__index.js
|__Webpack
|__base.config.js
|__client.config.js
|__server.config.js
서버 측 번들과 클라이언트 측 번들에서 공통으로 사용할 간단한 webpack 설정 파일을 먼저 작성할게요.
const path = require('path');
module.exports = {
// Tell webpack to run babel on every file it runs through
module: {
rules: [
{
test: /\.js|ts|tsx?$/,
loader: 'babel-loader',
exclude: /node_modules/,
options: {
presets: ['react', 'stage-0', ['env', { targets: { browsers: ['last 2 versions'] } }]]
}
}
]
},
resolve: {
extensions: ['.ts', '.tsx', '.js']
}
};
그리고 서버, 클라이언트의 별도 webpack 설정을 만들 때 위 설정 파일을 merge 해서 사용합니다. 아래는 서버 측 웹팩 설정 파일 예시입니다. 서버 빌드 시 웹팩이 node_modules 폴더는 무시하도록 "webpack-node-externals"를 추가해 주어요. 앱이 작을 때는 상관이 없지만, 규모가 커질수록 빌드 시간을 단축해 줄 수 있어요.
const path = require('path');
const merge = require('webpack-merge');
const baseConfig = require('./webpack.base.js');
const webpackNodeExternals = require('webpack-node-externals');
module.exports = {
target: 'node',
entry: './index.ts',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, '../build')
},
externals: [webpackNodeExternals()]
};
module.exports = merge(baseConfig, config);
마지막으로, 빌드할 스크립트를 package.json에 추가해 봅시다. 추가로, 개발 환경 시 코드를 수정하면 빌드가 다시 되도록 환경을 먼저 설정하도록 합니다. 우리는 매번 빌드하기가 귀찮으니까요. package.json의 스크립트에 아래와 같이 추가를 해 봅시다.
{
"scripts": {
// build 폴더 내 변경사항이 생기면 node build/bundle.js 커맨드를 다시 실행함.
"dev:server": "nodemon --watch build --exec \"node build/bundle.js\"",
// 코드 내 수정 사항이 생기면 webpack이 빌드를 다시함.
"dev:build:server": "webpack --config ./webpack/server.config.js --watch",
"dev:build-client": "webpack --config ./webpack/client.config.js --watch"
}
}
위 스크립트를 별개의 터미널에서 여러번 실행해야 하는 귀찮음을 없애기 위해 아래 스크립트를 추가해 이용해 봅시다.
{
"scripts": {
"develop": "npm-run-all --parallel dev:*"
}
}
이제 develop 스크립트를 실행하면, 로컬호스트에서 앱을 확인할 수 있습니다.
리액트 앱을 내려주는 간단한 express 앱을 아래와 같이 작성할 수 있습니다.
import express from 'express';
import React from 'react';
import { renderToString } from 'react-dom/server';
import App from './src/web/components/App';
const app = express();
app.use(express.static('build/public'));
app.get('/', (req, res) => {
const content = renderToString(<App />);
const html = `
<html>
<head></head>
<body>
<div id="root">${content}</div>
<script src="bundle.js"></script>
</body>
</html>
`;
res.send(html);
});
app.listen(3000, () => {
console.log('Listening on port 3000');
});
위 서버에서 리액트 앱을 렌더하고 html로 전달하는 부분을 별도의 모듈로 생성해 리팩토링 할 수 있습니다. 지금은 간단한 앱이지만 서비스가 커지면 이 부분도 복잡해 지기 때문에 별도의 모듈로 관리해 봅시다.
// src/helpers/renderer.js
import React from 'react';
import { renderToString } from 'react-dom/server';
import App from '../web/components/App';
const renderer = () => {
const content = renderToString(<App />);
return `
<html>
<head></head>
<body>
<div id="root">${content}</div>
<script src="bundle.js"></script>
</body>
</html>
`;
};
export default renderer;
// src/index/js
app.get('/', (req, res) => {
res.send(renderer());
});
// ...
클라이언트 측 index.js은 아래와 같이 작성할 수 있습니다. 클라이언트 측 번들이 돌 때 서버에서 이미 렌더한 콘텐츠가 "root" div 안에 있습니다. 리액트는 이벤트 핸들러나 필요한 부분의 코드 설정만 추가로 해 줍니다.
// src/client/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import App from './components/App';
ReactDOM.hydrate(<App />, document.getElementById('root'));
브라우저에서 여러 엔드포인트로 서버에 접근 할 때, 우리는 접근한 주소에 알맞은 UI를 화면에 보여주어야 하죠. 그리고 우리의 Express App은 이 일을 React Router에 위임하도록 할 거에요. 서버에서의 렌더링 및 클라이언트 측에서 hydrate를 완료하면 React router가 라우팅 관련된 일을 처리할 것이기 때문에 서버에서도 React Router를 통해 처리하는 것이 좋습니다.
브라우저에서 BrowserRouter를 통해 라우터는 주소창을 참고하고 알맞은 리액트 컴포넌트를 화면에 보여줍니다. 하지만, 서버에서 유저가 클릭해 location이 변하는 경우가 없기 때문에 SSR의 서버 렌더 시 StaticRouter 를 사용합니다.
서버, 클라이언트를 위한 별도의 Routes 모듈을 생성하지 않고 하나의 Routes 모듈을 만들어 서버와 브라우저에서 사용해 보도록 합시다.
// src/client/Routes.js
import React from 'react';
import { Route } from 'react-router-dom';
import App from './components/App';
const Routes = () => {
return (
<div>
<Route exact path="/" component={App} />
<Route exact path="/test" component={() => 'Hi, threre!'} />
</div>
);
};
export default Routes;
그리고 클라이언트와 서버측 렌더 시 위 라우터를 import 해서 추가합니다.
// src/client/index.js
import React from 'react';
import ReactDOM from 'react-dom';
import { BrowserRouter } from 'react-router-dom';
import Routes from './Routes';
ReactDOM.hydrate(
<BrowserRouter>
<Routes />
</BrowserRouter>,
document.getElementById('root')
);
그리고 서버측에서 렌더 시 추가하는 것도 잊지 말아 주세요. :) StaticRouter에는 context를 추가해야 하며, 리액트 라우터가 라우팅을 할 수 있도록 location도 같이 추가해보아요. 지금은 초기 설정이니 빈 객체를 context 에 임시로 넣어 보아요.
// ./index.js
import React from 'react';
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom';
import Routes from '../web/Routes';
import App from '../web/components/App';
const renderer = req => {
const content = renderToString(
<StaticRouter location={req.path} context={{}}>
<Routes />
</StaticRouter>
);
return `
<html>
<head></head>
<body>
<div id="root">${content}</div>
<script src="bundle.js"></script>
</body>
</html>
`;
};
export default renderer;
서버사이트렌더링에서 상태 관리 툴은 서버와 클라이언트에서 다르게 작동해야 해요. 이유는, 아래와 같은 조금 tricky한 작업을 하기 위해서인데요.
이미 앞서 이와 관련된 선행 작업을 진행 했는데, 우리는 BrowserRoute와 StaticRoute를 별도로 설정해 놓았습니다. 상태 관리 툴 설정을 위해 이와 비슷한 방법으로 진행할 것입니다. 샘플로 Redux 스토어를 생성할 것인데, 시간이 되면 Mobx도 추가해 보려 합니다.
브라우저 측 스토어 설정은 일반적인 리덕스 스토어를 생성하는 방법으로 생성합니다. 스토어 생성 후 바로 리덕스에 추가를 하면 됩니다.
서버 측 스토어 생성을 위해 helpers 폴더 안에 별도의 스토어 생성을 위한 파일를 만드는 것이 좋을 것 같습니다. 스토어가 서버에서 리액트 앱을 렌더하는 renderer 묘듈 안에서 사용할 것이지만, 두 로직을 분리하는 것이 앱이 커졌을 때 관리에 좋습니다. 또, 앞서 서버에서 데이터 패칭 완료 후 앱 렌더 할 것이라 언급했는데 이를 위해서도 별도 관리하는 것이 좋을 것 같습니다.
// src/helpers/createStore.js
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import reducers from '../reducers';
export default () => {
const store = createStore(reducers, {}, applyMiddleware(thunk));
return store;
};
그리고 위 함수를 이용해 서버측에서 스토어를 생성합니다. renderer에서 스토어를 사용할 것이지만, 렌더 전 사전 작업을 위해 서버 index.js에서 함수를 호출하고 스토어를 renderer에 전달합니다.
import express from 'express';
import renderer from './helpers/renderer';
import createStore from './helpers/createStore';
const app = express();
app.use(express.static('build/public'));
app.get('*', (req, res) => {
const store = createStore();
// 여기에 데이터 패칭 로직 및 별도 로직 추가할 거에요!! 좀만 기다립시다!
res.send(renderer(req, store));
});
app.listen(3000, () => {
console.log('Listening on port 3000');
});
스토어를 renderer에 인자로 전달 했으니, Provider를 추가해 줍니다.
// src/helpers/renderer.js
import React from 'react';
import { renderToString } from 'react-dom/server';
import { StaticRouter } from 'react-router-dom';
import { Provider } from 'react-redux';
import Routes from '../client/Routes';
const renderer = (req, store) => {
const content = renderToString(
<Provider store={store}>
<StaticRouter location={req.path} context={{}}>
<Routes />
</StaticRouter>
</Provider>
);
return `
<html>
<head></head>
<body>
<div id="root">${content}</div>
<script src="bundle.js"></script>
</body>
</html>
`;
};
export default renderer;
일반적인 클라이언트 측 리액트 앱과 같은 방식으로 서버 측에서 데이터 패칭을 할 수 없습니다. 예로, 리액트 앱에서 홈 페이지가 마운트 된 후 페이지에 필요한 데이터를 패치 후 화면을 그리지만, 서버측 렌더 시 이러한 과정이 없고, 앱을 렌더 후 브라우저에 바로 응답을 보냅니다.
차선책으로 willMount 메소드에서 필요한 데이터를 패치 후 어떻게든 패치가 완료된 것을 기다리고, 서버에서 다시 앱을 렌더하고 이를 다시 클라이언트에 응답을 보내는 방법을 생각해 볼 수도 있을 것입니다. 하지만, 이 방법은 서버에서 렌더를 두 번 해야 합니다.
이보다 더 나은 방법은 각 컴포넌트에 해당 컴포넌트에서 필요한 데이터를 패치하는 함수를 추가하는 방법입니다. 그리고, 서버에 요청이 들어오면 URL을 기준으로 렌더할 컴포넌트를 정하고 앞서 언급한 함수를 호출해 데이터를 패치합니다. 패치가 완료되면 서버에서 앱을 렌더 후 클라이언트에 응답을 보냅니다.
주요 SSR 프레임워크나 대부분의 앱에서 이러한 방법이 사용될 것입니다. 각 컴포넌트에 필요한 데이터만 패치 후 서버에서 앱을 한 번만 렌더할 수 있습니다. 단점으로는 앱이 더 복잡해지고 코드의 양도 많이 늘어납니다.
그래서, 모든 클라이언트 엡에서 SSR이 꼭 필요할 것 같지는 않습니다. 간단한 앱을 만드는데 이 방법을 사용하면 그만큼 더 시간도 많이 들고 복잡해 지니까요.
URL을 보고 UI에 보여질 컴포넌트를 선별해 서버에서 렌더하기 위해 react-router-config을 사용할 수 있습니다. 또, 서버 측에서 렌더 전 필요한 데이터를 패치하기에 더 수월합니다. react-router-config를 설치하고 Routes 묘듈을 수정해 보아요.
// src/client/Routes.js
import HomePage from './components/Pages/HomePage';
import ArticleListPage from './components/Pages/ArticleList';
const Routes = [
{
...HomePage,
path: '/',
exact: true
},
{
...ArticleListPage,
path: '/articles'
}
];
export default Routes;
라우터를 JSX가 아닌 일반 자바스크립트 배열로 변경 했으니, 클라이언트 측, 서버 측에서 앱 렌더 시에도 아래의 예와 같이 수정을 해 주어야 해요. 세부 사항은 문서에 잘 나와 있으니 참고해 주세요.
// example
<StaticRouter location={req.path} context={context}>
<div>{renderRoutes(Routes)}</div>
</StaticRouter>
다음 작업이 중요한데, 서버에서 렌더 전 렌더할 컴포넌트에 필요한 데이터를 패칭할 거에요. 패칭이 완료되면 클라이언트가 접근한 URL에 맞도록 라우팅이 설정된 앱을 렌더 후 응답을 보낼겁니다. matchRoutes를 사용해 url과 매칭된 라우터를 선별하고, 해당 컴포넌트에 필요한 데이터를 패칭합니다. 데이터 패칭을 위한 함수를 생성해야 하는데, 함수를 해당하는 컴포넌트에 선언한 후 export 하고 라우트에 추가를 합니다.
function ArticleList({ onMount, article }) {
// ... 생략 ... //
return <ul>{renderArticles()}</ul>;
}
function mapStateToProps(state) {
return { articles: state.articles };
}
function loadData(store) {
return store.dispatch(fetchArticle());
}
const component = connect(mapStateToProps, { onMount: fetchArticle })(ArticleList);
export default {
component,
loadData
};
그리고 react-router-config의 matchRoutes 함수를 이용해 서버에서 렌더 전 필요한 데이터를 아래와 같이 패치할 수 있어요. matchRoutes 함수에 앞서 작성한 Routes 설정과 pathname 을 전달하면 매칭된 라우트 정보를 배열로 반환해 줍니다. loadData 함수 안에서 패칭 후의 데이터를 업데이트 할 수 있도록 스토어를 인자로 넘겨 주었어요.
import { matchRoutes } from 'react-router-config';
// ...
app.get('*', (req, res) => {
const store = createStore();
const promises = matchRoutes(Routes, req.path).map(({ route }) => {
return route.loadData ? route.loadData(store) : null;
});
Promise.all(promises).then(() => {
res.send(renderer(req, store));
});
});
지금까지 작업한 서버 데이터 패칭 및 렌더링 후 UI 를 확인하면, 아래와 같은 에러 메세지를 확인할 수 있습니다. 그리고 컴퓨터 사양에 따라 브라우저가 깜빡 하는 것이 눈에 보일수도 있습니다.
이러한 동작의 원인은 서버에서 데이터를 패칭 및 렌더 후 클라이언트에 화면을 보여주지만, 클리이언트 측 자바스크립트가 실행 되면 새로운 스토어를 생성하고 이 새 스토어에는 데이터가 없기 때문에 이로 인해서 서버에서 그려진 화면과 간극이 발생하기 때문입니다. 따라서, 서버에서 패칭한 스토어 상태를 클라이언트 측 스토어에 연결해 주는 작업이 필요해요.
우리는 서버에서 응답으로 보내는 html에서 서버 측에서 업데이트한 state를 윈도우 객체에 저장하고, 이것을 클라이언트에서 스토어 생성 시 활용하는 식으로 상태를 연결할 수 있습니다.
// src/helpers/render.js
export default (req, store) => {
const content = renderToString(
<Provider store={store}>
<StaticRouter location={req.path} context={{}}>
<div>{renderRoutes(Routes)}</div>
</StaticRouter>
</Provider>
);
return `
<html>
<head></head>
<body>
<div id="root">${content}</div>
<script>
window.INITIAL_STATE = ${serialize(JSON.stringify(store.getState()))};
</script>
<script src="bundle.js"></script>
</body>
</html>
`;
};
서버에서 렌더 시 윈도우 객체에 넘겨준 State를 클라에서 스토어 생성 시 받으면 되요.
// Client side index.js
const store = createStore(reducers, JSON.parse(window.INITIAL_STATE), applyMiddleware(thunk));
ReactDOM.hydrate(
<Provider store={store}>
<BrowserRouter>
<div>{renderRoutes(Routes)}</div>
</BrowserRouter>
</Provider>,
document.getElementById('root')
);
XSS attack 대비를 위해 serialize를 추가해 주었어요. XSS에 대해서는 향후 좀 더 공부해 봐야 겠어요.
웹 개발에서 authentication을 브라우저와 서버 간 규약을 맺은 것처럼 생각해 보아요. 브라우저는 서버에 어떠한 요청을 할 때 서버가 클라이언트를 인식할 수 있는 정보 조각을 함께 전달해야 합니다. 이것은 JWT, cookie 등이 될 수 있는데, 서버는 이 인식 가능한 정보를 기반으로 클라이언트에 응답을 합니다.

SSR에서 authentication은 일반적인 위 흐름과는 다르게 진행이 됩니다. 서버 렌더링 단계에서 렌더링 서버가 API 요청 관련 작업을 처리해야 합니다. 이 작업에는 authentication 도 포함이 됩니다. 일반적으로 브라우저 서버간 인증 작업을 하면 서버에서 후에 인증할 수 있는 쿠키 또는 세션과 함께 응답을 보냅니다. 하지만, 이 과정은 브라우저와 API 서버간 이루어 지고, 중간에 렌더링 서버가 추가되면 우리의 렌더링 서버는 API 서버와는 다른 도메인이기 때문에 이 쿠키를 받지 못 합니다.
이러한 점은 렌더링 서버에서 Authentication이 필요한 요청을 API 서버에 할 수 없기 때문에 해결이 필요합니다. 해결법은 클라이언트에서 인증이 필요한 요청을 위해 렌더링 서버에 프록시를 설정하고, 이를 통해 API 서버와 통신하는 것입니다. 하지만, 초기 페이지 렌더링 시에는 렌더링 서버에서 직접 API 요청을 해야 하기 때문에 이를 위한 설정이 필요합니다.
우선 Authentication 문제 해결을 위해 proxy를 설정이 필요합니다. 처음 서버에서 렌더링 시 렌더링 서버에서 API 서버에 직접 요청을 하고, 이후의 요청은 proxy를 통해서 진행하게 됩니다.
proxy 설정을 도와주는 express middleware express-http-proxy 모듈을 설치하도록 해 보아요.
// index.js
const proxy = require('express-http-proxy');
// ...
app.use('/proxy', proxy('www.YOUR_API_SERVER.com'));
그리고 서버에서 스토어 생성 시, 통신을 할 API 서버 주소와 쿠키가 설정된 api 인스턴스 객체를 생성해 thunk 미들웨어에 함께 전달합니다. 이렇게 하면 추후 action에서 이 인스턴스를 받아 API 서버에 요청을 보내기가 수월할 수 있습니다.
// src/helpers/createStore
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
import axios from 'axios';
import reducers from '../reducers';
export default req => {
const axiosInstance = axios.create({
baseURL: 'http://YOUR-API-SERVER.com',
headers: { cookie: req.get('cookie') || '' }
});
const store = createStore(reducers, {}, applyMiddleware(thunk.withExtraArgument(axiosInstance)));
return store;
};
여기까지 authetication 처리를 도와줄 proxy와 쿠키 정보를 포함한 axios 인스턴스와 같은 툴을 만들어 보았습니다. 다음으로 authentication 관련 로직을 작성해 보도록 해요. Sign-in, Sing-out 로직과 로그인 유저만 접근할 수 있는 라우터 설정 등을 예로 들 수 있습니다.
작동 확인을 위해 클라이언트 측 코드를 추가해야 힙니다. 여기서 중요한 것은 어느 부분에서 이와 관련된 코드를 작성할 것이냐 입니다. 유저의 로그인 상태에 따라 앱에 처음 접근 시 다른 화면을 보여 주어야 하기 때문에 리액트 앱의 상단 부분에서 이를 처리해야 하고, 이를 상단에서 처리할 App 컴포너트를 만들어 보아요.
import React from 'react';
import { renderRoutes } from 'react-router-config';
import Header from './components/Header';
import { fetchCurrentUser } from './actions';
const App = ({ route }) => {
return (
<div>
<Header />
{renderRoutes(route.routes)}
</div>
);
};
export default {
component: App,
loadData: function({ dispatch }) {
return dispatch(fetchCurrentUser());
}
};
클라이언트가 앱에 접근을 하면 fetchCurrentUser action creator를 통해 현재 접속한 유저가 로그인을 했는지 안 했는지 API 서버를 통해 확인을 합니다. 그리고 그 초기 상태를 서버 렌더링 시 함께 전달 하도록 설정을 해야 합니다.
Routes 설정에도 App 컴포넌트를 최상단에 위치 시키고 나머지 컴포넌트는 네스팅하도록 수정합니다.
const Routes = [
{
...App,
routes: [
{
...HomePage,
path: '/',
exact: true
},
{
...UsersListPage,
path: '/users'
},
{
...NotFoundPage
}
]
}
];
설정 완료 후 클라이언트 측 Header 컴포넌트에서 현재 유저의 상태에 따라 로그인 버튼을 보여줄지, 로그아웃 버튼을 보여줄지를 결정하는 로직을 추가할 수 있습니다. 최종적으로 서버 측 렌더링 시 로직과 클라이언트 측에서 유저의 로그인 유무에 따른 UI 처리하는 로직을 연결하면 아래와 같은 작동하는 화면을 볼 수 있습니다.
유저가 존재하지 않는 라우트에 접근을 하면 대부분 두 가지 작업을 하려 할 거에요. 브라우저에 404 응답을 내려주고, 유저에게는 notFoundPage를 보여줍니다. 유저에게 NotFoundPage를 보여주는 작업은 간단합니다. 위의 Routes 설정에서와 같이 NotFoundPage를 마지막에 추가하면 됩니다. 고민해 볼 만한 부분은 브라우저에게 보낼 404 status 코드를 어느 부분에서 표기 할 지 입니다.
브라우저라우터에서 리다이렉트를 하면, 브라우저 히스토리가 state를 변경하고 UI가 변경됩니다. 서버 렌더링 시에는 state 변경이 없기 때문에 context를 이용할 수 있습니다. 404를 마킹하는 방법 중 하나로 이 context를 이용할 수 있습니다.
앞서 작성한 렌더링 서버의 render함수 호출 시 StaticRouter에게 context를 Prop으로 넘겨준 후, NotFoundPage 내에서 context의 변경이 생기면 이에 따라 브라우저에 응답을 다르게 보낼 수 있습니다.
// src/index.js
app.get('*', (req, res) => {
const store = createStore(req);
const promises = matchRoutes(Routes, req.path).map(({ route }) => {
return route.loadData ? route.loadData(store) : null;
});
Promise.all(promises).then(() => {
const context = {};
const content = renderer(req, store, context);
// NotFoundPage 컴포넌트 단에서 별도의 속성을 추가해 줄 수 있습니다.
// 여기서는 "notFound"라는 속성을 추가해 보았어요.
if (context.notFound) {
res.status(404);
}
res.send(content);
});
});
앞서 서버 렌더링 전 loadData 함수를 호출할 때 서버 Shut Down이나 API 에러 등 어떠한 이유로 에러가 발생할 수 있습니다. 에러가 발생하면 Promise는 catch 구문으로 넘어가고, loadData 함수를 이용해 보낸 모든 요청의 응답을 받기 전 서버 렌더링을 하고 클라이언트에 응답을 보내게 됩니다.
다소 클러지스럽고 매끄러운 방법은 아니지만, loadData 함수가 반환하는 Promise를 Promise로 감싸는 방법이 있습니다. API 에러가 발생해도 resolve, reject 상관없이 resolve 시킨 후 API 요청을 보낸 것에 대한 모든 응답을 받은 후 서버 렌덩링을 할 수 있습니다.
서버에서 데이터 패칭을 하는 단계에서 에러가 발생한 경우에도 우선은 렌덩링까지 멈추지 않고 진행을 했어요. 그럼 다음으로 에러가 발생한 경우에 처리를 해야해요.
에러 핸들링은 리액트 컴포넌트 안에처 처리를 할 거에요. 이러한 방식의 장점은 서버 측 렌덩링 단계에서와 브라우저에서 모두 동작한다는 점이에요.
비로그인 유저가 로그인 유저만 접근 가능한 페이지에 접근을 해, API 서버로부터 에러 응답을 받는 경우 에러 핸들링이 없다면 행이 걸릴 수 있어요. 당연히 이러한 상황 보다 적절한 메세지를 보여주거나 알맞은 Page 로 리다이렉트 하는 것이 좋겠죠? 이를 처리하기 위해 리액트 앱에서 HOC를 사용할 거에요. HOC가 복잡해 지는가 같은면 Children으로 넘겨주고 didMount 단계에서 아래의 switch와 관련된 로직을 실행해도 될 거에요.
export default ChildComponent => {
class RequireAuth extends Component {
render() {
switch (this.props.auth) {
case false:
return <Redirect to="/" />;
case null:
return <div>Loading</div>;
default:
return <ChildComponent {...this.props} />;
}
}
}
function mapStateToProps({ auth }) {
return { auth };
}
return connect(mapStateToProps)(RequireAuth);
};
그리고 위 HOC를 AdminPage에 연결해 줍니다.
// src/client/pages/AdminPage.js
// ...
export default {
component: connect(mapStateToProps, { fetchAdmins })(requireAuth(AdminPage)),
loadData: ({ dispatch }) => dispatch(fetchAdmins())
};
위와 같이 에러 발생시 핸들링 하는 방법을 알아 보았어요. 한 가지 더 확인할 사항은 서버에서는 StaticRoute를 사용하기 때문에 리액트 내에서 리다이렉트를 해도 변화가 발생하지 않아요. 다른 방법으로 서버에서 처리를 해야 하는데, 이 방법은 여러분이 한 번 찾아 보아요. :)
SEO가 잘 갖춰진 웹페이지의 헤더를 살펴보면 Open Graph Protocol 메타 태그를 확인할 수 있어요. 웹페이지를 설명해 주는 메타데이터라고 생각할 수 있어요. Facebook이나 Twitter 같은 서비스에서도 웹페이지를 나타낼 때 사용하고 있어요. Google 같은 검색엔진이 인덱싱을 위해서도 좋으며, 다른 유저가 해당 웹페이지 링크를 공유할 때도 해당 정보가 활용될 수 있어 중요합니다.
// 예시
<html lang="ko-KR">
<head>
<title data-rh="true">왓챠플레이</title>
<meta
data-rh="true"
name="description"
content="모든 영화, 드라마, 다큐멘터리, 애니메이션을 언제 어디서나 최고의 화질로 무제한 감상하세요."
/>
<meta
data-rh="true"
property="og:title"
content="왓챠플레이 - 체르노빌, 킬링이브, 왕좌의 게임 외 6만 편 영화, 드라마 무제한 감상"
/>
<meta data-rh="true" property="og:type" content="website" />
<meta
data-rh="true"
property="og:description"
content="모든 영화, 드라마, 다큐멘터리, 애니메이션을 언제 어디서나 최고의 화질로 무제한 감상하세요."
/>
<meta
data-rh="true"
property="og:image"
content="https://do6ll9a75gxk6.cloudfront.net/images/watchaPlayOgImage.210b1a9f427d6e28741f.png
"
/>
\
<meta data-rh="true" property="og:url" content="/home" />
<meta data-rh="true" property="og:locale" content="ko-KR" />
...
</head>
...
</html>
SSR를 하지 않고 클라이언트측 리액트 앱으로만 앱을 구현하다면, 몇몇 서비스는 메타데이터를 취하기도 하지만, 그렇지 못 하는 봇도 있으니 가급적 메타데이터 정보를 추가해 주는 것이 좋아요. 서비스를 만들었으면, 검색에 많이 노출되야 좋으니까요.
만약 페이스북 같은 서비스에 자신의 웹사이트 특정 링크가 공유 된다면, 해당 링크에 접근해 메타데이터를 가져오는 봇이 작동할 거에요. 이렇게 접근하는 Page에 따라 메타데이터도 다르게 보여주길 원한다면 이에 맞는 작업이 필요해요.
이를 위해 react-helmet을 사용할 거에요. 클라이언트 측에서 Helmet을 추가하는 것은 단순해요. 리액트 컴포넌트와 같이 Helmet을 추가하면, 라이브러리가 head 태그에 접근해 메타데이터를 추가해 줍니다.
특정 페이지 컴포넌트에서 Helmet은 아래와 같이 쉽게 추가할 수 있어요.
render() {
return (
<div>
<Helmet>
<title>{`${articles.length} Articled Loaded`}</title>
<meta property="og:title" content="Article App" />
</Helmet>
<ul>
<li><h4>Headlines</h4></li>
{renderArticles()}
</ul>
</div>
);
}
하지만, 서버 렌더링 시 Helmet을 이용해 메타데이터를 추가하는 방법은 클라이언트에서의 그것과는 조금 달라져요. 서버에서 Helmet 렌더 시 Helmet은 head 태그에 접근을 할 수 없어요. 서버 렌더링 시 브라우저 API 를 사용할 수 없을 뿐더러, head 태그 자체가 아직 존재하지 않기 때문이에요.
Because this component keeps track of mounted instances, you have to make sure to call renderStatic on server, or you'll get a memory leak.
그래서, 우선 Helmet은 컴포넌트 단에서 렌더를 하고, 서버에서 HTML 생성 시 메타데이터를 추가해야 해요. Helmet의 renderToString 메소드를 이용해 인스턴스를 생성하고 서버에서 클라이언트에 응답 시 페이지 컴포넌트 단에서 Helmet 을 이용해 추가한 메타데이터를 추가해 주어야 합니다.
export default (req, store, context) => {
const content = renderToString(
<Provider store={store}>
<StaticRouter location={req.path} context={context}>
<div>{renderRoutes(Routes)}</div>
</StaticRouter>
</Provider>
);
const helmet = Helmet.renderStatic();
return `
<html>
<head>
${helmet.title.toString()}
${helmet.meta.toString()}
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/materialize/1.0.0/css/materialize.min.css">
</head>
<body>
<div id="root">${content}</div>
<script>
window.INITIAL_STATE = ${serialize(JSON.stringify(store.getState()))};
</script>
<script src="bundle.js"></script>
</body>
</html>
`;
};