|

파이썬 이미지 파일명 변경: EXIF 날짜순 정리

파이썬으로 사진 파일명을 날짜순으로 정리하는 방법을 설명하는 대표 이미지
EXIF 또는 수정 시각 기준으로 사진 파일명을 다시 정리하는 작은 파이썬 도구

파이썬 이미지 파일명 변경 작업은 사진 폴더를 정리할 때 꽤 유용합니다. 휴대폰에서 옮긴 사진처럼 이름이 뒤섞인 폴더도 규칙만 잘 잡으면 안전하게 다시 정리할 수 있습니다.

이번 글에서는 EXIF 촬영 시각을 먼저 보고, 값이 없으면 수정 시각으로 내려가는 작은 정리기를 만들어보겠습니다. 핵심은 preview 모드, 이름 충돌 검사, 그리고 결과가 흔들리지 않는 정렬 규칙입니다.


무엇을 만들까

예를 들어 IMG_8123.JPG, KakaoTalk_20260501_101212.jpg, scan_003.png, vacation-final-edit.jpg 같은 파일이 섞여 있다고 해보겠습니다. 목표는 이 파일들을 가능한 한 찍은 순서에 가깝게 정렬한 뒤, trip_001.jpg, trip_002.jpg, trip_003.png처럼 다시 이름 붙이는 것입니다.

  1. EXIF가 있으면 DateTimeOriginal을 먼저 쓴다
  2. EXIF가 없으면 수정 시각으로 fallback 한다
  3. 같은 파일 집합에는 항상 같은 번호가 붙도록 정렬 규칙을 고정한다

왜 EXIF와 수정 시각을 같이 볼까

사진 파일에는 촬영 정보가 EXIF에 들어 있는 경우가 많고, 그중 DateTimeOriginal은 실제 촬영 시각을 가리키는 대표적인 값입니다. 하지만 메신저 저장 이미지, 스크린샷, 스캔본, 편집본처럼 EXIF가 없거나 기대와 다른 파일도 흔합니다.

그래서 실전에서는 EXIF DateTimeOriginal을 1순위로 보고, 값이 없으면 수정 시각으로 내려가는 방식이 가장 다루기 쉽습니다. 다만 수정 시각은 복사나 편집 후 바뀔 수 있으므로 촬영 순서를 완전히 보여준다고 단정하면 안 됩니다.

참고한 공식 문서는 pathlib 문서, os.path 문서, argparse 문서, Pillow Image 문서, Pillow ExifTags 문서입니다.


짧은 EXIF 읽기 예제

Pillow가 설치되어 있다면 이미지에서 EXIF를 읽을 수 있습니다. 먼저 DateTimeOriginal 값을 꺼내는 가장 짧은 예제부터 보겠습니다.

from PIL import Image
from PIL.ExifTags import TAGS

path = "IMG_8123.JPG"

with Image.open(path) as image:
    exif = image.getexif()
    for tag_id, value in exif.items():
        tag_name = TAGS.get(tag_id, tag_id)
        if tag_name == "DateTimeOriginal":
            print(value)

DateTimeOriginal 값은 보통 YYYY:MM:DD HH:MM:SS 형태입니다. 이 문자열을 datetime으로 바꾸면 정렬 키로 쓰기 쉽습니다.


전체 코드

아래 예제는 사진 폴더를 정리하는 전체 코드입니다. EXIF 촬영 시각 우선, 수정 시각 fallback, preview 모드, 이름 충돌 검사, 흔들리지 않는 번호 규칙을 모두 넣었습니다.

from __future__ import annotations

import argparse
from datetime import datetime
from pathlib import Path
from typing import Optional

from PIL import Image
from PIL.ExifTags import TAGS

IMAGE_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".tif", ".tiff"}
EXIF_DATETIME_TAG = next(
    tag_id for tag_id, name in TAGS.items() if name == "DateTimeOriginal"
)


def parse_exif_datetime(value: object) -> Optional[datetime]:
    if not value:
        return None
    text = str(value).strip()
    try:
        return datetime.strptime(text, "%Y:%m:%d %H:%M:%S")
    except ValueError:
        return None


def read_taken_at(path: Path) -> Optional[datetime]:
    try:
        with Image.open(path) as image:
            exif = image.getexif()
            return parse_exif_datetime(exif.get(EXIF_DATETIME_TAG))
    except Exception:
        return None


def collect_images(folder: Path) -> list[Path]:
    files = [
        path for path in folder.iterdir()
        if path.is_file() and path.suffix.lower() in IMAGE_EXTENSIONS
    ]
    return sorted(files, key=lambda path: path.name.casefold())


def build_sort_key(path: Path) -> tuple[datetime, str]:
    taken_at = read_taken_at(path)
    if taken_at is not None:
        return taken_at, path.name.casefold()

    modified_at = datetime.fromtimestamp(path.stat().st_mtime)
    return modified_at, path.name.casefold()


def build_target_name(path: Path, prefix: str, number: int, digits: int) -> str:
    return f"{prefix}_{number:0{digits}d}{path.suffix.lower()}"


def plan_renames(folder: Path, prefix: str, start: int, digits: int) -> list[tuple[Path, Path]]:
    files = collect_images(folder)
    if not files:
        raise FileNotFoundError("정리할 이미지 파일이 없습니다.")

    sorted_files = sorted(files, key=build_sort_key)
    plan: list[tuple[Path, Path]] = []

    for number, source in enumerate(sorted_files, start=start):
        target = source.with_name(build_target_name(source, prefix, number, digits))
        plan.append((source, target))

    return plan


