
마이크로프론트엔드: 프론트도 분리할 수 있을까?
백엔드는 마이크로서비스로 분리하면서 프론트는 왜 여전히 거대한 모놀리스일까? 마이크로프론트엔드가 해결하는 문제와 실전 적용법을 정리했다.

백엔드는 마이크로서비스로 분리하면서 프론트는 왜 여전히 거대한 모놀리스일까? 마이크로프론트엔드가 해결하는 문제와 실전 적용법을 정리했다.
클래스 이름 짓기 지치셨나요? HTML 안에 CSS를 직접 쓰는 기괴한 방식이 왜 전 세계 프론트엔드 표준이 되었는지 파헤쳐봤습니다.

왜 CPU는 빠른데 컴퓨터는 느릴까? 80년 전 고안된 폰 노이만 구조의 혁명적인 아이디어와, 그것이 남긴 치명적인 병목현상에 대해 정리했습니다.

시각 장애인, 마우스가 고장 난 사용자, 그리고 미래의 나를 위한 배려. `alt` 태그 하나가 만드는 큰 차이.

분명히 클래스를 적었는데 화면은 그대로다? 개발자 도구엔 클래스가 있는데 스타일이 없다? Tailwind 실종 사건 수사 일지.

팀이 10명이었을 때는 하나의 React 앱으로 괜찮았다. 근데 팀이 50명이 되고, 피처가 30개가 넘어가고, PR이 매일 100개 이상 쌓이면? 상황이 달라진다.
결제 팀이 체크아웃 컴포넌트를 수정하면, 사용자 프로필 팀까지 영향을 받는다. 배포 파이프라인이 하나라서 누군가 버그를 내면 전체가 홀드된다. 번들 크기는 계속 커지고, 빌드 시간은 5분에서 15분으로 늘어났다.
백엔드는 진작에 마이크로서비스로 풀었는데, 프론트는 왜 아직도 모놀리스냐고.
여기서 등장한 게 마이크로프론트엔드(Micro Frontends)다. 한 문장으로 정리하면: 마이크로서비스 철학을 프론트엔드에 적용하는 아키텍처 패턴.
모놀리식 프론트엔드에서 결제 팀과 검색 팀은 같은 레포를 건드린다. 코드 충돌, 리뷰 병목, 배포 조율 - 다 여기서 나온다.
마이크로프론트엔드는 팀별로 완전히 독립된 애플리케이션을 가질 수 있게 해준다.
팀 구조 예시:
├── Team Shell (앱 셸 / 라우팅)
├── Team Checkout (결제 흐름)
├── Team Search (검색 + 필터링)
├── Team Profile (사용자 계정)
└── Team Marketing (랜딩 페이지)
각 팀은 각자의 레포, 각자의 배포 파이프라인, 각자의 기술 스택을 가진다.
결제 팀이 새 기능을 만들었으면, 다른 팀 눈치 볼 필요 없이 배포할 수 있어야 한다. 마이크로프론트엔드는 이걸 가능하게 한다.
배포 흐름 비교:
모놀리스:
결제 기능 완성 → 전체 앱 빌드 → QA → 배포 (2시간)
마이크로프론트엔드:
결제 기능 완성 → 결제 MF만 빌드 → 배포 (10분)
레거시 팀은 Angular로 짰고, 신규 팀은 React를 쓰고 싶다. 모놀리스에서는 불가능하다. 마이크로프론트엔드에서는 가능하다 — 물론 트레이드오프가 있지만.
마이크로프론트엔드를 구현하는 방법은 크게 세 가지다. 언제 합치느냐에 따라 나뉜다.
빌드할 때 모든 마이크로 앱을 npm 패키지로 묶어서 하나의 앱으로 만든다.
// shell/package.json
{
"dependencies": {
"@mycompany/checkout": "^2.1.0",
"@mycompany/search": "^1.4.0",
"@mycompany/profile": "^3.0.0"
}
}
// shell/src/App.tsx
import { CheckoutApp } from "@mycompany/checkout";
import { SearchApp } from "@mycompany/search";
export default function App() {
return (
<Router>
<Route path="/checkout" component={CheckoutApp} />
<Route path="/search" component={SearchApp} />
</Router>
);
}
장점: 간단하다. 타입 안전성이 있다. 번들 최적화가 쉽다.
단점: 독립 배포가 불가능하다. 하나 바뀌면 셸 앱도 다시 빌드해야 한다. 버전 관리가 복잡해진다.
언제 쓸까: 팀이 작고, 배포 독립성보다 단순함이 중요할 때.
이게 진정한 마이크로프론트엔드다. 런타임에 동적으로 마이크로 앱을 로드한다. 이 방식의 핵심이 Module Federation이다.
사용자가 /checkout 방문 →
셸 앱이 checkout.mycompany.com/remoteEntry.js 로드 →
CheckoutApp 컴포넌트 동적 임포트 →
렌더링
각 마이크로 앱이 독자적으로 배포되어 있고, 셸이 런타임에 가져다 쓴다. 결제 팀이 새 버전을 배포하면? 셸 앱 재배포 없이 즉시 반영된다.
서버에서 각 마이크로 앱의 HTML을 가져와서 조합한 후 클라이언트에 내려준다.
요청 →
조합 서버 (Composition Server) →
헤더 팀 서버에서 HTML 조각 가져오기
검색 팀 서버에서 HTML 조각 가져오기
푸터 팀 서버에서 HTML 조각 가져오기
→ 조합 → 응답
SSI(Server Side Includes)나 Edge Side Includes를 쓰기도 하고, Next.js + Edge Functions 조합으로 구현하기도 한다.
장점: SEO 완벽. 초기 로딩 빠름. 단점: 서버 인프라 복잡도 증가. 각 팀이 서버 렌더링 지원해야 함.
Module Federation은 webpack 5에서 도입됐고, rspack에서도 지원한다. 마이크로프론트엔드 런타임 합성의 사실상 표준이다.
// checkout/webpack.config.js (Remote)
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "checkout",
filename: "remoteEntry.js",
exposes: {
"./CheckoutApp": "./src/CheckoutApp",
"./useCart": "./src/hooks/useCart",
},
shared: {
react: { singleton: true, requiredVersion: "^19.0.0" },
"react-dom": { singleton: true, requiredVersion: "^19.0.0" },
},
}),
],
};
// shell/webpack.config.js (Host)
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "shell",
remotes: {
checkout: "checkout@https://checkout.mycompany.com/remoteEntry.js",
search: "search@https://search.mycompany.com/remoteEntry.js",
},
shared: {
react: { singleton: true, requiredVersion: "^19.0.0" },
"react-dom": { singleton: true, requiredVersion: "^19.0.0" },
},
}),
],
};
// shell/src/pages/CheckoutPage.tsx
import React, { Suspense, lazy } from "react";
// 동적 임포트 — 런타임에 checkout 서버에서 로드
const CheckoutApp = lazy(() => import("checkout/CheckoutApp"));
export default function CheckoutPage() {
return (
<Suspense fallback={<div>결제 모듈 로딩 중...</div>}>
<CheckoutApp />
</Suspense>
);
}
rspack은 rust 기반 webpack 호환 번들러다. Module Federation 지원하면서 빌드 속도가 5-10배 빠르다.
// rspack.config.js
const { ModuleFederationPlugin } = require("@module-federation/enhanced/rspack");
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "checkout",
filename: "remoteEntry.js",
exposes: {
"./CheckoutApp": "./src/CheckoutApp",
},
shared: ["react", "react-dom"],
}),
],
};
마이크로프론트엔드에서 가장 골치아픈 문제 중 하나다.
React, React DOM은 반드시 싱글턴이어야 한다. 두 버전이 동시에 로드되면 "Invalid hook call" 같은 알 수 없는 에러가 터진다.
shared: {
react: {
singleton: true, // 여러 버전 로드 금지
requiredVersion: "^19.0.0",
eager: false, // 비동기 로드 허용
},
"react-dom": {
singleton: true,
requiredVersion: "^19.0.0",
},
// 디자인 시스템도 싱글턴이 나을 때가 많다
"@mycompany/design-system": {
singleton: true,
requiredVersion: "^5.0.0",
},
}
shared: {
lodash: {
// singleton 아님 - 각 팀이 자기 버전 가져도 OK
requiredVersion: "^4.0.0",
strictVersion: false, // 버전 미스매치 경고만, 에러 아님
},
}
[ModuleFederation] Shared module react@18.0.0 is already provided
by host. Version 19.0.0 from remote checkout will not be used.
이 경고가 뜨면 팀 간에 버전을 맞춰야 한다는 신호다.
각 마이크로 앱이 내부적으로 라우팅을 가질 때 셸과 충돌하면 안 된다.
// checkout/src/CheckoutApp.tsx
import { MemoryRouter, Route, Routes } from "react-router-dom";
// 셸에서 /checkout/* 를 이 컴포넌트에 넘겨주면
// 내부적으로 MemoryRouter로 서브 라우팅 처리
export default function CheckoutApp({ basePath }: { basePath: string }) {
return (
<MemoryRouter initialEntries={[basePath]}>
<Routes>
<Route path="/checkout/cart" element={<CartPage />} />
<Route path="/checkout/payment" element={<PaymentPage />} />
<Route path="/checkout/confirm" element={<ConfirmPage />} />
</Routes>
</MemoryRouter>
);
}
// shell/src/App.tsx
import { BrowserRouter, Route, Routes } from "react-router-dom";
export default function Shell() {
return (
<BrowserRouter>
<Routes>
{/* 셸의 라우터가 상위 경로 담당 */}
<Route path="/checkout/*" element={<CheckoutPage />} />
<Route path="/search/*" element={<SearchPage />} />
</Routes>
</BrowserRouter>
);
}
마이크로 앱끼리 직접 임포트하지 말고, 커스텀 이벤트로 통신한다.
// shared/src/eventBus.ts
type EventMap = {
"cart:updated": { itemCount: number; total: number };
"user:logged-in": { userId: string; name: string };
"navigation:push": { path: string };
};
class EventBus {
private handlers: Map<string, Set<Function>> = new Map();
emit<K extends keyof EventMap>(event: K, payload: EventMap[K]) {
const fns = this.handlers.get(event);
fns?.forEach((fn) => fn(payload));
}
on<K extends keyof EventMap>(
event: K,
handler: (payload: EventMap[K]) => void
) {
if (!this.handlers.has(event)) {
this.handlers.set(event, new Set());
}
this.handlers.get(event)!.add(handler);
return () => this.handlers.get(event)!.delete(handler);
}
}
export const eventBus = new EventBus();
// checkout/src/CartPage.tsx - 이벤트 발행
eventBus.emit("cart:updated", { itemCount: 3, total: 89000 });
// shell/src/Header.tsx - 이벤트 수신
useEffect(() => {
return eventBus.on("cart:updated", ({ itemCount }) => {
setCartCount(itemCount);
});
}, []);
Spotify 웹 플레이어는 마이크로프론트엔드로 구성된 유명한 사례다. 플레이어, 브라우저, 검색, 소셜 기능이 각기 다른 팀에서 독립적으로 개발된다.
IKEA의 e-commerce 플랫폼은 마이크로프론트엔드를 도입해서 팀 간 독립성을 확보했다. 각 국가 팀이 독자적인 기능을 배포할 수 있다.
마이크로프론트엔드가 항상 정답은 아니다.
마이크로프론트엔드가 맞는 상황:
✅ 팀이 5개 이상이고 각 팀이 프론트엔드를 독립적으로 개발
✅ 조직이 도메인 기반으로 분리된 경우
✅ 배포 독립성이 비즈니스 요구사항인 경우
✅ 기술 스택 이질성이 불가피한 경우
마이크로프론트엔드가 과킬인 상황:
❌ 팀이 2-3명
❌ 하나의 팀이 전체 프론트엔드 관리
❌ 통일된 UX/디자인이 최우선
❌ 인프라 복잡도를 감당할 DevOps 역량 부족
마이크로프론트엔드는 공짜가 아니다.
운영 복잡도: 배포 파이프라인이 5개가 된다. 모니터링도 5개 해야 한다.
초기 로딩 성능: remoteEntry.js를 각각 불러오면 네트워크 왕복이 늘어난다. 번들 사이즈 최적화가 더 어렵다.
개발 경험: 로컬에서 전체 시스템 띄우려면 여러 서버를 동시에 실행해야 한다.
# 로컬 개발 시 이런 식으로 여러 서버를 동시에 띄워야 함
# package.json (루트)
{
"scripts": {
"dev": "concurrently \"npm run dev:shell\" \"npm run dev:checkout\" \"npm run dev:search\"",
"dev:shell": "cd apps/shell && npm run dev",
"dev:checkout": "cd apps/checkout && npm run dev",
"dev:search": "cd apps/search && npm run dev"
}
}
CSS 격리: 각 앱의 CSS가 충돌할 수 있다. CSS Modules, Shadow DOM, 또는 네이밍 컨벤션으로 격리해야 한다.
| 항목 | 모놀리식 프론트엔드 | 마이크로프론트엔드 |
|---|---|---|
| 팀 독립성 | 낮음 | 높음 |
| 배포 독립성 | 없음 | 팀별 독립 배포 |
| 기술 스택 | 통일 | 팀별 선택 가능 |
| 초기 설정 비용 | 낮음 | 높음 |
| 운영 복잡도 | 낮음 | 높음 |
| 번들 최적화 | 쉬움 | 어려움 |
| 개발 경험 | 간단 | 복잡 |
| 적합 팀 규모 | ~10명 | 20명 이상 |
마이크로프론트엔드는 조직 규모 문제를 기술로 해결하는 패턴이다. 팀이 10명 이하인데 마이크로프론트엔드를 도입하면? 복잡도만 늘어난다.
하지만 팀이 여러 개고, 각 팀이 독립적으로 움직여야 하고, 프론트엔드 모놀리스가 실제로 병목이 되고 있다면 — 마이크로프론트엔드는 강력한 해법이다.
Module Federation으로 시작해서 점진적으로 분리하는 게 현실적인 접근이다. 한 번에 전체를 바꾸려 하지 말고, 하나의 도메인(예: 결제)부터 분리해서 패턴을 잡자.