Devin.KR

파이썬 파일 이름 일괄 변경 - 자연 정렬 충돌 검사와 되돌리기 기록 (파이썬 자동화 2단원)

개발자 조회 1

이 단원에서 배우는 것

자동화 1단원에서는 파일을 다른 폴더로 옮겼다. 이번에는 같은 폴더 안에서 이름을 한꺼번에 바꾼다. 파일 이름 변경은 코드 몇 줄이면 되는 것처럼 보이지만, 실제로는 옮기기보다 사고가 잦다. 이름이 곧 순서이고, 바꾼 이름이 다른 파일의 원래 이름과 겹칠 수 있으며, 한 번 바꾸면 원래 이름이 어디에도 남지 않기 때문이다.

  • Path.glob으로 패턴에 맞는 파일만 고른다.
  • scan 10scan 2보다 앞에 오는 정렬 문제를 자연 정렬로 해결한다.
  • 바꾸기 전에 충돌을 검사하고, 바꾼 기록을 CSV로 남겨 되돌리기를 만든다.

문제 상황

서점은 전화 주문을 종이 주문서로 받고, 하루가 끝나면 복합기로 스캔한다. 복합기는 파일 이름을 scan 1.pdf, scan 2.pdf, …, scan 20.pdf로 붙인다. 회계 담당자는 이 파일을 주문서_001.pdf부터 스캔한 순서대로 번호를 매겨 보관하길 원한다. 손으로 20개 이름을 바꾸다가 번호를 하나 건너뛰면 그 뒤를 전부 다시 바꿔야 한다.

요구 사항을 정리하면 다음과 같다.

  • 스캔 순서(파일 이름의 숫자) 그대로 주문서_001, 주문서_002로 바꾼다. 번호는 세 자리로 맞춰 탐색기에서도 순서대로 보이게 한다.
  • 같은 폴더의 다른 파일(표지 이미지 등)은 건드리지 않는다.
  • 바꾼 이름이 이미 있는 파일과 겹치면 아무것도 바꾸지 않고 중단한다.
  • 잘못 바꿨으면 명령 한 번으로 원래 이름으로 되돌린다.

완성 스크립트

rename.py로 저장한다. 사용법은 python3 rename.py 폴더 "패턴" 접두어 [--apply]python3 rename.py 폴더 --undo 두 가지다.

"""패턴에 맞는 파일 이름을 '접두어_001.확장자' 형식으로 일괄 변경한다. 기록을 남겨 되돌릴 수 있다."""
import csv
import re
import sys
from pathlib import Path

LOG_NAME = "rename_log.csv"


def natural_key(path):
    parts = re.split(r"(\d+)", path.stem)
    return [int(p) if p.isdigit() else p for p in parts]


def make_plan(folder, pattern, prefix):
    files = [p for p in folder.glob(pattern) if p.is_file()]
    files.sort(key=natural_key)
    plan = []
    for i, src in enumerate(files, start=1):
        dst = folder / f"{prefix}_{i:03d}{src.suffix}"
        plan.append((src, dst))
    return plan


def check_plan(plan):
    sources = {src for src, _ in plan}
    problems = []
    for src, dst in plan:
        if dst.exists() and dst not in sources:
            problems.append(f"이미 있는 파일을 덮어씁니다: {dst.name}")
    return problems


def apply_plan(plan, folder):
    temps = []
    for i, (src, dst) in enumerate(plan):
        tmp = folder / f".renaming-{i}{src.suffix}"
        src.rename(tmp)
        temps.append((tmp, src, dst))
    with open(folder / LOG_NAME, "w", encoding="utf-8", newline="") as f:
        writer = csv.writer(f)
        writer.writerow(["old", "new"])
        for tmp, src, dst in temps:
            tmp.rename(dst)
            writer.writerow([src.name, dst.name])


def undo(folder):
    log_path = folder / LOG_NAME
    with open(log_path, encoding="utf-8", newline="") as f:
        rows = list(csv.DictReader(f))
    for row in rows:
        if (folder / row["old"]).exists():
            print(f"중단: {row['old']} 가 이미 있어 되돌리면 덮어씁니다.")
            sys.exit(1)
    for row in rows:
        (folder / row["new"]).rename(folder / f".undo-{row['new']}")
    for row in rows:
        (folder / f".undo-{row['new']}").rename(folder / row["old"])
    log_path.unlink()
    print(f"{len(rows)}개 파일 이름을 되돌렸습니다.")


