Python Pillow로 1000장의 이미지 일괄 압축: 완전 가이드

이미지를 일괄 압축해야 하는 이유

카메라에서 내보낸 1000장의 고해상도 사진이 있고, 각각 약 8MB이며 전체 디렉토리가 거의 8GB를 소비한다고 가정해 봅시다. 웹사이트에 업로드하거나, 프레젠테이션에 삽입하거나, 이메일로 보내는 것이 목적이라면 그 용량은 전혀 받아들일 수 없습니다. 그래픽 편집기에서 하나씩 압축하는 것은 비효율적이고 오류가 발생하기 쉽습니다. 바로 이곳이 스크립트 기반의 일괄 처리가 빛을 발하는 곳입니다.

Python의 Pillow 라이브러리는 이미지 처리의 사실상 표준입니다. JPEG, PNG, WebP, BMP, TIFF를 포함한 주요 포맷의 읽기 및 쓰기를 지원하며, 품질 계수, 샘플링 레이트, 색상 모드에 대한 세밀한 제어를 제공합니다. 수십 줄의 Python으로 전체 디렉토리 트리를 순회하고, 균일한 압축 전략을 적용하며, 출력에서 원래 폴더 구조를 보존할 수 있습니다.

이 글은 Pillow의 기본 사용법에서 시작하여 점진적으로 완전히 실행 가능한 멀티스레드 일괄 압축 스크립트를 구축합니다. 또한 EXIF 보존, 포맷 변환, 지능형 매개변수 선택과 같은 실용적인 엔지니어링 세부 사항도 다룹니다.


Pillow 설치 및 기본 사용법

설치

Pillow는 PIL(Python Imaging Library)의 적극적으로 유지 관리되는 포크이지만, 임포트 이름은 PIL로 유지됩니다. pip로 설치합니다:


pip install Pillow

대량의 이미지를 처리해야 하거나 더 빠른 디코딩이 필요한 경우, 플러그인 지원이 포함된 전체 버전을 설치합니다:


pip install "Pillow[all]"

설치 후 버전을 확인합니다:


from PIL import Image, __version__
print(__version__)  # 예: 10.4.0

기본 작업

Pillow의 핵심 진입점은 Image 클래스입니다. 가장 일반적인 세 가지 작업은 열기, 수정, 저장입니다:


from PIL import Image

# 이미지 열기 (지연 로딩, 픽셀 데이터는 첫 액세스 시 읽음)
img = Image.open("photo.jpg")
print(img.format, img.size, img.mode)  # JPEG (4000, 3000) RGB

# 픽셀 값 가져오기
pixel = img.getpixel((100, 100))

# 크기 조정
resized = img.resize((1920, 1080))

# 저장
resized.save("photo_small.jpg", quality=85)

주의할 점이 몇 가지 있습니다: Image.open()이 반환하는 객체는 명시적으로 닫히거나 with 블록을 빠져나갈 때까지 해제되지 않는 파일 참조를 유지합니다. 일괄 처리에서는 항상 컨텍스트 매니저를 사용하거나 명시적으로 close()를 호출해야 하며, 그렇지 않으면 파일 핸들이 누출됩니다.


단일 이미지 압축 예제

일괄 압축의 기초는 단일 이미지 처리입니다. 먼저 JPEG 품질 조정, 해상도 축소, 포맷 변환을 지원하는 함수를 구현합니다.

JPEG 품질 조정

JPEG의 quality 매개변수는 1에서 100까지 범위이며, 양자화 테이블의 스케일링을 직접 제어합니다. 파일 크기와 이미지 품질의 균형을 맞추는 주요 조절기입니다:


from PIL import Image

