21. 마이크로 프론트엔드 가이드

0. 먼저 알고 가기 (30초 요약)

  • 팀별 배포 독립성과 사용자 통합성은 함께 갈 수 있지만 경계가 필요합니다.
  • host와 remote의 라이프사이클을 분리하고 오케스트레이션은 최소한으로 유지하세요.
  • 공통 런타임 버전 충돌은 운영 장애로 직결됩니다.

초심자용 한눈에 보기

마이크로 프론트엔드에서는 “연결은 가능하지만 강하게 묶이지 않게”가 핵심입니다.

핵심 용어 빠르게 정리

용어 쉬운 뜻
host 마이크로 앱을 담는 큰 컨테이너 앱
remote 독립적으로 배포되는 하위 앱
공유 런타임 여러 앱이 쓰는 공통 라이브러리/상태
버전 충돌 서로 다른 버전이 충돌해 기능이 깨지는 상태
오케스트레이션 여러 앱의 로딩/연동 흐름을 조정하는 구조
분류 핵심 기술 상태 Stable
연관 가이드 22. 모노레포, 04. 아키텍처, 11. CI/CD 도구 원칙 벤더 중립
핵심 테마 MFE 도입 기준, Federation/Import Maps, Runtime API, 독립 카나리 배포, RSC 통합 Update 최신 기준

"마이크로 프론트엔드는 독립적인 팀이 독립적으로 배포할 수 있는 프론트엔드 아키텍처이다. 핵심은 기술이 아니라 조직의 자율성과 배포 독립성이다." 본 가이드는 특정 런타임이나 번들러가 아니라 독립 배포 가능성, 계약 안정성, 공유 의존성 통제, 장애 격리를 기준으로 마이크로 프론트엔드를 검토하는 방법을 다룹니다. Module Federation 2.x, Native Federation, iframe, route-level composition은 상황별 구현 후보입니다.

현재 MFE 생태계 변화 요약

  • Federation 런타임 성숙도: 런타임 분리, manifest, dynamic type hint, runtime plugin이 성숙했지만, 번들러/프레임워크 adapter별 호환성 매트릭스를 반드시 확인합니다.
  • Runtime API: loadRemote, preloadRemote, registerRemotes, prefetch, cache API 통합. 빌드 플러그인 없이 순수 런타임 등록 가능
  • Native Federation (ESM): Import Maps 기반 대안입니다. 단순한 배포 독립성에는 유리하지만, shared singleton과 legacy browser 전략은 별도 검증이 필요합니다.
  • RSC + MFE 통합 패턴: Server Component에서 데이터 프리페치, Client 경계에서 리모트 로드를 검토하되, 사용하는 Federation 런타임과 프레임워크 adapter의 compatibility matrix를 기준으로 검증합니다.
  • MFE 남용 방지: 팀/배포/도메인 경계가 분명하지 않으면 모노레포 + route-level ownership이 더 낮은 비용입니다.

추천 항목 (실무 우선순위)

  • 시작 추천: host와 remote의 책임 경계를 명확히 정하고 버전 정책을 문서화하세요.
  • 안정 추천: shared dependency 충돌은 CI에서 공통 체크로 막고, fallback plan을 먼저 만듭니다.
  • 운영 추천: 운영 단계에서 리모트 배포 실패는 전체보다 범위 축소 전략(카나리/단일 기능)으로 대응하세요.

추천 항목 고도화 체크

  • 첫 적용 — host/remote contract와 shared dependency 중 하나를 실제 PR이나 운영 이슈에 붙이고, 변경 전 기준을 먼저 적는다.
  • 증거 정리 — remote manifest, contract test, canary deploy log를 같은 작업 기록에 남긴다.
  • 재점검 — remote load failure, shared singleton 충돌, independent deploy lead time가 나아졌는지 30일 안에 확인하고 기준을 유지, 수정, 폐기 중 하나로 판정한다.

추천 항목 실행 기록 템플릿

  • 작업 : host/remote contract와 shared dependency 적용 범위를 어느 화면, 패키지, 문서에 둘지 적는다.
  • 증거 : remote manifest, contract test, canary deploy log 중 실제로 남긴 항목만 링크한다.
  • 판정 : 유지/수정/폐기 중 하나와 이유를 한 문장으로 남긴다.
  • 다음 점검 : remote load failure, shared singleton 충돌, independent deploy lead time를 다시 볼 날짜와 담당자를 지정한다.

문서 책임 범위

이 문서가 결정하는 것 단일 출처로 따르는 문서
MFE 도입 조건, 도메인 경계, remote 계약, 장애 격리 04. 아키텍처, 22. 모노레포
remote별 독립 배포와 rollback 조건 11. CI/CD, 14. 배포
remote 성능 예산과 관측 지표 08. 성능, 09. 관측성
AI가 제안한 분할안의 검증 책임 18. AI 개발 워크플로우

0. 모든 프론트엔드 그룹 공통 Baseline

기준 최소 적용
도입 조건 독립 배포가 실제로 필요한 팀/도메인 경계가 있을 때만 도입합니다.
계약 관리 remote expose, shared dependency, event, route, auth context를 버전 관리합니다.
장애 격리 remote load/render 실패 시 host가 살아남는 fallback과 error boundary를 둡니다.
성능 예산 각 remote의 JS/CSS/critical path budget과 prefetch 정책을 둡니다.
운영성 remote별 release, canary, rollback, ownership, observability dimension을 분리합니다.

0.0 MFE 채택/운영 플로우

flowchart TD
  A[도메인 경계 후보 식별] --> B{독립 배포 필요성}
  B -->|높음| C["배포 단위(예: route/domain) 분할"]
  B -->|낮음| D[모노레포·패키지 분할 대안 검토]
  C --> E["Remote 계약(Expose/Shared/Auth) 정의"]
  D --> E2[도입 보류 및 기존 구조 유지]
  E --> F["Host/Remote 빌드·타입·runtime 검증"]
  E2 --> C
  F --> G{로드 실패/격리 전략 확인}
  G -->|안전| H["canary 배포 + rollback 설계"]
  G -->|불안정| I[구현 복잡도 재평가]
  H --> J["운영 지표 + 장애 격리 모니터링"]
  I --> C

0.1 교차 검증 매트릭스

권고 1차 출처 실행 증거 운영 증거 철회 조건
MFE 도입 전 모노레포/route ownership 대안을 먼저 비교한다 아키텍처 RFC/ADR, 번들러 공식 문서 dependency graph, deploy coupling 분석 팀별 lead time, cross-team blocking 독립 배포보다 런타임 복잡도가 더 큰 경우
remote 계약은 manifest와 타입/런타임 검증을 함께 둔다 Federation/Import Maps 공식 문서 contract test, type generation check remote load failure, shared version conflict host와 remote가 항상 같은 릴리스로 배포될 때
shared dependency는 singleton 남용을 피하고 충돌 탐지를 자동화한다 패키지 매니저/런타임 공식 문서 version policy lint, bundle duplicate report hydration error, duplicate runtime size 독립 배포보다 빌드타임 공유 패키지가 더 안정적일 때
canary와 rollback은 remote 단위로 검증한다 배포/관측성 표준 route-level canary E2E, rollback rehearsal remote별 error rate, conversion impact traffic split을 안정적으로 측정할 수 없을 때

0.2 운영 게이트

Gate Evidence Owner Rollback
MFE adoption RFC monorepo 대안 비교, dependency graph, cost model Architecture owner route ownership 또는 package boundary로 축소
Remote contract gate manifest diff, type compatibility, host smoke Host/remote owners 이전 remote manifest pinning
Shared dependency gate duplicate bundle report, singleton allowlist Platform owner shared 해제 또는 build-time package로 회귀
Remote canary gate route-level error rate, fallback hit rate Release owner remote traffic 0%, host fallback 유지

1. AI 보조 마이그레이션/분할 검증 시나리오

이 섹션의 AI 입력 구조, 민감정보 제거, 검증 책임은 18. AI 개발 워크플로우를 단일 출처로 따릅니다. 여기서는 마이크로 프론트엔드 분할과 마이그레이션에 필요한 도메인별 질문만 다룹니다.

AI가 제안한 MFE 분할안은 의사결정 초안입니다. 최종 판단은 의존성 그래프, 배포 결합도, 팀 ownership, 런타임 장애 격리, 성능 예산으로 검증합니다.

시나리오 입력 AI 산출물 필수 검증 승인 조건
도메인 경계 탐색 route map, import graph, package ownership 후보 remote 목록, 단계별 분할안 dependency graph, 순환 참조 검사 독립 배포 가치가 런타임 비용보다 큼
공유 모듈 계약 shared package API, consumer 목록 public API, versioning, deprecation plan contract test, type check, consumer smoke breaking change가 명시적으로 관리됨
remote 설정 생성 host/remote 목록, bundler, runtime 후보 federation/native config 초안 build, remote load E2E, fallback test host가 remote 실패 시 계속 동작
공유 상태 분리 store 코드, auth/session 흐름 event/API 기반 경계 제안 hydration, auth context, race condition test 전역 store 직접 공유를 제거 또는 제한
라우팅 통합 route table, navigation ownership route composition, deep link, 404 전략 browser navigation E2E URL 계약과 SEO/analytics가 유지됨
성능 예산 bundle report, route traffic, RUM remote별 JS/CSS/latency budget bundle budget, INP/LCP p75 remote 추가가 core path를 악화하지 않음
legacy 통합 전환 iframe/embedded app 구조 strangler migration plan security boundary, postMessage 제거 계획 단계별 rollback과 owner가 명확함

MFE RFC에는 최소한 다음 결론이 있어야 합니다.

  • 모노레포 + route ownership으로 충분하지 않은 이유
  • remote 장애 시 host fallback 방식
  • shared dependency 충돌 정책
  • 독립 배포와 rollback 책임자
  • 성능 예산과 관측 dimension

2. Module Federation 2.x vs Native Federation 비교

Module Federation 2.x 계열은 runtime, manifest, type hint, preload 같은 런타임 중심 API를 제공하지만, production 도입 여부는 사용하는 bundler와 framework adapter의 공식 호환성 매트릭스로 판단합니다. "지원" 표기는 전체 제품 안정성을 의미하지 않으므로 host/remote smoke, SSR 여부, shared dependency 충돌을 별도로 검증합니다.

일상 비유: Module Federation은 같은 회사의 여러 지점이 본사 ERP 한 대를 공유해 재고/회계를 실시간으로 정합하는 방식이고, Native Federation은 각 지점이 표준 카탈로그(Import Map) 한 장을 들고 같은 창고(CDN)에서 부품을 받아가는 방식이다.

이 그림은 빌드 통합(Module Federation)과 런타임 통합(Native Federation/Import Maps) 중 어떤 쪽을 선택할지 결정하는 흐름을 보여줍니다.

flowchart TD
  Start[MFE 통합 방식 선택] --> Q1{공유 의존성<br/>버전 협상 자동화 필요?}
  Q1 -->|Yes| Q2{SSR/RSC<br/>요구 사항?}
  Q1 -->|No| Q3{단일 번들러<br/>고정 가능?}
  Q2 -->|있음| MF[Module Federation 2.x<br/>+ Node Federation]
  Q2 -->|없음| Q4{런타임 dts/preload<br/>API 필요?}
  Q4 -->|Yes| MF
  Q4 -->|No| Native[Native Federation<br/>Import Maps + ESM]
  Q3 -->|Yes| Native
  Q3 -->|No| MF
  MF --> ProveMF[host/remote smoke<br/>shared duplicate report]
  Native --> ProveNative[Import Map diff<br/>integrity hash 검증]
  ProveMF --> Done[도입 결정]
  ProveNative --> Done
