TestForge | Aidevops | 📊 Plogger ✍️ Blog 📚 Docs
plogger

AI DevOps Korea

AI 서비스 개발, 운영, 성능개선을 하나의 루프로 연결합니다

aidevops.kr에서 LLMOps, RAG, AI Agent, 관측성, 평가, 비용-성능 최적화를 실전 운영 관점으로 정리합니다.

마이크로 프론트엔드 — Module Federation 실전 적용

· 수정 4월 15일
마이크로 프론트엔드 — Module Federation 실전 적용 다이어그램
이 글에서 다루는 핵심 흐름, 아키텍처 구조, 주요 판단 포인트를 한눈에 이해할 수 있도록 정리한 그림입니다.
마이크로 프론트엔드는 프론트엔드 버전의 마이크로서비스처럼 보이지만, 실제로는 훨씬 더 까다로운 면이 있습니다. 브라우저 안에서는 결국 하나의 사용자 경험으로 합쳐져야 하기 때문입니다.

즉, 팀은 독립적으로 개발하고 배포하고 싶어 하지만, 사용자는 헤더와 장바구니와 결제 화면이 각각 다른 앱처럼 느껴지길 원하지 않습니다. 그래서 마이크로 프론트엔드의 핵심은 단순 분리가 아니라 독립성과 일관성의 균형입니다.

Module Federation은 이 문제를 해결하는 대표적인 도구 중 하나입니다. 하지만 도입 가치가 큰 만큼 shared dependency, 런타임 오류, 디자인 일관성, 상태 공유 같은 새로운 복잡성도 함께 들어옵니다.

아키텍처 그림 설명

[Shell App]
    |
    +--> [Remote: Catalog]
    +--> [Remote: Checkout]
    +--> [Remote: Account]
    |
    v
[Shared Contracts]
 routing / auth / design system / events

이 구조에서는 각 팀이 화면 일부를 독립 배포할 수 있지만, Shell과 Remote 사이의 계약이 훨씬 중요해집니다. 인증, 라우팅, 공통 UI, 버전 호환성이 정리되지 않으면 독립 배포는 곧 런타임 혼란으로 바뀝니다. 즉 마이크로 프론트엔드는 분할 기술이 아니라 계약 관리 기술에 가깝습니다.

마이크로 프론트엔드가 필요한 진짜 신호

기술적으로 가능하다는 이유만으로 도입하면 대개 과합니다. 보통 아래와 같은 신호가 있을 때 가치가 생깁니다.

  • 하나의 프론트엔드 앱을 여러 팀이 동시에 만지면서 병목이 심하다
  • 특정 기능만 독립적으로 배포하고 싶은 요구가 크다
  • 조직 구조상 도메인별 UI 소유권을 명확히 나눠야 한다
  • 특정 화면군의 기술 스택이나 배포 주기가 확연히 다르다

반대로 팀이 작고 코드베이스도 아직 관리 가능한 수준이라면, 잘 정리된 모놀리식 프론트엔드가 더 빠르고 안정적일 수 있습니다.

마이크로 프론트엔드는 앱 분리가 아니라 소유권 분리다

좋은 구조는 보통 업무 경계와 맞물립니다.

shell (host)          shell.example.com
├── header/           teams-a.example.com
├── product-list/     teams-b.example.com
├── cart/             teams-c.example.com
└── checkout/         teams-d.example.com

이 구조의 핵심은 기술보다 책임입니다.

  • 어떤 팀이 어떤 화면 조각을 소유하는가
  • 그 조각이 독립 배포될 가치가 있는가
  • 다른 팀 변경에 덜 묶일 수 있는가

즉, Module Federation은 프런트 코드 분리 도구라기보다 팀 간 경계를 런타임까지 이어주는 도구에 가깝습니다.

Shell은 조립자이지 또 다른 거대 앱이 되면 안 된다

const { ModuleFederationPlugin } = require('@module-federation/enhanced/webpack')

module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'shell',
      remotes: {
        header: 'header@https://header.example.com/remoteEntry.js',
        products: 'products@https://products.example.com/remoteEntry.js',
        cart: 'cart@https://cart.example.com/remoteEntry.js',
      },
      shared: {
        react: { singleton: true, requiredVersion: '^18.0.0' },
        'react-dom': { singleton: true, requiredVersion: '^18.0.0' },
        '@company/ui': { singleton: true },
      },
    }),
  ],
}

Shell의 역할은 라우팅, 공통 레이아웃, 초기 부트스트랩, 공통 의존성 조율에 가깝습니다. 여기서 중요한 건 Shell이 비즈니스 로직을 너무 많이 흡수하지 않는 것입니다. 그 순간 Shell은 모든 팀이 의존하는 또 다른 병목이 됩니다.

shared 설정은 편의 기능이 아니라 런타임 계약이다

Module Federation에서 가장 민감한 영역 중 하나가 shared입니다. 특히 React 같은 핵심 라이브러리는 거의 항상 singleton으로 맞추는 편이 안전합니다.

