관리 메뉴

거니의 velog

(2) 서버 사이드 렌더링 2 본문

React_백엔드 프로그래밍

(2) 서버 사이드 렌더링 2

Unlimited00 2023. 12. 14. 20:28

3. 서버 사이드 렌더링 구현하기

* 서버 사이드 렌더링을 구현하려면 웹팩 설정을 커스터마이징해 주어야 한다. CRA로 만든 프로젝트에서는 웹팩 관련 설정이 기본적으로 모두 숨겨져 있으니 yarn eject 명령어를 실행하여 밖으로 꺼내 주자.

$ git add .
$ git commit -m'Commit before eject'
$ yarn eject

(1) 서버 사이드 렌더링용 엔트리 만들기

* 엔트리(entry)는 웹팩에서 프로젝트를 불러올 때 가장 먼저 불러오는 파일이다. 예를 들어 현재 작성 중인 리액트 프로젝트에서는 index.js를 엔트리 파일로 사용한다. 이 파일부터 시작하여 내부에 필요한 다른 컴포넌트와 모듈을 불러오고 있다.

* 서버 사이드 렌더링을 할 때는 서버를 위한 엔트리 파일을 따로 생성해야 한다. src 디렉터리에 index.server.js 라는 파일을 생성하자.

import React from "react";
import ReactDOMServer from "react-dom/server";

const html = ReactDOMServer.renderToString(
  <div>Hello Server Side Rendering</div>
);

console.log(html);

* 지금은 가장 기본적인 코드만 작성했다. 서버에서 리액트 컴포넌트를 렌더링할 때는 ReactDOMServer의 renderToString이라는 함수를 사용한다. 이 함수에 JSX를 넣어서 호출하면 렌더링 결과를 문자열로 반환한다.


(2) 서버 사이드 렌더링 전용 웹팩 환경 설정 작성하기

* 작성한 엔트리 파일을 웹팩으로 불러와서 빌드하려면 서버 전용 환경 설정을 만들어 주어야 한다. 먼저 config 경로의 path.js 파일을 열어서 스크롤을 맨 아래로 내린 후 module.exports 부분에 다음과 같이 두 줄을 추가해 주자.

// config after eject: we're in ./config/
module.exports = {
  dotenv: resolveApp(".env"),
  appPath: resolveApp("."),
  appBuild: resolveApp(buildPath),
  appPublic: resolveApp("public"),
  appHtml: resolveApp("public/index.html"),
  appIndexJs: resolveModule(resolveApp, "src/index"),
  appPackageJson: resolveApp("package.json"),
  appSrc: resolveApp("src"),
  appTsConfig: resolveApp("tsconfig.json"),
  appJsConfig: resolveApp("jsconfig.json"),
  yarnLockFile: resolveApp("yarn.lock"),
  testsSetup: resolveModule(resolveApp, "src/setupTests"),
  proxySetup: resolveApp("src/setupProxy.js"),
  appNodeModules: resolveApp("node_modules"),
  appWebpackCache: resolveApp("node_modules/.cache"),
  appTsBuildInfoFile: resolveApp("node_modules/.cache/tsconfig.tsbuildinfo"),
  swSrc: resolveModule(resolveApp, "src/service-worker"),
  ssrIndexJs: resolveApp("src/index.server.js"), // 서버 사이드 렌더링 엔트리
  ssrBuild: resolveApp("dist"), // 웹팩 처리 후 저장 경로
  publicUrlOrPath,
};

module.exports.moduleFileExtensions = moduleFileExtensions;

* 이 파일에는 ssrIndexJs와 ssrBuild 정보를 추가했다. ssrIndexJs는 불러올 파일의 경로이고, ssrBuild는 웹팩으로 처리한 뒤 결과물을 저장할 경로이다.

* 다음으로 웹팩 환경 설정 파일을 작성한다. config 디렉터리에 webpack.config.server.js 파일을 생성해 주자.

const paths = require("./paths");

