Module Federation 2.0으로 구축하는 마이크로프론트엔드 2026

프론트엔드

Module Federation마이크로프론트엔드Webpack아키텍처모노레포

이 글은 누구를 위한 것인가

  • 대규모 프론트엔드 앱을 팀별로 독립 배포하고 싶은 아키텍트
  • Module Federation을 도입했는데 공유 의존성 충돌 문제를 겪는 팀
  • 마이크로프론트엔드의 장단점과 적합한 상황을 이해하고 싶은 개발자

들어가며

마이크로프론트엔드는 모든 팀의 해결책이 아니다. 단일 팀이 운영하는 중소 규모 앱이라면 오버엔지니어링이 될 수 있다. 하지만 5개 이상의 팀이 하나의 프론트엔드 앱을 함께 개발하고 독립적으로 배포해야 한다면, 마이크로프론트엔드는 매우 실용적인 선택이다.

Module Federation 2.0(2024년 출시)은 기존 1.0의 한계를 크게 개선했다. 타입 공유, 동적 원격 설정, 더 나은 에러 처리가 주요 개선사항이다.

이 글은 bluefoxdev.kr의 마이크로프론트엔드 설계 가이드 를 참고하고, Module Federation 2.0 실전 구현 관점에서 확장하여 작성했습니다.


1. 마이크로프론트엔드 적합성 판단

[마이크로프론트엔드가 적합한 경우]
✅ 5개 이상 팀이 독립적으로 기능 개발
✅ 팀별로 다른 배포 주기 (한 팀이 다른 팀을 기다리지 않아야 함)
✅ 기술 스택이 다양 (React + Vue + Angular 혼합)
✅ 대규모 레거시를 점진적으로 현대화
✅ 확장 가능한 플랫폼 아키텍처 (마켓플레이스, SaaS)

[마이크로프론트엔드가 과한 경우]
❌ 팀이 1~3개로 조율이 쉬움
❌ 모든 기능이 밀접하게 연관됨
❌ 성능이 최우선 (초기 로딩 오버헤드)
❌ 팀에 DevOps 역량이 부족함

2. Module Federation 2.0 설정

2.1 Shell App (Host) 설정

// shell-app/webpack.config.js (Webpack 5 + MF 2.0)
const { ModuleFederationPlugin } = require('@module-federation/webpack');

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'shell',
      remotes: {
        // 동적 원격 설정 (URL을 빌드 타임이 아닌 런타임에 결정)
        catalog: 'catalog@[catalogUrl]/remoteEntry.js',
        checkout: 'checkout@[checkoutUrl]/remoteEntry.js',
        auth: 'auth@[authUrl]/remoteEntry.js',
      },
      shared: {
        react: {
          singleton: true,      // 하나만 로드 (필수!)
          requiredVersion: '^19.0.0',
          eager: true,          // shell에서 먼저 로드
        },
        'react-dom': {
          singleton: true,
          requiredVersion: '^19.0.0',
          eager: true,
        },
        // 디자인 시스템도 singleton으로 공유
        '@company/design-system': {
          singleton: true,
          requiredVersion: '^2.0.0',
        },
      },
    }),
  ],
};

2.2 Remote App (Catalog) 설정

// catalog-app/webpack.config.js
const { ModuleFederationPlugin } = require('@module-federation/webpack');

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'catalog',
      filename: 'remoteEntry.js',
      exposes: {
        // 외부에 노출할 모듈
        './CatalogPage': './src/pages/CatalogPage',
        './ProductCard': './src/components/ProductCard',
        './useCatalog': './src/hooks/useCatalog',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^19.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^19.0.0' },
        '@company/design-system': {
          singleton: true,
          requiredVersion: '^2.0.0',
        },
      },
    }),
  ],
};

2.3 TypeScript 타입 공유 (MF 2.0 신기능)

// catalog-app/webpack.config.ts
import { ModuleFederationPlugin } from '@module-federation/webpack';

export default {
  plugins: [
    new ModuleFederationPlugin({
      name: 'catalog',
      exposes: {
        './CatalogPage': './src/pages/CatalogPage',
      },
      // MF 2.0: 타입 자동 생성 및 배포
      dts: {
        generateTypes: true,          // 타입 파일 생성
        consumeTypes: true,           // 원격 타입 소비
        typesFolder: '@mf-types',     // 생성 위치
      },
    }),
  ],
};

3. Shell App 라우팅 구현

// shell-app/src/App.tsx
import React, { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route } from 'react-router-dom';

// 동적 import로 원격 컴포넌트 로드
const CatalogPage = lazy(() => import('catalog/CatalogPage'));
const CheckoutPage = lazy(() => import('checkout/CheckoutPage'));
const AuthPage = lazy(() => import('auth/AuthPage'));

// 에러 바운더리로 원격 앱 실패 격리
class RemoteErrorBoundary extends React.Component<
  { fallback: React.ReactNode; children: React.ReactNode },
  { hasError: boolean }
> {
  state = { hasError: false };

  static getDerivedStateFromError() {
    return { hasError: true };
  }

  render() {
    if (this.state.hasError) return this.props.fallback;
    return this.props.children;
  }
}

