글 한 편 5.8MB가 gzip으로 2.5MB, 그런데 줄어든 3.2MB 중 3.0MB는 PNG 세 장에서 나왔다
브라우저가 글 한 편을 다 그리려면 파일 여러 개를 받아야 합니다. 이 블로그의 글 한 편은 그 파일이 23개, 압축을 풀었을 때 기준으로 5.8MB였습니다. 서버가 gzip으로 눌러 보낸 실제 전송량은 2.5MB였고, 줄어든 양은 3.2MB였습니다. 절반 넘게 줄었으니 압축이 잘 걸린 것처럼 보입니다. 그런데 3.2MB를 항목별로 갈라 보니 3.0MB, 그러니까 93%가 PNG 파일 세 장에서 나왔습니다.
텍스트를 눌러서 얻은 것은 다 합쳐 227KB였습니다. 이 사이트에서 gzip이 하고 있는 일의 대부분은 원래 압축 대상이 아닌 이미지를 누르는 것이었고, 그것이 효과를 낸 이유도 압축이 잘돼서가 아니라 그 PNG 세 장이 사실상 압축되지 않은 채로 저장돼 있었기 때문이었습니다. 23개 파일을 하나씩 두 번 받아서(Accept-Encoding을 켜고 끄고) 크기와 헤더를 비교했고, 서버 설정과 업로드 폴더의 PNG 370장까지 확인했습니다.
글 한 편이 부르는 파일 23개
측정 대상은 SQL 쿼리를 세었던 글의 주소입니다. 같은 서버 밖에 있는 다른 서버에서 curl로 받았고, 각 파일마다 압축 없이 한 번, --compressed로 한 번씩 받아 두 값을 비교했습니다. 종류별로 묶으면 이렇습니다.
| 종류 | 개수 | 압축 전 | 전송량 | 절감 |
|---|---|---|---|---|
| HTML | 1 | 112,061B | 24,997B | 77.7% |
| CSS | 8 | 124,155B | 21,981B | 82.3% |
| 자체 JS | 4 | 58,152B | 20,860B | 64.1% |
| 외부 JS(광고) | 1 | 206,369B | 206,368B | 0.0% |
| 이미지 | 9 | 5,307,843B | 2,266,524B | 57.3% |
| 합계 | 23 | 5,808,580B | 2,540,730B | 56.3% |
파일 개수는 CSS·JS를 세었던 때보다 늘었습니다. 그때는 스타일과 스크립트 13개만 셌고, 이번에는 화면에 실제로 뜨는 이미지까지 넣었기 때문입니다. 이 글에는 본문 그래프 두 장이 있고 화면 아래쪽 관련 글 여섯 편의 썸네일이 함께 내려옵니다.
표에서 이미 눈에 걸리는 것이 두 가지 있습니다. 이미지 9장이 5.3MB로 페이지 전체의 91%를 차지한다는 것, 그리고 그 이미지가 57.3%나 줄었다는 것입니다. PNG는 파일 안에서 이미 압축된 형식이라 gzip을 한 번 더 걸어도 보통 줄지 않습니다.
줄어든 3.2MB의 출처
이미지 9장을 한 장씩 펼치면 그 57.3%가 어디서 나왔는지 바로 보입니다.
| 이미지 | 압축 전 | 전송량 | 절감 |
|---|---|---|---|
| 관련 글 썸네일 A | 1,275,326B | 273,690B | 78.5% |
| 관련 글 썸네일 B | 1,275,326B | 267,561B | 79.0% |
| 관련 글 썸네일 C | 1,275,326B | 248,934B | 80.5% |
| 대표이미지 | 495,250B | 494,642B | 0.1% |
| 관련 글 썸네일 D | 406,862B | 406,945B | -0.02% |
| 관련 글 썸네일 E | 381,194B | 381,229B | -0.01% |
| 관련 글 썸네일 F | 135,797B | 135,130B | 0.5% |
| 본문 그래프 | 60,005B | 55,636B | 7.3% |
| 댓글 아바타(외부) | 2,757B | 2,757B | 0.0% |

