빌드는 성공하는데 배포하면 죽어요 — @module-federation/vite의 조용한 결함을 upstream에서 고치기까지
안녕하세요. 프론트엔드 엔지니어 오태훈이에요. 플랫폼과 빌드 계층을 맡고 있어요.
eager: true shared를 켠 Module Federation host 앱이 프로덕션 빌드에서만 죽었어요. 원인이 번들러가 아니라 플러그인의 정규식 후처리에 있었고, upstream에 고쳐 넣기까지의 기록이에요.
초록불 빌드, 죽는 앱
프로덕션 Vue host 앱을 배포하면 화면이 하나도 뜨지 않고 콘솔에 이것만 남았어요.
ReferenceError: __mfLocalSharedImportMap is not defined
at getLocalSharedImportMap (http://localhost:5199/remoteEntry.js:215:5)
at Module.init (http://localhost:5199/remoteEntry.js:361:45)
at http://localhost:5199/assets/hostInit-CM6Ia1xK.js:86:45
at async http://localhost:5199/assets/mf-entry-bootstrap-0-1989a01d.js:32:3
hostInit에서 Module.init으로 들어가는 경로라 앱이 부팅 도중에 멈춰요.
minify: false로 다시 빌드해 remoteEntry.js를 열어보니 이유가 보였어요. 식별자를 쓰는 문장은 남아 있는데, 그 식별자를 바인딩하는 import가 없었어요.
import { _ as __vitePreload } from './assets/preload-helper-CWZBUsdZ.js';
import { h as helpers, i as init$1 } from './assets/index-GcC7LmTc.js';
...
async function getLocalSharedImportMap() {
return __mfLocalSharedImportMap; // free, undeclared identifier
}
CI가 못 잡는 이유도 여기 있어요. vite build는 EXIT 0으로 끝나고 Rollup 경고도 없어요. dev 서버에서는 안 나타나고 프로덕션 빌드에서만 생겨요. 게다가 선언이 사라진 __mfLocalSharedImportMap은 undeclared 전역이 되고, minifier는 선언되지 않은 식별자를 rename하지 못해요. 그래서 minify된 번들에도 이름이 그대로 남아, 산출물만 훑으면 평범한 코드처럼 보여요.
재현을 파일 6개로 줄이기
사내 코드를 공개할 수 없어 별도 최소 프로젝트로 옮겨야 했어요. 그런데 처음 만든 합성 재현은 멀쩡하게 빌드됐어요. 같은 플러그인, 같은 shared 설정인데 터지지 않았어요.
차이는 청크 토폴로지였어요. MF 런타임이 별도 shared 청크로 hoist되면 네임스페이스가 청크 경계를 넘고, 그때만 문제의 모양이 나와요. 런타임이 같은 청크에 남으면 바인딩이 { h, i, _ }처럼 다른 specifier와 함께 렌더링돼서 살아남아요.
그래서 조건을 하나씩 분리해 필요조건 세 개로 정리했어요.
eager: true인shared항목이 최소 하나- build entry가 둘 이상 (
build.rollupOptions.input) - 앱 소스가
@module-federation/runtime을 직접 import
셋 중 하나만 빼도 빌드가 정상으로 돌아와요. 이 조건들은 결함의 원인이 아니라 런타임을 별도 청크로 밀어내는 장치일 뿐이에요. 이렇게 좁히니 파일 여섯 개짜리 프로젝트로 브라우저 런타임 오류까지 재현됐어요.
확인은 브라우저 없이도 되게 만들어 뒀어요.
node -e '
const s = require("fs").readFileSync("dist/remoteEntry.js", "utf8");
console.log("uses the identifier :", s.includes("__mfLocalSharedImportMap"));
console.log("imports it :", /import\s*\*\s*as\s*__mfLocalSharedImportMap/.test(s));
console.log("references the chunk:", s.includes("localSharedImportMap"));
'
# broken -> true / false / false
# fine -> true / true / true
영향 범위는 다섯 버전에 걸쳐 매트릭스로 확정했어요.
@module-federation/vite | 결과 |
|---|---|
| 1.16.1 | 영향 없음 (hasEagerShared 코드 경로 자체가 없음) |
| 1.17.0 | 재현 |
| 1.19.0 | 재현 |
| 1.20.0 | 재현 |
| 1.20.2 | 재현 |
사내 코드 주석에는 "1.16.1부터"로 적혀 있었어요. 실측해보니 hasEagerShared가 들어온 1.17.0부터였고, 그 부분을 정정했어요.
범인은 Rollup이 아니었어요
이슈를 올린 시점의 제 가설은 코드 생성부였어요. generateRemoteEntry()가 hasEagerShared 게이트로 import 문과 return 문을 따로 뿌리니까, 그 게이트가 어긋난 거라고 봤어요.
계속 파보니 아니었어요. Rollup은 import를 정확히 내보냈어요. 지우는 쪽은 플러그인의 후처리, src/utils/controlChunkSanitizer.ts의 stripEmptyPreloadCalls()였어요.
const helperImportRegex = /import\s*\{\s*_\s*as\s*(\w+)\s*\}\s*from\s*["'][^"']+["']\s*;?/g;
...
nextCode = nextCode.replace(helperImportRegex, (statement, local) => {
const helperCallRegex = new RegExp(`\\b${local}\\s*\\(`);
return helperCallRegex.test(nextCode.replace(statement, '')) ? statement : '';
});
의도는 바로 위 empty-deps 재작성 때문에 안 쓰이게 된 Vite preload 헬퍼를 걷어내는 거예요. 문제는 import { _ as x } from '...'가 preload 헬퍼 전용 형태가 아니라는 점이에요. Rollup이 import * as ns from '...'를 청크 경계 밖으로 렌더링할 때도 똑같은 모양이 나와요. 그리고 네임스페이스 바인딩은 호출되지 않고 값으로만 참조돼요. "호출되지 않으니 안 쓰인다"는 판정에 걸려서 삭제돼요.
단계까지 특정하려고 정규식 콜백에 로그를 심고 빌드를 돌렸어요.
[MF-SANITIZER-DELETED] "import { _ as __mfLocalSharedImportMap } from './assets/_virtual_mf-localSharedImportMap___mfe_internal__hostApp__mf_owner__1-!~{005}~.js';"
파일명 끝의 !~{005}~가 Rollup의 해시 플레이스홀더예요. 해시가 아직 안 박힌 문자열을 지운다는 건, 이 삭제가 Rollup이 import를 내보낸 다음인 renderChunk 단계에서 일어난다는 뜻이에요.
고치는 방법을 한 번 바꿨어요
첫 수정안은 모듈 경로 게이트였어요. specifier에 preload-helper가 들어간 경우만 지우는 방식이에요. 크래시는 사라졌는데 기존 테스트 strips minified preload helpers and loadShare side-effect imports가 깨졌어요. 그 테스트에서는 헬퍼가 TreeLoader 청크에서 정당하게 들어와요. Vite는 헬퍼를 임의의 청크에 인라인할 수 있으니, 경로는 애초에 신뢰할 신호가 아니었어요.
그래서 게이트 대신 판정 기준 자체를 바꿨어요. "호출되는가"에서 "참조되는가"로요.
- const helperCallRegex = new RegExp(`\\b${local}\\s*\\(`);
- return helperCallRegex.test(nextCode.replace(statement, '')) ? statement : '';
+ const helperReferenceRegex = new RegExp(`\\b${local}\\b`);
+ return helperReferenceRegex.test(nextCode.replace(statement, '')) ? statement : '';
근거는 실패 모드의 비대칭이에요. 안 쓰는 import를 남기면 몇 바이트를 손해봐요. 쓰는 import를 지우면 빌드는 초록불인 채로 앱이 런타임에 죽어요. 판정은 남기는 쪽으로 기울어야 해요.
기존 동작도 그대로 보존돼요. 앞선 empty-deps 재작성이 x(()=>import(m),[],import.meta.url)을 import(m)으로 바꿔요. 그래서 empty-deps 호출에만 쓰였던 헬퍼는 남은 참조가 없어 여전히 지워져요.
메인테이너 대응은 빨랐어요. 이슈 등록 11분 뒤에 확인하겠다는 답이 왔고, 1시간 27분 뒤에 #1013이 머지되면서 이슈가 닫혔어요. #1013은 이 모양을 만들던 eager 분기를 제거해 크래시를 없애요. 그 자체로 좋은 수정이에요. 다만 sanitizer는 그대로라, 값으로만 참조되는 { _ as x } import가 다른 출처에서 control chunk에 들어오면 같은 손상이 다시 일어날 수 있어요. 그 관계를 이슈 코멘트로 정리하고 PR #1014를 따로 열었어요.
검증은 산출물에서
회귀 테스트를 하나 추가했어요. _ as __mfLocalSharedImportMap처럼 값으로 참조되는 import는 살아남고, 진짜로 안 쓰이는 preload 헬퍼는 여전히 제거되는지 양방향으로 봐요.
테스트가 비어있지 않은지부터 확인했어요. fix 없는 main에서 이렇게 실패해요.
FAIL src/utils/__tests__/controlChunkSanitizer.test.ts > controlChunkSanitizer > keeps `_` imports whose binding is referenced as a value instead of called
AssertionError: expected 'async function getLocalSharedImportMa…' to contain 'import{_ as __mfLocalSharedImportMap}…'
Tests 1 failed | 5 passed (6)
fix를 얹은 뒤 upstream 전체를 돌렸어요.
pnpm test -> Test Files 49 passed (49) / Tests 1017 passed | 1 skipped (1018)
pnpm fmt.check -> All matched files use the correct format.
pnpm typecheck -> clean
PR은 파일 두 개, 26 additions / 2 deletions로 08:25Z에 열고 08:49Z에 머지됐어요. 약 한 시간 뒤 1.20.3으로 릴리스됐어요. 마지막으로 배포된 tarball의 lib/index.js를 열어 바뀐 판정식이 실제로 들어갔는지 확인했어요.
남은 생각
"빌드가 성공했다"와 "산출물이 동작한다"는 서로 다른 명제예요. 이번 결함은 그 틈에 들어앉아 있었어요.
정규식으로 산출물 문자열을 후처리하는 코드에는 AST가 주는 안전망이 없어요. 서로 다른 두 의미가 같은 모양으로 렌더링되면 판정은 조용히 틀려요. 그래서 빌드 타임 코드는 종료 코드가 아니라 산출물 자체를 검증 대상으로 잡아야 해요. 이번에 그 역할을 한 건 remoteEntry.js에 import가 남았는지 보는 node 스니펫 세 줄이었어요.