스크립트·파일 자동화

파일명 일괄 변경 전에 미리보기 만들기

바꾸기 전 이름과 새 이름을 표로 확인하고 충돌을 검사합니다. --apply를 명시할 때만 새 폴더에 복사본을 만들어 원본을 남깁니다.

목차 보기

이런 분께여러 파일의 이름을 바꾸고 싶지만 잘못된 규칙이나 덮어쓰기가 걱정되는 입문자

준비사항
  • Python 3.12 이상을 준비하고 터미널에서 python --version으로 버전을 확인합니다.
  • 예제 ZIP을 새 폴더에 압축 해제합니다. 압축 파일 안에서 직접 실행하지 않습니다.
  • example.py가 보이는 폴더에서 터미널을 엽니다. Windows에서 python 명령이 없으면 py, macOS·Linux에서는 환경에 따라 python3를 사용합니다.
  • 외부 패키지 설치나 계정은 필요하지 않습니다. 동봉된 자료는 직접 작성한 합성 데이터입니다.

01먼저 이름 세 개만 미리보기

일괄 변경에서는 한 파일의 오타보다 잘못된 규칙이 모든 파일에 적용되는 상황을 먼저 막아야 합니다. 이 예제는 이름 목록을 전부 검증한 다음 계획을 보여 줍니다. 기본 명령은 파일을 복사하거나 폴더를 만들지 않습니다. 실제 작업은 확인 후 --apply를 붙여 별도로 실행합니다.

  1. 압축 해제한 폴더에서 mapping.csv와 source_files 안의 원본 세 개를 확인합니다.
  2. mapping.csv의 old_name과 new_name을 비교합니다. 왼쪽은 현재 파일명, 오른쪽은 새 복사본의 이름입니다.
  3. 아래 기본 명령을 실행하고 화살표로 연결된 세 이름이 의도한 조합인지 확인합니다.
  4. 이 단계에서 source_files가 그대로이고 outputs가 아직 없는지 확인합니다.
bash
python example.py

02이름 매핑을 읽는 방법

old_namenew_name
메모 초안.txtnote-draft.txt
견적 1.txtquote-001.txt
사진 설명.txtphoto-notes.txt

매핑의 각 행은 한 원본과 한 결과를 연결합니다. old_name은 실제 이름과 대소문자까지 정확히 맞춰 적습니다. 원본을 빠뜨리거나 없는 이름을 추가하면 전체 작업이 중지됩니다. 이 규칙 덕분에 폴더에 새 파일이 들어왔는데 예전 매핑을 그대로 실행하는 실수를 발견할 수 있습니다.

new_name끼리는 대소문자 차이만 있어도 충돌로 봅니다. report.txt와 REPORT.txt를 서로 다른 결과로 만들지 않도록 한 선택입니다. 이름 앞뒤 공백, 끝의 점, 경로 구분자, Windows 예약 이름도 거부합니다. 이름 정리 규칙이 업무에 맞는지는 미리보기에서 사람이 최종적으로 확인해야 합니다.

03실행에 쓰는 전체 코드

make_plan에서 모든 입력과 충돌을 검사합니다. main은 검증된 계획을 출력한 뒤 --apply 여부를 확인합니다. 실제 적용에서도 원본 이름을 rename하지 않고 파일 내용을 새 이름으로 복사하므로 시작 상태를 다시 확인할 수 있습니다.

example.py
"""기본은 미리보기입니다. --apply일 때만 새 이름의 복사본을 만듭니다."""

import argparse
import csv
import re
import shutil
import sys
from pathlib import Path

BASE = Path(__file__).resolve().parent
INPUT = BASE / "source_files"
MAPPING = BASE / "mapping.csv"
OUTPUT = BASE / "outputs"
RESERVED = {"CON", "PRN", "AUX", "NUL"} | {
    f"{prefix}{number}" for prefix in ("COM", "LPT") for number in range(1, 10)
}


def validate_name(name):
    # 폴더 경로, Windows 예약 이름, 제어 문자 등을 이름으로 받지 않습니다.
    if (not name or name in {".", ".."} or name != name.strip()
            or name.endswith(".") or re.search(r'[<>:"/\\|?*\x00-\x1f]', name)
            or name.split(".")[0].upper() in RESERVED):
        raise ValueError(f"사용할 수 없는 파일명: {name!r}")


