[파이썬 100강] 90강. 최종 미니 프로젝트로 파일 정리·리포트 자동화 완성하기

[파이썬 100강] 90강. 최종 미니 프로젝트로 파일 정리·리포트 자동화 완성하기

이제 마지막 강의입니다. 이론을 길게 끌지 않고, 앞에서 배운 표준 라이브러리 도구를 묶어서 실제로 돌릴 수 있는 자동화 스크립트를 완성해 보겠습니다.


핵심 개념

  • 자동화 스크립트의 핵심은 "한 번 실행하면 같은 결과를 안정적으로 재현"하는 구조입니다.
  • 파일 수집(pathlib/glob) → 규칙 적용(collections) → 결과 저장(csv/json) → 로그(logging)의 파이프라인으로 설계하면 유지보수가 쉬워집니다.
  • "코드가 돌아간다"와 "운영 가능한 코드"는 다릅니다. 입력 검증, 예외 처리, 실행 기록이 있어야 운영에서 버틸 수 있습니다.

많은 초보자가 마지막에 부딪히는 문제는, 기능을 하나씩 붙이다가 스크립트가 길어지고 흐름이 꼬이는 것입니다. 이때 중요한 건 "문제 해결 순서"를 코드 구조에 그대로 반영하는 겁니다. 예를 들어 파일 정리 자동화라면 1) 어떤 파일을 대상에 넣을지, 2) 분류 규칙은 무엇인지, 3) 결과를 어디에 기록할지, 4) 실패 시 어디서 원인을 확인할지를 먼저 정하고 함수로 분리해야 합니다. 이 순서만 지켜도 테스트가 쉬워지고, 이후 요구사항이 바뀌어도 특정 단계만 교체하면 됩니다. 이번 강의는 이 관점으로 작은 자동화 프로젝트를 끝까지 마무리하는 데 집중합니다.

기본 사용

예제 1) 대상 파일 수집 + 확장자 기준 분류

>>> from pathlib import Path
>>> from collections import defaultdict
>>>
>>> def collect_files(root: Path):
...     groups = defaultdict(list)
...     for path in root.rglob("*"):
...         if path.is_file():
...             ext = path.suffix.lower() or "(noext)"
...             groups[ext].append(path)
...     return groups
...
>>> # demo 폴더가 있다고 가정
>>> # groups = collect_files(Path("demo"))
>>> # sorted(groups.keys())[:3]
>>> print("확장자별로 파일을 먼저 그룹화하면 다음 단계(복사/이동/리포트)가 단순해진다")
확장자별로 파일을 먼저 그룹화하면 다음 단계(복사/이동/리포트)가 단순해진다

해설:

  • 전체 흐름의 첫 단계는 "무엇을 처리할지"를 안정적으로 수집하는 것입니다.
  • defaultdict(list)를 쓰면 키 초기화 코드를 줄이고 분류 로직을 명확하게 유지할 수 있습니다.

예제 2) 안전한 정리 작업

>>> import shutil
>>> from pathlib import Path
>>>
>>> def organize_by_ext(src: Path, dst: Path, dry_run=True):
...     moved = []
...     for file in src.rglob("*"):
...         if not file.is_file():
...             continue
...         ext = file.suffix.lower().lstrip(".") or "noext"
...         target_dir = dst / ext
...         target_dir.mkdir(parents=True, exist_ok=True)
...         target = target_dir / file.name
...         if dry_run:
...             moved.append((str(file), str(target), "DRY"))
...         else:
...             shutil.copy2(file, target)
...             moved.append((str(file), str(target), "COPIED"))
...     return moved
...
>>> print("dry_run=True를 기본값으로 두면 운영 실수를 크게 줄일 수 있다")
dry_run=True를 기본값으로 두면 운영 실수를 크게 줄일 수 있다

해설:

  • 실무 자동화에서 가장 위험한 순간은 "처음 실파일에 실행"할 때입니다.
  • 기본값을 dry_run=True로 두면 검토 후 실행하는 습관이 생기고, 데이터 훼손 위험이 줄어듭니다.

예제 3) 실행 결과를 CSV 리포트로 남기기

>>> import csv
>>> from datetime import datetime
>>> from pathlib import Path
>>>
>>> def write_report(rows, out_dir: Path):
...     out_dir.mkdir(parents=True, exist_ok=True)
...     ts = datetime.now().strftime("%Y%m%d_%H%M%S")
...     report_file = out_dir / f"organize_report_{ts}.csv"
...     with report_file.open("w", newline="", encoding="utf-8") as f:
...         writer = csv.writer(f)
...         writer.writerow(["source", "target", "status"])
...         writer.writerows(rows)
...     return report_file
...
>>> print("자동화는 실행 그 자체보다 기록이 중요하다. 나중에 검증 가능해야 한다")
자동화는 실행 그 자체보다 기록이 중요하다. 나중에 검증 가능해야 한다

