Skip to content
Published on

마이크로프론트엔드 아키텍처 실전 가이드: Module Federation, Single-SPA, Web Components 비교

Authors
  • Name
    Twitter

마이크로프론트엔드 아키텍처

1. 마이크로프론트엔드가 필요한 이유

모놀리식 프론트엔드 애플리케이션이 성장하면 예측 가능한 문제들이 발생한다. 빌드 시간이 10분을 넘기고, 하나의 팀이 배포하면 다른 팀의 코드까지 함께 배포되며, 작은 수정 하나에 전체 QA 사이클을 돌려야 한다. Martin Fowler의 "Micro Frontends" 아티클(2019)에서 최초로 체계적으로 정리된 마이크로프론트엔드 아키텍처는, 마이크로서비스의 철학을 프론트엔드에 적용한 것이다.

마이크로프론트엔드의 핵심 원칙은 다음과 같다.

  • 독립적 배포(Independent Deployment): 각 팀이 자체 릴리스 주기를 가진다
  • 기술 독립성(Technology Agnostic): 팀마다 React, Vue, Angular 등 다른 프레임워크를 선택할 수 있다
  • 팀 자율성(Team Autonomy): 도메인 단위로 팀을 구성하고, 프론트엔드부터 백엔드까지 수직 슬라이스로 소유한다
  • 장애 격리(Fault Isolation): 한 마이크로프론트엔드의 장애가 전체 앱을 다운시키지 않는다

하지만 마이크로프론트엔드는 만능 해결책이 아니다. Cam Jackson의 martinfowler.com 기고(2019)에서도 경고하듯이, 불필요한 복잡성을 도입하는 가장 확실한 방법이기도 하다. 도입 전에 "정말 필요한가?"를 먼저 물어야 한다.

2. 세 가지 구현 방식 상세 비교

2.1 Webpack Module Federation

Webpack 5에서 도입된 Module Federation은 현재 가장 널리 사용되는 마이크로프론트엔드 구현 방식이다. Webpack Module Federation 공식 문서에 따르면, 별도로 빌드된 여러 애플리케이션이 런타임에 서로의 모듈을 동적으로 공유할 수 있다.

Host Application (Shell) 설정

// host-app/webpack.config.js
const HtmlWebpackPlugin = require('html-webpack-plugin')
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin')

module.exports = {
  mode: 'production',
  output: {
    publicPath: 'https://host.example.com/',
    uniqueName: 'host_app',
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'host',
      remotes: {
        // 원격 마이크로프론트엔드 선언
        productApp: 'product@https://product.example.com/remoteEntry.js',
        cartApp: 'cart@https://cart.example.com/remoteEntry.js',
        userApp: 'user@https://user.example.com/remoteEntry.js',
      },
      shared: {
        react: {
          singleton: true, // 단일 인스턴스만 허용
          requiredVersion: '^18.0.0',
          eager: true, // 즉시 로드 (호스트에서만)
        },
        'react-dom': {
          singleton: true,
          requiredVersion: '^18.0.0',
          eager: true,
        },
        'react-router-dom': {
          singleton: true,
          requiredVersion: '^6.0.0',
        },
      },
    }),
    new HtmlWebpackPlugin({
      template: './public/index.html',
    }),
  ],
}

Remote Application (Product MFE) 설정

// product-app/webpack.config.js
const ModuleFederationPlugin = require('webpack/lib/container/ModuleFederationPlugin')

module.exports = {
  mode: 'production',
  output: {
    publicPath: 'https://product.example.com/',
    uniqueName: 'product_app',
  },
  plugins: [
    new ModuleFederationPlugin({
      name: 'product',
      filename: 'remoteEntry.js',
      exposes: {
        // 외부에 노출할 모듈
        './ProductList': './src/components/ProductList',
        './ProductDetail': './src/components/ProductDetail',
        './ProductSearch': './src/components/ProductSearch',
      },
      shared: {
        react: {
          singleton: true,
          requiredVersion: '^18.0.0',
        },
        'react-dom': {
          singleton: true,
          requiredVersion: '^18.0.0',
        },
        'react-router-dom': {
          singleton: true,
          requiredVersion: '^6.0.0',
        },
      },
    }),
  ],
}

Host에서 Remote 컴포넌트 사용

// host-app/src/App.jsx
import React, { Suspense, lazy } from 'react'
import { BrowserRouter, Routes, Route } from 'react-router-dom'
import ErrorBoundary from './components/ErrorBoundary'
import Navigation from './components/Navigation'
import LoadingSpinner from './components/LoadingSpinner'

// Remote 컴포넌트를 lazy 로딩
const ProductList = lazy(() => import('productApp/ProductList'))
const ProductDetail = lazy(() => import('productApp/ProductDetail'))
const Cart = lazy(() => import('cartApp/Cart'))
const UserProfile = lazy(() => import('userApp/UserProfile'))

// 장애 격리를 위한 래퍼 컴포넌트
function MicroFrontendWrapper({ children, fallback }) {
  return (
    <ErrorBoundary fallback={fallback || <div>이 섹션을 불러올 수 없습니다.</div>}>
      <Suspense fallback={<LoadingSpinner />}>{children}</Suspense>
    </ErrorBoundary>
  )
}

export default function App() {
  return (
    <BrowserRouter>
      <Navigation />
      <main>
        <Routes>
          <Route
            path="/products"
            element={
              <MicroFrontendWrapper>
                <ProductList />
              </MicroFrontendWrapper>
            }
          />
          <Route
            path="/products/:id"
            element={
              <MicroFrontendWrapper>
                <ProductDetail />
              </MicroFrontendWrapper>
            }
          />
          <Route
            path="/cart"
            element={
              <MicroFrontendWrapper>
                <Cart />
              </MicroFrontendWrapper>
            }
          />
          <Route
            path="/profile"
            element={
              <MicroFrontendWrapper>
                <UserProfile />
              </MicroFrontendWrapper>
            }
          />
        </Routes>
      </main>
    </BrowserRouter>
  )
}

ErrorBoundary 구현

// host-app/src/components/ErrorBoundary.jsx
import React from 'react'

class ErrorBoundary extends React.Component {
  constructor(props) {
    super(props)
    this.state = { hasError: false, error: null }
  }

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