위의 세 장만 합쳐 3,825,978B가 790,185B가 됐습니다. 절감 3,035,793B입니다. 페이지 전체 절감 3,267,850B의 92.9%가 이 세 장에서 나왔습니다. 나머지 이미지 다섯 장은 1,479,108B에서 1,473,582B로, 다 합쳐 5,526B 줄었습니다. 텍스트 파일 13개(HTML·CSS·자체 JS)의 절감을 전부 더해도 226,530B로, 세 장이 만든 절감의 7.5%입니다.
압축했더니 오히려 커진 파일
표에서 절감이 음수인 줄이 두 개 있습니다. 406,862B짜리가 406,945B로, 381,194B짜리가 381,229B로 나갔습니다. 각각 83바이트, 35바이트가 붙었습니다. gzip은 눌리지 않는 데이터를 만나면 원본을 그대로 담고 형식이 요구하는 머리말과 검사값만 덧붙입니다. 압축이 아니라 포장만 한 셈입니다. 두 파일은 이 과정에서 서버 CPU를 쓰고 바이트를 더 얹었습니다.
설정 한 줄이 전부를 누르고 있었다
PNG에 압축이 걸린 이유는 아파치 설정에 있었습니다. httpd.conf의 압축 항목은 이렇게 돼 있었습니다.
SetOutputFilter DEFLATE
AddOutputFilterByType DEFLATE text/html text/plain text/xml
text/css text/javascript application/javascript application/json
DeflateCompressionLevel 6
두 줄이 나란히 있지만 하는 일이 다릅니다. 두 번째 줄은 나열한 타입에만 압축 필터를 붙이라는 뜻이고, 첫 번째 줄은 타입을 가리지 말고 이 위치의 모든 출력에 붙이라는 뜻입니다. 첫 줄이 있으면 두 번째 줄은 이미 걸린 필터를 한 번 더 지정하는 것에 가깝습니다. 결과적으로 이 서버는 나가는 거의 모든 응답을 gzip으로 눌러 왔습니다.
목록에 없는 타입도 압축돼 나옵니다
설정이 그렇게 동작하는지는 응답 헤더로 바로 확인됩니다. 타입 목록에 없는 주소 몇 개를 받아 봤습니다.
| 주소 | Content-Type | 타입 목록에 있음 | Content-Encoding |
|---|---|---|---|
| 사이트맵 | application/xml | 아니오 | gzip |
| 본문 그래프 PNG | image/png | 아니오 | gzip |
| robots.txt | text/plain | 예 | gzip |
| REST 응답 | application/json | 예 | gzip |
목록에 text/xml은 있지만 application/xml은 없고, 이미지 타입은 아예 없습니다. 그런데도 둘 다 content-encoding: gzip을 달고 왔습니다. 목록이 아니라 첫 줄이 판단하고 있다는 증거입니다. 참고로 mod_brotli는 모듈로 올라와 있지만 설정이 없어 동작하지 않습니다. Accept-Encoding: br만 보내면 본문이 112,273B, 즉 압축 없이 그대로 옵니다.
소용없는 압축에 붙는 시간
압축은 공짜가 아닙니다. 세 파일을 압축 없이 일곱 번, 압축을 켜고 일곱 번 받아 중앙값을 비교했습니다.
| 파일 | 압축 없이 TTFB | gzip TTFB | 차이 | 줄어든 바이트 |
|---|---|---|---|---|
| 대표이미지 495KB | 20.0ms | 25.1ms | +5.1ms | 608B |
| 썸네일 407KB | 21.9ms | 25.2ms | +3.3ms | -83B |
| 썸네일 1,275KB | 18.9ms | 34.0ms | +15.1ms | 1,001,636B |
서버에서 직접 재도 비슷합니다. 495KB PNG를 gzip 6단계로 누르는 데 16.3ms, 407KB는 13.2ms, 1,275KB는 55.9ms가 걸렸습니다. 세 번째 줄은 15.1ms를 더 쓰고 1MB를 아꼈으니 명백히 이득입니다. 앞의 두 줄은 3~5ms를 쓰고 얻은 것이 없습니다. 압축 결과는 저장되지 않으므로 이 계산은 요청이 올 때마다 반복됩니다.
원인은 서버가 아니라 저장 방식이었다
여기까지 보면 문제가 아파치 설정 같지만, 진짜 이상한 것은 PNG 세 장이 78~80%나 눌렸다는 쪽입니다. 정상적으로 저장된 PNG라면 나올 수 없는 수치입니다. 업로드 폴더의 PNG 370장을 열어 파일 안의 압축 강도 표시를 읽었습니다. PNG는 화소 데이터를 zlib으로 담는데, 그 데이터 앞 두 바이트에 어느 강도로 눌렀는지가 적혀 있습니다.
| 압축 강도 | 파일 수 | 원본 합계 | gzip 후 | 절감 |
|---|---|---|---|---|
| 최저(0) | 10 | 12,753,260B | 2,652,506B | 79.2% |
| 기본(2) | 61 | 24,204,466B | 24,210,630B | -0.03% |
| 최대(3) | 299 | 30,193,050B | 30,070,680B | 0.4% |
370장 중 10장만 최저 강도였습니다. 이 열 장은 전부 652×488 크기에 파일 크기가 1,275,326B로 정확히 같습니다. 652 × 488 × 4바이트에 줄마다 붙는 1바이트를 더하면 1,273,192B로, 파일 안 화소 데이터 1,273,388B와 거의 일치합니다. 압축을 걸지 않고 화소를 그대로 담은 파일이라는 뜻입니다. 이미지 하나를 만드는 도구가 압축 강도를 0으로 두고 저장했고, 워드프레스가 그 원본을 그대로 보관하고 있었습니다.
워드프레스가 만든 축소본은 사정이 다릅니다. 같은 원본에서 나온 300×225 파일은 72,007B였습니다. 축소본은 워드프레스가 자기 설정으로 다시 저장하기 때문에 정상적으로 눌려 있습니다. 문제가 되는 것은 원본뿐입니다.
- 10장을 다시 저장하면 12.7MB가 2.7MB 근처가 됩니다. 서버 용량으로 10MB, 그리고 이 파일들이 화면에 뜰 때마다 서버가 반복하던 압축 계산이 함께 사라집니다.
- 나머지 360장에는 손댈 것이 없습니다. 이미 최대나 기본 강도로 저장돼 있고, gzip을 걸어도 0.4% 안쪽입니다. 그중 211장은 눌러도 크기가 그대로이거나 오히려 커집니다.
- 설정은 타입을 가리게 바꾸는 것이 맞습니다. 첫 줄을 빼고 타입 목록만 남기면 텍스트만 눌립니다. 이 페이지 기준으로 눌러야 할 것은 텍스트 13개, 그냥 보내야 할 것은 이미지 9개입니다.
두 번째 방문에서 다시 재면
지금까지는 처음 온 방문자 기준입니다. 같은 사람이 다시 오면 브라우저는 가지고 있는 파일의 날짜를 붙여 물어보고, 서버는 바뀐 게 없으면 본문 없이 304만 돌려줍니다. 23개 파일에 그 조건부 요청을 그대로 보내 봤습니다.
23개 중 21개가 304로 돌아왔습니다. 남은 두 개는 본문 HTML과 외부 광고 스크립트입니다. HTML은 cache-control: no-cache, must-revalidate라 매번 새로 받는 것이 맞고, 광고 스크립트는 다른 서버의 정책을 따릅니다. 재방문 시 실제 전송량은 231,366B로, 첫 방문 2,540,730B의 9.1%였습니다. 그중 206,368B가 광고 스크립트 한 개입니다.
정적 파일에는 cache-control: public, max-age=31536000, immutable이 붙어 있었습니다. 1년 동안 다시 묻지 말라는 뜻이라 사실 브라우저는 위의 조건부 요청조차 보내지 않습니다. 그래서 재방문 비용의 실체는 231KB가 아니라 그보다 더 작습니다.
고칠 순서
같은 페이지를 재는 방법을 바꿨더니 앞선 두 번의 측정과 다른 것이 보였습니다. 파일 개수를 셀 때는 CSS 8개가 눈에 띄었고, 쿼리를 셀 때는 관련 글 영역이 눈에 띄었습니다. 바이트로 재니 둘 다 반올림하면 사라지는 크기였고, 페이지의 91%는 이미지였습니다.
순서는 분명합니다. 최저 강도로 저장된 PNG 10장을 다시 저장하는 것이 먼저입니다. 파일 열 개를 건드리는 일로 12.7MB가 2.7MB가 되고, 그동안 서버가 요청마다 대신 해 주던 일이 없어집니다. 그다음이 압축 대상을 타입으로 제한하는 설정 변경입니다. 페이지 무게는 그대로지만 이미지마다 붙던 3~5ms와 그만큼의 CPU가 빠집니다. 관련 글 썸네일이 원본 크기로 내려오는 것은 그다음 문제입니다.
압축률 56.3%라는 숫자 하나만 봤다면 이 서버의 압축 설정은 잘 잡혀 있는 것으로 남았을 것입니다. 항목을 갈라 보니 그 숫자는 압축이 잘된 결과가 아니라, 압축되지 않은 파일 세 장이 섞여 있다는 신호였습니다.