[TIL] Module Federation

2026. 05. 02.Yeji Kim
Frontend

들어가며

어제 정리한 Micro Frontend 글에서 잠깐 Webpack Module Federation이 등장했다. Micro Frontend를 실제로 굴릴 때 가장 자주 등장하는 메커니즘 중 하나라 따로 정리해본다.

한 줄로 요약하면 -

빌드된 앱들이 런타임에 서로의 모듈을 import할 수 있게 해주는 기술

NPM publish/install 사이클 없이도 별도 빌드된 앱끼리 코드를 주고받게 해준다. Webpack 5(2020)에서 도입됐고, 이후 Rspack·Vite·Rsbuild에서도 호환 플러그인이 나오면서 사실상 Micro Frontend의 표준 메커니즘이 됐다.


1. 왜 필요한가

Micro Frontend를 만든다고 해보자. 결제팀이 만든 <PaymentForm> 컴포넌트를 상품팀이 쓰고 싶다.

전통적인 방법:

text
결제팀: npm publish @company/payment
상품팀: npm install @company/payment
       → 빌드 → 배포

문제점:

  • 결제팀이 코드 바꾸면 → 상품팀이 다시 빌드/배포해야 함
  • 버전 충돌 - 결제팀 v2가 나왔는데 다른 팀은 v1 쓰고 있으면?
  • 번들 크기 - 모든 팀이 React, 디자인 시스템 등을 자기 번들에 포함

원하는 그림:

text
결제팀: 결제 앱 빌드/배포 (자기 도메인에)
상품팀: 자기 앱 빌드/배포
       → 런타임에 결제팀 컴포넌트를 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 (결제팀)

js
// 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 (상품팀)

js
// 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 하듯 쓸 수 있다.

tsx
// 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. 동작 원리

text
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 옵션이 이걸 막는다.

js
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 같은 운영 비용이 따라온다

레퍼런스