ADR‐010: manualChunks에 캐치올을 두지 않는다 - Moadong/moadong GitHub Wiki
작성일: 2026-09-22 | 상태: Proposed
결정일자: — (PR #2065 리뷰 중)
주기적으로 나오는 질문이 있습니다.
"트리쉐이킹 잘 되고 있어?"
한 줄로 답할 수가 없는 질문이에요. 확인하려면 vite.config.ts의 manualChunks, 빌드 산출물 dist/index.html이 modulepreload로 무엇을 거는지, 그리고 각 청크의 소스맵을 패키지별로 쪼개서 뭐가 들어 있는지까지 봐야 했습니다. 한 번 봤으면 적어두는 게 낫겠다 싶었어요.
답이 중요한 이유는 번들이 무거울 때 고칠 데가 갈리기 때문입니다. 셰이킹이 안 되고 있으면 import 쓰는 방식을 고쳐야 하고, 셰이킹은 되는데 분할이 틀렸으면 설정 한 줄이거든요. 둘은 손대는 곳이 완전히 다릅니다.
답부터 말하면 셰이킹은 멀쩡했고, 분할이 틀려 있었습니다. 관리자 통계 탭에서만 쓰는 recharts가 전 방문자의 첫 로드에 내려가고 있었어요.
"번들 크기"라는 말이 두 가지를 가리키는데 이 건에서는 결론이 반대로 나옵니다. 그래서 정의를 먼저 박아둡니다.
| 말 | 정의 | 이번 변경 후 |
|---|---|---|
| 첫 로드 eager JS |
dist/index.html이 <script>와 modulepreload로 참조하는 .js의 gzip 합. 첫 방문자가 반드시 받는 양 |
647.6KB → 538.1KB |
| 전체 JS |
dist/assets/*.js 전부의 gzip 합. 지연 청크까지 포함 |
788.7KB → 763.3KB |
앞으로 청킹을 건드릴 때 비교할 값은 첫 로드 eager JS입니다. 전체 JS는 청크를 아무리 잘 나눠도 거의 안 줄어서 개선을 못 잡아내요. 반대로 첫 로드만 보면 "공용 모듈이 여러 청크에 중복 포함돼 총량이 늘었다"를 놓치니, 둘 다 적되 판단은 첫 로드로 합니다.
재는 법은 이렇습니다.
cd frontend
SENTRY_AUTH_TOKEN= npx vite build --config ./config/vite.config.ts
# dist/index.html에서 script/modulepreload가 가리키는 .js만 골라 gzip -9로 합산
⚠️ npm run build:prod를 그냥 돌리면.env의SENTRY_AUTH_TOKEN·VITE_SENTRY_RELEASE를 읽어 실제 Sentry 릴리스에 소스맵을 올립니다. 측정만 할 거면 위처럼 토큰을 비워서 돌리세요. (아래 "범위 밖에서 같이 발견한 것들" 참고)
manualChunks는 이름 붙일 청크만 명시하고, 나머지는 아무것도 반환하지 않습니다.
if (id.includes('date-fns')) return 'dates';
- return 'vendor';
+ return undefined;react-vendor, router, markdown, motion, sentry, swiper, dates, analytics, state, react-query — 이름 붙인 규칙은 그대로 둡니다. 바뀐 건 목록에 없는 의존성을 어떻게 할 것인가 하나뿐이에요.
캐치올이 있으면 이름 없는 node_modules가 전부 vendor 한 덩어리가 됩니다. 그런데 이 덩어리는 엔트리와 지연 청크(AdminRoutes)가 같이 참조해요. 그러면 Rollup이 이걸 엔트리의 정적 의존으로 올리고, Vite가 index.html에 modulepreload로 심습니다. 결과적으로 지연 로드하려고 lazy()로 떼어낸 의미가 그 의존성들에 한해 사라집니다.
소스맵으로 vendor(gzip 151KB) 안을 쪼개보면 이렇습니다.
| 패키지 | 소스 비중 | 실제 쓰는 곳 |
|---|---|---|
@floating-ui/react + core
|
218KB | react-datepicker 의존 → 관리자 모집 설정 탭 |
@reduxjs/toolkit + react-redux + immer
|
165KB | recharts v3 내부 스토어 |
decimal.js-light, es-toolkit
|
111KB | recharts 의존 |
recharts를 쓰는 화면은 AdminPage/tabs/StatisticsTab 하나입니다. 그런데 그 의존 트리가 통째로 홈 첫 화면에 따라왔어요.
캐치올을 없애면 Rollup 기본 배치가 적용되고, 어떤 엔트리에서 도달 가능한지를 Rollup이 직접 계산합니다. 지연 청크에서만 닿는 모듈은 그 지연 청크에 남아요.
이건 그렇게 하기로 정한 게 아니라 부산물입니다.
| 시점 | 무슨 일 |
|---|---|
2025-12-13 bdb4a125d
|
manualChunks 도입. 캐치올도 이때 같이 들어왔는데, 당시엔 무해했습니다. 지연 청크에만 있는 무거운 의존이 없었거든요 |
2026-07-21 7abe31ee7
|
관리자 통계 대시보드가 recharts를 들여옴. AdminRoutes는 lazy()라 화면 코드는 제대로 분리됐는데, 캐치올이 라이브러리만 앞으로 끌어당김 |
두 번째 커밋은 vite.config.ts를 건드리긴 했습니다. 다만 @ alias 추가였고 청킹은 아무도 보지 않았어요. 캐치올은 새 무거운 의존이 지연 경로에 들어오는 순간 조용히 해로워지는 종류의 설정이라, 도입 시점에 옳았던 판단이 반년 뒤에 뒤집힙니다.
| before | after | |
|---|---|---|
| 첫 로드 eager JS | 647.6KB (12청크) | 538.1KB (11청크) |
vendor |
495.6KB / gzip 151.3KB | 사라짐 |
dates |
153.4KB / gzip 37.0KB | 176.3KB / gzip 45.6KB |
| recharts 계열 | 첫 로드 |
AdminRoutes 지연 청크 |
| 전체 JS | gzip 788.7KB | gzip 763.3KB |
dates가 커진 건 @floating-ui가 vendor에서 이쪽으로 옮겨왔기 때문입니다. react-datepicker가 아직 dates에 묶여 있어서 그래요. PR #1945(react-datepicker 별도 청크 분리)가 머지되면 @floating-ui도 datepicker를 따라 지연 청크로 빠져 gzip 약 32KB가 더 줄어듭니다. 두 PR은 같은 함수를 고치니 머지 순서에 따라 충돌 해결이 필요합니다.
전체 JS도 25KB 줄었으니, 청크가 쪼개지면서 공용 모듈이 여기저기 중복 포함되는 손해는 없었습니다.
다른 대안들:
- ❌
manualChunks를 통째로 지운다: Rollup 완전 자동에 맡기면 첫 로드가 506.5KB로 오히려 더 좋습니다. 다만 산출물이 1개 청크가 돼요. 지금은react-vendor나router처럼 거의 안 바뀌는 덩어리를 따로 두고 있어서, 앱 코드만 고친 배포에서는 그 파일들의 브라우저 캐시가 살아남습니다. 31KB 더 받자고 그 성질을 버릴 이유가 없어요. - ❌ 관리자 전용 패키지를 손으로 열거해 지연 쪽으로 보낸다:
recharts만 적어선 안 되고@reduxjs/toolkit,react-redux,immer,decimal.js-light,es-toolkit,d3-*,victory-vendor까지 따라가야 합니다. 이 목록은 라이브러리 마이너 업데이트 한 번에 틀어집니다. Rollup이 이미 정확히 계산하는 걸 손으로 베껴 쓰면서 썩히는 일이에요. - ❌ 그냥 둔다: 지금 손해가 첫 로드 gzip 109KB이고, 앞으로 관리자 전용 라이브러리가 하나 늘 때마다 같은 일이 조용히 반복됩니다.
장점:
- 지연 로드가 의도대로 동작합니다.
lazy()로 떼어낸 화면의 라이브러리가 따라 나오지 않아요. - 앞으로 관리자 전용 의존을 추가할 때 청킹 설정을 건드릴 필요가 없습니다. 규칙을 손보지 않아도 알아서 지연 쪽에 남아요.
- 캐시를 살리고 싶은 덩어리(
react-vendor등)는 이름을 붙여 계속 통제합니다. 자동과 수동 중 하나를 고르는 게 아니라, 통제할 것만 이름을 붙이는 구조입니다.
단점/트레이드오프:
- 청크 경계가 의존 그래프를 따라 움직입니다. 이름 없는 의존성의 배치는 Rollup이 정하므로, 라이브러리를 하나 추가했을 때 기존 청크의 해시가 바뀌어 캐시가 깨질 수 있어요. 이름 붙인 청크는 영향을 안 받습니다.
-
vendor라는 이름이 사라져서 산출물 목록만 봐서는 뭐가 어디 있는지 안 보입니다. 확인하려면 소스맵을 열어 패키지별로 쪼개야 해요. 계측이 없는 게 이 결정의 약한 고리입니다(아래 참고). -
dates청크가 일시적으로 커졌습니다. #1945가 머지되기 전까지는 그대로예요.
원래 질문에 대한 답이니 같이 적어둡니다. 확인한 것들:
-
date-fns,swiper,recharts전부 named import입니다. 쓰는 함수만 들어와요. -
import * as가 20곳 넘게 있지만 전부 로컬.styles파일(import * as Styled from './Foo.styles')이라 번들과 무관합니다. 라이브러리에 쓴 건@sentry/react,@channel.io/...두 개뿐이에요. - Sentry는
browserTracingIntegration하나만 쓰고 Replay·Feedback은 안 들어갑니다. gzip 42.8KB는@sentry/core+ browser + tracing의 정상 비용이에요. - 배럴 파일(
index.ts)이 5개뿐이라 배럴발 과다 포함도 없습니다.
셰이킹이 아예 불가능한 건 mixpanel-browser 하나입니다. 단일 UMD 번들이라 쪼갤 수가 없어요. gzip 97.9KB가 첫 로드에 통째로 들어가고, 이게 현재 첫 로드에서 index 다음으로 큰 덩어리입니다.
청크 개수가 요청 수 병목으로 관측될 때입니다. 지금은 11개고 HTTP/2 위라 문제가 아니지만, 이름 붙인 규칙이 계속 늘어 산출물이 수십 개가 되고 그게 측정으로 드러나면 다시 묶는 게 맞습니다.
그리고 공용 모듈 중복이 관측될 때. 이번엔 전체 JS가 오히려 줄어서 해당 없었지만, 지연 청크가 여러 개로 늘면 같은 라이브러리가 여러 청크에 복제될 수 있어요. 그때는 그 모듈에 이름을 붙여 공용 청크로 올립니다 — 캐치올을 되살리는 게 아니라요.
"vendor 하나로 묶여 있는 게 보기 좋다"는 이유로는 뒤집지 않습니다.
결정이 아니라 결함 후보라 여기서 다루지 않습니다.
-
청킹을 볼 수 있는 계측이 없습니다.
manualChunks를 도입한 커밋bdb4a125d에는rollup-plugin-visualizer가 같이 들어 있었는데, 같은 날87707bd09("청크 분리 세분화 및 visualizer 플러그인 제거")로 빠졌습니다.package.json의 devDependencies에는 아직 남아 있어요(^5.14.0). 그래서 recharts가 첫 로드로 끌려온 지 두 달이 되도록 아무도 못 봤습니다. 이번 분석도 소스맵을 직접 파싱해서 알아낸 거예요. 되살릴지, 아니면 첫 로드 eager JS만 CI에서 숫자로 찍을지는 별도 판단이 필요합니다. -
mixpanel-browsergzip 97.9KB: 위에 적은 대로 셰이킹이 안 됩니다. 줄이려면 지연 로드밖에 없는데, 초기 이벤트 유실 위험이 있어서 계측 정확도와 맞바꾸는 결정이라 따로 재고 판단해야 합니다. -
eventsourcenpm 패키지(apis/clubSSE.ts): 브라우저에 네이티브EventSource가 있는데 폴리필을 import하고 있습니다.AdminRoutes지연 청크 안이라 첫 로드에는 영향이 없어요. 굳이 지금 건드릴 이유는 없습니다. -
로컬
npm run build:prod가 실제 Sentry 릴리스에 소스맵을 올립니다:vite.config.ts가loadEnv로.env를 읽는데 거기에SENTRY_AUTH_TOKEN과VITE_SENTRY_RELEASE가 들어 있습니다. 측정하려고 로컬 빌드를 돌렸을 뿐인데 릴리스v1.0.3의 아티팩트가 이 브랜치 산출물로 덮였어요. CI 전용으로 두거나 최소한 빌드 전 경고가 필요해 보입니다.
- 구현 PR: #2065 (이슈 #2064, MOA-1125)
- 함께 봐야 할 PR: #1945 — react-datepicker 별도 청크 분리 + 관리자 전용 Krona One 폰트 비차단화
-
manualChunks도입: 커밋bdb4a125d(2025-12-13, 캐치올의 출발점) - visualizer 제거: 커밋
87707bd09(2025-12-13, 같은 날) - recharts 유입: 커밋
7abe31ee7(2026-07-21, "add admin statistics dashboard") - 설정:
frontend/config/vite.config.ts - 지연 경계:
frontend/src/routes/AppRoutes.tsx(lazy(() => import('@/pages/AdminPage/AdminRoutes'))— 현재 유일한lazy)