  componentDidCatch(error, errorInfo) {
    // 에러 리포팅 서비스로 전송
    console.error('MFE Error:', error, errorInfo)

    // Sentry, DataDog 등으로 리포팅
    if (window.Sentry) {
      window.Sentry.captureException(error, {
        extra: { componentStack: errorInfo.componentStack },
      })
    }
  }

  render() {
    if (this.state.hasError) {
      return (
        this.props.fallback || (
          <div style={{ padding: '20px', textAlign: 'center' }}>
            <h3>일시적인 오류가 발생했습니다</h3>
            <button onClick={() => this.setState({ hasError: false })}>다시 시도</button>
          </div>
        )
      )
    }
    return this.props.children
  }
}

export default ErrorBoundary

2.2 Single-SPA

Single-SPA 공식 문서(single-spa.js.org)에 따르면, Single-SPA는 여러 프레임워크로 만든 애플리케이션을 하나의 페이지에서 공존시키는 메타 프레임워크다. Module Federation이 모듈 공유에 초점을 맞춘다면, Single-SPA는 애플리케이션 라이프사이클 관리에 초점을 맞춘다.

Root Config 설정

// root-config/src/index.js
import { registerApplication, start } from 'single-spa'

// 네비게이션 앱 (항상 활성)
registerApplication({
  name: '@myorg/navbar',
  app: () => System.import('@myorg/navbar'),
  activeWhen: ['/'],
  customProps: {
    domElement: document.getElementById('navbar'),
  },
})

// 상품 앱 (/products 경로에서 활성)
registerApplication({
  name: '@myorg/products',
  app: () => System.import('@myorg/products'),
  activeWhen: ['/products'],
  customProps: {
    domElement: document.getElementById('main-content'),
  },
})

// 장바구니 앱 (/cart 경로에서 활성)
registerApplication({
  name: '@myorg/cart',
  app: () => System.import('@myorg/cart'),
  activeWhen: ['/cart'],
  customProps: {
    domElement: document.getElementById('main-content'),
  },
})

// 사용자 앱 (/profile 경로에서 활성)
registerApplication({
  name: '@myorg/user',
  app: () => System.import('@myorg/user'),
  activeWhen: ['/profile', '/settings'],
  customProps: {
    domElement: document.getElementById('main-content'),
  },
})

// Single-SPA 시작
start({
  urlRerouteOnly: true, // popstate 이벤트만 라우팅 트리거
})

Import Map 설정

<!-- root-config/public/index.html -->
<!doctype html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>E-Commerce Platform</title>

    <!-- Import Map: 각 MFE의 URL 매핑 -->
    <script type="systemjs-importmap">
      {
        "imports": {
          "@myorg/root-config": "https://cdn.example.com/root-config/main.js",
          "@myorg/navbar": "https://cdn.example.com/navbar/main.js",
          "@myorg/products": "https://cdn.example.com/products/main.js",
          "@myorg/cart": "https://cdn.example.com/cart/main.js",
          "@myorg/user": "https://cdn.example.com/user/main.js",
          "react": "https://cdn.jsdelivr.net/npm/react@18/umd/react.production.min.js",
          "react-dom": "https://cdn.jsdelivr.net/npm/react-dom@18/umd/react-dom.production.min.js"
        }
      }
    </script>
    <script src="https://cdn.jsdelivr.net/npm/systemjs/dist/system.min.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/systemjs/dist/extras/amd.min.js"></script>
  </head>
  <body>
    <div id="navbar"></div>
    <div id="main-content"></div>

    <script>
      System.import('@myorg/root-config')
    </script>
  </body>
</html>

React MFE를 Single-SPA에 등록

// products-app/src/myorg-products.jsx
import React from 'react'
import ReactDOM from 'react-dom'
import singleSpaReact from 'single-spa-react'
import ProductApp from './App'

const lifecycles = singleSpaReact({
  React,
  ReactDOM,
  rootComponent: ProductApp,
  // 마운트할 DOM 요소 결정
  domElementGetter: (props) => {
    return props.domElement || document.getElementById('main-content')
  },
  errorBoundary(err, info, props) {
    // 에러 발생 시 폴백 UI
    return (
      <div className="mfe-error">
        <h3>상품 서비스를 불러올 수 없습니다</h3>
        <p>잠시 후 다시 시도해 주세요.</p>
        <button onClick={() => window.location.reload()}>새로고침</button>
      </div>
    )
  },
})

// Single-SPA 라이프사이클 함수 export
export const bootstrap = lifecycles.bootstrap
export const mount = lifecycles.mount
export const unmount = lifecycles.unmount

Vue MFE를 Single-SPA에 등록 (프레임워크 혼용 예시)

// cart-app/src/myorg-cart.js (Vue 3 기반)
import { createApp, h } from 'vue'
import singleSpaVue from 'single-spa-vue'
import CartApp from './App.vue'

const vueLifecycles = singleSpaVue({
  createApp,
  appOptions: {
    render() {
      return h(CartApp, {
        // Single-SPA에서 전달받는 props
        user: this.user,
        cartItems: this.cartItems,
      })
    },
  },
  handleInstance(app) {
    // Vue 플러그인 등록
    app.use(createPinia())
    app.use(i18n)
  },
})

export const bootstrap = vueLifecycles.bootstrap
export const mount = vueLifecycles.mount
export const unmount = vueLifecycles.unmount

2.3 Web Components 기반

Web Components는 브라우저 네이티브 기술(Custom Elements, Shadow DOM, HTML Templates)을 사용한 프레임워크 독립적인 접근 방식이다. Luca Mezzalira의 "Building Micro-Frontends" 저서에서도 프레임워크 종속성을 최소화하는 방법으로 Web Components를 권장한다.

Custom Element으로 MFE 래핑

// product-widget/src/ProductWidget.js
class ProductWidget extends HTMLElement {
  constructor() {
    super()
    // Shadow DOM으로 스타일 격리
    this.shadow = this.attachShadow({ mode: 'open' })
    this._products = []
    this._loading = true
  }

  // 관찰할 속성 목록
  static get observedAttributes() {
    return ['category', 'limit', 'theme']
  }

  // 속성 변경 시 호출
  attributeChangedCallback(name, oldVal, newVal) {
    if (oldVal !== newVal) {
      this[`_${name}`] = newVal
      this.render()
    }
  }

  // DOM에 연결될 때
  async connectedCallback() {
    this.render() // 초기 로딩 UI
    await this.loadProducts()
    this._loading = false
    this.render() // 데이터 로드 후 재렌더링
  }

