사실 한줄은 아니다
우리 팀엔 내가 그토록 동경하던 프론트엔드 시니어 개발자가 한명 있다.
솔직히 말해 100점짜리 리더는 아니지만, 120점짜리 플레이어라고 느낀다.
기술적으로나 마인드적으로 배울점이 많은 사람이다.
이 사람이 구축해놓은 MFE 레포지토리 설정파일은 너무 읽기 어렵지만 틈틈이 조금씩 공부해보고 있다.
그러다 발견한 하나의 작은 개선건. 하지만 결과는 결코 작지 않았다.
Type:
어느 날 프로젝트 내부의 Module Federation 설정 파일(lib/rspack/config/mf-config.js)을 공식 문서와 비교하며 공부하고 있었다.
설정을 하나씩 읽어가다가 눈에 띈 옵션이다.
공유 의존성(shared dependency)을 어떤 기준으로 선택할지 결정하는 옵션이다.
version-first (Default): 모든 remote의 entry 파일을 앱 시작 시 전부 로딩해서, 가장 높은 버전의 shared 모듈을 골라 쓴다.loaded-first: 이미 로딩된 shared 모듈이 있으면 그냥 그걸 쓴다. remote는 실제로 필요할 때 on-demand로 로딩한다.여기서 한 가지 의문이 들었다. 우리 프로젝트는 호스트 앱에 remote가 56개 등록되어 있는 대규모 마이크로 프론트엔드다.
dr.palette 라는 호스트에 모든 remote가 걸려있다.
version-first가 기본값이라고? 그러면 앱 시작할 때마다 58개 remote의 entry를 전부 가져온다는 뜻인데…
마치 React 최초 구동 시 chunk 되지않은 무거운 앱을 여는것과 다를바가 없지 않나?
실제로 차이가 나는지 확인해보고 싶었다.
사실 설정을 크게 할건 없었다. 그냥 sharedStrategy 옵션을 넣기만 하면 되는 거니까.
이 설정은 각 인스턴스별 설정인건 맞지만, 초기 로딩 시 모든 remote entry를 선제적으로 fetch 하는 것은 호스트의 판단이므로, 호스트 설정만 로컬에서 바꾸고 실행하더라도 차이를 확인할 수 있어 간편하다.
(만약 리모트를 호스트로 삼고 테스트를 한다면 해당 모듈을 로컬로 켜야 한다는 뜻이다.)
먼저 Lighthouse 지표부터 확인해 봤다.
왼쪽이 기존 version-first , 오른쪽이 loaded-first


정말 말도 안되는 차이가 난다. (사실 우리 프로젝트가 B2B이기도 하고 최적화가 좀 부족하다는 생각은 종종 했지만 이정도일줄은 몰랐다..)
사실 Lighthouse도 볼필요가 없는데, 캐시 비우기 + 강력 새로고침으로 네트워크 탭을 보더라도 한눈에 비교가 된다.
위가 기존 version-first , 아래가 loaded-first


10,000ms vs 3200ms 로 3배 이상의 차이를 보인다.
물론 지금은 극단적인 사례이긴 하다.
측정한 곳이 로그인 화면인데, 여기는 호스트를 제외하면 기본 디자인시스템 모듈만 불러오기 때문이다.
로그인 한 뒤의 메인 홈 화면으로 측정했을때는 다음과 같이 나왔다.
이곳은 화면에 보이는 p_home_notice, p_home_todo_list 같은 모듈 외에도 권한 모듈인 p_access_control 등 remoteEntry 을 8개 정도 불러오고 있었다.
당연히 56개보단 압도적으로 적은 양이다.

