highlight.js를 넣다가 홈페이지가 1년 넘게 lodash를 받고 있던 걸 알았다
며칠 전에 블로그 글의 코드블록에 문법 하이라이팅을 붙였다. 언어 표시, 줄 번호, 줄바꿈 버튼, 복사 버튼까지 넣었더니 글이 훨씬 읽기 편해졌다. 근데 배포하기 전에 늘 하던 대로 홈페이지가 실제로 뭘 내려받는지 확인하다가 이상한 걸 봤다. 하이라이팅은 글 상세 화면에서만 쓰는데, 홈에서도 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를 한 번 열어보길 추천한다. 전혀 상관없는 패키지 이름이 보인다면 같은 일이 생기고 있는 거다.