본문 바로가기

React

React로 마이크로 프론트엔드 구현하기

마이크로 프론트엔드는 팀별로 독립적으로 개발·배포하면서 하나의 사용자 경험을 제공하기 위한 접근입니다. React로 구현할 때는 번들 격리, 공용 라이브러리 버전, 라우팅, 스타일, 통신, 배포 전략을 명확히 해야 유지보수 비용을 통제할 수 있습니다.

1. 언제 마이크로 프론트엔드를 쓰는가

여러 팀이 서로 다른 도메인을 병렬로 개발·배포해야 하거나 레거시와 신규 스택을 공존시키는 경우에 적합합니다. 팀 규모가 작고 배포 빈도가 낮다면 단일 리포지토리/애플리케이션이 더 효율적일 수 있습니다.

2. 아키텍처 선택: Module Federation vs single-spa vs Web Components

Webpack Module Federation은 런타임에 원격 모듈을 불러와 React 컴포넌트를 직접 마운트할 수 있어 가장 보편적입니다. single-spa는 루트 구성에서 여러 SPA를 조합하는 프레임워크로 import maps와 함께 쓰면 프레임워크 불문 통합이 쉽습니다. Web Components/Shadow DOM은 스타일 격리에 유리하지만 React와의 경계 관리가 필요합니다.

3. Module Federation으로 React 셸/리모트 구성

셸은 공통 레이아웃과 라우팅을 담당하고, 리모트는 도메인 기능을 제공합니다. React, react-dom은 싱글톤으로 공유합니다.

// shell/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
const deps = require('./package.json').dependencies;
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'shell',
      remotes: {
        products: 'products@http://localhost:3002/remoteEntry.js',
      },
      shared: {
        react: { singleton: true, requiredVersion: deps.react },
        'react-dom': { singleton: true, requiredVersion: deps['react-dom'] },
      },
    }),
  ],
};
// products/webpack.config.js
const { ModuleFederationPlugin } = require('webpack').container;
const deps = require('./package.json').dependencies;
module.exports = {
  plugins: [
    new ModuleFederationPlugin({
      name: 'products',
      filename: 'remoteEntry.js',
      exposes: {
        './App': './src/App',
      },
      shared: {
        react: { singleton: true, requiredVersion: deps.react },
        'react-dom': { singleton: true, requiredVersion: deps['react-dom'] },
      },
    }),
  ],
};
// shell/src/Routes.jsx
import React from 'react';
const ProductsApp = React.lazy(() => import('products/App'));
export default function Routes() {
  return (
    <React.Suspense fallback={<div>로딩...</div>}>
      <ProductsApp />
    </React.Suspense>
  );
}

팁: 리모트와 셸의 React 버전을 정확히 맞추고 peerDependencies 경고를 CI에서 차단합니다.

4. 런타임 원격 경로를 환경에 맞게 주입

환경별 CDN 경로를 하드코딩하지 말고 런타임 매니페스트를 사용합니다.

// index.html에서 먼저 주입
<script>
  window.__remotes = {
    products: 'https://cdn.example.com/products/1.4.3/remoteEntry.js',
  };
</script>
// shell webpack remotes 동적 로더
remotes: {
  products: `promise new Promise(resolve => {
    const url = window.__remotes.products;
    const s = document.createElement('script');
    s.src = url;
    s.onload = () => resolve(window.products);
    document.head.appendChild(s);
  })`,
},

원격 엔트리는 짧은 TTL, 콘텐츠 해시가 있는 청크는 긴 TTL로 캐시합니다.

5. 라우팅 경계와 내비게이션 계약

상위 셸이 최상위 라우팅을 책임지고 각 리모트는 자신의 내부 라우팅만 관리합니다. 교차 내비게이션은 이벤트를 통해 요청합니다.

// 셸: 이벤트 리스너
window.addEventListener('mf:navigate', (e) => {
  const path = e.detail?.path;
  if (typeof path === 'string') {
    // 예: react-router의 navigate 사용
    window.appNavigate(path);
  }
});

// 리모트: 다른 도메인으로 이동 요청
window.dispatchEvent(new CustomEvent('mf:navigate', { detail: { path: '/products/123' } }));

팁: 브라우저 히스토리는 셸이 소유하고, 리모트는 메모리 히스토리를 사용하면 사이드이펙트를 줄일 수 있습니다.

6. 상태 공유와 통신

전역 상태를 공유 스토어로 묶으면 팀 간 결합도가 급증합니다. 가능한 이벤트 기반 통신을 사용하고 반드시 스키마를 문서화합니다.

// 간단한 이벤트 버스
export const publish = (type, payload) => {
  window.dispatchEvent(new CustomEvent(type, { detail: payload }));
};
export const subscribe = (type, handler) => {
  const fn = (e) => handler(e.detail);
  window.addEventListener(type, fn);
  return () => window.removeEventListener(type, fn);
};

// 사용
subscribe('cart:add', (item) => console.log('장바구니 추가', item));
publish('cart:add', { id: 'p-1', qty: 1 });

격리가 더 필요하면 iframe을 쓰고 postMessage로 통신합니다.

