빌드가 3배 느린 프로젝트 — 번들러를 바꿔도 안 줄던 5초의 정체
사내 프로젝트라 이름은 A(사용자용 웹), B(어드민)로 표기한다. 둘 다 같은 스택이다.
상황
같은 스택으로 만든 두 프로젝트의 빌드 시간을 비교하다가 시작됐다.
- A — SvelteKit 2 + Svelte 5 + Tailwind 4 + adapter-node
- B — 같은 스택, 어드민 성격
체감상 B의 배포가 유독 느려서 실제로 재봤더니 2.9배 차이가 났다. 원인이 단순히 "B가 더 크니까"인지 확인하려다 예상과 다른 곳에 도착했다.
증상
클린 빌드(rm -rf .svelte-kit build 후 vite build) 3회 평균:
| A | B | |
|---|---|---|
| 빌드 시간 | 2.74s | 7.87s (2.9배) |
| CPU time (user) | 4.4s | 12.7s |
그런데 코드 규모를 재보니 배수가 안 맞았다.
| A | B | |
|---|---|---|
| src 파일 수 | 303 | 532 (1.75배) |
| src LOC | 27,232 | 28,919 (거의 동일) |
| src 용량 | 2.5M | 2.4M |
코드량은 사실상 같은데 빌드는 2.9배 느리다. "규모 차이"로는 설명이 안 됐다.
환경
| A | B (초기) | |
|---|---|---|
| Vite | 8.0.9 | 7.3.3 |
| @sveltejs/vite-plugin-svelte | 7 | 6 |
| @sveltejs/kit | 2.57 | 2.50 |
| svelte | 5.55 | 5.51 |
| TypeScript | 6.0 | 5.9 |
| 패키지 매니저 | pnpm | npm |
| 추가 런타임 의존성 | — | chart.js, lucide-svelte |
- macOS (Darwin 25.5.0), Node 24
가설 (탐색 순서대로)
- B의 코드가 더 많다 → LOC가 거의 같아서 기각.
- 번들러 버전 차이 — A는 Vite 8(Rolldown/Rust), B는 Vite 7(Rollup/JS). 유력.
- 런타임 의존성 차이 — B에만
chart.js,lucide-svelte가 있음. 보류. - 패키지 매니저 차이(pnpm vs npm) — 빌드 자체는
vite build를 실행할 뿐이라 영향 없음. 기각.
시도한 것들
1차: Vite 7 → 8 업그레이드 (가설 2)
소스는 건드리지 않고 package.json 버전만 올렸다.
vite 7.3.3 → 8.1.5 (Rollup → Rolldown)
@sveltejs/vite-plugin-svelte 6 → 7
@sveltejs/kit 2.50 → 2.70
svelte 5.51 → 5.56
typescript 5.9 → 6.0
결과: 7.87s → 5.24s (33%↓). 개선은 됐지만 기대에 한참 못 미쳤다. Rolldown은 벤치마크상 Rollup보다 10~30배 빠르다고 알려져 있는데, 실제 빌드 시간은 33%밖에 안 줄었다. 그럼 나머지 5.24초는 뭘 하고 있는가?
2차: Vite 8이 알려준 힌트 — [PLUGIN_TIMINGS]
업그레이드 후 빌드 로그에 없던 경고가 찍혀 있었다.
[PLUGIN_TIMINGS] Your build spent significant time in plugins. Here is a breakdown:
- vite-plugin-sveltekit-guard (81%)
- vite-plugin-sveltekit-virtual-modules (7%)
- vite-plugin-svelte:compile (6%)
시간의 80% 이상이 플러그인 훅 안에 있었다. Svelte 컴파일 자체는 6%뿐이라 코드가 병목이 아니었다.
3차: 모듈 수 측정 (가설 3으로 복귀)
플러그인 훅은 모듈 하나당 한 번씩 호출되므로 플러그인 시간 ≈ 모듈 수다. 그래서 재봤다.
A: ✓ 427 modules transformed.
B: ✓ 4,315 modules transformed.
코드량이 같은데 모듈 그래프가 10배였다. 여기가 진짜 병목이었다.
4차: 3,800개의 출처 특정 — 왜 lucide-svelte였는가
모듈이 많다는 것까지는 알았지만, 아직 어느 패키지가 올리는지는 몰랐다. 특정은 순전히 산술로 했다.
① 설명되지 않는 모듈 수를 구했다
src 파일 532개 → ✓ 4,315 modules transformed
내 코드로 설명되는 건 최대 532개. 나머지 약 3,800개는 전부 node_modules에서 왔다.
대조군 A는 303 파일 → 427 모듈로 거의 1:1이었으므로, 이 격차 자체가 B에만 있는 구조적 특징이었다.
② 후보는 둘뿐이었다
A와 B의 환경 차이 중 런타임 의존성은 chart.js와 lucide-svelte 딱 둘. chart.js는 모듈 수천 개가 나올 규모의 패키지가 아니다. 남는 건 아이콘 라이브러리 하나였다.
③ 파일 수를 세니 격차와 일치했다 ← 결정적
$ ls node_modules/lucide-svelte/dist/icons/*.svelte | wc -l
1702
$ ls node_modules/lucide-svelte/dist/icons/*.js | wc -l
1951
# 합계 ≈ 3,650
설명되지 않던 3,800과 거의 일치했다. (아이콘 1,746개인데 파일이 3,650개인 이유는 아이콘마다 .svelte와 컴파일된 .js가 같이 실려 있기 때문이다.)
④ 기전을 코드로 확인했다
숫자가 맞으면 이유를 찾는다. 배럴 파일을 열어보니 명확했다.
// node_modules/lucide-svelte/dist/lucide-svelte.js
export * from './icons/index';
import { Star } from 'lucide-svelte' 한 줄이면 번들러는 Star의 위치를 알기 위해 아이콘 전체를 그래프에 올려야 한다.
⑤ 예측하고 측정해서 검증했다
서브패스로 바꾸면 3,600개쯤 빠질 것이라고 예측했고, 실제 결과는 4,315 → 724 (−3,591). 예측치와 실측치가 맞아떨어진 것으로 원인 지목이 확정됐다. 여기서 어긋났다면 다른 출처가 남아 있다는 뜻이 된다.
순서를 정리하면 ① 설명 안 되는 잔차를 수치로 만든다 → ② 후보를 좁힌다 → ③ 후보의 파일 수가 잔차와 맞는지 본다 → ④ 기전을 소스에서 확인한다 → ⑤ 감소량을 예측하고 측정한다. 빌드 로그를 아무리 봐도 "lucide가 느리다"는 말은 나오지 않는다.
[PLUGIN_TIMINGS]도vite-plugin-sveltekit-guard를 가리켰을 뿐이다. 범인은 로그가 지목해준 게 아니라 숫자가 안 맞는 걸로 좁혀 들어가서 찾았다.
🎯 원인
원인 1: JS 번들러(Rollup) — 전체의 일부일 뿐이었다
Rolldown(Rust)이 대체하는 건 번들링 코어(모듈 그래프 구성 · 트리셰이킹 · 청크 분할 · 미니파이)다. 이 부분은 네이티브 컴파일 + 멀티스레딩 + GC 없음의 이득을 그대로 받는다. CPU time이 12.7s → 8.2s로 준 게 그 증거다.
하지만 Rollup 호환 플러그인 인터페이스를 유지하는 대가로 플러그인 훅(resolveId/load/transform)은 여전히 JS 런타임에서 실행된다. vite-plugin-sveltekit-guard도, Svelte 컴파일러도 JS다. 즉 싱글 스레드 + JIT + GC라는 원래 제약을 그대로 갖고 있다.
고전적인 암달의 법칙 — 고속화되지 않는 구간이 전체 시간의 하한을 만든다. 코어를 30배 빠르게 해도 전체가 30배 빨라지지 않는다. 벤치마크에서 큰 배수가 나온 사례들(Linear 46초→6초 등)은 코어 바운드 프로젝트였다. B는 이미 플러그인 바운드였다.
원인 2 (진짜 원인): 배럴 import가 모듈 그래프를 10배로 부풀림
모듈 4,315개의 출처를 추적하니 lucide-svelte였다.
// node_modules/lucide-svelte/dist/lucide-svelte.js
export * from './icons/index'; // 아이콘 1,746개 재export
export * as icons from './icons/index'; // 네임스페이스 재export
소스에서 이렇게 쓰고 있었다.
import { Star } from 'lucide-svelte';
- 실제로 쓰는 아이콘은 32개 (60개 파일에서 사용)
- 그런데 이 배럴(barrel) 파일을 거치는 순간, 번들러는
Star가 어디 있는지 알기 위해 아이콘 모듈 1,746개를 전부 그래프에 올려야 한다. - 최종 번들에서는 트리셰이킹으로 다 빠진다. 하지만 버리기 전에 일단 다 읽고 파싱해야 하고, 그 1,746번의
load/transform훅이 전부 JS다.
[ 배럴 import ] import { Star } from 'lucide-svelte'
내 파일 ──> lucide-svelte.js (배럴)
└─ export * from './icons/index'
├─ arrow-up.js ┐
├─ bookmark.js │ 전부 load + parse
├─ ... │ (1,746개)
└─ star.js ┘ ← 실제로 쓰는 건 이것뿐
↓
트리셰이킹으로 1,745개 폐기
→ 번들 크기는 정상, 빌드 시간만 낭비
[ 서브패스 import ] import Star from 'lucide-svelte/icons/star'
내 파일 ──> icons/star.js ← 1개만 load + parse
↓
버릴 것 자체가 없음
→ 번들 크기 동일, 빌드 시간 절감
트리셰이킹이 완벽하게 작동하고 있었기 때문에 오히려 안 보이던 문제였다. 번들 크기만 보면 아무 이상이 없었다.
✅ 해결
1. Vite 8로 업그레이드 (번들러 코어)
위 1차 시도 그대로. 7.87s → 5.24s (33%↓), 소스 변경 없음.
2. 배럴 import → 서브패스 import (모듈 수)
lucide-svelte는 exports 맵에 이미 서브패스 진입점을 갖고 있었다. 패키지를 교체할 필요 없이 진입점만 바꾸면 됐다.
// node_modules/lucide-svelte/package.json
"exports": {
".": { "svelte": "./dist/lucide-svelte.js" }, // ← 배럴 (아이콘 1,746개)
"./icons/*": { "svelte": "./dist/icons/*.js" } // ← 아이콘 하나만
}
// Before — 배럴을 거쳐 아이콘 1,746개가 전부 그래프에 올라옴
import { ChevronDown, ChevronUp, CircleCheck } from 'lucide-svelte';
// After — 필요한 모듈 3개만
import ChevronDown from 'lucide-svelte/icons/chevron-down';
import ChevronUp from 'lucide-svelte/icons/chevron-up';
import CircleCheck from 'lucide-svelte/icons/circle-check';
PascalCase → kebab-case 변환이 규칙적이라(SquareArrowOutUpRight → square-arrow-out-up-right) 정규식 코드모드로 60개 파일을 일괄 변환했다. 바뀐 건 import 구문뿐(60줄 → 99줄), 로직 변경 없음.
3. ESLint로 재발 방지
새 코드에서 배럴 import가 다시 들어오면 원상복구되므로 진입점 자체를 막았다.
// eslint.config.js
rules: {
'no-restricted-imports': ['error', {
paths: [{
name: 'lucide-svelte',
message: 'lucide-svelte/icons/<kebab-case> 서브패스로 import할 것'
}]
}]
}
최종 결과
| 원래 (Vite 7) | ① Vite 8 | ② + 서브패스 import | |
|---|---|---|---|
| 변환 모듈 수 | 4,315 | 4,315 | 724 (83%↓) |
| 빌드 시간 (3회 평균) | 7.87s | 5.24s | 3.61s |
| CPU time (user) | 12.7s | 8.2s | 5.5s |
[PLUGIN_TIMINGS] 경고 | — | 발생 (guard 81%) | 사라짐 |
svelte-check 검사 파일 | 4,715 | 4,715 | 1,091 |
| build 산출물 크기 | 8.6M | 9.2M | 9.2M (동일) |
- ①만: −2.63초 (33%↓) / ②만: −1.63초 (31%↓)
- 누적: 7.87s → 3.61s (−4.26초, 54%↓) — 대조군 A(2.51s)와 거의 같은 체급이 됐다.
- 타입 에러 0건, 번들 동작 동일.
💭 배운 점 / 다음에는
번들 크기 최적화와 빌드 타임 최적화는 다른 문제다
가장 중요한 건 결과 표의 맨 아랫줄이다. 산출물 크기가 9.2M로 똑같다.
트리셰이킹은 원래도 완벽하게 작동하고 있었다. 사용자가 받는 번들에는 처음부터 아이콘 32개만 들어 있었다. 바뀐 건 "버리기 전에 읽어야 했던 3,600개 모듈"이 사라진 것뿐이다.
둘을 구분하지 못하면 "번들 크기 안 줄었으니 의미 없다"고 잘못 판단하게 된다. 번들 크기만 모니터링하고 있으면 이 병목은 영원히 안 보인다. 효과 측정은 산출물 크기가 아니라 모듈 수 · 빌드 시간 · 타입체크 시간으로 해야 한다.
모듈 그래프는 빌드만의 비용이 아니다
부수 효과로 svelte-check 검사 대상이 4,715 → 1,091 파일로 줄어 타입체크도 같이 빨라졌다. 모듈 그래프는 빌드 · 타입체크 · IDE 인덱싱 · dev 서버가 전부 공유하는 비용이다.
도구 업그레이드보다 "도구에게 주는 일의 양"이 레버가 컸다
번들러 교체(33%↓)보다 import 한 줄의 형태를 바꾼 쪽(31%↓)이 더 작은 변경으로 비슷한 효과를 냈다. 최신 도구로 올리는 것과, 내 코드가 그 도구에게 일을 얼마나 주고 있는지는 다른 축이다. 후자를 먼저 봤어야 했다.
진단 순서 (다음에 빌드가 느리면)
✓ N modules transformed를 src 파일 수와 비교한다. 훨씬 크면 배럴 import를 의심한다. (B는 532 파일 → 4,315 모듈)- Vite 8의
[PLUGIN_TIMINGS]를 확인한다. 무료 프로파일러다. 이 경고가 뜨면 병목은 번들러가 아니라 내 플러그인/의존성 구조다. 경고가 사라지는 것 자체가 개선 지표가 된다. - 의존성의
package.jsonexports를 열어본다. 서브패스 진입점이 있는데 배럴로 쓰고 있는지 확인.
의존성 선택 기준에 "배럴 구조"를 추가
아이콘·유틸 라이브러리처럼 export가 수천 개인 패키지는 서브패스 진입점을 제공하는지, 그리고 내가 그걸 쓰고 있는지 확인해야 한다. sideEffects: false가 걸려 있어도 빌드 타임 비용은 그대로다.
남은 것
- 남은 3.6초의 대부분은 여전히
vite-plugin-sveltekit-guard(JS)에 있다. 이건 SvelteKit 내부라 직접 줄이기 어렵다. - Rolldown의 lazy barrel optimization(미사용 re-export 모듈 빌드 생략)이 이 케이스를 정확히 겨냥한 최적화인데, Vite 8 기본값에서는 걷히지 않았다.
sideEffects: false인데도 대상이 아니었던 이유는 확인 필요. lucide-svelte는 deprecated →@lucide/svelte마이그레이션 필요. 단 빌드 시간과는 별개 이슈다(거기서도 배럴로 import하면 똑같이 느려진다).- 모듈 수 감소가 dev 서버 콜드 스타트·HMR에 주는 영향은 아직 미측정.
후속 (2026-08-10): 같은 빌드가 배포 서버에서는 왜 5배 느린가
로컬에서 3.6초가 된 뒤 실제로 배포해 보니 서버에서는 체감이 여전히 느렸다. 같은 커밋, 같은 npm run build인데도 그랬다.
로컬을 같은 기준(클린 빌드)으로 다시 재서 붙여 봤다.
| 로컬 (M5 Pro, 15코어 / 24GB) | 배포 서버 | |
|---|---|---|
| client 환경 | 807ms | 4.71s (5.8배) |
| ssr 환경 | 2.75s | 13.53s (4.9배) |
| 전체 wall time | 5.18s (npx vite build 프로세스 기동 + svelte-kit sync 포함) | — |
[PLUGIN_TIMINGS] 경고 | 안 뜸 | 뜸 (guard 85% / 71%) |
위 최종 결과 표의 3.61s는
vite build내부 빌드 시간 3회 평균이고, 여기 5.18s는 프로세스 기동·svelte-kit sync까지 포함한 전체 wall time이다. 재는 구간이 달라서 나는 차이다.
원인 1: 남은 병목이 싱글스레드 JS라 "코어 수"가 아니라 "코어 속도"로 결정된다
로컬 빌드의 CPU 사용률이 **166% cpu**였다. 15코어 머신에서 1.66코어만 쓴다는 건 이 빌드가 이미 거의 싱글스레드라는 뜻이다.
이유는 위 원인 1에서 정리한 것과 같다. Rolldown의 Rust 코어는 병렬화되지만 시간의 71~85%를 먹는 vite-plugin-sveltekit-guard는 JS라 싱글스레드다. 그래서 빌드 시간이 단일 코어 성능에 거의 그대로 비례한다.
- 노트북 고성능 코어 1개 vs VPS vCPU 1개는 3~5배 차이가 흔하고, 관측된 5배와 맞는다.
- 따라서 서버에 코어를 늘려도 거의 안 빨라진다. 늘려야 하는 건 코어 수가 아니라 클럭/IPC다.
[PLUGIN_TIMINGS]가 로컬에서는 안 뜨고 서버에서만 뜬 것도 같은 이야기다. 이 경고는 절대 시간 임계치 기반이라, 느린 머신에서 먼저 보인다.
원인 2: 배포 시간 ≠ 빌드 시간
배포 스크립트는 매번 git pull → npm ci(316패키지, 약 10s) → prepare의 svelte-kit sync → npm run build → pm2 restart를 전부 돈다. 체감 "배포가 느리다"의 상당 부분은 빌드가 아니라 **매 배포마다 node_modules를 새로 까는 npm ci**다.
락파일이 바뀐 배포에서만 설치하도록 바꾸면 그대로 빠진다.
if ! git diff --quiet HEAD@{1} HEAD -- package-lock.json; then npm ci; fi
원인 3: 디스크 I/O
모듈 1,389개(ssr 724 + client 665) 읽기 + 청크 수백 개 쓰기 + computing gzip size. 맥 NVMe와 VPS 스토리지의 차이가 여기서 붙는다.
배운 점
빌드 시간 최적화는 "일의 양"을 줄이는 것까지가 내 몫이고, 남은 시간은 하드웨어가 정한다. 모듈 4,315 → 724로 줄인 건 어느 머신에서나 같은 비율로 효과가 있지만, 그 뒤에 남은 절대 시간은 실행하는 CPU의 단일 코어 성능이 결정한다. 로컬에서 3.6초가 됐다고 배포도 3.6초가 되는 게 아니다.
그리고 CPU 사용률을 같이 보면 최적화 방향이 정해진다. 166% cpu를 확인한 순간 "코어를 늘리는 투자"는 후보에서 빠진다. 남은 선택지는 (a) 더 빠른 단일 코어, (b) JS 플러그인이 하는 일 자체를 줄이기, (c) 애초에 서버에서 빌드하지 않기 뿐이다.
남은 것 (배포 쪽)
- 로컬(빠른 CPU)에서 빌드하고 산출물만 rsync하는 방식이 가장 큰 레버다. 다만 지금 pm2가
npm run preview(=vite preview)로 떠 있어 서버에 node_modules와 vite가 그대로 필요하다. 이 방향으로 가려면adapter-node의node build실행으로 먼저 바꾸고,vite.config.ts의preview.port(8100)를PORT환경변수로 대체해야 한다. - 서버 실제 스펙(
nproc, 클럭, load average)은 아직 미확인.nproc이 1~2거나 평시 load가 높으면 위 추정이 확정된다.
🔗 참고
- 이 트러블슈팅의 배경 개념(왜 Rust 번들러가 빠른가, Rollup → Rolldown 전환): Rollup에서 Rolldown으로 — Vite가 번들러를 Rust로 다시 쓰는 이유
- Rolldown 공식: https://rolldown.rs
- Vite 8 발표 글: https://vite.dev/blog/announcing-vite8
PLUGIN_TIMINGS문서: https://rolldown.rs/reference/InputOptions.checks#plugintimings