  // DOM에서 제거될 때 (메모리 누수 방지)
  disconnectedCallback() {
    // 이벤트 리스너, 타이머 정리
    if (this._refreshInterval) {
      clearInterval(this._refreshInterval)
    }
  }

  async loadProducts() {
    try {
      const category = this.getAttribute('category') || 'all'
      const limit = this.getAttribute('limit') || 10
      const response = await fetch(`/api/products?category=${category}&limit=${limit}`)
      if (!response.ok) throw new Error(`HTTP ${response.status}`)
      this._products = await response.json()
    } catch (error) {
      this._error = error.message
      console.error('ProductWidget load failed:', error)
    }
  }

  render() {
    const theme = this.getAttribute('theme') || 'light'

    this.shadow.innerHTML = `
      <style>
        :host {
          display: block;
          font-family: -apple-system, BlinkMacSystemFont, sans-serif;
        }
        .product-grid {
          display: grid;
          grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
          gap: 16px;
          padding: 16px;
        }
        .product-card {
          border: 1px solid ${theme === 'dark' ? '#333' : '#e0e0e0'};
          border-radius: 8px;
          padding: 16px;
          background: ${theme === 'dark' ? '#1a1a1a' : '#ffffff'};
          color: ${theme === 'dark' ? '#ffffff' : '#333333'};
          transition: box-shadow 0.2s;
        }
        .product-card:hover {
          box-shadow: 0 4px 12px rgba(0,0,0,0.15);
        }
        .loading {
          text-align: center;
          padding: 40px;
          color: #888;
        }
        .error {
          color: #e74c3c;
          padding: 20px;
          text-align: center;
        }
        .price {
          font-size: 1.2em;
          font-weight: bold;
          color: ${theme === 'dark' ? '#4fc3f7' : '#1976d2'};
        }
      </style>

      ${this._loading ? '<div class="loading">상품을 불러오는 중...</div>' : ''}
      ${this._error ? `<div class="error">오류: ${this._error}</div>` : ''}
      ${
        !this._loading && !this._error
          ? `
        <div class="product-grid">
          ${this._products
            .map(
              (p) => `
            <div class="product-card" data-id="${p.id}">
              <h3>${p.name}</h3>
              <p>${p.description}</p>
              <div class="price">${p.price.toLocaleString()}원</div>
              <button class="add-to-cart-btn">장바구니 추가</button>
            </div>
          `
            )
            .join('')}
        </div>
      `
          : ''
      }
    `

    // 이벤트 위임으로 장바구니 버튼 처리
    this.shadow.querySelectorAll('.add-to-cart-btn').forEach((btn, index) => {
      btn.addEventListener('click', () => {
        const product = this._products[index]
        // Custom Event로 상위에 통신
        this.dispatchEvent(
          new CustomEvent('add-to-cart', {
            detail: { product },
            bubbles: true,
            composed: true, // Shadow DOM 경계를 넘어 전파
          })
        )
      })
    })
  }
}

// Custom Element 등록
customElements.define('product-widget', ProductWidget)

Shell에서 Web Components MFE 사용

<!-- shell-app/index.html -->
<!doctype html>
<html lang="ko">
  <head>
    <meta charset="UTF-8" />
    <title>E-Commerce</title>
    <!-- 각 MFE의 Custom Element 스크립트 로드 -->
    <script src="https://cdn.example.com/product-widget/bundle.js" defer></script>
    <script src="https://cdn.example.com/cart-widget/bundle.js" defer></script>
    <script src="https://cdn.example.com/user-widget/bundle.js" defer></script>
  </head>
  <body>
    <header>
      <nav-widget></nav-widget>
    </header>

    <main>
      <!-- 속성으로 설정 전달 -->
      <product-widget category="electronics" limit="12" theme="light"></product-widget>

      <cart-widget user-id="12345"></cart-widget>
    </main>

    <script>
      // Custom Event로 MFE 간 통신
      document.addEventListener('add-to-cart', (event) => {
        const cartWidget = document.querySelector('cart-widget')
        cartWidget.addItem(event.detail.product)
      })

      // 테마 변경 시 모든 MFE에 전파
      function setTheme(theme) {
        document.querySelectorAll('[theme]').forEach((el) => {
          el.setAttribute('theme', theme)
        })
      }
    </script>
  </body>
</html>

React 앱을 Web Component로 래핑

// React 앱을 Custom Element로 변환
import React from 'react'
import { createRoot } from 'react-dom/client'
import UserProfileApp from './UserProfileApp'

class UserProfileElement extends HTMLElement {
  constructor() {
    super()
    this.shadow = this.attachShadow({ mode: 'open' })
    this.mountPoint = document.createElement('div')
    this.shadow.appendChild(this.mountPoint)
  }

  connectedCallback() {
    const userId = this.getAttribute('user-id')
    this.root = createRoot(this.mountPoint)
    this.root.render(
      <React.StrictMode>
        <UserProfileApp userId={userId} />
      </React.StrictMode>
    )
  }

  disconnectedCallback() {
    if (this.root) {
      this.root.unmount()
    }
  }

  static get observedAttributes() {
    return ['user-id']
  }

  attributeChangedCallback(name, oldVal, newVal) {
    if (oldVal !== newVal && this.root) {
      this.root.render(
        <React.StrictMode>
          <UserProfileApp userId={newVal} />
        </React.StrictMode>
      )
    }
  }
}

customElements.define('user-profile', UserProfileElement)

3. 세 가지 접근 방식 종합 비교

항목Module FederationSingle-SPAWeb Components
구현 복잡도중간높음낮음~중간
프레임워크 독립성Webpack 의존프레임워크 무관완전 독립
공유 의존성내장 지원 (shared)Import Map으로 관리수동 관리
CSS 격리별도 전략 필요별도 전략 필요Shadow DOM 내장
라우팅 통합호스트 라우터 사용자체 라우팅 엔진수동 구현
번들 크기최적화 가능 (tree-shaking)오케스트레이터 오버헤드최소 (네이티브)
러닝 커브Webpack 지식 필요Single-SPA API 학습웹 표준 지식
브라우저 지원Webpack 런타임SystemJS 폴리필모던 브라우저 네이티브
독립 배포지원지원지원
디버깅 난이도중간높음 (라이프사이클 추적)낮음
생태계 성숙도높음높음중간
SSR 지원부분 지원제한적제한적
적합한 규모중~대규모대규모/이기종소~중규모/위젯

