[파이썬 100강] 52강. shutil로 파일 복사·이동·백업 자동화하기
파일 자동화 스크립트를 만들다 보면 open()과 Path.rename()만으로는 해결되지 않는 순간이 꼭 옵니다. 예를 들어 “원본 메타데이터(수정 시간, 권한)까지 보존해서 복사해야 한다”, “폴더째 백업하고 오래된 백업은 정리해야 한다”, “실수로 덮어쓰지 않도록 안전장치가 필요하다” 같은 요구는 생각보다 빨리 등장합니다. 이때 표준 라이브러리 shutil을 제대로 쓰면 코드를 짧게 유지하면서도 운영 안정성을 크게 끌어올릴 수 있습니다.
이번 강의의 핵심은 단순 함수 암기가 아닙니다. copy, copy2, move, copytree, rmtree 같은 기능을 언제 어떤 기준으로 선택할지, 그리고 자동화 작업에서 가장 위험한 “조용한 데이터 손실”을 어떻게 막을지까지 함께 다룹니다. 초보가 자주 하는 오해는 “복사만 되면 됐지, 어떤 함수든 비슷하다”인데요. 실제 실무에서는 메타데이터 보존 여부, 대상 경로 존재 여부, 예외 처리 방식에 따라 결과가 완전히 달라집니다.
핵심 개념
shutil은 파일/디렉터리 단위 작업(복사, 이동, 삭제, 아카이브)을 안전하고 일관된 API로 제공하는 표준 라이브러리입니다.- 파일 복사는 목적에 따라 함수가 다릅니다.
copyfile(내용만),copy(권한 일부 포함),copy2(메타데이터 최대 보존)처럼 선택 기준이 존재합니다. - 운영 자동화에서는 “복사 성공” 자체보다 “무엇을 언제 왜 복사했는지 추적 가능”한 로그와 검증 절차가 더 중요합니다.
shutil을 처음 접하면 함수가 많아 보여 헷갈릴 수 있습니다. 하지만 기준은 단순합니다. “파일 1개를 옮기는가?”, “폴더 전체를 복제하는가?”, “메타데이터까지 유지해야 하는가?”, “실패 시 되돌릴 수 있는가?” 이 네 가지 질문에 답하면 대부분의 선택이 정리됩니다. 특히 백업 자동화에서는 복사 직후 검증(파일 수, 크기 합, 핵심 파일 존재 확인)을 넣지 않으면, 스크립트는 성공 로그를 남겼는데 실제로는 불완전한 백업이 만들어지는 위험이 있습니다.
또한 shutil은 pathlib과 함께 사용할 때 가장 강력합니다. 경로 조합/탐색은 Path, 파일 조작은 shutil로 역할을 분리하면 코드 가독성이 크게 올라가고, 리뷰 시 의도를 파악하기 쉬워집니다. 이번 강의에서는 이런 “역할 분리 패턴”도 함께 익혀 보겠습니다.
왜 중요한가
백업과 파일 정리는 거의 모든 팀의 공통 과제입니다. 데이터팀은 원천 파일 아카이브를 만들고, 앱팀은 배포 산출물을 버전별로 보관하며, 운영팀은 로그를 주기적으로 이동·압축합니다. 이때 가장 무서운 사고는 화려한 장애가 아니라, 눈에 잘 띄지 않는 누락입니다. 예를 들어 특정 하위 폴더가 복사에서 빠졌는데도 작업이 끝난 것처럼 보이면, 복구가 필요한 날에야 문제가 터집니다.
shutil을 제대로 쓰면 이런 리스크를 줄일 수 있습니다. 함수 선택 기준을 통일해 두면 팀 내 코드 품질이 일정해지고, 로그 포맷과 검증 절차를 붙이기 쉬워집니다. 특히 copytree(..., dirs_exist_ok=True) 같은 옵션은 기존 디렉터리 갱신 작업에서 매우 실용적이지만, 잘못 쓰면 오래된 파일이 남아 “반쯤 최신 상태”가 되는 위험도 있습니다. 그래서 “어떤 옵션을 왜 켰는지”를 코드와 주석으로 남기는 습관이 중요합니다.
결국 이 주제가 중요한 이유는, 파일 자동화가 인프라 신뢰도와 직접 연결되기 때문입니다. 잘 짠 shutil 스크립트 하나는 수동 작업 시간을 줄이는 수준을 넘어, 사고 가능성을 구조적으로 낮춰 줍니다.
기본 사용
예제 1) 가장 기본 패턴: 파일 1개 안전 복사
>>> from pathlib import Path
>>> import shutil
>>> src = Path("samples/report.txt")
>>> dst = Path("backup/report.txt")
>>> dst.parent.mkdir(parents=True, exist_ok=True)
>>> _ = shutil.copy2(src, dst)
>>> dst.exists()
True
해설:
copy2는 가능한 범위에서 메타데이터(수정 시간 등)까지 보존하므로 백업 기본값으로 자주 사용됩니다.- 대상 폴더가 없으면 복사 전에
mkdir(parents=True, exist_ok=True)를 먼저 수행해 실패를 줄입니다.
예제 2) 조건/반복/조합 확장: 특정 확장자만 일괄 백업
>>> from pathlib import Path
>>> import shutil
>>> src_root = Path("daily_logs")
>>> dst_root = Path("daily_logs_backup")
>>> copied = 0
>>> for p in src_root.rglob("*.log"):
... rel = p.relative_to(src_root)
... target = dst_root / rel
... target.parent.mkdir(parents=True, exist_ok=True)
... shutil.copy2(p, target)
... copied += 1
>>> copied >= 0
True
해설:
relative_to를 이용하면 원본 폴더 구조를 유지한 채 백업할 수 있습니다.- 파일 수를 카운트해 결과를 남기면, 다음 실행과 비교해 이상 징후를 빨리 발견할 수 있습니다.
예제 3) 실전형 미니 케이스: 아카이브 생성과 이동
>>> import shutil
>>> from pathlib import Path
>>> out_base = Path("artifacts/release_2026_02_17")
>>> out_base.parent.mkdir(parents=True, exist_ok=True)
>>> archive_path = shutil.make_archive(str(out_base), "zip", root_dir="build")
>>> archive_path.endswith(".zip")
True
>>> final_path = shutil.move(archive_path, "dist/release_2026_02_17.zip")
>>> final_path
'dist/release_2026_02_17.zip'
해설:
make_archive로 압축 파일을 만들고,move로 배포/보관 위치를 분리할 수 있습니다.- 빌드 결과와 최종 배포 경로를 분리하면 롤백·재배포 자동화가 쉬워집니다.
예제 4) 백업 전후 검증 패턴
>>> from pathlib import Path
>>> src_files = sorted(Path("data").rglob("*.csv"))
>>> dst_files = sorted(Path("data_backup").rglob("*.csv"))
>>> len(src_files), len(dst_files)
(12, 12)
>>> all(f.name for f in dst_files)
True
해설:
- “복사 성공 메시지”만 믿지 말고, 최소한 파일 개수와 핵심 파일 존재 여부를 검증하세요.
- 검증 코드는 짧아도 효과가 큽니다. 운영 사고의 상당수를 이 단계에서 막을 수 있습니다.
자주 하는 실수
실수 1) copy와 copy2를 구분하지 않아 메타데이터 손실
>>> import shutil
>>> shutil.copy("input/report.csv", "backup/report.csv")
'backup/report.csv'
원인:
- 파일 내용만 같으면 충분하다고 생각해 메타데이터 요구사항(수정 시간, 권한 맥락)을 놓쳤습니다.
- 나중에 “가장 최근 파일”을 수정 시간으로 판단하는 로직에서 오동작할 수 있습니다.
해결:
>>> import shutil
>>> shutil.copy2("input/report.csv", "backup/report.csv")
'backup/report.csv'
copy2를 기본값으로 두고, 정말 메타데이터가 필요 없을 때만 copy/copyfile을 선택하는 식으로 팀 규칙을 정하면 실수를 줄일 수 있습니다.
실수 2) 대상 경로가 없는데 바로 복사해서 FileNotFoundError
>>> import shutil
>>> shutil.copy2("input/a.txt", "backup/2026/02/a.txt")
Traceback (most recent call last):
... FileNotFoundError: [Errno 2] No such file or directory: 'backup/2026/02/a.txt'
원인:
- 파일 복사 함수는 “부모 디렉터리 생성”까지 자동으로 해주지 않습니다.
- 특히 반복문에서 상대 경로를 생성할 때 이 실수가 자주 발생합니다.
해결:
>>> from pathlib import Path
>>> import shutil
>>> target = Path("backup/2026/02/a.txt")
>>> target.parent.mkdir(parents=True, exist_ok=True)
>>> shutil.copy2("input/a.txt", target)
PosixPath('backup/2026/02/a.txt')
복사 직전 parent.mkdir(...)를 공통 함수로 감싸 두면, 여러 스크립트에서 같은 문제를 반복하지 않습니다.
실수 3) rmtree를 검증 없이 사용해 잘못된 디렉터리 삭제
- 증상: 정리 스크립트를 실행했더니 예상보다 상위 폴더가 삭제되어 복구 작업이 필요해집니다.
- 원인: 문자열 경로 오타 또는 변수 값 검증 없이
shutil.rmtree(path)를 호출했습니다. - 해결: 삭제 전 안전장치(허용 루트 경로 검사, dry-run 출력, 사용자 확인 플래그)를 두고, 운영에서는 즉시 삭제 대신 휴지통/격리 폴더 이동 전략을 우선 검토합니다.
삭제 작업은 “코드가 짧다”가 장점이 아닙니다. “실수해도 크게 망가지지 않도록 제한한다”가 핵심입니다.
실무 패턴
- 입력 검증 규칙: 원본 경로 존재, 대상 루트 경로 허용 목록 포함 여부, 파일 개수 임계치(예: 0개면 경고)를 먼저 검사합니다.
- 로그/예외 처리 규칙: 파일 단위 실패는 누적 기록하고 전체 작업은 계속 진행하되, 마지막에 성공/실패 통계를 반드시 출력합니다.
- 재사용 함수/구조화 팁:
discover_targets(),copy_with_parents(),verify_backup(),write_report()로 단계를 분리하면 테스트와 유지보수가 쉬워집니다. - 성능/메모리 체크 포인트: 초대용량 디렉터리는 한 번에 전체 목록을 메모리에 올리지 말고 iterator 기반으로 처리하고, 필요하면 날짜/확장자 단위로 배치 실행합니다.
추가로, 운영 백업에서는 “무결성 체크”가 중요합니다. 단순히 파일 수만 맞는지 보는 것을 넘어, 핵심 산출물은 해시(SHA256) 비교를 넣는 편이 안전합니다. 물론 모든 파일에 해시를 적용하면 비용이 커질 수 있으므로, 비즈니스 중요도가 높은 파일부터 우선 적용하는 방식이 현실적입니다. 마지막으로, 배포 전에는 --dry-run 모드를 제공해 어떤 파일이 어디로 이동/복사되는지 먼저 보여 주는 습관을 권장합니다.
오늘의 결론
한 줄 요약: shutil 자동화의 본질은 복사 함수 호출이 아니라, 데이터 손실을 막는 선택 기준과 검증 루틴입니다.
기억할 것:
- 파일 백업 기본값은
copy2로 두고, 목적에 따라 함수를 명시적으로 선택하세요. - 복사 전 경로 검증, 복사 후 결과 검증(개수/핵심 파일/로그)을 루틴화하세요.
- 삭제/이동 작업은 반드시 안전장치(dry-run, 허용 경로 검사)와 함께 운영하세요.
연습문제
source_reports/하위의.csv만 찾아backup_reports/로 폴더 구조를 유지하며 복사하는 함수를 작성하세요. (부모 폴더 자동 생성 포함)- 백업 작업 후 원본/대상
.csv파일 개수를 비교하고, 불일치 시 경고 메시지를 출력하는 검증 함수를 작성하세요. archives/폴더를 날짜 기반 이름(archive_YYYYMMDD.zip)으로 압축 생성한 뒤dist/로 이동하는 스크립트를 작성하세요. 실패 시 예외 타입과 경로를 로그 리스트에 저장하세요.
이전 강의 정답
reports/하위.csv탐색 + 0바이트 제외
>>> from pathlib import Path
>>> def discover_csv_files(root: str):
... base = Path(root)
... return [p for p in base.rglob("*.csv") if p.is_file() and p.stat().st_size > 0]
>>> isinstance(discover_csv_files("reports"), list)
True
- 파일명 오름차순 정렬 + (이름, 크기) 출력
>>> files = sorted(discover_csv_files("reports"), key=lambda p: p.name)
>>> pairs = [(p.name, p.stat().st_size) for p in files]
>>> pairs[:2] if pairs else []
[]
- UTF-8 첫 줄 수집 + 실패 리스트 기록
>>> def collect_first_lines(paths):
... first_lines, failed = [], []
... for p in paths:
... try:
... with p.open("r", encoding="utf-8") as f:
... first_lines.append((p.name, f.readline().rstrip("\n")))
... except Exception as e:
... failed.append((p.name, type(e).__name__))
... return first_lines, failed
>>> out, failed = collect_first_lines(files)
>>> isinstance(out, list) and isinstance(failed, list)
True
실습 환경/재현 정보
- 실행 환경:
condaenvpython100(Python 3.11.14) - 가정한 OS: macOS/Linux 공통
- 사용 모듈:
pathlib,shutil(표준 라이브러리) - 재현 절차:
- 샘플 디렉터리(
source_reports,backup_reports,build)를 생성 - REPL에서 예제 순서대로 실행
- 실행 후 파일 개수/경로/압축 결과를 확인
- 샘플 디렉터리(
- 검증 포인트: 복사 성공 수, 실패 수, 누락 파일 유무, 압축 파일 생성 경로