def compress_jpeg(input_path, output_path, quality=75):
    """지정된 품질 계수로 JPEG 압축"""
    with Image.open(input_path) as img:
        # JPEG는 투명도를 지원하지 않음, RGBA/P 모드는 먼저 변환해야 함
        if img.mode in ("RGBA", "P", "LA"):
            img = img.convert("RGB")
        elif img.mode != "RGB":
            img = img.convert("RGB")
        img.save(
            output_path,
            format="JPEG",
            quality=quality,
            optimize=True,       # Huffman 인코딩 최적화 활성화
            progressive=True,    # 프로그레시브 JPEG 생성, 약간 더 크지만 로딩 경험이 향상됨
        )

# 다양한 품질 계수 비교
compress_jpeg("photo.jpg", "q95.jpg", quality=95)
compress_jpeg("photo.jpg", "q75.jpg", quality=75)
compress_jpeg("photo.jpg", "q50.jpg", quality=50)

optimize=True는 Pillow가 저장 시 데이터를 한 번 더 스캔하여 최적의 Huffman 인코딩 테이블을 재구성하게 합니다. 이는 일반적으로 약간 느린 저장을 대가로 파일 크기를 추가로 2-5% 줄입니다.

해상도 조정

웹 표시를 위해 4000x3000 원본은 필요 이상으로 훨씬 큽니다. thumbnail()을 사용하여 비례적으로 축소하는 것이 가장 효과적인 접근 방식입니다. 해상도를 반으로 줄이면 파일 크기가 약 75% 감소합니다:


from PIL import Image

def resize_image(input_path, output_path, max_size=1920, quality=80):
    """종횡비를 유지하면서 가장 긴 변을 max_size로 스케일"""
    with Image.open(input_path) as img:
        # thumbnail은 제자리에서 수정하며 지정된 크기를 초과하지 않음
        # Image.Resampling.LANCZOS는 최고 품질의 다운샘플링 알고리즘
        img.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)
        if img.mode != "RGB":
            img = img.convert("RGB")
        img.save(output_path, format="JPEG", quality=quality, optimize=True)

resize_image("photo.jpg", "photo_1920.jpg", max_size=1920, quality=80)

thumbnail()resize()의 차이점은 thumbnail()은 종횡비를 유지하고 이미지를 확대하지 않는다는 것입니다. 최종 크기는 항상 지정된 값 이하입니다. resize()는 이미지를 정확한 대상 크기로 강제합니다. 일괄 처리에서는 거의 항상 thumbnail()을 원합니다.

포맷 변환

PNG를 WebP로 변환하는 것은 일반적인 최적화입니다. WebP는 PNG보다 26% 작고, 동등한 품질의 JPEG보다 25-35% 작으며, 투명도도 지원합니다:


from PIL import Image

def convert_to_webp(input_path, output_path, quality=80, lossless=False):
    """모든 포맷을 WebP로 변환"""
    with Image.open(input_path) as img:
        img.save(
            output_path,
            format="WebP",
            quality=quality,
            lossless=lossless,   # True일 경우 무손실 모드 사용
            method=6,            # 압축 노력 0-6, 6이 가장 느리지만 가장 작음
        )

convert_to_webp("icon.png", "icon.webp", quality=85)
convert_to_webp("logo.png", "logo_lossless.webp", lossless=True)

method 매개변수는 0에서 6까지 압축 노력을 제어합니다. 값이 높을수록 더 나은 압축률을 제공하지만 시간이 더 걸립니다. 최종 파일 크기가 중요한 일괄 처리에서는 6으로 설정하고, 속도를 원하면 4가 좋은 균형입니다.


완전한 일괄 압축 스크립트

이제 단일 이미지 로직을 완전한 일괄 스크립트로 조립합니다. 이 스크립트는 입력 디렉토리의 모든 이미지를 순회하고, 출력에서 원래 폴더 구조를 보존하며, 처리 진행 상황과 전체 압축 결과를 표시합니다.

아래 순서도는 전체 일괄 처리 워크플로를 보여줍니다:

Python batch image compression processing flowchart: directory traversal, PIL/Pillow processing, quality adjustment, output

완전한 스크립트:


"""
batch_compress.py - 이미지 일괄 압축 스크립트
사용법: python batch_compress.py <input_dir> <output_dir> [--quality 75] [--max-size 1920]
"""
import os
import sys
import argparse
from pathlib import Path
from PIL import Image