선택 가이드

Module Federation을 선택해야 할 때:

  • 모든 팀이 이미 Webpack을 사용하고 있을 때
  • React 단일 프레임워크로 통일된 환경일 때
  • 컴포넌트 수준의 세밀한 공유가 필요할 때

Single-SPA를 선택해야 할 때:

  • React, Vue, Angular 등 이기종 프레임워크가 공존해야 할 때
  • 레거시 앱을 점진적으로 마이그레이션할 때
  • 페이지 단위의 마이크로프론트엔드가 필요할 때

Web Components를 선택해야 할 때:

  • 프레임워크 종속성을 최소화하고 싶을 때
  • 위젯 형태의 독립적 컴포넌트를 배포할 때
  • 표준 기술만으로 장기 유지보수를 원할 때

4. 공유 상태 관리 전략

마이크로프론트엔드에서 가장 까다로운 문제 중 하나가 상태 공유다. 각 MFE가 독립적이어야 하지만, 인증 정보, 장바구니, 사용자 설정 등은 공유되어야 한다.

4.1 Custom Event 기반 통신

가장 단순하고 프레임워크에 독립적인 방식이다.

// shared-lib/src/eventBus.js
// 타입 안전한 이벤트 버스

const EVENT_TYPES = {
  USER_LOGGED_IN: 'mfe:user:logged-in',
  USER_LOGGED_OUT: 'mfe:user:logged-out',
  CART_UPDATED: 'mfe:cart:updated',
  CART_ITEM_ADDED: 'mfe:cart:item-added',
  THEME_CHANGED: 'mfe:theme:changed',
  LANGUAGE_CHANGED: 'mfe:language:changed',
  NOTIFICATION: 'mfe:notification',
}

class MFEEventBus {
  constructor() {
    this._listeners = new Map()
  }

  emit(eventType, payload) {
    if (!EVENT_TYPES[eventType] && !Object.values(EVENT_TYPES).includes(eventType)) {
      console.warn(`Unknown event type: ${eventType}`)
    }
    const event = new CustomEvent(eventType, {
      detail: { payload, timestamp: Date.now(), source: 'mfe-event-bus' },
      bubbles: true,
    })
    window.dispatchEvent(event)
  }

  on(eventType, callback) {
    const wrappedCallback = (event) => callback(event.detail.payload)
    window.addEventListener(eventType, wrappedCallback)

    // 정리를 위해 리스너 추적
    if (!this._listeners.has(eventType)) {
      this._listeners.set(eventType, [])
    }
    this._listeners.get(eventType).push({ original: callback, wrapped: wrappedCallback })

    // 구독 해제 함수 반환
    return () => {
      window.removeEventListener(eventType, wrappedCallback)
      const listeners = this._listeners.get(eventType)
      const index = listeners.findIndex((l) => l.original === callback)
      if (index > -1) listeners.splice(index, 1)
    }
  }

  // MFE unmount 시 해당 MFE의 모든 리스너 정리
  removeAllListeners(eventType) {
    if (this._listeners.has(eventType)) {
      this._listeners.get(eventType).forEach(({ wrapped }) => {
        window.removeEventListener(eventType, wrapped)
      })
      this._listeners.delete(eventType)
    }
  }
}

// 싱글턴으로 내보내기
export const eventBus = new MFEEventBus()
export { EVENT_TYPES }

사용 예시

// product-app에서 장바구니에 상품 추가
import { eventBus, EVENT_TYPES } from '@myorg/shared-lib'

function ProductCard({ product }) {
  const handleAddToCart = () => {
    eventBus.emit(EVENT_TYPES.CART_ITEM_ADDED, {
      productId: product.id,
      name: product.name,
      price: product.price,
      quantity: 1,
    })
  }

  return (
    <div className="product-card">
      <h3>{product.name}</h3>
      <p>{product.price.toLocaleString()}</p>
      <button onClick={handleAddToCart}>장바구니 추가</button>
    </div>
  )
}

// cart-app에서 이벤트 수신
import { useEffect, useState } from 'react'
import { eventBus, EVENT_TYPES } from '@myorg/shared-lib'

function CartWidget() {
  const [items, setItems] = useState([])

  useEffect(() => {
    const unsubscribe = eventBus.on(EVENT_TYPES.CART_ITEM_ADDED, (payload) => {
      setItems((prev) => {
        const existing = prev.find((i) => i.productId === payload.productId)
        if (existing) {
          return prev.map((i) =>
            i.productId === payload.productId
              ? { ...i, quantity: i.quantity + payload.quantity }
              : i
          )
        }
        return [...prev, payload]
      })
    })

    return () => unsubscribe() // cleanup
  }, [])

  return (
    <div className="cart-widget">
      <span>장바구니: {items.length}</span>
    </div>
  )
}

4.2 공유 스토어 패턴

더 복잡한 상태 공유가 필요한 경우, 전역 스토어를 사용할 수 있다.

// shared-lib/src/globalStore.js
// 프레임워크 독립적인 상태 관리

function createGlobalStore(initialState) {
  let state = { ...initialState }
  const subscribers = new Set()

  return {
    getState() {
      return { ...state } // 불변성을 위해 복사본 반환
    },

    setState(updater) {
      const newState = typeof updater === 'function' ? updater(state) : { ...state, ...updater }

      // 변경이 없으면 알림 스킵
      if (JSON.stringify(state) === JSON.stringify(newState)) return

      state = newState
      subscribers.forEach((callback) => callback(state))
    },

    subscribe(callback) {
      subscribers.add(callback)
      // 구독 즉시 현재 상태 전달
      callback(state)
      // 구독 해제 함수 반환
      return () => subscribers.delete(callback)
    },
  }
}

// 전역 공유 스토어 인스턴스
export const authStore = createGlobalStore({
  isAuthenticated: false,
  user: null,
  token: null,
})

export const cartStore = createGlobalStore({
  items: [],
  totalAmount: 0,
  itemCount: 0,
})

export const uiStore = createGlobalStore({
  theme: 'light',
  language: 'ko',
  sidebarOpen: false,
})

React Hook으로 래핑

// shared-lib/src/hooks/useGlobalStore.js
import { useSyncExternalStore, useCallback } from 'react'

