TL;DR
- 모든 페이지가 받는 entry 청크에서 렌더에 안 쓰는 AG Grid(raw 1.3MB)를 걷어내 gzip 646KB → 406KB(-240KB, -37%)로 줄였습니다.
- 어드민 메인 청크는 벤더를 "가족 단위"(react / antd / refine)로
manualChunks분리해 raw 3.0MB → 1.2MB(gzip 885KB → 284KB)가 됐습니다.- 배포 직후 옛 해시 청크를 받아 생기는 흰 화면(stale chunk)은 세 갈래를 모두 잡아 배포당 1회만 자동 리로드해 자가복구합니다.
- 스택: Vite 7 + React 19 + Refine 5 + AntD 5, Sentry로 실패를 관측했습니다.
번들 최적화 글은 대개 "얼마나 줄였는가"에서 끝납니다. 그런데 청크를 잘게 쪼갤수록 새로 생기는 문제가 있습니다. 배포하는 순간 이미 접속 중이던 사용자의 브라우저가 흰 화면으로 죽는 현상입니다. 이 글은 사내 어드민/프론트 모노레포에서 (1) 초기 로드를 절반 이하로 줄이고, (2) 그 과정에서 커진 stale chunk 리스크를 어떻게 자가복구로 막았는지를 순서대로 정리한 회고입니다. 수치와 코드는 전부 실제 커밋에서 가져왔습니다.
Lighthouse와 번들 분석을 돌려보니, 모든 페이지가 무조건 받는 entry 청크(index-*.js, gzip 646KB)의 최대 구성요소가 ag-grid-community(raw 약 1.3MB)였습니다.
그런데 이상했습니다. 앱 어디에도 그리드를 렌더하는 코드가 없었습니다. columnDefs, rowData, AgGridReact, DataGrid를 앱 소스와 공유 UI 패키지(src·dist 포함)까지 grep해도 전부 0건이었습니다. 원인은 진입점 한 줄이었습니다. main.tsx가 AllCommunityModule(그리드 전 모듈)을 eager import한 뒤 ModuleRegistry.registerModules로 전역 등록하고 있었습니다. 쓰지도 않는 라이브러리를 첫 페인트 경로에 통째로 싣고 있던 것입니다.
여기서 판단이 하나 갈립니다. "청크를 분리하면 되지 않나?" 안 됩니다. main.tsx가 eager import하는 한, manualChunks로 청크만 나눠도 그 청크는 critical path에 그대로 남습니다. 사용처가 0이므로 분리가 아니라 제거가 유일하고 정확한 해법이었습니다.
typescript// main.tsx — 제거한 코드 (그리드 렌더 코드가 앱에 0건) - import { AllCommunityModule, ModuleRegistry } from 'ag-grid-community'; - ModuleRegistry.registerModules([AllCommunityModule]);
main.tsx의 import 두 줄과 package.json의 ag-grid-community/ag-grid-react 의존성을 지우고 lockfile을 갱신했습니다. 결과는 entry 청크 gzip 646KB → 406KB(-240KB, -37%). typecheck 통과, lint 0, 테스트 2,513건 전부 통과, 빌드 성공.
교훈은 단순합니다. 전역 등록형 라이브러리(ModuleRegistry, 플러그인 .use() 류)는 진입점에서 한 번 부르면 사용 여부와 무관하게 번들에 눌러앉습니다. 향후 그리드를 다시 붙이더라도 등록은 해당 (lazy) 그리드 페이지에 co-locate하기로 커밋 메시지에 남겨뒀습니다.
어드민 쪽은 결이 조금 달랐습니다. 항상 로드되는 메인 index 청크가 react + refine + antd + dayjs + rc-*를 manualChunks 없이 통째로 합쳐 raw 3,069KB(gzip 885KB)였습니다. 여기엔 dead weight가 아니라 실제로 쓰는 벤더가 들어 있어서, 제거가 아니라 분리가 답이었습니다.
분리의 목표는 두 가지입니다. 첫째, 항상 로드되는 메인 청크를 줄인다. 둘째, 벤더를 별 청크로 빼서 코드만 바뀐 배포에서는 벤더를 재다운로드하지 않게 한다(캐시 재사용).
문제는 벤더 분할에 지뢰가 있다는 점입니다. 과거에 벤더를 잘게 쪼갰다가 namespace undefined 계열의 초기화 순서(init-order) 크래시를 겪은 이력이 있었습니다. 그래서 이번엔 보수적 원칙을 세웠습니다. 서로 의존하는 패키지 가족은 절대 쪼개지 않고 통째로 한 청크에 묶는다. antd + rc-* + @ant-design이 한 덩어리, react 런타임이 한 덩어리, @refinedev가 한 덩어리입니다. 의존 방향이 @refinedev → antd → rc-* → react로 단방향이라, 가족끼리 분리하는 건 청크 경계를 가로지르는 순환을 만들지 않습니다.
typescript// apps/admin/vite.config.ts — build.rollupOptions.output manualChunks(id) { if (!id.includes('node_modules')) return undefined; // @refinedev 를 react/antd 보다 먼저 검사 — @refinedev/react-router 가 // react 그룹으로 새지 않게 한다. if (/[\\/]node_modules[\\/]@refinedev[\\/]/.test(id)) return 'vendor-refine'; if (/[\\/]node_modules[\\/](react|react-dom|react-router|react-router-dom|scheduler)[\\/]/.test(id)) return 'vendor-react'; if (/[\\/]node_modules[\\/](antd|@ant-design|rc-[^\\/]+)[\\/]/.test(id)) return 'vendor-antd'; if (/[\\/]node_modules[\\/]@sentry[\\/]/.test(id)) return 'vendor-sentry'; if (/[\\/]node_modules[\\/]dayjs[\\/]/.test(id)) return 'vendor-dayjs'; return undefined; }
여기서 검사 순서가 중요합니다. @refinedev/react-router 같은 패키지는 경로에 react가 들어가서, @refinedev를 먼저 매칭하지 않으면 vendor-react로 새어 들어갑니다. 그래서 refine을 맨 위에서 잡습니다.
의도적으로 손대지 않은 것도 있습니다. 에디터 가족(@blocknote/@mantine/@tiptap/prosemirror)입니다. 이들은 lazy 라우트(피드백 인박스, 공고 편집 등)에서만 정적 import되어 이미 lazy 청크로 잘 분리돼 있었습니다. 이걸 한 청크로 강제 병합했더니, eager 모듈 한 조각이 섞이면서 1.1MB 전체가 초기 modulepreload로 끌려 들어오는 회귀가 생겼습니다. 그래서 Rollup의 자동 청킹에 그대로 맡겼습니다. 5.8MB짜리 픽스처도 이미 동적 import라 초기 로드에 영향이 없어 건드리지 않았습니다.
결과는 메인 index 청크 raw 3,069KB → 1,194KB(gzip 885KB → 284KB). 어드민 유닛 1,066건 통과. 다만 커밋 메시지에 비차단 주의도 남겼습니다. 가족 단위 분할의 런타임 init-order 안전성은 빌드·유닛 테스트만으로 완전히 검증되지 않으므로, 배포 전 브라우저 스모크(에디터·프리뷰 경로)를 권장한다고 적었습니다. 최적화가 만든 리스크를 정직하게 기록하는 것도 재현 가능성의 일부라고 봅니다.
여기서부터가 이 글의 핵심입니다. 청크를 잘게 나눌수록 파일 개수가 늘고, 파일마다 콘텐츠 해시가 붙습니다. 배포하면 해시가 전부 바뀝니다. 그러면 배포 직전에 이미 접속해 있던 사용자의 브라우저는 옛 index.html을 든 채로 옛 해시의 청크를 요청하고, 그 파일은 서버에서 이미 사라져 404가 납니다. Sentry에는 CMARKET_ADMIN-2/-4/-6 같은 흰 화면 이슈가 배포할 때마다 쌓였습니다.
stale chunk는 한 종류가 아니었습니다. 실제로 세 갈래로 터졌습니다.
갈래 1 — lazy import 404. /guides 같은 lazy 라우트에 진입할 때 동적 import가 실패합니다. Vite 5+는 이 경우 window에 vite:preloadError를 쏩니다. 이건 잡기 쉽습니다.
갈래 2 — 정적 entry 스크립트 404. 캐싱이나 bfcache로 옛 index.html을 든 탭이, 부팅 시점의 옛 해시 <script type=module>·<link rel=modulepreload>를 404로 받는 경우입니다. 이건 React가 마운트되기 전이라 vite:preloadError도, ErrorBoundary도 못 잡습니다. 완전한 흰 화면이 됩니다. 리소스 로드 에러는 버블링하지 않으므로, window의 error를 capture 단계로 받아야만 잡힙니다.
갈래 3 — 조용한 undefined. lazy 청크는 200으로 잘 받았는데, 함께 바뀐 공유 의존 청크에서 named export가 사라져 모듈 namespace가 undefined로 평가되는 경우입니다. m.BidList에 접근하는 순간 TypeError가 납니다. fetch는 성공했으니 vite:preloadError도, 리소스 error도 안 뜨고, 오직 React ErrorBoundary만 잡습니다.
세 갈래를 하나의 가드로 묶었습니다. 정적/lazy 에러 리스너는 이렇게 설치합니다.
typescript// apps/admin/src/shared/preloadErrorReload.ts export function installPreloadErrorReload( reload: () => void = () => window.location.reload(), ): () => void { const onPreloadError = (event: Event): void => { event.preventDefault(); // Sentry unhandledrejection 노이즈 차단 reloadOnce(reload); }; // 정적 asset(script/link) 로드 실패만 처리 — 이미지 등 404 는 무시(오탐 방지) const onResourceError = (event: Event): void => { if (!isStaleAssetTarget(event.target)) return; reloadOnce(reload); }; window.addEventListener('vite:preloadError', onPreloadError); // 리소스 로드 에러는 버블링하지 않으므로 capture 단계로 받는다 window.addEventListener('error', onResourceError, true); return () => { /* 테스트 정리용 해제 */ }; }
갈래 3은 lazy 래퍼로 잡습니다. import 결과의 export가 비어 있으면 같은 가드로 리로드하고, 안정된 마커 에러를 던집니다.
typescriptexport async function loadLazyExport(factory, exportName, reload) { const mod = await factory(); const component = mod?.[exportName]; if (!component) { reloadOnce(reload); throw new StaleChunkError(exportName); // name='StaleChunkError' } return { default: component }; }
App.tsx에서는 lazy(...) 대신 이 lazyWithReload로 라우트를 선언합니다.
typescriptconst BidList = lazyWithReload( () => import('@/features/bids/presentation/BidList'), 'BidList', );
자동 리로드에는 함정이 있습니다. 리로드했는데 문제가 그대로면 무한 새로고침에 빠집니다. 그래서 세 리스너가 하나의 가드를 공유하도록 만들었습니다. sessionStorage에 마지막 리로드 시각을 남기고, 가드 시간(10초) 안의 재발생은 전부 무시합니다.
typescriptconst RELOAD_GUARD_TTL_MS = 10_000; function reloadOnce(reload: () => void): void { const lastReloadedAt = Number(sessionStorage.getItem(RELOAD_GUARD_KEY)); if (Date.now() - lastReloadedAt < RELOAD_GUARD_TTL_MS) return; sessionStorage.setItem(RELOAD_GUARD_KEY, String(Date.now())); reload(); }
TTL을 무한이 아니라 10초로 둔 건 의도적입니다. 같은 탭을 며칠 켜둔 사용자가 그 사이 두 번째 재배포를 만나도 다시 복구가 동작해야 하기 때문입니다. 한 배포당 1회만 리로드하되, 시간이 지나면 다음 배포용으로 가드가 풀립니다.
마지막 마무리는 관측 노이즈입니다. 자가복구는 예상된 동작이라 Sentry에 라우트마다 새 이슈를 만들 필요가 없습니다. 복구 throw에는 name = 'StaleChunkError'라는 안정 마커를 달고, Sentry beforeSend에서 이 마커(그리고 번들 경계로 name을 잃은 경우를 위한 message 꼬리표)를 보고 전송 전에 드롭합니다. 실제로 고장 난 것만 알림으로 남기고, 스스로 나은 것은 조용히 넘어갑니다.
이 가드는 테스트로 고정했습니다. window.location.reload는 jsdom에서 unforgeable이라 spy가 안 되므로, reload 의존성을 인자로 분리해 주입했습니다. 첫 발생 1회 리로드, 가드 안 재발생 무시, preventDefault 확인, TTL 경과 후 재허용, 정적 스크립트 404 복구, asset 경로가 아닌 스크립트 404 무시(오탐 방지)까지 케이스별로 검증합니다.
세 작업은 사실 하나의 이야기입니다.
manualChunks로 아무리 나눠도 critical path에 남습니다. 사용처 0을 grep으로 먼저 증명한 뒤 지웠습니다.namespace undefined를 부릅니다.ModuleRegistry, 플러그인 .use())를 특히 의심하세요.vite:preloadError 하나만 잡으면 갈래 2·3에 뚫립니다. 정적 리소스는 window error를 capture 단계로, undefined export는 lazy 래퍼로 잡으세요.sessionStorage + 짧은 TTL(예: 10초)이 단순하면서 잘 동작합니다.name)로 beforeSend에서 거르는 게 깔끔합니다.// Comments