[React JS] React 로 MPA(Multi Page Application) 구현하기

2024. 4. 22. 00:05React JS

React 프로젝트를 진행하던 중, 고객사에서 SPA 말고 MPA로 해달라는 요구가 들어왔다.
하지만 당시 개발마감 일자까지 1달 밖에 남지 않은 촉박한 상황이었으며 이미 팀원들의 화면개발은 거의 다 완료된 상태였다.
 
SPA를 MPA로 전환하기 위해서는 다른 프레임워크를 끼우는 방법이 있지만, 무언가를 새롭게 배워야하는 비용과 그때까지 작업하던 소스코드를 변경하는 비용은, 마감 일자가 1달 밖에 안남은 상황에서, 많은 부담이 되었다.

팀원들이 작업한 소스코드를 최대한 유지하고 새롭게 무언가를 배우는 비용은 줄이되, SPA를 MPA로 전환하는 방법이 필요했다.

 
머리를 쥐어짜며 끙끙 앓고 있던 나에게 직장 선배가 아래 링크의 글을 보내주며 이 글을 참고하면 React를 MPA로 전환할 수 있겠다고 이야기 하였다.
읽어보니 webpack을 이용하여 React 프로젝트를 화면 별로 여러 html페이지로 쪼개어 MPA를 구현할 수 있다는 내용이었다.
그리고 덕분에 실제로 React 프로젝트를 MPA로 구현하여 프로젝트를 잘 마무리 할 수 있었다!
 
아래 링크의 글은 영어로 되어 있으며 React 프로젝트로 MPA를 가능하게 하는 방법에 대해서 한글로 쓴 글은 아직까지도 찾지 못했다.
react를 mpa로 쓸 바에 차라리 next.js 같은 프레임워크를 사용할테니 관련 정보가 거의 없는 것은 당연하다.
 
그러나 혹시 모르지 않는가. 나 같은 상황에 처한 한국인이 미래에 또 있을지도 모르는 법!
 
그래서 내가 React 프로젝트로 MPA를 구현했던 방법을 아래 링크의 글을 번역하여 설명하고자 한다.
thttps://itnext.io/building-multi-page-application-with-react-f5a338489694

 

Multi Page Application with React

Building Multi Page Application with React

itnext.io


웹개발의 세계에서 우리는 웹앱(Web App)의 주요 디자인패턴 두가지를 나열할 수 있다.

하나는 multi-page application(MPA)이고, 나머지 하나는 single-page-application(SPA)이다. 

나는 이번 포스트에서 리액트 라이브러리를, 사용자의 브라우저에서 서버로부터 혹은 서버로 데이터를 불러오거나 보내기 위해 웹페이지를 다시 불러오는(reloading) 고전적인 아키텍처인, multi-page application로 통합시키는 것에 대해 집중적으로 이야기 하려고 한다. (원래는 리액트가 SPA이니 MPA로의 전환이라는 의미인 것 같음)

 

React 컴포넌트들을 정적인 페이지들로(.html)로 생성하는 설정이 포함되어 있는 Webpack 설정 파일을 만들려고 한다. 차근차근 프로젝트를 만들고 설정(set up) 해보자.

 

천마디의 말보다는 어떤 사진이, 경우에는 코드가, 더욱 가치 있다. 여기, 위에서 정적페이지를 만든다는 컨셉을 설명하는 예시가 있다.

각 React 컴포넌트들이 static page에 추가 되어있는 모습

 

개발환경

이 튜토리얼을 진행하기 위해서는 노드와 npm이 PC에 설치되어있어야 한다.

필자의 환경은 아래와 같다.

$ node -v
V20.12.2

$ npm -v
10.5.0

 

이제 설명은 그만하고 package.json을 만들자.

각자의 workspace에서 아래 명령어를 입력한다.

npm init

참고: 설치할 질문들이 있을 있는데 모든 것들을 생략하고 기본값으로 세팅하고 싶다면 뒤에다가 -y, —yes flag 추가한다.

 

이제 프로젝트 구조를 아래와 같이 해야하는데,

