파이썬 확장자별 폴더 정리: 드라이런·이름 충돌·되돌리기까지 확인하기
다운로드 폴더를 자동으로 정리하기 전에 확인할 것은 처리 속도보다 되돌릴 수 있는 이동 목록입니다. 이 실습은 ZIP의 sandbox만 대상으로 계획을 출력한 뒤 선택적으로 이동하고, 저장한 저널로 원래 경로를 복원합니다. 사용자의 Downloads 폴더를 기본값으로 지정하지 않았습니다.
드라이런은 파일 이동뿐 아니라 폴더 생성·저널 저장도 하지 않습니다. Python 3.12.14의 임시 폴더에서 실행 전후 경로·파일 내용·수정 시각을 비교했습니다. “1초 정리” 같은 성능 약속과 작업 중인 폴더의 동시 변경 안전성은 주장하지 않습니다.

고정 입력과 두 번의 이름 충돌
sandbox의 파일은 이름 분류를 시험하는 작은 텍스트 더미입니다. .pdf와 .JPG라는 이름이 있어도 실제 PDF·사진 콘텐츠가 아니므로 뷰어로 열어 확인할 파일은 아닙니다.
| 원래 경로 | 예상 결과 경로 | 이유 |
|---|---|---|
| report.pdf | PDF/report_2.pdf | PDF/report.pdf와 report_1.pdf가 이미 있음 |
| PHOTO.JPG | Images/PHOTO.JPG | 확장자를 소문자로 비교해 .jpg 분류 |
| notes.txt | Other/notes.txt | 지정하지 않은 확장자 |
| PDF/report.pdf, PDF/report_1.pdf | 그대로 | 기존 하위 폴더는 다시 순회하지 않음 |
| .keep | 그대로 | 점으로 시작하는 파일 제외 |
분류 폴더가 심볼릭 링크나 일반 파일이면 계획 단계에서 중단합니다. 같은 실행에서 예약한 목적지도 함께 검사하므로 같은 대상 이름을 두 번 배정하지 않습니다. 이미 있는 파일을 덮어쓰는 rename 대신 대상 이름이 존재하면 실패하는 하드 링크 생성과 원본 경로 제거를 사용합니다.
읽기 전용 계획부터 확인하기
Python 3.12.14 표준 라이브러리만 사용합니다. ZIP을 빈 폴더에 풀고 다음을 차례대로 실행합니다. 저널 moves.json은 sandbox 바깥에 둡니다.
python organize.py sandbox
python -m unittest -v test_example.py
python organize.py sandbox --apply --journal moves.json
python organize.py sandbox --rollback --journal moves.json
-
첫 명령으로 정리 계획만 출력하고 위 표의 세 경로와 원본 SHA-256을 대조합니다.
-
테스트를 별도 임시 사본에서 실행해 경로·내용 보존을 확인합니다.
-
확인한 sandbox에만
--apply를 사용해 이동합니다. -
이동한 내용을 바꾸지 않은 상태에서
--rollback을 실행하고 원래 파일 경로와 바이트를 비교합니다.
마지막 명령은 이동 후 내용이 바뀌지 않은 파일을 원위치로 되돌리고, 이번 실행에서 만든 빈 분류 폴더만 제거합니다. 기존 PDF 폴더와 그 안의 파일은 보존합니다.

