PNG 열 장을 4분의 1로 줄이고 이미지 gzip을 껐더니, 실제 전송량은 0.5%만 줄었다
어제 이 블로그의 정적 파일을 재면서 두 가지를 찾았습니다. 업로드된 PNG 열 장이 압축되지 않은 채로 저장돼 있었고, 아파치가 타입을 가리지 않고 모든 응답을 gzip으로 누르고 있었습니다. 오늘 둘 다 고쳤습니다. 열 장은 12.7MB에서 2.6MB가 됐고, 이미지에 붙던 gzip은 사라졌습니다.
그리고 같은 주소들을 다시 재 봤더니, 실제 전송량은 2,540,730바이트에서 2,528,074바이트로 12,656바이트, 0.5% 줄었습니다. 파일에서 덜어낸 10.2MB가 전송량에는 거의 나타나지 않았습니다. 원래 그 10.2MB는 gzip이 매 요청마다 대신 눌러 주고 있었기 때문입니다. 고치기 전과 후를 같은 방법으로 재지 않았다면 “1MB짜리를 4분의 1로 줄였다”까지만 보고 끝났을 일입니다.
고친 것 하나 — PNG 열 장 다시 저장
PNG는 화소를 zlib으로 눌러 담습니다. 그 눌린 덩어리 앞 두 바이트에 어느 강도로 눌렀는지가 적혀 있는데, 문제의 열 장은 그 값이 최저였습니다. 눌러 담는 자리에 누르지 않고 담은 파일입니다. 고치는 방법은 화소를 그대로 두고 다시 눌러 담는 것뿐입니다.
이미지 편집 도구를 쓰지 않고 파일 구조를 직접 다뤘습니다. 도구를 거치면 화소가 미세하게 바뀔 여지가 생기고, 그러면 “무손실”이라고 말할 수 없기 때문입니다. 절차는 이렇습니다.
1. IDAT 덩어리를 모아 압축을 푼다 → 필터가 걸린 줄 데이터
2. 필터를 풀어 원래 화소를 얻는다 → 기준값으로 보관
3. 다시 필터를 걸고 zlib 9로 누른다
4. 새로 만든 것을 다시 풀어 2번과 대조 → 한 바이트라도 다르면 중단
5. 파일로 쓴 뒤 그 파일을 다시 읽어 또 대조
열 장 모두 2번과 5번의 대조를 통과했습니다. 압축을 푼 화소 데이터는 1,273,192바이트로, 652 × 488 × 4바이트에 줄마다 붙는 필터 표시 1바이트씩을 더한 값과 정확히 같습니다.
| 파일 | 전 | 후 | 절감 |
|---|---|---|---|
| image-260706133738 | 1,275,326B | 242,964B | 80.9% |
| image-260709184556 | 1,275,326B | 246,628B | 80.7% |
| image-260706133323 | 1,275,326B | 250,719B | 80.3% |
| image-260709190235 | 1,275,326B | 255,268B | 80.0% |
| image-260706121615 | 1,275,326B | 256,815B | 79.9% |
| image-260709190357 | 1,275,326B | 261,433B | 79.5% |
| image-260709185342 | 1,275,326B | 262,175B | 79.4% |
| image-260706185049 | 1,275,326B | 264,265B | 79.3% |
| image-260706122607 | 1,275,326B | 267,473B | 79.0% |
| image-260703192436 | 1,275,326B | 281,654B | 77.9% |
| 합계 | 12,753,260B | 2,589,394B | 79.7% |
필터는 이미 제대로 골라져 있었습니다
PNG는 누르기 전에 줄마다 필터를 하나씩 고릅니다. 왼쪽 화소와의 차이만 남길지, 윗줄과의 차이만 남길지 같은 선택입니다. 잘 고르면 뒤따르는 압축이 훨씬 잘 먹습니다. 저장이 엉성한 파일이라면 이쪽도 손봐야 할 것 같아, 줄마다 다섯 가지를 전부 계산해 가장 좋은 것을 다시 고르는 것도 함께 해 봤습니다.
결과는 원본과 완전히 같았습니다. 488줄의 필터 선택이 한 줄도 다르지 않았고, 따라서 최종 크기도 바이트 단위로 같았습니다. 한 장의 예를 들면 Paeth 필터 422줄, Up 63줄, Sub 2줄, Average 1줄로 원본과 재선택이 일치했습니다.
이 파일들을 만든 도구는 비싼 일은 제대로 하고 싼 일을 건너뛴 셈입니다. 줄마다 다섯 가지를 계산해 고르는 것이 무거운 작업이고, 그 뒤에 zlib 강도를 지정하는 것은 값 하나를 넘기는 일입니다. 그 값 하나가 기본값이 아니었습니다. 다시 저장하는 데 걸린 시간은 한 장에 4초 안팎이었는데, 그 4초의 대부분도 검증용 필터 재계산에 쓴 것이고 실제 압축은 그보다 훨씬 짧습니다.
덤으로 줄어든 것이 하나 더 있습니다. 원본은 눌린 데이터를 156개 덩어리로 쪼개 담고 있었습니다. 덩어리마다 길이·이름·검사값으로 12바이트가 붙습니다. 새로 쓴 파일은 덩어리가 하나여서, 껍데기 155개분 1,860바이트가 함께 빠졌습니다.
고친 것 둘 — 설정 한 줄
아파치 쪽은 어제 확인한 그대로 첫 줄이 문제였습니다. 그 줄을 주석 처리하고, 대신 타입 목록에 빠져 있던 것 몇 개를 넣었습니다.
#SetOutputFilter DEFLATE
AddOutputFilterByType DEFLATE text/html text/plain text/xml
text/css text/javascript application/javascript application/json
application/xml application/rss+xml image/svg+xml
application/xml을 넣은 것은 사이트맵 때문입니다. 어제 사이트맵이 gzip으로 나가던 것은 목록에 있어서가 아니라 첫 줄 덕분이었으므로, 첫 줄을 빼면 사이트맵이 압축 없이 나가게 됩니다. 목록에는 text/xml만 있었고 실제 응답 타입은 application/xml이었습니다.
바꾼 뒤 문법 검사를 하고 다시 읽혔습니다. 확인해 보니 HTML·사이트맵·robots.txt·ads.txt는 그대로 gzip으로 나오고, 이미지에는 압축 표시가 사라졌습니다.
같은 주소 23개를 다시 재면
여기서 한 가지를 짚고 가야 합니다. 글 아래 관련 글은 무작위로 뽑힙니다. 그래서 같은 주소를 새로고침할 때마다 딸려 오는 썸네일이 달라지고, 페이지 전체 무게도 요청마다 달라집니다. 어제 잰 페이지에는 문제의 PNG가 세 장 들어 있었지만 오늘 같은 주소를 열면 다른 그림이 나옵니다. 페이지 단위로 어제와 오늘을 비교하면 고친 효과가 아니라 제비뽑기 결과를 재게 됩니다.
그래서 어제 실제로 받았던 주소 23개를 그대로 적어 두고, 오늘 같은 목록을 다시 받았습니다.
| 항목 | 어제 | 오늘 | 차이 |
|---|---|---|---|
| 압축 풀었을 때 합계 | 5,808,580B | 2,756,216B | -52.5% |
| 실제 전송량 | 2,540,730B | 2,528,074B | -0.5% |
| gzip이 붙은 응답 | 21개 | 13개 | -8 |
| 이미지에 붙은 gzip | 8개 | 0개 | -8 |

