글 한 편 5.8MB가 gzip으로 2.5MB, 그런데 줄어든 3.2MB 중 3.0MB는 PNG 세 장에서 나왔다

글 한 편 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%
gzip이 줄인 3.2MB의 출처 막대그래프. 압축 없이 저장된 PNG 3장 2,964.6KB, CSS 8개 99.8KB, HTML 85KB, 자체 JS 36.4KB, 나머지 이미지 5장 5.4KB
절감의 92.9%가 PNG 세 장에서 나왔습니다. 텍스트 13개를 전부 더해도 그 7.5%입니다.

위의 세 장만 합쳐 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%라는 숫자 하나만 봤다면 이 서버의 압축 설정은 잘 잡혀 있는 것으로 남았을 것입니다. 항목을 갈라 보니 그 숫자는 압축이 잘된 결과가 아니라, 압축되지 않은 파일 세 장이 섞여 있다는 신호였습니다.

Similar Posts

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다