export function useGlobalStore(store) {
  const state = useSyncExternalStore(store.subscribe, store.getState)
  return [state, store.setState]
}

// 사용 예시
import { useGlobalStore } from '@myorg/shared-lib'
import { authStore, cartStore } from '@myorg/shared-lib'

function UserGreeting() {
  const [auth] = useGlobalStore(authStore)

  if (!auth.isAuthenticated) {
    return <button>로그인</button>
  }
  return <span>안녕하세요, {auth.user.name}</span>
}

function CartBadge() {
  const [cart] = useGlobalStore(cartStore)
  return <span className="badge">{cart.itemCount}</span>
}

5. 라우팅과 네비게이션 통합

마이크로프론트엔드에서 라우팅은 두 가지 수준으로 나뉜다. 셸 레벨 라우팅(어떤 MFE를 활성화할지)과 MFE 내부 라우팅(MFE 안에서의 페이지 전환)이다.

// host-app/src/routing/ShellRouter.jsx
import React, { Suspense, lazy } from 'react'
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'

// MFE 로더 (동적 원격 로딩)
function loadMFE(remoteName, modulePath) {
  return lazy(() => {
    return new Promise((resolve, reject) => {
      const remoteUrl = window.__MFE_MANIFEST__?.[remoteName]
      if (!remoteUrl) {
        reject(new Error(`MFE not found: ${remoteName}`))
        return
      }

      const script = document.createElement('script')
      script.src = remoteUrl
      script.onload = () => {
        const container = window[remoteName]
        container.init(__webpack_share_scopes__.default)
        const factory = container.get(modulePath)
        factory.then((module) => resolve(module))
      }
      script.onerror = () => reject(new Error(`Failed to load MFE: ${remoteName}`))
      document.head.appendChild(script)
    })
  })
}

// 셸 레벨 라우팅: URL 패턴에 따라 MFE 전환
export default function ShellRouter() {
  return (
    <BrowserRouter>
      <Routes>
        {/* /products/** 는 Product MFE가 처리 */}
        <Route
          path="/products/*"
          element={
            <Suspense fallback={<div>Loading...</div>}>
              <ProductMFE />
            </Suspense>
          }
        />

        {/* /cart/** 는 Cart MFE가 처리 */}
        <Route
          path="/cart/*"
          element={
            <Suspense fallback={<div>Loading...</div>}>
              <CartMFE />
            </Suspense>
          }
        />

        {/* 기본 리다이렉트 */}
        <Route path="/" element={<Navigate to="/products" replace />} />
      </Routes>
    </BrowserRouter>
  )
}

6. CSS 격리 전략

CSS 충돌은 마이크로프론트엔드에서 가장 흔한 문제 중 하나다. 팀 A의 .button 클래스가 팀 B의 .button을 덮어쓰는 상황은 프로덕션에서 자주 발생한다.

6.1 세 가지 CSS 격리 방식 비교

방식격리 수준성능호환성복잡도
Shadow DOM완전 격리좋음모던 브라우저중간
CSS Modules클래스명 해싱매우 좋음모든 브라우저낮음
CSS-in-JS런타임 생성보통모든 브라우저중간
BEM + 네임스페이스규약 기반매우 좋음모든 브라우저낮음

6.2 CSS Modules 격리 (권장)

/* product-app/src/ProductCard.module.css */
.card {
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  padding: 16px;
  /* 빌드 시 .product_card__xyz123 같은 고유 클래스로 변환 */
}

.title {
  font-size: 1.2rem;
  font-weight: 600;
}

.price {
  color: #1976d2;
  font-weight: bold;
}
// product-app/src/ProductCard.jsx
import styles from './ProductCard.module.css'

export default function ProductCard({ product }) {
  return (
    <div className={styles.card}>
      <h3 className={styles.title}>{product.name}</h3>
      <span className={styles.price}>{product.price.toLocaleString()}</span>
    </div>
  )
}

6.3 네임스페이스 규약

/* product-app/src/styles/product.css */
/* 모든 클래스에 MFE 접두사 사용 */
.mfe-product__card {
  border: 1px solid #e0e0e0;
  border-radius: 8px;
  padding: 16px;
}

.mfe-product__title {
  font-size: 1.2rem;
}

/* cart-app/src/styles/cart.css */
.mfe-cart__card {
  border: 1px solid #f0f0f0;
  padding: 12px;
}

.mfe-cart__title {
  font-size: 1rem;
}

6.4 CSS 리셋 패턴

/* 각 MFE의 루트 요소에 적용하는 스코프드 리셋 */
.mfe-product-root {
  /* 전역 스타일의 영향을 받지 않도록 리셋 */
  all: initial;
  display: block;
  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  font-size: 16px;
  line-height: 1.5;
  color: #333;
}

/* 리셋 후 필요한 스타일 재정의 */
.mfe-product-root *,
.mfe-product-root *::before,
.mfe-product-root *::after {
  box-sizing: border-box;
}

7. 배포 전략

7.1 독립 배포 파이프라인

마이크로프론트엔드의 핵심 가치는 독립 배포다. 각 팀이 자체 CI/CD 파이프라인을 가진다.

# product-app/.github/workflows/deploy.yml
name: Deploy Product MFE

on:
  push:
    branches: [main]

jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npm test
      - run: npm run lint

  build-and-deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
      - run: npm ci
      - run: npm run build

      # S3에 정적 파일 업로드
      - name: Deploy to S3
        run: |
          aws s3 sync dist/ s3://mfe-assets/product-app/v$GITHUB_SHA/ \
            --cache-control "public, max-age=31536000, immutable"

      # remoteEntry.js는 캐시를 짧게 설정
          aws s3 cp dist/remoteEntry.js \
            s3://mfe-assets/product-app/latest/remoteEntry.js \
            --cache-control "public, max-age=60"

      # CDN 캐시 무효화
      - name: Invalidate CloudFront
        run: |
          aws cloudfront create-invalidation \
            --distribution-id $CDN_DISTRIBUTION_ID \
            --paths "/product-app/latest/*"

      # 배포 매니페스트 업데이트
      - name: Update manifest
        run: |
          curl -X PUT https://api.example.com/mfe-manifest \
            -H "Authorization: Bearer $MFE_DEPLOY_TOKEN" \
            -d "{\"product\": \"https://cdn.example.com/product-app/v$GITHUB_SHA/remoteEntry.js\"}"

7.2 버전 관리와 롤백

