ReactJS ServerSide Rendering

jiho·2019년 12월 15일
4

ReactJS

목록 보기
1/3

리액트의 기본원리는 충분히 숙지했다고 가정하에 정리해보겠습니다.

서버사이드 렌더링은 서버측에서 렌더링을 마친 후 결과만을 클라이언트에게 던져주는 형식이다. 기존의 템플릿 렌더링과 비교해서 생각해봅시다.

왜? 서버사이드 렌더링을 사용해야할까?
사실 기본 리액트는 index.html을 보면 알겠지만 시작할 때는 아예 비어있는 상태이고
자바스크립트 코드에 의해 컴포넌트 단위로 렌더링을 하고 AJAX 통신을 통해 서버로 부터 컨텐츠 내용을 받아오고나서야 클라이언트가 결과를 볼 수 있습니다.

위와 같은 방식을 따를 경우, 검색엔진에 의해 해당 웹페이지를 수집하기 어려울 것입니다. 크롤러가 해당 웹페이지의 컨텐츠 or 키워드를 수집하려고 할 때, 비어있는 화면을 보게 될 것이죠. 그리고 추가로 리액트 프로젝트자체가 사이즈가 작지않기때문에 초기 로딩 시간이 더 길어질 것입니다.

여기서 생기는 문제점을 정리해보면 서버사이드 렌더링이 왜 필요한지 알 수 있습니다.
1. 우리 웹페이지를 검색 엔진에 노출시키기 위해서
2. 초기 렌더링 속도를 향상 시키기 위해서(최적화)

저는 VELOPERT님의 책을 보고 참고했습니다. 더 나은 방법이 있다면 추가해보겠습니다.

서버사이드 렌더링 원리

CRA(create-react-app)을 통해서 작성해보겠습니다.
react project개발 과정은 기존의 방식과 다를 것이 없습니다.
마지막 배포시에 해당 작업들을 해주면 될 것입니다.
1. CRA 프로젝트 eject하기. (Eject을 안하는 방법이 나올 것같습니다.)
2. 서버용 webpack.config.server.js 작성하기.
서버용 webpack 설정파일의 차이점은 서버측에서 JSX를 다룰 것이기 때문에 loader들의 설정과 특정 파일들을(CSS, Image, 등등 static하게 처리될 파일들) 번들링하지않는 설정(onlyLocals: true)이 주가 될 것입니다. 그외에는 build시킬 경로, static파일을 가져올 경로 등등 경로 설정입니다.
3. 서버 측 렌더링 코드작성.
이 부분은 특정 url 요청이 왔을 때, 알맞은 페이지만 렌더링해서 보내주면 끝입니다.

짧게 설명해서 위 3개의 작업만 해주면 되지만 2번과정이 생각보다 귀찮고 헤갈리는 작업이 많습니다. 코드로 작성하고 설명으로 남기겠습니다.

설정방법(코드)

1. CRA 프로젝트 만들기

npx create-react-app server-side-rendering

2. 브라우저 렌더링 프로젝트 구현

프로젝트를 하나 생성해서 간단하게 라우터 페이지가 2개인 프로젝트를 만듭니다.
(너무 기본적인 코드니 생략하겠습니다.)

3. 웹펙 설정 및 빌드 스크립트

이제부터 웹팩 설정입니다. (reject한 상태)
./config/paths.js

// ...
module.exports = {
  ...
  ssrIndexJs: resolveApp("src/index.server.js"), // 서버 사이드 렌더링 엔트리
  ssrBuild: resolveApp("dist") // 웹팩 처리후 저장경로
}
  

./config/webpack.config.js


const nodeExternals = require("webpack-node-externals"); // 결과물에서 라이브러리 제거
const paths = require("./paths");
const getCSSModuleLocalIdent = require("react-dev-utils/getCSSModuleLocalIdent"); // CSS Module의 고유 className을 만들 때 필요한 옵션
const webpack = require("webpack");
const getClientEnviroment = require("./env");



// Loader들이 감지할 파일 확장자 타입 정규식
const cssRegex = /\.css$/;
const cssModuleRegex = /\.module\.css$/;
const sassRegex = /\.(scss|sass)$/;
const sassModuleRegex = /\.module\.(scss|sass)$/;

const publicUrl = paths.servedPath.sclie(0, -1);
const env = getClientEnviroment(publicUrl)