비교 항목 Module Federation 2.x Native Federation (ESM)
번들러 Webpack 5.97+, Rspack, Rolldown, Rsbuild, Vite, Metro 번들러 무관 (순수 ESM)
런타임 @module-federation/runtime (빌드 도구 독립) Import Maps + ES Modules
공유 의존성 singleton/eager/strictVersion + share scope 협상 Import Maps로 URL 레벨 공유
타입 공유 dts 플러그인이 mf-manifest로 자동 생성/소비 수동 타입 패키지 관리
버전 관리 런타임 share scope negotiation (mf-manifest 활용) Import Map 버전 명시
동적 리모트 registerRemotes() 런타임 등록 (빌드 시 미선언 가능) Import Map 동적 주입
프리로드/프리페치 preloadRemote, prefetch API, cache API 내장 수동 (<link rel="modulepreload">)
빌드 속도 Rspack/Rolldown 사용 시 매우 빠름 빌드 불필요 (ESM 직접 서빙)
디버깅 MF DevTools Chrome Extension (2.x에서 강화) 브라우저 네이티브 DevTools
프레임워크 지원 React, Vue, Angular, Svelte, React Native 프레임워크 무관
SSR 지원 Node Federation, Next.js 16/15 App Router 및 Modern.js 통합 검증 필요 제한적
프로덕션 사례 대규모 서비스와 복잡한 공유 의존성 환경에서 다수 채택 단순 구조와 네이티브 ESM 선호 환경에서 증가 중
적합한 케이스 대규모 팀, 복잡한 공유 의존성, RSC 통합 소규모, 단순 구조, 빌드 속도 중시

2.1 Native Federation (ESM) 기본 구성

// import-map.json -- 공유 의존성을 Import Map으로 관리
{
  "imports": {
    "react": "https://cdn.example.com/react@19.1.0/esm/index.js",
    "react-dom": "https://cdn.example.com/react-dom@19.1.0/esm/index.js",
    "react-dom/client": "https://cdn.example.com/react-dom@19.1.0/esm/client.js",
    "@shared/auth": "https://mfe.example.com/shared/auth/v2.1.0/index.js",
    "@shared/events": "https://mfe.example.com/shared/events/v1.3.0/index.js"
  },
  "scopes": {
    "https://mfe.example.com/app-a/": {
      "lodash-es": "https://cdn.example.com/lodash-es@4.17.21/lodash.js"
    }
  }
}
// native-federation/loader.ts -- ESM 기반 리모트 앱 로더
interface RemoteAppConfig {
  name: string
  url: string
  modulePath: string
  integrity?: string // SRI(Subresource Integrity) 해시
}

interface RemoteModule {
  default: React.ComponentType
  [key: string]: unknown
}

// 모듈 캐시: 동일 리모트를 중복 로드하지 않기 위한 메모이제이션
const moduleCache = new Map<string, RemoteModule>()

export async function loadRemoteModule(config: RemoteAppConfig): Promise<RemoteModule> {
  const cacheKey = `${config.name}@${config.url}`

  if (moduleCache.has(cacheKey)) {
    return moduleCache.get(cacheKey)!
  }

  try {
    // ESM dynamic import -- 브라우저 네이티브 모듈 시스템 활용
    const moduleUrl = new URL(config.modulePath, config.url).href
    const mod = (await import(/* webpackIgnore: true */ moduleUrl)) as RemoteModule

    moduleCache.set(cacheKey, mod)
    return mod
  } catch (error) {
    console.error(`Failed to load remote module: ${config.name}`, error)
    throw new RemoteLoadError(config.name, error)
  }
}

class RemoteLoadError extends Error {
  constructor(
    public readonly remoteName: string,
    public readonly cause: unknown,
  ) {
    super(`Remote "${remoteName}" failed to load`)
    this.name = 'RemoteLoadError'
  }
}

2.2 선택 기준 요약

  • Module Federation 2.x: 복잡한 shared dependency, runtime remote registration, typed manifest, host/remote 관측성이 필요할 때 검토합니다. adapter별 SSR/브라우저/번들러 호환성을 먼저 확인합니다.
  • Native Federation (Import Maps): ESM과 Import Maps 기반의 단순 배포 독립성을 우선할 때 검토합니다. legacy browser, singleton, 타입 공유 전략은 별도로 설계합니다.
  • Vite federation plugin: Vite 단독 환경에서 작은 remote를 빠르게 검증할 때 후보가 될 수 있습니다. maintenance 상태, HMR, SSR, shared dependency 동작을 PoC로 확인합니다.

3. Single-SPA vs Module Federation vs iframe 비교

비교 항목 Single-SPA Module Federation 2.1 iframe
통합 수준 라우트 기반 앱 마운트 컴포넌트/모듈 레벨 공유 완전 격리
공유 의존성 Import Maps / SystemJS 런타임 협상 (자동) 없음 (각자 로드)
스타일 격리 없음 (수동 관리) 없음 (수동 관리) 완전 격리
JS 격리 없음 (전역 오염 가능) 없음 (전역 오염 가능) 완전 격리
통신 방식 이벤트 버스, Props 직접 import, 이벤트 버스 postMessage
라우팅 통합 라우터 필수 Host 라우터 위임 각자 독립
SSR 지원 (isomorphic-layout-composer) Node Federation 지원 (각 iframe별)
번들 크기 중복 가능 최소화 (공유 싱글톤) 최대 (완전 중복)
초기 로딩 보통 빠름 (프리로드 가능) 느림 (다중 페이지 로드)
디버깅 보통 좋음 (DevTools 지원) 어려움 (크로스 프레임)
기술 다양성 최고 (React+Vue+Angular) 좋음 (주요 프레임워크) 최고 (완전 독립)
도입 난이도 높음 중간 낮음
적합한 시나리오 다중 프레임워크 점진 마이그레이션 동일 프레임워크 대규모 앱 레거시 통합, 보안 격리 필수

선택 의사결정 플로차트

일상 비유: 식당 인테리어 통합과 같다. 완전한 음식점 격리(iframe)는 푸드코트에서 칸막이로 분리, 동일 프레임워크 컴포넌트 공유(Module Federation)는 같은 주방을 쓰는 분점, 다양한 주방을 모으는 방식(Single-SPA)은 푸드코트 안에서 메뉴/언어가 모두 다른 가게가 한 홀에 모인 모습이다.

이 그림은 레거시·기술 스택·통합 단위에 따라 어떤 MFE 통합 방식을 선택할지 보여줍니다.

flowchart TD
  Q1{레거시 앱을<br/>통합해야 하는가?} -->|Yes| Q2{기술 스택<br/>변경 가능?}
  Q1 -->|No| Q3{단일 프레임워크로<br/>통일 가능?}
  Q2 -->|No| IFR[iframe<br/>완전 격리·보안 우선]
  Q2 -->|Yes| SSPA[Single-SPA<br/>점진적 strangler 마이그레이션]
  Q3 -->|Yes| MF[Module Federation 2.x<br/>컴포넌트/모듈 공유]
  Q3 -->|No| Multi[Single-SPA 또는<br/>Native Federation]
  IFR --> Note1[postMessage 통신<br/>SSO/세션 격리 점검]
  SSPA --> Note2[Import Maps<br/>route 기반 mount]
  MF --> Note3[shared scope 협상<br/>dts manifest]
  Multi --> Note4[Import Maps 우선<br/>또는 framework adapter]

3.1 스타일 격리 전략 비교

마이크로 프론트엔드에서 스타일 충돌은 가장 빈번한 문제 중 하나다. 통합 방식별 스타일 격리 전략을 아래와 같이 적용한다.

// 전략 1: CSS Modules -- Module Federation / Single-SPA 환경에서 권장
// 각 리모트 앱의 클래스명이 빌드 시 해시 처리되어 충돌 방지
// dashboard.module.css → .header_a1b2c3
import styles from './dashboard.module.css';

function DashboardHeader() {
  return <header className={styles.header}>Dashboard</header>;
}

// 전략 2: Shadow DOM -- 완전한 스타일 격리가 필요한 경우
// 주의: React와의 이벤트 전파 이슈 있음 (React 19에서 개선)
class RemoteAppHost extends HTMLElement {
  connectedCallback() {
    const shadow = this.attachShadow({ mode: 'open' });
    const container = document.createElement('div');
    shadow.appendChild(container);

    // Shadow DOM 내부에 React 앱 마운트
    const root = ReactDOM.createRoot(container);
    root.render(<RemoteApp />);
  }
}

// 전략 3: 네임스페이스 접두사 -- 가장 단순하지만 수동 관리 필요
// 각 리모트 앱의 최상위에 고유 접두사 부여
// .mfe-dashboard .header { ... }
// .mfe-settings .header { ... }

4. Module Federation 2.x 실전 설정

현재 공식 문서 기준 @module-federation/rsbuild-plugin, @module-federation/enhanced(Webpack), @module-federation/vite가 정식 출시되어 주요 번들러에서 같은 API를 씁니다. 아래는 rsbuild 기준 설정이며, Webpack 사용자는 @module-federation/enhanced/webpack을 import하면 동일하게 동작합니다.

일상 비유: Host는 백화점 전체 운영본부, Remote는 입점 매장이다. 백화점이 공용 전기/엘리베이터(React, react-router 같은 shared singleton)를 제공하면 매장들은 자기 인테리어(컴포넌트)만 챙기면 된다. 매장이 자기 발전기를 들고 와 따로 돌리면 화재(중복 React 인스턴스, hooks 오류)가 난다.

이 그림은 Host와 Remote가 manifest를 통해 어떻게 계약을 맺고 공유 의존성을 협상하는지 보여줍니다.

flowchart TD
  subgraph Host[Host Shell]
    HConfig[Host config<br/>remotes + shared scope]
    HRuntime[MF Runtime<br/>registerRemotes/init]
    HShared[Shared Singleton<br/>React/react-dom/router]
  end

  subgraph Remote[Remote App]
    RConfig[Remote config<br/>expose + shared declare]
    RBuild[Build output<br/>+ mf-manifest.json]
    RBundle[Module chunks<br/>+ remoteEntry.js]
  end

  RConfig --> RBuild
  RBuild --> RBundle
  RBundle -- "1. manifest fetch" --> HRuntime
  HConfig --> HRuntime
  HRuntime -- "2. shared 협상" --> HShared
  HShared -- "3. provide singleton" --> RBundle
  RBundle -- "4. consume singleton" --> HShared
  HRuntime -- "5. lazy import" --> Render[Component Render]

4.1 Host (Shell) 설정

// apps/shell/rsbuild.config.ts
import { defineConfig } from '@rsbuild/core'
import { pluginReact } from '@rsbuild/plugin-react'
import { ModuleFederationPlugin } from '@module-federation/rsbuild-plugin'

export default defineConfig({
  plugins: [pluginReact()],
  tools: {
    rspack: (config) => {
      config.plugins?.push(
        new ModuleFederationPlugin({
          name: 'shell',
          remotes: {
            // 런타임 동적 URL: 환경별 remoteEntry 주소 주입
            appDashboard: `appDashboard@${getRemoteUrl('app-dashboard')}/mf-manifest.json`,
            appSettings: `appSettings@${getRemoteUrl('app-settings')}/mf-manifest.json`,
            appAnalytics: `appAnalytics@${getRemoteUrl('app-analytics')}/mf-manifest.json`,
          },
          shared: {
            react: { singleton: true, requiredVersion: '^19.0.0' },
            'react-dom': { singleton: true, requiredVersion: '^19.0.0' },
            'react-router': { singleton: true, requiredVersion: '^7.0.0' },
            zustand: { singleton: true, requiredVersion: '^5.0.0' },
            '@shared/auth': { singleton: true, version: '2.0.0' },
            '@shared/events': { singleton: true, version: '1.0.0' },
          },
          // 2.x: mf-manifest.json 기반 타입 자동 생성 및 런타임 share scope 협상
          dts: {
            generateTypes: { extractRemoteTypes: true },
            consumeTypes: { generateAPITypes: true },
          },
          // 2.x: 런타임 플러그인 -- prefetch, retry, fallback 등 확장 가능
          runtimePlugins: [
            require.resolve('./mf-plugins/retry-plugin'),
            require.resolve('./mf-plugins/metrics-plugin'),
          ],
        }),
      )
      return config
    },
  },
})

