TL;DR: 5개 레거시 앱 → 하나의 통합 플랫폼. 2~3명이서. AI랑 같이. 3개월 만에.
TL;DR: 5개 레거시 앱 → 하나의 통합 플랫폼. 2~3명이서. AI랑 같이. 3개월 만에.
Sangwoo Yang
@IGhost-P
프론트엔드 개발자로 일하고 있는데, 어느 날 이런 이야기를 듣게 됐다.
“백엔드 전면 개편합니다. API 다 바뀌어요. 프론트도 좀 해주세요.”

원래는 api url만 바꾸면 되는 수준이라면서요..
그런데 프론트엔드 상황을 보니: Vue 2 앱 하나, Vue 3 앱 하나, Django Admin등.. 4~6년 묵은 레거시들. 각 앱마다 패키지 매니저가 다르고, 빌드 설정이 다르고, 배포 파이프라인이 다르다.
분석가가 모델 하나 확인하려면 앱 세 개를 돌아다녀야 하는 상황이었다.
그래서 이참에 전부 새로 만들기로 했다. 가용 인원? 2명. 중간에 1명 추가. 10월에 시작해서 1월 중순까지 끝내야 한다. 그리고 한 가지 더 — 1~2년 뒤에 이 통합 프로젝트가 다시 분리될 수도 있다.
이 글은 그 조건에서 Module Federation + Monorepo를 선택하고 개발한 이야기다. 왜 이 기술을 선택했는지, 런타임에서 실제로 어떤 일이 일어나는지, 그리고 어디서 삽질했는지를 정리해보려 한다.
이 글은 크게 세 축으로 구성된다. Module Federation의 런타임 동작과 핵심 개념(2~4장), Monorepo와 배포 전략(5~6장), 그리고 실전 개발 경험과 성과(7~8장)이다.
Martin Fowler는 이런 말을 했다.
“you shouldn’t start a new project with microservices, even if you’re sure your application will be big enough to make it worthwhile”
맞는 말이라고 생각한다. 처음부터 MSA 하겠다고 달려드는 건 대부분 오버엔지니어링이다.
그런데 우리는 이미 MSA 상태였다.
Dashboard(모니터링), Model Manager(모델 관리), Admin(시스템 설정). 각 앱이 다루는 비즈니스 도메인이 명확했고, 기존에도 물리적으로 분리된 앱이었다. Luca Mezzalira가 O’Reilly Building Micro-Frontends에서 정의한 것처럼:
“A micro-frontend is a portion of the UI representing a business domain that is autonomous, independently deliverable, and owned by a single team.”
우리 상황이 이 정의에 딱 맞았다. 도메인별로 자율적이고, 독립 배포 가능하고, 각 앱을 한 사람이 책임지는 구조.
마이크로 프론트엔드에는 크게 두 가지 분리 방식이 있다.
Horizontal Split: 하나의 페이지를 여러 팀이 쪼갬 (헤더 팀, 사이드바 팀, 콘텐츠 팀)
Vertical Split: 비즈니스 도메인 단위로 통째로 분리 (Dashboard 앱, Admin 앱)
Feature-Sliced Design 블로그 표현을 빌리면, Horizontal은 “micro-frontends within a page”, Vertical은 “micro-frontends as pages”다.
우리는 Vertical Split을 골랐다. 이유는 단순하다:
개발자 A → Dashboard 도메인 전체 + AI Agent
개발자 B → Model Manager 도메인 전체 + AI Agent
개발자 C → Admin 도메인 전체 + AI Agent각자 도메인을 통째로 맡으면 AI와의 협업이 훨씬 효율적이다. AI에게 “이 도메인의 이 페이지 만들어줘”라고 하면 되니까. 컨텍스트 스위칭이 없다.
그런데 분리만 하면 안 된다. UI도 통일돼야 하고, 인증도 공유해야 하고, 코드 컨벤션도 맞춰야 한다. 그래서 Module Federation으로 런타임 통합을, Monorepo로 개발 시점의 일관성을 잡기로 했다.
import('remoteApp/App') 한 줄이 실제로 어떤 일을 하는지 살펴보려 한다.
[전통적인 번들링] [Module Federation]
bundle.js (5MB) Shell (200KB)
├─ Shell 코드 ├─ Shell 코드
├─ Dashboard 코드 전체 └─ Shared Scope
├─ Model Manager 코드 전체 ├─ react ✓
├─ Admin 코드 전체 ├─ react-dom ✓
└─ React (중복 3번!) └─ ...
빌드 1개, 배포 1개 Dashboard (180KB) ← 필요할 때 로드
전체 재빌드 Model Manager (150KB) ← 필요할 때 로드
Admin (120KB) ← 필요할 때 로드코드 스플리팅으로 충분하지 않은가 하는 의문이 들 수 있다. 틀린 말은 아니지만, 코드 스플리팅은 같은 빌드 아티팩트 안에서의 분리다. 빌드 자체는 여전히 하나고, 배포도 한꺼번에 해야 한다.
Module Federation은 빌드 그래프 자체를 분리한다. Dashboard 코드를 고치면 Dashboard만 빌드하고, Shell은 런타임에 새 버전을 가져온다. 이 점이 근본적인 차이다.
Webpack 5에서 Zack Jackson이 제안한 Container Interface. Module Federation의 본질은 이 인터페이스에 있다.
interface Container {
init(shareScope: SharedScope): Promise<void>
get(module: string): Promise<() => Module>
}init은 공유 의존성을 초기화하고, get은 노출된 모듈을 가져온다. 그게 전부다. 나머지는 이 두 메서드를 실행하기 위한 인프라에 불과하다.
우리는 @module-federation/vite를 사용한다. Remote 앱 빌드 시 플러그인이 네 가지 결과물을 만든다.
// dashboard/vite.config.ts
federation({
...createRemoteFederationConfig({
name: 'dashboard-remote',
exposes: { './App': './src/app/index.tsx' },
}),
manifest: true,
})① remoteEntry.js — Container Interface의 실제 구현체다. ~2KB 정도로 가볍다.
// remoteEntry.js (간략화)
var moduleMap = {
"./App": () => import("./assets/App-a1b2c3.js")
};
var container = {
init(scope) { sharedScope = scope; },
get(module) { return moduleMap[module](); }
};
window["dashboard-remote"] = container;② mf-manifest.json — 이 컨테이너가 뭘 제공하는지, 어떤 의존성을 공유하는지 적힌 자기소개서.
③ 모듈 청크 — 해시가 포함된 실제 코드 (App-a1b2c3.js). 여기에 진짜 비즈니스 로직이 들어있다.
④ 폴백 번들 — Shared Scope에 공유 의존성이 없을 때 쓰는 자체 번들.
사용자가 /dashboard로 이동하면:
1. Shell이 mf-manifest.json 요청
2. remoteEntry.js를 <script>로 동적 로드 → window에 Container 등록
3. container.init() 호출 → Shell의 Shared Scope 전달
4. container.get('./App') → 모듈 팩토리 반환, 실제 청크 로드
5. React.lazy가 컴포넌트로 렌더링3단계가 핵심이다. init()에서 버전 협상(Version Negotiation)이 일어난다. Module Federation 공식 문서에 따르면 아래와 같다