SUPPORTED_FORMATS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff", ".tif"}


def compress_single(input_path, output_path, quality=75, max_size=1920,
                    target_format=None, keep_exif=True):
    """단일 이미지 압축"""
    with Image.open(input_path) as img:
        # 1. EXIF 데이터 추출 (촬영 시간, 카메라 모델, GPS 등)
        exif_data = img.info.get("exif", b"") if keep_exif else b""

        # 2. 색상 모드 변환: JPEG는 투명도를 지원하지 않음
        out_format = (target_format or img.format or "JPEG").upper()
        if out_format in ("JPEG", "JPG") and img.mode in ("RGBA", "P", "LA"):
            # 변환 후 검은 영역을 방지하기 위해 흰 배경에 합성
            background = Image.new("RGB", img.size, (255, 255, 255))
            if img.mode == "P":
                img = img.convert("RGBA")
            background.paste(img, mask=img.split()[-1] if img.mode == "RGBA" else None)
            img = background
        elif img.mode not in ("RGB", "RGBA", "L"):
            img = img.convert("RGB")

        # 3. 해상도 조정: 축소만, 확대는 안 함
        if max_size and max(img.size) > max_size:
            img.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)

        # 4. 저장
        save_kwargs = {"optimize": True}
        if out_format in ("JPEG", "JPG"):
            save_kwargs["quality"] = quality
            save_kwargs["progressive"] = True
            if exif_data:
                save_kwargs["exif"] = exif_data
        elif out_format == "WEBP":
            save_kwargs["quality"] = quality
            save_kwargs["method"] = 6
        elif out_format == "PNG":
            save_kwargs["optimize"] = True

        img.save(output_path, format=out_format, **save_kwargs)


def batch_compress(input_dir, output_dir, quality=75, max_size=1920,
                   target_format=None, keep_exif=True):
    """전체 디렉토리 일괄 압축"""
    input_dir = Path(input_dir)
    output_dir = Path(output_dir)

    # 처리할 모든 파일 수집
    tasks = []
    for root, _, files in os.walk(input_dir):
        for filename in files:
            if Path(filename).suffix.lower() in SUPPORTED_FORMATS:
                src = Path(root) / filename
                rel = src.relative_to(input_dir)
                dst = output_dir / rel
                # 대상 포맷에 따라 확장자 조정
                if target_format:
                    dst = dst.with_suffix(f".{target_format.lower()}")
                tasks.append((src, dst))

    total = len(tasks)
    if total == 0:
        print("지원되는 이미지 파일이 없습니다")
        return

    print(f"{total}개의 이미지를 찾았습니다. 압축을 시작합니다...\n")

    processed = 0
    total_original = 0
    total_compressed = 0
    errors = []

    for src, dst in tasks:
        # 출력 디렉토리가 존재하는지 확인
        dst.parent.mkdir(parents=True, exist_ok=True)

        original_size = src.stat().st_size
        try:
            compress_single(src, dst, quality, max_size, target_format, keep_exif)
            new_size = dst.stat().st_size
            total_original += original_size
            total_compressed += new_size
        except Exception as e:
            errors.append((src, str(e)))
            new_size = original_size  # 실패 시 원래 크기로 계산

        processed += 1
        pct = processed / total * 100
        saved = (1 - new_size / original_size) * 100 if original_size else 0
        print(f"[{pct:5.1f}%] ({processed}/{total}) {src.relative_to(input_dir)}  "
              f"{original_size // 1024}KB -> {new_size // 1024}KB  (-{saved:.1f}%)")

    # 요약 보고서
    print(f"\n{'=' * 50}")
    print(f"완료: {processed}개 성공, {len(errors)}개 실패")
    if total_original > 0:
        ratio = total_original / total_compressed if total_compressed else 0
        saved_mb = (total_original - total_compressed) / 1024 / 1024
        print(f"원래 크기: {total_original / 1024 / 1024:.2f} MB")
        print(f"압축 후 크기: {total_compressed / 1024 / 1024:.2f} MB")
        print(f"절약된 공간: {saved_mb:.2f} MB (비율 {ratio:.2f}x)")

    for src, err in errors:
        print(f"  실패: {src} - {err}")


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="이미지 일괄 압축 도구")
    parser.add_argument("input_dir", help="입력 이미지 디렉토리")
    parser.add_argument("output_dir", help="출력 디렉토리")
    parser.add_argument("--quality", type=int, default=75, help="JPEG/WebP 품질 계수 (1-100)")
    parser.add_argument("--max-size", type=int, default=1920, help="최대 가장 긴 변(픽셀)")
    parser.add_argument("--format", default=None, help="대상 포맷 (JPEG/WebP/PNG)")
    parser.add_argument("--no-exif", action="store_true", help="EXIF 데이터 폐기")
    args = parser.parse_args()

    batch_compress(
        args.input_dir,
        args.output_dir,
        quality=args.quality,
        max_size=args.max_size,
        target_format=args.format,
        keep_exif=not args.no_exif,
    )