// 환경별 리모트 URL 조회 함수
function getRemoteUrl(appName: string): string {
  const envUrls: Record<string, Record<string, string>> = {
    production: {
      'app-dashboard': 'https://dashboard.mfe.example.com',
      'app-settings': 'https://settings.mfe.example.com',
      'app-analytics': 'https://analytics.mfe.example.com',
    },
    staging: {
      'app-dashboard': 'https://dashboard.staging.mfe.example.com',
      'app-settings': 'https://settings.staging.mfe.example.com',
      'app-analytics': 'https://analytics.staging.mfe.example.com',
    },
    development: {
      'app-dashboard': 'http://localhost:3001',
      'app-settings': 'http://localhost:3002',
      'app-analytics': 'http://localhost:3003',
    },
  }

  const env = process.env.DEPLOY_ENV ?? 'development'
  return envUrls[env]?.[appName] ?? envUrls.development[appName]
}

4.2 Remote (앱) 설정

// apps/app-dashboard/rsbuild.config.ts
import { defineConfig } from '@rsbuild/core'
import { pluginReact } from '@rsbuild/plugin-react'
import { ModuleFederationPlugin } from '@module-federation/rsbuild-plugin'

export default defineConfig({
  server: { port: 3001 },
  plugins: [pluginReact()],
  tools: {
    rspack: (config) => {
      config.plugins?.push(
        new ModuleFederationPlugin({
          name: 'appDashboard',
          exposes: {
            // 라우트 단위 노출 -- 페이지 전체를 리모트로 제공
            './DashboardRoutes': './src/routes/index.tsx',
            // 개별 컴포넌트 노출 -- 다른 앱에서 위젯처럼 사용
            './StatCard': './src/components/StatCard.tsx',
            './RecentActivity': './src/components/RecentActivity.tsx',
          },
          shared: {
            react: { singleton: true, requiredVersion: '^19.0.0' },
            'react-dom': { singleton: true, requiredVersion: '^19.0.0' },
            'react-router': { singleton: true, requiredVersion: '^7.0.0' },
            zustand: { singleton: true, requiredVersion: '^5.0.0' },
            '@shared/auth': { singleton: true },
            '@shared/events': { singleton: true },
          },
          dts: {
            generateTypes: {
              extractThirdParty: true, // 서드파티 타입도 추출
              abortOnError: false, // 타입 에러 시에도 빌드 계속
            },
          },
        }),
      )
      return config
    },
  },
})

4.3 Host에서 리모트 컴포넌트 로드

// apps/shell/src/components/RemoteApp.tsx
import { init, loadRemote } from '@module-federation/enhanced/runtime';
import { Suspense, lazy, type ComponentType } from 'react';
import { ErrorBoundary } from './ErrorBoundary';

// 런타임 초기화 (앱 시작 시 1회 호출)
init({
  name: 'shell',
  remotes: [
    {
      name: 'appDashboard',
      entry: process.env.REMOTE_DASHBOARD_URL + '/mf-manifest.json',
    },
    {
      name: 'appSettings',
      entry: process.env.REMOTE_SETTINGS_URL + '/mf-manifest.json',
    },
  ],
});

// 리모트 컴포넌트 lazy 로드 헬퍼
function loadRemoteComponent<T = Record<string, never>>(
  remoteName: string,
  moduleName: string,
): React.LazyExoticComponent<ComponentType<T>> {
  return lazy(async () => {
    const mod = await loadRemote<{ default: ComponentType<T> }>(
      `${remoteName}/${moduleName}`,
    );
    if (!mod) {
      throw new Error(`Remote module ${remoteName}/${moduleName} not found`);
    }
    return mod;
  });
}

// 리모트 앱 래퍼: 로딩 + 에러 처리를 통합
interface RemoteAppProps {
  remoteName: string;
  moduleName: string;
  props?: Record<string, unknown>;
  fallback?: React.ReactNode;
  errorFallback?: React.ReactNode;
}

export function RemoteApp({
  remoteName,
  moduleName,
  props = {},
  fallback = <RemoteLoadingSkeleton />,
  errorFallback = <RemoteErrorFallback />,
}: RemoteAppProps) {
  const Component = loadRemoteComponent(remoteName, moduleName);

  return (
    <ErrorBoundary fallback={errorFallback}>
      <Suspense fallback={fallback}>
        <Component {...props} />
      </Suspense>
    </ErrorBoundary>
  );
}

// 로딩 스켈레톤: 리모트 앱이 로드되는 동안 표시
function RemoteLoadingSkeleton() {
  return (
    <div className="animate-pulse space-y-4 p-6">
      <div className="h-8 w-1/3 rounded bg-muted" />
      <div className="h-64 rounded bg-muted" />
    </div>
  );
}

// 에러 폴백: 리모트 로드 실패 시 표시
function RemoteErrorFallback() {
  return (
    <div className="rounded-lg border border-destructive/20 bg-destructive/5 p-6 text-center">
      <p className="text-sm text-destructive">앱을 불러오는 데 실패했습니다.</p>
      <button
        className="mt-2 text-sm underline"
        onClick={() => window.location.reload()}
      >
        새로고침
      </button>
    </div>
  );
}

4.4 공유 의존성 설정 상세

공유 의존성 설정은 마이크로 프론트엔드의 핵심이다. 잘못된 설정은 런타임 오류나 번들 크기 폭증으로 이어진다.

// federation.config.ts -- 모든 앱이 공유하는 의존성 설정 중앙 관리
// 이 파일을 packages/shared/federation-config/ 에 두고 각 앱에서 import

export const sharedDependencies = {
  // singleton: true -- 전체 앱에서 하나의 인스턴스만 사용
  // strictVersion: true -- 버전 불일치 시 런타임 에러 (프로덕션 안전)
  // requiredVersion -- 허용 가능한 semver 범위
  // eager: true -- Host에서 즉시 로드 (리모트 대기 불필요)
  react: {
    singleton: true,
    strictVersion: true,
    requiredVersion: '^19.0.0',
    eager: false, // 리모트에서는 false 권장 (Host가 제공)
  },
  'react-dom': {
    singleton: true,
    strictVersion: true,
    requiredVersion: '^19.0.0',
  },
  'react-router': {
    singleton: true,
    requiredVersion: '^7.0.0',
  },
  zustand: {
    singleton: true,
    requiredVersion: '^5.0.0',
  },
  // 내부 공유 패키지: version 명시로 정확한 호환성 관리
  '@shared/auth': {
    singleton: true,
    version: '2.0.0',
    requiredVersion: '^2.0.0',
  },
  '@shared/events': {
    singleton: true,
    version: '1.0.0',
    requiredVersion: '^1.0.0',
  },
} as const

// 각 앱의 rsbuild.config.ts 에서 사용:
// import { sharedDependencies } from '@shared/federation-config';
// shared: sharedDependencies,

5. 공유 의존성 버전 충돌 자동 탐지 CI

일상 비유: 같은 건물에 입주한 여러 회사가 같은 회의실 예약 시스템을 쓰는데, 회사마다 서로 다른 버전을 깔아 충돌이 나면 회의 알림이 뒤죽박죽이 된다. CI는 입주 단계에서 모두 같은 버전의 시스템을 쓰는지 자동 점검하는 관리실 역할이다.

이 그림은 shared dependency 충돌 탐지가 어떤 입력을 받아 어떤 출력으로 PR을 차단하는지 보여줍니다.

flowchart LR
  PR[PR 생성] --> Collect[shared 설정 수집<br/>모든 app rsbuild.config.ts]
  Lock[lockfile 정보] --> Collect
  Pkg[package.json] --> Collect
  Collect --> Compare{버전 일치?}
  Compare -->|Yes| Pass[Pass<br/>리포트만 출력]
  Compare -->|No| Sev{심각도}
  Sev -->|singleton + strict| Block[CI Fail<br/>PR 차단]
  Sev -->|범위 호환 가능| Warn[Warning + 다음 PR까지 유예]
  Block --> Suggest[해결안 자동 제안<br/>버전 통일/peerDep 추가]
  Warn --> Suggest

5.1 버전 충돌 탐지 스크립트