module.exports = {
  mode: "production", // 프로덕션 모드로 설정하여 최적화 옵션들을 활성화
  entry: paths.ssrIndexJs, // 엔트리 경로
  target: "node", // node 환경에서 실행될 것이라는 점을 명시
  output: {
    path: paths.ssrBuild, // 빌드 경로
    filename: "server.js", // 파일 이름
    chunkFilename: "js/[name].chunk.js", // 청크 파일 이름
    publicPath: paths.publicUrlOrPath, // 정적 파일이 제공될 경로
  },
};

* 웹팩 기본 설정을 작성했다. 빌드할 때 어떤 파일에서 시작해 파일들을 불러오는지, 또 어디에 결과물을 저장할지를 정해 주었다.

* 다음으로 로더를 설정한다. 웹팩의 로더는 파일을 불러올 때 확장자에 맞게 필요한 처리를 해 준다. 예를 들어 자바스크립트는 babel을 사용하여 트랜스파일링을 해주고, CSS는 모든 CSS 코드를 결합해 주고, 이미지 파일은 파일을 다른 경로에 따로 저장하고 그 파일에 대한 경로를 자바스크립트에서 참조할 수 있게 해 준다.

* 서버 사이드 렌더링을 할 때 CSS 혹은 이미지 파일은 그다지 중요하지 않다. 그렇다고 완전히 무시할 수는 없다. 가끔 자바스크립트 내부에서 파일에 대한 경로가 필요하거나 CSS Module처럼 로컬 classname을 참조해야 할 수도 있기 때문이다. 그래서 해당 파일을 로더에서 별도로 설정하여 처리하지만 따로 결과물에 포함되지 않도록 구현할 수 있다.

[config/webpack.config.server.js]

const paths = require("./paths");
const getCSSModuleLocalIdent = require("react-dev-utils/getCSSModuleLocalIdent"); // CSS Module의 고유 className을 만들 때 필요한 옵션

const cssRegex = /\.css$/;
const cssModuleRegex = /\.module\.css$/;
const sassRegex = /\.(scss|sass)$/;
const sassModuleRegex = /\.module\.(scss|sass)$/;

module.exports = {
  mode: "production", // 프로덕션 모드로 설정하여 최적화 옵션들을 활성화
  entry: paths.ssrIndexJs, // 엔트리 경로
  target: "node", // node 환경에서 실행될 것이라는 점을 명시
  output: {
    path: paths.ssrBuild, // 빌드 경로
    filename: "server.js", // 파일 이름
    chunkFilename: "js/[name].chunk.js", // 청크 파일 이름
    publicPath: paths.publicUrlOrPath, // 정적 파일이 제공될 경로
  },
  module: {
    rules: [
      {
        oneOf: [
          // 자바스크립트를 위한 처리
          // 기존 webpack.config.js 를 참고하여 작성
          {
            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,
              cacheCompression: false,
              compact: false
            }
          },

          // CSS 를 위한 처리
          {
            test: cssRegex,
            exclude: cssModuleRegex,
            //  exportOnlyLocals: true 옵션을 설정해야 실제 css 파일을 생성하지 않습니다.
            loader: require.resolve('css-loader'),
            options: {
              exportOnlyLocals: true
            }
          },
          // CSS Module 을 위한 처리
          {
            test: cssModuleRegex,
            loader: require.resolve('css-loader'),
            options: {
              modules: true,
              exportOnlyLocals: true,
              getLocalIdent: getCSSModuleLocalIdent
            }
          },
          // Sass 를 위한 처리
          {
            test: sassRegex,
            exclude: sassModuleRegex,
            use: [
              {
                loader: require.resolve('css-loader'),
                options: {
                  exportOnlyLocals: true
                }
              },
              require.resolve('sass-loader')
            ]
          },
          // Sass + CSS Module 을 위한 처리
          {
            test: sassRegex,
            exclude: sassModuleRegex,
            use: [
              {
                loader: require.resolve('css-loader'),
                options: {
                  modules: true,
                  exportOnlyLocals: true,
                  getLocalIdent: getCSSModuleLocalIdent
                }
              },
              require.resolve('sass-loader')
            ]
          },
          // url-loader 를 위한 설정
          {
            test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/],
            loader: require.resolve('url-loader'),
            options: {
              emitFile: false, // 파일을 따로 저장하지 않는 옵션
              limit: 10000, // 원래는 9.76KB가 넘어가면 파일로 저장하는데
              // emitFile 값이 false 일땐 경로만 준비하고 파일은 저장하지 않습니다.
              name: 'static/media/[name].[hash:8].[ext]'
            }
          },
          // 위에서 설정된 확장자를 제외한 파일들은
          // file-loader 를 사용합니다.
          {
            loader: require.resolve('file-loader'),
            exclude: [/\.(js|mjs|jsx|ts|tsx)$/, /\.html$/, /\.json$/],
            options: {
              emitFile: false, // 파일을 따로 저장하지 않는 옵션
              name: 'static/media/[name].[hash:8].[ext]'
            }
          }
        ]
      }
    ]
  }
};