medium 테이블도 지원 안해줘??
우리 구조에서는 Shell이 React 19를 Shared Scope에 등록하면, 이후 로드되는 모든 Remote가 0KB 추가 없이 같은 React를 재사용한다.

singleton이 제대로 적용되고 있는지 확인하고 싶을 때, 브라우저 콘솔에서 아래 명령어로 확인할 수 있다:
console.log(__FEDERATION__.__SHARE__)모든 공유 모듈의 버전, 로드 상태, 출처를 확인할 수 있다.
Shared Scope에서 버전 협상이 일어난다는 걸 이해했다면, 다음 질문은 자연스럽다. 어떤 의존성을 공유해야 하고, 그중 어떤 것을 singleton으로 잠가야 하는가.
Indra Avitech의 엔지니어 Matej Fačkovec가 쓴 “Beyond the Hype” 글에 이런 문장이 있다:
“In a one-parent SPA composed of multiple remotes, singleton dependencies are not optional — they are mandatory.”
깊이 공감하는 부분이다. 그리고 이건 React만의 이야기가 아니다.
그렇지 않다. 내부 상태나 컨텍스트를 갖는 모든 라이브러리가 singleton이어야 한다.
export const SHARED_DEPENDENCIES = {
// React 코어 — 두 개면 useContext가 undefined 반환
react: { singleton: true, requiredVersion: '^19.2.3', eager: true },
'react-dom': { singleton: true, requiredVersion: '^19.2.3', eager: true },
// 라우팅 - 두 개면 뒤로가기가 안 됨
'react-router-dom': { singleton: true, requiredVersion: '^7.8.2', eager: true },
// 상태 관리 - 두 개면 atom 값이 동기화 안 됨
jotai: { singleton: true, requiredVersion: '^2.13.1', eager: true },
// 서버 상태 - 두 개면 같은 API를 두 번 호출
'@tanstack/react-query': { singleton: true, requiredVersion: '^5.85.9', eager: true },
// 워크스페이스 패키지
'@shared/state': { singleton: true, eager: true },
'@shared/api': { singleton: true, eager: true },
'@shared/ui': { singleton: true, eager: true },
'@shared/auth': { singleton: true, eager: true },
// ...
}이 목록은 실제 코드에서 가져온 것이다. 모든 공유 의존성에 singleton: true와 eager: true를 건다.
eager: true는 "비동기로 기다리지 말고 즉시 사용 가능하게 해라"는 옵션이다.
Webpack 기반 Module Federation에서는 eager: true를 쓰려면 주의가 필요하다. entry 파일이 shared module에 동기적으로 접근하려 하지만 Federation 런타임이 아직 초기화되지 않아 "Shared module is not available for eager consumption" 에러가 발생한다. 이를 피하려면 import('./bootstrap') 패턴으로 async boundary를 수동으로 만들어야 한다.
우리는 @module-federation/vite를 쓴다. Vite 플러그인은 async boundary를 내부적으로 처리하기 때문에, bootstrap 패턴 없이도 eager: true를 사용할 수 있다. 다만 eager: true는 shared module을 entry 청크에 직접 포함시키므로 초기 번들 사이즈가 증가하는 trade-off가 있다. 우리는 초기 로딩 시 비동기 waterfall을 줄이는 것이 더 중요하다고 판단해서 전부 eager: true로 설정했다.
Module Federation의 공유는 패키지 루트 레벨에서만 동작한다.
import { useQuery } from '@tanstack/react-query' // ✅ Shared Scope에서 해결
import { hashKey } from '@tanstack/react-query/build/modern/utils' // ❌ 별도 번들링우리 워크스페이스 패키지도 subpath export를 쓰고 있어서, 명시적으로 등록해줘야 했다:
'@shared/api': { singleton: true, eager: true },
'@shared/api/config': { singleton: true, eager: true },
'@shared/api/shared': { singleton: true, eager: true },이걸 빠뜨리면 @shared/api는 공유되는데 @shared/api/config는 각 Remote에서 따로 번들링된다. 앱 로드할 때 별도 네트워크 요청이 나가고, 만약 내부에 상태가 있으면 동기화도 안 된다.
개발 중에 한 앱만 @tanstack/react-query를 버전 업한 적이 있었다. Singleton이라 하나의 버전만 로드되는데, share strategy 기본값(loaded-first)에서는 어떤 버전이 로드될지가 로딩 순서에 따라 달라진다.
해결 방법은 단순했다. 공유 의존성 버전을 루트 package.json에서 일원화하고, Monorepo 안에서 버전이 어긋나면 빌드 단계에서 잡히도록 했다.
Singleton으로 인스턴스를 하나로 잡았다. 그런데 인스턴스가 하나라는 것만으로 충분할까. React에서는 상태 전파의 문제가 남아 있다.