이유는 분명합니다.

  • React가 두 벌 올라오면 hook context가 깨질 수 있습니다.
  • 디자인 시스템이 중복되면 스타일과 state가 일관되지 않을 수 있습니다.
  • 버전 불일치가 런타임에서만 드러날 수 있습니다.

즉, shared는 “중복 번들 줄이기”보다 같은 브라우저 런타임 안에서 어떤 라이브러리를 공용 계약으로 볼 것인가를 결정하는 설정입니다.

Remote는 기능 단위로 노출해야 한다

new ModuleFederationPlugin({
  name: 'products',
  filename: 'remoteEntry.js',
  exposes: {
    './ProductList': './src/components/ProductList',
    './ProductDetail': './src/components/ProductDetail',
    './useCart': './src/hooks/useCart',
  },
  shared: {
    react: { singleton: true },
    'react-dom': { singleton: true },
  },
})

Remote가 너무 세밀한 내부 컴포넌트까지 많이 노출하기 시작하면, 다른 팀이 그 내부 구현에 의존하게 되면서 결합도가 다시 올라갑니다. 그래서 노출 단위는 가능한 한 업무적으로 의미 있는 공개 표면(public surface) 위주가 좋습니다.

런타임 로딩은 유연하지만 실패 지점도 늘린다

import React, { Suspense, lazy } from 'react'

const ProductList = lazy(() => import('products/ProductList'))
const Cart = lazy(() => import('cart/CartWidget'))

export function App() {
  return (
    <div>
      <Suspense fallback={<ProductListSkeleton />}>
        <ProductList />
      </Suspense>
      <Suspense fallback={<CartSkeleton />}>
        <Cart />
      </Suspense>
    </div>
  )
}

이 접근의 장점은 명확합니다.

  • 독립 배포된 UI를 런타임에 가져올 수 있습니다.
  • Shell 재배포 없이 기능 교체가 가능해집니다.
  • 초기 번들을 줄일 여지도 생깁니다.

하지만 대가도 있습니다.

  • 원격 로딩 실패가 곧 사용자 화면 오류가 됩니다.
  • CDN, 캐시, 버전 불일치 문제가 런타임에서 터집니다.
  • 개발 환경과 운영 환경 차이를 더 강하게 관리해야 합니다.

그래서 MFE에서는 graceful degradation과 fallback UI가 훨씬 중요합니다.

타입 안전성은 자동으로 생기지 않는다

export interface ProductListProps {
  categoryId?: string
  onAddToCart: (productId: string) => void
}

declare module 'products/ProductList' {
  import { ProductListProps } from '@company/mfe-types'
  const ProductList: React.FC<ProductListProps>
  export default ProductList
}

런타임으로 조립하는 구조에서는 컴파일 타임 안전성이 느슨해지기 쉽습니다. 그래서 공유 타입 패키지, 계약 버전 관리, 공개 API 문서화가 중요합니다. Module Federation은 코드 공유보다도 계약 관리 discipline이 더 중요해지는 구조입니다.

상태 공유는 최소화하는 편이 좋다

많은 팀이 처음에는 전역 상태를 여러 MFE에서 함께 쓰고 싶어 합니다. 하지만 이 방식은 빠르게 결합도를 높입니다. 가능하면 전역 store 공유보다 메시지 기반 통신이 더 낫습니다.

function CartButton({ productId }: Props) {
  const handleAdd = () => {
    window.dispatchEvent(
      new CustomEvent('cart:add', {
        detail: { productId, quantity: 1 },
      }),
    )
  }

  return <button onClick={handleAdd}>장바구니 추가</button>
}

function CartCount() {
  const [count, setCount] = useState(0)

  useEffect(() => {
    const handler = () => setCount((value) => value + 1)
    window.addEventListener('cart:add', handler as EventListener)
    return () => window.removeEventListener('cart:add', handler as EventListener)
  }, [])

  return <span>{count}</span>
}

이런 방식의 장점은 느슨한 결합입니다. 물론 이벤트 이름과 payload 계약 관리가 필요하지만, 적어도 store 내부 구조까지 공유하지는 않게 됩니다.

동적 remote URL은 운영 전략과 연결된다

const remoteUrlMap = {
  development: {
    products: 'http://localhost:3001/remoteEntry.js',
    cart: 'http://localhost:3002/remoteEntry.js',
  },
  production: {
    products: 'https://products.example.com/remoteEntry.js',
    cart: 'https://cart.example.com/remoteEntry.js',
  },
}[process.env.NODE_ENV]

런타임 URL 분리는 배포 유연성을 줍니다. 하지만 동시에 다음 운영 이슈를 함께 가져옵니다.

  • 캐시 무효화 전략
  • 특정 remote만 롤백할 수 있는가
  • shell과 remote의 호환성 버전은 어떻게 관리할 것인가
  • 장애가 난 remote를 임시로 차단할 수 있는가

