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
`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은 해당 워크스페이스를 건너뛰고 나머지 워크스페이스의 스크립트를 병렬로 실행합니다.
- 이는 스크립트가 없는 워크스페이스가 전체 실행 프로세스를 방해하지 않도록 하며, 작업을 중단하지 않기 때문에 개발 효율성을 높입니다.