먼저 밝혀둔다. 이 글은 내가 직접 프로덕션에 Module Federation을 붙여본 경험담이 아니다. 우아콘 2023의 "프론트엔드 개발의 미래, Module Federation" 세션을 듣고 정리한 내용에 내 생각을 더한 글이다. 세션에서 다룬 내용은 "발표에 따르면"으로, 내 생각은 "개인적으로는"으로 구분해서 쓴다.
새 기능 하나를 추가하려는데, 이게 어디서부터 시작해서 어디까지 영향을 미치는지 감이 안 잡히는 순간이 있다. 사이드 이펙트, 의존성, "이 컴포넌트를 고치면 저쪽 화면도 깨지나?" 하는 불안. 이번 세션은 이 불안을 프론트엔드 아키텍처의 문제로 정면으로 다뤘다.
백엔드는 나뉘었는데, 프론트엔드는 왜 안 나뉘었나
백엔드는 이미 마이크로서비스로 나뉘어 발전해왔다. 서비스마다 DB를 따로 두고, 컨테이너로 격리하고, 도메인 경계를 API로 그었다. 반면 프론트엔드는 오랫동안 하나의 거대한 SPA로 남아 있었다.
발표는 프로덕트가 고도화되면서 프론트엔드도 같은 문제를 겪는다고 짚었다. 다양한 기능을 갖게 되면서 복잡도가 높아지고, 새 프로덕트를 개발하려 할 때 어디서부터 추가해야 하는지, 어디까지 영향을 미치는지 파악하기 어려워진다는 것이다. 서비스를 도메인별로 나누면 복잡도가 낮아지고, 백엔드-프론트엔드 커뮤니케이션도 도메인 중심으로 좀 더 명확해져서 불필요한 의사소통이 줄어든다. 백엔드처럼 마이크로한 단위로 나누어 개발해야 하는 시점이 프론트엔드에도 왔다는 게 발표의 출발점이었다.
어드민이 하나씩 늘어날 때 벌어지는 일
발표에서 든 동기는 구체적이었다. 여러 사업 영역을 운영하는 서비스라면, 영역별로 어드민을 따로 두는 경우가 많다. 어드민이 하나 있을 때는 별문제 없다. 그런데 사업이 확장돼서 어드민이 N개가 되면 이런 일이 벌어진다.
- 어드민마다 따로 접속하고, 따로 로그인해야 한다.
- 인증/인가를 어드민 개수만큼 각자 구현하고 각자 관리한다.
- 기능 하나를 추가해도 어드민 개수만큼 손을 대야 한다.
운영·관리·개발 부담이 어드민 개수에 비례해서 커진다. 발표가 제안한 그림은 이렇다 — 어드민을 각자 독립된 앱으로 두는 대신, host 앱 하나가 여러 remote 앱을 불러와 조립하는 구조로 바꾼다. 인증/인가 같은 공통 관심사는 host 하나에서 관리하고, 각 도메인의 기능은 remote로 갈아 끼운다. N개로 늘어나도 관리 지점은 host 하나로 유지된다.
flowchart LR
subgraph before["도메인별 개별 어드민 늘어날수록 부담 배가"]
direction TB
A1["어드민 A 로그인 · 인증/인가 개별 구현"]
A2["어드민 B 로그인 · 인증/인가 개별 구현"]
A3["어드민 C 로그인 · 인증/인가 개별 구현"]
end
subgraph after["host + remote 진입점 하나로 관리"]
direction TB
H["host 앱 공통 인증/인가 · 셸"] --> R1["remote: 도메인 A 모듈"]
H --> R2["remote: 도메인 B 모듈"]
H --> R3["remote: 도메인 C 모듈"]
end
여기까지가 발표가 짚은 "왜"다. 그런데 발표는 곧바로 이 그림에 스스로 브레이크를 건다.
그래도 이게 항상 정답은 아니다
발표에서 인상적이었던 부분은 이 지점이다. 새 아키텍처를 소개하는 세션인데도, "이게 모든 상황에 정답이냐"는 질문을 스스로 던지고 답을 유보한다. 규모가 작은 제품이나 팀에는 코드베이스, 러닝커브, 구조 자체를 이해하는 데 드는 학습 비용이 만만치 않다. 도메인이 하나뿐이거나 팀이 작다면, 나누는 것 자체가 오히려 득보다 실이 될 수 있다는 것이다.
개인적으로는 이 자기 의심이 세션에서 가장 신뢰가 갔던 부분이다. "새 기술을 도입했다"는 얘기는 성공 사례로만 포장되기 쉬운데, 이 발표는 "도입 안 하는 게 나은 경우"를 먼저 인정하고 넘어갔다.
나뉜 앱들은 어떻게 대화하는가
앱을 쪼개는 순간 새로운 문제가 생긴다. 나뉜 앱들끼리 어떻게 상태를 공유하고 소통할 것인가. 발표는 네 가지 패턴을 비교했는데, 각각의 트레이드오프가 명확했다.
| 패턴 | 장점 | 단점 |
|---|---|---|
| props | 익숙하고 직관적 | host-remote 간 커플링·의존성 발생, 리렌더링 전파, 프레임워크 통일 필요 — 나눈 걸 다시 합치는 셈 |
| storage API (localStorage 등) | 의존성 적음, 브라우저 지원 넓음 | 확장하기 어려운 구조, 보안에 취약 |
custom event (window.dispatchEvent) | 비동기 이벤트 기반, 확장 용이 | 이벤트 이름·페이로드 계약을 문서화하지 않으면 추적이 어려움 |
| custom message bus | 비동기, 확장 용이, 구조를 팀 상황에 맞게 설계 가능 | 직접 구현해야 함 — 설계·구현 비용이 팀 몫으로 남음 |
props는 가장 익숙하지만, 함정이 있다. host와 remote가 props로 데이터를 주고받으려면 결국 두 앱이 같은 프레임워크 버전, 같은 렌더링 생명주기를 공유해야 한다는 뜻이다. 그러면 애초에 앱을 나눈 이유(독립적인 배포·개발)가 무색해진다. 발표는 이를 "나눠놨는데 다시 합치는 것과 같다"고 표현했는데, 개인적으로 이 한 줄이 마이크로 프론트엔드 통신 문제의 핵심을 가장 잘 요약한다고 생각한다. 앱을 독립적으로 만들고 싶다면, 통신도 독립적인 방식(이벤트, 메시지 버스)으로 풀어야 그 독립성이 유지된다.
Module Federation: host가 remote를 실제로 어떻게 불러오는가
여기서부터가 발표의 본론이다. CSR이든 SSR이든, 리액트 앱은 결국 컴포넌트를 import해서 렌더링하는 단계를 거친다. 그런데 그 컴포넌트가 지금 빌드하는 이 프로젝트 안에 없고, 다른 배포 단위에 있는 컴포넌트라면 어떻게 import할 것인가. 이 질문에 대한 웹팩의 답이 Module Federation이다.
핵심 개념은 두 개뿐이다.
- expose — 이 앱의 특정 모듈을 다른 앱이 가져다 쓸 수 있게 노출한다.
- remotes — 다른 앱이 노출한 모듈을 이 앱에서 가져와 쓴다.
remote 쪽 설정은 대략 이렇게 생겼다.
// remote-app/webpack.config.js
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = {
// ...
plugins: [
new ModuleFederationPlugin({
name: "remote", // 이 앱을 식별하는 이름
filename: "remoteEntry.js", // 이 파일이 "모듈 목록표" 역할을 한다
exposes: {
"./Nav": "./src/components/Nav", // host가 가져다 쓸 모듈
},
shared: ["react", "react-dom"],
}),
],
};host 쪽은 그 remote를 가리키는 URL을 등록한다.
// host-app/webpack.config.js
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = {
// ...
plugins: [
new ModuleFederationPlugin({
name: "host",
remotes: {
remote: "remote@https://remote-app.example.com/remoteEntry.js",
},
shared: ["react", "react-dom"],
}),
],
};host 코드에서는 마치 로컬 모듈처럼 이걸 import해서 쓴다.
// host-app/src/App.jsx
import { lazy, Suspense } from "react";
const RemoteNav = lazy(() => import("remote/Nav"));
function App() {
return (
<Suspense fallback={<div>로딩 중...</div>}>
<RemoteNav />
</Suspense>
);
}실제로 브라우저에서 벌어지는 일은 이렇다. host 앱이 로드되고 렌더링을 시작하는 시점에, remote/Nav를 만나면 host는 미리 등록해둔 URL로 remoteEntry.js를 동적으로 요청한다. 이 파일은 그 remote 앱이 무엇을 노출하고 있는지 알려주는 목록표 역할을 한다. 목록표를 받으면 host는 그 안에서 실제 Nav 컴포넌트 코드가 담긴 청크를 다시 요청해서 받아오고, 그걸로 렌더링을 완성한다.
flowchart TD U["브라우저: host 페이지 요청"] --> H["host 번들 로드 · 렌더 시작"] H -->|"remote/Nav import 시도"| RE["remote 서버에 remoteEntry.js 요청"] RE -->|"모듈 맵 어떤 모듈이 어디 있는지 응답"| H H -->|"Nav 컴포넌트 청크 요청"| CH["remote 서버에서 컴포넌트 코드 청크 응답"] CH --> S["React.lazy + Suspense로 Nav 렌더"]
왜 이게 유연한가 — 배포 단위가 잘게 나뉘고, 필요한 모듈만 필요할 때 교체할 수 있다. remote 하나를 새 버전으로 배포해도 host를 다시 빌드하지 않아도 된다(host 설정에 박힌 URL이 그대로 가리키는 remoteEntry.js가 최신 코드를 서빙한다는 전제 하에). 발표는 이를 유연성(작고 관리하기 쉬운 모듈로 분할, 필요에 따라 모듈 교체), 확장성(개별 앱의 크기와 복잡성 감소), 협업(팀 간 코드베이스 공유)으로 정리했다.
왜 이게 위험한가 — 반대로 그 유연성만큼 디펜던시 버저닝 관리가 어려워지고, 앱 간 코드를 공유하는 지점마다 보안 검토가 필요해지고, 여러 청크를 순서대로 받아와야 하니 잠재적인 성능 이슈도 생긴다.
Next.js에서는 SSR 여부에 따라 로딩 방식이 갈린다는 게 발표의 정리였다. 서버에서 렌더링하지 않는 컴포넌트라면 dynamic import로 클라이언트에서만 로드하고, 서버에서도 렌더링이 필요하다면 lazy + Suspense 조합을 쓴다.
도입하면 바로 만나는 문제들
발표의 후반부는 실전에서 부딪힌 문제들을 나열하는 데 썼다.
타입스크립트 지원
remote 모듈은 결국 컴파일된 JS로 서빙된다. host 입장에서는 이 원격 모듈의 타입을 컴파일 타임에 어떻게 알 수 있을까. 발표가 소개한 답은 @module-federation/typescript였다 — remote가 타입 정의 파일까지 만들어서 함께 노출하고, host가 런타임에 한 번 그 타입 정의를 다운로드해서 쓰는 방식. 문제는 이게 단방향(remote → host)만 지원한다는 것. 양방향으로 쓰려면 nativeFederationTypescript를 따로 써야 하는데, 그러면 host와 remote가 서로의 타입을 참조하는 사이클 구성이 생기면서 런타임 에러가 날 수 있다. 발표는 이 상황을 "이상하긴 해도 없는 것보단 낫다"고 정리했다. 개인적으로도 이 표현에 동의한다 — 완벽하지 않은 타입 안전성이라도, 아예 없는 것보다는 낫다.
러닝커브
webpack config에 Module Federation 설정을 얹는 것도 배워야 하고, Vite를 쓰면 Vite 설정을, Rollup을 쓰면 Rollup 설정을, Next.js를 쓰면 또 다른 설정을 배워야 한다. 번들러마다 지식이 따로 필요하다는 게 발표의 지적이었다. 팀에 여러 번들러가 혼재해 있다면 이 학습 비용이 만만치 않을 것 같다.
의존성이 준비되기 전에 컴포넌트가 로드되는 문제
react, react-dom 같은 의존성을 shared로 등록해두면, 여러 앱이 같은 인스턴스를 나눠 쓰면서 중복 로딩을 막을 수 있다. 그런데 이 공유 의존성이 아직 로드되지 않은 시점에 그걸 필요로 하는 컴포넌트가 먼저 실행되면 다음 에러가 난다.
Error: Shared module is not available for eager consumption
발표는 이 문제를 부트스트랩이라는 중간 레이어로 풀었다. 진입점을 두 파일로 쪼개는 것이다.
// index.js — 동적 import로 비동기 경계를 만든다
import("./bootstrap");// bootstrap.js — 실제 앱 코드는 여기서 시작
import { createRoot } from "react-dom/client";
import App from "./App";
createRoot(document.getElementById("root")).render(<App />);index.js의 동적 import()가 만드는 비동기 경계 덕분에, 웹팩은 그 경계 안에서 디펜던시 로드를 컴포넌트 로드 전에 처리할 수 있다. 반대로 이 과정 자체를 건너뛰고 싶다면 shared 설정에서 특정 의존성을 eager로 못박을 수도 있다.
shared: {
react: { singleton: true, eager: true },
},eager: true로 두면 그 의존성은 별도의 비동기 청크로 분리되지 않고, synchronous하게 initial chunk 안에 존재하게 된다. 발표는 이 옵션을 shell(=host) 앱에서만 제한적으로 쓰라고 권했다.
Next.js에서의 shared 라이브러리 관리
발표는 Next.js에서 nextFederatedPlugin을 쓰면 shared 라이브러리에 대한 기본 정책이 제공된다고 소개했다. 다만 이걸로 문제가 완전히 해결되는 건 아니고, 새로운 서드파티 라이브러리를 쓸 때마다 앞의 eager consumption 문제가 재발할 수 있다는 한계도 함께 짚었다.
불안정성
발표는 Module Federation의 지금 상태를 "빠르게 발전하는 것을 보여주지만, 상용 프로덕트에서 사용하기 안전할까" 싶은 상태로 표현했다. 이슈들에 대한 해결 방법이 버전별로 다르고, 하위 버전 지원의 안정성이 불확실하고, 레퍼런스 정보도 아직 빈약하다는 것이다. 빠르게 바뀌는 기술을 상용에 들여올 때 흔히 겪는 딜레마 그대로였다.
얻은 것과 잃은 것
발표는 마지막에 이걸 손익계산서처럼 정리했다.
얻은 것 — 여러 앱에 흩어져 있던 로직이 공통화된 프로덕트로 모인다. 도메인마다 확장할 때의 부담이 줄고 책임 경계가 명확해진다. 문제가 생겼을 때 영향 범위가 예측 가능한 수준으로 좁아진다. 그리고 상용 서비스에 이런 실험적인 기술을 들여왔다는 것 자체가, 비즈니스 요구와 기술적 갈증을 동시에 채운 사례로 남는다.
잃은 것 — 프로덕트의 단순함. host/remote 구조, 공유 의존성 협상, 런타임 로딩 같은 개념이 베이스 프로젝트에 얹히면서 프로젝트 자체의 크기와 복잡도가 커진다.
개인적으로는 이 트레이드오프가 결국 "복잡도를 어디에 둘 것인가"의 문제로 보인다. 도메인이 늘어날 때의 복잡도를 각 어드민에 흩어놓을지, 아니면 host/remote 아키텍처 자체의 복잡도로 모아서 짊어질지 선택하는 것. 정답은 없고, 도메인이 몇 개고 팀이 몇 명인지에 따라 답이 갈릴 문제다.
개인적으로 시도해보고 싶은 것
발표 마지막에 나온 두 가지 아이디어가 계속 머리에 남았다. 직접 해본 건 아니지만, 왜 필요한지는 꽤 공감이 갔다.
remote 앱 제너레이터. remote 하나를 새로 만들 때마다 프로젝트 구조, webpack 설정, 배포 파이프라인을 매번 손으로 맞추는 건 반복 노동이다. hygen 같은 코드 제너레이터로 프로젝트 템플릿을 CLI 명령 하나로 뽑아내고, 배포 파이프라인도 가이드라인화하거나 CI 서버의 REST API로 자동 생성할 수 있다면 remote 추가 비용이 크게 줄 것 같다.
dynamic remotes. 지금 방식은 remote 버전이 바뀔 때마다 host 설정 파일을 고쳐야 한다. 이걸 external-remotes-plugin 같은 웹팩 플러그인으로 라우터 기반으로 풀 수 있지 않을까 하는 아이디어였다. 예를 들어 URL의 slug로 remote를 읽어서, 등록된 게 있으면 그 컴포넌트를 동적으로 로드하는 방식. 사이드바 메뉴도 마찬가지다 — remote를 추가할 때마다 host 코드를 손대는 대신, 등록된 remote 목록을 순회(iterate)해서 사이드바를 자동으로 구성할 수 있지 않을까. [...slug] 라우트로 맵을 구성하고, injectScript로 remoteEntry를 동적으로 주입하고, React.Suspense와 에러 바운더리로 로딩·실패를 감싸는 그림이었다.
발표는 이 아이디어를 실제로 붙이면 또 다른 문제들이 딸려온다는 것도 인정했다 — 동적 라우터의 타입스크립트 처리, 동적 사이드바 메뉴 생성, 그리고 앞서 나온 앱 간 통신·공유 이슈들이 여전히 남는다는 것. 그럼에도 도메인이 계속 늘어나는 서비스라면 이 방향이 유용할 거라는 결론이었다.
마무리
이번 세션을 들으며 남은 인상은, Module Federation이 "프론트엔드도 마이크로서비스처럼 나눌 수 있다"는 걸 기술적으로 증명해 보인 것과, 그 나눔이 공짜가 아니라는 걸 발표자 스스로 계속 되짚었다는 것 두 가지였다. 도입을 고민한다면 결국 발표가 처음에 던진 질문으로 돌아가게 된다 — 도메인이 몇 개고, 팀이 몇 명이고, 그 복잡도를 어디에 두고 싶은가. 그 답이 "그렇다"일 때만 host와 remote를 그을 이유가 생긴다.