Python 파일 자동화 | 파일 정리, 이름 변경, 백업 자동화
이 글의 핵심
파일을 옮기거나 지우는 스크립트는 한 번 잘못 돌리면 되돌리기 어렵습니다. 실제로 바꾸기 전에 결과만 출력해 보는 드라이런, 작업 전 백업, 무엇을 했는지 남기는 로그를 기본으로 넣어야 하는 이유를 설명하고, 로그 파일 정리 스크립트에 이 습관을 그대로 적용해 봅니다.
들어가며
Python으로 파일 작업을 자동화하면 시간을 크게 절약할 수 있습니다. 다운로드 폴더 정리, 사진 이름 일괄 변경, 매일 밤 프로젝트 폴더 백업처럼 규칙이 분명한 반복 작업은 스크립트로 만들어 두면 한 번의 실행이나 예약 작업으로 끝납니다.
대신 파일 스크립트는 다른 자동화보다 실수의 대가가 큽니다. 휴지통을 거치지 않고 지워지고, 이름 변경은 기존 파일을 덮어쓸 수 있으며, 수천 개 파일에 같은 실수가 한 번에 적용됩니다. 이 글의 예제는 짧게 쓰기 위해 안전장치를 생략한 곳이 있어서, 각 예제 뒤에 그대로 쓰면 무엇이 잘못될 수 있는지를 함께 설명합니다. 파일 읽기·쓰기 자체의 기초는 파일 처리에서 다뤘습니다.
glob과 조건으로 파일 찾기
특정 확장자 파일 찾기
pathlib.Path.glob과 **는 폴더 서랍을 열어 같은 확장자만 골라 담는 작업을 재귀적으로 해 줍니다. 예를 들어 프로젝트 전체에서 .pdf만 모을 때 수작업으로 찾기보다 훨씬 빠릅니다.
from pathlib import Path
def find_files(directory, extension):
"""특정 확장자 파일 찾기"""
path = Path(directory)
return list(path.glob(f'**/*.{extension}'))
# 사용
pdf_files = find_files('.', 'pdf')
for file in pdf_files:
print(file)
glob('**/*.pdf')의 **는 “현재 폴더와 모든 하위 폴더”를 뜻하고, path.rglob('*.pdf')와 같습니다. 결과는 Path 객체라 file.stem(확장자 뺀 이름), file.suffix, file.stat().st_size처럼 바로 속성을 쓸 수 있어 문자열로 경로를 자르고 붙이는 것보다 실수가 적습니다.
패턴 매칭의 대소문자 규칙이 운영체제마다 다르다는 점은 알아 둬야 합니다. Windows에서는 *.pdf가 REPORT.PDF도 찾지만 Linux와 macOS 기본 설정에서는 찾지 못합니다. 휴대폰에서 옮긴 사진이 .JPG로 되어 있어 Linux 서버에서만 일부 파일이 누락되는 일이 흔합니다. Python 3.12부터는 glob(..., case_sensitive=False)를 쓸 수 있고, 그 이전 버전이라면 rglob('*')로 전부 가져온 뒤 file.suffix.lower() == '.pdf'로 거르는 방법이 확실합니다. node_modules나 .git처럼 파일이 매우 많은 폴더가 섞여 있으면 재귀 검색이 느려지므로 검색 시작 위치를 좁히는 것도 중요합니다.
조건부 파일 찾기
import os
from datetime import datetime, timedelta
def find_old_files(directory, days=30):
"""N일 이상 된 파일 찾기"""
cutoff = datetime.now() - timedelta(days=days)
old_files = []
for root, dirs, files in os.walk(directory):
for file in files:
filepath = Path(root) / file
mtime = datetime.fromtimestamp(filepath.stat().st_mtime)
if mtime < cutoff:
old_files.append(filepath)
return old_files
# 사용
old_files = find_old_files('.', days=90)
print(f"{len(old_files)}개의 오래된 파일")
os.walk는 폴더를 하나씩 내려가며 (현재 폴더, 하위 폴더 목록, 파일 목록)을 돌려줍니다. pathlib만으로도 rglob('*')로 같은 일을 할 수 있지만, os.walk는 dirs 리스트를 수정해 특정 폴더를 건너뛸 수 있다는 장점이 있습니다. 반복문 안에서 dirs[:] = [d for d in dirs if d != '.git']처럼 제자리 수정하면 그 폴더로 내려가지 않습니다(dirs = ...로 새 리스트를 대입하면 효과가 없습니다).
st_mtime은 내용이 마지막으로 수정된 시각입니다. 파일을 복사하거나 압축을 풀면 도구에 따라 mtime이 복사 시각으로 바뀌거나 원래 값이 유지되므로, “90일 이상 된 파일”의 기준이 생각과 다를 수 있습니다. Windows의 st_ctime은 생성 시각이지만 Unix 계열에서는 메타데이터 변경 시각이라 생성 시각 대용으로 쓰면 안 됩니다. 또 순회 도중 다른 프로그램이 파일을 지우면 stat()에서 FileNotFoundError가, 접근 권한이 없는 파일에서는 PermissionError가 나서 전체 스캔이 중단되므로, 긴 스캔에서는 파일 단위로 예외를 잡아 건너뛰는 편이 좋습니다.
일괄 이름 변경과 순번 붙이기
일괄 이름 변경
from pathlib import Path
def rename_files(directory, old_pattern, new_pattern):
"""파일 이름 일괄 변경"""
path = Path(directory)
for file in path.glob('*'):
if old_pattern in file.name:
new_name = file.name.replace(old_pattern, new_pattern)
file.rename(file.parent / new_name)
print(f"{file.name} → {new_name}")
# 사용
rename_files('.', 'old_', 'new_')
file.name.replace(old, new)는 이름 안의 모든 일치 부분을 바꾸므로, old_old_report.txt는 new_new_report.txt가 됩니다. 접두어만 바꾸려면 file.name.startswith(old_pattern)을 확인하고 앞부분만 교체해야 합니다. 폴더도 glob('*') 결과에 포함되므로 파일만 대상으로 하려면 file.is_file() 검사를 추가합니다.
가장 위험한 부분은 rename()의 덮어쓰기 동작이 운영체제마다 다르다는 점입니다. 이미 new_report.txt가 있는 상태에서 old_report.txt를 같은 이름으로 바꾸면, Windows에서는 FileExistsError가 나지만 Linux와 macOS에서는 기존 파일을 경고 없이 덮어씁니다. Windows에서 테스트한 스크립트를 서버에서 돌렸다가 파일이 사라지는 일이 실제로 흔합니다. 이름을 바꾸기 전에 if target.exists():로 확인하고 건너뛰거나 번호를 붙이는 처리를 넣으세요. 모든 플랫폼에서 덮어쓰기를 원한다면 replace()를 쓰면 의도가 분명해집니다.
또 print를 rename 뒤에 두었는데, file.name은 여전히 바꾸기 전 이름을 가리킵니다. Path 객체는 불변이라 rename()은 새 경로를 담은 새 객체를 반환하기 때문입니다.
순번 추가
def add_numbers(directory, extension):
"""파일에 순번 추가"""
path = Path(directory)
files = sorted(path.glob(f'*.{extension}'))
for i, file in enumerate(files, 1):
new_name = f"{i:03d}_{file.name}"
file.rename(file.parent / new_name)
print(f"{file.name} → {new_name}")
# 사용
add_numbers('./images', 'jpg')
# photo.jpg → 001_photo.jpg
sorted()로 목록을 먼저 확정한 뒤 이름을 바꾸는 점이 중요합니다. glob()이 돌려주는 반복자를 그대로 순회하면서 이름을 바꾸면, 운영체제와 Python 버전에 따라 바뀐 파일이 다시 목록에 잡혀 번호가 두 번 붙을 수 있습니다. 변경 작업 전에는 항상 list()나 sorted()로 대상 목록을 고정하는 것이 안전합니다.
정렬 순서도 확인해야 합니다. sorted()는 문자열 순서라 img10.jpg가 img2.jpg보다 앞에 옵니다. 촬영 순서대로 번호를 붙이고 싶다면 sorted(files, key=lambda f: f.stat().st_mtime)처럼 수정 시각을 기준으로 삼거나, 사진의 EXIF 촬영 시각을 읽어야 합니다. 그리고 이 함수는 두 번 실행하면 001_001_photo.jpg가 됩니다. 이미 번호가 붙은 파일을 건너뛰는 검사가 없으면 실수로 다시 실행했을 때 되돌리기 번거롭습니다.
확장자별 폴더 정리
확장자별 폴더 정리
import shutil
from pathlib import Path
def organize_files(directory):
"""확장자별로 폴더 정리"""
path = Path(directory)
for file in path.iterdir():
if file.is_file():
# 확장자 추출
ext = file.suffix[1:] # .jpg → jpg
if ext:
# 폴더 생성
target_dir = path / ext
target_dir.mkdir(exist_ok=True)
# 파일 이동
shutil.move(str(file), str(target_dir / file.name))
print(f"{file.name} → {ext}/")
# 사용
organize_files('./downloads')
iterdir()는 하위 폴더로 내려가지 않고 현재 폴더의 항목만 돌려줍니다. 이 함수는 파일을 downloads/jpg/ 같은 하위 폴더로 옮기므로, rglob처럼 재귀 검색을 쓰면 이미 옮긴 파일을 다시 처리하게 됩니다. 확장자가 없는 파일(README, Makefile)은 suffix가 빈 문자열이라 if ext:에서 건너뜁니다.
실제로 쓰려면 두 가지를 보완해야 합니다. 첫째, photo.JPG와 image.jpg가 JPG/와 jpg/ 두 폴더로 나뉩니다. Windows와 macOS 기본 파일 시스템은 대소문자를 구분하지 않아 같은 폴더로 합쳐지지만 Linux에서는 따로 생기므로 ext.lower()로 통일하는 편이 좋습니다. 둘째, 대상 폴더에 같은 이름의 파일이 있으면 shutil.move도 rename과 마찬가지로 Linux·macOS에서는 기존 파일을 덮어씁니다. 다운로드 폴더에는 report.pdf와 이미 정리된 pdf/report.pdf가 공존하는 경우가 많아서, 두 번째 실행에서 예전 파일이 조용히 사라지는 일이 생깁니다. 이동 전에 대상 경로가 존재하는지 확인하고 report (1).pdf처럼 이름을 바꾸는 처리를 넣으세요.
자동 백업과 오래된 백업 삭제
백업 스크립트
import shutil
from pathlib import Path
from datetime import datetime
def backup_directory(source, backup_root):
"""디렉토리 백업"""
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
backup_name = f"backup_{timestamp}"
backup_path = Path(backup_root) / backup_name
# 백업 실행
shutil.copytree(source, backup_path)
print(f"백업 완료: {backup_path}")
# 압축
shutil.make_archive(str(backup_path), 'zip', backup_path)
shutil.rmtree(backup_path) # 원본 폴더 삭제
print(f"압축 완료: {backup_path}.zip")
# 사용
backup_directory('./project', './backups')
폴더 이름에 %Y%m%d_%H%M%S 형식의 타임스탬프를 붙이면 백업마다 이름이 겹치지 않고, 문자열 정렬 순서가 곧 시간 순서가 됩니다. copytree는 대상 폴더가 이미 있으면 FileExistsError를 내므로(dirs_exist_ok=True를 주지 않는 한) 같은 초 안에 두 번 실행하면 실패합니다. 백업을 덮어쓰지 않는다는 점에서는 오히려 안전한 동작입니다.
이 함수는 전체를 복사한 뒤 압축하고 복사본을 지우는 방식이라 원본 크기만큼의 여유 공간이 추가로 필요합니다. shutil.make_archive(str(backup_path), 'zip', root_dir=source)로 원본 폴더를 바로 압축하면 복사 단계를 생략할 수 있습니다. 또 backup_root가 source 안에 있으면(./project/backups처럼) 백업을 만들 때마다 이전 백업까지 포함되어 크기가 눈덩이처럼 불어나므로, 백업 위치는 원본 바깥에 두어야 합니다. 대용량 폴더나 원격 서버로 증분 백업을 해야 한다면 rsync나 전용 백업 도구가 더 적합합니다.
오래된 백업 삭제
def cleanup_old_backups(backup_dir, keep_count=5):
"""최신 N개만 유지"""
path = Path(backup_dir)
backups = sorted(path.glob('backup_*.zip'), key=lambda x: x.stat().st_mtime)
# 오래된 것 삭제
for backup in backups[:-keep_count]:
backup.unlink()
print(f"삭제: {backup.name}")
# 사용
cleanup_old_backups('./backups', keep_count=5)
수정 시각 오름차순으로 정렬했으므로 앞쪽이 오래된 백업이고, backups[:-keep_count]는 뒤에서 keep_count개를 뺀 나머지, 즉 삭제 대상입니다. 백업이 5개 이하면 빈 리스트가 되어 아무것도 지우지 않습니다.
슬라이싱 트릭에는 함정이 하나 있습니다. keep_count=0을 넘기면 backups[:-0]은 backups[:0]과 같아 아무것도 지우지 않습니다. “전부 지우기”를 의도했다면 반대로 동작하는 셈이라, keep_count가 1 이상인지 검사하거나 backups[:len(backups) - keep_count]로 쓰는 편이 명확합니다. 정렬 기준으로 mtime 대신 파일 이름의 타임스탬프를 쓰면, 백업 파일을 다른 디스크로 복사해 mtime이 바뀌어도 순서가 유지됩니다.
해시로 중복 파일 찾기
해시 기반 중복 검사
import hashlib
from collections import defaultdict
def find_duplicates(directory):
"""중복 파일 찾기 (MD5 해시)"""
hashes = defaultdict(list)
for file in Path(directory).rglob('*'):
if file.is_file():
# 파일 해시 계산
with open(file, 'rb') as f:
file_hash = hashlib.md5(f.read()).hexdigest()
hashes[file_hash].append(file)
# 중복 파일 출력
duplicates = {h: files for h, files in hashes.items() if len(files) > 1}
for hash_val, files in duplicates.items():
print(f"\n중복 그룹 ({hash_val[:8]}...):")
for file in files:
print(f" - {file}")
return duplicates
# 사용
duplicates = find_duplicates('./documents')
파일 이름이 달라도 내용이 같으면 해시가 같으므로, 해시를 키로 파일 목록을 모으면 중복 그룹이 만들어집니다. MD5는 보안 용도로는 충돌 공격이 알려져 쓰면 안 되지만, 누군가 일부러 충돌을 만드는 상황이 아닌 중복 검사에는 빠르고 충분합니다. 걱정된다면 hashlib.sha256으로 바꿔도 코드는 같습니다.
f.read()로 파일 전체를 메모리에 올리는 부분은 동영상처럼 큰 파일이 섞여 있으면 메모리를 크게 차지합니다. iter(lambda: f.read(65536), b'')로 조각씩 읽어 update()하거나, Python 3.11 이상이면 hashlib.file_digest(f, 'md5')를 쓰면 됩니다. 속도도 개선할 수 있습니다. 크기가 다른 파일은 내용이 같을 수 없으므로 먼저 st_size로 묶고, 크기가 같은 파일끼리만 해시를 계산하면 대부분의 파일은 읽을 필요조차 없어집니다. 이 함수는 중복을 찾기만 하는데, 여기서 바로 삭제까지 붙이고 싶어지더라도 어느 쪽을 남길지(가장 오래된 것, 특정 폴더에 있는 것) 규칙을 정하고 목록을 눈으로 확인한 뒤 지우는 단계를 따로 두는 것이 안전합니다.
로그 파일 정리 스크립트
from pathlib import Path
import gzip
from datetime import datetime, timedelta
def cleanup_logs(log_dir, archive_days=7, delete_days=30):
"""
로그 파일 정리:
- 7일 이상: 압축
- 30일 이상: 삭제
"""
path = Path(log_dir)
now = datetime.now()
for log_file in path.glob('*.log'):
mtime = datetime.fromtimestamp(log_file.stat().st_mtime)
age = (now - mtime).days
if age >= delete_days:
# 삭제
log_file.unlink()
print(f"삭제: {log_file.name} ({age}일)")
elif age >= archive_days:
# 압축
gz_path = log_file.with_suffix('.log.gz')
with open(log_file, 'rb') as f_in:
with gzip.open(gz_path, 'wb') as f_out:
f_out.writelines(f_in)
log_file.unlink()
print(f"압축: {log_file.name} → {gz_path.name}")
# 사용
cleanup_logs('./logs', archive_days=7, delete_days=30)
조건 순서에 이유가 있습니다. 삭제 조건(30일)을 먼저 검사해야 40일 된 파일이 압축되지 않고 바로 지워집니다. 순서를 바꾸면 30일 넘은 파일도 “7일 이상”에 먼저 걸려 압축만 되고 삭제되지 않습니다. with_suffix('.log.gz')는 마지막 확장자 .log를 .log.gz로 바꾸므로 app.log는 app.log.gz가 됩니다. f_out.writelines(f_in)은 파일을 줄 단위로 읽어 쓰므로 큰 로그도 메모리에 한 번에 올리지 않습니다.
이 스크립트는 *.log만 대상으로 하므로 한 번 압축된 .log.gz 파일은 30일이 지나도 지워지지 않습니다. 압축 시점에 원본이 지워지면서 날짜 기준이 되는 파일이 .gz로 바뀌기 때문에, 삭제 대상에 *.log.gz도 포함해야 합니다. 또 지금 쓰이고 있는 로그를 건드리지 않도록 주의해야 합니다. 며칠째 기록 중인 로그라도 mtime은 마지막 기록 시각이라 대상에서 빠지지만, 로그를 거의 쓰지 않는 서비스라면 활성 로그가 7일 조건에 걸릴 수 있습니다. 프로세스가 열어 둔 파일을 압축 후 unlink()하면 Linux에서는 프로세스가 지워진 파일에 계속 기록해 디스크 공간이 반환되지 않고, Windows에서는 PermissionError가 납니다. 서버 로그라면 Python 스크립트보다 logrotate나 로깅 라이브러리의 TimedRotatingFileHandler에 맡기는 편이 이런 문제를 피할 수 있습니다.
백업·드라이런·로그로 실수 줄이기
파일을 지우거나 옮기는 스크립트는 한 번 잘못 돌리면 되돌리기 어렵습니다. 먼저 대상만 출력하는 테스트 모드로 범위를 확인하며, 중요한 디렉터리는 복사본을 둔 뒤 실행하는 습관이 안전합니다. 예외는 PermissionError처럼 원인이 다른 종류로 나누어 잡으면 디버깅이 빨라집니다.
# ✅ 안전한 파일 작업
# 1. 백업 먼저
# 2. 테스트 모드 (실제 작업 전 확인)
# 3. 로깅
# ✅ 에러 처리
try:
shutil.move(src, dst)
except PermissionError:
print("권한 없음")
except FileNotFoundError:
print("파일 없음")
# ✅ 진행 상황 표시
from tqdm import tqdm
for file in tqdm(files, desc="처리 중"):
process(file)
드라이런은 함수에 dry_run=True 인자를 기본값으로 두고, 참이면 print(f"[DRY RUN] {src} → {dst}")만 하고 실제 rename·move·unlink는 건너뛰는 식으로 만듭니다. 기본값을 True로 두는 것이 핵심인데, 실수로 인자 없이 실행해도 아무것도 바뀌지 않고 목록만 출력되기 때문입니다. 출력된 목록을 확인한 뒤 dry_run=False를 명시해 실행합니다.
처음 파일 정리 스크립트를 짜 보면 경로 하나를 잘못 넘겨 생각보다 넓은 범위를 건드리는 실수를 한 번쯤 하게 됩니다. 예를 들어 organize_files('.')를 스크립트가 있는 폴더가 아니라 홈 디렉터리에서 실행하면 홈의 모든 파일이 확장자 폴더로 흩어집니다. 드라이런 출력의 첫 몇 줄만 봐도 이런 실수는 바로 드러나고, 처리한 파일의 원래 경로와 새 경로를 로그 파일에 남겨 두면 문제가 생겼을 때 그 로그를 읽어 되돌리는 스크립트를 만들 수도 있습니다. tqdm은 별도 설치(pip install tqdm)가 필요한 외부 패키지입니다.
파일 자동화 요약
- 파일 찾기: glob, rglob
- 이름 변경: rename()
- 파일 이동: shutil.move()
- 백업: copytree(), make_archive()
- 중복 검사: 해시 비교
다음 단계
같이 보면 좋은 글
- Python 파일 처리
- Python 실전 데이터 분석 | Pandas로 데이터 분석하기
- Python 웹 스크래핑
- Python 실전 시리즈 전체 목차 | #01~#23 학습 경로·영문 글·연관 글
- Python File Automation | Organize, Rename, and Back Up Files
자주 묻는 질문 (FAQ)
Q. 파일을 옮기거나 지우는 스크립트를 실수 없이 처음 돌리려면 어떻게 하나요?
A. 삭제나 이동은 되돌리기 어렵기 때문에, 먼저 실제 작업 없이 대상 파일 목록만 출력하는 테스트 모드(드라이런)로 범위를 확인합니다. 중요한 디렉터리는 복사본을 만들어 둔 뒤 실행하고, 처리한 파일을 로그로 남겨 나중에 추적할 수 있게 합니다. shutil.move 같은 작업은 PermissionError와 FileNotFoundError를 나눠 잡으면 어떤 파일에서 왜 실패했는지 빠르게 파악할 수 있습니다.