def main():
    args = sys.argv[1:]
    if len(args) == 2 and args[1] == "--undo":
        undo(Path(args[0]))
        return
    if len(args) < 3:
        print('사용법: python3 rename.py 폴더 "패턴" 접두어 [--apply]')
        print("       python3 rename.py 폴더 --undo")
        sys.exit(2)
    folder, pattern, prefix = Path(args[0]), args[1], args[2]
    plan = make_plan(folder, pattern, prefix)
    for src, dst in plan:
        print(f"{src.name:<14} -> {dst.name}")
    problems = check_plan(plan)
    if problems:
        for message in problems:
            print("중단:", message)
        sys.exit(1)
    if "--apply" in args[3:]:
        apply_plan(plan, folder)
        print(f"{len(plan)}개 이름을 바꿨습니다. 기록: {LOG_NAME}")
    else:
        print(f"{len(plan)}개 변경 예정. 실제로 바꾸려면 --apply 를 붙이세요.")


if __name__ == "__main__":
    main()

한 줄씩 해설

natural_key — 사람이 기대하는 순서로 정렬한다

문자열 정렬은 글자를 앞에서부터 하나씩 비교한다. "scan 10.pdf""scan 2.pdf"는 여섯 번째 글자 "1""2"에서 승부가 나므로 10이 2보다 앞에 온다. 사람이 보기에는 틀린 순서다. 그래서 이름을 숫자 부분과 나머지로 쪼개 숫자는 진짜 정수로 바꾼 리스트를 정렬 기준으로 쓴다.

import re

names = ["scan 1.pdf", "scan 10.pdf", "scan 2.pdf", "scan 20.pdf", "scan 3.pdf"]
print("sorted      :", sorted(names))


def natural(name):
    return [int(p) if p.isdigit() else p for p in re.split(r"(\d+)", name)]


print("re.split    :", re.split(r"(\d+)", "scan 10.pdf"))
print("natural key :", natural("scan 10.pdf"))
print("자연 정렬   :", sorted(names, key=natural))
$ python3 demo_sort.py
sorted      : ['scan 1.pdf', 'scan 10.pdf', 'scan 2.pdf', 'scan 20.pdf', 'scan 3.pdf']
re.split    : ['scan ', '10', '.pdf']
natural key : ['scan ', 10, '.pdf']
자연 정렬   : ['scan 1.pdf', 'scan 2.pdf', 'scan 3.pdf', 'scan 10.pdf', 'scan 20.pdf']

re.split(r"(\d+)", ...)은 "숫자가 하나 이상 이어진 곳"을 기준으로 문자열을 자른다. \d는 숫자 한 글자, +는 한 번 이상 반복이라는 뜻이다. 패턴을 괄호로 감싸면 잘린 숫자도 결과에 남는다. 정규 표현식은 심화 과정에서 자세히 다루니, 여기서는 이 한 줄을 관용구로 기억해 두면 된다. 리스트끼리의 비교는 첫 원소부터 차례로 하므로, ['scan ', 2, '.pdf']['scan ', 10, '.pdf']보다 앞에 온다. 5단원에서 본 key= 인자에 함수를 넘기면 sort가 이 값을 기준으로 정렬한다.

sorted() 는 글자를 하나씩 비교해 scan 10 이 scan 2 앞에 온다. natural_key 는 숫자 조각을 int 로 바꿔 비교하므로 1, 2, 3, 10, 20 순서가 된다.

그림 · 문자열 정렬과 자연 정렬 — sorted() 는 글자를 하나씩 비교해 scan 10 이 scan 2 앞에 온다. natural_key 는 숫자 조각을 int 로 바꿔 비교하므로 1, 2, 3, 10, 20 순서가 된다.

make_plan — 바꾸기 전에 계획표부터 만든다

folder.glob(pattern)은 셸의 와일드카드처럼 *를 "아무 글자나 몇 개든"으로 해석해 맞는 경로를 돌려준다. "scan*.pdf"scan으로 시작하고 .pdf로 끝나는 파일만 고르므로, 같은 폴더의 표지.jpg나 이미 바꾼 주문서_001.pdf는 대상이 아니다.