// scripts/check-shared-deps.ts
import { readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'

interface SharedDepConfig {
  singleton?: boolean
  requiredVersion?: string
  version?: string
  strictVersion?: boolean
}

interface AppSharedDeps {
  appName: string
  configPath: string
  shared: Record<string, SharedDepConfig>
  installedVersions: Record<string, string>
}

interface ConflictReport {
  dependency: string
  conflicts: Array<{
    app: string
    requiredVersion: string
    installedVersion: string
  }>
  severity: 'error' | 'warning'
  resolution: string
}

// 모든 앱 디렉토리를 순회하며 공유 의존성 정보 수집
function collectSharedDeps(appsDir: string): AppSharedDeps[] {
  const apps = readdirSync(appsDir, { withFileTypes: true })
    .filter((d) => d.isDirectory())
    .map((d) => d.name)

  return apps.map((appName) => {
    const configPath = join(appsDir, appName, 'rsbuild.config.ts')
    const pkgPath = join(appsDir, appName, 'package.json')

    // 실제 구현에서는 AST 파싱 또는 별도 JSON 설정 파일 사용
    const shared = extractSharedFromConfig(configPath)
    const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'))

    const installedVersions: Record<string, string> = {
      ...pkg.dependencies,
      ...pkg.devDependencies,
    }

    return { appName, configPath, shared, installedVersions }
  })
}

// 수집된 의존성 정보에서 충돌 감지
function detectConflicts(allApps: AppSharedDeps[]): ConflictReport[] {
  const conflicts: ConflictReport[] = []

  // 모든 앱에서 사용하는 공유 의존성 수집
  const depMap = new Map<string, Array<{ app: string; required: string; installed: string }>>()

  for (const app of allApps) {
    for (const [dep, config] of Object.entries(app.shared)) {
      if (!depMap.has(dep)) depMap.set(dep, [])

      depMap.get(dep)!.push({
        app: app.appName,
        required: config.requiredVersion ?? '*',
        installed: app.installedVersions[dep] ?? 'unknown',
      })
    }
  }

  // 충돌 감지 로직
  for (const [dep, usages] of depMap) {
    // singleton인데 major 버전이 다른 경우 → 런타임 에러 위험
    const majorVersions = new Set(usages.map((u) => u.installed.replace(/[\^~]/, '').split('.')[0]))

    if (majorVersions.size > 1) {
      conflicts.push({
        dependency: dep,
        conflicts: usages.map((u) => ({
          app: u.app,
          requiredVersion: u.required,
          installedVersion: u.installed,
        })),
        severity: 'error',
        resolution: `Major version mismatch for singleton "${dep}". All apps must use the same major version.`,
      })
    }

    // minor 버전 불일치 (warning) → 기능 차이 가능
    const minorVersions = new Set(
      usages.map((u) => {
        const parts = u.installed.replace(/[\^~]/, '').split('.')
        return `${parts[0]}.${parts[1]}`
      }),
    )

    if (minorVersions.size > 1 && majorVersions.size === 1) {
      conflicts.push({
        dependency: dep,
        conflicts: usages.map((u) => ({
          app: u.app,
          requiredVersion: u.required,
          installedVersion: u.installed,
        })),
        severity: 'warning',
        resolution: `Minor version difference for "${dep}". Consider aligning to the latest minor.`,
      })
    }
  }

  return conflicts
}

// 충돌 리포트 출력
function printReport(conflicts: ConflictReport[]): void {
  if (conflicts.length === 0) {
    console.log('No shared dependency conflicts detected.')
    return
  }

  const errors = conflicts.filter((c) => c.severity === 'error')
  const warnings = conflicts.filter((c) => c.severity === 'warning')

  if (errors.length > 0) {
    console.error(`\n${'='.repeat(60)}`)
    console.error(`ERRORS: ${errors.length} shared dependency conflicts`)
    console.error('='.repeat(60))

    for (const conflict of errors) {
      console.error(`\n  ${conflict.dependency}:`)
      for (const c of conflict.conflicts) {
        console.error(`    ${c.app}: required=${c.requiredVersion} installed=${c.installedVersion}`)
      }
      console.error(`    Resolution: ${conflict.resolution}`)
    }
  }

  if (warnings.length > 0) {
    console.warn(`\nWARNINGS: ${warnings.length} minor version differences`)
    for (const w of warnings) {
      console.warn(
        `  ${w.dependency}: ${w.conflicts.map((c) => `${c.app}@${c.installedVersion}`).join(', ')}`,
      )
    }
  }

  // 에러가 있으면 CI 실패 처리
  if (errors.length > 0) {
    process.exit(1)
  }
}

// 실행
const allApps = collectSharedDeps('apps')
const conflicts = detectConflicts(allApps)
printReport(conflicts)

// 헬퍼: 설정 파일에서 shared 섹션 추출 (프로덕션에서는 AST 파서 사용 권장)
function extractSharedFromConfig(configPath: string): Record<string, SharedDepConfig> {
  // 실제로는 federation.config.json 등 별도 파일에서 읽는 것을 권장
  try {
    const content = readFileSync(configPath, 'utf-8')
    const sharedMatch = content.match(/shared:\s*\{([\s\S]*?)\}/)
    if (!sharedMatch) return {}
    // ... AST 파싱 로직
    return {}
  } catch {
    return {}
  }
}

5.2 CI 파이프라인

# .ci/workflows/shared-deps-check.yml
name: Shared Dependencies Check

on:
  pull_request:
    paths:
      - 'apps/*/package.json'
      - 'apps/*/rsbuild.config.ts'
      - 'packages/shared/*/package.json'

jobs:
  check-deps:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm

      - run: pnpm install --frozen-lockfile

      # 공유 의존성 버전 충돌 검사
      - name: Check shared dependency versions
        run: pnpm tsx scripts/check-shared-deps.ts

      # 번들 사이즈 체크 (각 리모트 앱별)
      - name: Bundle size check
        run: |
          for app in apps/*/; do
            app_name=$(basename "$app")
            echo "Checking bundle size for $app_name..."
            pnpm --filter "$app_name" build
            pnpm tsx scripts/check-bundle-size.ts "$app" --max-size 150KB
          done

      # 타입 호환성 검사
      - name: Type compatibility check
        run: pnpm tsx scripts/check-type-contracts.ts

5.3 라우트 충돌 감지 스크립트

여러 리모트 앱이 동일한 라우트를 등록하면 런타임에 예측 불가능한 동작이 발생한다. CI에서 사전에 차단한다.

// scripts/check-route-conflicts.ts
import { readFileSync, readdirSync } from 'node:fs'
import { join } from 'node:path'

interface RouteEntry {
  path: string
  app: string
  file: string
}

// 각 앱의 라우트 설정 파일에서 경로 추출
function collectRoutes(appsDir: string): RouteEntry[] {
  const routes: RouteEntry[] = []
  const apps = readdirSync(appsDir, { withFileTypes: true }).filter((d) => d.isDirectory())

  for (const app of apps) {
    const routeFile = join(appsDir, app.name, 'src/routes/index.tsx')
    try {
      const content = readFileSync(routeFile, 'utf-8')
      // path="..." 패턴 추출 (실무에서는 AST 파서 사용)
      const pathMatches = content.matchAll(/path:\s*['"]([^'"]+)['"]/g)
      for (const match of pathMatches) {
        routes.push({
          path: match[1],
          app: app.name,
          file: routeFile,
        })
      }
    } catch {
      // 라우트 파일이 없는 앱은 건너뛰기
    }
  }

  return routes
}

// 중복 라우트 검출
function detectDuplicates(routes: RouteEntry[]): Map<string, RouteEntry[]> {
  const pathMap = new Map<string, RouteEntry[]>()

  for (const route of routes) {
    const normalized = route.path.replace(/\/+$/, '') // 후행 슬래시 제거
    if (!pathMap.has(normalized)) pathMap.set(normalized, [])
    pathMap.get(normalized)!.push(route)
  }

  // 2개 이상의 앱이 등록한 경로만 반환
  const duplicates = new Map<string, RouteEntry[]>()
  for (const [path, entries] of pathMap) {
    const uniqueApps = new Set(entries.map((e) => e.app))
    if (uniqueApps.size > 1) {
      duplicates.set(path, entries)
    }
  }

  return duplicates
}

const routes = collectRoutes('apps')
const duplicates = detectDuplicates(routes)

if (duplicates.size > 0) {
  console.error('Route conflicts detected:')
  for (const [path, entries] of duplicates) {
    console.error(`  "${path}" is registered by:`)
    for (const entry of entries) {
      console.error(`    - ${entry.app} (${entry.file})`)
    }
  }
  process.exit(1)
} else {
  console.log('No route conflicts detected.')
}

6. 리모트 앱별 독립 카나리 배포

6.1 카나리 배포 아키텍처

일상 비유: 대형 음식점이 새 메뉴를 한 번에 전 매장에 풀지 않고, 한 매장의 한 코너에서 10명에게만 먼저 시식을 받는 방식. 입구(Shell)는 그대로 둔 채 코너 메뉴(Remote A)만 신메뉴와 기존 메뉴를 비율로 섞어 서빙한다.

이 그림은 Shell이 stable을 유지한 채 특정 Remote만 사용자 비율에 따라 canary/stable 버전을 분배하는 구조를 보여줍니다.

flowchart TD
  User[사용자 요청] --> CDN[CDN / LB]
  CDN --> Shell[Shell Host<br/>항상 stable]
  Shell -->|hash userId| Registry{Remote Registry}
  Registry -->|10%| A1[App A v2.1.0 canary]
  Registry -->|90%| A2[App A v2.0.3 stable]
  Shell --> B[App B v1.5.0 stable<br/>100%]
  Shell --> C[App C v3.0.0 stable<br/>100%]
  A1 --> Metrics[error rate / INP / 전환율]
  A2 --> Metrics
  Metrics --> Decision{게이트 통과?}
  Decision -->|Pass| Promote[canary 확대 또는 승격]
  Decision -->|Fail| Rollback[canary 0% 즉시 롤백]

핵심 원칙: Shell(Host)은 stable 버전을 유지하고, 각 리모트 앱만 독립적으로 카나리 배포한다. 이 구조는 리스크를 낮추면서도 빠른 배포 주기를 유지한다.

6.2 리모트 URL 동적 라우팅 서비스

// services/remote-registry/src/registry.ts
import { z } from 'zod'

// 리모트 버전 스키마 정의
const RemoteVersionSchema = z.object({
  stable: z.string().url(),
  canary: z.string().url().optional(),
  canaryPercentage: z.number().min(0).max(100).default(0),
})

const RegistrySchema = z.record(z.string(), RemoteVersionSchema)

type Registry = z.infer<typeof RegistrySchema>

// 리모트 레지스트리: 각 앱의 stable/canary URL 관리
const registry: Registry = {
  appDashboard: {
    stable: 'https://dashboard.mfe.example.com/v2.0.3/mf-manifest.json',
    canary: 'https://dashboard.mfe.example.com/v2.1.0-canary.1/mf-manifest.json',
    canaryPercentage: 10,
  },
  appSettings: {
    stable: 'https://settings.mfe.example.com/v1.5.0/mf-manifest.json',
    canaryPercentage: 0,
  },
  appAnalytics: {
    stable: 'https://analytics.mfe.example.com/v3.0.0/mf-manifest.json',
    canaryPercentage: 0,
  },
}

interface ResolvedRemotes {
  [remoteName: string]: {
    url: string
    version: 'stable' | 'canary'
  }
}

// 사용자 ID와 feature flag를 기반으로 각 리모트의 URL 결정
export function resolveRemoteUrls(
  userId: string,
  featureFlags?: Record<string, boolean>,
): ResolvedRemotes {
  const resolved: ResolvedRemotes = {}

  for (const [name, config] of Object.entries(registry)) {
    // 카나리 배포 대상 결정: 해시 기반 일관된 분배
    const isCanaryUser = shouldServeCanary(userId, name, config.canaryPercentage)

    // Feature flag 오버라이드: 특정 사용자에게 강제 카나리
    const forceCanary = featureFlags?.[`canary_${name}`] === true

    if ((isCanaryUser || forceCanary) && config.canary) {
      resolved[name] = { url: config.canary, version: 'canary' }
    } else {
      resolved[name] = { url: config.stable, version: 'stable' }
    }
  }

  return resolved
}

// 일관된 해싱: 같은 사용자는 항상 같은 버전을 받도록 보장
function shouldServeCanary(userId: string, appName: string, percentage: number): boolean {
  if (percentage === 0) return false
  if (percentage === 100) return true

  // 사용자 ID + 앱 이름 기반 해시 (세션 간 일관성 보장)
  const hash = simpleHash(`${userId}:${appName}`)
  return hash % 100 < percentage
}

function simpleHash(str: string): number {
  let hash = 0
  for (let i = 0; i < str.length; i++) {
    const char = str.charCodeAt(i)
    hash = (hash << 5) - hash + char
    hash = hash & hash // 32비트 정수 변환
  }
  return Math.abs(hash)
}

6.3 Shell에서 카나리 리모트 주입

// apps/shell/src/bootstrap.ts
import { init, registerRemotes } from '@module-federation/enhanced/runtime'

interface BootstrapConfig {
  userId: string
  featureFlags: Record<string, boolean>
}

export async function bootstrapShell(config: BootstrapConfig): Promise<void> {
  // 1. 리모트 레지스트리에서 URL 조회 (서버 API)
  const response = await fetch('/api/remote-registry', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      userId: config.userId,
      featureFlags: config.featureFlags,
    }),
  })

  const remotes = await response.json()

  // 2. Module Federation 런타임 초기화
  init({
    name: 'shell',
    remotes: Object.entries(remotes).map(([name, info]) => ({
      name,
      entry: (info as { url: string }).url,
    })),
  })

  // 3. 카나리 버전 로깅 (모니터링용)
  for (const [name, info] of Object.entries(remotes)) {
    const { version } = info as { url: string; version: string }
    if (version === 'canary') {
      console.info(`[MFE Canary] ${name} serving canary version`)
      reportCanaryActivation(name, config.userId)
    }
  }
}

// 카나리 활성화 메트릭을 비동기로 전송
function reportCanaryActivation(appName: string, userId: string): void {
  if (typeof navigator.sendBeacon === 'function') {
    navigator.sendBeacon(
      '/api/metrics/canary',
      JSON.stringify({
        event: 'canary_activated',
        app: appName,
        userId,
        timestamp: Date.now(),
      }),
    )
  }
}

6.4 카나리 롤백 자동화

# .ci/workflows/canary-deploy.yml
name: Canary Deploy Remote App

on:
  workflow_dispatch:
    inputs:
      app_name:
        description: 'Remote app to deploy'
        required: true
        type: choice
        options: [app-dashboard, app-settings, app-analytics]
      canary_percentage:
        description: 'Canary traffic percentage'
        required: true
        type: number
        default: 10
      auto_rollback_threshold:
        description: 'Error rate threshold for auto-rollback (%)'
        type: number
        default: 5

jobs:
  canary-deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: pnpm

      - run: pnpm install --frozen-lockfile

      # 해당 앱만 빌드
      - name: Build remote app
        run: pnpm --filter ${{ inputs.app_name }} build

      # 버전 태깅된 경로로 CDN 배포
      - name: Deploy to CDN (canary path)
        run: |
          VERSION=$(node -p "require('./apps/${{ inputs.app_name }}/package.json').version")
          CANARY_VERSION="${VERSION}-canary.${GITHUB_RUN_NUMBER}"
          pnpm tsx scripts/deploy-cdn.ts \
            --app ${{ inputs.app_name }} \
            --version "$CANARY_VERSION" \
            --path "canary"

      # 레지스트리 업데이트: canary URL + 비율 설정
      - name: Update remote registry
        run: |
          pnpm tsx scripts/update-registry.ts \
            --app ${{ inputs.app_name }} \
            --canary-percentage ${{ inputs.canary_percentage }}

      # 10분간 에러율 모니터링
      - name: Monitor canary health
        run: |
          pnpm tsx scripts/monitor-canary.ts \
            --app ${{ inputs.app_name }} \
            --duration 600 \
            --error-threshold ${{ inputs.auto_rollback_threshold }}
        continue-on-error: true
        id: monitor

      # 에러율 초과 시 자동 롤백
      - name: Auto-rollback on failure
        if: steps.monitor.outcome == 'failure'
        run: |
          echo "Canary error threshold exceeded. Rolling back..."
          pnpm tsx scripts/update-registry.ts \
            --app ${{ inputs.app_name }} \
            --canary-percentage 0

      # 성공 시 점진적 확대 알림
      - name: Notify success
        if: steps.monitor.outcome == 'success'
        run: |
          echo "Canary healthy. Consider increasing traffic percentage."

6.5 카나리 점진적 확대 전략

카나리 배포는 한 번에 100%로 전환하지 않고 단계적으로 트래픽을 확대한다.

일상 비유: 무대 조명을 끄고 켜는 게 아니라 디머(dimmer)로 천천히 밝기를 올리는 것과 같다. 도중에 객석 반응(에러율/INP)을 보고 멈추거나 되돌릴 수 있다.

이 그림은 단계별 트래픽 비율과 게이트 시점을 시간 축으로 보여줍니다.

gantt
  title 카나리 트래픽 확대 타임라인 (예시)
  dateFormat HH:mm
  axisFormat %H:%M
  section Phase1 (10%)
    내부 QA 배포 :p1, 09:00, 10m
    에러율 모니터링 :after p1, 10m
  section Phase2 (25%)
    얼리 어답터 배포 :p2, 09:20, 30m
    INP/LCP 비교 :after p2, 5m
  section Phase3 (50%)
    절반 트래픽 :p3, 09:55, 60m
    비즈니스 메트릭 :after p3, 10m
  section Phase4 (100%)
    전체 승격 :p4, 11:05, 5m
    stable 아카이브 :after p4, 5m
Phase 1: 10% (내부 QA 팀)
  → 10분 모니터링 → 에러율 < 1% 확인
Phase 2: 25% (얼리 어답터)
  → 30분 모니터링 → Core Web Vitals 변화 확인
Phase 3: 50% (일반 사용자 절반)
  → 1시간 모니터링 → 비즈니스 메트릭 (전환율 등) 확인
Phase 4: 100% (전체 사용자)
  → canary → stable 승격, 이전 stable 아카이브

7. Server Components + 마이크로 FE 통합 패턴

7.1 아키텍처 개요

일상 비유: 호텔 룸서비스 같다. 주방(Server Component)이 식사 재료를 미리 준비해 트레이(props)에 담아 보내면, 객실 앞 카트(Client boundary)에서 셰프가 마무리 토핑(인터랙션)을 올린다. 트레이가 도착하기 전에는 사진(skeleton)만 보여 줘 빈 화면을 피한다.

이 그림은 Server Component 레이어와 Client Component 레이어가 어떻게 분리되고 데이터가 어디로 흐르는지 보여줍니다.

flowchart TD
  Browser[브라우저 요청] --> Shell[Shell Next.js]
  subgraph Shell
    direction TB
    RSC[Server Components<br/>레이아웃/프리페치/인증]
    Bridge[Client Boundary<br/>use client]
    Client[Client Components<br/>인터랙션]
  end
  RSC -->|HTML + serialized props| Bridge
  Bridge --> Client
  Client -->|registerRemotes/import| Remote1[Remote App A]
  Client -->|lazy import| Remote2[Remote App B]
  RSC -.->|cache revalidate| API[(Internal API/DB)]
  Remote1 -.->|own client data| API2[(Remote API)]

원칙: Server Component에서 데이터를 프리페치하고, Client Component 경계(boundary)에서 Module Federation 리모트를 로드한다. SSR의 SEO/성능 이점과 MFE의 독립 배포 이점을 함께 얻는 배치다.

7.2 Server Component에서 리모트 데이터 프리페치

// apps/shell/src/app/dashboard/page.tsx (Server Component)

interface DashboardData {
  stats: { label: string; value: number }[];
  recentActivity: { id: string; action: string; timestamp: string }[];
}

// Server Component: 데이터를 서버에서 가져와 클라이언트 MFE에 주입
export default async function DashboardPage() {
  // 서버에서 데이터 프리페치 (DB 직접 접근 또는 내부 API)
  const data = await fetchDashboardData();

  return (
    <main className="space-y-6 p-6">
      <h1 className="text-2xl font-bold">Dashboard</h1>

      {/* Server Component: 정적 레이아웃 (JS 번들 0) */}
      <div className="grid grid-cols-3 gap-4">
        {data.stats.map((stat) => (
          <StatCardStatic key={stat.label} label={stat.label} value={stat.value} />
        ))}
      </div>

      {/* Client Component: Module Federation 리모트 로드 */}
      <DashboardRemoteSection initialData={data.recentActivity} />
    </main>
  );
}

