React 서버사이드 렌더링

이번 글은 Popit 서비스 개편을 하면서 진행한 Go API 서버와 React로 화면을 구성한 WordPress 읽기 전용 서비스 구축 사례를 소개한 연재글 입니다. 이번 글에서는 React 를 이용하여 화면 구성을 하면서 진행한 내용을 공유하려고 합니다. 이전 글은 다음에서 확인할 수 있습니다.

개발 하는 도중에는 WordPress와 같은 컨텐츠 서비스에 React 등을 이용한 Single Page Application 과 같은 형태로 구성하는 것이 좋은 방법은 아닐것 같다는 생각을 하였습니다. 물론 개발을 시작하기 전에도 그런 생각은 들었지만 페이지도 많지 않고 실제 사용자들이 많이 보는 페이지는 글 상세 페이지이기 때문에 개의치 않고 진행하였습니다.

개발이 완료되고 운영되고 있는 시점에서 생각해보면 이 생각도 조금은 바뀌었습니다. 이 생각은 결론에서 말씀드리겠습니다.

왜 React를 선택했나?

굳이 컨텐츠 서비스와는 잘 맞지 않다고 생각했는데에도 불구하고 왜 React로 개발했을까요? 필자의 자바스크립트 구성 능력이나, CSS, HTML 구조화 능력을 봤을 때 JQuery나 일반 자바스크립트로 화면을 구성할 경우 아주 지저분할 가능성이 많았습니다. 지난 글에서도 밝혔지만 popit 과 같은 서비스는 전담 개발 인력이 지속적으로 개발, 개선을 할 수 없기 때문에 가끔씩 코드를 봐도 쉽게 이해할 수 있도록 구조화가 잘되어야 하고 읽기 쉽게 구성이 되어야 합니다. React, Angular 등은 기본적으로 Component를 이용하여 화면을 구성하고 있기 때문에 모듈화하기 쉽고, 일반 자바스크립트가 아닌 ES6를 이용하기 때문에 Class 기반으로 개발하는 것이 가능합니다. 이것때문에 React나 Angular 와 같은 방식을 생각하게 되었습니다. React와 Angular 둘 사이에서의 선택은 크게 고민하지 않고 React를 선택하였는데 그 이유는 이미 몇군데 개발해본 경험이 있기 때문입니다.

Link 처리에 대한 고민

이번 개편 작업은 새로운 서비스를 만드는 것이 아니라 기존에 운영중인 서비스를 유지하면서 화면 구성만 변경하는 것이었습니다. 따라서 기존에 URL은 그대로 유지를 시켜야 하는 제약 조건이 있었습니다. 예를 들면 특정 글의 URL은 반드시 "https://www.popit.kr/<글 제목>"[1] 과 같은 형태를 반드시 유지해야만 했습니다. 실제 WordPress에서 사용자측 화면에서 사용하는 Link는 대략 다음과 같습니다.

  • 글 상세조회: https://www.popit.kr/<글제목>
  • 특정 Tag 글의 목록: https://www.popit.kr/tag/<tag>
  • 특정 Category의 목록: https://www.popit.kr/category/<category>
  • 특정 저자 글의 목록: https://www.popit.kr/author/<author name>
  • 글 검색 결과: https://www.popit.kr/s=<keyword>

두번째 문제는 컨텐츠를 제공하는 서비스의 특징은 서비스의 첫 페이지를 거치지 않고 바로 특정 글의 상세 조회로 접근하는 경우가 많다는 것입니다. 또한, 검색 엔진의 크롤러도 특정 URL에 직접 접근하여 컨텐츠를 가져가는 경우가 대부분입니다. 구글과 같은 좋은 크롤러는 자바스크립트로 구성된 페이지라도 크롤러가 스크립트 실행결과를 이용하여 그 결과를 이용하여 검색 데이터를 구성 하지만 그렇지 않은 일반 크롤러는 페이지 내용을 인식하지 못하는 경우도 많습니다.

React, Angular 등에서 사용하는 Single Page Application 들의 특징은 일반적인 방식의 웹에서 사용하는 링크가 아닌 이들 플랫폼 내부에 링크에 대한 처리를 지원하는 기능들이 내장되어 있어 브라우저의 링크와는 다른 방식으로 동작합니다. 브라우저의 URL에 "http://www.popit.kr/#/posts" 와 같이 중간에 "#" 문자열이 대표적인 예입니다. 최근에는 일반 링크와 동일한 형태로 지원하고는 있지만 Application 이라는 특징 때문에 첫페이지부터 진입하지 않으면 제대로 동작하지 않는 경우가 많이 있습니다.