// 부모 - iframe으로 메시지 전송
iframe.contentWindow.postMessage({ type: 'mf:cart:add', payload: { id: 'p-1' } }, 'https://products.example.com');

// 자식
window.addEventListener('message', (e) => {
  if (e.origin !== 'https://shell.example.com') return;
  if (e.data?.type === 'mf:cart:add') {
    // 처리
  }
});

7. 스타일 격리

CSS 충돌은 마이크로 프론트엔드의 대표적 장애 요인입니다. CSS Modules, Tailwind prefix, Shadow DOM을 활용합니다.

// tailwind.config.js
module.exports = {
  prefix: 'mf-prod-',
};
// CSS Modules 예: Button.module.css
.button { composes: text-sm from global; background: var(--color-primary); }

강한 격리가 필요하면 Web Components 래퍼를 두고 Shadow DOM을 활성화합니다.

class ProductsRoot extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open' });
  }
  connectedCallback() {
    const mount = document.createElement('div');
    this.shadowRoot.appendChild(mount);
    // React 앱을 mount에 마운트
  }
}
customElements.define('products-root', ProductsRoot);

8. 디자인 시스템 공유

디자인 시스템은 별도 npm 패키지로 배포하고 React와 react-dom은 peerDependencies로 선언합니다. 토큰과 컴포넌트를 버전 고정하고 변경은 메이저 버전으로만 반영합니다.

9. 에러 경계, 로깅, 관측

각 리모트는 자체 Error Boundary를 반드시 포함합니다. 셸은 리모트 로딩 실패를 감지해 폴백 UI를 제공합니다.

class ErrorBoundary extends React.Component {
  state = { hasError: false };
  static getDerivedStateFromError() { return { hasError: true }; }
  componentDidCatch(error, info) { console.error('MF error', error, info); }
  render() { return this.state.hasError ? <div>일시적 오류가 발생했습니다.</div> : this.props.children; }
}

리모트 로딩 시간, 실패율, 번들 크기, Web Vitals를 팀/애플리케이션 단위로 분리해 수집합니다.

10. 배포와 캐싱 전략

리모트는 독립 파이프라인으로 배포하고 모든 아티팩트는 콘텐츠 해시로 이름을 생성합니다. remoteEntry는 짧은 캐시, 나머지 청크는 긴 캐시로 설정합니다. CDN 경로는 매니페스트를 통해 셸에 주입하고 SRI와 CSP로 보안을 강화합니다.

// SRI 예시 (서버에서 해시 삽입)
<script src=".../remoteEntry.js" integrity="sha384-..." crossorigin="anonymous"></script>

11. 테스트 전략

계약 테스트로 이벤트 타입과 페이로드 스키마를 검증합니다. 조립된 셸 기준 E2E는 핵심 사용자 시나리오만 최소화하고, 각 리모트는 자체 컴포넌트/통합 테스트를 충분히 수행합니다.

12. 버전 관리와 공유 라이브러리

react, react-dom은 싱글톤으로 공유하고 semver 범위를 좁게 설정합니다. 디자인 시스템과 유틸은 내부 레지스트리에 배포하고 변경 로그를 자동 생성합니다.

13. single-spa + import maps 대안

프레임워크 혼합이나 Webpack 비의존 구성이 필요하면 single-spa와 import maps를 사용합니다.

// root-config/src/index.js
import { registerApplication, start } from 'single-spa';
registerApplication({
  name: '@org/nav',
  app: () => System.import('@org/nav'),
  activeWhen: ['/'],
});
start();
// index.ejs - import map
<script type="systemjs-importmap">{
  "imports": {
    "@org/nav": "https://cdn.example.com/nav/1.2.0/main.js"
  }
}</script>

이 방식은 번들러 제약이 적지만, 런타임 해상도와 레이턴시 관리가 중요합니다.

14. 성능 체크리스트

리모트 개수를 가능한 적게 유지하고 화면당 활성 리모트를 제한합니다. remoteEntry 사전 연결과 prefetch를 설정합니다. 공용 라이브러리를 무분별하게 공유하지 말고 각 리모트의 번들 분할을 최적화합니다.

// HTML에 사전 연결/프리로드
<link rel="preconnect" href="https://cdn.example.com" />
<link rel="prefetch" as="script" href="https://cdn.example.com/products/1.4.3/remoteEntry.js" />

15. 안티패턴과 피하기

교차 임포트로 리모트 간 모듈을 직접 참조하지 않습니다. 전역 CSS를 공유하지 않습니다. 전역 스토어를 공유해 양방향으로 데이터 의존을 만들지 않습니다. React 버전 불일치를 허용하지 않습니다.

16. 점진적 도입 시나리오

Strangler 패턴으로 셸을 먼저 도입하고 레거시 앱 일부 경로를 리모트로 대체합니다. 각 단계에서 관측치와 배포 리스크를 측정합니다.

핵심은 팀 간 계약과 경계를 명확히 하고, 런타임 조립의 이점을 캐싱/보안/관측으로 보완하는 것입니다. 위의 기본 구성을 바탕으로 작은 리모트부터 시작해 운영 성숙도를 높이시길 권장합니다.