enumerate(files, start=1)로 1부터 번호를 받고, f"{prefix}_{i:03d}{src.suffix}"로 새 이름을 만든다. :03d는 "정수를 세 자리로, 빈자리는 0으로"라는 6단원의 f-string 서식이다. 확장자는 원래 파일의 것을 그대로 붙인다.

이 함수는 파일을 하나도 건드리지 않는다. (원래 경로, 새 경로) 쌍의 리스트만 만든다. 계획과 실행을 분리해 두면 계획을 출력해서 사람이 확인할 수 있고, 계획을 검사하는 함수도 따로 만들 수 있다.

check_plan — 이미 있는 파일을 덮어쓰는지 검사한다

새 이름의 파일이 이미 있으면 위험하다. 단, 그 파일이 이번에 이름이 바뀔 파일 중 하나라면 괜찮다. 예를 들어 a → b, b → c를 한꺼번에 하는 경우 b는 지금 있지만 곧 c로 비켜 줄 것이다. 그래서 새 이름이 존재하고, 그리고 이번 대상 목록(sources)에 없을 때만 문제로 본다. 문제가 하나라도 있으면 main이 전부 출력하고 아무것도 바꾸지 않은 채 끝낸다. 절반만 바뀐 상태로 멈추는 것이 가장 복구하기 어렵기 때문이다.

표 · check_plan 이 문제로 보는 경우

새 이름(dst)의 상태check_plan 판단
아직 없음첫 실행의 scan 1.pdf → 주문서_001.pdf통과
있지만 이번 계획의 원본a → b, b → c 를 한꺼번에 할 때의 b통과 (임시 이름 단계가 순서 문제를 없앤다)
있고 이번 계획 밖의 파일주문서_001.pdf 가 있는데 새로 들어온 scan 1.pdf 를 또 바꿀 때중단, 종료 코드 1

apply_plan — 두 번에 나눠 바꾼다

tmp = folder / f".renaming-{i}{src.suffix}"
src.rename(tmp)

먼저 모든 파일을 점으로 시작하는 임시 이름으로 바꾸고, 두 번째 반복에서 최종 이름으로 바꾼다. 번거로워 보이지만 이유가 있다. 방금 본 a → b, b → c를 순서대로 바로 실행하면 첫 번째 단계에서 b가 덮어써진다. Path.rename은 맥과 리눅스에서 대상이 있으면 경고 없이 덮어쓴다(윈도우에서는 FileExistsError가 난다). 임시 이름을 거치면 순서와 상관없이 안전하다.

두 번째 반복에서 최종 이름으로 바꾸면서 동시에 rename_log.csvold,new 한 줄씩 쓴다. 7단원에서 익힌 csv.writernewline="" 관용구 그대로다.

계획을 만든 뒤 이번 계획의 원본이 아닌 기존 파일을 덮어쓰는지 먼저 검사한다. 하나라도 걸리면 아무것도 바꾸지 않고 종료 코드 1로 끝나고, 통과해야 임시 이름을 거쳐 한꺼번에 바꾼다.

그림 · 이름 변경의 충돌 검사와 두 단계 변경 — 계획을 만든 뒤 이번 계획의 원본이 아닌 기존 파일을 덮어쓰는지 먼저 검사한다. 하나라도 걸리면 아무것도 바꾸지 않고 종료 코드 1로 끝나고, 통과해야 임시 이름을 거쳐 한꺼번에 바꾼다.

undo — 기록을 거꾸로 적용한다

기록 파일을 읽어 new 이름을 old 이름으로 돌린다. 여기도 두 가지 안전장치가 있다. 첫째, 되돌릴 원래 이름이 이미 존재하면 시작하기 전에 중단한다. 이름을 바꾼 뒤에 스캐너가 같은 이름의 새 파일을 만들었을 수 있기 때문이다. 둘째, 바꾸기와 똑같이 임시 이름을 거친다. 되돌리기가 끝나면 기록 파일을 지워서 같은 되돌리기가 두 번 실행되지 않게 한다.

실행 결과

실습 폴더 스캔에는 scan 1.pdf, scan 2.pdf, scan 3.pdf, scan 10.pdf, scan 11.pdf, scan 20.pdf, 표지.jpg가 있다. 먼저 계획만 본다.