React Context는 Fiber 트리를 타고 전파된다. useContext를 호출하면 현재 Fiber에서 부모 방향으로 올라가며 가장 가까운 Provider를 찾는다.
Host와 Remote가 같은 React 인스턴스를 쓰면(singleton) Fiber 트리가 하나로 연결된다. Context가 정상 전파된다.
singleton이 필수인 이유가 여기에 있다. 3번 섹션에서 강조했던 내용이 여기서 연결된다.
// apps/shell/src/main.tsx (실제 코드)
root.render(
<React.StrictMode><JotaiProvider><JotaiHydrator defaultTimezone={...} defaultLocale={...}><QueryClientProvider client={queryClient}><I18nProvider><DateConfigProvider><RouterProvider router={router} /></DateConfigProvider></I18nProvider></QueryClientProvider></JotaiHydrator></JotaiProvider></React.StrictMode>,
)// apps/shell/src/router.tsx (실제 코드)
const DashboardApp = lazy(() => import('dashboardApp/App'))
const ModelManagerApp = lazy(() => import('modelManagerApp/App'))
const AdminApp = lazy(() => import('adminApp/App'))
export const router = createBrowserRouter([
{
path: '/',
element: (
<AuthProvider config={authConfig} skipAuth={skipAuth}><AppInitializer><Layout /></AppInitializer></AuthProvider>
),
children: [
{
path: 'services/:serviceId/*',
element: (
<Suspense fallback={<DashboardLoading />}><DashboardApp /></Suspense>
),
},
// Model Manager, Admin도 동일 패턴
],
},
])핵심은 Provider는 Shell에서 감싸고, Remote는 소비만 하는 구조다. Remote 앱 코드에서 useAuth(), useQuery(), useAtom() 같은 훅을 호출하면, 전부 Shell이 만든 Provider에서 값을 가져온다.
한 가지 까다로운 부분이 있었다. API 클라이언트는 React 트리 바깥에서 초기화될 수 있다. axios 인스턴스를 모듈 스코프에서 만들면, 그 시점에는 React Context가 없다.
우리는 OIDC 라이브러리(oidc-client-ts)가 토큰을 localStorage에 저장하는 걸 이용했다. API 클라이언트가 요청할 때마다 localStorage에서 직접 토큰을 읽는다. React 트리와 독립적으로 토큰에 접근할 수 있는 셀이다.
Remote 앱이 Standalone 모드(독립 실행)와 Federated 모드(Shell 안에서 실행)를 둘 다 지원해야 했는데, Standalone용 Provider가 Federated 모드에서도 활성화되면서 Shell의 Provider를 덮어쓰는 문제가 발생했다.
// dashboard/src/main.tsx (실제 코드)
if (checkStandaloneMode()) {
renderStandaloneApp() // Provider 포함
} else {
// Federated 모드 - Provider 없이 App만 렌더링
ReactDOM.createRoot(...).render(<App />)
}모드를 분기해서, Federated 모드에서는 Provider를 감싸지 않도록 해결했다. Provider는 반드시 Shell에서만 감싸고, Remote는 소비만 하는 원칙이 여기서도 적용된다.
런타임에서 ‘분리하되 공유한다’는 구조를 잡았다. 같은 원칙이 개발 환경에도 적용되어야 했다. 앱은 독립적으로 개발하되, 코드 컨벤션과 공유 패키지는 하나의 코드베이스에서 관리해야 한다.
Module Federation은 런타임에서 모듈을 공유하지만, 개발 시점의 문제는 해결해주지 않는다. 4개 앱이 14개 공유 패키지를 참조하는데, 이걸 별도 저장소(Multi-repo)로 관리하면 어떻게 될까. 공유 패키지 하나를 수정할 때마다 npm publish → 각 앱에서 버전 업데이트 → 테스트 → 배포, 이 사이클을 2~3명이 돌려야 한다. 3장에서 다룬 Shared 버전 불일치 문제도 Multi-repo에서는 발견이 훨씬 늦어진다.
Monorepo는 이 문제를 구조적으로 해결한다. 공유 패키지 수정이 즉시 모든 앱에 반영되고, 버전 불일치는 빌드 단계에서 잡히고, 하나의 PR로 앱과 패키지를 함께 변경할 수 있다. ‘분리하되 공유한다’는 런타임 원칙이 개발 환경에서도 자연스럽게 작동하는 셈이다.
팀원들이 Monorepo 경험이 없었다. 빠르게 온보딩할 수 있어야 했다.
Nx는 강력하지만 generator, executor, project graph 같은 고유 개념이 있다. 학습 곡선이 꽤 된다. Turborepo는 turbo.json 하나로 태스크 그래프를 정의할 수 있고, 기존 npm 스크립트를 그대로 쓸 수 있다.
인도의 핀테크 기업 Groww도 비슷한 맥락에서 Turborepo + pnpm 조합을 선택했다. 25분 넘게 걸리던 모놀리식 빌드를 MFE로 쪼개면서 팀 자율성과 독립 배포를 확보했다고 한다.
// turbo.json
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"dev": { "cache": false, "persistent": true },
"lint": { "dependsOn": ["^lint"] },
"test": { "dependsOn": ["^build"] }
}
}"dependsOn": ["^build"]에서 ^는 "네가 의존하는 패키지를 먼저 빌드해라"는 뜻이다. Dashboard가 @shared/ui를 쓰면, @shared/ui가 먼저 빌드된 뒤에 Dashboard가 빌드된다.
packages/
├── federation/ # Module Federation 설정 팩토리 (Host/Remote)
├── api/ # OpenAPI 자동 생성 + openapi-fetch + React Query
├── ui/ # Radix UI + Tailwind CSS 기반 UI 컴포넌트
├── auth/ # OIDC 인증 (oidc-client-ts, PKCE Flow)
├── config/ # 환경별 설정 (Zod 스키마 검증)
├── i18n/ # 국제화 (한국어 Key 방식, 5개 언어)
├── icons/ # 아이콘
├── state/ # 전역 상태 (Jotai atoms)
├── sentry/ # 에러 트래킹 (Shell에서만 초기화)
├── analytics/ # GA4
├── lib/ # 유틸리티
├── tsconfig/ # TypeScript 설정 프리셋
├── eslint-config/ # ESLint 규칙
└── docs-viewer/ # 문서 뷰어패키지를 이렇게까지 쪼갠 이유가 있다. Luca Mezzalira가 말하는 “hiding implementation details” 원칙 때문이다. Dashboard 앱 개발자는 인증이 OIDC인지 SAML인지 몰라도 된다. @shared/auth의 useAuth() 하나면 충분하다.
코드베이스를 하나로 모았으니, 다음은 이걸 어떻게 내보낼 것인가. Module Federation 환경에서 배포는 일반적인 SPA 배포와 다른 점이 있다.
보통 JS 파일은 파일명에 해시를 넣어서 캐시 버스팅을 한다 (app-a1b2c3.js). 그런데 remoteEntry.js는 파일명이 고정이어야 한다. Host가 이 이름을 알고 있어야 하니까.
이것이 의미하는 바는 다음과 같다:


remoteEntry.js는 ~2KB다. 실제 코드는 해시된 청크에 있으니까, 캐시 안 걸어도 성능 영향이 없다. 이 구분을 제대로 하지 않으면 배포 후에도 사용자에게 이전 버전이 보이는 문제가 발생할 수 있다.
각 앱은 독립된 S3 버킷과 CloudFront 배포를 가진다.
1. 코드 작성 + 테스트
2. RC 태그: v0.1.0-rc.1 → Dev 환경 빌드 + 배포
3. Dev에서 검증
4. 정식 태그: v0.1.0 → Prod 환경 빌드 + 배포Dashboard에 버그가 발생하면 Dashboard만 이전 태그로 롤백하면 된다. Shell, Model Manager, Admin은 그대로다. 마이크로 프론트엔드의 가장 큰 장점이라고 생각한다.
# .gitlab-ci.yml (간략화)
stages:
- install
- quality # lint, typecheck, test
- build # 앱별 병렬 빌드 (태그 생성 시)
- deploy-dev # RC 태그 → Dev 환경
- deploy-prod # 정식 태그 → Prod 환경
install:
script:
- pnpm install --frozen-lockfile
cache:
key: { files: [pnpm-lock.yaml] }
build:
script:
- pnpm turbo build --filter=@app/shell --filter=@app/dashboard...
deploy:
script:
# 1. assets/ → S3 (1년 캐시, immutable)
# 2. *.html → S3 (must-revalidate)
# 3. remoteEntry.js, mf-manifest.json → S3 (no-cache)
# 4. CloudFront 캐시 무효화Turborepo 캐시가 변경되지 않은 패키지의 빌드를 스킵하면서, 전체 빌드 ~40분이 ~3분으로 줄었다. (물론 BFF 가 없어지면서 짧아진게 크긴하다)
빌드와 배포에서도 독립성과 공유의 균형을 잡았다. 하지만 실제로 개발을 시작하면 이 균형이 무너지는 순간들이 있었다. CSS가 충돌하고, Remote가 죽으면 전체가 깨지고, Standalone 모드와 Federated 모드가 엇갈렸다.
turbo dev를 실행하면 Shell과 모든 Remote가 동시에 뜬다. @module-federation/vite는 dev 모드에서 Remote의 변경사항을 HMR로 반영한다. Dashboard의 컴포넌트를 수정하면 Shell 안에서 실시간으로 바뀐다.
다만 주의할 점이 있다. Shared Scope의 의존성(React, react-router-dom 등)을 수정하면 전체 앱을 재시작해야 한다. 개별 Remote의 비즈니스 로직 변경은 HMR이 되지만, 공유 의존성 수준의 변경은 HMR 범위를 벗어난다.
개발 효율을 위해 Remote 앱마다 Standalone 모드를 지원한다. Dashboard 개발자는 Shell 없이 Dashboard만 띄워서 작업할 수 있다. 4장에서 다룬 Provider Override의 checkStandaloneMode() 분기가 이를 가능하게 해준다.
Module Federation에서 Remote 하나가 응답하지 않으면, 기본적으로는 전체 앱이 깨진다. import('dashboardApp/App')이 실패하면 uncaught promise rejection이 발생한다.
우리는 React의 ErrorBoundary와 Suspense를 조합해서 이를 격리했다:
<ErrorBoundary fallback={<RemoteLoadError name="Dashboard" />}>
<Suspense fallback={<DashboardLoading />}>
<DashboardApp />
</Suspense>
</ErrorBoundary>Dashboard가 죽어도 Shell, Model Manager, Admin은 정상 동작한다. 사용자에게는 에러 안내 화면이 뜰 뿐이다. 앱 하나의 장애가 전체 플랫폼을 무너뜨리지 않는다 — 마이크로 프론트엔드의 핵심 가치라고 생각한다.
Module Federation은 기본적으로 CSS를 격리하지 않는다. 실제로 개발 중에 Remote 앱의 CSS가 Host보다 나중에 로드되면서 스타일 우선순위가 뒤바뀌는 문제가 발생했다. 같은 Tailwind 클래스인데 앱마다 다르게 보이는 현상이었다. 두 Remote가 같은 셀렉터를 정의하면 마지막으로 로드된 쪽이 이기는 문제가 생긴다. “Beyond the Hype” 글의 Matej Fačkovec도 이 문제를 꽤 강조한다:
“If two micro frontends define the same selector with different styles, the last-loaded remote wins.”
우리는 Tailwind CSS를 쓰기 때문에 클래스 이름 충돌 가능성이 낮지만, 그래도 대비해야 했다:
모든 앱이 @shared/ui 패키지를 통해 동일한 Tailwind 설정을 사용
Shell과 Remote 모두 cssCodeSplit: false를 설정해서 CSS를 하나의 파일로 번들링
CSS 로드 타이밍에 의한 스타일 우선순위 뒤바뀜 문제를 최소화
Ant Design 같은 컴포넌트 라이브러리를 쓰는 팀이라면, Indra Avitech처럼 ConfigProvider의 prefixCls로 네임스페이스를 분리하는 것도 좋은 전략이다.
이런 문제들을 하나씩 풀어가면서, 3개월이 지났다. 돌아보면 숫자로도 꼽 의미 있는 변화가 있었다.


