← Cases
Case 04어드민 B2026.07severity medium

같은 코드량인데 배포는 3배 느리다

번들러를 바꿔도 안 줄던 5초의 정체

01 · 문제
운영팀이 요청한 수정이 반영되기까지 유독 오래 걸리고, 배포가 도는 동안 어드민 자체도 잠깐 느려진다
02 · 분석
번들러의 모듈 그래프와 플러그인 훅 비용배럴 파일과 정적 링킹트리셰이킹이 숨기는 비용단일 코어 서버의 CPU 경쟁
03 · 04 · 해결 방안과 결론
번들러 업그레이드와 import 경로 교체로 빌드 시간을 54% 줄였다. CI 서버 분리는 조건을 적어 보류했다
결과
빌드 7.87s → 3.61s(−54%)변환 모듈 4,315 → 724개(−83%)산출물 크기 동일
Vite 8SvelteKitESLintpm2

사내 프로젝트라 이름은 A(사용자용 웹), B(어드민)로 표기한다. 둘은 같은 스택(SvelteKit 2 + Svelte 5)이다.

문제

어드민 B는 운영팀이 매일 쓰는 도구라 "이 컬럼 하나만 추가해 주세요" 같은 작은 수정 요청이 잦다. 수정 자체는 5분이면 끝나는데, 배포가 반영되기까지의 대기가 A보다 눈에 띄게 길었다. 운영팀에서 "아까 요청한 거 언제 들어가요?"라는 질문이 A보다 B에서 훨씬 자주 나왔다.

한 가지 증상이 더 있었다. B의 배포는 CI 없이 배포 서버에서 직접 npm ci → vite build → pm2 restart로 돈다. 그리고 그 서버는 단일 코어다. 빌드가 도는 몇 초 동안 서버 CPU를 빌드가 차지하니, 운영 중인 어드민의 응답도 그 사이 잠깐 느려졌다. 운영팀은 "가끔 몇 초 렉이 걸린다"고 느꼈고, 시간을 맞춰 보니 배포 시각과 일치했다.

두 증상 모두 근본에는 "빌드가 오래 걸린다"가 있다. 그래서 얼마나 오래 걸리는지부터 쟀다.

분석

먼저 숫자로 만든다

같은 조건(rm -rf .svelte-kit build 후 vite build, 3회 평균)으로 두 프로젝트를 비교했다.

AB
빌드 시간2.74s7.87s (2.9배)
CPU time (user)4.4s12.7s

"B가 더 크니까"가 첫 가설이었다. 그래서 규모도 쟀다.

AB
src 파일 수303532
src 코드 줄 수27,23228,919
src 용량2.5M2.4M

코드량은 사실상 같은데 빌드는 2.9배 느리다. 규모 가설은 여기서 기각됐다. 파일 수가 1.75배인 것도 2.9배를 설명하지 못한다.

환경 차이를 나열하니 후보가 셋 남았다.

AB
Vite8 (Rolldown, Rust)7 (Rollup, JS)
패키지 매니저pnpmnpm
추가 런타임 의존성—chart.js, lucide-svelte

패키지 매니저는 vite build를 실행하는 방법일 뿐 빌드 자체에 관여하지 않으니 기각했다. 번들러 버전 차이가 가장 유력해 보였고, 의존성 차이는 보류했다.

번들러는 무엇에 시간을 쓰는가

가설을 검증하기 전에, 빌드 시간이 어디서 나오는지 모형이 필요했다. 번들러의 일은 크게 두 단계다.

1. 모듈 그래프 구성
   엔트리에서 시작 → import를 따라가며 모듈마다
   resolveId(경로 찾기) → load(읽기) → transform(변환: Svelte 컴파일, TS 제거 등)
   → 그 안의 import를 다시 따라간다.   ※ 모듈 하나당 한 번씩, 플러그인 훅으로

2. 번들링 코어
   트리셰이킹(안 쓰는 export 제거) → 청크 분할 → 미니파이 → 파일 출력

Vite 8의 Rolldown은 2단계를 Rust로 다시 짠 것이다. 네이티브 컴파일, 멀티스레드, GC 없음의 이득을 그대로 받는다. 하지만 1단계의 플러그인 훅은 Rollup 플러그인 호환을 위해 여전히 JS에서 돈다. SvelteKit 플러그인도, Svelte 컴파일러도 JS다. 즉 1단계는 싱글 스레드 + JIT + GC라는 원래 제약을 그대로 갖고 있고, 그 비용은 모듈 수에 비례한다.

1차 검증 — 번들러를 바꿔 본다

소스는 건드리지 않고 B의 Vite를 7에서 8로 올렸다(관련 플러그인도 같이).

