파일 이름 일괄 변경 파이썬 스크립트 — 미리보기·되돌리기까지, 1,000개 0.97초 (실측)

사진 수백 장을 여행_001.jpg처럼 번호로 정리하거나, 다운로드할 때 붙은 보고서 (1).docx의 ” (1)”을 한꺼번에 지우고 싶을 때가 있습니다. 파일 이름 일괄 변경은 파이썬 몇 줄이면 되지만, 몇 줄짜리로 바로 실행하면 번호가 뒤섞이거나 중간에 멈춰 폴더가 반쯤 바뀐 상태로 남습니다. 직접 단순한 버전부터 돌려 보니 IMG_10.JPG가 여행_002.jpg가 됐고, ” (1)” 지우기와 번호 밀기는 둘 다 FileExistsError로 멈췄습니다. 이 글에서는 이 문제들을 하나씩 막아서 먼저 미리보기를 보여 주고, 문제가 있으면 아무것도 바꾸지 않고, 잘못 바꿨으면 되돌릴 수 있는 스크립트를 만듭니다. 이 노트북(윈도우 11, Python 3.12.10)에서 1,000개를 바꾸는 데 0.97초, 되돌리는 데도 0.97초 걸렸습니다.

먼저 짠 단순한 버전과 시험 파일

시험용 폴더 네 개를 만들었습니다. IMG_1.JPG~IMG_30.JPG 사진 30개, 보고서.docx·보고서 (1).docx·보고서 (2).docx·회의록 (1).txt·견적서.xlsx 문서 5개, 001.txt~005.txt 5개, scan 1.PNG~scan 1000.PNG 1,000개입니다. 각 파일 내용에는 처음 파일 이름을 적어 두었습니다. 이름을 바꾼 뒤 내용을 열어 보면 원래 어떤 파일이었는지 알 수 있고, 되돌린 뒤에는 “이름과 내용이 전부 같은지”로 원상복구를 확인할 수 있습니다.

아래는 처음 짠 버전입니다. 인터넷에서 흔히 보는 형태 그대로, 목록을 정렬해서 바로 os.rename을 부릅니다.

"""처음 짠 버전: 정렬해서 번호 붙이고 바로 os.rename"""
import os
import re
import sys

folder, mode = sys.argv[1], sys.argv[2]

if mode == "seq":  # IMG_1.JPG ... → 여행_001.jpg ...
    for i, name in enumerate(sorted(os.listdir(folder)), start=1):
        ext = os.path.splitext(name)[1].lower()
        os.rename(os.path.join(folder, name), os.path.join(folder, f"여행_{i:03d}{ext}"))

if mode == "strip":  # "보고서 (1).docx" → "보고서.docx"
    for name in os.listdir(folder):
        new = re.sub(r" \(\d+\)", "", name)
        if new != name:
            os.rename(os.path.join(folder, name), os.path.join(folder, new))
            print(name, "->", new)

if mode == "shift":  # 001.txt → 002.txt, 002.txt → 003.txt ...
    for name in sorted(os.listdir(folder)):
        n = int(name[:3])
        os.rename(os.path.join(folder, name), os.path.join(folder, f"{n + 1:03d}.txt"))

문제 1: IMG_10이 IMG_2보다 앞에 온다

seq 모드는 오류 없이 끝났습니다. 그런데 바뀐 파일을 열어 보니 번호가 뒤섞여 있었습니다.

여행_001.jpg <- IMG_1.JPG
여행_002.jpg <- IMG_10.JPG
여행_003.jpg <- IMG_11.JPG
...
여행_011.jpg <- IMG_19.JPG
여행_012.jpg <- IMG_2.JPG

sorted()는 글자 단위로 비교하기 때문에 IMG_1 다음에 IMG_10~IMG_19가 오고, 그다음에야 IMG_2가 옵니다. 오류가 나지 않으니 사진을 넘겨 보기 전까지는 모릅니다. 게다가 이 버전은 무엇을 무엇으로 바꿨는지 기록하지 않아서 되돌릴 방법도 없습니다. 내용에 원래 이름을 적어 두지 않았다면 원래 순서를 복구할 수 없었을 겁니다.

