Vite + Module Federation: 빠른 개발로 마이크로프론트엔드 구축하기
사이드 프로젝트: Spotify 클론 만들기
친구 3명과 사이드 프로젝트를 시작했다고 상상해보세요. Spotify 같은 음악 스트리밍 플랫폼을 만들기로 했습니다.
역할 분담:
- 나: 음악 플레이어 (재생, 일시정지, 진행바, 볼륨)
- 친구 A: 검색 & 라이브러리 (곡/앨범/아티스트 검색, 내 음악)
- 친구 B: 플레이리스트 (만들기, 편집, 공유)
- 친구 C: 추천 (좋아할 만한 곡, 차트, 새 앨범)
처음엔 하나의 Vite 프로젝트로 시작했습니다.
// 전통적인 모노리스 구조
spotify-clone/
├── src/
│ ├── player/ // 내가 작업
│ ├── search/ // 친구 A
│ ├── playlist/ // 친구 B
│ └── recommend/ // 친구 C
└── package.json // 모두 같은 의존성 공유
일주일 후 발생한 현실적인 문제들
하나의 프로젝트에서 4명이 작업하다 보니 금방 한계가 드러났습니다. 각자 다른 기능을 개발하는데도 서로의 작업이 발목을 잡기 시작했죠.
문제 1: 협업 지옥
나: "플레이어 볼륨 버그 수정했어! PR 올림"
친구 B: "잠깐, 플레이리스트 수정 중인데 main 브랜치 머지하지 마..."
친구 A: "나도 검색 리팩토링 중이야, 충돌날 것 같은데"
친구 C: "다들 작업 끝날 때까지 기다려야 하나..."
→ 결국 배포는 모두가 작업 끝난 금요일 밤 12시
문제 2: 의존성 충돌
// 나: 플레이어에 최신 오디오 라이브러리 사용하고 싶음
npm install wavesurfer.js@latest
// 친구 B: 플레이리스트에서 드래그앤드롭 라이브러리 추가
npm install react-beautiful-dnd
// 결과:
// - package.json에 40개의 의존성
// - 누가 뭘 쓰는지 모름
// - 하나 업데이트하면 다른 팀 코드 깨짐
문제 3: 빌드/배포 대기
# 플레이어 버그 하나 수정
$ git commit -m "fix: volume slider bug"
$ git push
# CI/CD 파이프라인
✓ 플레이어 빌드 (30초)
✓ 검색 빌드 (45초) # 내가 안 건드렸는데...
✓ 플레이리스트 빌드 (1분) # 왜 다시 빌드?
✓ 추천 빌드 (40초) # 시간 낭비...
✓ 통합 테스트 (2분)
────────────────────
총 5분 대기
해결책: Module Federation
이 모든 문제를 한 번에 해결할 방법이 있습니다. Module Federation을 사용하면 각자가 독립적인 앱을 만들되, 사용자에게는 하나의 통합된 앱으로 보이게 할 수 있습니다.
원하는 것:
✅ 각자 독립적으로 작업
✅ 자기 파트만 빌드/배포
✅ 다른 팀 작업 끝날 때까지 기다리지 않기
✅ 사용자에겐 하나의 앱으로 보이기
이걸 가능하게 하는 것이 Vite + Module Federation입니다!
Vite를 선택하는 이유
Module Federation은 Webpack으로도 구현할 수 있지만, Vite를 사용하면 개발 경험이 완전히 달라집니다. 숫자로 비교해보겠습니다.
Webpack vs Vite: 개발 경험 비교
Webpack 5 + Module Federation:
# 개발 서버 시작
$ npm run dev
⠋ Starting dev server...
⠋ Bundling...
✓ Ready in 8.3s
# HMR (파일 수정 시)
⠋ Rebuilding...
✓ Updated in 2.1s
Vite + Module Federation:
# 개발 서버 시작
$ npm run dev
✓ Ready in 234ms # 😱 35배 빠름!
# HMR (파일 수정 시)
✓ Updated in 47ms # 즉시 반영!
왜 이렇게 빠를까?
Webpack (번들 방식):
┌─────────────────────────────────────┐
│ 모든 파일을 하나로 합침 │
│ [player] + [search] + [playlist] │
│ → bundle.js (5초 소요) │
└─────────────────────────────────────┘
파일 하나 수정
↓
전체 번들 다시 빌드 (2초)
Vite (ESM 네이티브):
┌─────────────────────────────────────┐
│ 파일을 번들링하지 않고 그대로 제공│
│ player.js, search.js, playlist.js │
│ → 브라우저가 직접 로드 │
└─────────────────────────────────────┘
파일 하나 수정
↓
해당 파일만 교체 (47ms)
프로젝트 구조: 음악 스트리밍 앱
실제 구현에 들어가기 전에 프로젝트 구조를 살펴보겠습니다. 각 앱은 완전히 독립적인 Vite 프로젝트로 만들어집니다.
spotify-clone/
├── shell-app/ # 메인 앱 (Host)
│ ├── src/
│ │ ├── App.tsx
│ │ └── Layout.tsx
│ ├── vite.config.ts
│ └── package.json
│
├── player-app/ # 음악 플레이어 (Remote)
│ ├── src/
│ │ ├── MusicPlayer.tsx
│ │ ├── Controls.tsx
│ │ └── ProgressBar.tsx
│ ├── vite.config.ts
│ └── package.json
│
├── search-app/ # 검색 (Remote)
│ ├── src/
│ │ ├── Search.tsx
│ │ └── Library.tsx
│ ├── vite.config.ts
│ └── package.json
│
└── playlist-app/ # 플레이리스트 (Remote)
├── src/
│ ├── PlaylistView.tsx
│ └── CreatePlaylist.tsx
├── vite.config.ts
└── package.json
1단계: Shell App 설정 (메인 앱)
Shell App은 “메인 홀” 역할입니다. 다른 앱들을 불러와서 조합합니다.
설치
cd shell-app
npm create vite@latest . -- --template react-ts
npm install
npm install @originjs/vite-plugin-federation --save-dev
vite.config.ts
// shell-app/vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import federation from '@originjs/vite-plugin-federation'
export default defineConfig({
plugins: [
react(),
federation({
name: 'shell-app',
// 다른 Remote 앱들의 주소
remotes: {
player: 'http://localhost:3001/assets/remoteEntry.js',
search: 'http://localhost:3002/assets/remoteEntry.js',
playlist: 'http://localhost:3003/assets/remoteEntry.js',
},
// 공유할 라이브러리
shared: {
react: {
singleton: true, // 전체 앱에서 하나만 사용
},
'react-dom': {
singleton: true,
},
},
}),
],
// 개발 서버 설정
server: {
port: 3000,
cors: true,
},
// 빌드 설정
build: {
modulePreload: false,
target: 'esnext',
minify: false,
cssCodeSplit: false,
},
})
Shell App 코드
// shell-app/src/App.tsx
import { Suspense, lazy } from 'react'
import { BrowserRouter, Routes, Route, Link } from 'react-router-dom'
import './App.css'
// Remote 컴포넌트들을 동적 로드
const MusicPlayer = lazy(() => import('player/MusicPlayer'))
const Search = lazy(() => import('search/Search'))
const PlaylistView = lazy(() => import('playlist/PlaylistView'))
function App() {
return (
<BrowserRouter>
<div className="app">
{/* 사이드바 */}
<aside className="sidebar">
<h1>Spotify Clone</h1>
<nav>
<Link to="/search">검색</Link>
<Link to="/library">내 라이브러리</Link>
<Link to="/playlist">플레이리스트</Link>
</nav>
</aside>
{/* 메인 콘텐츠 */}
<main className="content">
<Routes>
<Route
path="/search"
element={
<Suspense fallback={<LoadingSpinner />}>
<Search />
</Suspense>
}
/>
<Route
path="/playlist/:id?"
element={
<Suspense fallback={<LoadingSpinner />}>
<PlaylistView />
</Suspense>
}
/>
<Route
path="/"
element={<Home />}
/>
</Routes>
</main>
{/* 하단 플레이어 (항상 표시) */}
<footer className="player-bar">
<Suspense fallback={<div>플레이어 로딩중...</div>}>
<MusicPlayer />
</Suspense>
</footer>
</div>
</BrowserRouter>
)
}
function LoadingSpinner() {
return (
<div className="loading">
<div className="spinner" />
<p>로딩 중...</p>
</div>
)
}
function Home() {
return (
<div className="home">
<h2>오늘 뭐 들을까요?</h2>
<p>검색해서 좋아하는 음악을 찾아보세요!</p>
</div>
)
}
export default App
2단계: Player App 설정 (음악 플레이어)
이제 첫 번째 Remote 앱인 Player를 만들겠습니다. 이 앱은 Shell에서 불러올 컴포넌트들을 제공합니다.
설치
cd player-app
npm create vite@latest . -- --template react-ts
npm install
npm install @originjs/vite-plugin-federation --save-dev
vite.config.ts
// player-app/vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import federation from '@originjs/vite-plugin-federation'
export default defineConfig({
plugins: [
react(),
federation({
name: 'player',
filename: 'remoteEntry.js',
// 외부에 공개할 컴포넌트들
exposes: {
'./MusicPlayer': './src/MusicPlayer',
'./Controls': './src/Controls',
'./ProgressBar': './src/ProgressBar',
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true },
},
}),
],
server: {
port: 3001,
cors: true,
},
build: {
modulePreload: false,
target: 'esnext',
minify: false,
cssCodeSplit: false,
},
})
Player 컴포넌트
// player-app/src/MusicPlayer.tsx
import { useState, useRef, useEffect } from 'react'
import './MusicPlayer.css'
interface Song {
id: string
title: string
artist: string
albumArt: string
url: string
duration: number
}
export default function MusicPlayer() {
const [currentSong, setCurrentSong] = useState<Song | null>(null)
const [isPlaying, setIsPlaying] = useState(false)
const [currentTime, setCurrentTime] = useState(0)
const [volume, setVolume] = useState(80)
const audioRef = useRef<HTMLAudioElement>(null)
// 재생/일시정지 토글
const togglePlay = () => {
if (!audioRef.current) return
if (isPlaying) {
audioRef.current.pause()
} else {
audioRef.current.play()
}
setIsPlaying(!isPlaying)
}
// 진행바 업데이트
const handleTimeUpdate = () => {
if (audioRef.current) {
setCurrentTime(audioRef.current.currentTime)
}
}
// 진행바 클릭 (시간 이동)
const handleSeek = (e: React.MouseEvent<HTMLDivElement>) => {
if (!audioRef.current || !currentSong) return
const rect = e.currentTarget.getBoundingClientRect()
const percent = (e.clientX - rect.left) / rect.width
const newTime = percent * currentSong.duration
audioRef.current.currentTime = newTime
setCurrentTime(newTime)
}
// 볼륨 조절
const handleVolumeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const newVolume = parseInt(e.target.value)
setVolume(newVolume)
if (audioRef.current) {
audioRef.current.volume = newVolume / 100
}
}
// 시간 포맷팅 (초 → mm:ss)
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
// 데모 곡 (실제로는 API에서 가져옴)
useEffect(() => {
setCurrentSong({
id: '1',
title: 'Bohemian Rhapsody',
artist: 'Queen',
albumArt: 'https://via.placeholder.com/60',
url: 'https://example.com/song.mp3',
duration: 354, // 5:54
})
}, [])
if (!currentSong) {
return (
<div className="music-player empty">
<p>재생할 곡을 선택하세요</p>
</div>
)
}
return (
<div className="music-player">
<audio
ref={audioRef}
src={currentSong.url}
onTimeUpdate={handleTimeUpdate}
onEnded={() => setIsPlaying(false)}
/>
{/* 왼쪽: 앨범 정보 */}
<div className="song-info">
<img src={currentSong.albumArt} alt={currentSong.title} />
<div className="song-details">
<div className="song-title">{currentSong.title}</div>
<div className="song-artist">{currentSong.artist}</div>
</div>
</div>
{/* 중앙: 재생 컨트롤 */}
<div className="player-controls">
<div className="control-buttons">
<button className="btn-control">⏮️</button>
<button className="btn-play" onClick={togglePlay}>
{isPlaying ? '⏸️' : '▶️'}
</button>
<button className="btn-control">⏭️</button>
</div>
{/* 진행바 */}
<div className="progress-bar-container">
<span className="time">{formatTime(currentTime)}</span>
<div className="progress-bar" onClick={handleSeek}>
<div
className="progress-bar-fill"
style={{
width: `${(currentTime / currentSong.duration) * 100}%`,
}}
/>
</div>
<span className="time">{formatTime(currentSong.duration)}</span>
</div>
</div>
{/* 오른쪽: 볼륨 */}
<div className="volume-control">
<span>🔊</span>
<input
type="range"
min="0"
max="100"
value={volume}
onChange={handleVolumeChange}
/>
</div>
</div>
)
}
/* player-app/src/MusicPlayer.css */
.music-player {
display: grid;
grid-template-columns: 1fr 2fr 1fr;
gap: 1rem;
padding: 1rem;
background: #181818;
border-top: 1px solid #282828;
align-items: center;
}
.song-info {
display: flex;
gap: 0.75rem;
align-items: center;
}
.song-info img {
width: 60px;
height: 60px;
border-radius: 4px;
}
.song-title {
font-weight: 500;
color: white;
}
.song-artist {
font-size: 0.875rem;
color: #b3b3b3;
}
.player-controls {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.control-buttons {
display: flex;
justify-content: center;
gap: 1rem;
}
.btn-play {
width: 40px;
height: 40px;
border-radius: 50%;
background: white;
border: none;
cursor: pointer;
font-size: 1.2rem;
transition: transform 0.1s;
}
.btn-play:hover {
transform: scale(1.05);
}
.btn-control {
background: none;
border: none;
color: white;
font-size: 1.2rem;
cursor: pointer;
opacity: 0.7;
}
.btn-control:hover {
opacity: 1;
}
.progress-bar-container {
display: flex;
align-items: center;
gap: 0.5rem;
}
.time {
font-size: 0.75rem;
color: #b3b3b3;
width: 40px;
text-align: center;
}
.progress-bar {
flex: 1;
height: 4px;
background: #404040;
border-radius: 2px;
cursor: pointer;
position: relative;
}
.progress-bar-fill {
height: 100%;
background: #1db954;
border-radius: 2px;
}
.progress-bar:hover .progress-bar-fill {
background: #1ed760;
}
.volume-control {
display: flex;
align-items: center;
gap: 0.5rem;
justify-content: flex-end;
}
.volume-control input[type="range"] {
width: 100px;
}
3단계: Search App 설정 (검색)
Search 앱도 Player와 비슷한 방식으로 구성됩니다. 간단하게 설정만 보여드리겠습니다.
cd search-app
npm create vite@latest . -- --template react-ts
npm install
npm install @originjs/vite-plugin-federation --save-dev
// search-app/vite.config.ts
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import federation from '@originjs/vite-plugin-federation'
export default defineConfig({
plugins: [
react(),
federation({
name: 'search',
filename: 'remoteEntry.js',
exposes: {
'./Search': './src/Search',
'./Library': './src/Library',
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true },
},
}),
],
server: {
port: 3002,
cors: true,
},
build: {
modulePreload: false,
target: 'esnext',
minify: false,
cssCodeSplit: false,
},
})
// search-app/src/Search.tsx
import { useState } from 'react'
import './Search.css'
interface SearchResult {
id: string
type: 'track' | 'album' | 'artist'
name: string
artist?: string
albumArt?: string
}
export default function Search() {
const [query, setQuery] = useState('')
const [results, setResults] = useState<SearchResult[]>([])
const [isSearching, setIsSearching] = useState(false)
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault()
if (!query.trim()) return
setIsSearching(true)
// 실제로는 API 호출
setTimeout(() => {
setResults([
{
id: '1',
type: 'track',
name: 'Bohemian Rhapsody',
artist: 'Queen',
albumArt: 'https://via.placeholder.com/60',
},
{
id: '2',
type: 'track',
name: 'Don\'t Stop Me Now',
artist: 'Queen',
albumArt: 'https://via.placeholder.com/60',
},
{
id: '3',
type: 'album',
name: 'A Night at the Opera',
artist: 'Queen',
albumArt: 'https://via.placeholder.com/60',
},
])
setIsSearching(false)
}, 500)
}
const handlePlay = (result: SearchResult) => {
console.log('재생:', result.name)
// 실제로는 글로벌 상태에 곡 정보 전달
}
return (
<div className="search">
<h2>검색</h2>
<form onSubmit={handleSearch} className="search-form">
<input
type="text"
placeholder="곡, 아티스트, 앨범 검색..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="search-input"
/>
<button type="submit" className="search-btn">
🔍 검색
</button>
</form>
{isSearching && (
<div className="searching">
<div className="spinner" />
<p>검색 중...</p>
</div>
)}
{!isSearching && results.length > 0 && (
<div className="search-results">
<h3>검색 결과</h3>
<div className="results-list">
{results.map((result) => (
<div key={result.id} className="result-item">
<img src={result.albumArt} alt={result.name} />
<div className="result-info">
<div className="result-name">{result.name}</div>
<div className="result-artist">{result.artist}</div>
<div className="result-type">
{result.type === 'track' ? '곡' : result.type === 'album' ? '앨범' : '아티스트'}
</div>
</div>
<button
className="btn-play-result"
onClick={() => handlePlay(result)}
>
▶️ 재생
</button>
</div>
))}
</div>
</div>
)}
{!isSearching && query && results.length === 0 && (
<div className="no-results">
<p>검색 결과가 없습니다</p>
</div>
)}
</div>
)
}
4단계: 실행하기
모든 설정이 끝났습니다! 이제 각 앱을 독립적으로 실행해보겠습니다.
터미널 4개를 열어서 각각 실행:
# 터미널 1: Shell App
cd shell-app
npm run dev
# → http://localhost:3000
# 터미널 2: Player App
cd player-app
npm run dev
# → http://localhost:3001
# 터미널 3: Search App
cd search-app
npm run dev
# → http://localhost:3002
# 터미널 4: Playlist App (만들 경우)
cd playlist-app
npm run dev
# → http://localhost:3003
브라우저에서 http://localhost:3000 접속!
상태 공유: 현재 재생 중인 곡 동기화
앱들이 독립적으로 실행되고 있지만, 서로 데이터를 공유해야 할 때가 있습니다. 현재 재생 중인 곡 정보를 모든 앱에서 동기화하는 방법을 알아보겠습니다.
여러 Remote 앱이 “현재 재생 중인 곡” 정보를 공유해야 합니다.
문제:
Player App: 현재 재생 중 - "Bohemian Rhapsody"
Search App: 현재 재생 중인 곡을 어떻게 알지? 🤔
Playlist App: 현재 재생 중인 곡을 표시하고 싶은데... 🤔
해결책: Shell App에서 Store 제공
Zustand로 상태 관리
cd shell-app
npm install zustand
// shell-app/src/store/musicStore.ts
import { create } from 'zustand'
interface Song {
id: string
title: string
artist: string
albumArt: string
url: string
duration: number
}
interface MusicStore {
currentSong: Song | null
isPlaying: boolean
currentTime: number
volume: number
setCurrentSong: (song: Song) => void
setIsPlaying: (playing: boolean) => void
setCurrentTime: (time: number) => void
setVolume: (volume: number) => void
}
export const useMusicStore = create<MusicStore>((set) => ({
currentSong: null,
isPlaying: false,
currentTime: 0,
volume: 80,
setCurrentSong: (song) => set({ currentSong: song, currentTime: 0 }),
setIsPlaying: (playing) => set({ isPlaying: playing }),
setCurrentTime: (time) => set({ currentTime: time }),
setVolume: (volume) => set({ volume: volume }),
}))
Shell App에서 Store 공개
// shell-app/vite.config.ts
federation({
name: 'shell-app',
// Store를 외부에 공개
exposes: {
'./store': './src/store/musicStore',
},
remotes: {
player: 'http://localhost:3001/assets/remoteEntry.js',
search: 'http://localhost:3002/assets/remoteEntry.js',
playlist: 'http://localhost:3003/assets/remoteEntry.js',
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true },
zustand: { singleton: true }, // 상태 관리 라이브러리도 공유
},
})
Player App에서 Store 사용
// player-app/vite.config.ts
federation({
name: 'player',
// Shell의 Store를 참조
remotes: {
shell: 'http://localhost:3000/assets/remoteEntry.js',
},
exposes: {
'./MusicPlayer': './src/MusicPlayer',
},
shared: {
react: { singleton: true },
'react-dom': { singleton: true },
zustand: { singleton: true },
},
})
// player-app/src/MusicPlayer.tsx
import { useEffect } from 'react'
// Shell의 Store 사용
import { useMusicStore } from 'shell/store'
export default function MusicPlayer() {
// 글로벌 상태 사용
const currentSong = useMusicStore((state) => state.currentSong)
const isPlaying = useMusicStore((state) => state.isPlaying)
const setIsPlaying = useMusicStore((state) => state.setIsPlaying)
const setCurrentTime = useMusicStore((state) => state.setCurrentTime)
const togglePlay = () => {
setIsPlaying(!isPlaying) // 글로벌 상태 업데이트
}
useEffect(() => {
// 오디오 재생/정지
if (audioRef.current) {
if (isPlaying) {
audioRef.current.play()
} else {
audioRef.current.pause()
}
}
}, [isPlaying])
// 나머지 코드...
}
Search App에서도 같은 Store 사용
// search-app/src/Search.tsx
import { useMusicStore } from 'shell/store'
export default function Search() {
const setCurrentSong = useMusicStore((state) => state.setCurrentSong)
const setIsPlaying = useMusicStore((state) => state.setIsPlaying)
const handlePlay = (result: SearchResult) => {
// 곡 재생 → 글로벌 상태 업데이트
setCurrentSong({
id: result.id,
title: result.name,
artist: result.artist || '',
albumArt: result.albumArt || '',
url: 'https://example.com/song.mp3',
duration: 240,
})
setIsPlaying(true) // 자동 재생
}
// 나머지 코드...
}
결과:
Search App에서 곡 클릭
↓
글로벌 Store 업데이트
↓
Player App이 자동으로 재생 시작!
↓
모든 앱이 동기화됨 ✅
TypeScript 타입 정의
TypeScript를 사용하는 경우, Remote 모듈에 대한 타입 정의가 필요합니다. 그렇지 않으면 import 시 에러가 발생합니다.
Remote 모듈의 타입을 정의해야 TypeScript 에러가 없습니다.
// shell-app/src/types/remotes.d.ts
declare module 'player/MusicPlayer' {
import { ComponentType } from 'react'
const MusicPlayer: ComponentType
export default MusicPlayer
}
declare module 'search/Search' {
import { ComponentType } from 'react'
const Search: ComponentType
export default Search
}
declare module 'playlist/PlaylistView' {
import { ComponentType } from 'react'
const PlaylistView: ComponentType
export default PlaylistView
}
// player-app/src/types/remotes.d.ts
declare module 'shell/store' {
import { create } from 'zustand'
interface Song {
id: string
title: string
artist: string
albumArt: string
url: string
duration: number
}
interface MusicStore {
currentSong: Song | null
isPlaying: boolean
currentTime: number
volume: number
setCurrentSong: (song: Song) => void
setIsPlaying: (playing: boolean) => void
setCurrentTime: (time: number) => void
setVolume: (volume: number) => void
}
export const useMusicStore: ReturnType<typeof create<MusicStore>>
}
프로덕션 빌드 & 배포
개발 환경에서는 잘 동작하는 것을 확인했습니다. 이제 실제 프로덕션 환경에 배포하는 방법을 알아보겠습니다.
빌드
# 각 앱을 독립적으로 빌드
cd player-app && npm run build
cd search-app && npm run build
cd playlist-app && npm run build
cd shell-app && npm run build
배포 구조
CDN/
├── shell/
│ ├── index.html
│ └── assets/
│ ├── index-abc123.js
│ └── remoteEntry.js
│
├── player/
│ └── assets/
│ ├── MusicPlayer-def456.js
│ └── remoteEntry.js
│
├── search/
│ └── assets/
│ ├── Search-ghi789.js
│ └── remoteEntry.js
│
└── playlist/
└── assets/
├── PlaylistView-jkl012.js
└── remoteEntry.js
환경별 Remote URL 설정
// shell-app/vite.config.ts
const getRemoteUrl = (name: string) => {
const isProd = process.env.NODE_ENV === 'production'
const urls = {
development: {
player: 'http://localhost:3001/assets/remoteEntry.js',
search: 'http://localhost:3002/assets/remoteEntry.js',
playlist: 'http://localhost:3003/assets/remoteEntry.js',
},
production: {
player: 'https://cdn.myapp.com/player/assets/remoteEntry.js',
search: 'https://cdn.myapp.com/search/assets/remoteEntry.js',
playlist: 'https://cdn.myapp.com/playlist/assets/remoteEntry.js',
},
}
return urls[isProd ? 'production' : 'development'][name]
}
export default defineConfig({
plugins: [
react(),
federation({
name: 'shell-app',
remotes: {
player: getRemoteUrl('player'),
search: getRemoteUrl('search'),
playlist: getRemoteUrl('playlist'),
},
// ...
}),
],
})
실전 팁
실제 프로젝트에서 유용한 패턴과 팁들을 공유합니다. 이런 기법들이 Module Federation의 진가를 발휘하게 합니다.
1. 독립 배포 전략
# Player 팀: 버그 수정
cd player-app
git commit -m "fix: volume control bug"
npm run build
npm run deploy # player만 배포
# 결과: 2분 만에 배포 완료!
# Shell, Search, Playlist는 영향 없음
2. 롤백
# Player에 버그 발견!
# 이전 버전으로 롤백
# CDN에서 심볼릭 링크 변경
ln -sf v1.2.0 latest
# Shell은 재배포 불필요!
# 즉시 이전 버전 Player 사용
3. A/B 테스트
// shell-app에서 10% 사용자에게 새 Player 제공
const getPlayerVersion = () => {
const userId = getUserId()
const isTestGroup = userId % 10 === 0
return isTestGroup
? 'https://cdn.myapp.com/player-v2/assets/remoteEntry.js'
: 'https://cdn.myapp.com/player/assets/remoteEntry.js'
}
remotes: {
player: getPlayerVersion(),
}
4. 로컬 개발 팁
# 팁 1: 다른 팀 Remote가 없어도 개발 가능
# Search 팀이 아직 개발 안 했다면?
# → Fallback 컴포넌트 제공
<Suspense fallback={<SearchPlaceholder />}>
<Search />
</Suspense>
# 팀 2: 터미널 관리 도구 사용
# tmux 또는 concurrently로 한 번에 실행
npm install -D concurrently
# package.json
{
"scripts": {
"dev:all": "concurrently \"npm run dev --prefix shell-app\" \"npm run dev --prefix player-app\" \"npm run dev --prefix search-app\""
}
}
디버깅
Module Federation을 사용하다 보면 가끔 문제가 발생할 수 있습니다. 가장 흔한 문제들과 해결 방법을 알아보겠습니다.
Remote 로딩 실패
// ErrorBoundary 추가
import { ErrorBoundary } from 'react-error-boundary'
function App() {
return (
<ErrorBoundary fallback={<ErrorFallback />}>
<Suspense fallback={<Loading />}>
<MusicPlayer />
</Suspense>
</ErrorBoundary>
)
}
function ErrorFallback({ error }: { error: Error }) {
return (
<div className="error">
<h3>플레이어를 불러올 수 없습니다</h3>
<p>{error.message}</p>
<button onClick={() => window.location.reload()}>
새로고침
</button>
</div>
)
}
네트워크 탭 확인
개발자 도구 → Network 탭
→ remoteEntry.js 파일들 확인
→ 404 에러? Remote 앱이 실행 중인지 확인
→ CORS 에러? vite.config.ts에 cors: true 설정
Vite vs Webpack: 어떤 걸 선택?
Module Federation 구현 시 Vite와 Webpack 중 무엇을 선택해야 할까요? 프로젝트 상황에 따라 달라질 수 있습니다.
| 항목 | Vite | Webpack 5 |
|---|---|---|
| 개발 서버 시작 | ⚡ 234ms | 🐢 8.3s |
| HMR | ⚡ 47ms | 🐢 2.1s |
| 프로덕션 빌드 | Rollup (최적화) | Webpack (검증됨) |
| 설정 복잡도 | ✅ 간단 | ⚠️ 복잡 |
| 생태계 | 🆕 성장 중 | ✅ 성숙함 |
| 레거시 지원 | ⚠️ ES2015+ | ✅ IE11까지 |
추천:
✅ Vite를 선택하세요 if:
- 새 프로젝트
- 빠른 개발 경험 중요
- 최신 브라우저만 지원
✅ Webpack을 선택하세요 if:
- 레거시 프로젝트
- IE11 지원 필요
- 복잡한 빌드 설정 필요
마치며
Vite와 Module Federation을 조합하면 빠른 개발 속도와 독립적인 배포라는 두 마리 토끼를 모두 잡을 수 있습니다. 이 가이드가 마이크로프론트엔드 여정에 도움이 되길 바랍니다.
Vite + Module Federation으로 이제 가능한 것들:
✅ 각 팀이 독립적으로 개발
✅ 2분 만에 배포 (전체 빌드 불필요)
✅ 번개같이 빠른 HMR (47ms)
✅ 다른 팀 작업 끝날 때까지 기다리지 않기
✅ 사용자에겐 하나의 매끄러운 앱
실제 경험:
Before:
- 전체 빌드: 5분
- 배포: 하루 1번
- 충돌 해결: 시간 낭비
After:
- Player만 빌드: 30초
- 배포: 하루 10번+
- 충돌: 거의 없음
시작하기:
# 1. 템플릿 클론
git clone https://github.com/yourusername/vite-mf-music-app
# 2. 의존성 설치
npm run install:all
# 3. 개발 서버 실행
npm run dev:all
# 4. 브라우저 열기
open http://localhost:3000
Happy Coding! 🎵
댓글