[SSR] 서버사이드렌더링(2) - SSR 직접구현, ReactDOMServer

권준혁·2021년 6월 12일
13

SSR

목록 보기
2/4
post-thumbnail

ReactDOMServer

React 공식문서: ReactDOMServer

React에서 ReactDOMServer객체를 이용하면 컴포넌트를 정적마크업으로 렌더링 할 수 있다. 대체로 Node서버에서 사용한다.


SSR

이전 포스팅 SSR과 CSR의 비교에서 포스팅했던 SSR에 대한 내용을 정리해보면

1. 서버에서 먼저 HTML문자열을 전송하게 된다. 이 때는 단순히 볼 수는 있는 화면을 제공한다. 클릭 등 사용자의 액션에 반응하지 않는다.
2. 브라우저는 JS번들을 다운로드해 리액트를 실행시킨다. 사용자는 여전히 브라우저가 그린 화면은 볼 수 있다.
3. 리액트가 가상돔생성과 이벤트주입 과정을 거쳐 인터렉티브한 웹이 만들어진다. 이 과정을 거치고나면 사용자의 액션에 반응하는 앱이 된다.

ReactDOMServer가 제공하는 함수들은 1번의 과정이다.


ReactDOMServer 함수들의 역할

ReactDOMServer에서 제공하는 함수들을 알아보자

  • renderToString()
  • renderToStaticMarkup()
  • renderToNodeStream()
  • renderToStaticNodeStream()

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

1. 설치

react와 react-dom을 설치한다.

react-dom은 리액트의 가상돔 생성에 필요하며, 서버렌더러를 포함하고있다.

yarn add react react-dom

노드서버 구축에 사용할 express프레임워크를 설치한다.

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에 번들파일을 삽입해주는 플러그인과 빌드폴더를 비워주는 플러그인을 함께 설치했다.

2. 클라이언트 측 코드 (아직 SSR 미적용)

html파일, 리액트 컴포넌트, 바벨 설정파일, 웹팩 설정파일를 작성해보자

html

<!DOCTYPE html>
<html>
    <head>
        <title>test-ssr</title>
    </head>
    <body>
        <div id="root"></div>
    </body>
</html>

index.js

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

ReactDom.hydrate(<App />, document.getElementById('root'));

App.js

import React from 'react';

export default function App() {
    const onClickButton = () => {
        window.alert("clicked !!")
    }
    return (
        <div>
            <button onClick={onClickButton}>클릭</button>
        </div>
    )
}

babel.config.js

module.exports = {
    presets: ['@babel/preset-react', '@babel/preset-env'],
    plugins: [],
};

webpack.config.js

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.jsindex.html이 생성되어있다.


3. 서버 측 코드

현재까지 작성한 코드는 첫 페이지만을 전달하고있어 온전한 SSR이라고 하기 어렵다. 특히 hydrate함수로 이벤트 주입도 하지 않고있다.

서버측 코드를 작성 후 마저 수정한다.

server.js

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.htmlutf-8인코딩 방식으로 읽어 문자열을 저장했다.
  • /favicon.ico부분의 코드는 자동으로 요청하는 파비콘에 대해서는 get * 로 요청이 전달되지 않도록 차단했다.
  • page는 서버사이드에서 리액트의 라우팅을 위해 url에서 추출한 라우트 이름을 initialData로 전달한다.
  • React의 renderToString으로 리액트 최상위컴포넌트인 App컴포넌트를 html문자열 형태로 바꿨다.

응답결과는 문자열 형태의 html이며, 이 후 자바스크립트 번들이 실행되면 리액트가 실행되고 이벤트 등을 주입하게된다.
.replace('__DATA_FROM_SERVER__',JSON.stringify(initialData))에 대한 설명은 클라이언트 측 코드를 수정하며 이어서 한다.

4. 클라이언트 측 코드 수정 (SSR)

바벨 설정파일 수정

현재는 바벨이 기본으로 바라보는 설정파일 이름인 babel.config.js를 사용하고 있다. 하지만 서버측에서 사용할 바벨설정과 클라이언트측 바벨설정이 달라진다.

서버측에서는 노드기반이기 때문에 폴리필 프리셋이 필요없으나 ECMA 모듈시스템을 읽을 수 없다.
클라이언트측엔 반대로 폴리필 프리셋이 필요하고 ECMA 모듈시스템은 사용할 수 있다.

.babelrc.common.js

module.exports = {
    presets: ['@babel/preset-react'],
    plugins: [],
}

.babelrc.server.js

const config = require('./.babelrc.common.js');
config.plugins.push('@babel/plugin-transform-modules-commonjs');
module.exports = config;

.babelrc.client.js

const config = require('./.babelrc.common.js');
config.presets.push('@babel/preset-env');
module.exports = config;

commonjs와 레거시 자바스크립트 문법으로 작성해야한다.

현재 webpack은 바벨의 기본설정파일명 .babelrc 또는 babel.config.js만을 읽는다. webpack설정을 바꿔준다.

webpack.config.js

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에 스크립트를 추가한다.

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"
  },

index.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 등 공식문서들을 참고해 작성했습니다.

소스코드: 깃허브

profile
웹 프론트엔드, RN앱 개발자입니다.

0개의 댓글