이미지를 압축했는데 용량이 커지는 이유 3가지
쓰고 있는 압축 도구는 아마 멀쩡합니다. 이미지 압축이 안 될 때, 그러니까 결과물이 넣은 것과 크기가 같거나 눈에 띄게 더 커져서 돌아올 때, 원인은 거의 언제나 셋 중 하나입니다. 그 목록에 “도구 고장”은 없습니다.
가장 흔한 원인은 브라우저의 canvas 요소입니다. canvas를 거쳐 압축하는 도구는 PNG의 color type을 버리고 모든 픽셀을 32비트 RGBA로 다시 인코딩합니다. 저희는 이 사이트의 Open Graph 카드 PNG 83개 전부를 Chrome 151.0.0.0의 canvas.toBlob('image/png')에 통과시켰습니다. 83개가 하나도 빠짐없이 더 커져서 돌아왔습니다. 증가율 중앙값은 +76.6%, 최소는 +35.6%, 최대는 +237.2%였습니다.
두 번째 원인은 JPEG가 이미 압축되어 있다는 것입니다. 품질 0.8로 다섯 번 연달아 돌리면 두 번째 회차부터 파일 크기가 멈추지만, 회차마다 그림은 계속 상합니다.
세 번째는 PNG가 이미 양자화되어 있다는 것입니다. 두 번째 회차가 걷어 갈 색상 중복이 남아 있지 않습니다.
이 셋 중 어느 것도 품질 슬라이더를 더 낮춘다고 해결되지 않습니다. 해법은 다른 포맷이거나 다른 인코더입니다. 같은 PNG 83개를 품질 0.8의 WebP로 변환하자 전부 작아졌고 중앙값은 **−94.2%**였습니다.
이 수치를 만들어 낸 환경입니다. Playwright로 구동한 Chrome 151.0.0.0, macOS 26.5.2, Node v25.8.2, upng-js 2.1.0, ImageMagick. 83개 파일 묶음은 이 사이트
public/og디렉터리에 있는 PNG 전부이며, 그중 일부를 뽑은 표본이 아닙니다. 여기에 1200×630과 800×600 크기의 통제된 파일 4개를 더해 사진, 그래픽, 팔레트, JPEG의 경우를 각각 따로 다뤘습니다.
1. 이미지 압축이 안 될 때: 30초 분류표
자기 상황에 해당하는 행을 찾은 다음, 그 행이 가리키는 절을 읽으세요.
| 넣은 것 | 나온 것 | 근본 원인 | 볼 곳 |
|---|---|---|---|
| PNG 스크린샷 또는 그래픽 | 원본보다 커짐 | canvas가 32비트 RGBA로 다시 인코딩함 | 2절 |
| JPEG 사진 | 원본보다 커짐 | PNG로 저장됨 | 2절 |
| JPEG 사진 | 거의 변화 없음 | 이미 크기 정체 구간에 있음 | 3절 |
| 압축기를 이미 한 번 거친 PNG | 변화 없거나 약간 커짐 | 남은 여유가 없음 | 4절 |
| 아무 이미지나 | 작아졌지만 흐리거나 색이 어긋남 | 세대 손실 또는 ICC 프로파일 제거 | 3절과 6절 |
각 행은 서로 배타적이지 않습니다. JPEG 사진을 canvas 기반 PNG 내보내기에 넣으면 앞의 두 행에 한꺼번에 걸리고, 47,828바이트짜리 파일이 809,415바이트가 되는 일이 그렇게 벌어집니다.
진단은 건너뛰고 일단 더 작은 파일부터 얻고 싶다면, 저희 이미지 압축기는 canvas 왕복 대신 양자화 PNG 인코더를 돌리며, 결과가 업로드한 파일보다 작지 않으면 자기 출력을 버립니다.
2. 원인 하나: 브라우저 canvas는 언제나 32비트 RGBA를 씁니다
canvas.toBlob()이 PNG에 실제로 하는 일
canvas 왕복에는 압축 단계가 없습니다. 디코딩과 재인코딩이 있을 뿐이고, 픽셀 값이 아닌 모든 것은 그 사이에서 버려집니다.
이미지를 canvas에 그리면 브라우저는 그것을 평평한 RGBA 버퍼로 디코딩합니다. 픽셀당 4바이트이고, 팔레트도 비트 심도 기교도 메타데이터도 남지 않습니다. 그다음 HTMLCanvasElement.toBlob()이 그 버퍼를 처음부터 다시 인코딩합니다. PNG 명세는 여섯 가지 color type을 정의하고, PNG 인코더는 이미지를 표현할 수 있는 가장 값싼 것을 마음대로 고를 수 있습니다. Chrome의 canvas 인코더는 고르지 않습니다. 언제나 color type 6을 내보냅니다.
| 들어간 color type | canvas.toBlob('image/png')이 돌려준 것 |
|---|---|
| RGB (color type 2) | RGBA (color type 6) |
| 팔레트 (color type 3) | RGBA (color type 6) |
| JPEG (PNG color type 없음) | RGBA (color type 6) |
이것은 바이트에서 직접 읽어 낼 수 있습니다. PNG 파일의 25번째 바이트가 비트 심도이고 26번째 바이트가 color type이며, 둘 다 IHDR 청크 안에 있습니다.
xxd -s 24 -l 2 -p suspect.png
# 0806 -> 비트 심도 8, color type 6 (RGBA)
위의 세 가지 입력 유형 모두 depth=8 type=6이 나왔습니다. 64색 팔레트 이미지는 픽셀당 1바이트에 작은 표 하나를 더해 저장하지만, 왕복을 거치고 나면 픽셀당 4바이트를 저장하고 표는 사라집니다. Deflate가 그중 일부는 되찾아 주지만, 전부는 아닙니다.
브라우저 콘솔에 이 스니펫을 붙여 넣으면 직접 재현할 수 있습니다. 파일을 하나 고르고, 왕복시킨 다음, 두 크기와 color type을 함께 출력합니다.
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/png,image/jpeg';
input.onchange = async () => {
const file = input.files[0];
const bitmap = await createImageBitmap(file);
const canvas = document.createElement('canvas');
canvas.width = bitmap.width;
canvas.height = bitmap.height;
canvas.getContext('2d').drawImage(bitmap, 0, 0);
const blob = await new Promise((r) => canvas.toBlob(r, 'image/png'));
const head = new Uint8Array(await blob.slice(0, 26).arrayBuffer());
console.log(file.name, file.size, '->', blob.size);
console.log('bit depth', head[24], 'color type', head[25]);
};
input.click();
toBlob에 넘기는 품질 인자는 image/png에서 무시됩니다. PNG는 무손실이라 품질 숫자가 내줄 것이 애초에 없고, canvas 기반 도구에서 PNG 압축을 조절하는 것처럼 보이는 슬라이더는 아무것도 조절하지 않습니다.
83개를 측정한 결과, 압축한 이미지가 전부 원본보다 커졌습니다
유리한 것만 고르지 않고 실행 결과 전체를 싣습니다.
| 측정 항목 | 값 |
|---|---|
| 테스트한 파일 | 83개 (public/og에 있는 PNG 전부, RGB와 팔레트 color type 모두 포함) |
| 커진 파일 | 83 / 83 (100%) |
| 최소 증가율 | +35.6% |
| 증가율 중앙값 | +76.6% |
| 최대 증가율 | +237.2% |
aes-decrypt.png 한 파일만 떼어 보면 505,516 B에서 898,014 B가 되어 +77.6% 늘었습니다. 같은 파일을 품질 0.8의 WebP로 인코딩하면 27,188 B, **−94.6%**입니다.
표본의 경계를 정확히 읽으십시오. 이 83개 파일은 Open Graph 카드입니다. 1200×630, 단색 배경, 큰 글자, 브랜드 색 몇 가지로 이뤄져 있습니다. 좋은 PNG 인코더가 잘 다루는 바로 그 종류의 그래픽이고, canvas 왕복이 이들을 유독 심하게 망가뜨리는 이유도 거기에 있습니다. 이것은 그래픽형 PNG에 대한 강한 결과이지, 세상의 모든 PNG가 canvas 왕복에서 커진다는 주장이 아닙니다. 처음부터 온전한 RGBA로 저장되어 있던 사진형 PNG는 잃을 것이 훨씬 적습니다.
압축한 이미지가 원본보다 크고 그 도구가 브라우저 탭에서 돌아간다면, 가장 먼저 들여다볼 곳은 설정이 아니라 인코더입니다.
JPEG를 PNG로 저장하면 16.9배가 되는 이유
통제된 파일 4개를 모두 같은 canvas 경로에 통과시켰습니다.
| 파일 | 성격 | 원본 | canvas PNG | 변화 | JPEG q92 | JPEG q80 | WebP q80 |
|---|---|---|---|---|---|---|---|
og-a.png | 그래픽, 2,351색, RGB | 306,302 | 607,481 | +98.3% | 56,459 | 37,695 | 13,852 |
quantized.png | 64색 팔레트 | 59,843 | 184,856 | +208.9% | 78,388 | 44,442 | 16,046 |
photo.png | 사진, 479,373색 | 498,639 | 867,763 | +74.0% | 77,321 | 45,050 | 24,068 |
photo.jpg | JPEG q82 | 47,828 | 809,415 | +1,592% (16.9배) | 58,762 (+22.9%) | 47,152 | 23,862 |
마지막 행은 들어갈 때 47,828바이트, 나올 때 809,415바이트입니다.
이 원리 하나가 “압축했더니 오히려 커졌다”는 제보 한 무리를 통째로 설명합니다. JPEG는 손실 주파수 영역 코덱입니다. 8×8 블록을 DCT 계수로 변환하고, 그 계수를 과감하게 양자화한 뒤, 살아남은 것만 저장합니다. PNG는 무손실 공간 영역 코덱입니다. 각 픽셀을 이웃 픽셀로부터 예측하고 그 잔차를 deflate로 압축합니다. JPEG를 디코딩하면 양자화기가 집어넣은 아티팩트를 전부 짊어진 픽셀이 나옵니다. 가장자리 주변에는 링잉이 남고, 매끄러운 그러데이션에는 블록이 생기며, 원본에 없던 미세한 잡음도 함께 들어옵니다.
그 픽셀을 PNG로 저장한다는 것은 무손실 코덱에게 그 아티팩트를 완벽하게 저장해 달라고 부탁하는 일입니다. 코덱은 부탁대로 해 줍니다. 파일을 작게 만들려고 JPEG가 만들어 낸 바로 그 잡음이, 이제는 PNG를 크게 만드는 원인이 됩니다. 예측 기반 무손실 인코더가 압축하지 못하는 것이 바로 잡음이기 때문입니다.
사진 앞에 “PNG로 저장”이 기본값으로 놓여 있는 곳이라면 어디서든 이 일이 벌어집니다. 스크린샷 도구, 디자인 내보내기, 채팅 클라이언트, 일부 업로드 위젯이 그렇습니다.
같은 픽셀인데 3.46배 차이, 둘 다 무손실
canvas 인코더가 얼마나 약한지는 숫자로 잴 수 있습니다. og-a.png의 픽셀 버퍼를 가져와 두 가지 방식으로, 둘 다 완전 무손실로 인코딩해 보겠습니다.
| 인코더 | 출력 | color type |
|---|---|---|
Chrome canvas toBlob('image/png') | 607,481 B | RGBA (강제 확장) |
upng-js encode(..., cnum=0) | 175,491 B | RGB (원래 color type 유지) |
똑같은 픽셀에 3.46배입니다. 무손실이라는 말은 가정하지 않고 직접 확인했습니다. cnum=0으로 만든 upng-js 출력을 다시 디코딩하면 입력 RGBA 버퍼와 바이트 단위로 동일한 버퍼가 돌아옵니다. 그 3.46배를 얻는 대가로 내준 것은 아무것도 없습니다.
둘 다 무료이고 둘 다 같은 것을 내세우는 온라인 압축기 두 개가 왜 비교조차 되지 않는 결과를 내놓을까요? “브라우저에서 압축한다”는 말이 완전히 다른 두 가지 구현을 가리키기 때문입니다. 하나는 픽셀을 canvas.toBlob에 넘기고 돌아온 것을 그대로 내보냅니다. 다른 하나는 진짜 PNG 인코더를 품고 있고 color type을 직접 제어합니다. 입력도 브라우저도 같은데 결과는 3.46배 차이입니다.
3. 원인 둘: 이미 압축된 JPEG에는 더 내줄 것이 없습니다
다섯 번 다시 압축해도 크기가 멈춥니다
이미 품질 82로 저장되어 있던 photo.jpg를 품질 0.8로 다섯 번 연달아 다시 압축했습니다. 각 세대의 출력이 다음 세대의 입력이 됩니다.
| 세대 | 바이트 | 직전 대비 |
|---|---|---|
| 0 (원본, q82) | 47,828 | — |
| 1 | 47,152 | −1.4% |
| 2 | 47,164 | +0.0% |
| 3 | 47,156 | −0.0% |
| 4 | 47,156 | 0.0% |
| 5 | 47,156 | 0.0% |
첫 회차가 벌어 주는 것은 1.4%입니다. 2세대부터는 파일 크기가 ±12바이트 폭 안에 묶여 있고, 4세대와 5세대는 크기가 3세대와 한 비트도 다르지 않습니다.
입력이 JPEG일 때 “압축이 안 된다”는 상황은 이런 모습입니다. 도구는 돌았습니다. 인코더도 돌았습니다. 그저 걷어 갈 것이 남아 있지 않았을 뿐입니다. 품질 80의 양자화 표가, 품질 82가 남겨 두었던 계수를 이미 대체로 0으로 만들고 있었기 때문입니다. 계수는 한 번 사라지면 다시 제거할 수 없습니다.
로컬에서 직접 눈으로 확인할 수 있습니다.
cp photo.jpg gen0.jpg
for i in 1 2 3 4 5; do
magick "gen$((i-1)).jpg" -quality 80 "gen$i.jpg"
done
stat -f%z gen0.jpg gen1.jpg gen2.jpg gen3.jpg gen4.jpg gen5.jpg
Linux에서는 stat -c%s를 쓰세요. 정확한 바이트 수는 ImageMagick 빌드가 어떤 인코더와 링크되어 있는지에 따라 달라지므로, 위 표가 숫자까지 그대로 재현되리라고 기대하지는 마세요. 중요한 것은 모양입니다. 의미 있는 하락이 한 번, 그다음은 평평한 선입니다.
품질 숫자를 올리는 것은 압축이 아닙니다
2절의 photo.jpg 행을 다시 보세요. 품질 82로 저장된 그 원본을 품질 92로 다시 압축하자 58,762바이트가 나왔습니다. **+22.9%**입니다.
품질 파라미터를 “작게”에서 “크게”까지 아무 데나 놓아도 되는 다이얼로 여기던 사람은 여기서 놀랍니다. 이 값은 절대적인 품질 목표가 아니라 양자화 표를 고르는 값입니다. 디코딩된 이미지를 그 이미지를 만들어 낸 표보다 더 조밀한 표에 다시 통과시키면, 기존 아티팩트를 더 정밀하게 저장하면서 그 위에 새로운 손실을 한 겹 더 얹습니다. 파일은 커지고 그림은 나빠지는 일이 동시에 일어납니다.
규칙은 간단합니다. JPEG를 저장할 때 쓴 품질보다 높은 설정으로 다시 압축하지 마십시오. 그 값이 무엇이었는지 모른다면 아예 다시 압축하지 말고 원본으로 돌아가십시오.
세대 손실: 눈에 보이지 않는 JPEG 재압축 화질 저하
정체 구간 표에는 함정이 하나 있습니다. 크기는 2세대부터 변하지 않았지만 이미지는 계속 변했습니다. 매 회차가 픽셀로 디코딩하고, 다시 변환하고, 다시 양자화합니다. 한 세대에서 경계선에 걸친 채 겨우 살아남은 계수는 다음 세대에서 경계 너머로 밀려납니다.
손상은 눈이 먼저 가는 자리에 나타나지 않습니다. 썸네일 크기에서는 5세대와 1세대를 구별할 수 없습니다. 100%로 확대해서 JPEG가 먼저 무너지는 자리를 확인하세요. 단색 배경 위의 날카로운 가장자리, 글자, 블록이 눈에 보이는 8×8 타일로 드러나는 매끄러운 그러데이션이 그런 자리입니다. 배포할 때마다 다시 압축하는 빌드 파이프라인에서는 이것이 몇 달 동안 소리 없이 쌓입니다.
여기서 저희가 측정한 것은 파일 크기이지 지각 품질이 아닙니다. 다섯 세대에 대한 PSNR이나 SSIM 수치를 제시하지 않는 것도 그래서입니다. 그래도 2세대부터는 비용이 전부 화질 쪽에 실리고 이득은 0이라는 사실은 크기 데이터만으로 충분히 드러납니다.
색이 어긋나는 것은 방아쇠는 같지만 별개의 고장입니다. canvas는 메타데이터를 전혀 싣지 않으므로 toBlob 왕복은 EXIF 블록과 ICC 프로파일을 함께 버립니다. Display P3나 Adobe RGB로 태그된 이미지가 들어가면 태그 없이 나오고, 뷰어는 그것을 sRGB로 해석합니다. 픽셀 값은 움직이지 않았습니다. 그 값을 해석하는 지침이 움직였습니다.
4. 원인 셋: 이미 양자화된 PNG는 용량이 줄지 않습니다
PNG가 이미 압축기를 한 번 거쳤다면 두 번째 회차에는 손댈 것이 없습니다. 앞서 나온 quantized.png 행이 바로 그 경우입니다. 이미 64색 팔레트로 줄여 둔 59,843바이트 파일을 서로 다른 세 경로로 돌려 봤습니다.
| 경로 | 결과 | 원본 대비 |
|---|---|---|
무손실 재인코딩 (upng cnum=0) | 61,377 | +2.6% |
| 256색으로 양자화 | 61,366 | +2.5% |
| 64색으로 양자화 | 61,366 | +2.5% |
세 경로 모두 원본보다 커졌습니다. 크게는 아니지만 어쨌든 커졌고, 여기에는 이미 64색인 파일을 64색으로 양자화한 경우까지 들어 있습니다.
PNG 압축은 중복을 걷어 내는 방식으로 작동합니다. 반복되는 색, 예측 가능한 이웃, 작은 팔레트가 그 중복입니다. 앞선 회차가 그것을 이미 다 걷어 갔습니다. 남은 것은 사실상 압축 불가능하고, 그 미세한 증가분은 인코더 자신의 오버헤드입니다. 팔레트 순서가 조금 달라지고, 스캔라인마다 필터 선택이 달라지고, deflate가 아주 조금 덜 운 좋게 걸린 결과입니다.
이미 최적화된 PNG에서 “0% 절감”이 나오는 것은 실패가 아니라 올바른 결과입니다. 약간 커졌다고 알려 준 다음 원본 파일을 그대로 지키는 도구는 제대로 동작하고 있습니다. 그런데도 더 커진 파일을 건네주는 도구는 그렇지 않습니다.
5. PNG는 재인코딩이 아니라 양자화로 줄입니다
무손실 재인코딩 vs 256색 vs 64색
파일 3개를 전략 3가지로 측정했습니다.
| 파일 | 원본 | 무손실 (cnum=0) | 256색 | 64색 |
|---|---|---|---|---|
og-a.png | 306,302 | 175,491 (−42.7%) | 110,772 (−63.8%) | 76,230 (−75.1%) |
photo.png | 498,639 | 587,863 (+17.9%) | 109,284 (−78.1%) | 61,397 (−87.7%) |
quantized.png | 59,843 | 61,377 (+2.6%) | 61,366 (+2.5%) | 61,366 (+2.5%) |
무손실 재인코딩은 쓸 수 있는 수단 가운데 가장 약합니다. 그래픽에서는 42.7%를 벌었지만, 사진에서는 17.9%를 도로 내줬고, 이미 양자화된 파일에서는 2.6%를 잃었습니다. 사진형 콘텐츠는 유능한 무손실 PNG 인코더조차 이겨 냅니다. 찾아낼 팔레트가 없고 이웃한 픽셀이 서로를 잘 예측하지 못하기 때문입니다.
실제 감량은 양자화에서 나오고, 차이도 큽니다. 같은 그래픽을 256색으로 양자화하면 63.8%인데 무손실은 42.7%이고, 64색이면 75.1%입니다. 명령줄에서는 이렇게 합니다.
magick logo.png -colors 64 PNG8:logo-64.png
magick logo.png -colors 256 PNG8:logo-256.png
PNG8: 접두사는 팔레트 PNG를 강제합니다. 이것이 없으면 ImageMagick은 색을 줄여 놓고도 트루컬러 파일로 쓸 수 있고, 그러면 이득의 대부분이 날아갑니다.
팔레트가 안전할 때와 색 띠가 생길 때
양자화는 손실 방식입니다. 모든 픽셀을 제한된 팔레트에서 가장 가까운 항목에 대응시키므로, 관건은 그 차이가 눈에 보일 만큼 서로 다른 색이 콘텐츠에 많은지입니다.
안전한 쪽은 아이콘, 로고, UI 스크린샷, 단색 일러스트, 도표, 그리고 균일한 색의 큰 영역과 날카로운 가장자리를 가진 모든 것입니다. 이런 이미지는 보통 많아야 수백 가지 색만 담고 있어서 256개짜리 팔레트는 거의 공짜이고 64색도 대개 버텨 냅니다.
위험한 쪽은 사진, 매끄러운 그러데이션, 부드러운 그림자, 반투명 오버레이입니다. 그러데이션을 64단계로 줄이면 눈에 보이는 띠가 생기고, 디더링은 그 띠를 잡음과 맞바꾸는데 그 잡음이 파일 크기를 다시 얼마간 잡아먹습니다. 그러데이션 위에 부분 투명도가 얹힌 경우가 그중에서도 가장 어렵습니다.
투명도는 따로 확인해야 합니다. 얼마나 살아남는지가 양자화 자체보다 인코더에 달려 있기 때문입니다. ImageMagick의 PNG8:은 이진 투명도를 쓰므로 픽셀은 완전 불투명 아니면 완전 투명이고, 부드럽게 안티에일리어싱된 가장자리는 딱딱하게 돌아옵니다. 전용 PNG 양자화기는 알파 채널 전체를 유지하고 그와 함께 부드러운 가장자리도 지킵니다. 소재에 그림자나 깃털 가장자리가 있다면 확정하기 전에 둘을 비교해 보세요.
결과는 썸네일이 아니라 100%에서 확인하세요. 색 띠는 축소된 미리보기가 가장 잘 감추는 아티팩트입니다.
6. 해결책: 다시 압축하지 말고 포맷을 바꾸세요
포맷 결정표
| 콘텐츠 | 선택 | 이유 |
|---|---|---|
| 사진 | WebP, 호환성이 최우선이면 JPEG | 사진에 필요한 것은 손실 주파수 인코딩 |
| 스크린샷, UI 그래픽 | 양자화 PNG 또는 WebP | 단색, 날카로운 가장자리, 작은 팔레트 |
| 아이콘과 로고 | 벡터가 있으면 SVG, 없으면 양자화 PNG | 벡터에는 해상도 문제가 없음 |
| 투명도가 필요한 모든 것 | WebP 또는 PNG | 둘 다 알파 채널 전체를 담음 |
| 애니메이션 | WebP | GIF 대신 포맷 하나로 해결 |
| 픽셀 단위 보존용 아카이브 | PNG, 무손실 | 무손실이 요구 조건인 유일한 경우 |
이 표는 일부러 짧게 두었습니다. 최신 포맷들의 인코딩 효율과 브라우저 지원은 WebP vs AVIF vs JPEG 비교에서 따로 다뤘습니다.
같은 83개를 WebP로 바꿨더니
canvas PNG를 거쳐 100% 커졌던 그 83개 묶음을, 이번에는 품질 0.8의 WebP로 변환했습니다. 83개 중 83개가 작아졌고 중앙값은 **−94.2%**입니다. 앞서 짚었던 파일 aes-decrypt.png는 505,516 B에서 27,188 B가 되어 **−94.6%**입니다.
통제된 파일들도 같은 방향을 가리킵니다. og-a.png는 PNG로 306,302, WebP q80으로 13,852입니다. photo.png는 PNG로 498,639, WebP q80으로 24,068입니다. photo.jpg는 JPEG로 47,828, WebP q80으로 23,862입니다.
마지막 비교에는 단서가 하나 붙습니다. 품질 0.8의 WebP는 손실 방식이므로 PNG와 대등한 조건에서 겨루는 것이 아니고, 기존 JPEG를 WebP로 다시 인코딩하는 일도 세대 하나를 대가로 치릅니다. 의미 있는 비교 대상은 가상의 완벽한 원본이 아니라 지금 막 내보내려던 그 파일입니다.
그래도 PNG를 지켜야 할 때
무손실이 취향이 아니라 요구 조건인 경우가 있습니다. 디자인 파이프라인으로 되돌아가 다시 편집될 소재, 픽셀 비교 테스트에 쓰이는 스크린샷, 색 하나만 어긋나도 시각적 diff가 깨지는 UI 슬라이스, 그리고 나중에 합성될 예정이라 양자화 아티팩트가 겹겹이 쌓일 모든 것에는 PNG를 지키세요. 이런 경우에는 콘텐츠가 허락한다면 제대로 된 양자화 인코더를 돌리고, 허락하지 않는다면 그 파일 크기를 받아들이세요.
압축과 아무 상관 없는 또 하나의 “커짐”
이미지를 data URI로 인라인하면 CSS나 HTML 안에서의 크기는 디스크에서의 크기가 아닙니다. Base64는 3바이트마다 4문자로 인코딩하고 여기에 패딩이 붙으므로, 전송 압축을 적용하기 전 기준으로 그 텍스트는 산술적으로 자기가 담은 바이트보다 +33% 큽니다. 완벽하게 최적화된 이미지라도 인라인하는 순간 3분의 1이 늘어납니다. 그 맞바꿈이 언제 이득인지는 data URI 인라인 가이드에서 다뤘습니다.
7. 직접 해 보기: 브라우저, 명령줄, 빌드 파이프라인
브라우저에서
브라우저 기반 도구를 고를 때 갈리는 지점은 PNG가 canvas를 거치는지 여부입니다. 저희 이미지 압축기에서는 거치지 않습니다. PNG 입력은 색 팔레트로 양자화된 뒤 알파 채널을 그대로 지닌 진짜 PNG로 다시 기록되므로 투명도와 부드러운 가장자리가 살아남습니다. 품질 슬라이더는 어차피 PNG가 무시할 toBlob 인자가 아니라 팔레트 크기에 대응됩니다. 품질 100은 무손실 재인코딩입니다. JPEG와 WebP는 canvas를 거치는데, 거기서는 품질 파라미터가 기대한 대로 작동합니다.
버그처럼 보이지만 버그가 아닌 동작이 하나 있습니다. 압축 결과가 업로드한 파일보다 작지 않으면 도구는 자기 출력을 버리고 원본 바이트를 그대로 지킵니다. 이미 최적화된 PNG에서 “0% 절감”이 보인다면, 4절에서 설명한 내용이 의도대로 작동하고 있다는 뜻입니다.
처리는 브라우저 안에서 끝나므로 파일이 서버로 올라가지 않습니다.
명령줄에서
cwebp는 libwebp에 함께 들어 있고, 포맷을 바꾸면 문제가 풀리는지 확인하는 가장 빠른 방법입니다.
# 손실 WebP, 품질 0-100
cwebp -q 80 photo.png -o photo.webp
# 무손실 WebP, 압축 강도 0-9
cwebp -z 9 logo.png -o logo.webp
ImageMagick 7은 변환과 양자화 두 경우를 모두 다룹니다.
# PNG를 원하는 품질의 JPEG로 변환
magick photo.png -quality 80 photo.jpg
# 64색 팔레트 PNG로 양자화
magick logo.png -colors 64 PNG8:logo-64.png
# EXIF를 비롯한 메타데이터 제거
magick photo.jpg -strip photo-clean.jpg
macOS에는 sips가 이미 설치되어 있고 별도 의존성이 필요 없습니다.
# JPEG로 변환. formatOptions는 0-100 또는 low/normal/high/best를 받습니다
sips -s format jpeg -s formatOptions 80 photo.png --out photo.jpg
# 가장 긴 변이 1200 px가 되도록 리사이즈, 종횡비는 유지됩니다
sips -Z 1200 photo.jpg --out photo-1200.jpg
# 크기를 다시 읽기
sips -g pixelWidth -g pixelHeight photo-1200.jpg
압축하기 전에 리사이즈하세요. 인코더는 픽셀을 상대로 일하고, 가장 값싼 픽셀은 존재하지 않는 픽셀입니다.
빌드 파이프라인에서
이 작업을 손이 아니라 빌드가 맡게 되면 판단의 축이 바뀝니다. 작업이 어디서 이뤄지는지, 어떤 라이브러리가 그것을 맡는지가 관건이 되고 맞바꿈의 성격도 달라집니다. 그 비교는 브라우저 방식과 Node.js 방식의 이미지 압축에서 다뤘습니다. 이 글에서 이어지는 규칙은 하나입니다. 빌드할 때마다 원본 소스 파일에서 압축하고, 직전 빌드의 출력에서는 절대 압축하지 마십시오. 파일 크기는 완벽하게 안정적으로 보이는데 파이프라인은 스스로 세대 손실 곡선을 걸어 내려가는 사태가 그렇게 시작됩니다.
8. 측정 결과가 뒷받침하지 않는 믿음 다섯 가지
“두 번 압축하면 더 작아진다.” 1세대가 벌어 준 것은 1.4%였습니다. 2세대부터 5세대까지는 ±12바이트 안에 머무는 동안 그림만 계속 상했습니다. 두 번째 회차는 순수한 비용입니다.
“PNG는 무손실이니까 더 나은 포맷이다.” 무손실은 성질이지 미덕이 아닙니다. 저희 테스트 사진은 PNG로 498,639바이트, WebP q80으로 24,068바이트입니다. 화면에서만 보게 될 사진을 비트 단위까지 보존해 봐야 얻는 것은 없고 파일의 대부분을 대가로 치릅니다.
“품질 100이 안전한 선택이다.” 품질 82의 JPEG를 품질 92로 다시 압축했더니 +22.9%에 더 나쁜 이미지가 나왔습니다. 원본의 품질 설정을 넘어서는 순간, 그 숫자는 “더 안전하다”가 아니라 “더 크다”를 뜻하기 시작합니다.
“파일이 큰 것은 해상도가 높아서다.” 해상도도 영향을 주지만, 같은 해상도에서는 포맷이 더 크게 작용합니다. og-a.png는 어느 쪽이든 1200×630입니다. canvas PNG로는 607,481바이트, WebP q80으로는 13,852바이트입니다. 픽셀 수는 동일합니다.
“온라인 압축기는 다 똑같다.” 같은 픽셀, 같은 브라우저, 둘 다 무손실인데 canvas는 607,481바이트, upng-js는 175,491바이트였습니다. 스스로를 똑같이 설명하는 두 도구 사이에 3.46배의 격차가 있습니다.
9. 자주 묻는 질문
압축한 이미지가 원본보다 큰 이유는 무엇인가요?
도구가 압축한 것이 아니라 다시 인코딩했기 때문입니다. 브라우저 canvas의 출력은 언제나 32비트 RGBA PNG이고, 이 과정에서 팔레트가 버려지고 픽셀당 4바이트가 저장됩니다. 실제 PNG 83개를 테스트한 결과 83개 모두 커졌고 증가율 중앙값은 +76.6%였습니다. 대신 WebP나 JPEG로 변환하세요.
PNG를 압축해도 용량이 줄지 않는 이유는 무엇인가요?
PNG는 무손실이라 품질 슬라이더가 내줄 것이 없습니다. 실질적인 감량은 색 수를 줄이는 데서 나오는데, 파일이 이미 양자화되어 있었다면 더 줄일 것이 남아 있지 않습니다. 저희 64색 테스트 PNG는 두 번째로 64색 양자화를 거친 뒤 +2.5%가 되어 돌아왔습니다.
JPEG를 두 번 압축하면 화질이 떨어지나요?
떨어집니다. 그리고 그 대가로 얻는 것은 거의 없습니다. 품질 0.8로 다섯 번 돌린 결과, 첫 회차에서 47,828바이트가 47,152바이트로 줄었고 이후 네 번은 12바이트 안에 묶여 있었습니다. 크기는 멈춰 있는 동안에도 매 회차가 그림을 계속 다시 양자화했습니다. 원본을 보관하세요.
파일을 줄이려면 PNG와 JPEG 중 무엇을 써야 하나요?
사진이라면 예외 없이 JPEG나 WebP입니다. 저희 테스트 사진은 PNG로 498,639바이트, JPEG q80으로 45,050바이트, WebP q80으로 24,068바이트가 나왔습니다. PNG는 작은 팔레트가 압축을 대신해 주는 단색 그래픽, 선명한 글자, 투명도에 남겨 두세요.
압축한 뒤 이미지가 흐려진 이유는 무엇인가요?
원인은 두 가지입니다. 손실 인코더를 낮은 품질로 돌리면 가장자리와 글자 주변에 블록이 눈에 띄게 생깁니다. 여러 번 반복해서 돌리면 파일 크기가 멈춘 뒤에도 세대 손실이 쌓입니다. 흐려진 것이 아니라 색이 어긋난 것이라면, canvas 왕복이 ICC 프로파일을 버린 것입니다. canvas는 메타데이터를 전혀 싣지 않기 때문입니다.
화질 손실 없이 이미지를 압축할 수 있나요?
가능하지만 기대치는 훨씬 낮춰야 합니다. 무손실 재인코딩은 동일한 픽셀을 더 효율적으로 다시 쓰는 일에 그칩니다. 저희 그래픽에서는 306,302바이트가 175,491바이트가 됐고, 디코딩한 뒤 바이트 단위로 동일함을 확인했습니다. 같은 방식을 사진에 쓰자 +17.9%로 반대 방향으로 갔습니다. 눈에 보이는 손실 없이 실질적으로 줄이려면 품질 80의 WebP를 쓰세요.
스크린샷일 뿐인데 PNG 용량이 큰 이유는 무엇인가요?
화면 캡처는 압축 전 기준으로 픽셀당 4바이트인 온전한 RGBA PNG로 저장되고, Retina 디스플레이는 가로세로 픽셀 수를 각각 두 배로 늘립니다. 단색 위주 콘텐츠는 팔레트 축소에 잘 반응합니다. 저희 Open Graph 카드 그래픽은 256색에서 63.8%, 64색에서 75.1% 줄었습니다.
리사이즈가 압축보다 파일 크기를 더 줄이나요?
대개 그렇고, 둘은 함께 작용합니다. 가로세로를 절반으로 줄이면 인코더가 시작하기도 전에 픽셀의 4분의 3이 사라지고, 파일 크기는 대체로 픽셀 수를 따라갑니다. 실제로 표시할 크기로 먼저 리사이즈한 다음 한 번만 압축하세요. 원본 해상도의 카메라 파일을 썸네일 자리에 넣으면 두 과정을 모두 낭비하게 됩니다.