블로그에 올리는 이미지는 생각보다 무겁습니다. 이 블로그의 대표이미지 59장(1200×675 PNG)만 해도 합쳐서 20.3MB였고, 한 장에 1MB 가까운 것도 있었습니다. 페이지가 무거우면 모바일에서 늦게 뜨고, 이미지가 쌓일수록 호스팅 용량도 줄어듭니다. 그래서 이미지 webp 일괄 변환 스크립트를 파이썬으로 만들어 이 노트북(윈도우 11, Python 3.12, Pillow 12.3)에서 직접 돌려 봤습니다. 대표이미지 59장과 윈도우 기본 배경화면 사진 36장, 합계 48.0MB가 3.5MB로 줄었고 걸린 시간은 19.34초였습니다. 다만 가장 단순하게 짠 첫 버전은 오류 없이 끝났는데도 세로 사진을 눕혀서 저장하는 문제가 있었습니다. 그 과정과 최종 스크립트를 정리합니다.
준비: Pillow 하나면 됩니다
WebP 변환에는 파이썬 이미지 라이브러리 Pillow를 씁니다. 설치되어 있지 않다면 pip install pillow로 설치합니다. 설치한 Pillow가 WebP를 지원하는지는 아래 한 줄로 확인할 수 있습니다. 이 노트북에서는 True가 나왔고, 내부 libwebp 버전은 1.6.0이었습니다.
python -c "import PIL.features as f; print(f.check('webp'))"
실험용 입력은 두 종류로 골랐습니다. 하나는 이 블로그에 실제로 올라간 대표이미지 PNG 59개(20.3MB)이고, 다른 하나는 C:\Windows\Web 폴더에 있는 윈도우 기본 배경화면 JPG 36개(27.7MB)입니다. 배경화면은 대부분 3840×2400이고 가장 큰 것은 6400×4000이라, 휴대폰이나 카메라로 찍은 고해상도 사진 대신 쓰기 좋았습니다. 원본은 건드리지 않도록 임시 폴더에 복사해서 작업했습니다.
첫 버전: 열고 저장하면 끝일까
먼저 가장 단순하게 짰습니다. 폴더 안 JPG·PNG를 찾아서 확장자만 .webp로 바꿔 저장합니다.
import sys
from pathlib import Path
from PIL import Image
src = Path(sys.argv[1])
dst = Path(sys.argv[2])
dst.mkdir(parents=True, exist_ok=True)
for f in src.rglob("*"):
if f.suffix.lower() in (".jpg", ".jpeg", ".png"):
Image.open(f).save(dst / (f.stem + ".webp"), quality=80)
print(f.name)
95개 변환에 36.70초가 걸렸고 오류는 하나도 없었습니다. 용량만 보면 이미 성공처럼 보였는데, 원본과 결과를 비교하는 스크립트를 따로 돌려 보니 문제가 보였습니다.
$ python naive.py images out-naive
...
thumb-88.png
exit: 0, 소요 36.70초
(원본과 비교)
.png: 59개 20.3MB -> 2.2MB (89% 감소)
.jpg: 36개 27.7MB -> 5.3MB (81% 감소)
전체: 48.0MB -> 7.4MB (85% 감소)
색 프로필(ICC) 사라짐: 9
가장 큰 해상도: (6400, 4000), (3841, 2400), (3840, 2401)
- 해상도가 그대로입니다. 6400×4000 사진이 그 크기 그대로 WebP가 됐습니다. 블로그 본문 폭은 대개 1,000px 안팎이라 이 정도 해상도는 필요가 없습니다.
- 색 프로필이 빠집니다. 원본 JPG 9개에 들어 있던 색 프로필(ICC)이 결과물에는 없었습니다. 확인해 보니 9개 모두 표준 sRGB 프로필이라 이번 경우 눈에 띄는 색 차이는 없지만, 다른 색 공간으로 저장된 사진이라면 색이 달라 보일 수 있습니다. Pillow는
icc_profile을 직접 넘겨줘야 WebP에 붙입니다.
비교 스크립트가 “투명도가 사라진 PNG 13개”도 보고했는데, 이건 제 스크립트의 오탐이었습니다. 해당 PNG들은 투명 채널(RGBA)을 갖고 있지만 값이 전부 255, 즉 완전히 불투명했습니다. WebP가 쓸모없는 투명 채널을 생략했을 뿐이고, 실제 투명 배경 PNG를 따로 만들어 변환해 보니 투명도는 단순 버전에서도 그대로 유지됐습니다.
함정: 세로 사진이 옆으로 눕는다
휴대폰 사진은 픽셀을 가로로 저장하고, “90도 돌려서 보여 달라”는 회전 정보(EXIF Orientation)만 따로 붙이는 경우가 많습니다. 이걸 재현하려고 1200×800 이미지에 회전값 6(시계 방향 90도)을 넣은 JPG를 만들어 변환해 봤습니다.
변환 전 phone.jpg RGB (1200, 800) EXIF 방향: 6
단순 버전 phone.webp RGB (1200, 800) EXIF 없음 -> 옆으로 누운 사진
개선판 phone.webp RGB (800, 1200) -> 세로로 바르게 저장
단순 버전은 회전 정보를 버린 채 픽셀만 저장해서, 사진 뷰어에서 세로로 보이던 사진이 WebP에서는 옆으로 누웠습니다. 오류가 나지 않아서 결과를 직접 열어 보기 전에는 알 수 없는 유형의 문제입니다. Pillow의 ImageOps.exif_transpose()로 회전을 픽셀에 먼저 반영한 뒤 저장하니 800×1200 세로로 바르게 나왔습니다.
최종 스크립트: 회전·축소·색 프로필·재실행까지
위에서 발견한 문제를 고치고, 매번 전부 다시 변환하지 않도록 이미 변환한 파일은 건너뛰게 했습니다. 원본 폴더의 하위 폴더 구조도 그대로 따라 만듭니다. 그래서 다른 폴더에 같은 이름의 파일이 있어도 서로 덮어쓰지 않습니다.
# 폴더 안 JPG/PNG를 WebP로 일괄 변환 (원본은 그대로 두고 out 폴더에 같은 구조로 저장)
# 사용법: python to_webp.py 원본폴더 결과폴더 [--quality 80] [--max-width 1600]
import argparse
import sys
import time
from pathlib import Path
from PIL import Image, ImageOps
sys.stdout.reconfigure(encoding="utf-8")
ap = argparse.ArgumentParser()
ap.add_argument("src")
ap.add_argument("dst")
ap.add_argument("--quality", type=int, default=80)
ap.add_argument("--max-width", type=int, default=1600)
args = ap.parse_args()
src, dst = Path(args.src), Path(args.dst)
start = time.perf_counter()
done = skipped = 0
before = after = 0
for f in sorted(src.rglob("*")):
if f.suffix.lower() not in (".jpg", ".jpeg", ".png"):
continue
out = dst / f.relative_to(src).with_suffix(".webp")
if out.exists() and out.stat().st_mtime >= f.stat().st_mtime:
skipped += 1 # 이미 변환했고 원본이 바뀌지 않았으면 건너뜀
continue
out.parent.mkdir(parents=True, exist_ok=True)
with Image.open(f) as im:
icc = im.info.get("icc_profile")
im = ImageOps.exif_transpose(im) # 휴대폰 사진의 회전 정보를 픽셀에 반영
if im.width > args.max_width:
h = round(im.height * args.max_width / im.width)
im = im.resize((args.max_width, h), Image.LANCZOS)
opts = {"quality": args.quality, "method": 6}
if icc:
opts["icc_profile"] = icc
im.save(out, "WEBP", **opts)
a, b = f.stat().st_size, out.stat().st_size
before += a
after += b
done += 1
print(f"{f.relative_to(src)} {a / 1024:,.0f}KB -> {b / 1024:,.0f}KB")
print(f"변환 {done}개, 건너뜀 {skipped}개, {time.perf_counter() - start:.2f}초")
if done:
print(f"합계 {before / 1024 / 1024:.1f}MB -> {after / 1024 / 1024:.1f}MB ({(1 - after / before) * 100:.0f}% 감소)")
사용법은 python to_webp.py 원본폴더 결과폴더이고, 필요하면 --quality 75, --max-width 1200처럼 바꿉니다. 실제 실행 결과입니다.
$ python to_webp.py images out
photos\Screen_img102.jpg 1,357KB -> 28KB
photos\Windows_img0.jpg 1,565KB -> 59KB
photos\touchkeyboard_TouchKeyboardThemeDark000.jpg 423KB -> 33KB
thumbs\thumb-105.png 301KB -> 31KB
thumbs\thumb-88.png 967KB -> 68KB
...
변환 95개, 건너뜀 0개, 19.34초
합계 48.0MB -> 3.5MB (93% 감소)
$ python to_webp.py images out (같은 명령 다시 실행)
변환 0개, 건너뜀 95개, 0.02초
단순 버전보다 오히려 빨라졌습니다(36.70초 → 19.34초). 큰 사진을 먼저 1600px로 줄인 뒤 압축하니 인코더가 처리할 픽셀이 크게 줄었기 때문입니다. 1,357KB였던 6400×4000 사진이 28KB가 된 것도 대부분 축소 덕분입니다. 같은 사진을 축소 없이 단순 버전으로 변환했을 때는 314KB였습니다. 같은 명령을 다시 실행하면 결과 파일이 원본보다 새로운 것은 모두 건너뛰어 0.02초에 끝납니다. 새 이미지만 추가한 뒤 다시 돌려도 그것만 변환됩니다.
quality 값은 얼마로? 설정별 실측
quality를 바꿔 가며 같은 이미지 묶음을 압축해 봤습니다. 모두 가로 1600px 이하로 줄인 뒤 메모리에서 잰 값입니다.
| 설정 | 사진 36개 | 시간 | 대표이미지 59개 | 시간 |
|---|---|---|---|---|
| quality=60 | 0.97MB | 6.14초 | 1.62MB | 3.28초 |
| quality=75 | 1.16MB | 6.42초 | 1.86MB | 3.26초 |
| quality=80 | 1.43MB | 6.54초 | 2.15MB | 3.24초 |
| quality=90 | 2.52MB | 7.30초 | 3.10MB | 3.69초 |
| quality=80, method=6 | 1.36MB | 7.27초 | 2.09MB | 4.42초 |
| lossless(무손실) | 23.61MB | 25.12초 | 13.27MB | 12.26초 |
숫자만 보면 60이 가장 작지만, 대표이미지처럼 글자가 들어간 이미지는 화질을 눈으로 봐야 합니다. 대표이미지 한 장의 제목 부분을 잘라 원본·60·80을 2배로 확대해 비교해 보니, 60은 작은 부제목 글씨의 획 주변이 원본보다 살짝 흐려 보였고 80은 원본과 거의 구분되지 않았습니다. 그래서 기본값을 80으로 정했습니다.
- method=6은 같은 quality에서 3~5% 더 작게 만들지만 시간이 더 걸립니다. 한 번 변환해 두고 오래 쓰는 블로그 이미지라 최종 스크립트에 넣었습니다.
- 무손실(lossless)은 원본 PNG보다는 작았지만(20.3MB → 13.27MB) 손실 압축보다 6배 이상 컸습니다. 블로그용이라면 손실 압축으로 충분합니다.
- 글자 없는 풍경 사진 위주라면 75 정도로 낮춰도 됩니다. 위 표에서 사진 기준 quality 80 대비 19% 작습니다.
주의할 점
- 원본은 꼭 남기세요. 위 스크립트는 결과를 다른 폴더에 저장합니다. WebP는 손실 압축이라 다시 원본 화질로 되돌릴 수 없고, 나중에 더 큰 해상도가 필요할 때 원본이 있어야 합니다.
- 오류가 없다고 끝난 게 아닙니다. 이번 실험에서도 회전 문제와 색 프로필 누락은 오류 메시지 없이 지나갔습니다. 처음 돌릴 때는 세로 사진 몇 장을 직접 열어 확인하세요.
- 파일 이름이 바뀝니다. 이미 글에
photo.jpg로 넣은 이미지를 WebP로 바꾸면 주소가 달라지므로, 기존 글의 이미지 주소도 함께 바꿔야 합니다. 새로 올릴 이미지부터 적용하는 게 가장 간단합니다. - 워드프레스는 5.8 버전부터 WebP 업로드를 기본 지원합니다. 그보다 오래된 버전이면 먼저 업데이트가 필요합니다.
용량을 가장 크게 줄인 건 WebP 자체보다 해상도 축소였습니다. 6400×4000 사진을 그대로 WebP로 바꾸는 것과 1600px로 줄인 뒤 바꾸는 것은 결과가 몇 배 차이 납니다.
마무리
가장 작은 첫걸음은 다음에 올릴 이미지 몇 장을 한 폴더에 모아 python to_webp.py 그폴더 그폴더_webp를 한 번 실행해 보는 것입니다. 마지막 줄의 “합계 ○MB -> ○MB”로 얼마나 줄었는지 바로 확인할 수 있고, 원본은 그대로 남으니 부담 없이 시험해 볼 수 있습니다.
다음 글에서는 자주 쓰는 폴더로 명령 한 번에 이동하는 방법을 다루겠습니다.