6 min read

Vue 3 모놀리식 콘솔을 Module Federation으로 쪼개기

B2B SaaS 콘솔의 모놀리식 Vue 3 프로젝트를 pnpm Workspace + Module Federation으로 마이크로 프론트엔드 구조로 전환한 경험을 공유합니다.

하나의 저장소가 만든 병목

B2B SaaS 콘솔을 만들고 있었습니다. 분석, 오디언스, 캠페인, 설정 등 10개 넘는 도메인이 하나의 Vue 3 프로젝트 안에 다 들어 있었죠.

팀이 3명일 때는 괜찮았습니다. 그런데 도메인마다 담당자가 생기면서 슬슬 불편해지기 시작했습니다.

  • A팀이 분석 쪽을 배포하고 싶은데, B팀 캠페인 PR이 머지되길 기다려야 합니다.
  • 빌드 한 번 돌리면 5분이 넘고, 로컬 HMR도 갈수록 느려집니다.
  • 공유 컴포넌트 하나 고쳤을 뿐인데 엉뚱한 곳이 깨집니다.

결국 각 팀이 서로 눈치 보지 않고 개발·배포할 수 있는 구조를 고민하게 됐습니다.


왜 Module Federation이었나

마이크로 프론트엔드를 구현하는 방법은 몇 가지가 있습니다.

방식 좋은 점 아쉬운 점
iframe 완벽하게 격리됨 UX가 나쁘고 상태 공유가 안 됨
npm 패키지로 분리 타입이 안전함 뭐 하나 바꾸면 전체 재빌드
Module Federation 런타임에 합쳐지고, 따로 배포 가능 초기 세팅이 복잡함

저희한테 중요했던 건 딱 두 가지였습니다.

  1. 사용자 눈에는 하나의 앱으로 보여야 한다.
  2. 각 도메인을 따로 배포할 수 있어야 한다.

둘 다 만족하려면 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 자동 등록

패키지가 10개 넘는데 이걸 하나하나 수동으로 등록하면 빠뜨리기 딱 좋습니다. 그래서 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에서 버전 일관성 체크를 넣어뒀습니다.

참고 자료