React Server Side Rendering(RSSR) 선택

이런 모든 문제를 해결하기 위해서 선택한 방법이 Server Side Rendering 이었습니다. 전통적인 웹은 대부분 Server Side Rendering 이었습니다. 즉, 서버에서 브라우저에 나타나는 형태 그대로를 HTML로 만들어서 제공하고 브라우저는 HTML을 표시하는 방식입니다.

웹의 초기에는 이런 Server Side Rendering 방식으로 사용하다가 화면의 Script에서 AJAX를 이용하여 데이터를 가져오는 방법이 보편화되면서 서버에서는 일부 HTML과 Script만 브라우저로 전달하고 브라우저에서 Script를 실행 시켜 서버에서 데이터를 조회하여 HTML을 생성하는 방식을 사용하게 되었습니다. 이렇게 함으로써 웹에서 화면과 액션을 처리할 수 있는 방법이 획기적으로 개선되었습니다.

이런 방식에서 하나 더 나아간 것이 Single Page Application 방식인데 Single Page Application 이전까지는 그래도 하나의 특정 페이지 내에서의 처리는 스크립트가 하지만 다른 메뉴로 이동하는 경우 다른 페이지가 이를 서비스하게 되었는데 Single Page Application에서는 전체가 하나의 페이지로 서비스를 제공하는 형태입니다. 이렇게 하면 웹 브라우저 위에 마치 하나의 앱이 동작하는 것과 같은 효과를 줄 수 있기 때문에 최근에 복잡한 화면, 기능을 가지는 서비스는 이런 방식을 선호하는 것 같습니다.

Single Page Application 에서도 검색 엔진 또는 페이스북 등과 같은 크롤러들에게 대응하거나 사용자가 직접 특정 URL을 입력하여 접근하는 방법을 지원하기 위해 전통적인 Server Side Rendering 방식을 지원하고 있습니다. 일반적인  Single Page Application 에 비해 약간은 추가 처리가 필요한 것이 있어 처음에는 이런 저런 다른 방식을 고민해보았지만 결국은 Server Side Rendering 이 위의 문제점을 해결하기에는 가장 최적이고 정상적인 방법이라 생각하여 Server Side Rendering을 선택하게 되었습니다.

RSSR의 동작 방식

React Server Side Rendering(RSSR)은 Single Page Application의 특징을 가지면서도 Server에서 온전한 HTML을 전달할 수 있는 방법을 제공합니다. 대략 개념적으로 보면 다음과 같은 형태로 처리됩니다.

react_server_side_render_01

여기서 가장 특징적인 부분은 동일한 소스 코드로 서버 측과 브라우저 측 렌더링을 모두 지원한다는 것입니다. 대략 아이디어는 다음과 같습니다.

  1. Home 으로의 정상적인 접근은 Nginx와 같은 웹서버에서 제공하는 javascript로만 실행되는 Single Page Application의 동작 방식과 동일하게 동작
  2. 특정 페이지로 직접 접근하는 경우 Nginx에서 처리하지 않고 Server에서 자바스크립트를 실행하는 엔진에서 해당 Link에 부합되는 Component의 Render()를 실행하여 출력되는 HTML 문자를 클라이언트로 전송.

    이때 서버 측에서는 서버에서 자바스크립트를 실행할 수 있는 nodejs 엔진을 사용

  3. Single Page Application의 브라우저에서나 Server 측에서 호출하는 API 서버는 동일하게 사용(여기서는 Golang으로 만든 API 서버)
  4. 대부분의 소스 코드는 기존 Single Page Application과 거의 동일하지만 Component의 초기 데이터 처리 하는 부분은 수정이 필요.

그러면 지금부터 간단하게 소스 코드를 통해 RSSR이 어떻게 구현되는지 살펴보겠습니다.

먼저 일반적인 React App과 같은 형태의 코드가 있습니다. 서버에서 전송하는 index.html 에서는 root div 만 정의해 놓게 됩니다.

index.html

1
2
3
4
5
6
7
8
9
<!DOCTYPE html>
<html>
  <body>
    <noscript>
      You need to enable JavaScript to run this app.
    </noscript>
    <div id="root"></div>
  </body>