해결은 이름 속 숫자를 숫자로 비교하는 “자연 정렬”입니다. re.split(r"(\d+)", name)로 이름을 글자와 숫자 조각으로 나눈 뒤 숫자 조각만 int로 바꿔 정렬 키로 씁니다. 완성본의 natural_key 함수가 이 일을 합니다.

문제 2·3: 이름이 겹치면 중간에 멈춘다

” (1)” 지우기와 번호를 하나씩 밀기(001→002, 002→003 …)는 둘 다 첫 충돌에서 멈췄습니다.

$ python rename_naive.py docs strip
Traceback (most recent call last):
  File "...rename_naive.py", line 17, in <module>
    os.rename(os.path.join(folder, name), os.path.join(folder, new))
FileExistsError: [WinError 183] 파일이 이미 있으므로 만들 수 없습니다: 'docs\\보고서 (1).docx' -> 'docs\\보고서.docx'

$ python rename_naive.py shift shift
FileExistsError: [WinError 183] 파일이 이미 있으므로 만들 수 없습니다: 'shift\\001.txt' -> 'shift\\002.txt'

” (1)” 지우기는 보고서 (1).docx를 보고서.docx로 바꾸려는데 같은 이름이 이미 있어서 실패했고, 그 뒤에 처리될 회의록 (1).txt는 손도 대지 못했습니다. 파일이 많은 폴더였다면 앞쪽 일부만 바뀐 채 멈춰서, 어디까지 바뀌었는지 하나씩 확인해야 했을 겁니다. 번호 밀기는 002.txt가 아직 그 자리에 있어서 첫 파일부터 실패했습니다.

참고로 윈도우에서는 이렇게 오류라도 나지만, 파이썬 문서에 따르면 os.rename은 리눅스·맥에서 대상 파일이 이미 있으면 오류 없이 덮어씁니다. 이번 실측은 윈도우에서만 했으니 직접 확인한 것은 아니지만, 같은 코드를 서버에서 돌리면 보고서.docx가 조용히 사라질 수 있다는 뜻입니다. 겹침 검사는 운영체제에 맡기지 말고 스크립트가 먼저 해야 합니다.

완성본: 미리보기 → 전체 검사 → 2단계 변경 → 기록

세 가지 문제를 막은 완성본입니다. 설치할 것 없이 표준 라이브러리만 씁니다.

"""파일 이름 일괄 변경 — 기본은 미리보기만, --apply 로 실제 변경, --undo 로 되돌리기

예) python bulk_rename.py photos --seq "여행_{n}" --lower-ext          (미리보기)
    python bulk_rename.py photos --seq "여행_{n}" --lower-ext --apply  (실행)
    python bulk_rename.py photos --undo photos/rename-log-20261021-101500.json
"""
import argparse
import json
import re
import sys
import uuid
from datetime import datetime
from pathlib import Path

sys.stdout.reconfigure(encoding="utf-8")
BAD_CHARS = re.compile(r'[\\/:*?"<>|]')  # 윈도우 파일 이름에 못 쓰는 글자


def natural_key(name):
    """IMG_2 가 IMG_10 보다 앞에 오도록 숫자는 숫자로 비교"""
    return [int(t) if t.isdigit() else t.lower() for t in re.split(r"(\d+)", name)]


def build_plan(folder, args):
    files = [f for f in folder.glob(args.glob)
             if f.is_file() and not f.name.startswith("rename-log-")]
    files.sort(key=lambda f: natural_key(f.name))
    plan = []
    for i, f in enumerate(files, start=args.start):
        stem, ext = f.stem, f.suffix
        if args.find:
            stem = re.sub(args.find, args.to, stem)
        if args.seq:
            stem = args.seq.replace("{n}", str(i).zfill(args.width))
        if args.lower_ext:
            ext = ext.lower()
        if stem + ext != f.name:
            plan.append((f.name, stem + ext))
    return plan


def find_problems(folder, plan):
    """바꾸기 전에 전부 검사. 하나라도 걸리면 아무것도 바꾸지 않음"""
    problems = []
    moving = {old.lower() for old, _ in plan}  # 윈도우는 대소문자 구분 없음
    existing = {f.name.lower() for f in folder.iterdir()}
    seen = {}
    for old, new in plan:
        key = new.lower()
        if not new.strip(" .") or BAD_CHARS.search(new):
            problems.append(f"쓸 수 없는 이름: {old} -> {new!r}")
        elif key in seen:
            problems.append(f"이름 겹침: {seen[key]}, {old} -> 둘 다 {new}")
        elif key in existing and key not in moving:
            problems.append(f"이미 있는 파일과 겹침: {old} -> {new}")
        seen.setdefault(key, old)
    return problems