// mfe-manifest-service/src/manifest.js
// MFE 매니페스트 관리 서비스

const manifestStore = {
  current: {
    product: 'https://cdn.example.com/product-app/v1.2.3/remoteEntry.js',
    cart: 'https://cdn.example.com/cart-app/v2.1.0/remoteEntry.js',
    user: 'https://cdn.example.com/user-app/v1.5.2/remoteEntry.js',
  },
  history: {
    product: [
      { version: 'v1.2.3', url: '...', deployedAt: '2026-03-15T10:00:00Z' },
      { version: 'v1.2.2', url: '...', deployedAt: '2026-03-14T15:00:00Z' },
      { version: 'v1.2.1', url: '...', deployedAt: '2026-03-13T09:00:00Z' },
    ],
  },
}

// 롤백 기능
async function rollback(mfeName, targetVersion) {
  const history = manifestStore.history[mfeName]
  const target = history.find((h) => h.version === targetVersion)

  if (!target) {
    throw new Error(`Version ${targetVersion} not found for ${mfeName}`)
  }

  manifestStore.current[mfeName] = target.url
  console.log(`Rolled back ${mfeName} to ${targetVersion}`)

  // 헬스 체크
  const response = await fetch(target.url)
  if (!response.ok) {
    throw new Error(`Health check failed for ${target.url}`)
  }

  return { success: true, version: targetVersion }
}

7.3 카나리 배포

// edge-function/src/mfeRouter.js
// 카나리 배포를 위한 엣지 함수

export default function handler(request) {
  const userId = request.cookies.get('userId')?.value

  // 사용자 ID 해시를 기반으로 카나리 대상 결정 (10% 트래픽)
  const isCanary = userId && hashCode(userId) % 100 < 10

  const manifest = isCanary
    ? {
        product: 'https://cdn.example.com/product-app/v1.3.0-canary/remoteEntry.js',
        cart: 'https://cdn.example.com/cart-app/v2.1.0/remoteEntry.js',
        user: 'https://cdn.example.com/user-app/v1.5.2/remoteEntry.js',
      }
    : {
        product: 'https://cdn.example.com/product-app/v1.2.3/remoteEntry.js',
        cart: 'https://cdn.example.com/cart-app/v2.1.0/remoteEntry.js',
        user: 'https://cdn.example.com/user-app/v1.5.2/remoteEntry.js',
      }

  return new Response(JSON.stringify(manifest), {
    headers: {
      'Content-Type': 'application/json',
      'X-Canary': isCanary ? 'true' : 'false',
    },
  })
}

function hashCode(str) {
  let hash = 0
  for (let i = 0; i < str.length; i++) {
    const char = str.charCodeAt(i)
    hash = (hash << 5) - hash + char
    hash = hash & hash
  }
  return Math.abs(hash)
}

8. 성능 최적화

8.1 공유 의존성 최적화

마이크로프론트엔드에서 가장 큰 성능 문제는 중복 번들이다. React가 각 MFE마다 중복 로드되면 수백 KB가 낭비된다.

// webpack.config.js - 공유 의존성 최적화
new ModuleFederationPlugin({
  name: 'host',
  shared: {
    // singleton: 하나의 인스턴스만 로드
    react: {
      singleton: true,
      requiredVersion: '^18.0.0',
      eager: true,
      strictVersion: false, // 마이너 버전 차이 허용
    },
    'react-dom': {
      singleton: true,
      requiredVersion: '^18.0.0',
      eager: true,
    },
    // 버전 범위로 호환성 보장
    '@tanstack/react-query': {
      singleton: true,
      requiredVersion: '^5.0.0',
    },
    // 공유하지 않을 것: 각 MFE 고유의 라이브러리
    // lodash: 공유하면 전체가 로드됨 -> 각 MFE에서 lodash-es 사용 권장
  },
})

8.2 레이지 로딩과 프리로딩

// host-app/src/MFELoader.jsx
import React, { Suspense, lazy, useEffect } from 'react'

// 레이지 로딩: 라우트 진입 시점에 MFE 로드
const ProductApp = lazy(() => import('productApp/ProductList'))
const CartApp = lazy(() => import('cartApp/Cart'))

// 프리로딩: 사용자가 이동할 가능성이 높은 MFE를 미리 로드
function preloadMFE(mfeName) {
  switch (mfeName) {
    case 'product':
      import('productApp/ProductList')
      break
    case 'cart':
      import('cartApp/Cart')
      break
  }
}

export function Navigation() {
  return (
    <nav>
      {/* hover 시 프리로딩 시작 */}
      <a href="/products" onMouseEnter={() => preloadMFE('product')}>
        상품
      </a>
      <a href="/cart" onMouseEnter={() => preloadMFE('cart')}>
        장바구니
      </a>
    </nav>
  )
}

// Intersection Observer로 뷰포트 진입 시 프리로드
export function VisibilityPreloader({ mfeName, children }) {
  const ref = React.useRef(null)

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) {
          preloadMFE(mfeName)
          observer.disconnect()
        }
      },
      { rootMargin: '200px' } // 200px 전에 미리 로드
    )

    if (ref.current) observer.observe(ref.current)
    return () => observer.disconnect()
  }, [mfeName])

  return <div ref={ref}>{children}</div>
}

8.3 성능 모니터링

// shared-lib/src/performanceMonitor.js
// MFE별 성능 메트릭 수집

class MFEPerformanceMonitor {
  constructor() {
    this.metrics = new Map()
  }

  // MFE 로드 시간 측정
  measureLoad(mfeName) {
    const startMark = `mfe-load-start-${mfeName}`
    performance.mark(startMark)

    return {
      end: () => {
        const endMark = `mfe-load-end-${mfeName}`
        performance.mark(endMark)
        const measure = performance.measure(`mfe-load-${mfeName}`, startMark, endMark)

        const loadTime = measure.duration
        this.recordMetric(mfeName, 'loadTime', loadTime)

        // 느린 로드 경고 (2초 이상)
        if (loadTime > 2000) {
          console.warn(`[Performance] ${mfeName} load time: ${loadTime.toFixed(0)}ms (slow)`)
        }

        return loadTime
      },
    }
  }