// 순수 Server Component -- 클라이언트 JS 번들에 포함되지 않음
function StatCardStatic({ label, value }: { label: string; value: number }) {
  return (
    <div className="rounded-lg border bg-card p-4">
      <p className="text-sm text-muted-foreground">{label}</p>
      <p className="text-2xl font-bold">{value.toLocaleString()}</p>
    </div>
  );
}

async function fetchDashboardData(): Promise<DashboardData> {
  const res = await fetch(`${process.env.API_URL}/dashboard`, {
    next: { revalidate: 60 }, // 60초 ISR 캐시
  });
  return res.json();
}

7.3 Client Boundary에서 MFE 로드

// apps/shell/src/app/dashboard/DashboardRemoteSection.tsx
'use client';

import { Suspense } from 'react';
import { RemoteApp } from '@/components/RemoteApp';

interface Props {
  initialData: Array<{ id: string; action: string; timestamp: string }>;
}

// 'use client' 경계: 이 컴포넌트부터 클라이언트 영역
export function DashboardRemoteSection({ initialData }: Props) {
  return (
    <section className="space-y-4">
      <h2 className="text-lg font-semibold">Recent Activity</h2>

      {/* Module Federation 리모트: 서버에서 가져온 데이터를 props로 전달 */}
      <RemoteApp
        remoteName="appDashboard"
        moduleName="RecentActivity"
        props={{ initialData }}
        fallback={
          <div className="h-48 animate-pulse rounded bg-muted" />
        }
      />
    </section>
  );
}

7.4 RSC + MFE 데이터 흐름 패턴

// patterns/server-client-bridge.ts
// Server Component에서 가져온 데이터를 MFE 리모트에 안전하게 전달

import { z } from 'zod'

// 계약 스키마: Server → Client 전달 데이터의 형태를 정의
const DashboardPropsSchema = z.object({
  initialData: z.array(
    z.object({
      id: z.string(),
      action: z.string(),
      timestamp: z.string(),
    }),
  ),
  userId: z.string(),
  permissions: z.array(z.string()),
})

type DashboardProps = z.infer<typeof DashboardPropsSchema>

// 서버 사이드: 데이터를 직렬화 가능한 형태로 변환 + 검증
export function prepareDashboardProps(raw: unknown): DashboardProps {
  // zod로 런타임 검증 (Server → Client 경계에서 계약 보장)
  return DashboardPropsSchema.parse(raw)
}

// 클라이언트 사이드: 리모트 앱이 수신한 props 검증
export function validateRemoteProps(props: unknown): DashboardProps {
  const result = DashboardPropsSchema.safeParse(props)
  if (!result.success) {
    console.error('Remote props validation failed:', result.error.issues)
    throw new Error('Invalid props received from host')
  }
  return result.data
}

7.5 SSR 호환성 주의사항

Module Federation 리모트를 SSR 환경에서 사용할 때 주의할 점:

// apps/shell/src/components/SafeRemoteApp.tsx
'use client';

import { useEffect, useState, type ReactNode } from 'react';
import { RemoteApp } from './RemoteApp';

interface SafeRemoteAppProps {
  remoteName: string;
  moduleName: string;
  props?: Record<string, unknown>;
  ssrFallback?: ReactNode; // SSR 시 표시할 플레이스홀더
}

// SSR 안전한 리모트 앱 래퍼
// Module Federation은 브라우저 전용이므로 서버에서는 fallback 표시
export function SafeRemoteApp({
  remoteName,
  moduleName,
  props,
  ssrFallback = <div className="h-48 animate-pulse rounded bg-muted" />,
}: SafeRemoteAppProps) {
  const [isMounted, setIsMounted] = useState(false);

  useEffect(() => {
    // 클라이언트에서만 리모트 로드
    setIsMounted(true);
  }, []);

  if (!isMounted) {
    return <>{ssrFallback}</>;
  }

  return (
    <RemoteApp
      remoteName={remoteName}
      moduleName={moduleName}
      props={props}
    />
  );
}

8. 공유 상태/인증/라우팅/에러 바운더리 패턴

8.1 이벤트 버스 (Cross-App 통신)

// packages/shared/events/src/event-bus.ts

// 타입 안전한 이벤트 핸들러
type EventHandler<T = unknown> = (payload: T) => void

// 전체 MFE 시스템에서 사용하는 이벤트 타입 정의
interface EventMap {
  'auth:login': { userId: string; token: string }
  'auth:logout': undefined
  'theme:change': { theme: 'light' | 'dark' }
  'notification:show': { message: string; type: 'info' | 'success' | 'error' }
  'remote:loaded': { name: string; version: string }
  'remote:error': { name: string; error: string }
  'navigation:change': { path: string; source: string }
}

class MFEEventBus {
  private handlers = new Map<string, Set<EventHandler>>()

  // 이벤트 구독 -- 구독 해제 함수를 반환하여 메모리 누수 방지
  on<K extends keyof EventMap>(event: K, handler: EventHandler<EventMap[K]>): () => void {
    if (!this.handlers.has(event)) {
      this.handlers.set(event, new Set())
    }
    this.handlers.get(event)!.add(handler as EventHandler)

    // 구독 해제 함수 반환 (useEffect cleanup에서 사용)
    return () => {
      this.handlers.get(event)?.delete(handler as EventHandler)
    }
  }

  // 이벤트 발행 -- 모든 구독자에게 payload 전달
  emit<K extends keyof EventMap>(event: K, payload: EventMap[K]): void {
    const handlers = this.handlers.get(event)
    if (!handlers) return

    for (const handler of handlers) {
      try {
        handler(payload)
      } catch (error) {
        console.error(`Event handler error for "${event}":`, error)
      }
    }
  }

  // 일회성 이벤트 구독 -- 한 번 실행 후 자동 해제
  once<K extends keyof EventMap>(event: K, handler: EventHandler<EventMap[K]>): () => void {
    const wrappedHandler: EventHandler<EventMap[K]> = (payload) => {
      unsubscribe()
      handler(payload)
    }
    const unsubscribe = this.on(event, wrappedHandler)
    return unsubscribe
  }

