[MicroFrontend 연습하기] - Webpack Federation 연습해보기

1. 프로젝트 초기 세팅하기

1) `pnpm init`으로 `package.json` 파일 만들기

pnpm init

 

2) `corepack` 설정

 

corepack은 Node.js 생태계에서 사용되는 패키지 관리 도구인 npm, yarn, pnpm 과 같은 다양한 패키지 매니저의 버전을 쉽게 관리하고 사용할 수 있도록 도와줍니다.

 

corepack의 주요 역할

  • 패키지 매니저의 버전 관리
    • 프로젝트별로 사용해야 하는 패키지 매니저의 버전을 지정할 수 있으며, Corepack은 해당 버전을 자동으로 다운로드하고 사용하도록 설정합니다. 이를 통해 각 프로젝트에 맞는 패키지 매니저의 버전을 손쉽게 관리할 수 있습니다.
  • 패키지 매니저 설치 자동화
    • Corepack을 사용하면 패키지 매니저를 사전에 설치할 필요 없이, 필요한 버전이 자동으로 다운로드되어 사용할 수 있게 됩니다. 이는 특히 여러 프로젝트에서 서로 다른 버전의 패키지 매니저를 사용하는 경우에 유용합니다.
  • 호환성 및 일관성 보장
    • 프로젝트 내에서 명시된 패키지 매니저 버전을 강제함으로써, 팀원들이 서로 다른 버전의 패키지 매니저를 사용하여 발생할 수 있는 호환성 문제를 예방할 수 있습니다. 이는 CI/CD 환경에서도 일관된 빌드 및 배포 과정을 보장하는 데 기여합니다.
  • 명령어 프록시 역할
    • Corepack은 npm, yarn, pnpm 등의 명령어에 대한 프록시 역할을 하며, 사용자가 명령어를 실행할 때 올바른 버전의 패키지 매니저가 사용되도록 합니다.

[Corepack 활성화]

Node.js 16.10.0 이상에서는 기본적으로 Corepack이 포함되어 있으나, 활성화하려면 다음 명령어를 사용해야 합니다.

corepack enable

 

[Corepack으로 패키지 매니저 설정하기]

corepack use pnpm@8.10.0

 pnpm의 8.10.0 버전을 사용하도록 corepack을 설정해주었다.

 

 

 

3) `pnpm-workspace.yaml` 파일 생성 및 설정 추가

touch pnpm-workspace.yaml
// pnpm-workspace.yaml
packages:
  - "apps/*"

2. 모노레포 구축하기

1) apps 디렉터리를 만들고, apps 디렉토리에서 프로젝트 생성

pnpm create mf-app

 

main-app과 component-app 2개의 워크스페이스를 만들기

 

`pnpm create <package-name>` 명령은 특정 템플릿이나 스크립트를 기반으로 새로운 프로젝트를 생성할 때 사용되는 명령어입니다. 주로 프로젝트 초기설정을 자동화하고 필요한 패키지 및 설정을 손쉽게 적용하는데 사용됩니다.

(`npx`나 `yarn create`와 비슷한 기능)

 

mf-app이란 https://github.com/jherr/create-mf-app 레포지토리에서 미리 세팅된 cli 명령어이며, 이 cli 실행하여 마이크로 프론트엔드를 쉽게 세팅할 수 있게 도와줍니다.

 

2) 루트에서 `pnpm install`을 통해 의존성 설치하기 (루트를 포함한 3개의 워크스페이스에 의존성 설치하기)

pnpm install

 

3) component-app 워크스페이스에서 `Button` 컴포넌트를 만들고, 해당 워크스페이스를 실행하기

 

Button 컴포넌트 만들기

// apps/component-app/src/components/Button.jsx

import React from "react";

const styleMapping = {
  primary: {
    marginLeft: "10px",
    color: "#fff",
    backgroundColor: "#409eff",
    borderColor: "#409eff",
    padding: "12px 20px",
    fontSize: "14px",
    borderRadius: "4px",
    outline: "none",
    border: "1px solid #dcdfe6",
    cursor: "pointer",
  },
  warning: {
    marginLeft: "10px",
    color: "#fff",
    backgroundColor: "#e6a23c",
    borderColor: "#e6a23c",
    padding: "12px 20px",
    fontSize: "14px",
    borderRadius: "4px",
    outline: "none",
    border: "1px solid #dcdfe6",
    cursor: "pointer",
  },
};