  // Web Vitals 수집
  measureWebVitals(mfeName) {
    // LCP (Largest Contentful Paint)
    new PerformanceObserver((entryList) => {
      const entries = entryList.getEntries()
      const lastEntry = entries[entries.length - 1]
      this.recordMetric(mfeName, 'lcp', lastEntry.startTime)
    }).observe({ type: 'largest-contentful-paint', buffered: true })

    // FID (First Input Delay)
    new PerformanceObserver((entryList) => {
      const entries = entryList.getEntries()
      entries.forEach((entry) => {
        this.recordMetric(mfeName, 'fid', entry.processingStart - entry.startTime)
      })
    }).observe({ type: 'first-input', buffered: true })

    // CLS (Cumulative Layout Shift)
    let clsValue = 0
    new PerformanceObserver((entryList) => {
      entryList.getEntries().forEach((entry) => {
        if (!entry.hadRecentInput) {
          clsValue += entry.value
          this.recordMetric(mfeName, 'cls', clsValue)
        }
      })
    }).observe({ type: 'layout-shift', buffered: true })
  }

  recordMetric(mfeName, metricName, value) {
    if (!this.metrics.has(mfeName)) {
      this.metrics.set(mfeName, {})
    }
    this.metrics.get(mfeName)[metricName] = value

    // 메트릭 서버로 전송
    navigator.sendBeacon(
      '/api/metrics',
      JSON.stringify({
        mfe: mfeName,
        metric: metricName,
        value,
        timestamp: Date.now(),
      })
    )
  }

  getReport() {
    const report = {}
    this.metrics.forEach((metrics, name) => {
      report[name] = { ...metrics }
    })
    return report
  }
}

export const perfMonitor = new MFEPerformanceMonitor()

9. 실패 사례와 교훈

9.1 의존성 버전 충돌

증상: 팀 A가 React 18.2를 사용하고, 팀 B가 React 18.3을 사용할 때, singleton 설정에서 예기치 않은 동작이 발생한다.

// 문제 상황: 버전 불일치로 인한 런타임 에러
// host: react@18.2.0
// remote: react@18.3.1 (새로운 hook 사용)

// 해결 1: strictVersion 비활성화 + 호환 범위 설정
shared: {
  react: {
    singleton: true,
    requiredVersion: '^18.2.0', // 18.x.x 모두 허용
    strictVersion: false,       // 버전 불일치 시 경고만 출력
  },
}

// 해결 2: 패키지 매니저로 버전 통일 강제
// 공유 패키지 버전을 모노레포의 루트 package.json에서 관리

// 해결 3: 버전 호환성 테스트 자동화
// ci/check-shared-versions.js
const fs = require('fs');
const path = require('path');

const mfeDirectories = ['product-app', 'cart-app', 'user-app'];
const sharedPackages = ['react', 'react-dom', 'react-router-dom'];

function checkVersionCompatibility() {
  const versions = {};

  mfeDirectories.forEach(dir => {
    const pkg = JSON.parse(
      fs.readFileSync(path.join(dir, 'package.json'), 'utf-8')
    );

    sharedPackages.forEach(name => {
      if (!versions[name]) versions[name] = {};
      versions[name][dir] = pkg.dependencies[name] || pkg.devDependencies[name];
    });
  });

  let hasConflict = false;
  Object.entries(versions).forEach(([pkg, dirVersions]) => {
    const uniqueVersions = [...new Set(Object.values(dirVersions))];
    if (uniqueVersions.length > 1) {
      console.error(`Version conflict for ${pkg}:`);
      Object.entries(dirVersions).forEach(([dir, ver]) => {
        console.error(`  ${dir}: ${ver}`);
      });
      hasConflict = true;
    }
  });

  if (hasConflict) process.exit(1);
  console.log('All shared package versions are compatible');
}

checkVersionCompatibility();

9.2 전역 상태 오염

증상: MFE A가 window.store에 상태를 저장하고, MFE B도 같은 키를 사용하여 데이터가 덮어써짐

// 잘못된 접근: 전역 네임스페이스 오염
// product-app
window.store = { products: [...] };  // 위험!

// cart-app (나중에 로드)
window.store = { cartItems: [...] };  // product 데이터 소실!

// 올바른 접근: 네임스페이스 격리
// product-app
window.__MFE_PRODUCT__ = window.__MFE_PRODUCT__ || {};
window.__MFE_PRODUCT__.store = { products: [...] };

// cart-app
window.__MFE_CART__ = window.__MFE_CART__ || {};
window.__MFE_CART__.store = { cartItems: [...] };

// 더 나은 접근: MFE 간 공유가 필요한 상태만 명시적 API로 관리
// shared-lib/src/sharedState.js 참조 (섹션 4.2)

9.3 메모리 누수

증상: MFE가 마운트/언마운트를 반복하면서 이벤트 리스너, 타이머, 구독이 정리되지 않아 메모리가 계속 증가

// 잘못된 패턴: cleanup 없는 이벤트 등록
function ProductUpdater() {
  useEffect(() => {
    // 문제: unmount 시 리스너가 정리되지 않음
    window.addEventListener('storage', handleStorageChange)
    const interval = setInterval(fetchLatestProducts, 30000)
    const ws = new WebSocket('wss://api.example.com/products')

    // cleanup 함수가 없음!
  }, [])
}

// 올바른 패턴: 모든 사이드 이펙트 정리
function ProductUpdater() {
  useEffect(() => {
    const handleStorage = (e) => {
      /* ... */
    }
    window.addEventListener('storage', handleStorage)

    const interval = setInterval(fetchLatestProducts, 30000)

    const ws = new WebSocket('wss://api.example.com/products')
    ws.onmessage = handleWSMessage

    // cleanup: 모든 리소스 정리
    return () => {
      window.removeEventListener('storage', handleStorage)
      clearInterval(interval)
      ws.close()
    }
  }, [])
}

// Single-SPA에서의 cleanup
export function unmount(props) {
  // React 트리 언마운트
  const root = roots.get(props.domElement)
  if (root) {
    root.unmount()
    roots.delete(props.domElement)
  }

  // 전역 이벤트 리스너 정리
  eventBus.removeAllListeners('mfe:product:*')

  // 캐시 정리
  queryClient.clear()

  return Promise.resolve()
}

9.4 배포 순서 의존성

증상: Host가 먼저 배포되어 새로운 Remote API를 기대하지만, Remote는 아직 이전 버전