  // 디버깅용: 등록된 이벤트 목록 조회
  getRegisteredEvents(): string[] {
    return [...this.handlers.keys()]
  }

  // 모든 구독 해제 (앱 언마운트 시 사용)
  clear(): void {
    this.handlers.clear()
  }
}

// 싱글톤: 전역 윈도우에 할당하여 MFE 간 공유
function getEventBus(): MFEEventBus {
  const key = '__MFE_EVENT_BUS__'

  if (typeof window !== 'undefined') {
    if (!(window as Record<string, unknown>)[key]) {
      ;(window as Record<string, unknown>)[key] = new MFEEventBus()
    }
    return (window as Record<string, unknown>)[key] as MFEEventBus
  }

  // SSR 환경에서는 임시 인스턴스 반환
  return new MFEEventBus()
}

export const eventBus = getEventBus()
export type { EventMap }

8.2 React Hook으로 이벤트 버스 사용

// packages/shared/events/src/useEventBus.ts
import { useEffect, useCallback } from 'react'
import { eventBus, type EventMap } from './event-bus'

// 이벤트 구독 훅 -- 컴포넌트 언마운트 시 자동으로 구독 해제
export function useEventListener<K extends keyof EventMap>(
  event: K,
  handler: (payload: EventMap[K]) => void,
): void {
  useEffect(() => {
    const unsubscribe = eventBus.on(event, handler)
    return unsubscribe // cleanup에서 자동 해제
  }, [event, handler])
}

// 이벤트 발행 훅 -- 메모이제이션된 emit 함수 반환
export function useEventEmitter<K extends keyof EventMap>(
  event: K,
): (payload: EventMap[K]) => void {
  return useCallback(
    (payload: EventMap[K]) => {
      eventBus.emit(event, payload)
    },
    [event],
  )
}

// 사용 예시:
// function NotificationBanner() {
//   useEventListener('notification:show', (payload) => {
//     showToast(payload.message, payload.type);
//   });
//   return null;
// }

8.3 공유 인증 컨텍스트

// packages/shared/auth/src/AuthProvider.tsx
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react';
import { eventBus } from '@shared/events';

interface User {
  id: string;
  email: string;
  name: string;
  roles: string[];
}

interface AuthContextValue {
  user: User | null;
  isAuthenticated: boolean;
  isLoading: boolean;
  login: (token: string) => Promise<void>;
  logout: () => void;
  hasPermission: (permission: string) => boolean;
}

const AuthContext = createContext<AuthContextValue | null>(null);

// Shell(Host)에서 AuthProvider를 최상위에 배치하면
// 모든 리모트 앱이 동일한 인증 상태를 공유할 수 있음
export function AuthProvider({ children }: { children: ReactNode }) {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    // 초기 인증 상태 복원
    restoreSession().then((restoredUser) => {
      setUser(restoredUser);
      setIsLoading(false);
    });

    // 다른 MFE에서 발생한 로그아웃 이벤트 수신
    const unsubLogout = eventBus.on('auth:logout', () => {
      setUser(null);
    });

    return () => { unsubLogout(); };
  }, []);

  const login = async (token: string) => {
    const userData = await fetchUserProfile(token);
    setUser(userData);
    sessionStorage.setItem('auth_token', token);
    // 모든 MFE에 로그인 이벤트 브로드캐스트
    eventBus.emit('auth:login', { userId: userData.id, token });
  };

  const logout = () => {
    setUser(null);
    sessionStorage.removeItem('auth_token');
    // 모든 MFE에 로그아웃 이벤트 브로드캐스트
    eventBus.emit('auth:logout', undefined);
  };

  const hasPermission = (permission: string) => {
    return user?.roles.includes(permission) ?? false;
  };

  return (
    <AuthContext.Provider value={{ user, isAuthenticated: !!user, isLoading, login, logout, hasPermission }}>
      {children}
    </AuthContext.Provider>
  );
}

export function useAuth(): AuthContextValue {
  const context = useContext(AuthContext);
  if (!context) {
    throw new Error('useAuth must be used within AuthProvider');
  }
  return context;
}

async function restoreSession(): Promise<User | null> {
  const token = sessionStorage.getItem('auth_token');
  if (!token) return null;

  try {
    return await fetchUserProfile(token);
  } catch {
    sessionStorage.removeItem('auth_token');
    return null;
  }
}

async function fetchUserProfile(token: string): Promise<User> {
  const res = await fetch('/api/auth/me', {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!res.ok) throw new Error('Auth failed');
  return res.json();
}

8.4 에러 바운더리 (리모트 앱 격리)

일상 비유: 빌딩 방화구획과 같다. 한 호실에서 불이 나도 방화문이 닫히면서 옆 호실과 1층 로비는 계속 운영된다. Error Boundary가 방화문이고, Shell이 1층 로비다.

이 그림은 Remote 부트스트랩과 fallback 전환 흐름, Error Boundary가 잡아내는 지점을 시퀀스로 보여줍니다.

sequenceDiagram
  participant U as 사용자
  participant H as Host Shell
  participant R as MF Runtime
  participant Reg as Registry
  participant Re as Remote App
  participant EB as ErrorBoundary
  participant Bus as EventBus

  U->>H: 페이지 진입
  H->>Reg: resolveRemoteUrls(userId)
  Reg-->>H: stable/canary URL
  H->>R: registerRemotes()
  R->>Re: fetch mf-manifest.json
  alt 정상 로드
    Re-->>R: chunks + expose map
    R-->>H: lazy module
    H->>EB: Render(<RemoteApp/>)
    EB-->>U: 화면 표시
  else 로드/런타임 실패
    Re-->>R: error
    R-->>EB: throw
    EB->>Bus: emit remote:error
    EB-->>U: fallback UI + retry
    Bus->>H: 메트릭/관측 송신
  end
// packages/shared/ui/src/ErrorBoundary.tsx
import { Component, type ErrorInfo, type ReactNode } from 'react';
import { eventBus } from '@shared/events';

interface Props {
  children: ReactNode;
  fallback?: ReactNode;
  remoteName?: string;
  onError?: (error: Error, info: ErrorInfo) => void;
}

interface State {
  hasError: boolean;
  error: Error | null;
}

// 리모트 앱별 에러 바운더리: 하나의 리모트가 크래시해도 전체 앱에 영향 없음
export class RemoteErrorBoundary extends Component<Props, State> {
  state: State = { hasError: false, error: null };

  static getDerivedStateFromError(error: Error): State {
    return { hasError: true, error };
  }

  componentDidCatch(error: Error, info: ErrorInfo): void {
    const { remoteName, onError } = this.props;

    // 에러 리포팅 (참고: [09. 관측성](./09_장애_대응_및_관측성_표준.md))
    console.error(`[MFE Error] ${remoteName ?? 'unknown'}:`, error, info);

    // 이벤트 버스로 에러 전파 (Shell에서 수집하여 대시보드에 표시)
    if (remoteName) {
      eventBus.emit('remote:error', {
        name: remoteName,
        error: error.message,
      });
    }

    onError?.(error, info);
  }

  render(): ReactNode {
    if (this.state.hasError) {
      return this.props.fallback ?? (
        <div className="rounded-lg border border-red-200 bg-red-50 p-6 text-center dark:border-red-900 dark:bg-red-950">
          <p className="text-sm text-red-600 dark:text-red-400">
            {this.props.remoteName
              ? `${this.props.remoteName} 앱에서 오류가 발생했습니다.`
              : '오류가 발생했습니다.'}
          </p>
          <button
            className="mt-3 rounded bg-red-100 px-4 py-1.5 text-xs text-red-700 hover:bg-red-200 dark:bg-red-900 dark:text-red-300"
            onClick={() => this.setState({ hasError: false, error: null })}
          >
            다시 시도
          </button>
        </div>
      );
    }

    return this.props.children;
  }
}

8.5 Host-Remote 라우팅 통합

// apps/shell/src/routes/index.tsx
// Shell(Host)의 라우트 설정: 각 리모트 앱을 lazy 라우트로 등록

import { createBrowserRouter, type RouteObject } from 'react-router';
import { RemoteApp } from '@/components/RemoteApp';
import { ShellLayout } from '@/layouts/ShellLayout';

// 리모트 앱 라우트를 Shell 라우터에 통합
const routes: RouteObject[] = [
  {
    path: '/',
    element: <ShellLayout />, // 공통 레이아웃 (헤더, 사이드바)
    children: [
      {
        path: 'dashboard/*', // /dashboard 이하 모든 경로를 리모트에 위임
        element: (
          <RemoteApp
            remoteName="appDashboard"
            moduleName="DashboardRoutes"
          />
        ),
      },
      {
        path: 'settings/*', // /settings 이하 → appSettings 리모트
        element: (
          <RemoteApp
            remoteName="appSettings"
            moduleName="SettingsRoutes"
          />
        ),
      },
      {
        path: 'analytics/*', // /analytics 이하 → appAnalytics 리모트
        element: (
          <RemoteApp
            remoteName="appAnalytics"
            moduleName="AnalyticsRoutes"
          />
        ),
      },
    ],
  },
];

export const router = createBrowserRouter(routes);

// 리모트 앱 내부에서는 상대 경로를 사용하여 Host 라우터와 독립적으로 동작
// 예: appDashboard 내부의 /overview → Host에서는 /dashboard/overview
// apps/app-dashboard/src/routes/index.tsx
// 리모트 앱의 내부 라우트 -- Host로부터 받은 basename 기준 상대 경로

import { Routes, Route } from 'react-router';
import { DashboardOverview } from '../pages/DashboardOverview';
import { DashboardDetails } from '../pages/DashboardDetails';

export default function DashboardRoutes() {
  return (
    <Routes>
      {/* 상대 경로: Host의 /dashboard/* 와 결합됨 */}
      <Route index element={<DashboardOverview />} />
      <Route path="details/:id" element={<DashboardDetails />} />
    </Routes>
  );
}

9. 성능 최적화 + 모니터링

9.1 리모트 프리로드 전략

// apps/shell/src/lib/remote-preload.ts
import { preloadRemote } from '@module-federation/enhanced/runtime'

interface PreloadConfig {
  name: string
  // 프리로드 트리거 조건
  trigger: 'immediate' | 'idle' | 'hover' | 'viewport'
  // hover/viewport 트리거 시 대상 DOM 셀렉터
  selector?: string
}

const preloadConfigs: PreloadConfig[] = [
  // 대시보드: 로그인 직후 즉시 프리로드 (가장 방문 빈도 높은 페이지)
  { name: 'appDashboard', trigger: 'immediate' },
  // 설정: 사이드바 설정 메뉴에 마우스 올릴 때 (사전 로드)
  { name: 'appSettings', trigger: 'hover', selector: '[data-nav="settings"]' },
  // 분석: 뷰포트 진입 시 (스크롤 기반 레이아웃에서 유용)
  { name: 'appAnalytics', trigger: 'viewport', selector: '#analytics-section' },
]

export function setupPreloading(): void {
  for (const config of preloadConfigs) {
    switch (config.trigger) {
      case 'immediate':
        // 앱 시작 시 즉시 프리로드
        preloadRemote([{ nameOrAlias: config.name, prefetchInterface: true }])
        break

      case 'idle':
        // 브라우저 유휴 시간에 프리로드 (메인 스레드 차단 최소화)
        if ('requestIdleCallback' in window) {
          requestIdleCallback(() => {
            preloadRemote([{ nameOrAlias: config.name }])
          })
        }
        break

      case 'hover': {
        // 마우스 호버 시 프리로드 (사용자 의도 감지)
        const el = document.querySelector(config.selector!)
        el?.addEventListener(
          'mouseenter',
          () => {
            preloadRemote([{ nameOrAlias: config.name }])
          },
          { once: true },
        ) // 한 번만 실행
        break
      }

      case 'viewport': {
        // 뷰포트 진입 시 프리로드 (Intersection Observer 활용)
        const el = document.querySelector(config.selector!)
        if (el) {
          const observer = new IntersectionObserver(
            ([entry]) => {
              if (entry.isIntersecting) {
                preloadRemote([{ nameOrAlias: config.name }])
                observer.disconnect()
              }
            },
            { rootMargin: '200px' }, // 200px 전에 미리 로드
          )
          observer.observe(el)
        }
        break
      }
    }
  }
}

9.2 리모트 로드 성능 모니터링

// apps/shell/src/lib/remote-monitor.ts

interface RemoteLoadMetric {
  remoteName: string
  loadTimeMs: number
  cacheHit: boolean
  version: 'stable' | 'canary'
  timestamp: number
}

class RemoteMonitor {
  private metrics: RemoteLoadMetric[] = []

  // 리모트 로드 시간 추적 래퍼
  async trackLoad<T>(
    remoteName: string,
    version: 'stable' | 'canary',
    loadFn: () => Promise<T>,
  ): Promise<T> {
    const start = performance.now()
    let cacheHit = false

    try {
      // Performance API 마크 -- Chrome DevTools에서 확인 가능
      performance.mark(`mfe-load-start:${remoteName}`)

      const result = await loadFn()

      const loadTime = performance.now() - start
      performance.mark(`mfe-load-end:${remoteName}`)
      performance.measure(
        `mfe-load:${remoteName}`,
        `mfe-load-start:${remoteName}`,
        `mfe-load-end:${remoteName}`,
      )

      // 캐시 히트 판정: 5ms 미만이면 메모리 캐시로 간주
      cacheHit = loadTime < 5

      const metric: RemoteLoadMetric = {
        remoteName,
        loadTimeMs: Math.round(loadTime),
        cacheHit,
        version,
        timestamp: Date.now(),
      }

      this.metrics.push(metric)
      this.reportMetric(metric)

      return result
    } catch (error) {
      const loadTime = performance.now() - start
      console.error(`[MFE Monitor] ${remoteName} failed after ${Math.round(loadTime)}ms`)
      throw error
    }
  }

  // 메트릭을 서버로 전송 (sendBeacon으로 비동기 전송)
  private reportMetric(metric: RemoteLoadMetric): void {
    if (typeof navigator.sendBeacon === 'function') {
      navigator.sendBeacon('/api/metrics/mfe-load', JSON.stringify(metric))
    }
  }

  // 수집된 메트릭 조회
  getMetrics(): RemoteLoadMetric[] {
    return [...this.metrics]
  }

  // 평균 로드 시간 계산 (캐시 히트 제외)
  getAverageLoadTime(remoteName?: string): number {
    const filtered = remoteName
      ? this.metrics.filter((m) => m.remoteName === remoteName && !m.cacheHit)
      : this.metrics.filter((m) => !m.cacheHit)

    if (filtered.length === 0) return 0
    return filtered.reduce((sum, m) => sum + m.loadTimeMs, 0) / filtered.length
  }
}

export const remoteMonitor = new RemoteMonitor()

9.3 번들 사이즈 모니터링 스크립트

// scripts/check-bundle-size.ts
// CI에서 각 리모트 앱의 번들 사이즈를 검사하여 예산 초과 시 PR 블로킹

import { readdirSync, statSync } from 'node:fs'
import { join } from 'node:path'
import { gzipSync } from 'node:zlib'
import { readFileSync } from 'node:fs'

interface BundleReport {
  app: string
  files: Array<{
    name: string
    rawSize: number
    gzipSize: number
  }>
  totalRaw: number
  totalGzip: number
  budget: number // gzip 기준 예산 (bytes)
  overBudget: boolean
}

function analyzeBundle(distDir: string, budgetKB: number): BundleReport {
  const app = distDir.split('/').at(-2) ?? 'unknown'
  const budget = budgetKB * 1024 // KB → bytes
  const files: BundleReport['files'] = []

  // dist 디렉토리의 모든 JS 파일 분석
  const jsFiles = findFiles(distDir, /\.js$/)

  for (const file of jsFiles) {
    const content = readFileSync(file)
    const gzipped = gzipSync(content)
    files.push({
      name: file.replace(distDir, ''),
      rawSize: content.length,
      gzipSize: gzipped.length,
    })
  }

  const totalRaw = files.reduce((sum, f) => sum + f.rawSize, 0)
  const totalGzip = files.reduce((sum, f) => sum + f.gzipSize, 0)

  return {
    app,
    files: files.sort((a, b) => b.gzipSize - a.gzipSize), // 큰 파일 순
    totalRaw,
    totalGzip,
    budget,
    overBudget: totalGzip > budget,
  }
}

function findFiles(dir: string, pattern: RegExp): string[] {
  const results: string[] = []
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
    const fullPath = join(dir, entry.name)
    if (entry.isDirectory()) {
      results.push(...findFiles(fullPath, pattern))
    } else if (pattern.test(entry.name)) {
      results.push(fullPath)
    }
  }
  return results
}