계획·적용·복구가 연결된 전체 코드
본문의 organize.py는 저널의 생성 폴더 목록 검사까지 보완한 버전입니다. 다운로드 ZIP에는 아래 보완 코드와 9개 회귀 테스트가 포함되어 있으므로 파일을 따로 교체할 필요가 없습니다. 저널은 실행했던 폴더의 경로와 내용을 담으므로 다른 컴퓨터나 다른 위치의 폴더에 재사용하지 않습니다. 수정·출처 불명의 저널도 그대로 실행하지 않습니다.
되돌리기 전에 폴더 목록도 확인합니다
이전 코드는 이동할 파일의 경로를 검사했지만, 마지막에 빈 폴더를 지울 때는 저널의 created_dirs 값을 그대로 사용했습니다. 여기에 ../outside나 절대 경로가 섞이면 실습 폴더 바깥의 빈 디렉터리까지 삭제 대상으로 해석할 수 있습니다. 분리된 임시 폴더에서 이 경우를 재현한 뒤, 어떤 파일도 되돌리기 전에 폴더 목록을 먼저 검사하도록 바꿨습니다.
허용하는 이름은 PDF, Excel, Images, Archives, Other이며 한 단계 폴더명만 받습니다. 문자열 목록이 아니거나 이름이 중복된 경우, 심볼릭 링크나 없어진 폴더가 포함된 경우에도 중단합니다. 삭제 대상은 계속 비어 있는 폴더로 한정합니다. Python의 Path.rmdir는 빈 디렉터리를 제거하는 메서드이지만, 그 경로가 허용된 위치인지는 호출 전에 별도로 확인해야 합니다.
기존 7개 테스트와 폴더 목록 변조·심볼릭 링크 복구를 추가한 2개 테스트, 총 9개가 Python 3.12.14의 임시 디렉터리에서 통과했습니다. 경로 이탈·절대 경로·중복 이름·잘못된 자료형을 거절한 뒤에도 이동된 파일과 바깥의 빈 폴더가 그대로 남는지 확인했습니다. 이는 저널의 작성자를 인증하거나 다른 프로그램의 동시 변경을 막는 기능은 아닙니다. 작업 중에는 다른 쓰기를 멈추고 이 예제가 만든 원래 저널만 사용하세요.
보완 코드를 저장한 폴더에서 다음을 별도 check_rollback_guard.py로 저장해 실행하면, 사용자의 실제 다운로드 폴더 대신 자동으로 만든 임시 연습 폴더에서 경로 거부와 정상 복구를 비교합니다.
import copy
import tempfile
from pathlib import Path
from organize import plan, apply, rollback
with tempfile.TemporaryDirectory() as temporary:
base = Path(temporary)
root = base / "sandbox"
outside = base / "outside"
root.mkdir()
outside.mkdir()
(root / "notes.txt").write_text("practice", encoding="utf-8")
record = apply(plan(root), base / "moves.json")
changed = copy.deepcopy(record)
changed["created_dirs"].append("../outside")
try:
rollback(changed)
except ValueError:
pass
else:
raise AssertionError("잘못된 폴더 목록이 통과했습니다")
if (not outside.is_dir()
or (root / "notes.txt").exists()
or (root / "Other" / "notes.txt").read_text(encoding="utf-8") != "practice"):
raise AssertionError("거부 과정에서 경로나 파일이 바뀌었습니다")
rollback(record)
if (root / "notes.txt").read_text(encoding="utf-8") != "practice":
raise AssertionError("정상 저널 복구 결과가 다릅니다")
print("경로 거부·무변경·정상 복구 확인")
ZIP에 포함한 organize.py 전체 코드
import argparse
import hashlib
import json
import os
from pathlib import Path
GROUPS = {".pdf": "PDF", ".xlsx": "Excel", ".png": "Images", ".jpg": "Images", ".zip": "Archives"}
def digest(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def plan(root):
root = Path(root).resolve(strict=True)
operations, reserved = [], set()
for source in sorted(root.iterdir()):
if source.is_symlink() or not source.is_file() or source.name.startswith("."):
continue
group = GROUPS.get(source.suffix.lower(), "Other")
folder = root / group
if folder.is_symlink() or (folder.exists() and not folder.is_dir()):
raise ValueError(f"분류 폴더가 일반 디렉터리가 아닙니다: {group}")
target, number = folder / source.name, 1
while target.exists() or target.is_symlink() or str(target).casefold() in reserved:
target = folder / f"{source.stem}_{number}{source.suffix}"
number += 1
reserved.add(str(target).casefold())
operations.append({"source": str(source.relative_to(root)), "target": str(target.relative_to(root)), "sha256": digest(source)})
return {"root": str(root), "operations": operations}
def paths(record, item):
root = Path(record["root"]).resolve(strict=True)
source, target = root / item["source"], root / item["target"]
if (source.is_symlink() or target.is_symlink() or target.parent.is_symlink()
or source.parent != root or target.parent.parent != root
or not source.resolve().is_relative_to(root) or not target.resolve().is_relative_to(root)):
raise ValueError("저널 경로가 실습 폴더를 벗어납니다")
return source, target
def move_exclusive(source, target):
# Same-volume regular files only. Creating an existing hard-link name fails.
os.link(source, target)
try:
source.unlink()
except BaseException:
target.unlink()
raise
def rollback(record):
if record.get("status") != "complete":
raise ValueError("완료된 저널만 자동 복구합니다")
root = Path(record["root"]).resolve(strict=True)
created_dirs = record.get("created_dirs")
allowed_dirs = set(GROUPS.values()) | {"Other"}
if (type(created_dirs) is not list
or any(type(name) is not str or name not in allowed_dirs for name in created_dirs)
or len(created_dirs) != len(set(created_dirs))):
raise ValueError("복구 보류: 저널의 생성 폴더 목록이 올바르지 않습니다")
for name in created_dirs:
directory = root / name
if directory.is_symlink() or not directory.is_dir():
raise ValueError("복구 보류: 생성 폴더가 없거나 일반 디렉터리가 아닙니다")
pairs = []
for item in record["operations"]:
source, target = paths(record, item)
if source.exists() or source.is_symlink() or not target.is_file() or digest(target) != item["sha256"]:
raise ValueError("복구 보류: 원래 경로 충돌 또는 이동 후 내용 변경")
pairs.append((source, target))
for source, target in reversed(pairs):
move_exclusive(target, source)
for directory in reversed(record["created_dirs"]):
path = Path(record["root"]) / directory
if path.is_dir() and not any(path.iterdir()):
path.rmdir()
record["status"] = "rolled_back"
return record
def apply(record, journal):
journal = Path(journal).resolve()
root = Path(record["root"])
if journal.is_relative_to(root):
raise ValueError("저널은 정리 폴더 밖에 저장하세요")
for item in record["operations"]:
source, target = paths(record, item)
if not source.is_file() or digest(source) != item["sha256"] or target.exists():
raise ValueError("계획 이후 입력/대상이 바뀌었습니다")
record = {**record, "status": "in_progress", "created_dirs": []}
with journal.open("x", encoding="utf-8") as stream:
stream.write(json.dumps(record, ensure_ascii=False, indent=2))
completed = []
try:
for item in record["operations"]:
source, target = paths(record, item)
if not target.parent.exists():
target.parent.mkdir()
record["created_dirs"].append(target.parent.name)
move_exclusive(source, target)
completed.append(item)
record["status"] = "complete"
except Exception:
partial = {**record, "status": "complete", "operations": completed}
rollback(partial)
record["status"] = "rolled_back_after_error"
raise
finally:
journal.write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8")
return record
if __name__ == "__main__":
p = argparse.ArgumentParser()
p.add_argument("root")
p.add_argument("--apply", action="store_true")
p.add_argument("--journal", default="moves.json")
p.add_argument("--rollback", action="store_true")
a = p.parse_args()
if a.apply and a.rollback:
p.error("--apply와 --rollback은 함께 사용하지 않습니다")
if a.rollback:
record = json.loads(Path(a.journal).read_text(encoding="utf-8"))
if Path(a.root).resolve() != Path(record["root"]):
p.error("저널의 원본 폴더가 다릅니다")
record = rollback(record)
Path(a.journal).write_text(json.dumps(record, ensure_ascii=False, indent=2), encoding="utf-8")
else:
record = plan(a.root)
if a.apply:
record = apply(record, a.journal)
print(json.dumps(record, ensure_ascii=False, indent=2))
실제 검산한 결과
드라이런에는 세 이동 계획이 있으며 디렉터리와 저널은 새로 생기지 않았습니다. 적용 후 세 파일의 SHA-256이 원래 값과 일치했고, 복구 후 모든 원래 상대 경로와 파일 바이트가 일치했습니다. PDF/report.pdf와 PDF/report_1.pdf는 처음부터 끝까지 그대로였습니다.
ZIP에 포함한 9개 회귀 테스트에는 두 번째 이동에서 의도적으로 OSError를 발생시키는 사례도 있습니다. 앞서 이동한 파일을 되돌리고 저널을 rolled_back_after_error로 남기는 결과를 확인했습니다. 이동한 파일의 내용을 나중에 바꾸면 자동 복구는 멈춥니다. 새 내용을 지워 예전 상태로 강제로 맞추지 않습니다.


중단됐을 때의 복구 기준
같은 --apply 실행에서 생성한 계획 이후 입력 해시가 바뀌거나 목적지가 점유되면 사전 검사에서 이동 전에 중단합니다. --apply는 앞선 드라이런 출력을 읽지 않고 현재 폴더에서 계획을 새로 만들므로, 두 명령 사이의 변경을 이전 계획과 비교해 거부하지는 않습니다. 드라이런 확인 뒤 적용할 때까지 실습 사본을 변경하지 마세요. 성공 저널이라도 원래 경로에 새 파일이 있거나 이동한 내용이 달라졌다면 복구를 보류하므로 사본끼리 대조해야 합니다.
in_progress 저널은 강제 종료나 비정상 중단을 뜻할 수 있습니다. 이 코드는 그 상태에서 자동 복구를 약속하지 않습니다. 원본·목적지 양쪽의 존재와 해시를 확인하고 별도 사본을 만든 뒤 수동 복구하세요. 완료 저널만 --rollback으로 처리합니다. 적용 중의 예외 복구도 운영체제의 파일 쓰기 실패까지 모두 극복하는 것은 아닙니다.
사용 범위와 남는 위험
같은 볼륨에서 하드 링크를 지원하는 일반 파일만 대상입니다. 외장 장치 경계를 넘거나 클라우드 동기화 폴더, 여러 프로그램이 동시에 쓰는 폴더에는 적용하지 마세요. 실행 전 다른 쓰기 작업을 멈춘 실습 사본에서 확인해야 합니다. 이 코드는 파일 잠금·충돌 방지 트랜잭션·강제 종료 후 자동 재개 도구가 아닙니다. 백업을 대체하지도 않습니다.
Path.iterdir는 해당 디렉터리의 항목을 순회합니다. 이 예제는 그 결과 중 바로 아래의 일반 파일만 계획에 넣고, 하위 폴더를 재귀적으로 정리하지 않습니다.
Python 3.12.14 문서를 참고 자료로 사용했습니다. ZIP에 실행 기록을 함께 넣었습니다.

공식 자료
pathlib — Object-oriented filesystem paths
출처 원문 수집일: S1 2026-09-23.