들어가며
어제 정리한 Micro Frontend 글에서 잠깐 Webpack Module Federation이 등장했다. Micro Frontend를 실제로 굴릴 때 가장 자주 등장하는 메커니즘 중 하나라 따로 정리해본다.
한 줄로 요약하면 -
빌드된 앱들이 런타임에 서로의 모듈을 import할 수 있게 해주는 기술
NPM publish/install 사이클 없이도 별도 빌드된 앱끼리 코드를 주고받게 해준다. Webpack 5(2020)에서 도입됐고, 이후 Rspack·Vite·Rsbuild에서도 호환 플러그인이 나오면서 사실상 Micro Frontend의 표준 메커니즘이 됐다.
1. 왜 필요한가
Micro Frontend를 만든다고 해보자. 결제팀이 만든 <PaymentForm> 컴포넌트를 상품팀이 쓰고 싶다.
전통적인 방법:
결제팀: npm publish @company/payment
상품팀: npm install @company/payment
→ 빌드 → 배포문제점:
- 결제팀이 코드 바꾸면 → 상품팀이 다시 빌드/배포해야 함
- 버전 충돌 - 결제팀 v2가 나왔는데 다른 팀은 v1 쓰고 있으면?
- 번들 크기 - 모든 팀이 React, 디자인 시스템 등을 자기 번들에 포함
원하는 그림:
결제팀: 결제 앱 빌드/배포 (자기 도메인에)
상품팀: 자기 앱 빌드/배포
→ 런타임에 결제팀 컴포넌트를 fetch해서 사용이게 Module Federation이 풀어주는 문제다.
2. 핵심 개념
Host와 Remote
- Remote: 모듈을 노출(expose) 하는 쪽. 결제 앱이
<PaymentForm>을 노출 - Host: 모듈을 소비(import) 하는 쪽. 상품 앱이
<PaymentForm>을 가져옴
한 앱이 동시에 host이자 remote가 될 수도 있다 (양방향 federation).
exposes / remotes / shared
webpack.config.js에 들어가는 3가지 핵심 옵션.
exposes(remote 쪽) - "이 모듈을 외부에 공개한다"remotes(host 쪽) - "이 URL에서 모듈을 가져온다"shared(양쪽) - "이 라이브러리는 한 번만 로드해서 공유한다" (React 등)
3. 코드 예시
Remote (결제팀)
// payment-app/webpack.config.js
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "payment",
filename: "remoteEntry.js",
exposes: {
"./PaymentForm": "./src/components/PaymentForm",
"./useCheckout": "./src/hooks/useCheckout",
},
shared: {
react: { singleton: true, requiredVersion: "^18.0.0" },
"react-dom": { singleton: true, requiredVersion: "^18.0.0" },
},
}),
],
};빌드하면 dist/remoteEntry.js가 생긴다. 이게 manifest 역할 - "이 앱이 어떤 모듈을 노출하는지" 정보가 들어있다.
Host (상품팀)
// product-app/webpack.config.js
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "product",
remotes: {
payment: "payment@https://payment.example.com/remoteEntry.js",
},
shared: {
react: { singleton: true, requiredVersion: "^18.0.0" },
"react-dom": { singleton: true, requiredVersion: "^18.0.0" },
},
}),
],
};이제 상품팀 코드에서 그냥 import 하듯 쓸 수 있다.
// product-app/src/CheckoutPage.tsx
import React, { Suspense } from "react";
const PaymentForm = React.lazy(() => import("payment/PaymentForm"));
export function CheckoutPage() {
return (
<Suspense fallback={<div>결제 모듈 로드 중...</div>}>
<PaymentForm />
</Suspense>
);
}import("payment/PaymentForm") - TypeScript는 이 경로를 모르지만, Webpack이 federation으로 처리한다. 빌드 시점에 코드가 묶이지 않고, 런타임에 결제 서버에서 fetch된다.
4. 동작 원리
1. 상품 앱이 로드되면 → product-app의 main bundle 실행
2. <CheckoutPage> 렌더 → import("payment/PaymentForm") 트리거
3. Webpack runtime이 https://payment.example.com/remoteEntry.js 를 <script> 태그로 동적 fetch
4. remoteEntry.js가 "PaymentForm 모듈 위치 = https://payment.example.com/static/PaymentForm.chunk.js" 같은 manifest 노출
5. 그 chunk를 fetch → 실제 PaymentForm 컴포넌트 코드 로드
6. shared로 등록된 React는 이미 host에 로드되어 있으므로 재사용 (중복 로드 안 함)
7. <PaymentForm /> 렌더핵심은 4번. remoteEntry.js가 일종의 "원격 모듈 manifest" 역할을 한다. NPM의 package.json이 정적 manifest라면, remoteEntry.js는 동적 manifest다.
5. shared가 풀어주는 문제
각 앱이 자기 React를 번들에 포함하면 한 페이지에 React가 두 번 로드된다 - 번들 낭비도 문제지만 더 심각한 건 두 React 인스턴스가 서로의 hooks를 못 알아본다는 것. useState가 두 개의 다른 React에서 발급되면 Provider가 깨진다.
shared 옵션이 이걸 막는다.
shared: {
react: { singleton: true, requiredVersion: "^18.0.0" },
}singleton: true- 단 하나의 React만 로드requiredVersion- 호환 가능한 버전 명시, 안 맞으면 콘솔 경고
처음 로드된 React가 모든 federated 모듈에서 공유된다. 결제 앱이 v18.2를 가지고 있어도 host가 이미 v18.3을 로드했으면 v18.3 쓴다.
6. 다른 접근들과 비교
| 방식 | 격리 | 통신 | 공통 의존성 | 빌드 시점 |
|---|---|---|---|---|
| iframe | 완벽 (origin 격리) | postMessage 등 별도 채널 | 각자 가짐 | 무관 |
| NPM 패키지 | 없음 (그냥 import) | 함수 호출 | 호스트 번들에 포함 | 호스트 빌드 시 |
| single-spa | 약함 (전역 등록) | 전역 객체 | 직접 관리 | 각자 빌드 |
| Module Federation | 약함 (같은 origin) | import 그대로 | shared로 일원화 | 각자 빌드 |
Module Federation의 매력은 "NPM처럼 자연스러운 import 경험" 과 "각자 빌드 + 런타임 합치기" 를 동시에 준다는 것.
7. 한계와 주의점
같은 React 메이저 버전이 필요
shared.singleton: true라면 결제 앱과 상품 앱은 같은 React major version을 써야 한다. v17과 v18을 섞으면 깨진다. 메이저 업그레이드 시 모든 federated 앱이 동시에 올라가야 한다 - 이게 MSA의 분산 트랜잭션 문제와 비슷한 상황.
타입 공유가 어렵다
import("payment/PaymentForm") - TypeScript는 이 경로를 모른다. 해결책:
@module-federation/typescript- 빌드 시 remote의 d.ts를 자동 fetch- Manual ambient declaration -
declare module "payment/PaymentForm" { ... } - Monorepo의 shared types 패키지
보안
remote URL을 host가 신뢰해야 한다. 악의적인 remoteEntry.js가 host에 임의 코드 주입할 수 있다. 그래서 보통 같은 조직 내부 또는 CSP/SRI로 묶인 환경에서만 쓴다.
CORS
remoteEntry.js와 chunk 파일이 다른 origin에서 fetch되므로 CORS 헤더가 필수.
빌드 도구 종속
원래 Webpack 전용. Vite는 @originjs/vite-plugin-federation이나 Module Federation 공식 @module-federation/vite 등 별도 플러그인 필요. Rspack은 native 지원.
8. 언제 쓰나
좋은 fit:
- 여러 팀이 한 앱을 분담해서 만들 때 (Micro Frontend)
- 동일 조직 안에서 같은 디자인 시스템·인증을 공유하면서 독립 배포가 필요할 때
- 공통 라이브러리 (UI 컴포넌트, util)를 별도 앱이 아닌 형태로 공유하고 싶을 때
오버킬:
- 단일 팀 단일 앱 - 그냥 monorepo + workspace로 충분
- 외부에 라이브러리를 공개 - NPM publish가 더 깔끔
- 완전한 격리가 필요 - iframe 또는 별도 origin이 더 안전
정리
- Module Federation은 빌드된 앱끼리 런타임에 모듈을 주고받는 기술
exposes(remote가 공개) /remotes(host가 가져옴) /shared(라이브러리 공유) 3가지 축으로 구성- 핵심은
remoteEntry.js라는 동적 manifest - host가 런타임에 이걸 fetch해서 모듈 위치를 알아낸다 - React 같은 라이브러리는
shared.singleton으로 한 번만 로드 - 다중 인스턴스로 인한 hook 깨짐 방지 - Micro Frontend의 가장 자연스러운 통합 방식이지만, 버전 호환·타입·보안·CORS 같은 운영 비용이 따라온다