사용 예:


# 기본 사용법
python batch_compress.py ./photos ./output

# WebP로 변환, 품질 80, 최대 가장 긴 변 2560
python batch_compress.py ./photos ./output --quality 80 --max-size 2560 --format webp

이 스크립트는 세 가지 핵심 설계 원칙을 가집니다: os.walk()를 사용하여 재귀적으로 순회하고 디렉토리 구조를 보존합니다, relative_to()를 사용하여 상대 경로를 계산해 출력 레이아웃이 입력과 일치하도록 합니다, 그리고 Path.stat()을 사용하여 파일 크기를 얻고 실제 압축 효과를 계산합니다.


고급 기술

EXIF 정보 보존

EXIF 데이터에는 촬영 시간, GPS 좌표, 카메라 매개변수, 조리개, 셔터 속도 및 기타 메타데이터가 포함됩니다. 기본적으로 img.save()는 이 정보를 폐기합니다. 보존하려면 수동으로 추출하여 저장 시 전달해야 합니다:


from PIL import Image

def compress_with_exif(input_path, output_path, quality=75):
    with Image.open(input_path) as img:
        exif = img.info.get("exif", b"")  # 원시 EXIF 바이트 추출
        if img.mode != "RGB":
            img = img.convert("RGB")
        img.save(
            output_path,
            format="JPEG",
            quality=quality,
            exif=exif if exif else None,  # EXIF가 없으면 None 전달
            optimize=True,
        )

특정 EXIF 필드만 유지해야 하는 경우(예: 방향 태그만) getexif() 메서드를 사용하여 개별적으로 항목을 선택합니다:


from PIL import ExifTags

def keep_orientation_only(input_path, output_path, quality=75):
    with Image.open(input_path) as img:
        exif = img.getexif()
        orientation = exif.get(0x0112)  # 방향 태그
        # 방향 태그에 따라 이미지 회전
        if orientation:
            from PIL import ImageOps
            img = ImageOps.exif_transpose(img)
        img.save(output_path, format="JPEG", quality=quality, optimize=True)

ImageOps.exif_transpose()는 EXIF 방향 태그에 따라 이미지를 자동으로 회전합니다. 이것은 스마트폰으로 촬영한 사진을 처리할 때 특히 유용합니다. 많은 휴대폰 센서가 가로 방향으로 장착되어 있으며, 올바른 표시 방향을 나타내기 위해 EXIF 방향 태그에 의존합니다.

PNG를 WebP로

PNG를 WebP로 변환할 때, 이미지 특성에 따라 손실 및 무손실 모드 중에서 선택해야 합니다. 텍스트와 날카로운 선이 있는 이미지(아이콘, UI 요소)의 경우, 무손실 WebP는 선명한 가장자리를 보존합니다. 사진 콘텐츠의 경우, 손실 WebP는 상당한 크기 이점을 제공합니다:


