프론트엔드 시스템 디자인 - 7. 대규모 조직을 위한 마이크로 프론트엔드 (MFE)
인프런 강의인 미국 빅테크 프론트엔드 시스템 디자인 실전: 단순 구현자로 남지 않기 위한 프론트엔드 개발자를 위해 를 참고하여 공부하며 포스트로 정리하고 있습니다. 처음부터 보시려면 프론트엔드 시스템 디자인 - 1. RADIO 접근법 부터..!
프론트엔드 아키텍처는 무엇을 위해 존재하는가
사용자가 제품을 안정적으로 이해하고 조작할 수 있게 만들기 위해 존재한다. 그래서 사용자에게는 하나의 제품처럼 보여야 한다.
하지만 내부적으로는 여러 팀이 동시에 개발한다. 각 팀은 자기 도메인을 빠르게 배포하고 싶어 하고, 전체 디자인과 접근성, 성능, 관측성 기준은 일관되어야 한다.
대규모 프론트엔드 아키텍처의 목적은 세 가지다.
- One Product Experience. 하나의 제품 경험을 일관성 있게 유지한다
- 여러 팀이 독립적으로 배포하고 운영할 수 있게 한다
- 리스크를 통제한다
이 세 가지를 동시에 만족시키기 어렵기 때문에 마이크로 프론트엔드 아키텍처가 존재한다.
Monolith Frontend
하나의 큰 프론트엔드 애플리케이션이다. 흔히 아는 프론트엔드 앱 구조와 같다. 하나의 React 또는 Next.js 앱 안에 Search, Product, Cart, Checkout, Account가 전부 들어 있다.
src/
pages/
search/
product/
cart/
components/
hooks/
구조가 단순하고 빌드와 배포가 단순하다. 라우팅이 쉽고 상태 공유가 쉽고 디자인 일관성을 유지하기 쉽다. 작은 팀에서는 이 구조가 적합하다.
모노리스는 작은 팀과 초기 제품에서는 오히려 가장 합리적인 구조다.
Modular Monolith와의 구분
모노리스는 나쁜 것, MFE는 좋은 것이라는 인식이 있는데 그렇게 볼 것이 아니다.
Modular Monolith는 하나의 앱, 하나의 빌드, 하나의 배포 단위를 유지하면서 내부 구조만 도메인 기준으로 나눈 것이다.
src/
features/
search/
product/
cart/
| 앱과 빌드 | 배포 단위 | 나누는 기준 | |
| Monolith | 하나 | 하나 | 레이어 (pages, components, hooks) |
| Modular Monolith | 하나 | 하나 | 도메인 |
| Micro Frontend | 여러 개 | 여러 개, 독립 | 도메인 + 오너십 |
대부분의 팀은 바로 마이크로 프론트엔드로 가면 안 된다. 먼저 모듈러 모노리스로 해결되는지 봐야 한다.
- 마이크로 프론트엔드가 필요한 것이 아니라 도메인 경계가 필요한 것일 수 있다
- 독립 배포가 필요한 것이 아니라 폴더 구조와 오너십 정리가 필요한 것일 수 있다
조직이 커지면 비용 구조가 바뀐다
하나의 커머스 앱에 Search팀, Product팀, Cart팀, Checkout팀이 붙는다고 해보자. 이 팀들이 하나의 앱, 하나의 배포 파이프라인, 하나의 dependency 그래프를 공유한다.
처음에는 괜찮지만 시간이 지나면 이런 일이 생긴다.
- Search팀의 실험 코드가 전체 앱에 영향을 준다
- Checkout팀은 안정적으로 배포하고 싶은데 다른 팀 변경까지 함께 검증하고 배포 절차를 지켜야 한다
- Product팀이 dependency를 올렸는데 Cart 화면이 깨진다
비용으로 치면 모노리스 프론트엔드의 비용은 이런 것들이 합쳐진 구조다.
- 전체 앱을 빌드하는 비용
- 한 팀의 변경에도 전체 테스트를 돌려야 하는 비용
- 여러 팀이 하나의 배포 일정에 묶이는 비용
- 팀 간 조정 비용
팀 간 조정 관계는 n(n-1)/2이다. 팀이 3개면 3, 5개면 10이다. 팀 수가 늘어나면 조정 관계는 선형으로 늘지 않는다.
실제 조직에서 모든 팀이 모든 팀과 협업하지는 않지만, 이 수식은 팀 수가 늘어날수록 하나의 프론트엔드 앱을 함께 배포하는 조정 비용이 빠르게 커진다는 직관을 준다.
MFE가 해결하는 것과 새로 만드는 비용
마이크로 프론트엔드는 독립적으로 전달 가능한 프론트엔드 애플리케이션들이 더 큰 하나의 전체로 조합되는 구조다.
비용을 없애주지는 않는다. 없어지는 비용과 새로 생기는 비용이 있다.
모노리스에서 커지는 비용
- 팀 간 조정 비용
- 전체 빌드와 테스트 비용
- 전체 배포 병목
- 장애 전파 범위
- 점진적 마이그레이션의 어려움
MFE에서 새로 생기는 비용
- 런타임 컴포지션 비용
- 로딩과 실패 처리
- Design Consistency 유지 비용
- Shared Dependency 관리
- Shared Platform 운영 비용
선택은 최적화 문제다
전체 비용을 최소화하는 아키텍처를 고르는 문제다.
- 작은 팀: Monolith의 총비용 < MFE의 총비용
- 대규모 조직: MFE의 총비용 < Monolith의 총비용
MFE는 프론트엔드가 커졌다고 쓰는 것이 아니라, 조직과 배포의 병목 비용이 런타임 복잡도보다 커졌을 때 쓰는 구조다.
+) 콘웨이의 법칙
이 판단 기준의 배경에 콘웨이의 법칙이 있다. 시스템을 설계하는 조직은 그 조직의 커뮤니케이션 구조를 복제한 설계를 만들게 된다는 관찰이다.
MFE 논의에서 이 법칙이 자주 인용되는 이유는, MFE가 기술 결정이 아니라 조직 결정이라는 점을 설명해주기 때문이다. 팀 경계와 배포 경계가 어긋나 있으면 어떤 아키텍처를 써도 조정 비용이 남는다. 반대로 팀이 하나뿐인데 배포 단위를 여러 개로 쪼개면 얻는 자율성 없이 런타임 복잡도만 늘어난다.
Team Topologies는 이 관찰을 뒤집어서 원하는 아키텍처가 있으면 팀 구조를 먼저 그에 맞게 설계하라고 제안한다. 역콘웨이 전략이라고 부른다.
Shell
셸은 전체 앱의 공통 실행 환경이다.
여러 리모트를 로딩하고 실행시키는 공통 호스트 애플리케이션이고, 사용자에게 보이는 하나의 제품, 제품의 틀이다.
셸이 가지는 것은 Top Level Layout, Header, Routing, Theme Provider, Telemetry Provider 같은 것들이다.
동작 순서는 이렇다.
- 사용자가 어떤 URL에 있는지 확인한다
- 그 URL을 담당하는 리모트가 무엇인지 찾는다
- 리모트의 매니페스트를 읽고 엔트리 URL과 버전을 확인한다
- 리모트를 로딩한다
- 리모트에 앱 컨텍스트를 전달한다
- 로딩에 실패하면 fallback을 보여주고, 렌더 에러는 에러 바운더리로 격리한다
사용자가 Cart 페이지를 방문하면, 셸이 라우트를 보고 담당 리모트를 찾고, 매니페스트를 확인해서 cdn.example.com/cart/remote/entry.js를 로드하고, 앱 컨텍스트를 전달한다. 그러면 Cart Remote가 Cart 페이지를 렌더링한다.
셸은 Cart가 어떻게 장바구니 수량을 계산하는지 몰라야 한다. 셸이 알아야 하는 것은 이 정도다.
{
"remoteName": "cart",
"route": "/cart",
"entryUrl": "https://cdn.example.com/cart/remote/entry.js",
"version": "1.4.2",
"ownerTeam": "cart-team",
"fallback": "CartFallback"
}
추가 정리: 셸이 리모트를 로딩하는 실제 기술
강의는 셸과 리모트를 개념으로 다루는데, 실제로 조합하는 방식은 여러 가지가 있고 각각 트레이드오프가 다르다.
빌드 타임 통합. 각 리모트를 npm 패키지로 배포하고 셸이 의존성으로 가져온다. 구현이 단순하지만 리모트가 바뀔 때마다 셸을 다시 빌드하고 배포해야 해서 독립 배포가 안 된다. 이러면 MFE의 핵심 이점이 사라진다.
서버 사이드 컴포지션. 서버나 엣지에서 HTML 조각을 합친다. SSI, ESI, 또는 Edge Worker. 초기 로딩이 빠르고 SEO에 유리하다.
iframe. 격리는 가장 확실하다. 스타일도 전역 변수도 안 섞인다. 대신 라우팅 동기화, 높이 조절, 모달 처리, 접근성이 전부 어려워진다.
Web Components. 커스텀 엘리먼트로 리모트를 감싼다. 프레임워크 중립적이지만 프레임워크별 통합 비용이 있다.
런타임 JS 통합 (Module Federation). 현재 가장 많이 쓰이는 방식이다. Webpack 5에서 도입됐고 Rspack과 Vite 플러그인도 있다. 셸이 런타임에 리모트의 remoteEntry.js를 가져와 모듈을 로드한다. 강의에서 설명한 엔트리 URL과 매니페스트 구조가 이것이다.
Single-SPA. Module Federation보다 먼저 나온 라이브러리로, 라우팅에 따라 여러 앱을 마운트하고 언마운트하는 방식이다.
Remote
리모트는 특정 도메인 경험을 구현하는 독립 프론트엔드 유닛이다. Cart Remote 안에는 이런 것들이 들어간다.
- Cart Page UI, Cart Item Component, Quantity Control, Remove Item Flow
- Cart API Client, Cart Query, Mutation
- Cart Validation, Cart Error Handling
Checkout Remote라면 Shipping Step, Payment Step, Checkout API Client, Payment SDK 통합, Checkout Error Handling이 들어간다.
리모트는 그래서 단순한 컴포넌트가 아니라 UI, State, API, Validation, Telemetry, Error Handling, 그리고 Release Unit이다. 다른
정리하자면 애플리케이션은 셸과 여러 리모트의 조합이다.
Shell (layout, routing, auth, theme, telemetry, remote loader)
├─ Search Remote
├─ Product Remote
└─ Cart Remote
무엇을 셸에 두고 무엇을 리모트에 둘까
| 셸 | 리모트 |
| Theme | Search Filters |
| Global Navigation | Product Image |
| Top Level Routing | Checkout Payment Step |
| Remote Loading | 도메인 내부 상태와 로직 |
| 전역 에러 | 도메인별 에러 처리 |
셸이 모든 상태를 관리하면 안 되는 이유
셸이 모든 상태를 관리하고 리모트는 UI만 그리면 더 단순해 보인다. 모든 상태가 한 곳에 있으니 공유가 쉽고 디버깅도 쉬워 보인다.
하지만 이런 구조는 셸이 도메인 내부 상태를 너무 많이 알게 된다. 셸이 Cart의 내부 상태를 알고 있으면 Cart Remote는 독립적이지 않다. Cart의 내부 로직을 바꾸려면 셸도 영향을 받게되고, coupling이 증가한다.
마이크로 프론트엔드의 독립성은 결합도와 반비례한다.
셸이 리모트 내부를 많이 알수록 리모트의 독립 배포는 어려워지고, 셸의 변경 위험은 올라가고, 테스트 범위가 늘어나고, 팀 간 조정 비용이 늘어난다.
예를 들어 헤더의 Cart Count는 셸에서 필요할 수 있다. 하지만 Cart Item, Quantity Editing, Validation은 Cart Remote가 소유해야 한다.
- 셸: Cart Count Summary 표시
- Cart Remote: Cart Item, Quantity Editing, Remove Item, Coupon, Cart Mutation
셸은 리모트를 실행할 만큼만 알고 있어야 하고, 리모트 내부 도메인 로직은 몰라야 한다.
+) Cart Count가 셸까지 전달되는 방법
강의는 셸이 요약값을 표시한다고만 언급하고 전달 방식은 다루지 않는다. 실무에서 쓰이는 방식은 세 가지 정도라고 한다.
커스텀 이벤트. 리모트가 window.dispatchEvent(new CustomEvent('cart:updated', { detail }))를 쏘고 셸이 듣는다. 계약이 이벤트 이름과 payload 형태뿐이라 결합도가 낮다. 대신 타입 안전성이 없어서 이벤트 스키마를 별도 패키지로 관리하는 경우가 많다.
공유 pub/sub 또는 상태 스토어. 셸이 얇은 스토어를 제공하고 리모트가 구독한다. 편하지만 스토어가 커질수록 셸이 도메인을 알게 되는 방향으로 흘러가기 쉽다.
URL. 상태를 URL에 두면 셸과 리모트가 같은 소스를 본다. 필터나 페이지처럼 공유 가능한 상태에 잘 맞는다.
방향도 중요하다. 리모트 → 셸은 요약값만, 셸 → 리모트는 실행 컨텍스트(사용자, 테마, 로케일, 트레이스 ID)만 흐르게 하는 게 결합도를 낮게 유지하는 형태다.
리모트는 페이지 컴포넌트가 아니다
도메인별 페이지 분리와 비슷해 보일 수 있지만, 리모트는 독립적인 빌드와 배포가 가능한 단위다. 코드가 나뉘었는가가 아니라 배포 단위가 나뉘었는가를 봐야 한다.
| 나누는 것 | 결과 |
| 폴더를 나눈다 | 모듈화 |
| 청크를 나눈다 | 코드 분리 |
| 배포 단위를 나눈다 | 마이크로 프론트엔드 |
리모트는 특정 팀이 소유하고 독립적으로 빌드와 배포가 가능한 도메인 단위의 프론트엔드다.
리모트를 많이 만들수록 좋은가?
작게 나누면 각 팀이 더 독립적으로 일할 수 있어 보이지만, 리모트가 많아질수록 런타임 컴포지션 비용이 커진다.
- 리모트 엔트리 요청 증가
- 로딩 상태 증가
- 텔레메트리 복잡도 증가
- Dependency 충돌 가능성 증가
리모트 경계는 컴포넌트 크기가 아니라 도메인과 오너십 기준으로 정해야 한다.
리모트는 작을수록 좋은 것이 아니라 책임 경계가 명확해야 한다.
+) Shared Dependency가 터지는 때
"Dependency 충돌 가능성 증가"가 실제로 어떤 문제인지 Module Federation 기준으로 정리하면 이렇다.
React가 두 벌 로드되는 문제. 셸과 리모트가 각자 React를 번들에 넣으면 페이지에 React 인스턴스가 두 개 생긴다. 훅이 깨지고 Invalid hook call 에러가 난다. shared에 singleton: true로 선언해 인스턴스를 하나로 강제한다.
shared는 셸이 정해주는 것이 아니라 양쪽이 각자 선언하고 런타임에 합의한다. 셸과 리모트는 빌드 시점에 서로의 설정을 모른다. 각자 자기 번들에 "React를 shared로 쓰고, singleton이고, ^18.2.0이 필요하다"는 정보를 넣어둘 뿐이다.
// shell webpack.config.js
new ModuleFederationPlugin({
name: 'shell',
remotes: { cart: 'cart@https://cdn.example.com/cart/remoteEntry.js' },
shared: { react: { singleton: true, requiredVersion: '^18.2.0' } },
});
// cart-remote webpack.config.js
new ModuleFederationPlugin({
name: 'cart',
exposes: { './CartPage': './src/CartPage' },
shared: { react: { singleton: true, requiredVersion: '^18.2.0' } },
});
런타임에는 공유 스코프가 생긴다. 셸이 먼저 자기 React를 등록하고, 리모트는 로드되면서 스코프를 확인해 조건에 맞는 React가 이미 있으면 자기 사본을 버리고 그것을 쓴다. 셸이 결정권을 가진다기보다 먼저 도착해 자리를 잡는 것이다.
한쪽이 singleton을 빠뜨리면 지켜지지 않는다. 셸이 singleton: true인데 리모트가 shared: ['react']로만 두면, 리모트는 조건이 안 맞을 때 자기 사본을 로드해도 된다고 판단한다. 인스턴스가 둘이 되고 훅이 깨진다. shared 블록은 양쪽이 동일하게 서명하는 계약으로 다뤄야 한다.
버전이 안 맞으면 높은 쪽이 이긴다. singleton이면 인스턴스가 하나여야 하므로 높은 버전이 쓰이고 낮은 쪽에 경고가 나간다. 리모트가 React 18 기준으로 만들어졌는데 셸이 19를 올리면, 리모트 팀이 모르는 사이에 리모트가 19 위에서 돈다. strictVersion: true로 두면 경고 대신 에러가 나서 그 리모트의 로딩이 실패한다. 조용히 깨지는 것보다는 낫지만, 셸이 버전을 올리는 순간 따라오지 못한 리모트가 죽어버린다.
그래서 shared 설정은 보통 공유 패키지로 뺀다. 각 팀이 알아서 맞추게 두면 어긋나기 때문이다.
// @company/mfe-shared-config
module.exports = {
react: { singleton: true, requiredVersion: '^18.2.0' },
'react-dom': { singleton: true, requiredVersion: '^18.2.0' },
};
셸과 모든 리모트가 이것을 가져다 쓰고, 이 패키지를 올리는 것이 곧 조직 차원의 버전 업그레이드가 된다. 여기서 shared의 성격이 드러나게 되는데,
shared에 넣은 의존성은 그 팀의 것이 아니라 조직의 것이 된다. 독립 배포는 얻어도 의존성 업그레이드는 여전히 전원 합의 사항이다. 모노리스에서 겪던 문제가 shared로 선언한 범위만큼 남는 것이다.
반대로 shared에서 빼면 리모트마다 자유롭게 버전을 올릴 수 있지만, 중복 로드로 번들이 커지고 셸과 리모트가 컴포넌트를 주고받는 순간 인스턴스 분리 문제가 생긴다. MFE 소개 글이 흔히 내세우는 기술 스택 자유는 이 비용을 지불한 뒤에 얻는 것이고, 실제로 그 값을 치르는 조직은 많지 않다. 레거시 앱을 점진적으로 흡수하는 한시적인 상황에서 주로 쓰인다.
eager와 bootstrap. shared 모듈을 동기적으로 쓰려면 엔트리를 import('./bootstrap') 하나로 두고 실제 코드를 그 뒤로 미뤄야 한다. 안 하면 Shared module is not available for eager consumption 에러가 난다.
모든 것을 공유하면 안 된다. ...deps로 전부 shared에 넣으면 오히려 번들이 커진다. 여러 리모트가 실제로 함께 쓰는 것만 공유하는 것이 맞다.
컴파일 타임 의존성이 런타임 의존성이 된다. 이것이 근본 원인이다. 타입 체커와 번들러가 잡아주던 문제가 배포 후 브라우저에서 드러난다.
+) 디자인 일관성과 스타일 격리
"Design Consistency 유지 비용"도 구체적으로 보면 두 가지 문제다.
스타일 충돌 리모트들이 전역 CSS를 쓰면 서로 덮어쓴다. CSS Modules, CSS-in-JS의 스코프, Shadow DOM, 또는 리모트별 클래스 prefix로 격리한다.
Tailwind를 쓰는 경우 특히 걸리기 쉬운 지점이 preflight다. preflight는 Tailwind가 빌드 결과 앞에 자동으로 붙이는 CSS 리셋이다.
*, ::before, ::after { box-sizing: border-box; border: 0 solid; }
h1, h2, h3, p { margin: 0; font-size: inherit; font-weight: inherit; }
ul, ol { list-style: none; margin: 0; padding: 0; }
button { background-color: transparent; background-image: none; }
선택자가 *나 ul 같은 전역 태그 선택자라 스코프가 없고 문서 전체에 적용된다.
Cart Remote가 Tailwind를 쓰고 Search Remote는 자체 CSS로 만들어졌다고 해보자. Search는 <ul>에 점이 나오는 것을 전제로 만들어져 있다. 사용자가 Cart 페이지에 들어가면 Cart Remote가 로드되면서 자기 CSS를 <head>에 주입하고, 거기에 preflight가 들어 있다. 이 순간부터 문서 전체의 <ul>에서 점이 사라진다. Cart를 벗어나 Search로 돌아가도 그 CSS는 <head>에 남아 있으므로 Search 목록도 점이 없는 상태가 된다.
리모트가 지연 로딩된다는 점이 여기서 문제를 키우는 것인데, Search를 처음부터 보면 정상인데 Cart를 한 번 갔다 오면 깨지므로, 재현 조건이 "특정 순서로 페이지를 방문했을 때"가 된다. omg
여러 리모트가 모두 Tailwind를 쓰면 preflight가 여러 벌 주입되고 나중에 로드된 것이 앞의 것을 덮는다. Tailwind 버전이 서로 다르면 리셋 내용도 미묘하게 달라서 로드 순서에 따라 화면이 바뀐다.
대응은 리모트에서 corePlugins: { preflight: false }로 끄고 셸이 리셋을 한 벌만 넣는 것이다. 리셋이 전역 관심사라는 것을 인정하고 셸의 책임으로 올리는 셈이다. 유틸리티 클래스 이름 충돌은 prefix 옵션으로 따로 막는다.
Tailwind만의 문제는 아니고 전역 리셋을 넣는 CSS 프레임워크는 같은 문제가 있다. Tailwind가 preflight를 기본으로 켜두기 때문에 모르고 지나가기 쉬워 사례로 자주 언급된다.
디자인 시스템 버전 스큐. 리모트가 독립 배포되면 각자 다른 버전의 디자인 시스템을 쓰게 되고, 같은 화면에 두 세대의 버튼이 같이 보인다. 대응은 디자인 시스템을 shared singleton으로 두거나(대신 업그레이드가 전체 동기화 작업이 된다), CSS 변수 기반 토큰만 셸에서 공급하고 컴포넌트는 각자 두는 것이다.
MFE는 성능을 좋게 만드는가
MFE 에선 기능이나 도메인별로 나누기 때문에 사용자가 필요로 하지 않는 코드는 처음부터 받지 않아도 된다.
모노리스에서는 초기 번들에 Home, Product, Cart 코드가 함께 들어간다. 사용자가 홈만 보고 나가더라도 Checkout 관련 코드를 처음부터 받는다. MFE에서는 Checkout을 별도 리모트로 분리하니 초기 번들이 Shared + Home Remote가 된다.
전체 사용자 100명 중 체크아웃까지 가는 사용자가 5명뿐이라면, 체크아웃 코드를 모든 사용자에게 처음부터 보내는 것은 낭비다.
유리한 조건은 이렇다.
- 특정 기능을 일부 사용자만 방문한다
- 각 도메인의 코드 크기가 크다
- 초기 진입 속도가 중요하다
- 리모트를 지연 로딩할 수 있다
+) 코드 스플리팅과의 비교
위 설명은 코드 스플리팅을 하지 않는 모노리스를 전제로 한다. 모노리스도 라우트 단위로 청크를 나누면 초기 로딩속도는 위와 같은 이유로 빨라진다.
const Checkout = lazy(() => import('./pages/Checkout'));
이렇게 두면 번들러가 Checkout을 별도 청크로 뽑고, 사용자가 홈만 보고 나가면 그 청크는 받지 않는다. Next.js는 페이지 단위로 이것을 기본 제공한다. 즉 "사용자가 가지 않는 화면의 코드를 처음부터 받지 않는다"는 목표는 코드 스플리팅으로 이미 달성된다.
로딩 그림은 양쪽이 거의 같다. 차이는 청크의 출처와 배포 시점에 있다.
| 코드 스플리팅 | MFE | |
| 청크가 만들어지는 시점 | 하나의 빌드 | 리모트마다 별도 빌드 |
| 청크 위치를 아는 방법 | 번들러가 매니페스트에 박아둠 | 런타임에 리모트 매니페스트 조회 |
| Checkout을 고치면 | 전체 재빌드와 전체 배포 | Checkout Remote만 배포 |
| 공유 의존성 | 번들러가 컴파일 타임에 중복 제거 | 런타임에 shared 스코프에서 협상 |
| 버전 스큐 | 없음. 항상 한 빌드에서 나온다 | 있음. 리모트마다 배포 시점이 다르다 |
코드 스플리팅으로 나뉜 청크들은 같은 빌드에서 나온 형제라서 React 버전이 하나인 것이 보장되고 타입도 맞는다.
MFE의 청크들은 서로 남남이라 그 보장이 사라지고, 그래서 shared 협상과 매니페스트 같은 장치가 필요해진다.
성능만 놓고 보면 코드 스플리팅이 유리하다. MFE에는 오버헤드가 두 가지 더 붙는다.
- 매니페스트 조회 단계가 추가된다. 코드 스플리팅은 청크 경로가 번들에 박혀 있어 바로 요청하지만, MFE는 매니페스트를 읽고 엔트리를 받은 다음 청크를 받는다
- 중복 제거가 덜 된다. 하나의 빌드 안에서는 번들러가 여러 청크의 공통 모듈을 정확히 계산해 공유 청크로 뽑는다. MFE는 각 리모트가 자기 빌드만 보기 때문에, shared에 명시하지 않은 라이브러리는 리모트마다 중복으로 들어간다
그래서 번들 크기를 근거로 MFE를 선택하는 것은 근거가 약하다. 그것은 코드 스플리팅으로 더 싸게 해결된다. MFE의 이유는 배포 독립성이고, 번들 이득은 그 부산물이다.
하지만 비용이 옮겨간다
문제는 MFE가 코드를 없애는 것이 아니라 나중으로 미룬다는 점이다.
사용자가 Cart로 이동하면 모노리스에서는 Cart 코드가 이미 초기 번들에 있었다. MFE에서는 Cart에 처음 들어가는 순간 리모트를 가져와야 한다.
사용자 Cart 방문
→ 셸이 라우트 처리
→ 매니페스트 조회
→ 리모트 엔트리 다운로드
→ 청크 다운로드
→ Dependency 해결
→ Bootstrap
→ 데이터 페칭
→ 렌더
여기서 중요한 것은 Cart 화면이 느릴 때 원인이 Cart API만이 아닐 수 있다는 점이다. 리모트 엔트리가 느릴 수도, 청크가 느릴 수도, 데이터 페칭이 느릴 수도 있다. MFE에서는 라우트 전환 시간이 여러 단계로 분해된다.
마이크로 프론트엔드는 성능 최적화 기술이 아니라 성능 트레이드오프를 바꾸는 아키텍처다. 초기 번들 비용을 줄이는 대신 리모트를 런타임에 로딩하고 조립하는 비용을 추가한다.
+) 워터폴을 줄이는 방법
위 단계가 순차적으로 일어나면 그대로 워터폴이 된다. 실무에서 쓰는 완화 수단들이다.
- 매니페스트를 미리 가져온다. 셸 초기 로드 때 전체 매니페스트를 받아두면 라우트 전환 시 조회 단계가 빠진다. 대신 리모트가 배포되면 매니페스트가 낡으므로 짧은 TTL과 콘텐츠 해시를 같이 쓴다
- 리모트 엔트리를 prefetch한다. 링크에 호버하거나 뷰포트에 들어올 때 미리 받는다
- 데이터 페칭을 리모트 로딩과 병렬로 돌린다. 리모트 코드를 기다린 다음 API를 부르면 두 지연이 더해진다
- CDN에서 서빙하고 엔트리는 짧게, 청크는 길게 캐싱한다
그리고 매니페스트가 낡으면 존재하지 않는 청크를 가리킬 수 있다. 리모트 로딩 실패를 에러 바운더리와 fallback으로 감싸는 게 선택이 아니라 필수인 이유다.
+) 관측성
MFE에서 성능과 에러를 측정하려면 지표에 리모트 이름과 버전을 붙여야 한다. 셸이 텔레메트리 프로바이더를 제공하는 이유가 이것이다.
- 에러가 발생했을 때 어느 리모트, 어느 버전, 어느 팀 소유인지 식별
- 라우트 전환 시간을 매니페스트 조회, 엔트리 다운로드, 부트스트랩, 데이터 페칭 단계로 쪼개서 측정
- 리모트별 sourcemap을 따로 업로드해야 스택 트레이스가 읽힌다
+) MFE 말고 다른 선택지
모노리스와 MFE 사이에도 선택지가 있다.
- 모노레포 + 독립 배포 가능한 앱들. Nx나 Turborepo로 여러 앱을 한 저장소에 두고 변경된 것만 빌드하고 배포한다. 코드 공유와 버전 통일은 쉬운데, 저장소가 하나라 오너십 경계는 MFE만큼 강하지 않다
- Next.js Multi-Zone. 라우트 경로별로 다른 Next 앱을 붙이는 방식. 런타임 조합 없이 배포만 나눈다. 페이지 전환이 풀 로드라는 제약이 있어서 도메인 간 이동이 잦지 않을 때 맞는다
- 리버스 프록시 라우팅. 경로별로 다른 앱에 라우팅한다. 가장 단순하고 격리도 확실하다. 페이지 전환 경험을 포기하는 대가로 얻는다
참고한 글
개념과 판단 기준
- Micro Frontends (Martin Fowler / Cam Jackson)
- You probably don't need a micro-frontend (Scott Logic)
- Monoliths, Microfrontends, Monorepos, and the Real Tradeoffs (Steve Kinney)
- Conway's Law (Martin Fowler)
실패 사례와 안티패턴
- 5 Pitfalls of Using Micro Frontends and How to Avoid Them (SitePoint)
- A Catalog of Micro Frontends Anti-patterns (arXiv)
Module Federation
- shared 설정 문서 (module-federation.io)
- Solving micro-frontend challenges with Module Federation (LogRocket)
- Think twice before sharing a dependency
- Pitfalls with Module Federation (ANGULARarchitects)