module.exports = {
  mode: 'production', // 프로덕션 모드
  entry: 'paths.ssrIndexJs', // 서버 사이드에서 엔트리 경로
  target: 'node', // 노드환경에서 실행될 것이라는 점을 명시(서버)
  ouput: {
  	path: paths.ssrBuild, // 빌드 경로
    filename: 'server.js', // 파일 이름
    chunkFilename: 'js/[name]/chunk.js', //청크 파일 이름
    publicPath: paths.servedPath, // 정적파일이 제공될 경로
  },
  module: {
   	 mode: "production", // production mode
  entry: paths.ssrIndexJs, // entry path
  target: "node", // running env
  output: {
    path: paths.ssrBuild, // build path
    filename: "server.js",
    chunkFilename: "js/[name].chunk.js",
    publicPath: paths.servedPath // 정적 파일이 제공될 경로
  },
  module: {
    rules: [
      {
        oneOf: [
          // 기존 웹펙 설정 파일을 참고하여 작성 다른 부분만 코멘트
          {
            test: /\.(js|mjs|jsx|ts|tsx)$/,
            include: paths.appSrc,
            loader: require.resolve("babel-loader"),
            options: {
              customize: require.resolve(
                "babel-preset-react-app/webpack-overrides"
              ),
              plugins: [
                [
                  require.resolve("babel-plugin-named-asset-import"),
                  {
                    loaderMap: {
                      svg: {
                        ReactComponent: "@svgr/webpack?-svgo![path]"
                      }
                    }
                  }
                ]
              ],
              cacheDirectory: true,
              compact: false,
              cacheCompression: false
            }
          },
          {
            test: cssRegex,
            exclude: cssModuleRegex,
            loader: require.resolve("css-loader"),
            options: {
              onlyLocals: true // css는 실제 결과물을 번들링할 때 생성하지 않습니다. 나중에 따로 static하게 접근할 예정.
            }
          },
          // CSS Module을 위한 처리
          {
            test: cssModuleRegex,
            loader: require.resolve("css-loader"),
            options: {
              modules: true,
              onlyLocals: true,
              getLocalIndent: getCSSModuleLocalIdent
            }
          },
          // SASS를 위한 처리
          {
            test: sassRegex,
            exclude: sassModuleRegex,
            use: [
              {
                loader: require.resolve("css-loader"),
                options: {
                  onlyLocals: true
                }
              },
              require.resolve("sass-loader")
            ]
          },
          // for Sass + CSS module
          {
            test: sassRegex,
            exclude: sassModuleRegex,
            use: [
              {
                loader: require.resolve("css-loader"),
                options: {
                  modules: true,
                  onlyLocals: true,
                  getLocalIndent: getCSSModuleLocalIdent
                }
              },
              require.resolve("sass-loader")
            ]
          },
          // url-loader를 위한 설정.
          {
            test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
            loader: require.resolve("url-loader"),
            options: {
              emitFile: false, // 파일을 따로 저장하지 않는 옵션
              limit: 1000,
              name: "static/media/[name].[hash:8].[ext]"
            }
          },
          {
            loader: require.resolve("file-loader"),
            exclude: [/\.(js|mjs|jsx|ts|tsx)$/],
            options: {
              emitFile: false, // 파일을 따로 저장하지 않는 옵션
              name: "static/media/[name].[hash:8].[ext]"
            }
          }
        ]
      }
    ] 
  },
    // 이제 코드에서 node_modules 내부의 라이브러리를 불러올 수 있게 설정합니다.
  resolve: {
    modules: ['node_modules']
  },
    // node library를 번들링에 포함시키지않음.
  externals: [nodeExternals()],
  plugins: [
  	new webpack.DefinePlugin(env.stringfied) // 환경 변수 주입.  
  ]
}

길이가 좀 많이 길긴 하지만 한번은 작성해볼 필요있다고 생각합니다. 설정은 쓰면서 왜쓰는지 이해하는 것이 학습에 많이 도움되는것 같습니다.
주목해야할 점은 CSS, URL, File은 따로 저장을 하지않는 부분입니다. 서버사이드 쪽에서는 따로 static하게 제공해야할 정보들이기 때문입니다.

그리고 또 주목할만한 점은 마지막에 resolve 설정입니다. 해당 결과물 내부에서 node_modules에서 라이브러리를 사용할 수 있게하는 것입니다.

서버를 위해 번들링할 때는 node_modules에서 불러오는 것을 제외하고 번들링하는 것이 좋습니다. 이를 위해서 아래 라이브러리를 사용해야합니다.

yarn add webpack-node-externals 

리액트 결과물에 라이브러리를 포함시키않습니다. 필요할 때마다 서버측에서 라이브러리를 통해 사용하기 때문에 불필요하게 클라이언트에게 제공할 필요가 없습니다. 이 처리을 통해 더욱 클라이언트측에서 코드가 더욱 가벼워질 것입니다.

웹팩 설정은 끝났습니다. 설정자체는 매우 길고 이해가 안되지만 문서를 찾아보면 크게 어렵지는 않습니다. 즉 서버웹팩설정은 단순히 서버측에서 우리가 작성한 react컴포넌트들을 인스턴스화 시켜서 HTML로 변환하기 위해서 설정한 것들입니다.

빌드 스크립트 작성하기

방금 만든 webpack.config.server.js 설정을 통해 프로젝트를 빌드하는 스크립트를 작성해 보겠습니다. scripts 디렉토리는 기존 CRA에서 build, start, test스크립트들이 있습니다.

scripts/build.server.js

process.env.BABEL_ENV = "production";
process.env.NODE_ENV = "production";

process.on("unhandledRejection", err => {
  throw err;
});

require("../config/env");
const fs = require("fs-extra");
const webpack = require("webpack");
const config = require("../config/webpack.config.server");
const paths = require("../config/paths");

function build() {
  console.log("Creating server build");
  fs.emptyDirSync(paths.ssrBuild);
  let compiler = webpack(config); // 기존에 설정했던 설정파일을 가지고 webpack compiler생성
  return new Promise((resolve, reject) => {
    // webpack 실행
    compiler.run((err, stats) => {
      if (err) {
        console.log(err);
        return;
      }
      console.log(stats.toString());
    });
  });
}

build();

4. 렌더링 서버 코드 작성

import React from "react";
import ReactDOMServer from "react-dom/server";
import App from "./App";
import express from "express";
import { StaticRouter } from "react-router-dom";
import path from "path";
import fs from "fs";

// 번들링에 포함되지않은 static 파일들의 이름과 위치를 담은 Dictionary를 생성
const manifest = JSON.parse(
  fs.readFileSync(path.resolve("./build/asset-manifest.json"), "utf8")
);
const manifest_files = manifest.files;
const chunks = Object.keys(manifest.files)
  .filter(key => /chunk\.js$/.exec(key))
  .map(key => `<script src="${manifest_files[key]}"></script>`)
  .join("");

// index.html을 생성하는 func
function createPage(root) {
  return `
      <!DOCTYPE html>
      <html lang="en">
      <head>
        <meta charset = "utf-8"/>
        <link rel="shortcut icon" href="/favicon.ico"/>
        <meta
          name="viewport"
          content="width=device-width, initial-scale=1, shrink-to-fit=no"
        />
        <title>ReactApp</title>
        <link href="${manifest_files["main.css"]}" rel="stylesheet"/>
      </head>
      <body>
        <noscript> You need to enableJavaScript to run the this app</noscript>
        <div id="root">
        ${root}
        </div>
        <script src="${manifest_files["runtime-main.js"]}"></script>
        ${chunks}
        <script src="${manifest_files["main.js"]}></script
      </body>
      </html>
    `;
}

const app = express();
// 서버 사이드 렌더링 미들웨어(핵심)
const serverRender = (req, res, next) => {
  const context = {};
  const jsx = (
    <StaticRouter location={req.url} context={context}>
      <App />
    <StaticRouter/>
  );
  const root = ReactDOMServer.renderToString(jsx);
  res.send(createPage(root));
};

// 
const serve = express.static(path.resolve("./build"), {
  index: false // "/" 경로에서 index.html을 보여주지 않도록 설정
});

app.use(serve);
app.use(serverRender);

app.listen(5000, () => {
  console.log("Running on http://localhost:5000");
});

서버측 렌더링까지 모두 구현했습니다. 다른 프로젝트할 때 핵심만 알고 있으면 충분히 응용가능할 것같습니다.
마지막으로 매번 실행과 빌드를 커맨드로 치기 귀찮으니 실행 스크립트를 만들고 맘무리 하겠습니다.

"scripts": {
 	"start:server": "node dist/server.js",
     "build:server" : "node scripts/build.server.js"
}
profile
Scratch, Under the hood, Initial version analysis

6개의 댓글

comment-user-thumbnail
2020년 5월 30일

정리 잘해주셨네요! 감사합니다 +_+

1개의 답글
comment-user-thumbnail
2020년 5월 30일

ouput -> output 오타 발견

1개의 답글
comment-user-thumbnail
2020년 6월 30일

ajax를 통해 가져온 데이터는 ssr 결과에 어떻게 포함시키나요?

답글 달기
comment-user-thumbnail
2020년 9월 4일

4번. 렌더링 서버 코드 작성 은 어디파일에 작성해야 하는건가요?
import App from "./App"; 이면,
index.js 에 작성하는건가요?

그리고 4번 코드가 붙여넣기 하니까 오류가 나네요. 오타가 있는듯 합니다.

답글 달기