from PIL import Image

def png_to_webp(input_path, output_path, lossless_threshold=0.3):
    """이미지 특성에 따라 손실 또는 무손실 WebP를 지능적으로 선택"""
    with Image.open(input_path) as img:
        # 색상 수를 세어 단순 그래픽인지 확인
        colors = img.getcolors(maxcolors=65536)
        is_simple = colors is not None and len(colors) < 256

        if is_simple:
            # 색상이 적고, 날카로운 가장자리: 무손실 모드
            img.save(output_path, format="WebP", lossless=True, method=6)
            print(f"무손실 WebP (색상 수: {len(colors)})")
        else:
            # 색상이 풍부, 사진: 손실 모드
            if img.mode != "RGB":
                img = img.convert("RGB")
            img.save(output_path, format="WebP", quality=82, method=6)
            print("손실 WebP (사진)")

getcolors()는 색상 목록 또는 None(색상 수가 maxcolors를 초과할 때)을 반환합니다. 적은 수의 색상은 일반적으로 아이콘이나 스크린샷과 같은 단순한 그래픽을 나타내며, 무손실 압축에 더 적합합니다.

스마트 매개변수 선택

다른 이미지는 다른 압축 전략에 가장 적합합니다. 다음 함수는 해상도, 투명도, 색상 수를 기반으로 포맷과 매개변수를 자동으로 선택합니다:


from PIL import Image

def smart_compress(input_path, output_path):
    """이미지 특성에 따라 최적의 압축 전략을 자동으로 선택"""
    with Image.open(input_path) as img:
        width, height = img.size
        pixel_count = width * height
        mode = img.mode
        has_transparency = mode in ("RGBA", "LA") or (
            mode == "P" and "transparency" in img.info
        )

        # 전략 1: 투명도가 있음 -> WebP (투명도와 크기 균형)
        if has_transparency:
            if pixel_count > 2_000_000:
                q = 80
            else:
                q = 88
            img.save(output_path, format="WebP", quality=q, method=6)
            return "손실 WebP (투명)"

        # 전략 2: 매우 큰 사진 -> 축소 + JPEG 중간 품질
        if pixel_count > 8_000_000 or width > 4000:
            img = img.convert("RGB")
            img.thumbnail((2560, 2560), Image.Resampling.LANCZOS)
            img.save(output_path, format="JPEG", quality=75, optimize=True)
            return "JPEG (대형 이미지 축소)"

        # 전략 3: 일반 사진 -> JPEG 더 높은 품질
        if pixel_count > 500_000:
            img = img.convert("RGB")
            img.save(output_path, format="JPEG", quality=82, optimize=True)
            return "JPEG (고품질)"

        # 전략 4: 작은 이미지 -> PNG로 유지
        img.save(output_path, format="PNG", optimize=True)
        return "PNG (작은 이미지 보존)"

이 전략의 핵심 논리는: 투명도가 있는 이미지는 WebP로, 큰 이미지는 압축 전에 축소하고, 일반 사진은 JPEG를 사용하며, 작은 이미지는 충실도를 위해 PNG를 사용합니다. 실제 요구 사항에 따라 임계값을 조정할 수 있습니다.


성능 최적화

멀티스레드 처리

이미지 압축은 I/O 바운드 작업과 CPU 바운드 작업이 혼합된 작업입니다. 디스크 읽기/쓰기와 인코딩이 각각 시간의 일부를 차지합니다. Python의 GIL은 순수 CPU 병렬성을 제한하지만, Pillow는 JPEG/WebP 인코딩을 위해 기본 C 라이브러리를 호출할 때 GIL을 해제하므로, 멀티스레딩은 상당한 속도 향상을 가져올 수 있습니다:


import os
from pathlib import Path
from PIL import Image
from concurrent.futures import ThreadPoolExecutor, as_completed
import threading

SUPPORTED_FORMATS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff"}

_print_lock = threading.Lock()