* 이제 코드에서 node_module 내부의 라이브러리를 불러올 수 있게 설정한다.

[config/webpack.config.server.js]

const paths = require("./paths");
const getCSSModuleLocalIdent = require("react-dev-utils/getCSSModuleLocalIdent"); // CSS Module의 고유 className을 만들 때 필요한 옵션

const cssRegex = /\.css$/;
const cssModuleRegex = /\.module\.css$/;
const sassRegex = /\.(scss|sass)$/;
const sassModuleRegex = /\.module\.(scss|sass)$/;

module.exports = {
  mode: "production", // 프로덕션 모드로 설정하여 최적화 옵션들을 활성화
  entry: paths.ssrIndexJs, // 엔트리 경로
  target: "node", // node 환경에서 실행될 것이라는 점을 명시
  output: {
    path: paths.ssrBuild, // 빌드 경로
    filename: "server.js", // 파일 이름
    chunkFilename: "js/[name].chunk.js", // 청크 파일 이름
    publicPath: paths.publicUrlOrPath, // 정적 파일이 제공될 경로
  },
  module: {
    (...)
  },
  resolve: {
    modules: ["node-modules"],
  },
};

* 이렇게 했을 때 react, react-dom/server 같은 라이브러리를 import 구문으로 불러오면 node_module에서 찾아 사용한다. 라이브러리를 불러 오면 빌드할 때 결과물 파일 안에 해당 라이브러리 관련 코드가 함께 번들링된다.

* 브라우저에서 사용할 때는 결과물 파일에 리액트 라이브러리와 우리의 애플리케이션에 관한 코드가 공존해야 하는데, 서버에서는 굳이 결과물 파일 안에 리액트 라이브러리가 들어 있지 않아도 된다. node_modules를 통해 바로 불러와서 사용할 수 있기 때문이다.

* 따라서 서버를 위해 번들링할 때는 node_modules에서 불러오는 것을 제외하고 번들링하는 것이 좋다. 이를 위해 webpack-node-externals라는 라이브러리를 사용해야 한다. 이 라이브러리를 yarn 명령어를 사용하여 설치해 주자.

$ yarn add webpack-node-externals

* 다음으로 이 라이브러리를 webpack.config.server.js의 상단에 불러와서 설정에 적용한다.

[config/webpack.config.server.js]

const nodeExternals = require("webpack-node-externals");
const paths = require("./paths");
const getCSSModuleLocalIdent = require("react-dev-utils/getCSSModuleLocalIdent"); // CSS Module의 고유 className을 만들 때 필요한 옵션

const cssRegex = /\.css$/;
const cssModuleRegex = /\.module\.css$/;
const sassRegex = /\.(scss|sass)$/;
const sassModuleRegex = /\.module\.(scss|sass)$/;