</html>

그리고 Entry 스크립트 파일인 index.js에는 다음과 같이 정의합니다.

index.js

1
2
import App from './App';
ReactDOM.render(<App />, document.getElementById('root'));

물론 이렇게 정의하면 webpack 과 같은 자바스크립트 빌드 도구에 의해 index.html은 index.js에서 사용되는 모든 react component와 개발자가 만든 component를 빌드하여 script 파일을 만들어 줍니다. 다음과 같이 원래 index.html 파일에 4번 라인과 같이 srcipt를 실행하는 구문을 추가하게 됩니다.

컴파일 된 index.html

1
2
3
4
5
<body>
  <noscript>....</noscript>
  <div id="root"></div>
  <script type="text/javascript" src="js/bundle.js"></script>
</body>

즉, 실제로 브라우저에 전달되는 코드는 아무런 HTML 본문에 대한 요소가 없고 단지 <div id="root"> 라는 Element만 가지는 HTML 만 전달됩니다. 그리고 브라우저가 자바스크립트를 실행하는 방식이 됩니다. 이때 가장 먼저 실행되는 부분이 ReactDOM.render()로 최상위 컴포넌트인 "App" 컴포넌트부터 시작하여 하위 컴포넌트까지를 실행하게 됩니다. 여기까지가 우리가 알고 있는 일반적인 React의 동작 방식이고 Client Side Rendering 방식이라고 할 수 있습니다.

Server Side Rendering은 ReactDOM.render()를 서버에서 실행하고 그 결과 HTML을 클라이언트에 전달합니다. 이것이 가능하기 위해서는 그림에도 있듯이 Server 쪽에 JavaScript를 해석해서 실행할 수 있는 엔진을 이용하는 것인데 일반적으로는 nodejs를 가장 많이 이용하고 있습니다. Server 에서도 브라우저와 동일하게 JavaScript를 실행할 수 있으니 다음과 같이 할 수 있습니다.

/src/server/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
import { renderToString } from "react-dom/server";
const appRenderingResult = renderToString(<App/>);
res.send(`
<!DOCTYPE html>
<html>
  <body>
    <noscript>
      You need to enable JavaScript to run this app.
    </noscript>
    <div id="root">${appRenderingResult}</div>
    <script type="text/javascript" src="js/bundle.js"></script>
  </body>
</html>
`);

서버에서 Render를 위해 사용되는 기능은 "react-dom/server" 에서 제공하는 renderToString() 함수로 위 코드와 같이 파라미터로  최상위 컴포넌트를 전달합니다. renderToString()는 브라우저에서 실행되는 방식과 동일하게 실행되어 render 결과를 문자열로 반환해줍니다.  그리고 일반적으로 Server Side에서 처리하는 방식과 동일하게 HTML 전체 코드를 전달합니다.  앞에서 설명한 Client Side Rendering의 index.html과 index.js의 코드를 하나로 묶은 모양새입니다. 물론 서버쪽도 index.html을 분리할 수 있는데 popit 구현 시에 이렇게 구현하지 않은 것은 header의 메타 정보를 글에 따라 다르게 설정해야 하기 때문인데 자세한 내용은 다음 글에서 설명하도록 하겠습니다.

res.send() 이 코드는 nodejs의 express 서버를 이용할 때 사용하는 코드입니다. 즉, 앞의 구성도 그림에서 Server Side에서 JavaScript 실행 엔진으로는 nodejs를 사용하고 클라이언트로부터의 Http 요청을 받는(실제로는 nginx를 거쳐서) 서버를 express  서버로 사용하고 있는 겁니다. express 까지 포함한 전체 코드는 대략 다음과 같은 모습이 됩니다.

/src/server/index.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import express from "express";
import cors from "cors";
import React from "react";
import { renderToString } from "react-dom/server";
import App from '../App';
const app = express();
app.use(cors());
app.get("*", (req, res, next) => {
    const appRenderingResult = renderToString(<App/>);
    res.send(`
      <!DOCTYPE html>
      <html>
        <body>
          <noscript>
            You need to enable JavaScript to run this app.
          </noscript>
          <div id="root">${appRenderingResult}</div>
          <script type="text/javascript" src="js/bundle.js"></script>
      </html>
    `)
  });