해설:

  • 로그만으로는 사람이 훑기 어려운 경우가 많습니다.
  • CSV 보고서를 남기면 "무엇이 어디로 갔는지"를 팀원과 쉽게 공유할 수 있습니다.

예제 4) 최종 실행 함수로 파이프라인 연결

>>> import logging
>>> from pathlib import Path
>>>
>>> logging.basicConfig(level=logging.INFO, format="%(levelname)s | %(message)s")
>>>
>>> def run_pipeline(src="inbox", dst="sorted", report="reports", dry_run=True):
...     src_p, dst_p, report_p = Path(src), Path(dst), Path(report)
...     rows = organize_by_ext(src_p, dst_p, dry_run=dry_run)
...     report_file = write_report(rows, report_p)
...     logging.info("done rows=%s report=%s", len(rows), report_file)
...     return report_file
...
>>> print("파이프라인 엔트리포인트가 있으면 스케줄러(cron) 붙이기가 쉬워진다")
파이프라인 엔트리포인트가 있으면 스케줄러(cron) 붙이기가 쉬워진다

해설:

  • 실행 진입점을 하나로 고정하면 테스트, 수동 실행, 스케줄 실행이 모두 쉬워집니다.
  • 끝에 처리 건수와 리포트 경로를 찍어 두면 운영 확인 시간이 줄어듭니다.

자주 하는 실수

실수 1) 대상 경로 검증 없이 바로 실행

>>> from pathlib import Path
>>> def unsafe_run(src):
...     for p in Path(src).rglob("*"):
...         pass
...
>>> # unsafe_run("/")  # 이런 식의 실수는 운영에서 치명적
>>> print("루트/상위 경로를 실수로 넣으면 스캔 범위가 폭증한다")
루트/상위 경로를 실수로 넣으면 스캔 범위가 폭증한다

원인:

  • 경로 인자를 신뢰하고 바로 처리하면, 오타 하나로 전체 파일시스템을 훑는 사고가 납니다.

해결:

>>> def validate_source(src: Path):
...     banned = {Path("/"), Path.home()}
...     if src in banned:
...         raise ValueError("위험한 경로입니다. 하위 작업 폴더를 지정하세요.")
...     if not src.exists() or not src.is_dir():
...         raise ValueError("존재하는 디렉터리를 지정하세요.")
...
>>> print("실행 전에 경로 검증 함수를 무조건 통과시키는 패턴을 고정한다")
실행 전에 경로 검증 함수를 무조건 통과시키는 패턴을 고정한다

실수 2) 파일 이름 충돌 처리 누락

  • 증상: 같은 이름의 파일이 덮어써져 데이터가 유실됩니다.
  • 원인: 대상 디렉터리에 동일 파일명이 이미 있을 수 있다는 가정을 빼먹음.
  • 해결: 충돌 시 번호를 붙이거나 해시를 붙여 고유 이름으로 저장.
>>> from pathlib import Path
>>> def safe_target(path: Path):
...     if not path.exists():
...         return path
...     stem, suffix = path.stem, path.suffix
...     i = 1
...     while True:
...         cand = path.with_name(f"{stem}_{i}{suffix}")
...         if not cand.exists():
...             return cand
...         i += 1
...
>>> print("덮어쓰기보다 이름 변경이 기본값이어야 사고 복구 비용이 줄어든다")
덮어쓰기보다 이름 변경이 기본값이어야 사고 복구 비용이 줄어든다

실수 3) 예외를 통째로 삼켜서 실패 원인을 모르는 상태

>>> def bad_copy(files):
...     for f in files:
...         try:
...             _ = f.read_bytes()
...         except Exception:
...             pass
...
>>> print("에러를 숨기면 '조용히 실패'가 생겨서 운영에서 더 위험하다")
에러를 숨기면 '조용히 실패'가 생겨서 운영에서 더 위험하다

원인:

  • "일단 멈추지 않게" 하려고 except Exception: pass를 넣으면, 나중에 원인 추적이 불가능해집니다.

해결:

>>> import logging
>>> def better_copy(files):
...     ok, fail = 0, 0
...     for f in files:
...         try:
...             _ = f.read_bytes()
...             ok += 1
...         except OSError as e:
...             fail += 1
...             logging.warning("read failed file=%s err=%s", f, e)
...     return ok, fail
...
>>> print("예외는 숨기지 말고 최소한 파일 경로+에러 메시지는 로그로 남긴다")
예외는 숨기지 말고 최소한 파일 경로+에러 메시지는 로그로 남긴다