const Button = ({ type, children, onClick }) => {
  const buttonType = type === "warning" ? "warning" : "primary";

  return (
    <button style={styleMapping[buttonType]} onClick={onClick}>
      {children}
    </button>
  );
};

export default Button;

 

App 컴포넌트에 Button 컴포넌트를 사용하기

// apps/component-app/src/App.jsx

import React from "react";
import ReactDOM from "react-dom/client";

import "./index.css";
import Button from "./components/Button";

const App = () => (
  <div className="container">
    <div>Name: component-app</div>
    <div>Framework: react</div>
    <div>Language: JavaScript</div>
    <div>CSS: Empty CSS</div>
    <Button>Sample</Button>
  </div>
);
const rootElement = document.getElementById("app");
if (!rootElement) throw new Error("Failed to find the root element");

const root = ReactDOM.createRoot(rootElement);

root.render(<App />);

 

component-app 워크스페이스를 실행하기

pnpm --filter component-app start:live

 

이렇게 실행하면 다음과 같은 화면을 볼 수 있다.

 

3. Webpack Federation 설정하기

1) component-app 워크스페이스의 컴포넌트를 다른 워크스페이스에서 사용할 수 있도록 웹팩의 `exposes` 설정 추가하기

// apps/component-app/webpack.config.js

const HtmlWebPackPlugin = require("html-webpack-plugin");
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
const path = require("path");
const Dotenv = require("dotenv-webpack");

const deps = require("./package.json").dependencies;

const printCompilationMessage = require("./compilation.config.js");

module.exports = (_, argv) => ({
  output: {
    publicPath: "http://localhost:3001/",
  },

  resolve: {
    extensions: [".tsx", ".ts", ".jsx", ".js", ".json"],
  },

  devServer: {
    port: 3001,
    historyApiFallback: true,
    watchFiles: [path.resolve(__dirname, "src")],
    onListening: function (devServer) {
      const port = devServer.server.address().port;

      printCompilationMessage("compiling", port);

      devServer.compiler.hooks.done.tap("OutputMessagePlugin", (stats) => {
        setImmediate(() => {
          if (stats.hasErrors()) {
            printCompilationMessage("failure", port);
          } else {
            printCompilationMessage("success", port);
          }
        });
      });
    },
  },

  module: {
    rules: [
      {
        test: /\.m?js/,
        type: "javascript/auto",
        resolve: {
          fullySpecified: false,
        },
      },
      {
        test: /\.(css|s[ac]ss)$/i,
        use: ["style-loader", "css-loader", "postcss-loader"],
      },
      {
        test: /\.(ts|tsx|js|jsx)$/,
        exclude: /node_modules/,
        use: {
          loader: "babel-loader",
        },
      },
    ],
  },

  plugins: [
    new ModuleFederationPlugin({
      name: "component_app",
      filename: "remoteEntry.js",
      remotes: {},
      exposes: {
        "./Button": "./src/components/Button",
      },
      shared: {
        ...deps,
        react: {
          singleton: true,
          requiredVersion: deps.react,
        },
        "react-dom": {
          singleton: true,
          requiredVersion: deps["react-dom"],
        },
      },
    }),
    new HtmlWebPackPlugin({
      template: "./src/index.html",
    }),
    new Dotenv(),
  ],
});

 

