파이썬 백업 스크립트 - datetime zipfile 로 날짜별 압축과 보관 개수 관리 (파이썬 자동화 5단원)
이 단원에서 배우는 것
자동화 스크립트가 파일을 옮기고, 이름을 바꾸고, 보고서를 덮어쓰기 시작하면 되돌릴 수 있는 사본이 필요해진다. 이번 단원에서는 작업 폴더를 날짜가 붙은 zip 파일로 백업하고, 백업이 끝없이 쌓이지 않게 최근 몇 개만 남기는 스크립트를 만든다.
datetime으로 정렬 가능한 날짜·시각 문자열을 만든다.zipfile로 폴더를 압축하고, 만든 압축 파일이 정상인지 검사한다.- 임시 이름으로 쓰고 다 끝나면 이름을 바꿔서 반쯤 만들어진 백업이 남지 않게 한다.
- 오래된 백업을 정리하는 보관 개수 관리(로테이션)를 한다.
문제 상황
서점 사무실의 서점자료 폴더에는 재고표와 주문 기록이 있다. 지금까지는 담당자가 가끔 생각날 때 폴더를 USB에 복사했다. 날짜별로 따로 복사하지 않고 덮어써서, 어제 실수로 지운 파일이 백업에서도 이미 지워져 있던 적이 있다.
원하는 것은 이렇다.
- 실행할 때마다
서점자료-20260923-020000.zip처럼 날짜와 시각이 이름에 들어간 새 백업을 만든다. - 임시 파일(
.tmp), 로그(.log), 파이썬 캐시(__pycache__)처럼 백업할 가치가 없는 것은 뺀다. - 백업은 최근 3개만 남긴다. 디스크가 차면 백업이 아예 안 되기 때문이다.
- 백업 도중에 멈추거나 압축이 깨지면, 깨진 파일을 정상 백업으로 착각하지 않게 한다.
완성 스크립트
backup.py로 저장한다. 사용법은 python3 backup.py 원본폴더 백업폴더다.
"""폴더를 날짜가 붙은 zip 으로 백업하고, 오래된 백업은 최근 N개만 남긴다."""
import sys
import zipfile
from datetime import datetime
from pathlib import Path
SKIP_DIRS = {"__pycache__", ".git", "node_modules"}
SKIP_SUFFIXES = {".tmp", ".log"}
KEEP = 3
def should_skip(rel_path):
if any(part in SKIP_DIRS for part in rel_path.parts):
return True
return rel_path.suffix in SKIP_SUFFIXES
def make_backup(source, backup_dir, now):
backup_dir.mkdir(parents=True, exist_ok=True)
name = f"{source.name}-{now:%Y%m%d-%H%M%S}.zip"
final = backup_dir / name
partial = backup_dir / (name + ".part")
count = 0
with zipfile.ZipFile(partial, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for path in sorted(source.rglob("*")):
rel = path.relative_to(source)
if path.is_dir() or should_skip(rel):
continue
zf.write(path, arcname=rel.as_posix())
count += 1
with zipfile.ZipFile(partial) as zf:
bad = zf.testzip()
if bad is not None:
partial.unlink()
raise RuntimeError(f"압축 검사 실패: {bad}")
partial.rename(final)
return final, count
def rotate(backup_dir, prefix, keep):
backups = sorted(backup_dir.glob(f"{prefix}-*.zip"))
old = backups[:-keep] if len(backups) > keep else []
for path in old:
path.unlink()
return old
def main():
if len(sys.argv) != 3:
print("사용법: python3 backup.py 원본폴더 백업폴더")
sys.exit(2)
source, backup_dir = Path(sys.argv[1]), Path(sys.argv[2])
if not source.is_dir():
print(f"원본 폴더가 없습니다: {source}")
sys.exit(1)
if backup_dir.resolve().is_relative_to(source.resolve()):
print("백업 폴더를 원본 폴더 안에 두면 안 됩니다.")
sys.exit(1)
final, count = make_backup(source, backup_dir, datetime.now())
size_kb = final.stat().st_size / 1024
print(f"백업 완료: {final.name} ({count}개 파일, {size_kb:.1f}KB)")
for path in rotate(backup_dir, source.name, KEEP):
print(f"오래된 백업 삭제: {path.name}")
if __name__ == "__main__":
main()
한 줄씩 해설
파일 이름에 넣는 날짜는 %Y%m%d-%H%M%S
name = f"{source.name}-{now:%Y%m%d-%H%M%S}.zip"
f-string 중괄호 안에서 datetime 값 뒤에 :를 붙이고 서식을 적으면 strftime과 같은 결과가 나온다. %Y는 네 자리 연도, %m은 두 자리 월, %d는 두 자리 일, %H%M%S는 24시간제 시·분·초다. 이 형식을 고른 이유는 문자열 순서 = 시간 순서이기 때문이다. 2026-9-3이나 9월 23일 같은 형식은 문자열로 정렬하면 시간 순서가 깨진다. 년·월·일·시·분·초를 큰 단위부터, 빈자리를 0으로 채워 적으면 sorted() 한 번으로 오래된 순서가 된다. 아래 rotate가 이 성질에 기대고 있다.
파일 이름에는 콜론(:)을 쓰지 않는다. 윈도우에서 파일 이름에 쓸 수 없는 문자다. 그래서 isoformat()(2026-09-23T02:00:00)을 파일 이름에 그대로 쓰지 않는다.
should_skip — 제외 규칙
rel_path.parts는 경로를 폴더 단위로 쪼갠 튜플이다. 재고/작업중.tmp면 ("재고", "작업중.tmp")다. 경로 어딘가에 __pycache__ 같은 제외 폴더가 끼어 있으면 건너뛴다. 그다음 확장자로 한 번 더 거른다. 자동화 1단원처럼 규칙을 맨 위 상수(SKIP_DIRS, SKIP_SUFFIXES)로 빼 두었다.
표 · backup.py 의 제외 규칙과 보관 개수
| 상수 | 값 | 이유 |
|---|---|---|
SKIP_DIRS | .git, __pycache__, node_modules | 언제든 다시 만들 수 있는 캐시·저장소 |
SKIP_SUFFIXES | .log, .tmp | 작업 중 임시 파일과 계속 커지는 로그 |
KEEP | 3 | 최근 백업 수. 나머지는 오래된 것부터 지운다 |
make_backup — 임시 이름으로 쓰고, 검사하고, 이름을 바꾼다
partial = backup_dir / (name + ".part")
with zipfile.ZipFile(partial, "w", compression=zipfile.ZIP_DEFLATED) as zf:
압축은 .zip.part라는 임시 이름으로 쓴다. "w" 모드는 7단원의 파일 쓰기와 같다. ZIP_DEFLATED를 주지 않으면 압축하지 않고 묶기만 한다(기본값이 ZIP_STORED다). 텍스트 파일은 압축률이 높으니 꼭 준다.
source.rglob("*")는 하위 폴더까지 모든 항목을 내준다. sorted()로 감싸 순서를 고정했다. 폴더는 건너뛰고 파일만 넣는데, zip은 파일 경로 안에 폴더 정보가 들어 있어서 폴더를 따로 넣을 필요가 없다.
zf.write(path, arcname=rel.as_posix())
arcname은 압축 파일 안에서 쓸 이름이다. 원본 폴더 기준의 상대 경로(재고/inventory.csv)를 준다. as_posix()는 윈도우에서도 경로 구분자를 /로 바꿔 준다. zip 형식의 표준 구분자가 /라서, 이렇게 해야 어느 운영체제에서 풀어도 폴더 구조가 같다. 이걸 빠뜨렸을 때 무슨 일이 생기는지는 아래 "자주 틀리는 것"에서 직접 본다.
with zipfile.ZipFile(partial) as zf:
bad = zf.testzip()
다 쓴 뒤 파일을 다시 열어 testzip()으로 모든 항목의 체크섬을 검사한다. 문제가 없으면 None, 깨진 항목이 있으면 그 이름을 돌려준다. 깨졌으면 임시 파일을 지우고 예외를 던진다. 검사를 통과해야만 partial.rename(final)으로 정식 이름을 붙인다.
이 순서가 중요한 이유는 rotate에 있다. 정리 함수는 서점자료-*.zip만 백업으로 인정한다. 전원이 나가거나 디스크가 차서 압축 도중에 멈추면 .zip.part 파일만 남고, 이 파일은 정리 대상에도 복구 대상에도 끼지 않는다. 만약 처음부터 정식 이름으로 썼다면, 반쯤 쓰인 zip이 "가장 최근 백업"으로 남고 멀쩡한 옛 백업이 정리돼 지워질 수 있다. 같은 디스크 안에서 이름 바꾸기는 한 번에 일어나는 동작이라 중간 상태가 없다.
그림 · 임시 이름으로 쓰고 검사한 뒤 이름을 바꾼다 — 압축은 .zip.part 에 쓰고 testzip 으로 검사한 다음에야 .zip 으로 이름을 바꾼다. 도중에 멈추면 .part 만 남고, 보관 개수 계산(glob '서점자료-*.zip')에는 절대 끼지 않는다.
표 · 어느 단계에서 멈추면 무엇이 남는가
| 멈춘 시점 | 남는 파일 | 다음 실행 때 |
|---|---|---|
| ① 압축 도중 | 반쯤 쓴 ….zip.part | 새 이름으로 다시 만든다. rotate 는 .part 를 보지 않으므로 옛 백업이 지워지지 않는다(남은 .part 는 손으로 지운다) |
| ② 검사 실패 | 없음 (.part 를 지우고 RuntimeError) | 원인을 고친 뒤 다시 실행한다 |
| ③ 이름을 바꾼 뒤 | 완성된 .zip | 정상 백업으로 세어 보관 개수에 들어간다 |
| ④ rotate 도중 | 지우다 만 오래된 백업 | 다음 실행의 rotate 가 마저 지운다 |
rotate — 최근 N개만 남긴다
backups = sorted(backup_dir.glob(f"{prefix}-*.zip"))
old = backups[:-keep] if len(backups) > keep else []
이름이 곧 시간 순서이므로 정렬하면 오래된 것이 앞에 온다. backups[:-keep]은 "뒤에서 keep개를 뺀 나머지"다. 3단원의 슬라이싱이다. 개수가 keep 이하면 아무것도 지우지 않는다. 지운 목록을 돌려줘서 main이 무엇을 지웠는지 출력하게 했다. 지우는 스크립트는 무엇을 지웠는지 반드시 말해야 한다.
glob 패턴에 원본 폴더 이름을 넣은 것도 안전장치다. 같은 백업 폴더에 다른 폴더의 백업(회계자료-*.zip)이 함께 있어도, 이 스크립트는 자기가 만든 백업만 센다.
그림 · 최근 3개만 남기는 보관 개수 회전 — 이름에 날짜와 시각이 들어 있어 이름순 정렬이 곧 시간순이다. 새 백업을 만든 뒤 정렬하고 뒤에서 3개를 남기므로 0919·0920 두 개가 지워진다(backup-run 실행 결과).
main — 백업 폴더가 원본 안에 있으면 거절한다
backup_dir.resolve().is_relative_to(source.resolve())는 백업 폴더가 원본 폴더 안쪽인지 검사한다. 원본 안에 백업을 만들면 다음 백업에 이전 백업 zip이 통째로 들어가고, 그다음 백업은 그 두 개를 또 담는다. 백업 크기가 실행할 때마다 두 배로 불어난다. resolve()는 ..이나 상대 경로를 절대 경로로 풀어서, 표기가 달라도 같은 위치인지 비교할 수 있게 한다.
실행 결과
실습용 서점자료 폴더에는 재고표, 주문 JSON, 임시 파일, 로그, 파이썬 캐시가 있고, backups 폴더에는 9월 19일부터 22일까지 이전 백업 네 개가 있다. 출력의 시각은 실제로 실행한 시각이다.
$ python3 backup.py 서점자료 backups
백업 완료: 서점자료-20260923-235024.zip (2개 파일, 0.3KB)
오래된 백업 삭제: 서점자료-20260919-020000.zip
오래된 백업 삭제: 서점자료-20260920-020000.zip
파일 다섯 개 중 백업 대상은 두 개뿐이다. 새 백업이 생기면서 백업이 다섯 개가 되었고, 가장 오래된 두 개가 정리됐다.
서점자료-20260921-020000.zip
서점자료-20260922-020000.zip
서점자료-20260923-235024.zip
만든 압축 파일 안을 보려면 파이썬에 들어 있는 zipfile 명령행 기능을 쓴다. 운영체제의 압축 프로그램이 없어도 된다.
$ python3 -m zipfile -l backups/서점자료-20260923-235024.zip
File Name Modified Size
재고/inventory.csv 2026-09-23 23:50:22 1550
주문.json 2026-09-23 23:50:22 360
작업중.tmp, app.log, __pycache__가 빠졌고, 경로는 원본 폴더 기준이다. 백업 폴더를 원본 안에 두려고 하면 거절한다.
$ python3 backup.py 서점자료 서점자료/backups
백업 폴더를 원본 폴더 안에 두면 안 됩니다.
실무에서 자주 틀리는 것
1. arcname을 빠뜨린다
import zipfile
from pathlib import Path
source = Path("서점자료").resolve()
target = source / "주문.json"
with zipfile.ZipFile("no_arcname.zip", "w") as zf:
zf.write(target)
with zipfile.ZipFile("with_arcname.zip", "w") as zf:
zf.write(target, arcname=target.relative_to(source).as_posix())
for name in ["no_arcname.zip", "with_arcname.zip"]:
with zipfile.ZipFile(name) as zf:
stored = zf.namelist()[0]
print(f"{name:<17} 안의 이름 길이 {len(stored):>3}자, 마지막 부분: ...{stored[-20:]}")
$ python3 demo_arcname.py
no_arcname.zip 안의 이름 길이 84자, 마지막 부분: ...nxwv7ak/서점자료/주문.json
with_arcname.zip 안의 이름 길이 7자, 마지막 부분: ...주문.json
arcname 없이 넣으면 파일을 넣을 때 준 경로가 거의 그대로 압축 파일 안의 이름이 된다. 절대 경로를 줬다면 앞의 /만 떼고 사용자 폴더부터 시작하는 긴 경로가 들어간다. 풀면 Users/서점/Desktop/... 같은 깊은 폴더가 생기고, 다른 사람에게 보낸 백업에 내 컴퓨터의 폴더 구조와 사용자 이름이 그대로 드러난다. 항상 arcname을 원본 기준 상대 경로로 준다.
2. 백업만 만들고 복원을 한 번도 해 보지 않는다
백업이 있다는 것과 복원할 수 있다는 것은 다른 이야기다. 제외 규칙에 실수로 .csv를 넣었다면 백업은 매일 성공하는데 정작 재고표는 한 번도 백업되지 않았다. 한 달에 한 번은 백업 하나를 빈 폴더에 풀어서(python3 -m zipfile -e 백업.zip 복원시험) 중요한 파일이 들어 있는지 열어 본다. testzip()은 압축이 깨지지 않았다는 것만 확인할 뿐, 필요한 파일이 들어 있는지는 확인하지 않는다.
3. 같은 디스크에만 백업한다
원본과 백업이 같은 디스크에 있으면 디스크가 고장 날 때 둘 다 잃는다. 랜섬웨어처럼 컴퓨터 전체를 망가뜨리는 사고에도 소용없다. 이 스크립트의 두 번째 인자를 외장 디스크나 NAS 경로로 주고, 그 백업을 주기적으로 다른 장소로 옮기는 것까지가 백업이다.
4. 파일 수정 시각으로 오래된 백업을 고른다
파일을 복사하거나 다른 디스크로 옮기면 수정 시각이 바뀔 수 있다. 그러면 st_mtime 기준으로 "가장 오래된" 백업이 실제로는 가장 최근 것일 수 있다. 이름에 날짜를 넣고 이름으로 정렬하면 이 문제가 없다. 대신 이름 형식을 바꾸면 정렬 순서가 깨지므로, 형식은 처음 정한 대로 유지한다.
스스로 확인하기
- 이 스크립트를 1초 안에 두 번 실행하면 어떻게 되는가? 이름 형식을 어떻게 바꾸면 막을 수 있는가?
- 보관 규칙을 "최근 3개"에서 "최근 7일"로 바꾸고 싶다. 파일 이름에서 날짜를 다시 읽어
datetime으로 바꾸려면 어떤 함수를 쓰는가? - 백업 대상이 0개(원본 폴더가 비었거나 전부 제외됨)일 때도 빈 zip을 만들고 오래된 백업을 지운다. 왜 위험하며 어떻게 고치겠는가?
정답
- 같은 초에 두 번 실행하면 두 번째 실행이 같은 이름의
.part파일을"w"로 열어 첫 번째 것을 덮어쓰고,rename도 같은 이름을 덮어쓴다(윈도우에서는FileExistsError). 서식에 마이크로초%f를 더하거나,final.exists()면 잠시 기다렸다 다시 만들거나, 자동화 7단원에서 다룰 "이미 실행 중이면 끝내기" 장치를 쓴다. datetime.strptime(stamp, "%Y%m%d-%H%M%S")로 읽는다. 파일 이름서점자료-20260921-020000.zip에서path.stem의 마지막 15글자(path.stem[-15:])를 떼어 넘기고,now - 그 값 > timedelta(days=7)이면 지운다. 이 경우에도 최소 1개는 남기는 조건을 함께 둔다.- 원본 경로를 잘못 줬거나 네트워크 드라이브 연결이 끊겨 빈 폴더로 보이면, 매일 빈 백업이 쌓이고 사흘 뒤에는 멀쩡한 옛 백업이 전부 정리된다.
count == 0이면 백업 파일을 지우고 예외를 던져rotate까지 가지 않게 한다. "평소보다 파일 수가 절반 이하로 줄면 경고"처럼 이전 백업과 비교하는 검사도 효과가 크다.
다음 단원에서는 내 컴퓨터 밖의 데이터를 가져온다. 웹 API를 호출해 JSON을 받고 파일로 남기는 과정을, 인터넷 없이도 연습할 수 있게 내 컴퓨터에 작은 연습 서버를 띄워서 해 본다.