지표를 확인한 뒤, 의기양양하게 리더에게 공유했다.
"이 옵션 하나만 넣으면 초기 로딩이 엄청 빨라질 것 같아요."
대개 나정도가 발견하면 시니어급 리더도 알고있는 문제다. 그래서 아직까지 그렇게 교체하지 않은 이유에 대해 들었다.
bootstrap.tsx 비동기 MF 초기화 타이밍당시 우리 프로젝트의 모든 bootstrap.tsx는 이런 구조였다.
await import("palette-design-system/design-css.css");
await import("palette-design-system/components");
await import("palette-design-system/ui");
const root = document.getElementById("app");
createRoot(root).render(<App />);파일 최상단에서 바로 await를 쓰고 있었다. Top-level await 자체는 문법적으로 문제가 없지만, Module Federation의 비동기 초기화 흐름과 충돌할 수 있었다.
version-first에서는 어차피 모든 remote를 먼저 다 가져왔기 때문에, shared 모듈이 초기화되기 전에 import가 실행되더라도 이미 다 준비된 상태라 문제가 없었다.
하지만 loaded-first에서는 다르다. remote의 shared 모듈이 아직 안 가져왔는데 쓰려고 하는 타이밍 이슈가 생길 수 있고, 반드시 생기게 된다.
예를들어 기존 지표에서 말도 안되는 차이를 보였던 로그인화면에서 로그인 후 홈화면으로 이동했을때, version-first 옵션은 모든 mf-manifest.json (모듈의 위치) 를 받고, remoteEntry.js 까지 모두 준비된 상태다. 그러나 loaded-first 는 그렇지 않다.
해결책은? bootstrap 을 async function으로 감싸면 된다!
// After: async function으로 감싸기
async function bootstrap() {
await import("palette-design-system/design-css.css");
await import("palette-design-system/components");
await import("palette-design-system/ui");
const root = document.getElementById("app");
createRoot(root).render(<App />);
}
bootstrap();이렇게 하면 Module Federation 런타임이 share scope를 먼저 초기화한 뒤에 bootstrap()이 실행되도록 보장할 수 있다.
문제는... 이걸 모든 패키지에 노가다적용해야 한다는 것이었다. 호스트 포함 56개 패키지의 bootstrap.tsx를 전부 수정했다.
Type: string[] | Array<[string, Record<string, unknown>]>Required: NoDefault: undefined The runtimePlugins configuration is used to add additional plugins needed at runtime. The value can be: A string representing the path to the specific plugin (absolute/relative path or package name)An array where each element can be either a string or a tuple with [string path, object options] You can learn more about how to develop runtimePlugin details by visiting the Plugin System. Once set, runtime plugins will be automatically injected and used during the build process. Examples Basic usage: To create a runtime plugin file, you can name it custom-runtime-plugin.ts: Then, apply this plugin in your build configuration: With options: You can also provide options to runtime plugins by using a tuple format: The plugin can then access these options:
우리 프로젝트에는 CDN 캐시 무효화를 위한 커스텀 런타임 플러그인 external-remote-load-plugin 이 있다.
리더가 옛날에 만들어 둔 remoteEntry URL에 ?t=타임스탬프를 붙여서 브라우저 캐시를 우회하는 용도였는데, 기존에는 beforeRequest 훅을 사용하고 있었다.
// Before: 개별 요청마다 처리
beforeRequest: (args) => {
const { options, id } = args;
const remoteName = id.split("/").shift();
const remote = options.remotes.find((remote) => remote.name === remoteName);
if (!remote) return args;
remote.entry = `${remote?.entry}?t=${Date.now()}`;
return args;
};beforeRequest는 remote 모듈을 요청할 때마다 호출되는 훅이다. version-first에서는 모든 remote가 초기화 시점에 로딩되니까 이 훅이 예측 가능한 순서로 호출되었다. 하지만 loaded-first에서는 remote가 on-demand로 로딩되기 때문에, 이 훅이 호출되는 타이밍과 순서가 달라진다.
리더는 이걸 init 훅으로 옮기는 게 안전하다고 조언해줬다.
init은 MF 런타임이 초기화될 때 딱 한 번 호출되는 훅이라, 모든 remote의 entry URL을 일괄적으로 처리할 수 있다.
// After: 초기화 시점에 일괄 처리
init: (args) => {
args.options?.remotes?.forEach((remote) => {
if (!remote.entry.includes("?t=")) {
remote.entry = `${remote.entry}?t=${Date.now()}`;
}
});
return args;
};코드가 훨씬 단순해졌고, 왜 처음부터 이렇게 안 했을까 싶을 정도로 깔끔하다.
정리하면 이렇다.
| 변경 사항 | 파일 수 | 이유 |
|---|---|---|
| shareStrategy: 'loaded-first' 추가 | 1개 | 핵심 설정 |
| bootstrap.tsx 구조 변경 | 56개 | MF 비동기 초기화와의 호환성 |
| 런타임 플러그인 훅 변경 (beforeRequest → init) | 1개 | on-demand 로딩 시 타이밍 안정성 |
설정 한 줄 바꾸면 끝나는 줄 알았는데, 그 한 줄이 안정적으로 동작하기 위한 기반 작업이 58개 파일에 걸쳐 있었다.
사실 이 경험에서 가장 인상 깊었던 건 성능 개선 수치가 아니다. 시니어가 "그거 한 줄만 바꾸면 안 될걸요?" 라고 했을 때, 그 이유를 하나하나 설명해주던 과정과 내가 보지 못했던 부분을 알게 된 것. 그것이 큰 경험이었다.
이전 회사의 나였다면 (그땐 내가 결정자였으므로) 설정 한 줄 바꾸고, 로컬에서 잘 되는 거 확인하고, 그대로 배포했을 것이다. 배포환경에서 터지고 나서야 "아, await bootstrap.." 하고 깨달았겠지.
shareStrategy의 기본값은 version-first다. remote가 10개 미만인 소규모 프로젝트라면 체감 차이가 크지 않을 수 있다.
하지만 우리는 현재 remote가 56개이고, 앞으로 점점 늘어날 것이며, 청크할 수 있는 완벽한 MFE 환경이어서 도입하지 않을 이유가 없었다고 생각한다.
현재 우리같은 상황은 loaded-first가 압도적으로 유리하다.
단, 설정 한 줄만 바꾸면 끝이 아니다. 프로젝트 환경에 맞춰 다양한 변수에 대해 고민하고 설정해줘야 한다.
누군가 Module Federation 보일러플레이트를 셋업하며 shareStrategy를 기본값으로 두고 있다면, 한번 확인해보길 권한다. 특히 서브모듈의 수가 많으면 많을수록, 체감이 확실히 다를 것이다.