$ python3 rename.py 스캔 "scan*.pdf" 주문서
scan 1.pdf     -> 주문서_001.pdf
scan 2.pdf     -> 주문서_002.pdf
scan 3.pdf     -> 주문서_003.pdf
scan 10.pdf    -> 주문서_004.pdf
scan 11.pdf    -> 주문서_005.pdf
scan 20.pdf    -> 주문서_006.pdf
6개 변경 예정. 실제로 바꾸려면 --apply 를 붙이세요.

10이 3 뒤에 제대로 왔고 표지.jpg는 대상이 아니다. 실제로 바꾼다.

$ python3 rename.py 스캔 "scan*.pdf" 주문서 --apply
scan 1.pdf     -> 주문서_001.pdf
scan 2.pdf     -> 주문서_002.pdf
scan 3.pdf     -> 주문서_003.pdf
scan 10.pdf    -> 주문서_004.pdf
scan 11.pdf    -> 주문서_005.pdf
scan 20.pdf    -> 주문서_006.pdf
6개 이름을 바꿨습니다. 기록: rename_log.csv
$ cat 스캔/rename_log.csv
old,new
scan 1.pdf,주문서_001.pdf
scan 2.pdf,주문서_002.pdf
scan 3.pdf,주문서_003.pdf
scan 10.pdf,주문서_004.pdf
scan 11.pdf,주문서_005.pdf
scan 20.pdf,주문서_006.pdf

회계 담당자가 "번호를 1이 아니라 101부터 매겨야 했다"고 한다. 기록이 있으니 되돌리면 된다.

$ python3 rename.py 스캔 --undo
6개 파일 이름을 되돌렸습니다.
scan 1.pdf
scan 10.pdf
scan 11.pdf
scan 2.pdf
scan 20.pdf
scan 3.pdf
표지.jpg

탐색기에서 보는 것처럼 문자열 순서로 나열했기 때문에 scan 10.pdfscan 2.pdf 앞에 보이지만, 이름은 전부 원래대로 돌아왔다. 기록 파일도 사라졌다.

다시 적용한 뒤 다음 날이 되었다. 복합기가 번호를 1부터 다시 매겨 새 스캔 scan 1.pdf, scan 2.pdf가 생겼다. 같은 명령을 실행하면 이렇게 된다.

$ python3 rename.py 스캔 "scan*.pdf" 주문서
scan 1.pdf     -> 주문서_001.pdf
scan 2.pdf     -> 주문서_002.pdf
중단: 이미 있는 파일을 덮어씁니다: 주문서_001.pdf
중단: 이미 있는 파일을 덮어씁니다: 주문서_002.pdf

새 파일이 어제 만든 주문서_001.pdf를 덮어쓸 뻔했지만, 아무것도 바꾸지 않고 종료 코드 1로 끝났다. 이럴 때는 접두어를 주문서_0923처럼 날짜를 넣어 바꾸거나, 연습 과제 3처럼 시작 번호를 받게 고친다.

표 · 상황별 명령과 실제 종료 코드

상황명령종료 코드마지막 출력 줄
미리보기python3 rename.py 스캔 "scan*.pdf" 주문서06개 변경 예정. 실제로 바꾸려면 --apply 를 붙이세요.
실제 변경python3 rename.py 스캔 "scan*.pdf" 주문서 --apply06개 이름을 바꿨습니다. 기록: rename_log.csv
되돌리기python3 rename.py 스캔 --undo06개 파일 이름을 되돌렸습니다.
충돌python3 rename.py 스캔 "scan*.pdf" 주문서1중단: 이미 있는 파일을 덮어씁니다: 주문서_002.pdf

실무에서 자주 틀리는 것

1. 패턴을 따옴표 없이 쓴다

python3 rename.py 스캔 scan*.pdf 주문서

맥과 리눅스의 셸은 파이썬에 넘기기 전에 scan*.pdf를 현재 폴더에서 펼친다. 현재 폴더에 맞는 파일이 있으면 sys.argv에 패턴 대신 파일 이름들이 들어가서 인자 순서가 완전히 어긋난다. 맞는 파일이 없으면 패턴 그대로 넘어가서 우연히 동작한다. 그래서 "어제는 됐는데 오늘은 이상하다"가 된다. 패턴은 항상 "scan*.pdf"처럼 따옴표로 감싼다. 윈도우의 명령 프롬프트는 펼치지 않지만 습관을 같게 들여 두는 편이 낫다.

