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! 🎵

댓글