가장 큰 성과는 숫자가 아니라 체감이었던 것 같다. 각 앱이 독립적이고, 개발자 1명이 AI를 붙여서 도메인 하나를 통째로 담당하니 속도가 많이 나왔다. 서로 다른 앱인데 마치 같은 앱처럼 보이는 것도 좋았다.
솔직히 말하면, 완벽하지는 않았다.
Module Federation의 Shared Scope가 어떻게 돌아가는지 제대로 이해하기까지 꽤 걸렸다. remoteEntry.js에 캐시를 걸어놓고 “배포했는데 왜 안 바뀌지?”라고 삽질한 적도 있다. Provider가 이중으로 감싸져서 상태가 꼬인 걸 찾느라 반나절을 날린 적도 있다.
다시 한다면 무엇을 바꿀까.
E2E 테스트/ 코드리뷰 AI 를 더 빨리 붙였을 거다. AI 주도 개발이 되다 보니 코드 리뷰와 화면 테스트에 대한 압박이 심했다. 코드 리뷰 AI를 붙이기 전이었으니, AI가 마구 찍어낸 코드를 사람이 다시 AI와 함께 리뷰하는 아이러니한 상황이었다. E2E 테스트나 리뷰 봇을 더 일찍 도입했다면 그 병목을 훨씬 줄일 수 있었을 거다. (현재는 코드리뷰 AI를 직접 개발해 도입했다)
공유 패키지의 버전 관리 전략도 더 일찍 잡았을 거다. 14개 패키지가 서로 의존하면서 “이 버전에서는 되는데 저 버전에서는 안 돼”가 몇 번 있었다. Changesets 같은 도구로 시맨틱 버저닝을 강제했다면 좀 더 편했을 거다.
그래도 하나 확실한 건 있다. 이 구조가 아니었으면 3개월은 불가능했다. 각자 도메인을 통째로 맡고, AI를 붙여서 빠르게 치고, 공유 패키지로 일관성을 유지하는 이 사이클이 적은 인원에서의 속도를 만들어줬다.
그리고 이건 끝이 아니다. 지금은 Turborepo에서 Nx로 전환 중이고, CI에 AI 기반 코드 리뷰를 붙이고 있고, Module Federation의 버전 관리를 더 정교하게 만들고 있다. 마이크로 프론트엔드는 한 번 구축하고 끝나는 게 아니라, 계속 다듬어가는 구조다.
이 글이 비슷한 고민을 하는 누군가에게 도움이 됐으면 하는 바람이다. 적어도 remoteEntry.js에 캐시 거는 실수는 피할 수 있지 않을까.
서적
Luca Mezzalira, Building Micro-Frontends, O’Reilly, 2021
Module Federation
아키텍처
프로덕션 사례
Monorepo
AI & Design