def validate_plan(plan: list[tuple[Path, Path]]) -> None:
    seen_targets: set[str] = set()

    for source, target in plan:
        target_key = target.name.casefold()

        if target_key in seen_targets:
            raise ValueError(f"같은 새 이름이 두 번 만들어졌습니다: {target.name}")
        seen_targets.add(target_key)

        if source == target:
            raise ValueError(f"변경 전후 이름이 같습니다: {source.name}")

        if target.exists() and target != source:
            raise FileExistsError(f"이미 같은 이름의 파일이 있습니다: {target.name}")


def apply_plan(plan: list[tuple[Path, Path]], preview: bool) -> None:
    for source, target in plan:
        print(f"{source.name} -> {target.name}")

    if preview:
        print("preview 모드이므로 실제 변경은 하지 않았습니다.")
        return

    for source, target in plan:
        source.rename(target)

    print(f"총 {len(plan)}개 파일 이름을 변경했습니다.")


def main() -> None:
    parser = argparse.ArgumentParser(description="이미지 파일명 날짜순 정리기")
    parser.add_argument("folder", help="대상 폴더 경로")
    parser.add_argument("--prefix", required=True, help="새 파일명 접두사")
    parser.add_argument("--start", type=int, default=1, help="시작 번호")
    parser.add_argument("--digits", type=int, default=3, help="번호 자릿수")
    parser.add_argument("--preview", action="store_true", help="실제 변경 없이 결과만 출력")
    args = parser.parse_args()

    folder = Path(args.folder)
    if not folder.exists() or not folder.is_dir():
        raise NotADirectoryError(f"폴더를 찾을 수 없습니다: {folder}")

    plan = plan_renames(folder, args.prefix, args.start, args.digits)
    validate_plan(plan)
    apply_plan(plan, preview=args.preview)


if __name__ == "__main__":
    main()

핵심 포인트

가장 중요한 것은 보기 좋은 이름보다 결과가 흔들리지 않는 규칙입니다. 이 예제는 EXIF 촬영 시각을 먼저 보고, EXIF가 없으면 수정 시각으로 내려가며, 같은 시각이면 파일명으로 한 번 더 정렬합니다. 그래야 같은 파일 집합에서 결과가 덜 흔들립니다.

이름 충돌 검사도 꼭 필요합니다. 계획 안에서 같은 새 이름이 두 번 생기는지, 같은 폴더에 이미 그 이름의 파일이 있는지를 먼저 확인해야 중간에 일부만 바뀌는 상황을 줄일 수 있습니다.

preview 모드는 거의 필수입니다. 처음에는 실제 변경보다 old 에서 new로 어떻게 바뀌는지 눈으로 확인하는 쪽이 훨씬 안전합니다.

사진 정리기에서 가장 중요한 것은 예쁜 이름보다 다시 실행해도 같은 규칙으로 번호가 붙는가입니다.


실행 예시

처음에는 항상 preview 모드로 결과만 확인하는 편이 안전합니다.

python photo_rename.py ./photos --prefix trip --preview
IMG_8123.JPG -> trip_001.jpg
KakaoTalk_20260501_101212.jpg -> trip_002.jpg
scan_003.png -> trip_003.png
vacation-final-edit.jpg -> trip_004.jpg
preview 모드이므로 실제 변경은 하지 않았습니다.

문제가 없으면 preview 옵션을 빼고 실제로 이름을 바꾸면 됩니다.

python photo_rename.py ./photos --prefix trip

번호를 100번부터 시작하고 네 자리로 맞추고 싶다면 이렇게 바꿀 수 있습니다.

python photo_rename.py ./photos --prefix jeju --start 100 --digits 4 --preview

실전 팁

  • 원본 폴더는 먼저 복사해 둔다
  • 처음에는 작은 샘플 폴더로 테스트한다
  • 메신저 저장 이미지와 카메라 원본은 가능하면 분리한다
  • 편집본이 섞여 있으면 수정 시각이 촬영 순서를 어지럽힐 수 있음을 감안한다
  • HEIC 같은 포맷은 별도 확인이 필요할 수 있다

수정 시각 fallback은 실용적이지만 완벽한 진실은 아닙니다. 복사나 편집을 거친 파일에서는 원래 촬영 순서와 달라질 수 있다는 점을 알고 써야 합니다.


자주 하는 실수

  • preview 없이 바로 실행한다
  • EXIF가 없을 수도 있다는 점을 빼먹는다
  • 같은 시각일 때 보조 정렬 키 없이 정렬한다
  • 이미 존재하는 파일명 충돌을 검사하지 않는다
  • 확장자를 유지하지 않고 전부 같은 확장자로 바꿔버린다

특히 같은 촬영 시각을 가진 파일이 있을 때는 파일명 같은 보조 정렬 키를 함께 넣는 편이 좋습니다. 그래야 결과가 덜 애매해집니다.


마무리

파이썬 이미지 파일명 변경 도구는 작은 프로젝트지만 실전성이 높습니다. EXIF 촬영 시각을 우선 보고, 없으면 수정 시각으로 내려가고, preview와 충돌 검사를 먼저 넣으면 꽤 믿을 만한 사진 정리기가 됩니다.

같은 흐름의 자동화에 관심이 있다면 파이썬 파일명 일괄 변경, 파이썬 이미지 워터마크, 파이썬 PDF 합치기 글도 함께 읽어보면 좋습니다.

함께보면 좋은 글