미니파이어가 실제로 하는 일 — 그리고 gzip 이후 남는 실익
제대로 된 JavaScript 미니파이어(terser, esbuild, swc)는 공백 제거보다 훨씬 많은 일을 합니다. 소스를 AST로 파싱한 뒤 의미를 보존하는 변환을 적용합니다 — 식별자 맹글링(지역 변수를 한 글자로 개명 — 스코프를 알기 때문에 안전), 상수 폴딩(1000 * 60 * 60이 3600000으로), 죽은 코드 제거(if (false) 분기 삭제), 불리언·프로퍼티 단축 재작성(true → !0, obj['key'] → obj.key), 함수 인라이닝. pure 주석 마커가 달린 호출은 결과가 사용되지 않으면 통째로 제거될 수 있습니다 — 트리셰이킹된 라이브러리가 광고하는 크기에 도달하는 방법이 바로 이것입니다.
숫자가 중요한 이유는 gzip이 이야기를 바꾸기 때문입니다. 미니피케이션 단독으로는 애플리케이션 코드에서 보통 원시 바이트의 30~60%를 제거합니다. 하지만 gzip은 공백과 반복되는 긴 식별자를 아주 잘 압축하므로 압축 후 격차는 줄어듭니다 — 400KB에서 160KB로 미니파이되는 파일이 gzip 후에는 미니파이 52KB vs 비미니파이 61KB 정도가 됩니다. 남는 15~20%도 물론 가치가 있습니다 — 그리고 맹글링이 압축에 주는 도움은 기대보다 작습니다. 짧은 이름은 gzip이 어차피 활용했을 중복성을 줄이기 때문입니다.
실무 규칙: 원시 크기가 아니라 압축된 크기를 측정하세요. 원시 40%를 아끼지만 gzip 기준 3%만 아끼는 미니피케이션 단계라면, 그것이 끌어들이는 소스맵 복잡성을 정당화하지 못할 수 있습니다. 반대로 죽은 코드 제거와 트리셰이킹은 모든 공백 제거를 합친 것보다 압축 바이트를 더 많이 아끼는 경우가 흔합니다 — 삭제된 코드는 압축해도 0바이트이기 때문입니다.
# same bundle, four ways (typical mid-size app)
source 412,000 bytes
minified 168,000 bytes (-59% raw)
minified + gzip 52,000 bytes
minified + brotli-11 46,000 bytes
gzip WITHOUT minify ≈ 61,000 bytes
# → minification still buys ~15% after compression,
# mostly from dead-code elimination, not whitespace정규식 vs AST: 바이트 수준 JavaScript 미니피케이션이 위험한 이유
JavaScript는 문자열 치환으로 안전하게 미니파이할 수 없으며, 그 이유는 구조적입니다. 첫째, 슬래시 문자가 모호합니다. a / b는 나눗셈이지만 /b/.test(a)는 정규식 리터럴입니다. 특정 슬래시가 어느 쪽인지는 파싱 상태에 달려 있는데, 정규식 기반 치환기는 그 상태를 모릅니다. /https?:\/\// 같은 정규식 리터럴 안의 "주석"을 제거하면 코드 의미가 조용히 바뀝니다.
둘째, 자동 세미콜론 삽입(ASI) 때문에 줄바꿈이 의미를 가집니다. return 뒤에 줄바꿈, 그다음 객체 리터럴이 오면 undefined가 반환됩니다 — ASI가 return 뒤에 세미콜론을 넣기 때문입니다. 줄을 무턱대고 이어 붙이면 이 버그를 숨기거나, 더 나쁘게는 새 버그를 만듭니다. 변수로 끝나는 줄과 여는 괄호/대괄호로 시작하는 다음 줄이 붙으면 함수 호출이나 프로퍼티 접근으로 융합됩니다. 진짜 미니파이어는 먼저 파싱하므로 세미콜론이 필요한 위치를 알고, 심지어 ASI를 역이용해 안전하게 세미콜론을 제거하기도 합니다.
셋째, 문자열과 템플릿 리터럴은 불투명한 컨테이너입니다. 슬래시 두 개가 든 문자열은 주석이 아니고, 템플릿 리터럴 안의 공백은 데이터라서 바이트 단위로 보존돼야 합니다.
이 같은 빠른 온라인 미니파이어에 대한 결론: 나중에 눈으로 검수할 수 있는 사소한 스니펫 — 북마클릿, 인라인 이벤트 핸들러, 짧은 스타일 블록 — 에는 괜찮습니다. 사용자에게 배포되는 코드라면 빌드에서 terser, esbuild, swc를 돌리세요. esbuild는 terser보다 대략 10~100배 빠르면서 출력은 1~2% 정도만 크고, terser는 가장 강하게 쥐어짜며, swc는 그 중간에서 훌륭한 TypeScript 지원을 제공합니다.
// 1. ASI: this returns undefined, not the object
function pick() {
return // <- semicolon auto-inserted here
{ ok: true };
}
// 2. line joining creates a call that never existed
const total = fn
(a || b).forEach(log) // joined: fn(a || b).forEach(log)
// 3. slash ambiguity — is it division or a regex?
const x = a / b / c; // two divisions
const ok = /a\/b/.test(s); // one regex containing a slash
// a byte-level replacer cannot tell these apartCSS 미니피케이션: 안전한 변환들과 calc() 함정
CSS는 JavaScript보다 단순한 언어라서 더 많은 부분을 기계적으로 미니파이할 수 있지만 — 전부는 아닙니다. 확실히 안전한 변환들: 주석 제거(단, /*!로 시작하는 라이선스 주석은 관례상 빌드 도구가 보존), 문자열 밖 공백 접기, 각 선언 블록의 마지막 세미콜론 제거, 자릿수가 쌍을 이루는 6자리 hex 색상 단축(#ffffff → #fff), 길이 0에서 단위 제거(0px → 0 — 0은 어떤 길이 단위로도 0이므로 유효), 동일한 박스 값 4개 병합(margin: 4px 4px 4px 4px → margin: 4px).
고전적인 함정은 calc()입니다. CSS 문법은 calc 안의 +와 - 주위에 공백을 요구합니다. 숫자에 붙은 마이너스는 뺄셈이 아니라 음수 값으로 파싱되기 때문입니다. calc(100% - 20px)는 유효하지만 calc(100%-20px)는 아니며, 브라우저는 해당 선언 전체를 조용히 버립니다. 비슷한 공백 민감성이 미디어 쿼리(screen and (min-width: ...))와 CSS 커스텀 프로퍼티에도 있습니다. 커스텀 프로퍼티 값은 토큰 그대로 저장되므로 var() 폴백이나 커스텀 프로퍼티 값 내부를 미니파이하면 소비하는 쪽이 읽는 내용이 달라질 수 있습니다.
또 하나 비직관적인 것: 0%는 항상 0으로 바꿀 수 있는 게 아니고(flex-basis와 일부 애니메이션 문맥에서는 퍼센트가 의미를 가짐), 폰트 이름이나 url() 값의 따옴표 제거는 내용에 예약 문자가 없을 때만 안전합니다.
실제 파이프라인에서는 cssnano(PostCSS 기반, 매우 세밀한 설정 가능)나 Lightning CSS(Rust, 매우 빠르고 최신 문법을 구형 브라우저용으로 트랜스파일까지)가 이를 처리합니다. 둘 다 AST로 파싱하기 때문에 위의 변환을 안전하게 수행하고, 문맥이 금지하는 곳에서는 건너뛸 수 있습니다.
/* before */
.card { margin: 0px 0px 0px 0px; background: #ffffff; }
.col { width: calc(100% - 20px); }
/* correct minify */
.card{margin:0;background:#fff}.col{width:calc(100% - 20px)}
/* BROKEN: whitespace inside calc() is syntax, not formatting */
.col{width:calc(100%-20px)} /* declaration silently dropped */
/*! preserved license header — the ! is the convention */소스맵: 디버깅 능력을 잃지 않고 미니파이하기
bundle.min.js의 1행 48213열을 가리키는 프로덕션 스택 트레이스는 쓸모가 없습니다. 소스맵이 이를 해결합니다. .map 파일(VLQ로 인코딩된 mappings 문자열을 담은 JSON)은 생성된 모든 위치에 대해 원본 파일, 행, 열, 그리고 대개 원래 식별자 이름까지 기록합니다. 미니파이된 파일은 마지막 줄의 sourceMappingURL 주석으로 이를 참조하고, 브라우저 개발자 도구가 필요할 때만 가져옵니다 — 일반 방문자는 절대 다운로드하지 않습니다.
배포에서의 쟁점은 누가 맵을 볼 수 있느냐입니다. 흔한 구성 세 가지: (1) 공개 맵 — 가장 단순하지만 궁금해하는 누구에게나 원본 소스가 노출됩니다. (2) 히든 맵 — 생성은 하되 번들에서 참조하지 않고 에러 트래커(Sentry, Datadog, Rollbar)에 업로드해 서버 측에서 스택 트레이스를 심볼리케이트합니다. (3) 인증된 내부 IP에만 제공하는 제한 맵. 클로즈드 소스 제품이라면 히든 맵이 표준 답안입니다. sourcesContent를 포함한 맵은 맵 파일 자체에 원본 소스 전문이 들어 있다는 점도 기억하세요.
실무 함정 두 가지. 체인의 모든 변환 단계가 맵을 생산하고 소비해야 합니다(TypeScript → esbuild → terser). 그러지 않으면 최종 맵이 중간 산출물을 가리키게 되어 모든 프레임이 엉뚱한 위치로 해석됩니다. 그리고 재배포 시 맵은 생성 당시의 번들과 바이트 단위로 일치해야 합니다 — 에러 트래커가 릴리스 식별자나 콘텐츠 해시로 맵을 키잉하는 이유가 바로 이것입니다. 심볼리케이트된 트레이스가 그럴듯한데 미묘하게 틀려 보인다면, 릴리스가 어긋난 오래된 맵이 범인일 가능성이 큽니다.
// last line of bundle.min.js
//# sourceMappingURL=bundle.min.js.map
// hidden-maps workflow (Sentry example)
esbuild app.ts --minify --sourcemap=external --outfile=dist/app.js
sentry-cli sourcemaps upload --release=v1.4.2 ./dist
rm dist/*.map # maps never reach the CDN
// stack trace before / after symbolication
at t (bundle.min.js:1:48213)
at checkout (src/cart/checkout.ts:87:11) // with map + matching release미니파이, 압축, 번들링: 서로 다른 세 가지 일, 셋 다 하라
팀들은 서로 다른 계층에서 작동하는 세 가지 최적화를 자주 뒤섞습니다. 미니피케이션은 빌드 시점에 소스 텍스트를 한 번 재작성합니다. 전송 압축(gzip 또는 Brotli)은 서빙 시점에 응답별로 바이트를 인코딩하고 브라우저가 투명하게 디코딩합니다. 번들링은 모듈을 합쳐 요청 오버헤드를 줄이고 모듈 간 트리셰이킹을 가능하게 합니다. 이 셋은 서로 곱해지는 효과이며 — 어느 것도 다른 것을 대체하지 않습니다.
압축 설정은 생각보다 중요합니다. 정적 자산은 빌드 중에 최고 레벨로 사전 압축하고(Brotli 품질 11, gzip 9) 서버가 콘텐츠 협상으로 인코딩을 고르게 하세요. Brotli-11은 요청마다 돌리기엔 너무 느리지만 배포 시 한 번 하는 것은 공짜입니다. 동적 응답에는 Brotli 4~5나 gzip 6을 쓰세요 — 더 높은 레벨은 한 자릿수 퍼센트 이득에 CPU를 태웁니다. 일반적인 텍스트 압축률: gzip 약 3~4배, Brotli는 JS/CSS에서 gzip보다 약 10~20% 더 작음.
이 도구가 겨냥하는 인라인 스니펫 워크플로의 작업 순서: 읽기 좋은 원본은 저장소에 두고, HTML 속성·GTM 컨테이너·이메일 템플릿에 붙여넣을 사본만 미니파이한 뒤, 결과가 여전히 파싱되는지 검증하세요 — JS는 브라우저 콘솔에, CSS는 밸리데이터에 붙여 보면 됩니다. 미니파이된 텍스트를 편집 가능한 원본으로 취급하지 마세요. 언제든 재생성할 수 있는 일방향 산출물은 무해하지만, 손으로 수정한 미니파이 덩어리는 되돌릴 수 없는 기술 부채입니다.