번들 하나가 700KB였다. Vite manualChunks로 쪼개면서 정한 기준

포트폴리오 사이트를 빌드했더니 JS가 한 덩어리로 나왔다. 700KB 가 넘는 파일 하나에 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 폴더를 다시 잰 값이다. 처음 글을 쓸 때보다 내 코드가 16KB 정도 늘었고, 나머지 청크는 그대로다.

dist/assets/ChatBot.js          5.10 kB   gzip:  2.58 kB   <- 안 누르면 안 받음
dist/assets/react-icons.js     12.09 kB   gzip:  4.55 kB
dist/assets/index.css          38.00 kB   gzip:  8.50 kB
dist/assets/index.js          111.38 kB   gzip: 26.45 kB   <- 내 코드
dist/assets/vendor.js         142.31 kB   gzip: 47.37 kB   <- framer-motion
dist/assets/react-core.js     161.94 kB   gzip: 52.62 kB   <- 거의 안 바뀜
dist/assets/lottie.js         317.44 kB   gzip: 81.89 kB   <- 에러 화면에서만

숫자를 보면 명확하다. lottie 하나가 전체의 절반쯤이다. 이걸 메인에 두는 것과 빼는 건 첫 로딩에서 차이가 크다.

그리고 내 코드(index.js)는 gzip 26KB밖에 안 된다. 배포를 매일 해도 사용자가 다시 받는 건 그 26KB고, 나머지 190KB 가까이는 브라우저 캐시에서 나온다. 나누기 전엔 오타 하나 고쳐도 매번 전부 다시 받았다.

안 한 것 하나

framer-motion을 lazy로 빼볼까 고민하다 접었다. 첫 화면 카드 등장 애니메이션에 바로 쓰이기 때문이다. lazy로 빼면 애니메이션 라이브러리를 기다렸다가 애니메이션이 시작되는데, 그건 안 하느니만 못하다.

142KB가 아깝긴 한데, 애니메이션이 이 사이트의 핵심이라 값을 치르기로 했다.

숫자를 다시 보다가

글을 쓰면서 빌드 결과를 다시 봤는데 이상한 게 있었다. lottie가 317KB인데, 이걸 쓰는 건 에러 화면 하나다. 사용자가 평생 한 번 볼까 말까 한 화면 때문에 전체 번들의 절반을 차지하는 청크를 유지하고 있는 거다.

lazy로 빼놨으니 안 받긴 한다. 근데 애초에 에러 화면에 로티 애니메이션이 필요한가는 생각을 안 해봤다. SVG 한 장이면 될 일이었을지도 모른다.

다음에 손볼 때 그것부터 봐야겠다. 청크를 어떻게 나눌지보다 그 라이브러리가 애초에 필요한지를 먼저 묻는 게 순서였다.