function App() {
  return (
    <BrowserRouter>
      <Shell>  {/* 공통 레이아웃 */}
        <Routes>
          <Route
            path="/catalog/*"
            element={
              <RemoteErrorBoundary fallback={<ErrorPage remote="catalog" />}>
                <Suspense fallback={<PageLoader />}>
                  <CatalogPage />
                </Suspense>
              </RemoteErrorBoundary>
            }
          />
          <Route
            path="/checkout/*"
            element={
              <RemoteErrorBoundary fallback={<ErrorPage remote="checkout" />}>
                <Suspense fallback={<PageLoader />}>
                  <CheckoutPage />
                </Suspense>
              </RemoteErrorBoundary>
            }
          />
        </Routes>
      </Shell>
    </BrowserRouter>
  );
}

4. 공유 상태 관리

// 공유 상태는 singleton 서비스로 관리 (Context 대신)
// shared-state/src/store.ts (별도 패키지)

class SharedStore {
  private static instance: SharedStore;
  private subscribers = new Map<string, Set<(value: unknown) => void>>();
  private state: Record<string, unknown> = {};

  static getInstance(): SharedStore {
    if (!SharedStore.instance) {
      SharedStore.instance = new SharedStore();
    }
    return SharedStore.instance;
  }

  get<T>(key: string): T | undefined {
    return this.state[key] as T;
  }

  set<T>(key: string, value: T): void {
    this.state[key] = value;
    this.subscribers.get(key)?.forEach(fn => fn(value));
  }

  subscribe<T>(key: string, fn: (value: T) => void): () => void {
    if (!this.subscribers.has(key)) {
      this.subscribers.set(key, new Set());
    }
    this.subscribers.get(key)!.add(fn as (value: unknown) => void);
    return () => this.subscribers.get(key)?.delete(fn as (value: unknown) => void);
  }
}

// React hook으로 래핑
function useSharedState<T>(key: string, defaultValue: T): [T, (value: T) => void] {
  const store = SharedStore.getInstance();
  const [value, setValue] = useState<T>(store.get<T>(key) ?? defaultValue);

  useEffect(() => {
    return store.subscribe<T>(key, setValue);
  }, [key]);

  const setter = useCallback((newValue: T) => {
    store.set(key, newValue);
  }, [key]);

  return [value, setter];
}

// 사용 (catalog-app)
const [cartCount, setCartCount] = useSharedState('cartCount', 0);

// 사용 (shell-app)
const [cartCount] = useSharedState('cartCount', 0);
// cart 변경 시 shell의 헤더도 자동 업데이트

5. 배포 전략

[마이크로프론트엔드 배포 아키텍처]

CDN (edge 캐싱)
├── shell.example.com/              → Shell App (React Shell)
├── shell.example.com/remotes.json  → 원격 URL 동적 설정
│
├── catalog.example.com/
│   └── remoteEntry.js              → Catalog Remote
│
├── checkout.example.com/
│   └── remoteEntry.js              → Checkout Remote
│
└── auth.example.com/
    └── remoteEntry.js              → Auth Remote

배포 순서:
1. Remote Apps 먼저 배포 (하위 호환 API 유지)
2. remotes.json 업데이트 (새 URL 가리키기)
3. Shell App 배포 (선택적)
// shell.example.com/remotes.json (런타임 원격 URL 설정)
{
  "catalog": "https://catalog.example.com/remoteEntry.js",
  "checkout": "https://checkout.example.com/remoteEntry.js",
  "auth": "https://auth.example.com/remoteEntry.js"
}
// Shell에서 동적으로 원격 URL 로드
async function loadRemoteConfig() {
  const res = await fetch('/remotes.json');
  return res.json();
}

// Module Federation 동적 원격 설정
import { init } from '@module-federation/runtime';

const remoteConfig = await loadRemoteConfig();

init({
  name: 'shell',
  remotes: Object.entries(remoteConfig).map(([name, entry]) => ({
    name,
    entry,
  })),
});

6. 성능 고려사항

[마이크로프론트엔드 성능 최적화]

1. 공유 의존성 singleton 관리
   - React, React DOM은 반드시 singleton
   - 버전 불일치 시 두 번 로드됨 → 번들 크기 2배

2. 초기 로딩 최적화
   - Shell App은 최소한으로 유지
   - 각 Remote는 lazy load
   - 중요 Remote만 prefetch

3. 캐싱 전략
   - remoteEntry.js: 짧은 TTL (5분)
   - 실제 청크: 긴 TTL + content hash
   - 배포 시 remoteEntry.js만 무효화

4. 에러 격리
   - 각 Remote가 ErrorBoundary로 격리
   - Remote 실패 시 Shell은 계속 동작

마무리

Module Federation은 강력하지만 복잡성을 수반한다. 도입 전 반드시 팀 구성, 배포 독립성 필요도, DevOps 역량을 함께 고려해야 한다.

Module Federation 2.0의 TypeScript 타입 공유와 동적 원격 설정은 1.0의 주요 페인 포인트를 해결했다. 신규 마이크로프론트엔드 프로젝트라면 2.0부터 시작하는 것을 강력히 권장한다.