실무 패턴

  • 입력 검증 규칙:
    • 경로 존재 여부, 디렉터리 여부, 금지 경로 여부를 먼저 검사합니다.
    • 처리 대상 확장자 allowlist(예: {".jpg", ".png", ".pdf"})를 두면 예상치 못한 파일을 건드리지 않습니다.
  • 로그/예외 처리 규칙:
    • 시작 로그(입력 경로/옵션), 요약 로그(성공/실패 건수), 실패 샘플 로그(최대 N건)를 남깁니다.
    • 전체 실패보다 "부분 실패 허용 + 실패 리포트" 전략이 운영 현실에 더 맞는 경우가 많습니다.
  • 재사용 함수/구조화 팁:
    • collect_files, build_target, copy_or_move, write_report를 분리하면 테스트 범위가 분명해집니다.
    • CLI 인자 파싱(argparse)과 핵심 로직은 분리해, 나중에 API/스케줄러로 붙여도 코드 재사용이 됩니다.
  • 성능/메모리 체크 포인트:
    • 아주 큰 폴더는 rglob 결과를 리스트로 전부 담지 말고 순회하면서 바로 처리합니다.
    • 파일 해시 계산 등 무거운 작업은 필요 시에만 수행하고, 진행률 로그를 넣어 장시간 작업의 불안을 줄입니다.

실무에서 자동화 스크립트는 "한 번 멋지게 만든 코드"보다 "다음 달에도 무사히 돌아가는 코드"가 훨씬 가치가 큽니다. 그래서 완성도는 UI가 아니라 실패 내구성으로 판단해야 합니다. 입력이 조금 더러워도 버티는지, 일부 파일이 깨져 있어도 전체 작업이 끝나는지, 실행 결과를 사람이 검증할 수 있는지, 이 세 가지를 항상 확인하세요. 특히 운영 환경에서는 데이터가 항상 깔끔하지 않기 때문에, 정상 시나리오만 통과하는 코드는 거의 반드시 문제를 일으킵니다. 마지막 강의의 핵심은 고급 문법 자체가 아니라, 배운 문법을 "실패 가능한 현실"에 맞춰 조합하는 감각입니다.

오늘의 결론

한 줄 요약: 자동화의 완성은 코드 길이가 아니라, 재현 가능성·안전성·검증 가능성을 갖춘 파이프라인 설계입니다.

기억할 것:

  • 기본값은 보수적으로(dry_run=True, 덮어쓰기 금지, 경로 검증 필수) 둡니다.
  • 처리 결과는 사람이 읽을 수 있는 리포트(CSV/JSON)로 남깁니다.
  • 실패를 숨기지 말고 구조화된 로그와 요약 통계로 관리합니다.

연습문제

  1. organize_by_ext를 확장해, .jpg/.pngimages/, .mp4videos/, 나머지는 others/로 분류되도록 규칙 기반 매핑을 구현해 보세요.
  2. 현재 파이프라인에 --move 옵션을 추가해, 기본은 copy2이지만 옵션이 켜지면 move하도록 바꿔 보세요. 단, 충돌 시 안전 이름 생성은 반드시 유지하세요.
  3. 리포트를 CSV뿐 아니라 JSON으로도 저장하도록 바꾸고, 실행 요약(총 파일 수/성공/실패/소요 시간)을 함께 기록해 보세요.

이전 강의 정답

  1. normalize_phone(text) + doctest 예제 3개
>>> def normalize_phone(text):
...     """전화번호 문자열에서 숫자만 남긴다.
...
...     >>> normalize_phone("010-1234-5678")
...     '01012345678'
...     >>> normalize_phone("010 1234 5678")
...     '01012345678'
...     >>> normalize_phone("(010)1234-5678")
...     '01012345678'
...     """
...     return "".join(ch for ch in text if ch.isdigit())
...
>>> normalize_phone("+82 10-1234-5678")
'821012345678'
  1. to_bool(text) 변환 + 잘못된 입력 ValueError
>>> def to_bool(text):
...     """문자열을 bool로 변환한다.
...
...     >>> to_bool("yes")
...     True
...     >>> to_bool("0")
...     False
...     >>> to_bool("NO")
...     False
...     """
...     norm = text.strip().lower()
...     if norm in {"yes", "y", "1", "true"}:
...         return True
...     if norm in {"no", "n", "0", "false"}:
...         return False
...     raise ValueError(f"unsupported boolean text: {text}")
...
>>> to_bool("1")
True
>>> to_bool("maybe")
Traceback (most recent call last):
...
ValueError: unsupported boolean text: maybe
  1. 기존 유틸 docstring을 doctest 형식으로 수정 예시 (slugify)
>>> def slugify(title):
...     """제목을 URL slug로 변환한다.
...
...     >>> slugify("Python 100")
...     'python-100'
...     >>> slugify(" Fast   API Guide ")
...     'fast-api-guide'
...     """
...     return "-".join(title.strip().lower().split())
...
>>> slugify("Data Pipeline")
'data-pipeline'

실습 환경/재현 정보

  • 실행 환경: conda env python100 (Python 3.11.14)
  • 가정한 OS: macOS/Linux 공통
  • 권장 실행 방식:
    • 드라이런: python lesson90_pipeline.py --src inbox --dst sorted --dry-run
    • 실제 실행: python lesson90_pipeline.py --src inbox --dst sorted --no-dry-run
  • 검증 포인트:
    • 리포트 파일 생성 여부
    • 성공/실패 건수 로그 출력 여부
    • 파일명 충돌 시 안전 이름(_1, _2) 부여 여부