const server = app.listen(5000, () => {
  console.log(`Server is listening on port: 5000`)
});

webpack 빌드[2]

여기까지 하게 되면 하나의 프로젝트(소스 코드)로 서버쪽과 클라이언트쪽 모두를 처리하는 두 개의 애플리케이션을 사용할 수 있게 됩니다. 각각은 다른 entry와 빌드 환경을 가질 수 있기 때문에 webpack의 빌드 설정 또한 두가지로 구분되어야 합니다. 필자가 사용한 빌드 구성은 다음과 같습니다.

webpack.config.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
const path = require('path');
const webpack = require('webpack');
const nodeExternals = require('webpack-node-externals');
const cleanWebpackPlugin = require('clean-webpack-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
const CompressionPlugin = require('compression-webpack-plugin');
const browserConfig = {
  name: 'client',
  entry: ['babel-polyfill', './src/browser/index.js'],
  output: {
    path: __dirname + '/public/',
    filename: 'bundle.js',
    publicPath: '/',
  },
  module: {
    rules: [{
      test: /\.(js)$/,
      use: [{
        loader: 'babel-loader',
        options: {
          plugins: [
            ["import", {"libraryName": "antd", "style": "css"}],
          ]
        }
      }],
      exclude: [
        /(node_modules|bower_components|unitTest)/,
      ]
    }, {
      test: /\.css$/,
        loader: ['style-loader', 'css-loader'],
    }, {
      test: /\.(png|svg|jpg|gif|ico)$/,
        loader: 'file-loader'
    }]
  },
  plugins: [
    new cleanWebpackPlugin(['public']),
    new webpack.DefinePlugin({
      'process.env.BROWSER': JSON.stringify(true),
    }),
    new CopyWebpackPlugin([
      {from: './src/asset/antd.css'},
    ]),
    new webpack.optimize.UglifyJsPlugin({
      include: /\.js$/,
      minimize: true
    })
  ]
};
const serverConfig = {
  name: 'server',
  entry: ['babel-polyfill', './src/server/index.js'],
  target: 'node',
  externals: [nodeExternals()],
  output: {
    path: __dirname + '/server/',
    filename: 'server.js',
    publicPath: '/'
  },
  module: {
    rules: [{
      test: /\.(js)$/,
      use: [{
        loader: 'babel-loader'
      }],
      exclude: [
        /(node_modules|bower_components|unitTest)/,
      ]
    }, {
      test: /\.css$/,
      loader: 'css-loader/locals?module&localIdentName=[name]__[local]___[hash:base64:5]'
    }, {
      test: /\.(png|svg|jpg|gif|ico)$/,
      loader: 'url-loader?limit=10240'
    }]
  },
  plugins: [
    new cleanWebpackPlugin(['server']),
    new webpack.DefinePlugin({
      'process.env.BROWSER': JSON.stringify(false),
    }),
  ]
};
module.exports = [browserConfig, serverConfig];

webpack에 대한 설명은 구체적으로 하지 않겠습니다. 제가 자세하게 모르는 부분도 있고 설명이 너무 길어질 것 같아서 입니다.

Router 붙이기

서비스가 하나의 Link만 있으면 위의 구성만으로 충분합니다. 불행하게도 대부분의 서비스는 여러 Link를 가지고 있고, React에서는 이를 위해 Router를 이용하여 각 Link에 바인딩되는 하위 컴포넌트를 로딩하게 됩니다. 그리고 Link 클릭시 서버로 해당 Link에 대한 새로운 HTML을 요청하지 않고, 클라이언트 내의 자바 스크립트 내에 바인딩되어 있는 컴포넌트를 실행하고 결과를 화면에 렌더링하게 됩니다. 즉 React의 Router에 의해 연결된 링크는 클라이언트에서 처리를 위한 Link입니다. 이런 특징 때문에 주로 "https://www.popit.kr/#/search" 와 같이 "#"을[3] 이용하여 서버쪽 링크와 구분을 하였습니다. 일반적으로 클라이언트 측에서의 Router 구성은 다음과 같이 합니다.

1
2
3
4
5
  <Router>
    <Route exact path="/" component={Home} />
    <Route path="/about" component={About} />
    <Route path="/topics" component={Topics} />
  </Router>

Server Side Rendering이 정상 동작하기 위해서는 Client Side에서 Router에 의해 로딩된 React Component와 Router의 Context 정보를 서버에서도 동일하게 처리해야 합니다. 이를위해Server  측에서는 StaticRouter 라는 컴포넌트를 이용하여 컴포넌트 로딩을 위한 Path 전달과 Context를 전달하게 됩니다.

1
2
3
4
5
const appRenderingResult = renderToString(
  <StaticRouter location={req.url} context={context}>
    <App/>
  </StaticRouter>
);

StaticRouter의 context property에는 Component에 전달될 Props를 넣어 줍니다. 이 정보는 각 Component에서 "this.props.staticContext" 와 같이 참조 할 수 있습니다.

이 부분에 대한 자세한 설명은 https://reacttraining.com/react-router/web/guides/server-rendering 을 참고하세요.

RSSR은 componentDidMount가 호출되지 않는다!

React의 구체적인 스펙은 확인해보지 않았지만 실제 테스트한 결과와 다음 글에서도 확인할 수 있듯이 Server Side Rendering에서는 생성자와 componentWillMount[4] 만 호출됩니다.

필자의 경우 컴포넌트에서 화면을 표현하는데 필요한 데이터를 가져오는 로직의 대부분을 componentDidMount에서 호출하고 있습니다. 이어차피 서버에서는 DOM 객체가 없기 때문에 어떻게 보면 당연할 수도 있습니다. 그렇다면 Server에서는 render에 필요한 데이터는를 어떻게 가져올까요? componentWillMount에 넣는 것도 방법이지만 이 이벤트 함수는 Deprecated  되었고, 향후 버전에서는 없어질거라고 합니다. 그리고 대부분의 데이터는 Async 로 가져오기 때문에 실제 Render가 호출되는 시점에 componentWillMount의 처리가 완료되었는지도 확실하게 판단하기 어렵습니다. 이렇게 되면 서버쪽에서의 Render 처리에서는 아무런 데이터를 표현할 수 없기 때문에 Server Side Rendering을 사용하는 효과가 없게 됩니다.

이 문제를 해결하기 위한 아이디어는 대략 다음과 같습니다.

  • Server 측에서는 Request URL을 이용하여 해당 Router 정보를 찾는다.
  • 이 Router 정보에는 Render에 필요한 데이터를 로딩하는 로직이 구현되어 있다.
  • Router 정보의 데이터 로딩 로직을 이용하여 데이터를 로딩하고 그 데이터를 StaticRouter의 context에 전달한다.
  • 각 Component는 브라우저인 경우와 SSR인 경우를 구분하여 각각 초기 데이터 로딩 처리를 한다.

Router 정보에 componentDidMount에서의 데이터 로딩 기능 구현

이를 위해 Router 처리 하는 부분을 변경해야 합니다. 이 코드는 실제 popit에서 사용하는 코드의 일부분입니다.

/src/router.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
const routes =  [
  {
    path: '/',
    exact: true,
    component: Home,
  }, {
    path: '/search/:keyword',
    component: SearchPostsPage,
    fetchInitialData: (req) => {
      const keyword = req.path.split('/').pop();
      return PostApi.searchPosts(keyword, 1);
    }
  }, {
    path: '/:permalink/',
    component: SinglePostPage,
    fetchInitialData: (req) => {
      const tokens = req.path.split('/');
      if (tokens.length < 2) {
        return Promise.resolve();
      }
      return PostApi.getPostByPermalink(tokens[1]);
    }
  }
];
export { routes }

코드에 있는 routes는 라우팅 관련 정보를 담고 있는 배열 변수 입니다. 라우팅 정보는 path, component와 같이 기본적으로 Rotue를 구성하는 필요한 정보와 초기 데이터를 로딩하는 로직을 담고 있는 fetchInitialData 함수를 가지고 있습니다. 이 routes 정보를 이용하여 애플리케이션의 라우터를 구성합니다.

/src/App.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import React, { Component } from 'react'
import { routes } from './routes'
import { Route, Switch } from 'react-router-dom'
import NoMatch from './NoMatch'
class App extends Component {
  render() {
    return (
      <div>
          <Switch>
            {routes.map(({ path, exact, component: Component, ...rest }) => (
                <Route key={path} path={path} exact={exact} render={(props) => (
                  <Component isMobile={this.props.isMobile} {...props} {...rest} />
                )} />
            ))}
            <Route render={(props) => <NoMatch {...props} /> } />
          </Switch>
      </div>
    )
  }
}
export default App

이렇게 구성한 다음 client 측의 entry 파일에는 다음과 같이 최상의 Router를 추가합니다.

/src/browser/index.js

1
2
3
4
5
6
7
8
9
10
import React from 'react';
import { hydrate } from 'react-dom';
import App from '../App'
import { BrowserRouter as Router } from 'react-router-dom'
hydrate(
    <Router>
      <App />
    </Router>,
  document.getElementById('app')
);

일반적인 경우에서 React의 entry 파일에는 ReactDOM.render() 로 최상의 컴포넌트를 감싸게 됩니다. 위 코드에서 ReactDOM.render 대신 hydrate를 사용하는데 이것은 SSR인 경우 발생하는 문제를 회피하기 위해 사용하였는데 자세한 내용은 다음 글을 참고하세요.

서버에서 데이터 로딩 처리

서버에서는 routes 정보에 정의된 fetchInitialData 함수를 이용하여 Component를 Render하기 전에 데이터를 로딩하고 이 정보를 Component 생성자에 전달합니다.

/src/server/server.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import ...
const app = express();
app.use(cors());
app.get("*", (req, res, next) => {
  const activeRoute = routes.find((route) => matchPath(req.url, route)) || {};
  const promise = activeRoute.fetchInitialData ? activeRoute.fetchInitialData(req) : Promise.resolve();
  promise.then((data) => {
    const context = { data };
    const appRenderingResult = renderToString(<App/>);
      <StaticRouter location={req.url} context={context}>
        <App isMobile={isMobile}/>
      </StaticRouter>
    );
    res.send(`
      <!DOCTYPE html>
      <html>
        <head>
          <script>window.__INITIAL_DATA__ = ${serialize(data)}</script>
        </head>
        <body>
          <div id="root">${appRenderingResult}</div>
          <script type="text/javascript" src="js/bundle.js"></script>
      </html>
    `)
  }).catch(error => {
    // redirect to error page
  })
});  
const server = app.listen(5000, () => {
  console.log(`Server is listening on port: 5000`)
});

