RSS듀오랩스
프론트엔드

WebP가 JPEG보다 커질 때: 사진 종류별 WebP·AVIF 실측

작성자
듀오랩스 대표·8분 읽기

인쇄기 롤러 견적 사이트를 만들면서 화면에 나가는 사진을 전부 WebP로 정했습니다. 제품 사진 네 장으로 재 보고 내린 결론이었습니다. 그 뒤에 설계도 사진 세 장이 들어왔고, 같은 스크립트에 그대로 태웠습니다. 이 글을 쓰려고 다시 재 보니 그중 두 장은 WebP가 JPEG보다 컸습니다.

원본은 assets, 화면에 나가는 파일은 public

원본 사진은 assets/에 커밋하고, 화면에 나가는 파일은 빌드 때 public/에 만듭니다. public/은 커밋하지 않습니다. 둘 다 커밋해 두면 원본을 바꾸고 굽기를 잊은 날 두 벌이 조용히 갈라집니다.

변환은 sharp로 짠 스크립트 하나가 합니다. npm run devnpm run build 앞에 predev·prebuild로 물려 있고, 결과 파일이 원본보다 새것이면 건너뜁니다. dev 서버를 켤 때마다 다시 굽지 않게 하려는 것입니다.

const JOBS = [
  { from: "assets/rollers", to: "public/rollers", ext: ".webp",
    encode: (img) => img.resize({ width: 768, withoutEnlargement: true }).webp({ quality: 75 }) },
  { from: "assets/brand", to: "public/brand", ext: ".png",
    encode: (img) => img.png({ palette: true, colors: 64, compressionLevel: 9 }) },
  { from: "assets/blueprints", to: "public/blueprints", ext: ".webp",
    encode: (img) => img.rotate().resize({ width: 1600, withoutEnlargement: true }).webp({ quality: 80 }) },
];

Next.js의 next/image는 쓰지 않았습니다. next/image는 요청이 들어올 때 서버가 크기와 형식을 바꿔 캐시해 두는 방식입니다. 이 사이트의 사진은 요청마다 달라질 것이 없어서, 빌드 때 한 번 줄여 두면 런타임에 할 일이 남지 않습니다.

롤러 사진 네 장에서 WebP를 고른 근거

제품 카드의 폭이 380px 안팎이라 사진은 폭 768px로 줄였습니다. 2배 밀도 화면에서도 선명한 크기입니다. 네 장을 형식별로 구우면 이렇습니다.

형식 (설정) 네 장 합계 인코딩 시간 (네 장)
원본 JPEG 164.2KB
JPEG (mozjpeg q75) 52.4KB
WebP q75 31.6KB 61ms
AVIF q50 20.3KB 225ms

AVIF가 가장 작았습니다. 그래도 네 장을 합쳐 11KB 차이였고 인코딩은 3.7배 걸렸습니다. 카탈로그 첫 화면의 이미지 전송량이 로고까지 합쳐 37.2KB라, 11KB를 더 줄이자고 형식을 올릴 이유를 찾지 못했습니다. 브라우저 지원도 WebPAVIF보다 몇 해 앞서 있습니다. WebP로 정했습니다.

카드의 <img>에는 width={768} height={512}를 적어 두었습니다. 사진이 늦게 도착해도 브라우저가 자리를 먼저 잡아 두니 카드 아래가 밀리지 않습니다. 화면 밖의 카드는 loading="lazy"로 스크롤할 때 받습니다.

글자 테두리 때문에 PNG로 남긴 로고

로고는 159×35 래스터 한 장뿐입니다. 손실 압축에서는 작은 글자의 테두리가 뭉개졌습니다. 그래서 로고만 무손실 PNG로 두고 색을 64개로 줄였습니다(팔레트 PNG). 16.3KB가 5.6KB가 됐습니다. 색이 몇 개 안 되는 그림은 색 수를 줄여도 눈에 띄는 것이 없고, 줄인 만큼 무손실 압축이 잘 먹힙니다.

레티나 화면에서는 조금 흐립니다. 원본이 그 크기뿐이라 벡터 원본을 받으면 SVG로 바꿀 생각입니다.

설계도 사진에서 뒤집힌 WebP와 JPEG의 순서