def make_plan():
    if (INPUT.is_symlink() or not INPUT.is_dir()
            or getattr(INPUT, "is_junction", lambda: False)()):
        raise ValueError("source_files는 실제 폴더여야 합니다.")
    if MAPPING.is_symlink():
        raise ValueError("mapping.csv 링크는 처리하지 않습니다.")
    sources = list(INPUT.iterdir())
    if any(path.is_symlink() or not path.is_file() for path in sources):
        raise ValueError("source_files에는 일반 파일만 넣으세요.")
    if not sources:
        raise ValueError("source_files에 파일이 없습니다.")
    plan, old_keys, new_keys = [], set(), set()
    with MAPPING.open("r", encoding="utf-8-sig", newline="") as stream:
        reader = csv.DictReader(stream, strict=True)
        if reader.fieldnames != ["old_name", "new_name"]:
            raise ValueError("mapping.csv의 열은 old_name,new_name이어야 합니다.")
        for row in reader:
            if None in row or any(value is None for value in row.values()):
                raise ValueError(f"mapping.csv {reader.line_num}행의 열 수를 확인하세요.")
            old, new = row["old_name"], row["new_name"]
            validate_name(old)
            validate_name(new)
            if old.casefold() in old_keys:
                raise ValueError(f"원본이 중복 지정되었습니다: {old}")
            if new.casefold() in new_keys:
                raise ValueError(f"새 이름 충돌: {new}")
            old_keys.add(old.casefold())
            new_keys.add(new.casefold())
            plan.append((old, new))
    # 한 파일이라도 누락되거나 없는 파일을 지정하면 아무것도 복사하지 않습니다.
    if {old for old, _ in plan} != {path.name for path in sources}:
        raise ValueError("mapping.csv의 old_name은 원본 파일 전체와 정확히 일치해야 합니다.")
    if OUTPUT.exists() or OUTPUT.is_symlink():
        raise FileExistsError("outputs가 이미 있습니다. 기존 결과를 옮긴 뒤 실행하세요.")
    return plan


def main():
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--apply", action="store_true", help="새 이름의 복사본을 outputs/renamed에 만듭니다.")
    args = parser.parse_args()
    plan = make_plan()  # 충돌 검사는 출력 폴더를 만들기 전에 전체에 대해 끝냅니다.
    for old, new in plan:
        print(f"{old} → {new}")
    if not args.apply:
        print(f"미리보기: {len(plan)}개. 파일은 변경되지 않았습니다.")
        print("이름을 확인한 뒤 python example.py --apply 를 실행하세요.")
        return 0

    OUTPUT.mkdir()
    renamed = OUTPUT / "renamed"
    renamed.mkdir()
    for old, new in plan:
        # 이름 변경 대신 복사합니다. xb는 대상이 생겼으면 덮어쓰지 않고 실패합니다.
        with (INPUT / old).open("rb") as source, (renamed / new).open("xb") as target:
            shutil.copyfileobj(source, target)
    with (OUTPUT / "manifest.csv").open("x", encoding="utf-8-sig", newline="") as stream:
        writer = csv.writer(stream)
        writer.writerow(["old_name", "new_name"])
        writer.writerows(plan)
    print(f"완료: {len(plan)}개 복사본 → {renamed}")
    return 0


if __name__ == "__main__":
    try:
        raise SystemExit(main())
    except (OSError, ValueError, csv.Error) as error:
        print(f"중지: {error}", file=sys.stderr)
        raise SystemExit(2)

argparse의 store_true 옵션이 있으므로 --apply를 생략한 상태가 기본 미리보기입니다. 복사 대상은 xb 모드로 열어 대상 파일이 이미 생긴 경우 덮어쓰지 않습니다. 완료된 매핑은 manifest.csv에도 기록해 새 이름에서 원본 이름을 역으로 찾을 수 있게 했습니다.

04확인한 계획으로 복사본 만들기

미리보기의 세 줄을 확인했다면 같은 폴더에서 다음 명령을 실행합니다. 미리보기 이후 mapping.csv를 수정했다면 기본 명령을 다시 실행해 새 계획을 확인하세요. 적용 명령은 이전 미리보기 화면을 기억하는 방식이 아니라 실행 시점의 매핑을 다시 읽고 검사합니다.

bash
python example.py --apply
위치예상 결과
source_files/기존 이름의 원본 3개 유지
outputs/renamed/note-draft.txt, quote-001.txt, photo-notes.txt
outputs/manifest.csv열 이름 뒤에 원본→새 이름 매핑 3행

터미널에는 ‘완료: 3개 복사본’과 출력 폴더 경로가 표시됩니다. 새 파일의 이름뿐 아니라 내용도 하나씩 열어 봅니다. 예를 들어 note-draft.txt 안의 연습용 문서 번호와 메모 초안.txt의 번호가 같아야 합니다.

05충돌을 직접 만들어 중지 확인하기

  1. 새 압축 해제 폴더를 하나 더 만듭니다. 기존 결과가 있는 폴더에서 오류 실험을 섞지 않습니다.
  2. mapping.csv의 두 번째 new_name을 첫 번째와 같은 note-draft.txt로 바꾸고 저장합니다.
  3. python example.py --apply를 실행합니다. ‘새 이름 충돌’ 메시지가 표시되어야 합니다.
  4. outputs 폴더가 생성되지 않았고 source_files의 세 원본이 모두 남아 있는지 확인합니다.
  5. 매핑을 원래 값으로 되돌린 뒤 다시 미리보기부터 확인합니다.