서버 측 코드에서는 현재 request에 대한 route 정보를 먼저 찾습니다. 위 코드에서 routes.find 부분입니다. route 정보를 찾은 다음에 이 route 정보의 fetchInitialData 함수를 호출합니다. 이 함수는 Promise를 반환하기 때문에 promise의 then 의 파라미터로 데이터가 전달되고 이 데이터를 이용하여 StaticRouter의 context 파라미터에 전달합니다. 에러가 발생하면 에러 페이지로 리다이렉션을 시킵니다.

head에 window._INITIAL_DATA__ 에 전달받은 data를 설정하는 부분이 있는데 이것은 서버에서 렌더링된 html을 클라이언트가 받았을때 초기 데이터를 설정하기 위함입니다. 이 정보를 전달하지 않으면 클라이언트는 다시 데이터를 로딩하기 때문에 비효율적이라고 볼 수 있습니다. 이 데이터릐 활용에 대해서는 다음 절에서 설명합니다.

Component에서 context로 받은 데이터 처리

이제 각 Component에서 초기 데이터 처리에 대한 구성을 해야 합니다. 클라이언트 렌더링을 사용할 경우에는 주로 componentDidMount에 데이터를 로딩하고 데이터 로딩이 완료되면 setState를 이용하여 state에 로딩된 데이터를 설정하고, render() 에서 이를 활용하는 방식으로 구현되었습니다. SSR 구성에서는 다음 세가지 경우를 모두 구려하여 구현해야 합니다.

  1. 클라이언트에서 전통적인 React App의 형태로 클라이언트 링크를 통해 Component가 로딩된 경우(클라이언트에서 실행됨)
  2. 서버측에서 되는 경우(서버에서 실행됨)
  3. 서버측에서 이미 render 되어서 전달된 html을 클라이언트가 받은 경우(클라이언트에서 실행됨)