def compress_single(input_path, output_path, quality=75, max_size=1920):
    with Image.open(input_path) as img:
        exif = img.info.get("exif", b"")
        if img.mode != "RGB":
            img = img.convert("RGB")
        if max_size and max(img.size) > max_size:
            img.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)
        kwargs = {"quality": quality, "optimize": True, "progressive": True}
        if exif:
            kwargs["exif"] = exif
        img.save(output_path, format="JPEG", **kwargs)


def batch_compress_threaded(input_dir, output_dir, quality=75, max_size=1920,
                            workers=4):
    input_dir = Path(input_dir)
    output_dir = Path(output_dir)

    # 작업 수집
    tasks = []
    for root, _, files in os.walk(input_dir):
        for f in files:
            if Path(f).suffix.lower() in SUPPORTED_FORMATS:
                src = Path(root) / f
                rel = src.relative_to(input_dir)
                dst = output_dir / rel
                dst.parent.mkdir(parents=True, exist_ok=True)
                tasks.append((src, dst))

    total = len(tasks)
    print(f"총 {total}개의 이미지, {workers}개 스레드로 처리\n")

    completed = 0
    total_saved = 0

    with ThreadPoolExecutor(max_workers=workers) as executor:
        futures = {
            executor.submit(compress_single, src, dst, quality, max_size): (src, dst)
            for src, dst in tasks
        }
        for future in as_completed(futures):
            src, dst = futures[future]
            try:
                future.result()
                saved = src.stat().st_size - dst.stat().st_size
                total_saved += saved
            except Exception as e:
                with _print_lock:
                    print(f"실패: {src.name} - {e}")
            completed += 1
            with _print_lock:
                print(f"[{completed / total * 100:5.1f}%] ({completed}/{total}) {src.name}")

    print(f"\n완료. 절약된 공간: {total_saved / 1024 / 1024:.2f} MB")


if __name__ == "__main__":
    batch_compress_threaded("./photos", "./output", quality=75, max_size=1920, workers=4)

스레드 수에 대한 경험적 규칙은 CPU의 논리 코어 수입니다. 8코어 머신에서 4-6개의 스레드가 일반적으로 거의 최적의 속도 향상을 달성합니다. 스레드가 너무 많으면 디스크 I/O 경합과 메모리 압력으로 인해 성능이 저하될 수 있습니다.

병렬성을 더욱 발전시켜야 하는 경우, ProcessPoolExecutor를 사용하여 GIL을 우회할 수 있습니다. 단점은 프로세스가 메모리를 공유할 수 없고, 각 프로세스가 독립적으로 Pillow 라이브러리를 로드해야 하며, 시작 오버헤드가 더 높다는 것입니다.

메모리 관리

고해상도 이미지를 처리할 때 메모리 소비가 우려됩니다. 단일 6000x4000 RGB 이미지는 디코딩 후 약 72MB의 메모리를 차지합니다(6000x4000x3 바이트). 10개를 동시에 열면 720MB입니다.

몇 가지 핵심 메모리 관리 원칙:


from PIL import Image
import gc

# 1. 항상 with 문을 사용하여 파일 핸들과 픽셀 버퍼가 신속히 해제되도록 보장
def safe_compress(input_path, output_path, quality=75):
    with Image.open(input_path) as img:
        # 2. 수정이 필요한 경우 이미지를 복사하여 원본에 대한 작업을 피함
        work = img.copy()
    # img는 해제됨; work는 여전히 범위 내에 있음
    if work.mode != "RGB":
        work = work.convert("RGB")
    work.save(output_path, format="JPEG", quality=quality, optimize=True)
    work.close()  # 3. 명시적으로 닫기

# 4. 일괄 처리 중에 주기적으로 가비지 컬렉션 트리거
def batch_with_gc(tasks, interval=100):
    for i, task in enumerate(tasks):
        process(task)
        if i % interval == 0:
            gc.collect()