왼쪽 줄과 오른쪽 줄의 대비가 이 글의 전부입니다. 파일에서 3MB가 없어졌지만 선을 타고 가는 양은 12,656바이트 줄었습니다. 그 12,656바이트도 파일이 작아져서가 아니라, 파일에 걸린 압축 강도가 아파치가 매번 쓰던 강도보다 높기 때문에 생긴 차이입니다.
그래서 무엇이 남았는가
전송량이 그대로라면 고칠 필요가 없었느냐 하면, 그렇지 않습니다. 얻은 것은 다른 자리에 있습니다.
| 파일 | 어제 gzip | 오늘 | 어제 압축 없이 |
|---|---|---|---|
| 그래프 495KB | 25.1ms | 20.7ms | 20.0ms |
| 썸네일 407KB | 25.2ms | 20.8ms | 21.9ms |
| 썸네일 1,275KB → 267KB | 34.0ms | 22.6ms | 18.9ms |
첫 바이트가 오기까지의 시간이 이미지마다 4~11ms 줄었습니다. 서버가 요청마다 하던 압축 계산이 없어졌기 때문입니다. 495KB 한 장을 누르는 데 16.3ms, 1,275KB 한 장에 55.9ms가 걸리고 있었는데, 그 계산은 결과를 저장하지 않으므로 같은 그림을 백 번 요청하면 백 번 반복됩니다.
- 디스크 10,163,866바이트. 업로드 폴더의 PNG 390장 합계가 70,135,947B에서 59,972,081B가 됐습니다. 백업본까지 고려하면 실제로 회수한 것은 그보다 적지만, 백업은 지우면 되는 것입니다.
- 요청마다 반복되던 압축이 이미지에서 사라졌습니다. 지금 이 폴더 전체에 gzip을 걸어 봐야 0.2%가 줄어듭니다. 어제는 14.6%였습니다. 열 장이 빠지자 남은 380장이 원래부터 압축할 것이 없는 파일이었다는 사실이 드러납니다.
- 브라우저 쪽 일도 줄었습니다. 압축돼 온 이미지는 받는 쪽에서 다시 풀어야 합니다. 이제 그 과정이 없습니다.
고치고 나서 확인한 것
이미지 파일을 바꾸는 작업이라 확인할 것이 몇 가지 있었습니다.
| 확인 | 방법 | 결과 |
|---|---|---|
| 화소가 바뀌지 않았는가 | 필터 해제 후 바이트 대조 | 10장 모두 일치 |
| 파일이 깨지지 않았는가 | 내려받아 덩어리별 검사값 확인 | 이상 없음 |
| 크기 정보가 맞는가 | 워드프레스 첨부 정보 갱신 | 10건 갱신 |
| 화면이 정상인가 | 홈·글·사이트맵 응답 확인 | 모두 200 |
워드프레스는 첨부 파일마다 크기를 따로 적어 둡니다. 파일만 바꾸면 그 값이 옛날 크기로 남아 있게 되므로 열 건을 함께 고쳤습니다. 가로세로는 그대로이므로 목록과 본문에 뜨는 그림의 크기나 srcset은 손댈 것이 없었습니다. 원본은 날짜를 붙인 폴더에 그대로 보관해 두었습니다.
이 측정에서 배운 것
고치기 전에 재는 것보다 고친 뒤에 같은 방법으로 다시 재는 것이 어려웠습니다. 관련 글이 무작위라는 사실은 쿼리를 세었을 때도 보이던 것인데, 그때는 개수만 봤기 때문에 문제가 되지 않았습니다. 바이트를 재기 시작하니 같은 주소가 요청마다 다른 값을 내놓는 것이 곧바로 걸림돌이 됐습니다. 재는 대상이 바뀌면 어떤 개선도 증명할 수 없습니다.
결과 자체도 예상과 달랐습니다. 1.2MB짜리 열 장을 4분의 1로 줄이는 일은 크게 보였지만, 그 앞단에서 gzip이 이미 같은 일을 하고 있었습니다. 눈에 보이는 파일 크기와 실제로 나가는 바이트가 다른 층에 있었던 것입니다. 이번에 줄어든 것은 전송량이 아니라 서버가 매 요청 되풀이하던 계산이었고, 그건 파일 크기를 보고는 알 수 없는 종류의 낭비였습니다.