파이썬 자동화 파일 정리 - pathlib shutil 로 폴더 분류와 미리보기 (파이썬 자동화 1단원)
이 단원에서 배우는 것
지금까지 문법을 익히면서 서점 재고표를 다뤘다. 이번 자동화 1단원부터 자동화 7단원까지는 문법 설명을 줄이고 실제로 매일 손으로 하던 일을 스크립트로 넘기는 연습을 한다. 모든 예제는 인터넷 없이 파이썬만 설치돼 있으면 돌아가고, 외부 패키지를 설치하지 않는다. 단원마다 같은 순서로 진행한다.
- 문제 상황 — 어떤 귀찮은 일을 없애려는지
- 완성 스크립트 — 복사해서 바로 실행할 수 있는 전체 코드
- 한 줄씩 해설 — 왜 이렇게 짰는지
- 실행 결과 — 실제로 돌려서 나온 출력 그대로
- 실무에서 자주 틀리는 것과 스스로 확인하기
첫 번째 과제는 파일 정리다. 이 단원에서 다룰 내용은 다음과 같다.
pathlib.Path로 폴더 안의 파일을 훑고 확장자를 읽는다.shutil.move로 파일을 옮기고, 같은 이름이 있으면 번호를 붙여 피한다.- 기본은 미리보기, 실제 실행은 명시적으로라는 자동화의 첫 번째 안전 원칙을 익힌다.
문제 상황
서점 사무실 컴퓨터의 받은파일 폴더에는 메일과 메신저로 받은 파일이 쌓인다. 매출 엑셀, 재고 CSV, 행사 사진, 계약서 한글 파일, 설치 파일이 한 폴더에 섞여 있어서 필요한 파일을 찾을 때마다 한참 스크롤한다. 금요일마다 누군가 손으로 폴더를 만들고 끌어다 놓는데, 매번 30분씩 걸리고 실수로 지난주 보고서를 덮어쓴 적도 있다.
원하는 결과는 이렇다.
- 확장자를 보고
문서,표,이미지,압축,기타폴더로 나눈다. - 옮길 곳에 같은 이름이 있으면 덮어쓰지 않고
보고서 (1).pdf처럼 번호를 붙인다. - 무엇을 옮길지 먼저 보여 주고, 사람이 확인한 뒤에만 실제로 옮긴다.
세 번째 조건이 이 단원의 핵심이다. 파일을 옮기거나 지우는 스크립트는 한 번 잘못 돌면 수백 개 파일을 한꺼번에 망가뜨린다. 손으로 할 때는 한 번에 파일 하나를 잘못 옮기지만, 스크립트는 실수도 자동으로 반복한다. 그래서 이 책의 자동화 스크립트는 전부 "아무 옵션 없이 실행하면 계획만 출력한다"는 규칙을 따른다. 이것을 흔히 dry-run(시험 실행)이라고 부른다.
완성 스크립트
organize.py로 저장한다. 8단원에서 본 대로 함수를 나누고 맨 아래에 if __name__ == "__main__":을 둔다.
"""받은 파일 폴더를 확장자별 하위 폴더로 정리한다. 기본은 미리보기다."""
import shutil
import sys
from pathlib import Path
RULES = {
"문서": {".pdf", ".docx", ".hwp", ".txt"},
"표": {".csv", ".xlsx"},
"이미지": {".jpg", ".jpeg", ".png"},
"압축": {".zip", ".gz"},
}
def category_of(path):
ext = path.suffix.lower()
for name, extensions in RULES.items():
if ext in extensions:
return name
return "기타"
def free_name(target):
candidate = target
n = 1
while candidate.exists():
candidate = target.with_name(f"{target.stem} ({n}){target.suffix}")
n += 1
return candidate
def organize(folder, apply):
count = 0
for path in sorted(folder.iterdir()):
if not path.is_file() or path.name.startswith("."):
continue
dest_dir = folder / category_of(path)
target = free_name(dest_dir / path.name)
label = "이동" if apply else "예정"
print(f"{label}: {path.name} -> {target.relative_to(folder)}")
if apply:
dest_dir.mkdir(exist_ok=True)
shutil.move(path, target)
count += 1
return count
def main():
if len(sys.argv) < 2:
print("사용법: python3 organize.py 폴더 [--apply]")
sys.exit(2)
folder = Path(sys.argv[1])
apply = "--apply" in sys.argv[2:]
if not folder.is_dir():
print(f"폴더가 아닙니다: {folder}")
sys.exit(1)
count = organize(folder, apply)
if apply:
print(f"{count}개 파일을 옮겼습니다.")
else:
print(f"{count}개 파일을 옮길 예정입니다. 실제로 옮기려면 --apply 를 붙이세요.")
if __name__ == "__main__":
main()
한 줄씩 해설
규칙을 데이터로 분리한다 — RULES
맨 위의 RULES는 "폴더 이름 → 그 폴더에 들어갈 확장자 집합"을 담은 딕셔너리다. 값이 리스트가 아니라 셋({".pdf", ...})인 이유는 3단원에서 본 대로 in 검사가 빠르고, 같은 확장자를 두 번 적어도 문제가 없기 때문이다. 분류 규칙을 if ext == ".pdf" or ext == ".docx" ... 같은 조건문으로 쓰지 않고 데이터로 빼 두면, 동료가 .pptx를 추가해 달라고 할 때 로직을 건드리지 않고 한 줄만 고치면 된다.
표 · organize.py 의 분류 규칙 RULES
| 분류 폴더 | 확장자 |
|---|---|
문서 | .docx, .hwp, .pdf, .txt |
표 | .csv, .xlsx |
이미지 | .jpeg, .jpg, .png |
압축 | .gz, .zip |
기타 | 위 어디에도 없는 확장자와 확장자가 없는 파일 |
category_of — 확장자는 소문자로 맞춰서 비교한다
path.suffix는 마지막 점부터 끝까지를 돌려준다. 행사사진.JPG의 suffix는 ".JPG"다. 카메라나 스마트폰이 만든 파일은 대문자 확장자가 흔하므로 .lower()로 맞춘 다음 비교한다. 이 한 줄을 빼면 사진 파일이 전부 기타로 간다. 어느 규칙에도 걸리지 않으면 "기타"를 돌려준다. 분류하지 못한 파일을 건너뛰지 않고 한곳에 모으면, 나중에 기타 폴더를 보고 규칙을 보완할 수 있다.
free_name — 덮어쓰기를 막는 반복문
target.exists()가 참인 동안 이름 뒤에 (1), (2)를 붙여 가며 비어 있는 이름을 찾는다. target.stem은 확장자를 뺀 이름(보고서), target.with_name(...)은 폴더는 그대로 두고 파일 이름만 바꾼 새 경로를 만든다. 문자열을 잘라 붙이는 대신 Path의 속성을 쓰면 폴더 이름에 점이 들어 있어도 안전하다.
shutil.move는 옮길 곳에 같은 이름의 파일이 있으면 운영체제에 따라 경고 없이 덮어쓴다. 파일 정리 스크립트에서 가장 흔한 사고가 이것이라 이 함수가 따로 있다.
organize — 목록을 먼저 확정하고 나서 옮긴다
for path in sorted(folder.iterdir()):
folder.iterdir()는 폴더 안의 항목을 하나씩 내준다. 그 결과를 sorted()로 감싼 것에는 이유가 두 가지 있다. 첫째, 출력 순서가 매번 같아서 미리보기와 실제 실행 결과를 눈으로 비교하기 쉽다. 둘째, 반복하는 도중에 폴더 내용을 바꾸지 않게 된다. sorted()는 목록을 전부 읽어 리스트로 만든 다음 돌기 때문에, 반복 중에 파일을 옮기고 새 폴더를 만들어도 반복 대상이 흔들리지 않는다. 4단원에서 리스트를 돌면서 원소를 지우면 안 된다고 한 것과 같은 원리다.
if not path.is_file() or path.name.startswith("."):
continue
폴더(이전에 만든 문서 등)와 숨김 파일은 건너뛴다. 맥의 .DS_Store처럼 운영체제가 만든 숨김 파일을 옮기면 파인더 설정이 꼬인다.
그다음 줄이 안전장치의 핵심이다.
label = "이동" if apply else "예정"
print(f"{label}: {path.name} -> {target.relative_to(folder)}")
if apply:
dest_dir.mkdir(exist_ok=True)
shutil.move(path, target)
계획을 출력하는 코드와 실제로 옮기는 코드가 같은 반복문 안에 있다. 미리보기용 함수와 실행용 함수를 따로 만들면 언젠가 둘이 달라지고, 미리보기에서는 멀쩡했는데 실행하면 다른 일이 벌어진다. 한 반복문 안에서 apply 값으로만 갈라지게 하면 "보여 준 것 = 실행한 것"이 코드 구조로 보장된다. mkdir(exist_ok=True)는 폴더가 이미 있어도 오류를 내지 않는다.
그림 · 미리보기와 실제 실행이 한 반복문에서 갈린다 — 파일마다 분류 폴더와 겹치지 않는 이름을 먼저 정하고 계획을 출력한다. --apply 가 없으면 여기서 끝나고, 있을 때만 폴더를 만들고 옮긴다.
main — 옵션은 가장 단순하게
sys.argv는 명령행에 입력한 단어들의 리스트다. python3 organize.py 받은파일 --apply라고 입력하면 ["organize.py", "받은파일", "--apply"]가 된다. 옵션이 하나뿐이라 "--apply" in sys.argv[2:]로 충분하다. 옵션이 늘어나면 자동화 7단원의 argparse로 바꾼다. 인자가 부족하면 사용법을 출력하고 sys.exit(2)로 끝낸다. 종료 코드 0은 성공, 0이 아니면 실패라는 약속은 자동화 7단원에서 예약 실행과 함께 다시 다룬다.
실행 결과
실습용 폴더에는 파일 10개와 이미 정리된 문서/보고서.pdf가 들어 있다. 먼저 옵션 없이 실행한다.
$ python3 organize.py 받은파일
예정: 9월_매출.xlsx -> 표/9월_매출.xlsx
예정: 계약서.hwp -> 문서/계약서.hwp
예정: 메모.txt -> 문서/메모.txt
예정: 보고서.pdf -> 문서/보고서 (1).pdf
예정: 설치파일.dmg -> 기타/설치파일.dmg
예정: 자료모음.zip -> 압축/자료모음.zip
예정: 재고.csv -> 표/재고.csv
예정: 표지.png -> 이미지/표지.png
예정: 행사사진.JPG -> 이미지/행사사진.JPG
9개 파일을 옮길 예정입니다. 실제로 옮기려면 --apply 를 붙이세요.
아무것도 옮기지 않았다. 보고서.pdf는 문서 폴더에 같은 이름이 있어서 보고서 (1).pdf로 갈 예정이라는 것도 미리 보인다. .DS_Store는 목록에 없다. 계획이 맞으면 --apply를 붙인다.
표 · 미리보기 출력(organize-dry)에서 뽑은 이동 계획
| 원래 파일 | 분류 폴더 | 옮길 이름 |
|---|---|---|
| 9월_매출.xlsx | 표 | 9월_매출.xlsx |
| 계약서.hwp | 문서 | 계약서.hwp |
| 메모.txt | 문서 | 메모.txt |
| 보고서.pdf | 문서 | 보고서 (1).pdf |
| 설치파일.dmg | 기타 | 설치파일.dmg |
| 자료모음.zip | 압축 | 자료모음.zip |
| 재고.csv | 표 | 재고.csv |
| 표지.png | 이미지 | 표지.png |
| 행사사진.JPG | 이미지 | 행사사진.JPG |
$ python3 organize.py 받은파일 --apply
이동: 9월_매출.xlsx -> 표/9월_매출.xlsx
이동: 계약서.hwp -> 문서/계약서.hwp
이동: 메모.txt -> 문서/메모.txt
이동: 보고서.pdf -> 문서/보고서 (1).pdf
이동: 설치파일.dmg -> 기타/설치파일.dmg
이동: 자료모음.zip -> 압축/자료모음.zip
이동: 재고.csv -> 표/재고.csv
이동: 표지.png -> 이미지/표지.png
이동: 행사사진.JPG -> 이미지/행사사진.JPG
9개 파일을 옮겼습니다.
정리 후 폴더는 이렇게 된다.
.DS_Store
기타/
설치파일.dmg
문서/
계약서.hwp
메모.txt
보고서 (1).pdf
보고서.pdf
압축/
자료모음.zip
이미지/
표지.png
행사사진.JPG
표/
9월_매출.xlsx
재고.csv
그림 · 정리 전후의 받은파일 폴더 — --apply 로 실행하면 파일 9개가 확장자별 폴더로 옮겨진다. 이미 있던 문서/보고서.pdf 는 그대로 두고 새로 옮긴 파일은 보고서 (1).pdf 가 된다. 숨김 파일 .DS_Store 는 건드리지 않는다.
한 번 더 실행하면 옮길 파일이 없다고 나온다. 몇 번을 실행해도 결과가 같은 성질을 멱등성이라고 한다. 예약 실행을 걸 스크립트라면 반드시 갖춰야 할 성질이다.
$ python3 organize.py 받은파일
0개 파일을 옮길 예정입니다. 실제로 옮기려면 --apply 를 붙이세요.
실무에서 자주 틀리는 것
1. 확장자를 문자열 자르기로 구한다
name.split(".")[1]처럼 쓰면 점이 여러 개인 이름에서 틀린다. Path가 이미 경우를 나눠 두었으니 그대로 쓴다. 실제로 확인해 보자.
from pathlib import Path
for name in ["행사사진.JPG", "backup.tar.gz", ".DS_Store", "README"]:
p = Path(name)
print(f"{name:<14} suffix={p.suffix!r:<8} suffixes={p.suffixes} stem={p.stem!r}")
$ python3 demo_suffix.py
행사사진.JPG suffix='.JPG' suffixes=['.JPG'] stem='행사사진'
backup.tar.gz suffix='.gz' suffixes=['.tar', '.gz'] stem='backup.tar'
.DS_Store suffix='' suffixes=[] stem='.DS_Store'
README suffix='' suffixes=[] stem='README'
눈여겨볼 것이 세 가지다. 대문자 확장자는 그대로 대문자로 나온다. backup.tar.gz의 suffix는 .gz뿐이라서, .tar.gz를 한 덩어리로 다루려면 suffixes를 봐야 한다. 점으로 시작하는 숨김 파일과 확장자 없는 파일은 suffix가 빈 문자열이다. 우리 스크립트에서는 확장자가 없으면 기타로 간다.
2. 미리보기 없이 바로 옮기는 버전부터 만든다
"일단 돌아가게 만들고 나중에 안전장치를 붙이자"고 하면 대개 붙이지 않는다. 그리고 첫 실행에서 폴더 경로를 잘못 넣어 바탕화면 전체를 정리해 버린다. 순서를 뒤집는다. 출력만 하는 버전을 먼저 만들고, 출력이 맞는 것을 확인한 다음 if apply: 아래에 실제 동작을 넣는다. 이 책의 나머지 자동화 스크립트도 전부 이 순서로 만들었다.
3. 테스트를 진짜 폴더에서 한다
첫 실행은 반드시 복사본 폴더에서 한다. 탐색기나 파인더에서 폴더를 통째로 복사하거나, 파이썬으로 shutil.copytree("받은파일", "받은파일_연습")을 한 줄 실행하면 된다. 미리보기가 있어도 규칙 자체가 틀렸을 수 있다. 예를 들어 .txt를 문서로 분류했는데 사실은 프로그램 설정 파일이었다면 미리보기로는 알아채기 어렵다.
4. 윈도우에서 경로에 역슬래시를 그대로 쓴다
폴더 경로를 코드 안에 적을 때 역슬래시를 그대로 쓰면 두 가지 사고가 난다. "C:\Users\서점"은 \U가 유니코드 이스케이프로 해석되어 실행 전에 SyntaxError가 난다. 더 나쁜 쪽은 "D:\data\new"다. 오류 없이 실행되는데 \n이 줄바꿈 문자로 바뀌어 전혀 다른 경로를 찾는다(파이썬 3.12부터는 \d에 대해 SyntaxWarning만 한 번 보여 준다). 경로는 명령행 인자로 받거나, 코드에 적어야 한다면 Path("C:/Users/서점/new")처럼 슬래시를 쓴다. 윈도우에서도 파이썬은 슬래시 경로를 정상으로 받아들인다. 7단원에서 본 대로 Path끼리는 / 연산자로 잇는다.
스스로 확인하기
- 파워포인트 파일(
.pptx)을문서에, 영상 파일(.mp4,.mov)을 새 폴더영상에 넣도록 바꿔라. 몇 줄을 고쳐야 하는가? - 하위 폴더 안에 있는 파일까지 정리하려고
folder.iterdir()를folder.rglob("*")로 바꿨다. 두 번째 실행에서 무슨 일이 벌어지는가? - 30일이 지난 파일만 옮기고 싶다.
path.stat().st_mtime(마지막 수정 시각, 1970년부터 흐른 초)과time.time()을 이용해 조건을 한 줄로 써라.
정답
RULES만 고친다."문서"집합에".pptx"를 넣고"영상": {".mp4", ".mov"},한 줄을 추가하면 끝이다. 로직은 한 줄도 바뀌지 않는다. 규칙을 데이터로 분리한 효과다.rglob은 방금 만든문서,표폴더 안까지 내려간다. 이미 정리된문서/계약서.hwp가 다시 대상이 되고, 옮길 곳이 자기 자신이라free_name이계약서 (1).hwp를 만들어 낸다. 실행할 때마다 번호 붙은 복사본 이름이 늘어나 멱등성이 깨진다. 하위 폴더까지 볼 거라면 분류 폴더(RULES의 키와기타)는 건너뛰는 조건을 반드시 넣는다.if time.time() - path.stat().st_mtime < 30 * 24 * 60 * 60: continue— 맨 위에import time을 추가하고is_file()검사 바로 아래에 둔다. 초 단위라서 30일을 초로 바꿔 비교한다. 날짜 계산을 더 읽기 쉽게 하려면 13단원의datetime과timedelta(days=30)를 쓴다.
다음 단원에서는 파일을 옮기는 대신 이름을 바꾼다. 수십 개 파일의 이름을 한 번에 바꿀 때는 옮기기보다 함정이 많다. 정렬 순서, 이미 있는 이름, 그리고 잘못 바꿨을 때 되돌리는 방법을 다룬다.