// 방어적 Remote 로딩 패턴
async function loadRemoteModule(remoteName, modulePath) {
  try {
    const module = await import(`${remoteName}/${modulePath}`)
    return module
  } catch (error) {
    console.error(`Failed to load ${remoteName}/${modulePath}:`, error)

    // 폴백 1: 이전 버전의 번들 시도
    try {
      const fallbackUrl = await getFallbackUrl(remoteName)
      await loadScript(fallbackUrl)
      const module = await import(`${remoteName}/${modulePath}`)
      return module
    } catch (fallbackError) {
      // 폴백 2: 로컬 대체 컴포넌트 반환
      console.error('Fallback also failed:', fallbackError)
      return { default: () => <div>서비스를 일시적으로 사용할 수 없습니다</div> }
    }
  }
}

// 버전 호환성 검사
async function checkRemoteCompatibility(remoteName) {
  try {
    const response = await fetch(`https://cdn.example.com/${remoteName}/manifest.json`)
    const manifest = await response.json()

    const hostVersion = process.env.MFE_PROTOCOL_VERSION
    if (manifest.protocolVersion !== hostVersion) {
      console.warn(
        `Protocol version mismatch: host=${hostVersion}, ` + `remote=${manifest.protocolVersion}`
      )
      return false
    }
    return true
  } catch {
    return false
  }
}

10. 운영 체크리스트

10.1 도입 전 체크리스트

  • 팀이 3개 이상이고, 독립적인 릴리스 주기가 필요한가?
  • 모놀리스 빌드 시간이 10분 이상이어서 개발 생산성이 떨어지는가?
  • 각 팀이 프론트엔드부터 백엔드까지 수직 슬라이스로 소유할 수 있는가?
  • 공유 디자인 시스템/컴포넌트 라이브러리가 준비되어 있는가?
  • CI/CD 파이프라인을 팀별로 분리할 인프라가 있는가?
  • 통합 테스트(E2E) 전략이 수립되어 있는가?

10.2 운영 모니터링 항목

  • 각 MFE의 번들 사이즈 추적 (증가 경고 알림)
  • MFE별 로드 시간 대시보드 (P95, P99)
  • 에러율 모니터링 (MFE별 JavaScript 에러)
  • 공유 의존성 버전 일관성 검사 (CI에서 자동화)
  • Core Web Vitals (LCP, FID, CLS) MFE별 분리 측정
  • 메모리 사용량 추적 (마운트/언마운트 사이클)

10.3 트러블슈팅 가이드

// 디버깅 유틸리티
window.__MFE_DEBUG__ = {
  // 현재 로드된 모든 MFE 확인
  listLoadedMFEs() {
    const entries = performance
      .getEntriesByType('resource')
      .filter((e) => e.name.includes('remoteEntry'))
      .map((e) => ({
        name: e.name,
        duration: `${e.duration.toFixed(0)}ms`,
        size: `${(e.transferSize / 1024).toFixed(1)}KB`,
      }))
    console.table(entries)
  },

  // 공유 모듈 상태 확인
  inspectSharedModules() {
    if (typeof __webpack_share_scopes__ !== 'undefined') {
      const shared = __webpack_share_scopes__.default
      Object.entries(shared).forEach(([name, versions]) => {
        console.log(`${name}:`, Object.keys(versions))
      })
    }
  },

  // MFE 강제 리로드
  reloadMFE(name) {
    const scripts = document.querySelectorAll(`script[src*="${name}"]`)
    scripts.forEach((s) => s.remove())
    delete window[name]
    console.log(`${name} 캐시 제거 완료. 페이지를 새로고침하세요.`)
  },
}

11. 언제 마이크로프론트엔드를 쓰지 말아야 하는가

Martin Fowler의 아티클과 Luca Mezzalira의 저서 모두 강조하듯이, 마이크로프론트엔드는 조직 문제를 해결하는 아키텍처 패턴이지, 기술 문제를 해결하는 패턴이 아니다.

쓰지 말아야 할 상황:

  1. 팀이 5명 이하인 경우: 커뮤니케이션 오버헤드보다 마이크로프론트엔드의 운영 복잡성이 더 크다
  2. 제품이 초기 단계인 경우: 도메인 경계가 확실하지 않은 상태에서 분리하면 잘못된 경계로 고착된다
  3. 성능이 최우선인 경우: 마이크로프론트엔드는 필연적으로 번들 크기와 네트워크 요청을 증가시킨다
  4. 단일 프레임워크로 충분한 경우: "다양한 프레임워크를 쓸 수 있다"는 것이 "써야 한다"는 의미는 아니다
  5. 배포 주기가 동일한 경우: 모든 팀이 2주마다 함께 배포한다면, 독립 배포의 이점이 없다

대안 접근:

  • 모노레포 + 패키지 분리: Turborepo나 Nx로 코드는 분리하되 빌드는 통합
  • 라우트 기반 코드 분할: React.lazy + Suspense로 라우트별 독립 번들
  • 컴포넌트 라이브러리: 공유 UI 킷으로 일관성 확보
  • 모듈러 모놀리스: 도메인별 모듈 경계를 명확히 하되, 하나의 앱으로 배포

12. 마치며

마이크로프론트엔드는 강력하지만 복잡한 아키텍처 패턴이다. 성공적인 도입을 위해 기억해야 할 핵심 사항을 정리한다.

  1. 조직이 먼저다: 기술 결정 전에 팀 구조와 소유권 모델을 먼저 정의하라
  2. 점진적으로 도입하라: 빅뱅 전환이 아닌, 하나의 MFE부터 시작하여 점진적으로 확장하라
  3. 공유 계약을 명확히 하라: 공유 의존성 버전, API 계약, 이벤트 스키마를 문서화하고 자동 검증하라
  4. CSS 격리는 필수다: 스타일 충돌은 가장 흔하고 디버깅이 어려운 문제다
  5. 장애 격리를 설계하라: ErrorBoundary와 폴백 UI로 하나의 MFE 장애가 전체를 다운시키지 않도록 하라
  6. 성능을 지속 모니터링하라: 번들 크기, 로드 시간, Core Web Vitals를 MFE별로 추적하라

Module Federation은 Webpack 생태계에서 가장 성숙한 선택이고, Single-SPA는 이기종 프레임워크 환경에서 유연하며, Web Components는 표준 기술로 장기적인 호환성을 보장한다. 프로젝트의 규모, 팀 구조, 기술 스택에 맞는 올바른 도구를 선택하는 것이 성공의 열쇠다.

참고 자료