여기서 중요하게 생각해야할 부분은 `ModuleFederationPlugin` 설정입니다. 이는 Webpack 5에서 도입된 기능으로, 여러 독립된 애플리케이션 간에 코드를 공유하고 실행할 수 있도록 도와주는 도구입니다.

  • `name: "component_app"`
    • 이 이름은 현재 모듈이 참조될 때 사용되는 이름이며 원격 모듈의 이름을 정의합니다.
      다른 애플리케이션이 이 모듈을 사용할 때, `name`을 통해 참조할 수 있습니다.
  • `filename: "remoteEntry.js"`
    • 이 파일은 노출된 모듈과 그 정보를 포함하는 엔트리 파일이며, 원격 엔트리 파일은 다른 애플리케이션에서 이 애플리케이션의 모듈을 로드하기 위해 사용됩니다. 
      이 파일은 해당 모듈이 빌드될 때 자동으로 생성됩니다.
  • `remotes: { ... }`
    • remotes는 현재 애플리케이션이 의존하는 원격 모듈을 정의합니다.
      현재 빈 객체로 설정된 것은 이 애플리케이션이 현재 다른 원격 모듈을 참조하지 않는다는 것을 의미합니다.
  • `exposes: { ... }`
    • exposes는 이 애플리케이션이 다른 애플리케이션에 제공할 모듈을 정의합니다.
      여기서는 `./src/components/Button`에 위치해 있는 모듈을 `./Button`이라는 이름으로 노출하고 있으며, 다른 애플리케이션은 이 모듈을 `component_app/Button`으로 가져올 수 있습니다.
  • `shared: { ... }`
    • shared는 이 애플리케이션과 다른 애플리케이션 간에 공유할 패키지를 정의합니다.
      이 설정을 통해 서로 다른 모듈이 같은 라이브러리의 동일한 버전을 사용할 수 있도록 합니다.
    • 자동공유
      • `...deps`를 통해 `package.json`에 명시된 모든 의존성을 기본적으로 공유하도록 설정하고 있습니다. 이 애플리케이션이 사용하는 모든 라이브러리를 자동으로 공유하도록 보장합니다.
    • 옵션
      • `singleton: true`: 특정 패키지가 단일 인스턴스로만 로드되도록 보장합니다. 일반적으로 `react`, `react-dom`과 같은 상태나 컨텍스트를 공유하는 라이브러리에 사용됩니다.
      • `requiredVersion`: 패키지의 요구버전을 지정합니다.

 

이렇게 수정을 하고 http://localhost:3001/remoteEntry.js 를 확인해보면 다음과 같다. 

 

웹팩 설정을 통해 component-app 워크스페이스의 소스코드를 `remoteEntry.js` 라는 output을 통해 다른 워크스페이스에서 사용할 수 있도록 해주는 것이다.

 

2) main-app 워크스페이스에서 component-app 워크스페이스의 컴포넌트를 활용할 수 있도록 웹팩 `remotes` 설정하기

 

component-app의 webpack의 `ModuleFederationPlugin` 설정의 name을 main-app에 사용하도록 수정합니다.

// apps/main-app/webpack.config.js

const HtmlWebPackPlugin = require("html-webpack-plugin");
const ModuleFederationPlugin = require("webpack/lib/container/ModuleFederationPlugin");
const path = require("path");
const Dotenv = require("dotenv-webpack");

const deps = require("./package.json").dependencies;

const printCompilationMessage = require("./compilation.config.js");