이 세가지 경우를 고려하기 위해 다음과 같이 구현하였습니다.

/src/components/detail/SinglePostPage.js

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
export default class SinglePostPage extends React.Component {
  constructor(props) {
    super(props);
    let post;
    if (process.env.BROWSER) {
      // 서버 측 렌더에 의해 실행된 결과인 html을 클라이언트가 받아서 실행되는 코드  
      if (window.__INITIAL_DATA__) {
        post = window.__INITIAL_DATA__.data;
      }
      delete window.__INITIAL_DATA__;
    } else {
      // 서버측 렌더 시 실행되는 코드
      post = this.props.staticContext.data.data;
    }
    this.state = {
      post: post,
    };
    this.getPostByPermalink = this.getPostByPermalink.bind(this);
  }
  componentDidMount () {
    if (!this.state.post) {
      this.getPostByPermalink(this.props.match.params.permalink);
    }
  }
  getPostByPermalink(permalink) {
    PostApi.getPostByPermalink(permalink)
      .then(json => {
        this.setState({
          post: json.data;
        }; 
      })
      .catch(error => {
        alert("Error:" + error);
      });
  };
  render() {
    const { post } = this.state;
    return (
      <div>
        <div>{post.postTitle}</div>
        <div>{post.content}</div>
      </div>  
    )
  };  
}

SinglePostPage 컴포넌트는 popit의 글의 상세 조회 페이지 입니다. 이 페이지는 URL 그 자체가 post 정보를 찾는데 사용됩니다. 즉 "https://www.popit.kr/이것은_테스트_입니다."  이런 URL 구조라면 "이것은_테스트_입니다" (permalink)가 post의 키가 되고 이 정보를 이용하여 post를 찾아 내용을 화면에 표시하는 기능을 수행합니다. permalink는 Router 정의에 의해 path 파라미터로 정의되어 있어 "this.props.match.params.permalink" 로 접근이 가능합니다.

이제 일반적인 클라이언트 측 렌더링과 SSR이 조합된 컴포넌트의 다른 점에 대해 살펴보겠습니다. 첫번째로 눈에 띄는 부분은 생성자(constructor)입니다. 생성자의 로직을 보면 process.env의 BROWSER 값을 이용하여 클라이언트 사이드 인지 여부를 판단합니다. 이 값은 앞에서 정의한 webpack 설정 코드에서 설정하고 있습니다.

1
2
3
new webpack.DefinePlugin({
  'process.env.BROWSER': JSON.stringify(true),
}),

window.__INITIAL_DATA_ 확인하여 설정하는 다음 코드 부분에서 서버에서 받은 HTML 코드에서 데이터가 있는 경우에 대한 초기 설정 부분입니다. 앞에서 설명한 Component에서 고려해야 하는 경우 중 세번째 "서버측에서 이미 render 되어서 전달된 html을 클라이언트가 받은 경우" 에 대한 처리입니다.

if (window.__INITIAL_DATA__) { post = window.__INITIAL_DATA__.data; }

서버에서 이미 렌더되어 화면에 본문이 나와 있기 때문에 다시 post 데이터를 가져올 필요가 없습니다. 이를 위해 서버에서 전달된 html에서 head 부분에서 스크립트로 설정한 INITIAL_DATA 를 확인하여 이 정보가 있으면 post 데이터를 설정합니다. 실제 클라이언트가 받은 html에도 다음과 같이 되어 있습니다.

react_ssr_initial_data

여기에서 다시 componentDidMount 부분을 다음과 같이 수정해야 합니다.

1
2
3
4
5
componentDidMount () {
  if (!this.state.post) {
    this.getPostByPermalink(this.props.match.params.permalink);
  }
}

일반적인 경우라면 state.post가 존재하는지 확인할 필요가 없습니다.  서버에서 이 정보를 제공한 경우 생성자에서 이미 state.post 값을 설정하였기 때문에 굳이 다시 가져올 필요가 없습니다. 이 부분을 반영한 코드입니다. 이렇게 구성하면 1, 3번 경우는 해결했습니다. 이제는 2번 상황에 대한 코드입니다.

env.BROWSER가 false 인 경우 서버 측에서 실행되는 환경이기 때문에 staticContext로 전달된 정보를 이용합니다. 이 정보는 server/index.js 파일의 구현에 보면 fetchInitialData에서 조회한 데이터를 전달한 값입니다. 즉, post 데이터 그 자체입니다. 따라서 state.post에 이 값을 설정합니다.

이렇게 Component를 수정하면 render()는 어떤 환경에서도 render하기 위해 필요한 데이터를 가지고 있게 되어, 원하는 html을 생성할 수 있게 됩니다.

마치며

지금까지 React를 이용하여 Server Side Rendering 구현에 대해 설명드렸습니다. SSR 내용을 제대로 이해하기 위해서는 웹 애플리케이션의 기본 동작원리, React 기반은 Single Page Application 동작원리 등을 모두 이해하고 있어야 하고, 실제 구현에서도 이 두 개념의 동작에 기반하여 구현이 되기 때문에 글의 설명 자체도 다소 장황하게 되었을 수도 있습니다. 실제 동작하는 서비스와 코드를 비교하면서 보시면 이해하는데 도움이 될 겁니다. 실제 동작하는 코드는 다음 gitlab 프로젝트에서 확인할 수 있습니다.

필자는 클라이언트 영역만 전문적으로 개발하지 않기 때문에 깊게 확인하고 서비스를 구현하지는 않았습니다. 대체로 사용자 수준에서 적절하게 타협을 하고 구현을 하였습니다. React의 기본 동작 원리를 깊게 이해한 상태였으면 조금 더 이해하기 쉬운 글을 쓸 수 있을텐데라는 아쉬움은 남지만 RSSR이 실제 운영환경에서 어떻게 구현되는지 정도만을 공유한 것에 만족하려고 합니다. 글 또는 실제 코드에 문제가 있는 부분은 언제든지 알려주시면 반영하도록 하겠습니다. Pull Request 보내 주시면 더 고맙고요.

그리고, 이 글에서 필자가 구현한 방식이 RSSR의 일반적인 방식인지는 필자도 확인해보지 않았습니다. 검색을 통해 나온 소스 코드와 이를 검증하면서 만들었기 때문입니다. 그리고 nodejs 서버의 코드에서 html을 문자열로 만드는 방식보다 index.html 파일을 이용하는 방식 또는 template를 이용하는 방식 등이 더 좋을 수도 있습니다. 또한 다양한 더 좋은 구현 방식이 있을 수 있기 때문에 이 글에서 설명하는 방법이 정답이 아니라고 강조하고 싶습니다.

이 글의 서두에 던진 질문인 그러면 WordPress와 같은 컨텐츠 서비스에 Single Page Application이 맞을 것이냐? 라는 질문에 대해서는 제 생각은 대략 다음과 같이 답변을 드릴 수 있을 것 같습니다.