module.exports = {
  mode: "production", // 프로덕션 모드로 설정하여 최적화 옵션들을 활성화
  entry: paths.ssrIndexJs, // 엔트리 경로
  target: "node", // node 환경에서 실행될 것이라는 점을 명시
  output: {
    path: paths.ssrBuild, // 빌드 경로
    filename: "server.js", // 파일 이름
    chunkFilename: "js/[name].chunk.js", // 청크 파일 이름
    publicPath: paths.publicUrlOrPath, // 정적 파일이 제공될 경로
  },
  module: {
    (...)
  resolve: {
    modules: ["node-modules"],
  },
  externals: [nodeExternals()],
};

* 이제 환경 설정 파일은 거의 작성했다. 마지막으로 환경 변수를 주입하자.

[config/webpack.config.server.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 getClientEnvironment = require("./env");

const cssRegex = /\.css$/;
const cssModuleRegex = /\.module\.css$/;
const sassRegex = /\.(scss|sass)$/;
const sassModuleRegex = /\.module\.(scss|sass)$/;

const env = getClientEnvironment(paths.publicUrlOrPath.slice(0, -1));

module.exports = {
  mode: "production", // 프로덕션 모드로 설정하여 최적화 옵션들을 활성화
  entry: paths.ssrIndexJs, // 엔트리 경로
  target: "node", // node 환경에서 실행될 것이라는 점을 명시
  output: {
    path: paths.ssrBuild, // 빌드 경로
    filename: "server.js", // 파일 이름
    chunkFilename: "js/[name].chunk.js", // 청크 파일 이름
    publicPath: paths.publicUrlOrPath, // 정적 파일이 제공될 경로
  },
  module: {
    rules: [
    	(...)
    ],
  },
  resolve: {
    modules: ["node-modules"],
  },
  externals: [nodeExternals()],
  plugins: [
    new webpack.DefinePlugin(env.stringified), // 환경 변수를 주입해 준다.
  ],
};

* 환경 변수를 주입하면, 프로젝트 내에서 process.env.NODE_ENV 값을 참조하여 현재 개발 환경인지 아닌지를 알 수 있다.


(3) 빌드 스크립트 작성하기

* 이번에는 방금 만든 환경 설정을 사용하여 웹팩으로 프로젝트를 빌드하는 스크립트를 작성해 보자. scripts 경로를 열어 보면 build.js라는 파일이 있다. 이 스크립트는 클라이언트에서 사용할 빌드 파일을 만드는 작업을 한다. 이 스크립트와 비슷한 형식으로 서버에서 사용할 빌드 파일을 만드는 build.server.js 스크립트를 작성해 보겠다.

[scripts/build.server.js]

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

process.on("unhandleRejection", (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);
  return new Promise((resolve, reject) => {
    compiler.run((err, stats) => {
      if (err) {
        console.log(err);
        return;
      }
      console.log(stats.toString());
    });
  });
}

build();

* 코드를 다 작성한 뒤에는 다음 명령어를 실행하여 빌드가 잘 되는지 확인해 보자.

$ node scripts/build.server.js

* 성공적으로 잘 실행되었는가? 이어서 다음 명령어를 실행하여 작성한 결과물이 잘 작동하는지 확인해 보자.

$ node dist/server.js

* 테스트 삼아 만들었던 JSX가 문자열 형태로 잘 렌더링되었다.

* 매번 빌드하고 실행할 때마다 파일 경로를 입력하는 것이 번거로울 수 있으니 package.json에서 스크립트를 생성하여 더 편하게 명령어를 입력할 수 있도록 하자.

[ package.json  - script 부분 ]

  "scripts": {
    "start": "node scripts/start.js",
    "build": "node scripts/build.js",
    "test": "node scripts/test.js",
    "start:server": "node dist/server.js",
    "build:server": "node scripts/build.server.js"
  },

* 이렇게 스크립트를 만들면 다음 명령어로 서버를 빌드하고 시작할 수 있다.

$ yarn build:server
$ yarn start:server

* 스크립트를 다 만들고 나서 짧아진 명령어로 다시 한 번 실행해 보자. 잘 작동한다. 이제 본격적으로 서버 사이드 렌더링을 구현할 준비가 되었다!