def rename_all(folder, plan):
    """1단계: 전부 임시 이름으로, 2단계: 임시 이름 → 새 이름.
    001→002, 002→003 처럼 서로 자리를 바꾸는 경우도 충돌 없이 처리됩니다."""
    tag = uuid.uuid4().hex[:8]
    temps = [(old, f".tmp-{tag}-{i}", new) for i, (old, new) in enumerate(plan)]
    done = []
    try:
        for old, tmp, new in temps:
            (folder / old).rename(folder / tmp)
            done.append((old, tmp, None))
        for k, (old, tmp, new) in enumerate(temps):
            (folder / tmp).rename(folder / new)
            done[k] = (old, tmp, new)
    except OSError as e:
        print(f"중간에 실패: {e}\n바꾼 것을 원래대로 돌립니다.")
        for old, tmp, new in reversed(done):
            (folder / (new or tmp)).rename(folder / old)
        raise SystemExit(1)


def show(plan, limit):
    for old, new in plan[:limit]:
        print(f"  {old}  ->  {new}")
    if len(plan) > limit:
        print(f"  ... 외 {len(plan) - limit}개")


def main():
    ap = argparse.ArgumentParser(description="파일 이름 일괄 변경 (기본: 미리보기)")
    ap.add_argument("folder", type=Path)
    ap.add_argument("--glob", default="*", help='대상 파일 패턴, 예: "*.JPG"')
    ap.add_argument("--find", help="이름(확장자 제외)에서 찾을 정규식")
    ap.add_argument("--to", default="", help="--find 를 바꿀 글자")
    ap.add_argument("--seq", help='번호 이름, {n} 자리에 번호: "여행_{n}"')
    ap.add_argument("--start", type=int, default=1)
    ap.add_argument("--width", type=int, default=3, help="번호 자릿수 (3 → 001)")
    ap.add_argument("--lower-ext", action="store_true", help=".JPG → .jpg")
    ap.add_argument("--apply", action="store_true", help="실제로 바꿈")
    ap.add_argument("--undo", type=Path, help="기록 파일로 되돌리기")
    ap.add_argument("--show", type=int, default=10, help="미리보기 줄 수")
    args = ap.parse_args()
    folder = args.folder

    if args.undo:
        log = json.loads(args.undo.read_text(encoding="utf-8"))
        plan = [(new, old) for old, new in log["renamed"]]
        print(f"되돌리기 {len(plan)}개 ({log['time']} 실행분)")
    else:
        plan = build_plan(folder, args)
        print(f"바뀔 파일 {len(plan)}개")
    show(plan, args.show)

    problems = find_problems(folder, plan)
    if problems:
        print(f"\n문제 {len(problems)}건 — 아무것도 바꾸지 않았습니다.")
        for msg in problems:
            print("  " + msg)
        raise SystemExit(1)
    if not plan:
        return
    if not (args.apply or args.undo):
        print("\n미리보기입니다. 실제로 바꾸려면 --apply 를 붙이세요.")
        return

    rename_all(folder, plan)
    if args.undo:
        args.undo.rename(args.undo.with_suffix(".undone"))
        print("되돌리기 완료")
    else:
        stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
        log_path = folder / f"rename-log-{stamp}.json"
        log_path.write_text(json.dumps({"time": stamp, "renamed": plan},
                                       ensure_ascii=False, indent=1), encoding="utf-8")
        print(f"완료. 되돌리려면: python bulk_rename.py {folder} --undo {log_path}")


if __name__ == "__main__":
    main()