또한 Image.MAX_IMAGE_PIXELS는 기본적으로 약 1억 7800만 픽셀(약 8900x8900)의 제한을 가집니다. 이를 초과하면 DecompressionBombError가 발생합니다. 실제로 매우 큰 이미지를 처리해야 하는 경우 이 임계값을 높일 수 있지만, 메모리 소비에 주의해야 합니다:


Image.MAX_IMAGE_PIXELS = None  # 제한 비활성화 (주의해서 사용)

압축 결과 비교

아래 표는 50장의 스마트폰 사진(원래 평균 6.5MB, 4000x3000 픽셀) 세트의 측정 데이터를 보여줍니다. 모든 테스트는 동일한 하드웨어에서 실행되었으며, 해상도는 가장 긴 변 1920로 균일하게 축소했습니다:

품질 계수포맷평균 크기비율주관적 품질용도
원본JPEG6.5 MB1.0x기준선
95JPEG1.8 MB3.6x사실상 동일고품질 아카이빙
85JPEG0.92 MB7.1x인지할 수 없는 차이웹사이트 히어로 이미지
75JPEG0.58 MB11.2x가까이 검사 시 약간의 아티팩트썸네일, 목록 페이지
65JPEG0.42 MB15.5x눈에 띄는 블록 아티팩트권장하지 않음
50JPEG0.28 MB23.2x명확한 왜곡아주 작은 미리보기 전용
80WebP0.51 MB12.7xJPEG 85와 동등현대적인 웹사이트
무손실WebP4.2 MB1.5x완벽하게 무손실투명도가 필요한 경우

데이터에서 몇 가지 패턴이 나타납니다:

  • 품질 85가 비용 효율성의 변곡점: 95에 비해 거의 절반의 크기를 절약하며, 품질 차이는 눈에 거의 인지되지 않습니다.
  • 품질 70 이하에서는 수확이 빠르게 감소: 파일 크기는 계속 감소하지만, 품질 저하가 눈에 띄게 가속화됩니다.
  • WebP 품질 계수는 JPEG과 직접적으로 동등하지 않음: WebP 80은 JPEG 85와 동등한 품질을 더 작은 크기로 생성합니다.
  • 무손실 WebP는 투명도가 필요할 때만 의미가 있음, 사진의 경우 크기 이점이 미미합니다.

FAQ

Q1: 압축 후 이미지가 거꾸로 또는 옆으로 보이는 이유는 무엇입니까?

이것은 압축 중에 스마트폰의 EXIF 방향 태그가 폐기되는 반면, 실제 픽셀 데이터는 가로 방향으로 저장되었기 때문에 발생합니다. 해결책은 저장 전에 ImageOps.exif_transpose(img)를 호출하는 것입니다. EXIF 방향 태그에 따라 픽셀 데이터를 회전시켜 이미지가 저장되기 전에 올바르게 방향이 잡히도록 합니다. 이렇게 하면 다운스트림 소프트웨어가 EXIF 방향 정보를 읽지 않더라도 이미지가 올바르게 표시됩니다.

Q2: 1000장의 이미지를 처리할 때 메모리 사용량이 계속 증가하는 이유는 무엇입니까?

가장 일반적인 원인은 이미지 객체를 신속하게 닫지 않는 것입니다. 각 이미지가 처리된 후 즉시 해제되도록 항상 with Image.open(...) as img: 컨텍스트 매니저를 사용하십시오. 둘째, 멀티스레드 버전에서 모든 작업을 한 번에 제출하면 Future 객체가 소비될 때까지 결과에 대한 참조를 유지하여 메모리가 누적됩니다. 해결책은 chunksize를 사용하여 작업을 일괄적으로 제출하거나, 생산자-소비자 큐 패턴으로 전환하여 동시성을 제어하는 것입니다. 마지막으로, 주기적으로 gc.collect()를 호출하면 순환 참조에 의해 유지된 메모리를 회수할 수 있습니다.

Q3: PNG를 JPEG로 변환한 후 투명한 영역이 검게 변하는 이유는 무엇입니까?