결과는 7.87s → 5.24s (33%↓). 개선은 됐지만 Rolldown이 벤치마크에서 보여 주는 수십 배와는 거리가 멀었다. 위 모형대로라면 이건 "2단계는 빨라졌는데 1단계가 그대로"라는 뜻이다. 그럼 나머지 5.24초는 어디에 있는가.

2차 — 빌드 로그가 준 힌트

업그레이드 후 빌드 로그에 없던 경고가 찍혔다.

[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차 — 모듈 수를 센다

A: ✓   427 modules transformed.
B: ✓ 4,315 modules transformed.

코드량이 같은데 모듈 그래프는 10배였다. 여기가 병목이었다. A는 파일 303개에 모듈 427개로 거의 1:1인데, B는 파일 532개에 모듈 4,315개다.

4차 — 3,800개의 출처를 찾는다

로그는 "모듈이 많다"까지만 말해 준다. 어느 패키지가 올리는지는 말해 주지 않는다. 그래서 숫자가 맞지 않는 곳을 좁혀 들어갔다.

① 설명되지 않는 잔차를 구한다. 내 코드로 설명되는 모듈은 최대 532개다. 나머지 약 3,800개는 전부 node_modules에서 왔다. A에는 이 잔차가 없으니, 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과 거의 일치

④ 기전을 소스에서 확인한다. 패키지의 진입점을 열어 보니 이렇게 돼 있었다.

// node_modules/lucide-svelte/dist/lucide-svelte.js
export * from './icons/index';           // 아이콘 1,746개를 전부 재export

그리고 우리 소스에서는 이렇게 쓰고 있었다.

import { Star, ChevronDown } from 'lucide-svelte';

이런 식으로 export를 한곳에 모아 재export하는 파일을 **배럴(barrel)**이라고 부른다. ES 모듈은 정적 링킹이다. import { Star }가 어디서 오는지 알려면 번들러는 export * 뒤에 있는 모든 모듈을 읽고 파싱해서 그중 어느 것이 Star를 내보내는지 확인해야 한다. 실제로 쓰는 아이콘은 32개였지만, 그 32개의 위치를 알기 위해 1,746개를 전부 그래프에 올려야 했다.

최종 번들에서는 트리셰이킹이 1,714개를 버린다. 그래서 산출물 크기만 보면 아무 이상이 없었다. 하지만 버리기 전에 일단 읽고 파싱해야 하고, 그 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
                             ↓
                    버릴 것 자체가 없음

⑤ 감소량을 예측하고 측정한다. 서브패스로 바꾸면 모듈이 약 3,600개 빠질 것이라고 예측했다. 실측은 4,315 → 724 (−3,591). 예측과 실측이 맞아떨어져 원인 지목이 확정됐다. 여기서 어긋났다면 다른 출처가 남아 있다는 뜻이 됐을 것이다.

정리하면 ① 설명 안 되는 잔차를 숫자로 만든다 → ② 후보를 좁힌다 → ③ 후보의 크기가 잔차와 맞는지 본다 → ④ 기전을 소스에서 확인한다 → ⑤ 감소량을 예측하고 측정한다. 로그는 끝까지 "lucide가 느리다"고 말해 주지 않았다. [PLUGIN_TIMINGS]도 sveltekit-guard를 가리켰을 뿐이다. 범인은 로그가 지목한 것이 아니라 숫자가 안 맞는 곳으로 좁혀 들어가서 찾았다.

왜 단일 코어 배포 서버에서 더 아팠나

로컬 맥북에서 7.87초였던 빌드가 배포 서버에서는 더 오래 걸렸다. 두 가지 이유가 겹친다.

  • Rolldown의 이득 중 멀티스레딩 몫은 코어 수에 비례한다. 단일 코어에서는 네이티브 컴파일 이득만 남는다. CPU time 12.7초가 곧 벽시계 시간이 된다.
  • 빌드와 운영 중인 서비스가 같은 CPU를 나눠 쓴다. OS 스케줄러는 빌드 프로세스와 pm2가 띄운 Node 프로세스에 타임 슬라이스를 번갈아 준다. 빌드가 CPU를 100% 쓰는 12초 동안 어드민 요청은 슬라이스를 얻을 때까지 기다린다. 운영팀이 느낀 "배포 때 몇 초 렉"의 정체다.

해결 방안

원인은 "모듈 그래프가 10배"이고, 그 위에 "번들러 코어가 JS"와 "빌드가 운영 서버 CPU를 뺏는다"가 얹혀 있다. 각각에 대해 선택지를 놓고 비교했다. 기준은 소스 변경 범위, 재발 가능성, 그리고 인프라를 새로 들이지 않고 되는가였다.

선택지방법효과비용·단점판단
A. Vite 7 → 8버전만 올린다−33%. 번들링 코어가 네이티브로소스 변경 0. 플러그인 호환 확인 필요채택
B. 배럴 → 서브패스 importlucide-svelte/icons/<name>으로 60개 파일 수정모듈 −83%, 이후 추가 −31%import 구문이 길어진다. 새 코드에서 배럴이 돌아오면 원상복구채택 + 재발 방지
C. ESLint로 배럴 진입점 금지no-restricted-importsB의 재발 방지없음채택
D. 아이콘을 SVG 스프라이트로 자체화의존성 제거, 32개 SVG 직접 관리모듈 0아이콘 추가마다 수작업. 라이브러리가 서브패스를 이미 제공하는데 굳이 버릴 이유가 없다기각
E. 빌드를 CI로 옮기고 산출물만 배포GitHub Actions 등에서 빌드 → 서버는 받기만운영 서버 CPU 경쟁 해소, 멀티코어 이득파이프라인 신규 구축, 시크릿·아티팩트 관리. 인프라가 하나 늘어난다조건부 보류
F. 배포 서버 코어 증설인스턴스 스펙 업빌드도 서비스도 여유비용 증가. 모듈 10배라는 원인은 그대로 남는다기각
G. 빌드 캐시이전 빌드 결과 재사용증분 빌드Vite 프로덕션 빌드에는 안정적인 영구 캐시가 아직 없다기각

E는 매력적이지만, 원인을 고치지 않은 채 환경을 키우는 선택이다. 먼저 A·B·C로 원인을 없애고, 그래도 배포 중 렉이 문제로 남으면 그때 E를 검토하기로 했다. 결과적으로 원인을 없애자 배포 중 렉도 체감 범위 아래로 내려가서 E는 필요하지 않았다.

결론

A + B + C를 적용했다.

// 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';
// 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,3154,315724 (−83%)
빌드 시간 (3회 평균)7.87s5.24s3.61s (−54%)
CPU time (user)12.7s8.2s5.5s
[PLUGIN_TIMINGS] 경고—발생 (guard 81%)사라짐
svelte-check 검사 파일4,7154,7151,091
산출물 크기8.6M9.2M9.2M (동일)

빌드 시간은 대조군 A(2.5s 안팎)와 같은 체급이 됐다. 타입 에러 0건, 번들 동작 동일. 운영팀이 느끼던 "요청 후 반영까지"의 대기와 "배포 때 렉"이 함께 줄었다.

감수한 부작용

  • import 구문이 길어졌다. 아이콘 다섯 개면 다섯 줄이다. 가독성 비용을 내고 빌드 시간을 샀다.
  • 라이브러리 버전업 시 서브패스 경로가 바뀔 수 있다. 그때는 60개 파일을 다시 손봐야 한다. 다만 경로가 깨지면 빌드가 실패하니 조용히 넘어가지는 않는다.
  • ESLint 규칙이 배럴을 막는 건 lucide-svelte 하나뿐이다. 다른 배럴형 패키지가 들어오면 같은 문제가 다시 생긴다. 이건 규칙이 아니라 아래 모니터링으로 잡는다.

운영에서 보는 것

이 병목은 산출물 크기를 아무리 지켜봐도 보이지 않는다. 산출물은 처음부터 정상이었다. 그래서 지표를 바꿨다.

  1. 배포 스크립트가 ✓ N modules transformed의 N을 기록한다. src 파일 수 대비 비율이 2를 넘으면 경고를 출력한다. 의존성 하나가 배럴로 들어오면 이 비율이 먼저 뛴다. 새 도구 없이 빌드 로그를 grep하는 한 줄이다.
  2. [PLUGIN_TIMINGS] 경고의 재등장. 이 경고가 뜨면 병목이 번들러가 아니라 의존성 구조에 있다는 뜻이다. 경고가 없는 상태를 기준선으로 삼는다.
  3. 의존성을 추가할 때 package.json의 exports를 연다. export가 수백 개 이상인 패키지(아이콘·유틸·UI 키트)는 서브패스 진입점이 있는지 확인하고, 있으면 처음부터 그걸 쓴다. 리뷰 체크리스트에 한 줄 넣었다.

남긴 것

번들 크기 최적화와 빌드 시간 최적화는 다른 문제다. 결과 표의 마지막 줄이 이걸 말해 준다. 산출물은 9.2M 그대로였다. 바뀐 것은 "버리기 전에 읽어야 했던 3,600개"가 사라진 것뿐이다. 둘을 구분하지 못하면 "번들 크기가 안 줄었으니 의미 없다"고 잘못 판단하게 된다. 효과는 산출물 크기가 아니라 모듈 수·빌드 시간·타입체크 시간으로 재야 했고, 원인은 로그가 아니라 맞지 않는 숫자를 좁혀 들어가서 찾았다.

사례 전체와 이력 보기