번들 하나가 700KB였다 — Vite manualChunks로 쪼개면서 정한 기준
무엇을 어느 청크에 넣을지 정하는 기준과, lazy를 언제 쓰고 언제 안 쓰는지
#Vite #번들최적화 #React #Code Splitting #성능최적화
번들 하나가 700KB였다 — Vite manualChunks로 쪼개면서 정한 기준 포트폴리오 사이트를 빌드했더니 JS가 한 덩어리로 나왔다. React, framer-motion, lottie, react-icons가 전부 한 파일에 들어있었다. 문제는 첫 화면에 그게 다 필요하지 않다는 것 이었다. lottie는 에러 화면에서만 쓰고, 챗봇은 사용자가 버튼을 눌러야 뜬다. 그런데 아무것도 안 해도 전부 다운로드된다. 이 글은 그걸 쪼개면서 무엇을 어디에 넣을지 어떻게 정했는지 에 대한 기록이다. 자동 청킹으로는 왜 부족했나 Vite(정확히는 Rollup)도 알아서 청크를 나눈다. 동적 import를 만나면 그 지점에서 잘라준다. 그런데 정적으로 import한 라이브러리는 다 한 덩어리로 묶는다 . 내 코드가 그랬다. import { motion } from 'framer-motion'; // 정적 import import Lottie from 'lottie-react'; // 정적 import → 둘 다 메인 번들에 들어간다 그래서 두 가지를 같이 했다. 진짜 안 쓰는 건 동적 import로 잘라내고, 쓰긴 쓰는데 성격이 다른 건 manualChunks로 분리 했다. 기준 1 — 캐시 수명이 다른 건 분리한다 제일 중요한 기준이었다. 내 코드는 배포할 때마다 바뀌지만 React는 몇 달에 한 번 바뀐다 . 이 둘이 한 파일에 있으면, 오타 하나 고쳐서 배포해도 사용자는 React까지 다시 받는다. // vite.config.ts manualChunks(id) { if (!id.includes('node_modules')) return; if ( id.includes('/react/') || id.includes('/react-dom/') || id.includes('/react-router') || id.includes('/react-error-boundary') || id.includes('/@remix-run/') || id.includes('/react-is/') || id.includes('/scheduler/') ) { return 'react-core'; } } react-router를 같이 넣은 이유는 같이 올라가고 같이 안 바뀌기 때문 이다. @remix-run과 react-is, scheduler는 react-router와 react-dom이 끌고 오는 것들이라 같이 묶어야 청크가 서로를 참조하는 일이 안 생긴다. 처음엔 이걸 안 넣어서 react-core 청크가 vendor를 참조하는 이상한 그래프가 나왔다. 의존성을 나눌 때는 딸려오는 것까지 같이 봐야 한다 . 기준 2 — 무거운데 늦게 필요한 건 따로 뺀다 lottie가 그랬다. 에러 화면 애니메이션에만 쓰는데 317KB 다. 첫 화면에서 이걸 받을 이유가 없다. if (id.includes('lottie-web') || id.includes('lottie-react')) return 'lottie'; if (id.includes('react-icons')) return 'react-icons'; if (id.includes('framer-motion')) return 'vendor'; react-icons도 따로 뺐다. 아이콘은 늘어나기만 하고 줄지는 않아서, 내 코드랑 같이 두면 아이콘 하나 추가할 때마다 메인 번들 해시가 바뀐다. 기준 3 — 사용자가 눌러야 뜨는 건 아예 lazy로 청크를 나누는 것과 아예 안 받는 것 은 다르다. manualChunks는 파일을 나눌 뿐 결국 다 받는다. 진짜로 안 받게 하려면 동적 import를 써야 한다. const ChatBot = lazy(() = import('../../component/ChatBot/ChatBot')); Suspense fallback={null} ChatBot / /Suspense 챗봇은 사용자가 버튼을 눌러야 열린다. lazy로 빼니 4.49KB짜리 별도 청크 가 됐고, 안 누르면 아예 안 받는다. 반대로 lazy로 안 뺀 것 도 있다. 랜딩 카드나 About 패널 같은 건 lazy로 빼면 클릭하고 나서 로딩 스피너가 한 번 뜬다. 파일 크기는 작은데 체감은 더 느려진다. lazy는 "무겁다"가 아니라 "안 볼 수도 있다"가 기준 이어야 했다. lazy로 뺀다 안 볼 수도 있다 + 무겁다 예: 챗봇, 에러 화면 애니메이션 lazy로 안 뺀다 거의 다 본다 예: 첫 화면 카드, 주요 패널 (쪼개면 클릭할 때마다 대기가 생긴다) 결과 나누고 나서 빌드 결과는 이렇게 됐다. ../dist/assets/index.css 35.26 kB │ gzip: 7.99 kB ../dist/assets/ChatBot.js 4.49 kB │ gzip: 2.46 kB ← 안 누르면 안 받음 ../dist/assets/react-icons.js 10.48 kB │ gzip: 3.98 kB ../dist/assets/index.js 94.99 kB │ gzip: 25.64 kB ← 내 코드 ../dist/assets/vendor.js 142.32 kB │ gzip: 47.47 kB ← framer-motion ../dist/assets/react-core.js 161.94 kB │ gzip: 52.71 kB ← 거의 안 바뀜 ../dist/assets/lottie.js 317.45 kB │ gzip: 81.99 kB ← 에러 화면에서만 숫자를 보면 명확하다. lottie 하나가 전체의 절반쯤 이다. 이걸 메인에 두는 것과 빼는 건 첫 로딩에서 차이가 크다. 그리고 내 코드(index.js)는 gzip 25KB밖에 안 된다 . 배포를 매일 해도 사용자가 다시 받는 건 25KB고, 나머지 190KB는 캐시에서 나온다. 나누기 전엔 매번 전부 다시 받았다. 안 한 것 하나 framer-motion을 lazy로 빼볼까 고민하다 접었다. 첫 화면 카드 등장 애니메이션에 바로 쓰이기 때문 이다. lazy로 빼면 애니메이션 라이브러리를 기다렸다가 애니메이션이 시작되는데, 그건 안 하느니만 못하다. 142KB가 아깝긴 한데, 애니메이션이 이 사이트의 핵심이라 값을 치르기로 했다. 숫자를 다시 보다가 글을 쓰면서 빌드 결과를 다시 봤는데 이상한 게 있었다. lottie가 317KB인데, 이걸 쓰는 건 에러 화면 하나다. 사용자가 평생 한 번 볼까 말까 한 화면 때문에 전체 번들의 절반을 차지하는 청크를 유지하고 있는 거다. lazy로 빼놨으니 안 받긴 한다. 근데 애초에 에러 화면에 로티 애니메이션이 필요한가 는 생각을 안 해봤다. SVG 한 장이면 될 일이었을지도 모른다. 다음에 손볼 때 그것부터 봐야겠다.