JSON 압축을 잘못된 순서로 하지 마세요: minify vs gzip vs brotli, 측정 결과
핵심 요약: minify는 원본 JSON을 30~50% 줄이지만 gzip이 그 이득의 대부분을 지웁니다. 실제 모양별 측정 수치, 그리고 minify가 일을 그르치는 네 경우를 다룹니다.

핵심부터 말하면, 먼저 전송 압축과 구조 트리밍을 분리하세요. gzip이나 brotli가 아직 없다면 서버 설정을 고치세요. gzip -6만으로 94.5%에 도달해 minify는 0.9포인트만 남깁니다. minify의 온전한 46.3%는 바이트 청구서가 로컬에 기록될 때만 성립합니다. 요청 본문, Lambda의 6MB, Firestore의 1MiB, 또는 디스크와 로그에 쓰는 JSON.
즐겨 쓰는 minifier의 "35% 절감" 배지는 정직합니다. 원본 바이트를 측정한 것입니다. API는 gzip이나 brotli 위에서 돌고, 그 둘은 정확히 같은 중복을 먹습니다. 두 수치를 곱하면 틀린 답이 나옵니다.
1. "압축"을 서로 다른 두 가지로 나누기
사람들이 JSON 압축이라 부르는 것은 실제로 두 무관한 작업이며, 둘을 혼동하는 것이 모든 잘못된 결론의 시작입니다.
| minify(공백 제거) | gzip / brotli(전송 압축) | |
|---|---|---|
| 본질 | 문서 변환 | 전송 변환 |
| 출력 | 여전히 유효하고 읽히는 JSON 텍스트 | 바이너리, 디코딩 전에는 못 씀 |
| 이득이 떨어지는 곳 | 디스크, 메모리, 로그, 크기 제한 | 오직 전송로와 캐시 |
| 디코딩 후 | 바이트가 더 작게 유지 | 바이트가 원래 크기로 돌아감 |
| 소유자 | 빌드 스크립트, 직렬화 인자 | Nginx, CDN, 서버 프레임워크 |
마지막 두 행이 핵심입니다. 플랫폼 제한은 디코딩 후에 측정됩니다. AWS Lambda의 6MB 페이로드 상한, Firestore의 문서당 1MiB 상한. gzip을 켜도 그곳에서는 한 바이트도 벌지 못합니다. 둘 다 디코딩 결과를 셉니다.
2. minify는 정확히 한 가지를 제거: 구조적 공백
RFC 8259는 구조 문자 주변의 공백(space, \t, \n, \r)을 허용합니다. JSON 문서에서 나머지는 선택이 아닙니다. 키 이름은 데이터이고, 숫자는 모든 자리를 유지하며, 문자열 내용은 바이트 그대로 손댈 수 없습니다.
그래서 작업 전체가 한 줄입니다:
const minified = JSON.stringify(JSON.parse(pretty));
// RFC 8259 allows whitespace only around structural characters,
// so spaces inside a string are data, not formatting
JSON.stringify(JSON.parse('{"note": "two spaces"}'));
// {"note":"two spaces"} - both inner spaces are preserved
언급할 만한 것은 "압축"이라는 이름 아래 들어가지만 실제로 문서를 수정하는 세 작업입니다. 값이 null인 키 버리기, 부동소수점 반올림, 키 재정렬. 이것들은 문서를 재형식화하는 것이 아니라 바꾸므로, "딥 압축"을 광고하는 도구는 켜기 전에 소비자의 승인을 받아야 합니다.
minify는 공백만 제거하므로 이득은 거의 전적으로 파일에서 들여쓰기가 차지하는 비율에 달려 있습니다. 이 사실이 아래 표들에서 반복해서 나타납니다.
3. 측정: 네 문서, 세 gzip 수준, 그리고 brotli
환경: Node v22.13.1, zlib.gzipSync 수준 1 / 6 / 9, brotliCompressSync 기본 품질(11). 네 샘플은 실제로 보내는 모양을 의도적으로 덮습니다. 중첩 주문 50개(깊은 구조, 반복 키), 6단계 설정(긴 키, 짧은 값), 200줄 로그 배열(긴 문자열, 거의 들여쓰기 없음), 그리고 이 사이트의 실제 media-manifest.json(혼합 모양, URL이 많은 문자열).
1단계: 원본 바이트.
| 샘플 | pretty(2 스페이스) | pretty(4 스페이스) | minify 후 | minify 절감 |
|---|---|---|---|---|
| 중첩 주문 50개 | 29,551 B | 40,761 B | 15,881 B | 46.3% |
| 6단계 설정 | 25,450 B | 38,012 B | 11,679 B | 54.1% |
| 200줄 로그 배열 | 14,582 B | 14,982 B | 13,981 B | 4.1% |
| media-manifest.json | 25,149 B | 28,114 B | 21,222 B | 15.6% |
여기서 "35% 절감" 배지가 나오며, 거짓말이 아닙니다. 문제는 2단계에서 나타납니다.
2단계: 각 변형을 압축한 뒤 minify가 여전히 얼마를 더하는지 묻기.
| 샘플 | gzip -1 위에서 | gzip -6 위에서 | gzip -9 위에서 | brotli 위에서 |
|---|---|---|---|---|
| 중첩 주문 50개 | 21.0% | 17.0% | 8.9% | 10.6% |
| 6단계 설정 | 74.2% | 59.1% | 58.0% | 56.4% |
| 200줄 로그 배열 | -0.7% | -0.4% | 0.6% | -0.3% |
| media-manifest.json | 1.5% | 1.0% | 1.0% | 1.6% |
원본 바이트의 46.3%를 잃는 같은 중첩 주문 문서가 gzip -6이 끼면 그중 17.0%만 유지합니다. 로그 배열은 완전히 음수가 됩니다. minify 후 압축이 pretty 원본을 압축한 것보다 약간 큽니다. 음수는 버그가 아니라 예상대로이며, 다음 절이 이유를 설명합니다.
3단계: 원본 pretty 문서 대비 총 감소 — 실제로 전송로를 오가는 수치.
| 샘플 | gzip -6만 | minify + gzip -6 | minify + brotli |
|---|---|---|---|
| 중첩 주문 50개 | 94.5% | 95.4% | 96.9% |
| 6단계 설정 | 96.0% | 98.4% | 99.0% |
| 200줄 로그 배열 | 89.9% | 89.9% | 95.0% |
| media-manifest.json | 83.5% | 83.6% | 86.1% |
4. gzip이 minify의 공로를 먹는 이유
gzip은 LZ77에 이어 허프만 부호화입니다. LZ77의 핵심 동작은 "32KB 창을 뒤져 반복 시퀀스를 찾아 (거리, 길이) 포인터로 교체"입니다. 들여쓰기는 그 동작에 가능한 가장 쉬운 표적입니다. 중첩 단계마다 동일한 공백 런을 내고, 인접한 수십 줄이 같은 조합을 반복합니다. 허프만은 빈도 높은 기호에 짧은 코드를 할당하므로 \n과 스페이스 여덟 개 같은 패턴 — 파일 하나에서 수천 번 반복될 수 있는 — 이 2~3비트 비용이 됩니다.
그래서 minify가 지우는 바이트는 압축기가 사실상 무료로 접었을 바로 그 바이트입니다. 직접 하는 것은 압축기가 이미 탁월한 작업을 다시 하는 일이고, 46%가 17%로 접힙니다. 그게 위 표의 전부입니다.
음수 로그 배열 결과는 따로 설명할 만합니다. 그 샘플은 거의 전부 긴 문자열(로그 줄, URL, base64)이라 반복이 적고 엔트로피가 높습니다. 압축기가 지울 중복이 처음부터 적습니다. pretty 버전의 줄 바꿈이 우연히 매처에 안정적 경계를 줍니다. 줄 바꿈을 지우면 LZ77이 그 고정점을 잃습니다. 이 샘플에서 pretty 원본을 gzip -1로 압축하면 1,508바이트, minify 후 압축하면 1,518바이트입니다. 10바이트 더 나쁩니다.
크기순으로 정렬하면 레버는 이렇게 나옵니다:
| 레버 | 원본 pretty 문서 대비 절감(중첩 주문) | 비용 |
|---|---|---|
| minify | 46.3%(원본 바이트만) | 가독성 소실, 빌드 단계 필요 |
| gzip -6 | 94.5% | 약간의 서버 CPU, 거의 제로 설정 |
| minify + gzip -6 | 95.4% | 위 둘의 합 |
| minify + brotli | 96.9% | HTTPS와 brotli 모듈 필요, 정적 자산 사전 압축 |
0에서 94.5%로의 도약은 gzip의 일입니다. 94.5%에서 95.4%로의 도약 — 0.9포인트 — 은 minify의 일입니다. 이 표가 노력이 어디에 속하는지 정합니다.
5. 압축 수준이 숨은 변수
단일 고정 gzip 수준을 가정하는 논쟁은 모래 위에 서 있습니다. 수준이 minify의 한계 가치를 직접 바꾸기 때문입니다:
| 전송 설정 | minify가 여전히 아끼는 추가 바이트(중첩 주문) |
|---|---|
gzip -1(Nginx gzip_comp_level 기본) |
21.0% |
| gzip -6(대부분 표준 라이브러리 기본) | 17.0% |
| gzip -9 | 8.9% |
| brotli(기본 품질) | 10.6% |
전송 압축이 약할수록 minify의 이득이 큽니다. 실용 규칙: gzip_comp_level이 아직 1인 순정 Nginx 설치에서는 minify가 21%를 줍니다. 9로 튜닝하거나 brotli로 전환하면 9~11%로 떨어져 대개 건너뛸 수 있습니다.
어느 쪽으로 논쟁하기 전에 실제로 어느 계층에 있는지 측정하세요:
# Fetch the same response with three Accept-Encoding values and compare what landed
for enc in identity gzip br; do
size=$(curl -s -o /dev/null -w '%{size_download}' \
-H "Accept-Encoding: $enc" https://api.example.com/v1/orders)
echo "$enc -> $size bytes"
done
세 수치가 세 질문에 한 번에 답합니다. 압축이 켜졌는지, 어떤 수준인지, minify에 얼마의 여지가 남았는지. identity와 gzip이 거의 같게 돌아오면 압축이 전혀 활성되지 않은 것이고, 고칠 것은 Nginx이지 JSON이 아닙니다:
gzip on;
gzip_comp_level 6;
gzip_min_length 256; # skip tiny responses, they only grow
gzip_types application/json application/javascript text/css;
6. minify가 진짜 이득인 경우
한 가지 검사가 결정합니다. 아낀 바이트가 어딘가에 기록되는가, 아니면 원본 바이트를 세는 한도에 부딪히는가? 예라면 온전한 46%가 실현됩니다. 답이 "전송로에만 존재한다"라면 실제 가치는 17%입니다.
첫째, HTTP 요청 본문. 대부분 서버는 요청 본문을 압축 해제하지 않고, gzip은 거의 응답 전용으로 설정됩니다. POST하는 JSON은 대개 전송 계층 보호가 전혀 없어 minify의 절감이 온전히 도착합니다. permessage-deflate를 협상하지 않은 웹훅 콜백, MQTT 텔레메트리, WebSocket 프레임도 같은 상황입니다.
둘째, 하드 바이트 상한이 있는 채널 — 그리고 모두 디코딩된 바이트를 셉니다:
| 채널 | 한도 | gzip이 도움이 되나? |
|---|---|---|
| AWS Lambda 동기 페이로드 | 6 MB | 아니요, 디코딩 후 측정 |
| Firestore 단일 문서 | 1 MiB | 아니요 |
| Kafka 단일 메시지(기본) | 1 MB | 아니요, 브로커 측 압축은 다른 계층 |
| localStorage 할당 | ~5 MB(문자로 셈) | 아니요 |
| GET 요청 URL | 서버 의존, 8KB가 흔함 | 아니요 |
이 범주에서 minify는 최적화가 아니라 제출되는 요청과 실패하는 요청의 차이입니다. URL 쿼리 문자열에 JSON을 내장하는 것이 가장 날카로운 사례입니다. URL은 절대 압축되지 않고 길이 상한이 있으며, minify한 뒤 base64가 정말 필요한지 결정하세요.
셋째, 디스크나 로그에 쓰는 JSON. 로그와 인덱스는 전송 계층 없이 문자 바이트를 저장합니다. 사람들이 자주 넘어지는 주의점: minify는 애초에 pretty JSON을 쓰고 있었을 때만 도움이 됩니다. 그 200줄 로그 샘플은 4.1%만 절감했고, 줄이 이미 압축되어 들여쓰기가 파일에서 차지하는 비율이 거의 없었습니다. 반대로 로거가 JSON.stringify(obj, null, 2)를 호출한다면 세 번째 인자를 빼는 것이 공짜 돈입니다.
마지막 노트: 원본 바이트가 진짜 빠듯할 때, 키 이름이 대개 들여쓰기보다 큰 레버입니다. media-manifest.json에서 14개의 고유 키를 k0부터 k13으로 바꾸면 minify 형태가 21,222 B에서 18,887 B로 줄어듭니다. 추가 11.0%. 압축하면 남은 이점은 gzip -6 아래 2.4%, brotli 아래 1.3%로 줄어듭니다. 위의 모든 것과 같은 메커니즘입니다. 들여쓰기와 키 이름은 모두 반복되는 구조 정보이고 압축기는 어느 쪽에서든 같은 배당을 거둡니다.
7. 역효과가 나는 네 경우
커밋하는 파일은 minify하지 마세요. "qty": 2를 3으로 바꾸면 pretty 파일은 git diff에서 깨끗한 한 줄을 보여 주지만, minify 파일은 전체 줄을 재작성해 수백 필드에서는 변경을 전혀 찾을 수 없습니다. 이는 JSON 머지 충돌 해결 방법의 같은 메커니즘입니다. diff 도구는 줄 단위로 동작해 한 줄 JSON은 모든 편집을 "전체 줄이 다름"으로 만듭니다. 저장소에는 pretty 파일을 두고 빌드 출력이나 저장소 쓰기에서만 minify하세요.
아주 작은 응답은 압축하지 마세요. {"ok": true}만 담은 응답은 들여쓰기를 더하면 16바이트이고 gzip -6 후 36바이트 — 20바이트 더 큽니다. gzip 자체 사전과 헤더가 공간을 차지합니다. 이는 이론이 아니라 gzip_min_length가 존재하는 정확히 그 이유입니다. Nginx 기본 20바이트는 운영에 너무 낮고, 대개 256이 하한입니다.
더 빠른 파싱을 기대하지 마세요. 같은 주문 세트를 4,000 레코드로 키우면 pretty 문서는 2,374,378바이트, minify는 1,282,358바이트로 46% 더 작지만, JSON.parse 중앙값은 5.7ms에서 5.0ms로 대략 12%만 빨라집니다. 크기를 절반으로 한다고 시간이 절반이 되지 않습니다. 파서는 예산의 대부분을 문자열 자르기와 숫자 변환에 쓰고, 공백 건너뛰기는 부수적입니다. 파싱 비용을 실제로 몰아가는 것은 키 수와 문자열 내용이며, 대용량 JSON 파일 파싱, 렌더링, 스트리밍에서 자세히 분석합니다.
이미 brotli나 gzip -9로 돌고 있는 서비스에 소급 적용하지 마세요. 이득은 9~11%로 떨어지고, 디버깅 중 가독성의 영구 손실이라는 비용은 남습니다. 대신 gzip_types가 application/json을 덮는지 확인하세요. gzip을 켜고 text/html만 압축해 JSON은 전혀 압축하지 않는 사이트가 많습니다. 그 상황에서 첫 단계는 minify가 아니라 설정 수정입니다.
8. 결정 표: 어느 단계를 먼저
| 현재 상황 | 먼저 할 일 | minify 순위 |
|---|---|---|
| 전송 압축이 전혀 없음 | Nginx gzip_types 설정 또는 CDN 자동 압축 켜기 |
건너뛰기, 이득의 0.9가 사라짐 |
| gzip -1(Nginx 기본) 실행 중 | gzip_comp_level을 6으로 올리기 |
두 번째, 21% 값어치 |
| 이미 brotli나 gzip -9 사용 중 | 긴 키 점검, 페이지네이션 추가 또는 필드 트리밍 | 건너뛰기, 9~11%만 남음 |
| Lambda 6MB나 Firestore 1MiB에 부딪힘 | 바로 minify, 온전한 46%가 셈됨 | 첫 번째 |
| POST 본문, 웹훅, MQTT | 바로 minify, 여기엔 전송 압축이 거의 적용 안 됨 | 첫 번째 |
| Git에 들어가는 설정 파일 | pretty로 유지 | 빌드 출력에서만 |
| 로그, 텔레메트리, 오프라인 덤프 | 로거에서 null, 2 인자 제거 |
그것이 첫 단계 |
한 문장 규칙: 줄이려는 바이트 청구서가 로컬에 기록되는 것(디스크, 할당, 플랫폼 한도, 요청 본문)인지 전송로에만 존재하는 것인지 보세요. 로컬이면 minify, 전송로만이면 먼저 brotli나 gzip을 켜세요.
실무에서는 위 curl 루프를 돌려 전송 압축이 살아 있음을 확인한 뒤, 샘플을 JSON Minify 도구에 통과시켜 절감 수치를 읽으세요. 그 수치가 40%를 넘는데 curl이 identity와 gzip을 거의 같은 크기로 보고하면 병목은 JSON이 아니라 서버 설정입니다. 두 curl 수치가 이미 가깝다면 이 문서가 줄 것이 거의 없어 필드 트리밍이나 페이지네이션으로 넘어가야 합니다. JSON API 계약 설계 요점의 주제입니다.
minify한 JSON도 여전히 유효한 JSON인가?
예. minify는 RFC 8259가 구조 문자 주변에서 허용하는 공백만 제거하고 문자열 내용은 절대 건드리지 않으므로 JSON.parse가 원본과 같은 값을 냅니다. 유일한 손실은 인간 가독성입니다.
brotli인가 gzip인가?
같은 minify 샘플에서 brotli는 96.9%, gzip -6는 95.4%를 절감합니다. 차이가 좁습니다. 동적 응답에는 CPU를 낮게 유지하려고 gzip -6를 쓰고, brotli는 미리 .br 파일로 압축할 수 있는 HTTPS 전달 정적 자산에 남겨 두세요.
JSON을 minify했는데 API가 빨라지지 않았습니다. 왜?
curl -sI로 응답의 Content-Encoding: gzip 헤더를 확인하세요. 많은 Nginx 설정이 gzip_types를 text/html로만 두어 JSON이 전혀 덮이지 않았고, 46%의 5분의 1도 전송로에 도달하지 못했습니다.
설정 파일을 minify해서 커밋해야 하나?
아니요. diff 도구는 줄 단위로 비교하므로 한 줄 JSON은 모든 편집을 "전체 줄이 변경됨"으로 만들고, 충돌 해결 비용이 아낀 바이트보다 훨씬 큽니다. 저장소에는 pretty 버전을 두고 빌드 시 minify하세요.
gzip이 값어치가 있으려면 응답이 얼마나 커야 하나?
흔한 임계는 256바이트에서 1KB입니다. {"ok": true} 같은 작은 응답 — 들여쓰기 포함 16바이트 — 은 gzip -6 아래 36바이트로 커집니다. 사전과 헤더가 회수하는 것보다 더 비싸기 때문입니다. gzip_min_length가 제어하는 것이 정확히 이입니다.