2. 번호를 자릿수 없이 붙인다

f"{prefix}_{i}"로 만들면 주문서_1, 주문서_10, 주문서_2 순서로 보여서, 자연 정렬로 고친 문제를 이번에는 결과물에서 다시 만든다. 파일 개수가 1000개를 넘을 수 있으면 :04d로 넉넉히 잡는다.

3. 대소문자만 다른 이름으로 바꾼다

Report.pdfreport.pdf로 바꾸려고 하면, 윈도우와 맥의 기본 파일 시스템은 두 이름을 같은 파일로 본다. check_plandst.exists()가 참이 되어 자기 자신과 충돌한다고 보고 중단한다. 리눅스에서는 두 이름이 다른 파일이라 그대로 진행된다. 같은 스크립트가 운영체제마다 다르게 동작하는 대표적인 경우다. 중단하는 쪽이 안전한 실패이긴 하지만, 대소문자만 고치는 작업이 목적이라면 이 스크립트의 apply_plan처럼 임시 이름을 한 번 거쳐 두 단계로 바꿔야 어느 운영체제에서든 같은 결과가 난다.

4. 기록 없이 바꾼다

이름 변경은 되돌리기 버튼이 없다. 원래 이름이 사라지면 그 이름에 담긴 정보(스캔 순서, 촬영 날짜, 거래처 이름)도 함께 사라진다. 한 줄짜리 CSV 기록을 남기는 비용은 거의 없다. 기록 파일이 있으면 되돌리기 말고도 "지난달 주문서_014는 원래 어떤 파일이었나" 같은 질문에 답할 수 있다.

스스로 확인하기

  1. sorted(names)sorted(names, key=natural)의 결과가 같아지는 이름 목록의 예를 들어라. 그런 목록에서도 자연 정렬을 쓰는 것이 손해인가?
  2. apply_plan에서 임시 이름 단계를 없애고 src.rename(dst)만 남겼다. 맥에서 b.txt → c.txt, a.txt → b.txt 순서의 계획을 실행하면 괜찮은데, a.txt → b.txt, b.txt → c.txt 순서면 무슨 일이 생기는가?
  3. 시작 번호를 바꿀 수 있게 make_planstart 인자를 추가하고, 명령행 네 번째 인자로 받게 하라. --apply와 섞여도 동작하게 하려면 어떻게 하겠는가?

정답

  1. 숫자 자릿수가 모두 같으면 결과가 같다. scan 01, scan 02, …, scan 20처럼 앞에 0이 채워진 이름이 그렇다. 이 경우에도 자연 정렬은 결과가 같을 뿐 틀리지 않으므로, 다른 이름 규칙이 섞여 들어올 때를 대비해 그대로 두는 편이 낫다.
  2. a.txtb.txt로 바뀌는 순간 원래 b.txt의 내용이 사라진다(덮어쓰기). 그다음 b.txt → c.txt는 원래 a.txt 내용을 c.txt로 옮기므로, 결과적으로 파일 하나가 흔적 없이 없어지고 오류도 나지 않는다. 계획의 순서에 따라 결과가 달라지는 코드는 언젠가 사고를 낸다. 임시 이름 단계가 그 의존성을 없앤다.
  3. def make_plan(folder, pattern, prefix, start=1):로 바꾸고 enumerate(files, start=start)를 쓴다. main에서는 --로 시작하는 인자를 먼저 걸러 낸 뒤(rest = [a for a in args if not a.startswith("--")]) rest[3]이 있으면 int(rest[3])를 넘긴다. 옵션이 이 정도로 늘어나면 손으로 파싱하는 것보다 자동화 7단원의 argparse를 쓰는 편이 훨씬 짧고 도움말도 자동으로 생긴다.

다음 단원에서는 파일 자체가 아니라 파일의 내용을 다룬다. 지점마다 보내오는 판매 CSV를 하나로 합쳐 보고서를 만들고, 형식이 틀린 행 때문에 전체가 멈추지 않게 하는 방법을 본다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.