  1. 브라우저에서 운영되는 프로그램을 전문적으로 개발하는 역량을 가지고 있으면 어떤 구성을 해도 상관은 없을 것 같다.
  2. 저와 같이 그럭저럭 만들 수 있는 개발자도 RSSR에 대한 이해만 있으면 나쁘지 않은 구성이다.
  3. 원래 React를 선택한 목적이 자바스크립트를 구조화 시키는 방법을 모르고 그냥 개발해도 어느 정도는 체계화된 코드 구성을 만들 수 있다는 측면에서 보면 매우 만족하였다. 운영 중인 현재에도 이것 저것 수정하는데에도 큰 무리가 없이 수정하고 있다.

즉, 화면을 많이 개발하지 않은 필자의 입장에서 보면 아주 좋은 선택이었고, RSSR 적용 시 일부 고생은 했지만 만족하고 있습니다.

다음 글에서는 RSSR을 적용하면서 겪었던 몇가지 삽질에 대한 내용을 공유하겠습니다.

주석

[1] 글 상세 조회 URL은 WordPress 설정에서 여러 형태로 변경이 가능하지만 SEO 최적화를 위해서는 URL에 글의 제목과 같이 중요한 정보를 제공해주는 것이 좋다. 정확하게 말하면 글제목이 아니라 글 제목의 형태를 가진 Path 이다.

[2] 빌드를 위해 webpack을 사용하고 있습니다. 필자가 프론트엔드 개발을 지속적으로 하지 않았기 때문에 특별하게 어떤 것이 좋다라는 것을 고려하지 않고 현재 가장 많이 사용하는 빌드 솔루션을 선정하였기 때문입니다.

[3] BrowseRouter를 사용하면  "#"이 나타나지 않는데 그래도 여전히 브라우저 내에서만 링크의 이동이 있고 서버로는 요청이 전달되지 않습니다.

[4] componentWillMount는 Deprecated 되었기 때문에 사용하지 않는 것이 좋습니다.


Popit은 페이스북 댓글만 사용하고 있습니다. 페이스북 로그인 후 글을 보시면 댓글이 나타납니다.