// CLI 실행
const [distDir, , budgetStr] = process.argv.slice(2)
const budgetKB = parseInt(budgetStr ?? '150', 10)
const report = analyzeBundle(join(distDir, 'dist'), budgetKB)

console.log(`\n=== Bundle Report: ${report.app} ===`)
console.log(`Total: ${(report.totalGzip / 1024).toFixed(1)}KB gzip (budget: ${budgetKB}KB)`)

if (report.overBudget) {
  console.error(`OVER BUDGET by ${((report.totalGzip - report.budget) / 1024).toFixed(1)}KB`)
  console.error('Top files:')
  for (const file of report.files.slice(0, 5)) {
    console.error(`  ${file.name}: ${(file.gzipSize / 1024).toFixed(1)}KB`)
  }
  process.exit(1)
}

10. 마이그레이션 전략: 모놀리스에서 MFE로

기존 모놀리스를 한 번에 MFE로 전환하는 것은 위험하다. 아래의 단계적 마이그레이션 전략을 따른다.

10.1 5단계 마이그레이션 로드맵

일상 비유: 영업 중인 식당의 주방을 통째로 리모델링하지 않는다. 영업 시간 중에는 임시 부엌(Shell)을 짓고, 메뉴 한 코너씩(도메인 하나씩) 새 주방으로 옮겨가며 영업을 멈추지 않게 한다.

이 그림은 5단계의 마이그레이션 일정과 단계 간 의존성을 시간 축으로 보여줍니다.

gantt
  title 모놀리스 → MFE 마이그레이션 로드맵
  dateFormat YYYY-MM-DD
  section Phase 0 준비
    의존성 그래프 분석 :p0a, 2030-01-01, 14d
    도메인 경계 식별 :p0b, after p0a, 14d
  section Phase 1 Shell 추출
    Shell 분리 :p1, after p0b, 21d
  section Phase 2 첫 Remote
    독립 도메인 추출 :p2a, after p1, 21d
    카나리 파이프라인 :p2b, after p1, 14d
  section Phase 3 점진 분리
    나머지 도메인 분리 :p3, after p2a, 84d
  section Phase 4 제거
    모놀리스 아카이브 :p4, after p3, 21d
Phase 0: 준비 (2-4주)
  - 의존성 그래프 분석 (dependency-cruiser, madge)
  - 도메인 경계 식별 (1장의 도메인 경계 탐색 시나리오 활용)
  - 공유 모듈 API 계약 설계

Phase 1: Shell 추출 (2-3주)
  - 레이아웃, 네비게이션, 인증을 Shell 앱으로 분리
  - 기존 모놀리스를 단일 리모트로 등록 (전체를 하나의 MFE로)
  - 이 시점에서는 기능 변경 없이 구조만 변경

Phase 2: 첫 번째 리모트 분리 (3-4주)
  - 가장 독립적인 도메인 하나를 리모트로 추출
  - 공유 상태 이벤트 버스 구축
  - 카나리 배포 파이프라인 구성
  - Feature flag로 모놀리스/MFE 전환 스위치

Phase 3: 점진적 분리 (6-12주)
  - 나머지 도메인을 하나씩 리모트로 분리
  - 각 분리 시 계약 테스트 추가
  - 팀별 소유권 이전

Phase 4: 모놀리스 제거 (2-4주)
  - 모든 기능이 리모트로 이전 확인
  - 모놀리스 코드 아카이브
  - 모니터링 대시보드 완성

10.2 Feature Flag 기반 점진적 전환

// apps/shell/src/components/FeatureFlaggedRemote.tsx
'use client';

import { useFeatureFlag } from '@shared/feature-flags';
import { RemoteApp } from './RemoteApp';

interface Props {
  flagKey: string;            // feature flag 키
  remoteName: string;         // MFE 리모트 이름
  moduleName: string;         // 리모트 모듈 이름
  LegacyComponent: React.ComponentType; // 기존 모놀리스 컴포넌트
}

// Feature flag로 모놀리스 컴포넌트와 MFE 리모트를 전환
// 롤백이 필요하면 flag만 끄면 즉시 모놀리스로 복귀
export function FeatureFlaggedRemote({
  flagKey,
  remoteName,
  moduleName,
  LegacyComponent,
}: Props) {
  const isMFEEnabled = useFeatureFlag(flagKey);

  if (isMFEEnabled) {
    return (
      <RemoteApp
        remoteName={remoteName}
        moduleName={moduleName}
        // MFE 로드 실패 시 모놀리스 컴포넌트로 자동 폴백
        errorFallback={<LegacyComponent />}
      />
    );
  }

  // flag가 꺼져 있으면 기존 모놀리스 컴포넌트 렌더링
  return <LegacyComponent />;
}

// 사용 예시:
// <FeatureFlaggedRemote
//   flagKey="mfe_dashboard_enabled"
//   remoteName="appDashboard"
//   moduleName="DashboardRoutes"
//   LegacyComponent={LegacyDashboard}
// />

10.3 공유 상태 마이그레이션 매트릭스

상태 유형 범위 마이그레이션 방식 예시
App-local 단일 MFE 내부 그대로 유지 (내부 Zustand/useState) 폼 상태, 모달 열림/닫힘
Cross-app 2-3개 MFE 공유 이벤트 버스 (eventBus) 알림, 네비게이션 변경
Global 전체 앱 공유 Shell Context + 이벤트 버스 인증, 테마, 언어 설정
Server 서버 캐시 React Query / SWR (각 MFE 독립) API 데이터, 사용자 프로필

11. 테스팅 전략

마이크로 프론트엔드에서는 단위 테스트뿐 아니라 계약 테스트통합 테스트가 중요하다. 더 자세한 테스팅 패턴은 07. 테스팅 가이드를 참고한다.

11.1 계약 테스트 (Contract Testing)

// packages/shared/contracts/src/__tests__/event-contract.test.ts
// 이벤트 버스 계약 테스트: 리모트 앱 간 이벤트 형식이 호환되는지 검증

import { describe, it, expect } from 'vitest'
import { z } from 'zod'

// 이벤트 스키마 정의 -- 이 파일이 변경되면 모든 앱이 영향 받음
const AuthLoginSchema = z.object({
  userId: z.string().min(1),
  token: z.string().min(10),
})

const NotificationSchema = z.object({
  message: z.string().min(1),
  type: z.enum(['info', 'success', 'error']),
})

