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-browser gzip 97.9KB: 위에 적은 대로 셰이킹이 안 됩니다. 줄이려면 지연 로드밖에 없는데, 초기 이벤트 유실 위험이 있어서 계측 정확도와 맞바꾸는 결정이라 따로 재고 판단해야 합니다.
  • eventsource npm 패키지(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)
⚠️ **GitHub.com Fallback** ⚠️