검증을 복사 루프 안에서 한 파일씩 했다면 첫 파일을 처리한 뒤 두 번째에서 실패할 수 있습니다. 이 예제는 전체 이름을 먼저 검사해 이러한 사전 검증 오류가 부분 적용으로 이어지지 않게 했습니다. 기존 outputs가 있는 경우도 시작 전에 중지합니다.

06내 문서의 이름 규칙으로 바꾸기

업무에 적용할 때는 한 폴더의 작은 사본부터 시작합니다. source_files에 원본 사본을 넣고 mapping.csv에 파일 수만큼 행을 작성합니다. 확장자까지 포함한 전체 이름을 써야 합니다. 확장자는 파일 형식을 변환하지 않으므로 내용은 텍스트인데 이름만 .pdf로 바꾸는 식의 매핑은 만들지 마세요.

번호를 붙인다면 001, 002처럼 자릿수를 정하고 날짜는 같은 순서로 쓰면 결과를 찾기 쉽습니다. 이름에 넣을 항목을 먼저 합의한 뒤 미리보기에서 순서와 누락을 확인하세요. 이 예제는 문서 내용에서 제목을 추출하거나 사용자의 의도를 추정해 이름을 자동 생성하지 않습니다.

07중지 메시지별 확인 위치

메시지확인할 점해결
새 이름 충돌new_name에 같은 값 또는 대소문자만 다른 값서로 구분되는 최종 이름을 지정합니다.
원본이 중복 지정됨old_name이 두 행에 반복됨각 원본에 한 행만 남깁니다.
원본 파일 전체와 일치해야 함누락된 파일 또는 old_name 오타폴더 목록과 매핑을 다시 대조합니다.
사용할 수 없는 파일명경로·예약 이름·금지 문자·끝 점파일명만 적고 제한된 문자를 뺍니다.
outputs가 이미 있음이전 복사 결과결과를 보관할 다른 이름으로 옮기고 다시 시작합니다.

적용 후 같은 명령을 반복해도 기존 결과를 갱신하지 않습니다. 미리보기도 기존 outputs를 발견하면 중지합니다. 한 작업의 결과를 다른 작업과 섞지 않기 위한 규칙이며, 덮어쓰기 옵션은 제공하지 않습니다.

08복사 방식의 범위와 남는 확인

이 예제는 원본 파일을 읽기만 하고 복사본의 이름을 바꿉니다. 저장 공간은 원본과 복사본만큼 추가로 필요합니다. 문서 내용은 보존하지만 생성·수정 시각, 접근 권한, 운영체제의 모든 부가 메타데이터까지 동일하게 복제하는 도구는 아닙니다.

충돌처럼 복사 전에 발견하는 오류는 전체 중지합니다. 복사를 시작한 뒤 전원이 꺼지거나 디스크 공간이 부족해지는 경우까지 하나의 거래처럼 되돌리지는 않습니다. 이때 outputs에 일부 파일이 남을 수 있으므로 완료 문구와 세 파일을 확인하세요. 동시에 다른 프로그램이 원본이나 매핑을 바꾸는 상황도 별도 설계가 필요합니다.

실행·검증 기록

2026-09-19 · Windows 11 · CPython 3.12.14 · 추가 패키지 없음 · 배포본의 임시 복사본에서 실행

  • 기본 미리보기는 출력 폴더를 만들지 않음
  • --apply 복사본 3개의 바이트가 원본과 동일
  • 같은 이름·대소문자 충돌을 출력 생성 전에 거부
  • 원본 중복·누락·없는 원본·경로 탈출·예약 이름 거부
  • 재실행은 기존 결과를 보존하며 중지
검증 범위의 한계
  • 원본의 이름 자체를 변경하지 않습니다.
  • 디스크 오류 이후 부분 출력을 자동으로 되돌리지 않습니다.
  • 파일 메타데이터 전체를 보존하지 않습니다.
  • 실행 검증은 Windows에서 수행했습니다.

사이트 전체의 작성·검증 원칙

직접 실행할 예제 파일

코드·입력 데이터·실행 안내가 포함되어 있습니다. 압축을 풀고 README.txt부터 읽어 주세요.

예제 ZIP 다운로드

직접 작성한 연습 자료 · 원본을 따로 보관한 뒤 실행하세요.

참고 출처

설명과 예제는 직접 작성했습니다. 관련 동작과 개념은 아래 공식 자료에서 확인할 수 있습니다.