describe('이벤트 계약 테스트', () => {
  it('auth:login 이벤트가 스키마와 일치해야 한다', () => {
    // 실제 앱에서 발행하는 이벤트 형태
    const payload = { userId: 'user-123', token: 'jwt-token-abc' }
    expect(AuthLoginSchema.safeParse(payload).success).toBe(true)
  })

  it('notification:show 이벤트가 스키마와 일치해야 한다', () => {
    const payload = { message: '저장되었습니다', type: 'success' as const }
    expect(NotificationSchema.safeParse(payload).success).toBe(true)
  })

  it('잘못된 이벤트 페이로드는 거부되어야 한다', () => {
    const invalid = { userId: '', token: '' } // 빈 문자열
    expect(AuthLoginSchema.safeParse(invalid).success).toBe(false)
  })
})

11.2 리모트 앱 격리 테스트

// apps/app-dashboard/src/__tests__/standalone.test.tsx
// 리모트 앱이 Host 없이도 독립 실행 가능한지 검증

import { render, screen } from '@testing-library/react';
import { MemoryRouter } from 'react-router';
import { describe, it, expect, vi } from 'vitest';
import DashboardRoutes from '../routes/index';

// 공유 의존성 모킹 -- Host 없이 테스트하기 위해
vi.mock('@shared/auth', () => ({
  useAuth: () => ({
    user: { id: 'test-user', name: 'Test', email: 'test@example.com', roles: ['admin'] },
    isAuthenticated: true,
    isLoading: false,
    hasPermission: () => true,
  }),
}));

vi.mock('@shared/events', () => ({
  eventBus: {
    on: vi.fn(() => vi.fn()), // 구독 해제 함수 반환
    emit: vi.fn(),
  },
}));

describe('Dashboard 리모트 앱 독립 실행', () => {
  it('Host 없이도 대시보드가 렌더링되어야 한다', () => {
    render(
      <MemoryRouter initialEntries={['/']}>
        <DashboardRoutes />
      </MemoryRouter>,
    );

    expect(screen.getByText(/dashboard/i)).toBeInTheDocument();
  });

  it('상세 페이지 라우트가 동작해야 한다', () => {
    render(
      <MemoryRouter initialEntries={['/details/123']}>
        <DashboardRoutes />
      </MemoryRouter>,
    );

    expect(screen.getByText(/details/i)).toBeInTheDocument();
  });
});

11.3 E2E 통합 테스트

// e2e/mfe-integration.spec.ts
// Playwright 기반 E2E 테스트: Shell + 리모트 앱 통합 검증

import { test, expect } from '@playwright/test'

test.describe('마이크로 프론트엔드 통합 테스트', () => {
  test('Shell 로드 후 Dashboard 리모트가 정상 표시되어야 한다', async ({ page }) => {
    await page.goto('/dashboard')

    // Shell 레이아웃 확인
    await expect(page.locator('header')).toBeVisible()
    await expect(page.locator('nav')).toBeVisible()

    // 리모트 앱 로드 완료 대기 (스켈레톤 사라짐)
    await expect(page.locator('.animate-pulse')).toHaveCount(0, { timeout: 10_000 })

    // 대시보드 콘텐츠 표시 확인
    await expect(page.getByText('Dashboard')).toBeVisible()
  })

  test('리모트 로드 실패 시 에러 폴백이 표시되어야 한다', async ({ page }) => {
    // 리모트 URL을 차단하여 로드 실패 시뮬레이션
    await page.route('**/mf-manifest.json', (route) => route.abort())
    await page.goto('/dashboard')

    // 에러 폴백 UI 확인
    await expect(page.getByText('앱을 불러오는 데 실패했습니다')).toBeVisible({ timeout: 10_000 })
  })

  test('리모트 앱 간 네비게이션이 정상 동작해야 한다', async ({ page }) => {
    await page.goto('/dashboard')
    await page.click('[data-nav="settings"]')

    // URL 변경 확인
    await expect(page).toHaveURL(/\/settings/)

    // Shell 레이아웃이 유지되는지 확인
    await expect(page.locator('header')).toBeVisible()
  })
})

12. 체크리스트

아키텍처 설계

  • [ ] 도메인 경계 분석 완료 (1장의 도메인 경계 탐색 시나리오 활용)
  • [ ] 공유 모듈 API 계약 정의 (@shared/types, @shared/events)
  • [ ] 통합 방식 결정 (Module Federation / Native Federation / Single-SPA / iframe)
  • [ ] Host-Remote 라우팅 전략 확정 및 라우트 충돌 검사 CI 추가
  • [ ] Server Components + Client MFE 경계 설계
  • [ ] 스타일 격리 전략 확정 (CSS Modules / Shadow DOM / 네임스페이스)
  • [ ] 공유 상태 분류 완료 (App-local / Cross-app / Global / Server)

개발 환경

  • [ ] Module Federation 2.1 Host/Remote 설정 완료
  • [ ] 공유 의존성 목록 확정 + singleton/strictVersion 설정
  • [ ] 공유 의존성 중앙 관리 (federation.config.ts)
  • [ ] TypeScript 타입 자동 생성 (dts 플러그인) 설정 완료
  • [ ] 로컬 개발 환경: 독립 실행 + 통합 실행 모두 지원 확인
  • [ ] 이벤트 버스 + 인증 컨텍스트 공유 구현 완료
  • [ ] React Hook 기반 이벤트 버스 래퍼 (useEventListener, useEventEmitter) 구현

CI/CD

  • [ ] 공유 의존성 버전 충돌 CI 검사 설정 (check-shared-deps.ts)
  • [ ] 라우트 충돌 감지 CI 검사 설정 (check-route-conflicts.ts)
  • [ ] 앱별 독립 빌드/배포 파이프라인 구축
  • [ ] 카나리 배포 워크플로 구성 (비율 조절, 자동 롤백 포함)
  • [ ] 번들 사이즈 예산 CI 체크 (리모트당 150KB gzip 이하)
  • [ ] 타입 계약 호환성 검사 (check-type-contracts.ts)

마이그레이션

  • [ ] 마이그레이션 Phase 0-4 로드맵 수립 완료
  • [ ] Feature flag 기반 점진적 전환 메커니즘 구현
  • [ ] 모놀리스 → MFE 전환 시 롤백 스위치 (feature flag) 확인
  • [ ] 공유 상태 마이그레이션 매트릭스 작성 완료

테스팅

  • [ ] 계약 테스트: 이벤트/타입 스키마 검증 (vitest + zod)
  • [ ] 격리 테스트: 각 리모트 앱이 Host 없이 독립 실행 가능 확인
  • [ ] E2E 통합 테스트: Shell + 리모트 앱 연동 검증 (Playwright)
  • [ ] 리모트 로드 실패 시 에러 폴백 동작 확인

성능/모니터링

  • [ ] 리모트 프리로드 전략 설정 (immediate/idle/hover/viewport)
  • [ ] 리모트 로드 타임아웃 + fallback UI 구현
  • [ ] 에러 바운더리로 리모트 앱 격리 (RemoteErrorBoundary)
  • [ ] Core Web Vitals 모니터링 (LCP < 2.5s, INP < 200ms, CLS < 0.1)
  • [ ] 카나리 배포 에러율 모니터링 대시보드 구축
  • [ ] 번들 사이즈 추이 모니터링 (check-bundle-size.ts)
  • [ ] SSR 호환성 확인 (SafeRemoteApp 래퍼 사용)

참고 자료

주제 링크
Module Federation 2.x https://module-federation.io
MF Runtime API (loadRemote, preloadRemote) https://module-federation.io/guide/runtime/runtime-api
Rsbuild Module Federation Plugin https://rsbuild.rs/guide/advanced/module-federation
Rspack (Rust 번들러) https://rspack.rs
Vite federation plugin https://github.com/originjs/vite-plugin-federation
Native Federation https://www.npmjs.com/package/@softarc/native-federation
Single-SPA 6.x https://single-spa.js.org
React 19 Server Components https://react.dev/reference/rsc/server-components
Next.js App Router + MFE 통합 https://module-federation.io/practice/frameworks/next/nextjs-mf

연관 가이드

번호 가이드 관련 내용
04 아키텍처 설계 패턴 FSD, Clean Architecture
07 테스팅 가이드 단위/통합/E2E 테스트 전략
08 성능 최적화 가이드 Core Web Vitals, 번들 최적화
09 장애 대응 및 관측성 표준 에러 모니터링, 장애 대응
11 CI/CD 파이프라인 표준 빌드/배포 파이프라인
12 CDN 캐시 전략 CDN 배포, 캐시 무효화
22 모노레포 운영 가이드 pnpm workspace, Turborepo

실무 적용 가이드

언제 이 문서를 펼칠까

  • 팀별 독립 배포가 필요하지만 host/remote 계약이 불안정할 때
  • shared dependency 충돌로 런타임 오류가 날 때
  • 한 remote 장애가 전체 shell 장애로 번질 때

적용 순서

  1. MFE가 필요한 이유를 배포 독립성, ownership, runtime isolation 관점에서 확인한다.
  2. host/remote public contract와 버전 정책을 정한다.
  3. shared dependency singleton과 허용 버전을 명시한다.
  4. remote load 실패 fallback과 kill switch를 구현한다.
  5. contract test와 canary health를 배포 gate로 둔다.

함께 두는 파일

  • remote별 manifest, exposed module, contract test, fallback UI를 같은 remote 폴더에 둔다.
  • shared dependency 정책은 host와 remote가 함께 참조하는 위치에 둔다.
  • 공통 UI는 디자인 시스템 package로 분리한다.

흔한 실수

  • 단순 폴더 정리 문제를 MFE로 해결하려 한다.
  • shared dependency 버전을 느슨하게 둔다.
  • remote 실패 시 전체 shell이 죽는다.
  • 독립 rollback 경로 없이 독립 배포를 말한다.

PR 완료 기준

  • [ ] host/remote contract test가 있다.
  • [ ] remote fallback과 kill switch가 있다.
  • [ ] shared dependency report가 있다.
  • [ ] remote 단독 rollback이 가능하다.

추천 항목 실행 우선순위 매핑

  • P1(7일 내) — host/remote contract와 shared dependency 중 하나를 작은 변경 1건에 적용하고 증거(remote manifest)를 남긴다.
  • P2(30일 내) — 마이크로 프론트엔드 기준을 팀 템플릿, 체크리스트, CI 중 한 곳에 고정한다.
  • P3(90일 내) — remote load failure, shared singleton 충돌, independent deploy lead time 추이를 보고 기준을 유지할지 조정할지 결정한다.
  • 완료 기준 — MFE 오너가 증거와 철회 조건을 확인했다는 기록을 남긴다.

추천 항목 실행 체크리스트

  • [ ] 1단계(7일) : host/remote contract와 shared dependency 적용 대상을 1개로 좁힌다.
  • [ ] 2단계(30일) : 증거(remote manifest, contract test, canary deploy log)를 PR, ADR, 회고 중 한 곳에 연결한다.
  • [ ] 3단계(60일) : remote load failure, shared singleton 충돌, independent deploy lead time가 기준 안에 들어왔는지 확인한다.
  • [ ] 문제 대응 : 미달성 사유와 다음 조치, 중단 여부를 같은 기록에 남긴다.

추천 항목 실행 운영 규칙

  • 실행 게이트 : 독립 배포가 사용자 경험과 dependency 경계를 깨지 않는지 확인한다.
  • 승인 체계 : MFE 오너가 영향 범위와 rollback 담당자를 적용 전에 확인한다.
  • 재개 조건 : host/remote contract와 rollback route가 확인되면 remote를 늘린다.
  • 정지 조건 : shared dependency 충돌이나 remote 장애 격리가 안 되면 중단한다.
  • 리스크 점수 : remote 수, shared singleton 수, runtime fallback 범위로 산정한다.
  • 리더 승인자 : 플랫폼 아키텍트가 최종 승인 책임을 맡는다.
  • 승인 역할 : 마이크로 프론트엔드 작성자, 검토자, 운영 확인자를 분리해 기록한다.
  • 재평가 주기 : remote 추가 때마다 dependency matrix를 갱신한다.