💡 Module Federation의 핵심 개념, 동작 원리, 그리고 주요 기술적 특징들을 분석합니다.
마이크로프론트엔드 아키텍처를 고민하다 보면 자연스럽게 Module Federation과 만나게 됩니다. 실무에서 사용하면서 느낀 점은, 이 기술이 단순히 코드를 분리하는 것 이상의 가치를 제공한다는 것입니다.
본 글에서는 Module Federation의 핵심 개념부터 실제 동작 원리, 그리고 현실적인 제약사항까지 깊이 있게 다뤄보겠습니다.
Module Federation이란 무엇인가
Module Federation의 'Federation'은 "연합"이라는 뜻으로, 하나의 큰 앱을 여러 모듈로 나누고 런타임에 통합하는 기술입니다.
이 개념은 Apollo GraphQL의 Federation에서 영감을 받았습니다. Apollo GraphQL Federation이 여러 GraphQL 마이크로서비스의 스키마를 조합하여 하나의 큰 GraphQL 스키마로 만드는 방식인 것처럼, Module Federation은 여러 JavaScript 애플리케이션을 런타임에 하나의 통합된 애플리케이션으로 결합합니다.
기존 방식과의 차이점
기존의 모놀리스 방식:
[모든 코드] → [하나의 번들] → [브라우저]
Module Federation:
[앱 A] ←→ [네트워크] ←→ [앱 B]
↓ ↓
[번들 A] [번들 B]
↓ ↓
[브라우저 런타임에서 통합]
빌드 시점에는 독립적으로 분리되어 있지만, 브라우저에서 실행될 때는 하나의 애플리케이션처럼 동작합니다.
핵심 개념 이해하기
기본적인 정의를 이해했다면, 이제 Module Federation을 실제로 다루기 위해 필요한 개념들을 살펴봐야 합니다.
전방향적 참조: 독특한 특징
Module Federation의 가장 독특한 점은 전방향적(omnidirectional) 참조가 가능하다는 것입니다. 일반적인 마이크로프론트엔드 패턴과 달리, 앱들이 서로를 참조할 수 있습니다.
// 하나의 앱이 동시에 호스트와 리모트 역할을 수행
new ModuleFederationPlugin({
name: 'shopping-app',
// 다른 앱의 컴포넌트를 사용 (호스트 역할)
remotes: {
'user-service': 'userService@http://localhost:3001/remoteEntry.js',
},
// 자신의 컴포넌트를 제공 (리모트 역할)
exposes: {
'./ProductList': './src/components/ProductList',
'./ShoppingCart': './src/components/ShoppingCart',
},
})
이는 remotes와 exposes 속성이 배타적이지 않기 때문입니다. 순환 참조도 가능하며, 이는 모듈 로드와 평가가 분리된 독특한 구조 덕분입니다.
용어 정리
Module Federation의 핵심 용어들:
- 로컬 모듈: 현재 빌드에 포함된 일반적인 모듈
- 원격 모듈: 다른 빌드에서 네트워크를 통해 가져오는 모듈
- 컨테이너: 다른 앱에서 로드 가능한 단위, 노출된 모듈을 포함
- 컨테이너 레퍼런스: 다른 앱을 import할 때 만들어지는 참조 관계
- Share Scope: 공유 의존성이 유효한 범위
동작 원리 깊이 파보기
Module Federation이 브라우저에서 실제로 어떻게 동작하는지 알아야 문제가 생겼을 때 원인을 파악할 수 있습니다.
모듈 로드와 평가의 분리
Module Federation이 순환 참조를 해결할 수 있는 핵심은 모듈 접근을 두 단계로 분리한다는 점입니다:
- 모듈 로드 (비동기): 청크 로드 중 실행
- 모듈 평가 (동기): 모듈의 코드를 실행하고 결과를 생성
// 이런 import 구문이
const RemoteComponent = lazy(() => import('remote-app/Component'));
// 실제로는 이렇게 두 단계로 처리됨
// 1단계: 원격 모듈 로딩 (비동기)
const modulePromise = loadRemoteModule('remote-app/Component');
// 2단계: 모듈 실행 (동기)
const Component = await modulePromise.then(factory => factory());
이 분리 덕분에 복잡한 의존성 관계도 안전하게 처리할 수 있습니다.
런타임 청크의 역할
main.js
- 최초 앱 초기화를 담당
- 가장 상위의 마이크로앱 HTML에서 가장 먼저 불러와지는 청크
- 브라우저에서 앱을 최초로 로드하는데 필요한 코드를 포함
remoteEntry.js
- 리모트 앱을 초기화하는 청크
- 특정 마이크로앱에서 다른 마이크로앱을 import할 때 가장 먼저 호출되는 청크
- 원격 모듈에 접근할 수 있는 인터페이스 제공
// remoteEntry.js 내부 구조 예시
window.microApp = {
get: (moduleName) => {
// 요청된 모듈을 동적으로 반환
return import(`./exposed-modules/${moduleName}`);
},
init: (sharedScope) => {
// 공유 의존성 초기화
Object.assign(__webpack_share_scopes__.default, sharedScope);
}
};
호스트 앱 청크의 동작
실제 원격 모듈을 로드할 때의 코드는 다음과 같습니다:
// 호스트 앱 청크에 포함된 원격 모듈 로딩 코드
module.exports = new Promise((resolve, reject) => {
if(typeof microApp !== "undefined") return resolve();
__webpack_require__.l("http://localhost:3000/remoteEntry.js", (event) => {
if(typeof microApp !== "undefined") return resolve();
// 에러 처리 로직...
}, "microApp");
}).then(() => (microApp));
공유 의존성
Module Federation을 실무에서 사용하다 보면 필연적으로 마주하게 되는 문제가 있습니다. 바로 여러 앱이 동일한 라이브러리를 사용할 때 이를 어떻게 관리할 것인가 하는 의존성 문제입니다.
이 문제의 해답이 바로 Share Scope입니다.
Share Scope의 정확한 이해
Share Scope는 공유 의존성이 유효한 범위를 의미하며, Module Federation의 핵심 메커니즘 중 하나입니다.
동작 과정
- 빌드 타임: 공유 의존성을 별도 청크로 분리하여 번들링
- 런타임: 먼저 로드되는 앱이 공유 의존성을 로드
- 최적화: 이후 로드되는 앱들은 이미 로드된 의존성을 재사용
- 버전 호환성: Share scope가 맞지 않으면 자체 번들 사용
중요한 점: 호스트 앱의 의존성이 무조건 사용되는 것이 아닙니다. 리모트 앱이 먼저 로드되면 리모트 앱의 의존성이 사용될 수 있습니다.
공유가 필요한 경우들
공유 의존성이 반드시 필요한 주요 상황들:
1. 전역 변수를 사용하는 라이브러리
// 문제가 되는 경우
if (typeof window !== 'undefined') {
window.someLibraryInstance = new SomeLibrary();
}
// 인스턴스가 2개 이상이면 동시성 이슈 발생
2. 스타일 관련 라이브러리
// styled-components, emotion 등
shared: {
'styled-components': { singleton: true },
'@emotion/react': { singleton: true },
}
// 인스턴스가 여러 개면 스타일 충돌 발생
3. Context Provider 기반 라이브러리
// React Context를 사용하는 라이브러리들
const ThemeContext = createContext();
function HostApp() {
return (
<ThemeContext.Provider value={darkTheme}>
<RemoteComponent /> {/* 같은 Context를 사용해야 함 */}
</ThemeContext.Provider>
);
}
의존성 분류 체계
의존성을 분류하는 방법:
const sharedDependencies = {
// Core 라이브러리 - 반드시 singleton
react: {
singleton: true,
requiredVersion: deps.react,
eager: true, // 초기 청크에 포함
},
// 유틸리티 라이브러리 - 버전 호환성 고려
lodash: {
requiredVersion: deps.lodash,
singleton: false, // 여러 버전 허용
},
// UI 라이브러리 - 스타일 충돌 방지
'@mui/material': {
singleton: true,
requiredVersion: deps['@mui/material'],
}
};
현실적 제약사항들
Module Federation이 아무리 강력해도 만능은 아닙니다. 도입을 고려한다면 이 기술이 가진 근본적인 한계를 알고 있어야 합니다.
트리쉐이킹의 한계
Module Federation을 사용하면서 가장 먼저 체감하게 되는 제약사항이 바로 트리쉐이킹의 한계입니다.
// 일반적인 애플리케이션에서는 가능
import { debounce } from 'lodash'; // 사용하는 함수만 번들에 포함
// Module Federation에서는 불가능
// lodash 전체가 공유 모듈로 번들에 포함됨
shared: {
'lodash': { /* 전체 라이브러리가 포함 */ }
}
웹팩 메인테이너의 말을 빌리면: "개별 분리될 마이크로앱에서 사용할 모듈과 구현체를 모르는데 어떻게 트리쉐이킹을 하겠는가?"
이는 앞으로도 해결되기 어려운 근본적 한계입니다.
타입 안전성 문제
// 타입 정의가 별도로 필요
declare module 'remote-app/UserProfile' {
interface UserProfileProps {
userId: string;
theme?: 'light' | 'dark';
}
const UserProfile: React.FC<UserProfileProps>;
export default UserProfile;
}
// 실제 사용
import UserProfile from 'remote-app/UserProfile';
// 타입 검사는 되지만 런타임 호환성은 별도 보장 필요
SSR과 Module Federation
런타임에 동적으로 모듈을 불러오는 Module Federation과 빌드 타임에 모든 게 결정되어야 하는 SSR은 근본적으로 충돌합니다. 이는 두 기술의 작동 방식이 서로 반대 방향을 향하고 있기 때문입니다. 하지만 완전히 불가능한 것은 아닙니다.
SSR의 근본적 문제
Module Federation은 런타임에 원격 모듈을 동적으로 로드하는 방식인데, 이는 SSR의 기본 전제와 충돌합니다:
// 서버에서는 이런 코드가 문제가 됨
const RemoteComponent = lazy(() => import('remote-app/Component'));
// 서버는 http://localhost:3001/remoteEntry.js에 접근할 수 없음
SSR 구현 패턴
그렇다면 이를 어떻게 해결할 수 있을까요? 상황에 따라 세 가지 접근법을 고려해볼 수 있습니다.
1. 서버에서 원격 모듈을 직접 로드하는 방식
호스트 앱에서 SSR을 수행하고 리모트 앱은 CSR로 처리하는 패턴입니다.
- 서버와 클라이언트용 분리 빌드: 리모트 앱을 Node.js 환경용과 브라우저용 두 가지로 빌드
- 서버에서 동적 모듈 로드: CommonJS 형태로 빌드된 리모트 모듈을 서버에서 import
- 스트리밍 SSR 활용: Suspense와 함께 사용하여 원격 모듈 로드 중에도 다른 부분을 먼저 렌더링
// 핵심 아이디어: 서버에서 리모트 모듈 동적 로드
const remoteModule = await import('http://localhost:3001/server/remoteEntry.js');
const RemoteButton = await remoteModule.get('./Button');
2. 분산 SSR 아키텍처
각 리모트 앱이 자체적으로 SSR을 수행하는 방식입니다.
- 리모트 앱에 SSR 엔드포인트 추가: 각 리모트 앱이
/ssr-component같은 엔드포인트를 통해 렌더링된 HTML을 제공 - 호스트 앱에서 fetch로 HTML 가져오기: 서버에서 리모트 앱의 SSR 엔드포인트를 호출하여 HTML을 받아옴
- 하이드레이션 처리: 클라이언트에서 실제 React 컴포넌트로 하이드레이션
// 핵심 아이디어: 리모트 앱의 SSR 결과를 fetch로 가져오기
const response = await fetch('http://localhost:3001/ssr-component');
const { html, css } = await response.json();
중요한 점: 하이드레이션을 위해 remoteEntry.js 파일은 여전히 필요합니다.
3. 하이브리드 접근 방식
서버와 클라이언트의 역할을 적절히 분리하는 방식입니다.
- 서버에서는 플레이스홀더 HTML만 렌더링
- 클라이언트에서 실제 원격 컴포넌트 로드
- 로딩 상태와 에러 처리를 통한 사용자 경험 개선
Modern.js에서 제공하는 통합 솔루션
세 가지 패턴을 직접 구현하는 것은 상당히 복잡한 작업입니다. 다행히 Modern.js 같은 프레임워크는 이러한 문제를 프레임워크 레벨에서 해결해줍니다.
// modern.config.ts - 간단한 설정으로 MF + SSR 지원
export default defineConfig({
server: {
ssr: { mode: 'stream' },
},
runtime: {
features: { federation: true },
},
});
Modern.js가 해결한 문제들:
- 서버/클라이언트 빌드 자동 분리
- 스트리밍 SSR과 Module Federation 통합
- 데이터 페칭과 SSR 자동 연동
- 하이드레이션 불일치 방지
Next.js와 Module Federation
Next.js는 React 생태계에서 가장 많이 사용되는 프레임워크입니다. 하지만 Module Federation과 함께 사용하는 것은 다른 프레임워크들에 비해 상당한 제약이 따릅니다.
현재 상황 (#3153)
2024년 11월부터 Next.js 지원이 유지보수 모드로 전환되었습니다.
- Pages Router: 2026년 중반까지 버그 수정만 제공
- App Router: 실험 단계, 프로덕션 사용 비권장
왜 어려운가?
이러한 제한이 생긴 이유는 Next.js의 구조적 특성 때문입니다:
-
App Router의 복잡한 구조: 서버 컴포넌트(RSC), SSR, 클라이언트 컴포넌트 등 10개 이상의 레이어가 있고, 각각 다른 React 버전을 사용합니다.
-
번들러 변경: Webpack에서 TurboPack으로 전환 중인데, TurboPack은 Module Federation을 지원하지 않습니다.
-
기술적 충돌: Next.js의 진입점 구조가 Module Federation의 동적 로딩 방식과 맞지 않습니다.
실무에서 사용하려면?
Pages Router 사용 (안정적 옵션)
// next.config.js
const { NextFederationPlugin } = require('@module-federation/nextjs-mf');
module.exports = {
webpack(config) {
config.plugins.push(
new NextFederationPlugin({
name: 'host',
remotes: { remote: 'remote@http://localhost:3001/remoteEntry.js' },
shared: { react: { singleton: true } },
})
);
return config;
},
};
App Router는?
실험 단계이며 메인테이너가 vercel 팀과 협력하며 지원을 위해 힘쓰고는 있지만 현재 프로덕션에서 사용하기엔 안정적이지 않습니다.
관련 진행 사항이 더 궁금하다면 이슈에서 직접 확인해보는게 큰 도움이 될 것 같ㄴ습니다.
추천 방향
그렇다면 실무에서는 어떤 선택을 해야 할까요?
지금 Next.js를 쓰고 있다면:
- Pages Router를 유지하고 2026년 이전에 마이그레이션 계획 수립
- Module Federation 대신 Multi-Zones나 Monorepo 고려
새 프로젝트를 시작한다면:
- Module Federation이 필수라면 Modern.js, Remix 같은 대안 검토
- Next.js를 선택한다면 Module Federation 없이 진행
Next.js App Router와 Module Federation을 함께 사용하려면 많은 제약과 불확실성을 감수해야 합니다. 프로덕션 환경에서는 더 안정적인 대안을 고려하는 것이 좋습니다.
핵심 특징 정리
- 분산 시스템 관점: 네트워크 통신, 버전 불일치, 장애 상황을 항상 고려해야 합니다
- 런타임 동적 해결: 빌드 타임이 아닌 런타임에 모듈이 결합됩니다
- 의존성 협상: 서로 다른 버전의 라이브러리도 Share Scope를 통해 조율됩니다
- 독립적 배포: 각 팀이 독립적으로 배포할 수 있습니다
적용 시 고려사항
Module Federation은 강력하지만 복잡도가 높은 기술입니다. 번들 크기 증가, 네트워크 지연, 버전 관리 등 추가로 신경 써야 할 부분이 많습니다.
하지만 다음과 같은 상황이라면 도입을 고려해볼 만합니다:
- 여러 팀이 독립적으로 개발하고 배포해야 하는 경우
- 각 팀의 기술 스택이나 배포 주기가 다른 경우
- 레거시 앱과 신규 앱을 점진적으로 통합해야 하는 경우
반대로 팀 규모가 작거나 단일 애플리케이션으로 충분하다면 굳이 도입할 필요는 없습니다. 아키텍처는 문제를 해결하기 위한 도구일 뿐입니다.