Vue 3 모놀리식 콘솔을 Module Federation으로 쪼갠 이야기
하나의 저장소가 만든 병목
B2B SaaS 콘솔을 만들고 있었습니다. 분석, 오디언스, 캠페인, 설정 등 10개 넘는 도메인이 하나의 Vue 3 프로젝트 안에 다 들어 있었죠.
팀이 3명일 때는 괜찮았습니다. 그런데 도메인마다 담당자가 생기면서 슬슬 불편해지기 시작했습니다.
- A팀이 분석 쪽을 배포하고 싶은데, B팀 캠페인 PR이 머지되길 기다려야 합니다.
- 빌드 한 번 돌리면 5분이 넘고, 로컬 HMR도 갈수록 느려집니다.
- 공유 컴포넌트 하나 고쳤을 뿐인데 엉뚱한 곳이 깨집니다.
결국 "각 팀이 서로 눈치 보지 않고 개발·배포할 수 있는 구조"를 고민하게 됐습니다.
왜 Module Federation이었나
마이크로 프론트엔드를 구현하는 방법은 몇 가지가 있습니다.
| 방식 | 좋은 점 | 아쉬운 점 |
|---|---|---|
| iframe | 완벽하게 격리됨 | UX가 나쁘고 상태 공유가 안 됨 |
| npm 패키지로 분리 | 타입이 안전함 | 뭐 하나 바꾸면 전체 재빌드 |
| Module Federation | 런타임에 합쳐지고, 따로 배포 가능 | 초기 세팅이 복잡함 |
저희한테 중요했던 건 딱 두 가지였습니다.
- 사용자 눈에는 하나의 앱으로 보여야 한다.
- 각 도메인을 따로 배포할 수 있어야 한다.
둘 다 만족하려면 Module Federation 말고는 선택지가 없었습니다.
최종 구조
pnpm Workspace 위에 Module Federation을 얹은 모노레포입니다.
monorepo/
├── packages/
│ ├── main/ # Host (Shell)
│ ├── shared/ # 공유 라이브러리, 디자인 토큰
│ ├── analytics/ # Remote: 분석
│ ├── audience/ # Remote: 오디언스
│ ├── campaign/ # Remote: 캠페인
│ ├── settings/ # Remote: 설정
│ ├── home/ # Remote: 대시보드
│ └── ... # 도메인별 마이크로 앱
├── configs/ # eslint, tsconfig, stylelint 공유
├── styles/ # 글로벌 SCSS 변수
└── pnpm-workspace.yaml
main이 껍데기(Shell) 역할을 하고, 나머지는 각각 독립적으로 빌드·배포되는 Remote 앱입니다.
핵심 구현
RsBuild + Module Federation Plugin
빌드 도구를 Webpack에서 RsBuild로 바꾸면서 @module-federation/rsbuild-plugin을 붙였습니다.
Host 쪽 설정은 이렇습니다.
// packages/main/rsbuild.config.ts
import { pluginModuleFederation } from "@module-federation/rsbuild-plugin";
export default defineConfig({
plugins: [
pluginModuleFederation({
name: "main",
shared: getSharedModules(),
remotes: getRemoteModules(["*"]),
filename: "remoteEntry.js",
shareStrategy: "loaded-first",
}),
],
});
shareStrategy: "loaded-first"가 핵심입니다. 이미 로드된 모듈이 있으면 그걸 쓰겠다는 뜻인데, Vue나 Pinia처럼 반드시 하나만 있어야 하는 라이브러리에서 꼭 필요한 옵션입니다.
Remote 자동 등록
패키지가 13개인데 이걸 하나하나 수동으로 등록하면 빠뜨리기 딱 좋습니다. 그래서 packages/ 폴더를 스캔해서 remote 목록을 자동 생성하도록 만들었습니다.
// configs/scripts/build.ts
export function getRemoteModules(useModules: string[]) {
const isDeployment = process.env.IS_DEPLOYMENT === "true";
const packages = scanPackages(); // packages/ 하위 자동 스캔
return packages
.filter((m) => useModules.includes("*") || useModules.includes(m.name))
.reduce((result, module) => {
if (!isDeployment) {
// 로컬: 각 앱의 dev server manifest
result[module.name] = `${module.name}@https://local.dev:${module.port}/mf-manifest.json`;
} else {
// 배포: CDN에 버전별로 올라간 경로
result[module.name] = `${module.name}@${cdnURL}/modules/${module.name}/${module.version}/remoteEntry.js`;
}
return result;
}, {});
}
로컬에서는 mf-manifest.json을 바라보고, 배포할 때는 버전이 박힌 CDN 경로를 씁니다. 이렇게 해두면 Remote 앱을 따로 배포해도 Host가 항상 맞는 버전을 가져옵니다.
Shared 의존성: 알아서 싱글톤으로
공통 의존성 설정도 자동화했습니다.
export function getSharedModules(deps = rootPackage.dependencies) {
return Object.keys(deps).reduce((result, key) => {
result[key] = {
requiredVersion: deps[key],
singleton: true,
};
return result;
}, {});
}
루트 package.json의 dependencies를 그대로 읽어서 shared 설정을 만듭니다. 라이브러리 하나 추가할 때마다 설정 파일 건드릴 필요가 없죠.
전부 singleton: true로 잡은 이유는 간단합니다. Vue, Pinia, Element Plus 같은 건 인스턴스가 두 개 뜨는 순간 상태가 꼬이거나 스타일이 깨집니다.
고민이 필요했던 부분
1. Vue 인스턴스가 두 개 뜨는 문제
Remote 앱에서 습관적으로 createApp()을 호출하면 Vue가 두 벌 뜹니다. 이 상태에서 Pinia Store를 쓰려고 하면 "getActivePinia was called with no active Pinia" 에러가 나죠.
→ Remote는 createApp()을 직접 부르지 않고, Host가 만든 앱 인스턴스를 넘겨받도록 바꿨습니다.
2. Element Plus 스타일이 깨지는 현상
일부 컴포넌트에서는 ui 프레임워크인 Element Plus 를 사용하고 있었고,
Remote마다 Element Plus CSS를 각자 불러오면 스타일이 겹칩니다. 특히 다크모드 CSS 변수가 충돌하면서 UI가 이상해지는 경우가 있었습니다.
→ CSS는 Host에서 딱 한 번만 로드하고, Remote는 컴포넌트만 가져다 쓰도록 정리했습니다. 디자인 토큰도 shared 패키지에서 한 곳에서 관리합니다.
4. Remote 앱의 타입을 Host에서 모르는 문제도...
Module Federation은 런타임에 합쳐지다 보니, Remote가 뭘 export하는지 Host 입장에서 타입을 알 수가 없습니다. @cloudbeds/webpack-module-federation-types-plugin으로 빌드할 때 타입 정의를 자동 생성해서 해결했습니다.
결국, 어떤걸 해결할 수 있었을까?
| Before | After | |
|---|---|---|
| 빌드 | 전체 5분+ | 앱 하나당 30초~1분 |
| 배포 | 다른 팀 끝날 때까지 대기 | 팀별로 알아서 배포 |
| HMR | 전체 리로드 | 해당 앱만 갱신 |
| 코드 충돌 | 자주 발생 | 도메인 경계가 명확해서 거의 없음 |
남은 문제는 뭐가 있었을까
- 초기 세팅에 2~3주 걸렸습니다. 빌드 스크립트, CI/CD, 로컬 개발 환경을 전부 새로 짜야 했습니다.
- 디버깅이 까다롭습니다. Remote 앱끼리 엮인 문제가 생기면 원인 찾기가 쉽지 않습니다.
- 버전을 맞춰야 합니다. shared 라이브러리 버전이 어긋나면 런타임에 터집니다. CI에서 버전 일관성 체크를 넣어뒀습니다.
마무리
Module Federation은 "팀이 서로 독립적으로 움직여야 하는 규모"에서 진가를 발휘합니다.
소수 인원이 하나의 앱을 만드는 상황이라면 굳이 이렇게까지 할 필요 없습니다. 하지만 도메인별로 팀이 나뉘어 있고, 배포 주기가 제각각이고, 서로 코드에 영향을 안 줘야 한다면 — 투자할 만한 가치가 있었습니다.
Vue 3에서 Module Federation 사례가 많지 않아서 삽질을 꽤 했지만, @module-federation/enhanced + RsBuild 조합은 생각보다 잘 돌아갔습니다.
참고 자료
- Module Federation 공식 문서 — RsBuild Plugin — 설정 옵션 레퍼런스
- How to build Microfrontends with Module Federation and Vue — Vue 3 + MF 실전 가이드
- Micro-Frontends: Are They Still Worth It in 2025? — MF, single-spa, qiankun 등 방식별 비교
- The Micro-Frontend Architecture Handbook (freeCodeCamp) — 전체 아키텍처 개론
- From iframes to Module Federation (freeCodeCamp) — iframe → MF 진화 과정
- RsBuild Module Federation Guide — RsBuild 공식 MF 가이드