├── src
|   ├── components/
|   |   └── Menu.js
|   └── pages/
|       ├── products/
|       |   ├── product-1.js
|       |   └── product-1.html
|       ├── contact.js
|       ├── contact.html
|       ├── index.js
|       └── index.html
├── package.json
└── webpack.config.js

 

이를 빨리 만들기 위해 아래의 명령어를 실행한다.

touch webpack.config.js && mkdir -p src/components 
&& mkdir -p src/pages/products && touch src/components/Menu.js 
&& touch src/pages/products/product-1.js && touch src/pages/products/product-1.html 
&& touch src/pages/contact.js && touch src/pages/contact.html 
&& touch src/pages/index.js && touch src/pages/index.html

 

Pages 디렉토리를 보면 같은 파일이름으로 된 한 무리의 js와 html 파일들을 볼 수 있다. 저 파일이름은 짝이 맞는 js파일과 html파일을 웹팩에서 연결하기 위한 cue(key, 신호?)이다.

index.html index.js 쌍으로 예를 들자면, 웹팩은 React컴포넌트들을 차례로 불러오는(inport) index.js 포함하는 index.html 생성한다. 웹팩 설정에 대해서 깊이 있게 들어가기 전에, 위에서 만든 파일들에 코드를 작성해보자.

 

먼저 Menu 컴포넌트이다.

// /src/components/Menu.jsx

import React, { Component } from 'react';

export default class Menu extends Component {
    render() {
        return (
            <ul>
                <li><a href="/index.html">Home</a></li>
                <li><a href="/products/product-1.html">Product</a></li>
                <li><a href="/contact.html">Contact</a></li>
            </ul>
        );
    }
}

 

두번째로, 이전 단계에서 만든 Menu 컴포넌트를 import하는 코드를 index.js 작성한다.

// /src/pages/index.js - 이 코드를 contact.js와 product-1.js에도 복사 붙여넣기한다.

import React from "react";
import ReactDOM from "react-dom";
import Menu from "components/Menu";

ReactDOM.render(<Menu />, document.getElementById("menu"));

 

마지막으로 <Menu> React 컴포넌트가 렌더링 index.html 파일을 아래와 같이 작성한다.

<!— /src/pages/index.html - contact.html과 product-1.html 파일에도 <title> tag 부분만 바꿔서 복사 붙여넣기 한다.  —>

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <title>Home Page</title>
  </head>
  <body>
    <div id="menu"></div>
    <p>Lorem ipsum dolor sit amet consectetur adipisicing elit.</p>
  </body>
</html>

html 파일에서 가장 흥미로운 부분은 id 속성이있는 <div>태그이다. Id 값이 menu 되어있는 <div>태그 안에는 Menu 컴포넌트가 배치될 것이다. 이것을 해내기 전에 우리는 bundler, 개발서버와 같이 우리를 도와줄 도구들을(tools) 설치(set up)해야 한다.

 

Depengency 설치

프로젝트에서 우리는 webpack module bulder 설치할 것이다. webpack-cli 커맨드 라인에서 (package.json 파일에서) webpack 사용하게 해주고, html-webpack-plugin 모든 webpack bundle 포함하는 html 파일들을 생성하게 해주고, webpack-dev-server 개발 서버로서 live reloading(여러분들이 알고 있는 리로딩) 기능을 제공해준다.

npm i webpack webpack-cli webpack-dev-server html-webpack-plugin --save-dev

 

다음으로 우리는 react react-dom 설치할 것이다. 이는 React html DOM 사이에 접착제로서 컴포넌트를 렌더링하고 DOM 접근할 있게 해준다. (이미 설치 되어있는 경우 넘어가도 됩니다.)

npm i react react-dom --save

 

React 함께 ES6 JSX 지원하게 해주는 babel 플러그인 설치가 필요하다.

npm i @babel/core @babel/preset-env @babel/preset-react babel-loader --save-dev

 

모든게 설치 되고 나면, package.json build start 스크립트를 추가한다.

{
   "name": "multi-page-app-with-react",
   "version": "1.0.0",
   "description": "Multi Page Application with React",
   "main": "index.js",
   "scripts": { 
      "build": "webpack — mode production", 
      "start": "webpack-dev-server — mode development — hot — open — port 3100" 
   }
   "keywords": [],
   "author": "",
   "license": "ISC"
}

 

