highlight.js를 넣다가 홈페이지가 1년 넘게 lodash를 받고 있던 걸 알았다
코드블록 하이라이팅을 붙이고 홈 요청을 확인했더니 글 화면에서만 쓰는 highlight.js 71KB가 같이 내려오고 있었다. 원인은 Rollup의 CommonJS 헬퍼였고, 같은 이유로 lodash도 1년 넘게 홈에서 받고 있었다.
#Vite #빌드 #성능최적화 #React #프론트엔드
며칠 전에 블로그 글의 코드블록에 문법 하이라이팅을 붙였다. 언어 표시, 줄 번호, 줄바꿈 버튼, 복사 버튼까지 넣었더니 글이 훨씬 읽기 편해졌다. 근데 배포하기 전에 늘 하던 대로 홈페이지가 실제로 뭘 내려받는지 확인하다가 이상한 걸 봤다. 하이라이팅은 글 상세 화면에서만 쓰는데, 홈에서도 highlight.js 청크를 받고 있었다. 파고들어 보니 하이라이팅 문제가 아니었다. 같은 이유로 홈페이지가 1년 넘게 lodash를 받고 있었다는 것까지 알게 됐다. 이 글에서는 그 원인이 뭐였는지, 그리고 왜 빌드 결과만 봐서는 절대 못 찾는 문제인지 정리해본다. 글 화면에서만 쓰는 라이브러리가 왜 홈에 있나 코드블록은 DB에 언어 정보 없이 저장돼 있어서, 렌더링할 때 내용을 보고 언어를 추정한 다음 highlight.js로 색을 입힌다. 이 코드는 글 본문을 그리는 ContentBody 에만 들어 있고, 글 상세 라우트는 lazy() 로 따로 불러온다. 그러니까 홈에서는 highlight.js를 받을 이유가 하나도 없다. 근데 빌드된 index.html 을 열어보니 미리 불러오는 목록 맨 첫 줄이 highlight.js였다. link rel="modulepreload" crossorigin href="/assets/highlight.js-e6918b47.js" link rel="modulepreload" crossorigin href="/assets/react-f12e2e30.js" link rel="modulepreload" crossorigin href="/assets/scheduler-765c72db.js" modulepreload 에 들어 있다는 건 첫 화면을 그리는 데 필요하다고 Vite가 판단했다는 뜻이다. 내가 어디선가 highlight.js를 정적으로 import한 줄 알고 소스를 다 뒤졌는데, 그런 코드는 없었다. 청크 맨 위의 import를 봐야 보인다 소스에 없으면 빌드 결과물에 있다. 청크 파일 맨 앞의 import 문을 하나씩 열어봤다. // react-dom 청크 import{g as fa}from"./highlight.js-e6918b47.js" // lottie-web 청크 import{g as getDefaultExportFromCjs,c as commonjsGlobal}from"./highlight.js-e6918b47.js" // quill 청크 import{c as vt}from"./highlight.js-e6918b47.js" react-dom이 highlight.js 청크에서 g 라는 걸 가져오고 있었다. react-dom이 코드 하이라이팅을 쓸 리는 없으니 저 g 가 뭔지가 문제였다. lottie-web 쪽은 이름이 압축되지 않아서 바로 보였다. getDefaultExportFromCjs 와 commonjsGlobal 이다. 이 둘은 Rollup이 CommonJS 패키지를 ES 모듈로 바꿀 때 쓰는 헬퍼다. react-dom, lottie-web, quill, highlight.js가 전부 CommonJS로 배포되는 패키지라 다 같이 이 헬퍼를 쓴다. 문제는 이 헬퍼가 highlight.js 청크 안에 들어가 있었다는 거다. 그래서 react-dom을 쓰려면 highlight.js 청크를 통째로 받아야 했다. 헬퍼가 왜 하필 highlight.js 청크에 들어갔나 블로그의 청크 분할 규칙은 단순했다. node_modules 경로에 있으면 패키지 이름으로 청크를 만든다. manualChunks(id) { if (id.includes('node_modules')) { return id.toString().split('node_modules/')[1].split('/')[0].toString(); } } 근데 CommonJS 헬퍼는 실제 파일이 아니라 플러그인이 만들어내는 가상 모듈이다. Vite 안에서 이름이 \0commonjsHelpers.js 로 잡혀 있어서 node_modules 라는 글자가 없다. 그래서 내 규칙에는 안 걸리고 주인 없는 모듈로 남는다. Rollup은 주인 없는 모듈을 그냥 두지 않는다. 직접 지정한 청크들을 하나씩 돌면서 그 청크가 의존하는 모듈 중 아직 주인이 없는 걸 그 청크에 같이 넣는다. 그리고 이 순회를 청크 이름의 알파벳 순서로 한다. CommonJS 패키지 청크 이름을 정렬하면 이렇다. highlight.js lodash lottie-web quill react react-dom h가 제일 앞이다. 그래서 highlight.js 청크가 헬퍼를 먼저 가져갔고, 헬퍼가 필요한 나머지 청크가 전부 highlight.js 청크를 import하게 됐다. react-dom은 첫 화면에 꼭 필요하니 highlight.js도 같이 첫 화면 목록에 올라간 거다. 정말 이름 순서 때문인지 확인하려고 highlight.js 청크 이름만 zz-highlight 로 바꿔서 빌드해봤다. 헬퍼가 lodash 청크로 옮겨갔다. 이름 하나 바꿨다고 빌드 결과가 바뀌는 걸 보고 나서야 확신이 들었다. 그럼 그 전에는 어디 있었나 여기서 하나가 더 걸렸다. highlight.js가 없던 시절에도 헬퍼는 어딘가에 들어가 있었을 거다. 하이라이팅을 넣기 직전 커밋을 따로 빌드해봤다. // 하이라이팅 이전 커밋 빌드, react-dom 청크 import{g as fa}from"./lodash-c86a663f.js" // index.html link rel="modulepreload" crossorigin href="/assets/lodash-c86a663f.js" lodash에 들어 있었다. 알파벳 순서상 highlight.js가 오기 전에는 lodash가 제일 앞이었으니 당연한 결과다. lodash는 블로그 코드에서 직접 쓰지도 않는다. 관리자 글쓰기 화면의 에디터인 react-quill이 끌고 오는 의존성일 뿐이다. 그런데도 홈에 들어오는 모든 방문자가 lodash 청크(13.23KB, gzip 5.32KB)를 받고 있었다. 청크를 패키지별로 나눈 게 작년 6월이니 1년 넘게 그랬던 거다. 크기가 작아서 그동안 아무도, 그러니까 나도 눈치채지 못했다. highlight.js는 71KB라 눈에 띄었을 뿐이다. 고친 건 한 줄이다 헬퍼에 자기 청크를 따로 주면 된다. 주인이 정해져 있으면 Rollup이 다른 청크에 끼워 넣지 않는다. manualChunks(id) { // Rollup 의 CommonJS 헬퍼는 node_modules 경로가 아니라서 규칙에 안 걸리고, // 이름순으로 먼저 오는 패키지 청크에 얹힌다. 헬퍼는 별도의 작은 청크로 뺀다. if (id.includes('commonjsHelpers')) return 'commonjs-helpers'; if (id.includes('node_modules')) { return id.toString().split('node_modules/')[1].split('/')[0].toString(); } } 새로 생긴 commonjs-helpers 청크는 0.24KB다. 헬퍼 함수 두 개가 전부다. 이제 react-dom, lottie-web, quill은 전부 이 작은 청크에서 헬퍼를 가져간다. // 고친 뒤 react-dom 청크 import{g as fa}from"./commonjs-helpers-725317a4.js" highlight.js 청크를 참조하는 청크 수도 크게 줄었다. 고치기 전에는 react, react-dom, lodash, lottie-web, quill에 로그인, 방명록 같은 라우트 청크까지 25개가 highlight.js를 참조했다. 고친 뒤에는 실제로 하이라이팅을 쓰는 글 상세와 관리자 글쓰기 미리보기 쪽 3개, 그리고 lazy import 목록을 들고 있는 진입 청크까지 4개만 남았다. 홈에서 받는 JS를 playwright로 세어봤다. 같은 조건에서 비교하려고 압축 없이 띄운 로컬 서버 기준이다. 홈 JS (압축 전 크기) 파일 수 합계 highlight.js lodash 헬퍼 규칙 없음 17 691,297 B 받음 안 받음 헬퍼 규칙 있음 17 620,702 B 안 받음 안 받음 운영 서버에서 실제로 전송된 양으로 보면, 지금 홈의 JS는 187KB이고 글 상세에 들어가야 비로소 highlight.js 22.8KB를 받는다. 규칙이 없었다면 홈 JS가 12% 정도 늘어날 뻔했다. 언어도 쓰는 것만 등록했다 하이라이팅을 붙이면서 하나 더 신경 쓴 게 있다. highlight.js는 그냥 import hljs from 'highlight.js' 로 가져오면 지원하는 언어 192개가 전부 딸려 온다. import 방식 압축 전 gzip highlight.js 전체 (192개 언어) 1,081,310 B 311,671 B lib/core 만 21,130 B 8,504 B lib/core + 내가 쓰는 11개 언어 71,335 B 22,573 B 전체를 가져오면 gzip 기준으로도 14배쯤 크다. 내 글에 나오는 코드는 JS, TS, HTML, 터미널, SQL, JSON, CSS, Apache, Nginx, PHP, YAML 정도라 그것만 등록했다. import hljs from 'highlight.js/lib/core'; import javascript from 'highlight.js/lib/languages/javascript'; import typescript from 'highlight.js/lib/languages/typescript'; import bash from 'highlight.js/lib/languages/bash'; // ... xml, sql, json, css, apache, nginx, php, yaml hljs.registerLanguage('javascript', javascript); hljs.registerLanguage('typescript', typescript); hljs.registerLanguage('bash', bash); 언어마다 크기도 꽤 차이가 난다. CSS가 13KB로 제일 크고 JSON은 0.4KB다. 나중에 Go나 Python 글을 쓰게 되면 그때 하나씩 추가하면 된다. 빌드 결과만 봐서는 못 찾는다 이 문제가 까다로운 건 아무 신호가 없다는 점이다. 빌드할 때 경고가 안 나오고, 청크 크기도 거의 그대로다. 헬퍼가 들어 있던 highlight.js 청크가 70.98KB, 뺀 뒤가 70.80KB다. 빌드 로그의 크기 표만 보고 있었다면 절대 못 찾았을 거다. 보이는 곳은 세 군데뿐이었다. 청크 파일 맨 위의 import 문, index.html 의 modulepreload 목록, 그리고 브라우저가 실제로 요청한 파일 목록이다. 나는 홈페이지가 19.7MB를 받고 있던 걸 찾았을 때 이후로 배포 전에 브라우저 요청 목록을 확인하는 습관이 생겼고, 그 덕에 찾았다. 그리고 이건 한 번 고쳤다고 끝나는 문제도 아니다. 이름순으로 먼저 오는 CommonJS 패키지를 새로 추가하면 헬퍼가 또 거기로 옮겨갈 수 있다. 지금은 헬퍼 청크를 따로 빼둬서 괜찮지만, 청크 분할 규칙을 바꾸거나 Vite 8의 Rolldown 처럼 번들러가 바뀌면 다시 확인해야 한다. manualChunks를 처음 나눌 때의 기준은 예전 글 에 정리해뒀다. manualChunks로 패키지별 청크를 나눠 쓰고 있다면, 진입 청크나 react-dom 청크 맨 위의 import를 한 번 열어보길 추천한다. 전혀 상관없는 패키지 이름이 보인다면 같은 일이 생기고 있는 거다.