module.exports = (_, argv) => ({
  output: {
    publicPath: "http://localhost:3000/",
  },

  resolve: {
    extensions: [".tsx", ".ts", ".jsx", ".js", ".json"],
  },

  devServer: {
    port: 3000,
    historyApiFallback: true,
    watchFiles: [path.resolve(__dirname, "src")],
    onListening: function (devServer) {
      const port = devServer.server.address().port;

      printCompilationMessage("compiling", port);

      devServer.compiler.hooks.done.tap("OutputMessagePlugin", (stats) => {
        setImmediate(() => {
          if (stats.hasErrors()) {
            printCompilationMessage("failure", port);
          } else {
            printCompilationMessage("success", port);
          }
        });
      });
    },
  },

  module: {
    rules: [
      {
        test: /\.m?js/,
        type: "javascript/auto",
        resolve: {
          fullySpecified: false,
        },
      },
      {
        test: /\.(css|s[ac]ss)$/i,
        use: ["style-loader", "css-loader", "postcss-loader"],
      },
      {
        test: /\.(ts|tsx|js|jsx)$/,
        exclude: /node_modules/,
        use: {
          loader: "babel-loader",
        },
      },
    ],
  },

  plugins: [
    new ModuleFederationPlugin({
      name: "main_app",
      filename: "remoteEntry.js",
      remotes: {
        component_app: "component_app@http://localhost:3001/remoteEntry.js",
      },
      exposes: {},
      shared: {
        ...deps,
        react: {
          singleton: true,
          requiredVersion: deps.react,
        },
        "react-dom": {
          singleton: true,
          requiredVersion: deps["react-dom"],
        },
      },
    }),
    new HtmlWebPackPlugin({
      template: "./src/index.html",
    }),
    new Dotenv(),
  ],
});
  • `remotes: { ... }`: remotes는 현재 애플리케이션이 의존하는 원격 모듈을 정의합니다.
    • 키-값 쌍으로 정의를 합니다.
      키는 애플리케이션 내에서 원격 모듈을 참조할 때 사용할 이름이며, 값은 원격 모듈의 엔트리 포인트 URL입니다.
    • : `component_app`
      이 키는 현재 애플리케이션에서 원격 모듈을 참조할 때 사용할 별칭이며, 코드 내에서 이 이름을 사용하여 원격모듈을 가져올 수 있습니다.
    • 값: `"component_app@http://localhost:3001/remoteEntry.js"`
      이 값은 원격 모듈의 엔트리 포인트 URL을 지정하며, URL은 원격 애플리케이션에서 제공하는 `remoteEntry.js` 파일의 위치를 나타냅니다.
      • `component_app` 부분은 원격 애플리케이션의 `ModuleFederationPlugin` 설정에 있는 `name` 속성과 일치해야 하며, 원격 애플리케이션이 자신을 식별하는 이름입니다.
      • `http://localhost:3001/remoteEntry.js`은 원격 애플리케이션의 엔트리 파일이 위치한 URL입니다.
        이 파일에는 원격 애플리케이션이 노출하는 모듈과 해당 모듈을 어떻게 로드할지에 대한 정보가 포함되어 있습니다.
  • 작동방식
    • 원격 모듈 해싱: 현재 애플리케이션이 실행되면, Webpack은 지정된 URL에서 `remoteEntry.js` 파일을 로드합니다. 이 파일은 원격 애플리케이션이 노출하는 모듈과 이를 로드하는 방법에 대한 메타데이터를 포함합니다.
    • 원격 모듈 가져오기: 애플리케이션 코드에서 원격 모듈을 가져올 때, 위에서 지정한 별칭을 사용합니다. 

 

3) main-app에서 component-app의 `Button` 컴포넌트 사용하기

// apps/main-app/src/App.jsx

import React from "react";
import ReactDOM from "react-dom/client";

import "./index.css";
import Button from "component_app/Button";

const App = () => (
  <div className="container">
    <div>Name: main-app</div>
    <div>Framework: react</div>
    <div>Language: JavaScript</div>
    <div>CSS: Empty CSS</div>
    <Button
      onClick={() => {
        console.log("Clicked!!");
      }}
    >
      Primary
    </Button>
    <Button type="warning">Warning</Button>
  </div>
);
const rootElement = document.getElementById("app");
if (!rootElement) throw new Error("Failed to find the root element");

const root = ReactDOM.createRoot(rootElement);

root.render(<App />);

 

이제 다음의 명령어로 실행해보자

pnpm --filter main-app start:live

 

 

개발자도구에서 네트워크 탭을 보면 `remoteEntry.js` 파일이 넘어져오는 것을 볼 수 있다.

 

다만 이렇게 다른 서버의 컴포넌트를 런타임에 가져오려면 다른 서버가 켜져 있어야 하므로 두 터미널에서 각각 서버를 실행해줘야 하는데, 루트에서 한 번에 실행되도록 루트에서 스크립트를 만드는 것이 좋다. 

 

4) 루트에서 `package.json` 스크립트를 통해 한 번에 여러 워크스페이스를 실행하도록 스크립트 추가하기

// package.json 
{
  "name": "module-federation-basic-example",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "pnpm --parallel start:live",
    "build": "pnpm --parallel build",
    "serve": "pnpm --parallel build:start"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "packageManager": "pnpm@8.10.0+sha256.3c5d70d07b0c4849d7e07398b62bf48ca6619ca86f77981125eaab7f5ee82c4c"
}
  • `--parallel` 옵션은 여러 패키지를 동시에(병렬로) 실행할 수 있도록 합니다.
    • `pnpm --parallel` 명령어를 사용할 때, 특정 워크스페이스에 스크립트가 정의되어 있지 않으면 pnpm은 해당 워크스페이스를 건너뛰고 나머지 워크스페이스의 스크립트를 병렬로 실행합니다.
    • 이는 스크립트가 없는 워크스페이스가 전체 실행 프로세스를 방해하지 않도록 하며, 작업을 중단하지 않기 때문에 개발 효율성을 높입니다.