즉, MFE는 프런트 구조이지만 실제로는 CDN, 배포, 캐시, 버전 호환성 문제를 깊게 다루게 됩니다.

CI/CD는 독립 배포의 약속을 지켜줘야 한다

on:
  push:
    paths:
      - 'apps/products/**'

jobs:
  deploy:
    steps:
      - run: npm run build --workspace=apps/products
      - name: Deploy to CDN
        run: aws s3 sync dist/ s3://products-mfe/ --cache-control max-age=31536000
      - run: aws cloudfront create-invalidation --paths '/remoteEntry.js'

여기서 핵심은 asset과 remoteEntry.js를 같은 방식으로 캐싱하지 않는 것입니다. 번들 asset은 강한 캐시가 가능하지만, remoteEntry.js는 새 배포를 가리키는 엔트리이므로 훨씬 더 신중하게 다뤄야 합니다.

마이크로 프론트엔드의 가장 큰 위험은 UX 분열이다

기술적으로 조립이 잘 돼도 아래가 깨지면 사용자는 하나의 서비스로 느끼지 못합니다.

  • 디자인 시스템 일관성
  • 로딩/오류 처리 경험
  • 접근성 기준
  • 라우팅/네비게이션 감각
  • 성능 예산

그래서 MFE를 도입할수록 공통 디자인 시스템, 린트/테스트 규칙, 접근성 기준, 성능 기준은 오히려 더 강하게 가져가는 편이 좋습니다.

언제 Module Federation이 특히 잘 맞는가

  • 여러 팀이 도메인별 UI를 강하게 소유해야 한다
  • 독립 배포가 큰 사업적 가치가 있다
  • 공통 플랫폼/디자인 시스템이 어느 정도 성숙했다
  • 프론트엔드 플랫폼 팀이 운영 복잡도를 감당할 수 있다

반대로 팀 규모가 작고 아직 제품 구조가 자주 바뀌는 단계라면, MFE는 지나치게 무거운 선택일 수 있습니다.

마무리

Module Federation 기반 마이크로 프론트엔드의 본질은 “런타임 import”가 아니라, 프론트엔드 소유권과 배포 독립성을 브라우저 환경에서 실현하는 구조입니다.

잘 맞는 조직에서는 큰 효과가 있지만, 그만큼 shared dependency, 계약 관리, UX 일관성, 운영 전략까지 같이 성숙해야 합니다. 결국 중요한 질문은 “분리할 수 있는가”보다 “이 분리가 팀과 사용자 모두에게 실제로 이익인가”입니다.

운영 환경에서 어려워지는 지점

  • 마이크로 프론트엔드는 통합 비용보다 소유권 경계가 더 강할 때만 조직 확장에 도움이 된다.
  • Module Federation은 복잡도를 의존성 호환성, 공유 런타임 계약, 릴리스 조정 문제로 옮긴다.
  • 진짜 어려움은 원격 코드를 불러오는 기술보다 여러 팀의 UX와 운영 동작을 일관되게 유지하는 일이다.

중요한 아키텍처 결정

  • 마이크로 프론트엔드는 팀 자율성과 릴리스 분리가 강한 제약일 때만 도입하고, 유행 때문에 도입하지 않는다.
  • remote를 늘리기 전에 공유 의존성, 디자인 토큰, 인증 컨텍스트, 에러 처리 계약을 먼저 정한다.
  • 도메인 경계는 작은 위젯이 아니라 의미 있는 워크플로우를 소유할 만큼 충분히 크게 잡는다.

실무 예시

현실적인 federation 구조는 인프라 관심사를 의도적으로 공유하고 기능 소유권은 로컬에 남긴다.

shell app
  - 라우팅
  - 인증 세션
  - 공유 디자인 토큰
remote app A
  - 카탈로그 워크플로우
remote app B
  - 체크아웃 워크플로우

피해야 할 안티패턴

  • 릴리스마다 팀 간 디버깅이 필요할 정도로 페이지 조각 단위로 과도하게 쪼개는 것.
  • 버전 규율 없이 너무 많은 라이브러리를 singleton으로 공유하는 것.
  • 실은 팀 프로세스 문제인 코드베이스 품질 이슈를 federation이 해결해줄 것이라 기대하는 것.

운영 체크리스트

  • remote 로드 실패율과 fallback 동작을 추적한다.
  • 특히 routing과 auth 같은 공유 계약을 의도적으로 버전 관리한다.
  • 여러 repo와 배포 단위를 다루는 로컬 개발 경험 비용을 예산에 넣는다.
  • 릴리스 독립성이 실제로 생기고 있는지 점검한다.

최종 판단

마이크로 프론트엔드는 기본 UI 아키텍처가 아니라 조직 확장 도구다. 팀 자율성과 독립 배포 가치가 통합 오버헤드보다 클 때만 정당화된다.

Continue Reading

다음으로 읽기 좋은 글

다음 탐색

이 주제를 시스템 관점으로 더 이어서 보기