JPEG 포맷은 투명도를 지원하지 않습니다. Pillow가 기본적으로 RGBA를 RGB로 변환할 때 투명 배경을 검은색으로 채웁니다. 해결책은 먼저 흰 배경의 RGB 이미지를 만든 다음 paste()를 사용하여 알파 채널을 마스크로 원본 이미지를 겹치는 것입니다:


background = Image.new("RGB", img.size, (255, 255, 255))
background.paste(img, mask=img.split()[3])  # 4번째 채널이 알파

또는 추가 처리 없이 투명도를 기본적으로 지원하는 WebP로 직접 변환할 수 있습니다.

Q4: 멀티스레딩이 선형 속도 향상을 제공하지 않는 이유는 무엇입니까?

이미지 압축에는 디스크 I/O와 CPU 인코딩이 모두 포함됩니다. 스레드 수가 특정 지점을 넘으면 디스크 대역폭이 병목이 됩니다. CPU는 데이터를 읽고 쓸 수 있는 것보다 빠르게 갈 수 없습니다. 또한 고해상도 이미지의 디코딩된 픽셀 데이터가 크고, 다른 스레드에서 여러 큰 이미지를 동시에 처리하면 메모리 대역폭 경합과 캐시 적중률 감소를 초래합니다. 실제로 4-8개의 스레드는 일반적으로 2-4배의 속도 향상을 제공하며, 그 이상은 수확 체감이 있습니다. 기계식 하드 디스크의 경우 4개의 스레드를 권장하며, NVMe SSD의 경우 스레드 수를 다소 늘릴 수 있습니다.


요약

Python Pillow를 사용한 이미지 일괄 압축은 세 가지 레이어로 나눌 수 있습니다:

  • 기본 레이어: Image.open(), thumbnail(), save() 세 가지 메서드를 마스터하고, qualityoptimize 매개변수를 결합하면 단일 이미지를 압축할 수 있습니다. 이것이 모든 일괄 처리의 기본 구성 요소입니다.
  • 엔지니어링 레이어: os.walk()로 디렉토리를 순회하고, Path.relative_to()로 구조를 보존하며, concurrent.futures로 병렬 처리하여 단일 이미지 로직을 수천 장의 이미지로 확장합니다. 이 레이어는 정확성, 견고성, 처리량에 중점을 둡니다.
  • 최적화 레이어: 이미지 특성에 따라 포맷과 매개변수를 지능적으로 선택합니다. 투명한 이미지는 WebP로, 큰 이미지는 먼저 축소하고, 작은 이미지는 PNG로 유지합니다. EXIF 보존, 방향 보정, 메모리 관리와 같은 세부 사항이 스크립트가 프로덕션 환경에서 안정적으로 실행될 수 있는지를 결정합니다.

품질 계수 선택에 대한 경험적 규칙이 있습니다: 85는 시각적으로 무손실 품질의 임계값, 75는 크기와 품질의 균형점, 65 미만은 표시 목적으로 권장되지 않습니다. WebP는 이제 현대 브라우저에서 완전히 지원되며 새 프로젝트의 우선 포맷이 되어야 합니다.

이 스크립트의 가치는 전문 도구를 대체하는 것이 아니라 사용자 정의 가능성에 있습니다. 실제 필요에 따라 각 단계를 조정할 수 있습니다. 특정 디렉토리에 다른 매개변수를 설정하고, 압축 후 자동으로 CDN에 업로드하거나, CI/CD 파이프라인에 통합할 수 있습니다. 각 코드 줄 뒤의 원리를 이해하면 모든 이미지 처리 시나리오를 처리할 수 있는 기반을 갖게 됩니다.

관련 글:

파일 압축이 필요하신가요? SmartSlim을 사용해 보세요

자체 개발한 Rust 압축 엔진 기반으로 PDF/이미지/비디오/Office/OFD 등 10대 분류 40+ 포맷을 지원합니다. 로컬 압축으로 데이터가 외부로 나가지 않습니다.