build 스크립트는 페이지 로딩 속도를 개선하기 위해 production 모드를 실행한다.(i.e. minified code, lighter weight source maps 기타 등등의 기능 제공) - 

(이 부분은 번역이 좀 부자연스럽네요. 빌드를 돌리면 코드도 압축 되고 source map 등 페이지 로딩 속도를 감소시키기 위해 이런 저런 작업이 일어나거든요. 이렇게 빌드한 프로젝트를 배포하거나 하는데, 빌드된 프로젝트를 development mode에서도 사용한다는 것이 포함되어있는 것인지를 잘 모르겠어요.)

start 스크립트는 development 모드의, hot module replacement(참고: https://webpack.kr/guides/hot-module-replacement/기능이 포함 되어있는 서버를 실행시키고 서버가 완전히 켜지면 브라우저를 연다.

 

하지만 모든 스크립트를 실행하기 전에, webpack.config.js 파일을 작성하고 설정해야한다.

 

웹팩 설정(Webpack configuration)

가장 먼저, webpack.config.js 파일에서 entry 프로퍼티를 설정함으로서 entry point를 정의하고자한다. Webpack에게 각 page 마다 하나의 entry point를 사용하라고 말해주려는 것인데, 이는 entryChunkName 키와 해당 page의 js 파일의 경로를 값으로 정의한 object를 entry 프로퍼티의 값으로 세팅함으로서 가능해진다.

(예를 들어 contact url pathname 되고 /contact.html에는 ./src/pages/contact.js 파일을 불러오는 )

// webpack.config.js

module.exports = {
  entry: {
    'index' : './src/index.js',
    'products/product-1': './src/pages/products/product-1.js',
    'contact' : './src/pages/contact.js'
  }
};

 

당연하게도, 우리는 위에서 것처럼 하드코딩하고 싶진 않다. 왜냐하면 새로운 페이지를 추가할 때마다 설정도 업데이트 해야 하기 때문이다. 제일 좋은 솔루션은(desired solution) pages 디렉토리 내에서 모든 .js 파일을 찾은 다음 이를 바탕으로 entry points 정보가 담긴 object 생성하는 것이다.

 

그러기 위해서 getFilesFromDir 함수를 files.js 구현할 것이다.

// /config/files.js - 

const fs = require("fs");
const path = require("path");

function getFilesFromDir(dir, fileTypes) {
  const filesToReturn = [];
  function walkDir(currentPath) {
    const files = fs.readdirSync(currentPath);
    for (let i in files) {
      const curFile = path.join(currentPath, files[i]);
      if (
        fs.statSync(curFile).isFile() &&
        fileTypes.indexOf(path.extname(curFile)) != -1
      ) {
        filesToReturn.push(curFile);
      } else if (fs.statSync(curFile).isDirectory()) {
        walkDir(curFile);
      }
    }
  }
  walkDir(dir);
  return filesToReturn;
}

module.exports = getFilesFromDir;

 

그 다음엔 getFilesFromDir 함수를 import 하고 실행 결과를 entry property 값을 교체해준다.

webpack.config.js 최종 버전은 아래에서 확인할 있습니다.(git 있구요)

const path = require("path");
const getFilesFromDir = require("./config/files");
const PAGE_DIR = path.join("src", "pages", path.sep);
const jsFiles = getFilesFromDir(PAGE_DIR, [".js"]);
const entry = jsFiles.reduce( (obj, filePath) => {
   const entryChunkName = filePath.replace(path.extname(filePath), "").replace(PAGE_DIR, "");
   obj[entryChunkName] = `./${filePath}`;
   return obj;
}, {});
module.exports = {
  entry: entry
};

 

entry point 세팅되고 나면, html-webpack-plugin 설정 부분으로 넘어갈 있다. 플러그인의 주요 목표는 상응하는 javascript 파일을 포함하고 있는 html 파일을 dist 폴더 안에 생성하는 것이다. (“상응하는”-corresponding이라는 단어. index.html에서는 index.js, contact.html에서는 contact.js import하는 구조이다. 풀어말하자면 해당 html 파일이 렌더링하는 js 파일) 예를 들어, 만약 contact 페이지를 생성한다고 하면, contact.js 파일이 <body> 안에 <script> 포함되어있는 contact.html 파일이 생성될 것을 예상한다. 그러기 위해서 최소한 3개의 설정 옵션을 제공해야한다.

plugins:[
…
  new HtmlWebPackPlugin({
    chunks:["contact", "vendor"],
    template: "src/pages/contact.html",
    filename: "contact.html"
})
…
]

 

template 파일에 <script> 태그를 통해 chunks 프로퍼티의 코드베이스가 포함되고, 결과적으로 이것이 filename 으로 html 파일로 생성된다. 우리는 코드가 뭉태기로 import 되는 것이 아니라 분할되어 import 되기를 원하기 때문에 (As we want to split vendor (i.e. 3rd party libraries) and app code into separate bundles,) chunks 테이블에 “vendor” 추가해 주어야 한다. 이것에 대한 자세한 내용은 최적화에 대해 이야기할 작성하도록 하겠다.

 

위에 작성한 설정값은 올바르게 작성된 값이다. 하지만 구조대로 가길 원한다면 html 파일 마다 하드코딩으로 HtmlWebPackPlugin object 작성해야할 것이다. entry points 정의할 때와 같은 이유로 그러고 싶지 않다. 그래서 getFileFromDir 함수를 사용할 것이다. 하지만 이번에는 html 파일만 찾을 것이다.

const HtmlWebPackPlugin = require("html-webpack-plugin");
const htmlFiles = getFilesFromDir(PAGE_DIR, [".html"]);
const htmlPlugins = htmlFiles.map( filePath => {
  const fileName = filePath.replace(PAGE_DIR, "");
  return new HtmlWebPackPlugin({
    chunks:[fileName.replace(path.extname(fileName), ""), "vendor"],
    template: filePath,
    filename: fileName})
});
module.exports = {
  entry: entry,
  plugins: [...htmlPlugins]
};

 

React 컴포넌트 내에서 module들을 더욱 쉽게 import 하기 위해서는 module resolver alias 설정할 있다. 공통적으로 쓰이는 “components”폴더와 “src” 폴더를 alias 지정하자.

module.exports = {
  entry: entry,
  plugins: [...htmlPlugins],
  resolve:{
     alias:{
        src: path.resolve(__dirname, "src"),
        components: path.resolve(__dirname, "src", "components")
     }
  },
};

 

이제 Webpack 아래와 같은 모듈 import resolve alias 사용할 것이다.

import Menu from "components/Menu";

 

파일 위치의 변화에 민감한, 기본 import 메커니즘 대신에 말이다. 이를 테면 아래와 같은,,,

import Menu from "../../components/Menu";

 

마지막이지만 중요한 것으로서, 우리는 React 실행될 있도록 babel-loader 설정해주어야한다. (이는 ES6 ES5코드로 JSX javascript 트랜스파일 해준다.)

module.exports = {
   // put previously defined properties here (entry, plugins etc)
   module: {
      rules: [{
         test: /\.js$/,
         exclude: /node_modules/,
         use: {
            loader:”babel-loader”,
               options:{
                  presets: [
                     “@babel/preset-env”,
                     “@babel/preset-react”
                  ]
               }
         }
      }]
   },
}

Note: 프리셋은 .babelrc 파일에 작성해 놓을 수도 있다.

 

드디어, 프로젝트를 build 있다.

npm run build

 

결과물을 보자! 모든게 원활하게 이루어졌다면, dist 디렉토리에 html js 파일들이 pages 폴더를 기반으로 생성된 것이 보일 것이다.

 

그러나, js 파일에 한가지 문제점이 있다. 빌드된 js 파일 아무거나 열고 편집한다면 안에 어플리케이션과 써드파티 소스코드가(다른 말로 리액트와 라이브러리 - npm 라이브러리) 포함되어있는 것을 것이다.

최적화 이전의 index.js 파일은 React와 다른 라이브러리들의 코드들도 포함되어있다.

 

이는 두가지 문제를 야기한다.

  1. 우리가 만약 코드 한줄이라도 변경한다면 사용자는 다시 하나의 거대한 파일을 모두 다운 받아야 한다.
  2. 같은 써드 파티 소스코드(라이브러리) js 파일마다 포함되어있고 다운로드 해야 한다. 무슨 말이냐면, index.js, contact.js, product-1.js 모두 같은 React 코드를 포함하고 있다는 듯이다.

이슈에 대한 해결책은 써드파티 라이브러리를 vendor.js 파일로 분리시키는 것이다. 그러면 Webpack4에서 코드 스플리팅의 기본적인 해결책이 되는 splitChunksPlugin 이용하여 패키지를 별도의 파일로 분리해보자.

 

module.exports = {
   // put previously defined properties here (entry, plugins etc)
   optimization: {
      splitChunks: {
         cacheGroups: {
            vendor: {
               test: /node_modules/,
               chunks: "initial",
               name: "vendor",
               enforce: true
            }
         }
      }
   }
}

 

모든 변화의 과정을 거친 webpack.config.js 파일의 최종 모습이다.(제가 프로젝트 할때 사용한 파일은 git 있습니다!)

const path = require("path");
const HtmlWebPackPlugin = require("html-webpack-plugin");
const getFilesFromDir = require("./config/files");
const PAGE_DIR = path.join("src", "pages", path.sep);

const htmlPlugins = getFilesFromDir(PAGE_DIR, [".html"]).map( filePath => {
  const fileName = filePath.replace(PAGE_DIR, "");
  return new HtmlWebPackPlugin({
    chunks:[fileName.replace(path.extname(fileName), ""), "vendor"],
    template: filePath,
    filename: fileName
  })
});

const entry = getFilesFromDir(PAGE_DIR, [".js"]).reduce( (obj, filePath) => {
  const entryChunkName = filePath.replace(path.extname(filePath), "").replace(PAGE_DIR, "");
  obj[entryChunkName] = `./${filePath}`;
  return obj;
}, {}); 

module.exports = {
  entry: entry,
  plugins: [
    ...htmlPlugins
  ],
  resolve:{
    alias:{
      src: path.resolve(__dirname, "src"),
      components: path.resolve(__dirname, "src", "components")
    }
  },
  module: {
    rules: [
      {
	test: /\.js$/,
	exclude: /node_modules/,
	use: {
	  loader:"babel-loader",
	  options:{
	    presets: [
	      "@babel/preset-env",
	      "@babel/preset-react"
	    ], 
	  }
	},
      }]
    },
    optimization: {
      splitChunks: {
        cacheGroups: {
          vendor: {
            test: /node_modules/,
            chunks: "initial",
            name: "vendor",
            enforce: true
          }
        }
      }
    }
};

 

어플리케이션을 다시 빌드 해보자

npm run build

 

이번 빌드가 완료되었을 , 새로운 vendor.js 파일이 dist 폴더에 생성된 것을 것이다. 파일은 오직 써드파티 코드만 포함하고 있다. 아래 사진에 있는 다른 *.js 파일들은 이제 써드파티 코드로부터 해방 되어 있어야 한다.

최적화 후 생성된 파일들

 

모든 요소들이 준비되었을 , 드디어 우리는 development 모드로 들어가 프로젝트를 실행 시킬 있다.

npm start

 

다음으로 할 일은?

 

우리 모두가 알듯이, 항상 개선의 여지가 남아 있다. 그렇기 때문에 우리는 CSS/SCSS loader 관련해서 설정을 쉽게 확장시킬 수도 있고 linter 더하고, (캐싱을 비활성화 하기 위한 목적으로) .js 파일 이름을 암호화할 수도 있으며 다른 여럿 멋진 것들을 수도 있다. 누구든지 언급된 개선점들이 함께 있는 설정을 따라 사용하고 싶은 사람들을 위해 Github 리파지토리를 준비해두었다.

 

git: https://github.com/przemek-nowicki/multi-page-app-with-react


나의 Git: https://github.com/kingkiboots/react-mpa