핵심 설계는 다섯 가지입니다.

  • 기본은 미리보기: --apply를 붙이지 않으면 바뀔 목록만 보여 주고 끝납니다. 실수로 실행해도 아무 일도 일어나지 않습니다.
  • 바꾸기 전에 전부 검사: 윈도우에서 쓸 수 없는 글자(\ / : * ? " < > |), 바꿀 이름끼리 겹침, 이미 있는 다른 파일과 겹침을 먼저 확인하고 하나라도 걸리면 한 파일도 바꾸지 않습니다.
  • 2단계 변경: 전부 .tmp-xxxx-번호 임시 이름으로 옮긴 다음 새 이름으로 바꿉니다. 001→002, 002→003처럼 서로 자리를 넘겨받는 경우도 충돌하지 않습니다.
  • 중간 실패 시 복구: 변경 도중 오류가 나면 그때까지 바꾼 것을 역순으로 원래 이름으로 돌립니다.
  • 기록 파일: 성공하면 rename-log-날짜-시각.json에 바꾼 목록을 남기고, --undo에 이 파일을 주면 되돌립니다. 되돌린 기록은 .undone으로 이름이 바뀌어 두 번 되돌려지지 않습니다.

실행 결과: 미리보기, 실행, 되돌리기

먼저 옵션 없이 미리보기를 봤습니다. 이번에는 IMG_9 다음에 IMG_10이 옵니다.

$ python bulk_rename.py photos --seq "여행_{n}" --lower-ext
바뀔 파일 30개
  IMG_1.JPG  ->  여행_001.jpg
  IMG_2.JPG  ->  여행_002.jpg
  IMG_3.JPG  ->  여행_003.jpg
  ...
  IMG_9.JPG  ->  여행_009.jpg
  IMG_10.JPG  ->  여행_010.jpg
  ... 외 20개

미리보기입니다. 실제로 바꾸려면 --apply 를 붙이세요.

--apply로 실행하고 바로 되돌려 봤습니다.

$ python bulk_rename.py photos --seq "여행_{n}" --lower-ext --apply --show 3
바뀔 파일 30개
  IMG_1.JPG  ->  여행_001.jpg
  IMG_2.JPG  ->  여행_002.jpg
  IMG_3.JPG  ->  여행_003.jpg
  ... 외 27개
완료. 되돌리려면: python bulk_rename.py photos --undo photos\rename-log-20261021-100209.json

$ python bulk_rename.py photos --undo photos/rename-log-20261021-100209.json --show 3
되돌리기 30개 (20261021-100209 실행분)
  여행_001.jpg  ->  IMG_1.JPG
  여행_002.jpg  ->  IMG_2.JPG
  여행_003.jpg  ->  IMG_3.JPG
  ... 외 27개
되돌리기 완료

실행 후 여행_010.jpg를 열어 보니 내용이 IMG_10.JPG여서 순서가 맞았습니다. 되돌린 뒤 확인 스크립트는 “파일 30개, 원래 이름과 다른 파일 0개”를 출력했습니다.

단순한 버전에서 멈췄던 두 작업도 다시 돌려 봤습니다. ” (1)” 지우기는 문제 두 건을 보고하고 아무것도 바꾸지 않았고, 쓸 수 없는 글자인 콜론(:)을 넣은 이름도 걸러졌습니다.

$ python bulk_rename.py docs --find " \(\d+\)" --apply
바뀔 파일 3개
  보고서 (1).docx  ->  보고서.docx
  보고서 (2).docx  ->  보고서.docx
  회의록 (1).txt  ->  회의록.txt

문제 2건 — 아무것도 바꾸지 않았습니다.
  이미 있는 파일과 겹침: 보고서 (1).docx -> 보고서.docx
  이름 겹침: 보고서 (1).docx, 보고서 (2).docx -> 둘 다 보고서.docx

$ python bulk_rename.py docs --glob "견적서*" --find 견적서 --to "견적서: 10월"
바뀔 파일 1개
  견적서.xlsx  ->  견적서: 10월.xlsx

문제 1건 — 아무것도 바꾸지 않았습니다.
  쓸 수 없는 이름: 견적서.xlsx -> '견적서: 10월.xlsx'

겹치지 않는 회의록 (1).txt만 --glob "회의록*"으로 골라 실행하자 정상적으로 바뀌었습니다. 번호 밀기는 2단계 변경 덕분에 한 번에 끝났고, 내용을 대조해 보니 002.txt 안에 001.txt, 006.txt 안에 005.txt가 들어 있었습니다.

$ python bulk_rename.py shift --seq "{n}" --start 2 --apply
바뀔 파일 5개
  001.txt  ->  002.txt
  002.txt  ->  003.txt
  003.txt  ->  004.txt
  004.txt  ->  005.txt
  005.txt  ->  006.txt
완료. 되돌리려면: python bulk_rename.py shift --undo shift\rename-log-20261021-100221.json

중간에 실패하면 정말 원래대로 돌아오는지

복구 코드는 실제로 실패가 나 봐야 믿을 수 있습니다. 윈도우는 다른 프로그램이 열어 둔 파일의 이름을 바꾸지 못하게 막는데, 이걸 이용했습니다. 1,000개 중 scan 500.PNG 하나를 파이썬으로 열어 둔 채 일괄 변경을 실행했습니다.

바뀔 파일 1000개
중간에 실패: [WinError 32] 다른 프로세스가 파일을 사용 중이기 때문에 프로세스가 액세스 할 수 없습니다: '...\\bulk\\scan 500.PNG' -> '...\\bulk\\.tmp-b99752b4-499'
바꾼 것을 원래대로 돌립니다.

$ python make_files.py check bulk
bulk: 파일 1000개, 원래 이름과 다른 파일 0개

임시 이름 .tmp-b99752b4-499, 즉 500번째 파일에서 실패했고, 그 앞의 499개는 이미 임시 이름으로 옮겨진 상태였습니다. 스크립트가 이 499개를 모두 원래 이름으로 돌렸고, 확인 스크립트로 1,000개 모두 원래 이름인 것을 확인했습니다. 실무에서는 엑셀로 열어 둔 파일, 탐색기 미리보기 창이 잡고 있는 파일에서 같은 상황이 생길 수 있습니다.

주의할 점: 윈도우는 대소문자를 구분하지 않는다

--lower-ext로 IMG_1.JPG를 IMG_1.jpg로, 대소문자만 바꾸는 작업도 30개 모두 정상적으로 끝났습니다. 그런데 이 상태에서 Path("photos/IMG_1.JPG").exists()를 실행하니 실제 파일은 IMG_1.jpg뿐인데도 True가 나왔습니다. 윈도우(NTFS)는 이름의 대소문자를 구분하지 않기 때문입니다.

그래서 겹침 검사를 “새 이름이 이미 있으면 거부”로 단순하게 짜면 대소문자만 바꾸는 작업은 전부 막힙니다. 반대로 대소문자를 구분해서 비교하면 Report.docx와 report.docx가 겹친다는 걸 놓칩니다. 완성본은 이름을 소문자로 바꿔 비교하고, 이번에 이름이 바뀌어 자리가 비워질 파일(moving)은 겹침에서 제외하는 방식으로 두 경우를 모두 처리합니다.

1,000개 처리 시간

작업시간(파이썬 시작 포함)
미리보기 1,000개0.15초
실행(–apply) 1회차0.97초
되돌리기(–undo)0.97초
실행 2회차0.99초

파이썬을 켜는 데만 0.1초 안팎이 걸리니, 1,000개 기준으로 이름 변경 자체는 1초가 채 안 됐습니다. 2단계 변경이라 이름을 두 번씩 바꾸지만 체감할 만한 차이는 아니었습니다. 미리보기는 파일을 건드리지 않아서 거의 바로 끝납니다. 목록이 길면 --show 50처럼 줄 수를 늘려 확인하면 됩니다.

마무리

이름 바꾸기 자체는 한 줄이지만, 사고는 정렬 순서와 이름 겹침, 그리고 중간에 멈췄을 때 생깁니다. 바꾸기 전에 보여 주고, 걸리면 멈추고, 바꾼 건 기록해 두면 됩니다.

가장 작은 첫걸음은 이렇습니다.

  1. 정리하려는 폴더를 통째로 복사해 두고, 복사본에서 --apply 없이 미리보기부터 봅니다.
  2. 목록이 맞으면 --apply로 실행하고, 출력에 나온 --undo 명령을 한 번 그대로 실행해 원래대로 돌아오는지 확인합니다.
  3. 되돌리기까지 확인됐으면 원본 폴더에 적용합니다. 기록 파일은 결과를 다 확인할 때까지 지우지 않습니다.

다음 글에서는 느린 파이썬 스크립트에서 시간이 어디에 쓰이는지, 표준 라이브러리 cProfile로 병목을 찾아내는 방법을 실측과 함께 다루겠습니다.

댓글 남기기