설계도는 거래처가 처음 건넨 자료 세 장입니다. 한 장은 종이에 손으로 그린 옵션 스케치이고, 두 장은 엑셀 규격표가 떠 있는 모니터를 휴대폰으로 찍은 사진입니다. 글씨가 읽혀야 해서 폭을 1600px로 넉넉히 두고 WebP q80으로 구웠습니다. 롤러 사진에서 WebP가 이겼으니 여기서도 그럴 거라 여겼고, 따로 재지 않았습니다.

사진 원본 JPEG (mozjpeg q80) WebP q80 AVIF q50
손그림 스케치 (세로) 428.3KB 119.6KB 65.4KB 46.3KB
모니터 속 엑셀 표 ① 2,330.3KB 379.4KB 459.4KB 165.0KB
모니터 속 엑셀 표 ② 2,087.8KB 353.8KB 424.4KB 146.0KB
합계 4,846.4KB 852.9KB 949.2KB 357.4KB

손그림은 롤러 사진과 같은 순서가 나왔습니다. 모니터를 찍은 두 장은 WebP가 JPEG보다 각각 80KB, 71KB 컸습니다. 세 장을 합치면 WebP를 쓰는 바람에 JPEG로 냈을 때보다 96KB를 더 보내고 있었습니다. AVIF와의 차이는 11KB가 아니라 592KB였습니다.

품질 숫자는 인코더마다 척도가 달라서 같은 숫자가 같은 화질을 뜻하지 않습니다. 위 표는 각 설정에서 나온 크기이고 화질을 맞춘 비교가 아닙니다. 그래서 화질은 눈으로 따로 봤습니다.

WebP가 용량을 쓴 곳, 모니터 잔무늬

엑셀 표 사진의 같은 조각을 세 형식으로 잘라 1:1로 나란히 놓았습니다. 숫자와 한글은 셋 다 또렷이 읽혔습니다. 차이는 배경에 있었습니다. 모니터를 찍은 사진에는 화면 픽셀이 만든 잔무늬(모아레)가 깔려 있는데, AVIF는 이것을 조금 뭉개 평평하게 만들었고 WebP와 JPEG는 그대로 살렸습니다.

WebP가 여기서 JPEG에 진 이유가 이 무늬라고 봅니다. 읽어야 할 정보와 상관없는 잔무늬를 충실히 옮기느라 용량을 썼다는 뜻입니다. 확인한 것은 아닙니다. 인코더 설정을 바꿔 가며 원인을 좁혀 보지는 않았습니다.

빌드는 초록불인데 이미지가 404인 구조

이미지를 빌드 때 굽는 방식에는 함정이 하나 따라옵니다. Docker 이미지를 npx next build로 지으면 npm의 prebuild 훅이 돌지 않아서 public/에 사진이 생기지 않습니다. 그리고 Next.js의 standalone 산출물public/을 가져가지 않아서 러너 단계로 따로 복사해야 합니다. 둘 중 하나만 빠져도 빌드는 성공하고 배포도 초록불인데 사진과 로고가 전부 404입니다.

그래서 Dockerfile의 해당 줄마다 왜 npm run build인지, 왜 public/을 복사하는지 주석을 붙여 두었습니다. 누가 npx next build로 줄이려는 순간 그 주석을 먼저 읽게 됩니다.

다시 골라야 하는 설계도 사진의 형식

후보는 둘입니다. 설계도만 AVIF로 옮기거나, 사진마다 형식을 달리해 모니터 사진은 JPEG로 내는 것입니다. 저는 AVIF 쪽으로 기울어 있습니다. 설계도는 빌드 때 한 번 굽고 끝이라 세 장에 1.4초 걸리는 인코딩은 비용이라 할 것이 못 되고, 592KB는 11KB와 달리 휴대폰 회선에서 체감되는 크기입니다.

설계도 페이지의 <img>에는 width와 height도 빠져 있습니다. 롤러 카드에는 적어 두고 여기서는 잊었습니다. 사진이 늦게 오면 그 아래 내용이 한 번 밀려 내려갑니다.

형식을 한 번 정하면 그 뒤에 들어오는 사진에도 그 결론이 조용히 따라붙습니다. 결론을 낸 표본이 제품 사진 네 장이었다는 사실은 설정 파일에 남지 않았습니다.

마지막 수정:

공유하실 때는 출처(Duolabs)와 원문 주소를 표시해 주세요.