Devin.KR

AI 개발 · 실무

바이브 코딩 실전 예제 - 장마다 하나씩 완성하는 12가지 프로그램

폴더 정리기 - 확장자·날짜별로 옮기기 전 미리 보기

pathlib 로 파일 훑기, 실제로 옮기기 전 계획 출력(dry-run), 이름 충돌 처리, 되돌리기 기록, 임시 폴더에서만 실행

개발자KR · 원고 갱신

이 장에서 배우는 것

다운로드 폴더를 정리하려다가 같은 이름의 파일을 덮어쓸까 봐 멈춘 경험이 있을 수 있다. 파일을 옮기는 일 자체는 짧은 코드로 끝난다. 어려운 부분은 무엇을 옮길지 먼저 설명하고, 예상하지 못한 파일을 건드리지 않으며, 실행 뒤에 원래 위치로 돌아갈 근거를 남기는 일이다.

앞 장에서 입력 자료와 계산 결과를 구분했다면, 이번에는 정리 계획과 실제 파일 변경을 구분한다. pathlib로 파일 경로를 다루고, 실행 전 미리 보기(dry-run)를 거쳐 정리와 되돌리기를 한 번씩 수행하는 프로그램을 만든다. 실습 대상은 프로그램이 직접 만든 임시 폴더뿐이다. 독자의 다운로드 폴더나 문서 폴더를 입력받는 기능은 넣지 않는다.

  • pathlib로 바로 아래 파일을 찾고 이름순으로 처리한다.
  • 확장자와 수정 날짜를 기준으로 목적지를 계산한다.
  • 같은 이름이 있으면 번호를 붙이고 계획 안의 충돌까지 검사한다.
  • 이동 기록을 JSON으로 남기고 역순으로 되돌린다.
  • 미리 보기 전후와 되돌리기 전후의 파일 내용을 비교한다.

문제 상황

작은 모임의 자료 담당자가 공용 작업 폴더에서 받은 파일을 정리한다고 가정한다. 메모, 사진, 확장자 없는 안내 파일이 한곳에 섞여 있다. 정리 기준은 ‘확장자별 폴더 아래에 수정 날짜별 폴더를 만든다’이다. 사진 이름이 photo.JPG라면 jpg/2024-01-02/photo.JPG로 옮긴다. 확장자가 없는 README는 no_ext 폴더로 보낸다.

이미 정리한 폴더에 report.txt가 있는데 새 report.txt가 들어오는 상황도 있다. 기존 파일을 덮어쓰면 안 된다. 새 파일은 report_2.txt로 옮기고, 그 이름도 사용 중이면 report_3.txt를 찾는다. 번호는 실행할 때의 우연한 탐색 순서가 아니라 이름순으로 만든 계획에 따라 결정한다.

요구사항은 다음과 같이 고정한다. 날짜는 파일 내용에 적힌 날짜나 생성 날짜가 아니라 파일의 수정 시각이다. 날짜 경계가 컴퓨터 설정에 따라 달라지지 않도록 협정 세계시(UTC)를 사용한다. 실제 업무에서 현지 날짜가 필요하다면 기준 시간대를 먼저 합의해야 한다.

이번 프로그램의 입력 범위와 처리 규칙
항목규칙확인할 결과
실행 범위TemporaryDirectory로 만든 폴더만 사용한다.외부 경로를 입력받지 않는다.
탐색 범위inbox 바로 아래 일반 파일만 처리한다.하위 폴더와 심볼릭 링크는 제외한다.
분류 기준마지막 확장자를 소문자로 바꾸고 UTC 수정 날짜를 붙인다.JPG는 jpg, 확장자 없음은 no_ext가 된다.
충돌과 복원기존 이름을 보존하고 이동 기록을 역순으로 읽는다.report_2.txt를 만들고 원래 파일 내용으로 복원한다.

AI에게 보내는 요청문에도 이 범위를 그대로 넣는다. ‘폴더를 깔끔하게 정리해 달라’는 표현만으로는 재귀 탐색 여부, 날짜 기준, 충돌 규칙이 정해지지 않는다.

Python 3.12 이상 표준 라이브러리만 사용해 main.py 하나로 끝나는 폴더 정리 예제를 작성하라. python3 main.py로 실행하면 tempfile로 만든 임시 폴더에 입력 파일 네 개와 충돌용 기존 파일 하나를 직접 만들라. 수정 시각은 2024-01-02 12:00:00 UTC로 고정하라.

pathlib로 inbox 바로 아래 일반 파일만 이름순으로 훑고 심볼릭 링크와 폴더는 제외하라. 마지막 확장자를 소문자로 바꾼 폴더와 UTC 수정 날짜 폴더로 분류하라. 확장자가 없으면 no_ext를 사용하라. 목적지 이름이 겹치면 _2부터 번호를 붙이되 기존 파일과 같은 계획에서 이미 예약한 경로를 모두 확인하라.

계획 함수는 파일을 변경하지 않아야 한다. 먼저 상대 경로로 계획을 출력한 뒤 이동을 실행하고 JSON 기록을 남겨라. 기록을 역순으로 읽어 되돌리고 파일 경로와 바이트 내용이 원래대로인지 검사하라. 임시 경로 자체와 현재 시각은 출력하지 말라. 실제 사용자 폴더를 받는 인자는 만들지 말라.

경로를 훑기 전에 정할 기준

Path 객체는 파일 내용이 아니라 경로를 나타낸다. 경로끼리 / 연산자로 결합하면 운영체제에 맞는 경로가 만들어진다. 여기서는 root / "inbox"로 입력 폴더를 만들고, root / "organized"로 정리 결과를 모은다. 문자열에 슬래시를 이어 붙이는 방식보다 경로의 역할이 잘 드러난다.

iterdir()는 바로 아래 항목을 돌려준다. 그 순서를 이름순이라고 가정하면 실행 환경에 따라 충돌 번호와 출력 순서가 달라질 수 있다. 따라서 sorted()에 파일 이름을 기준으로 주고, 정렬한 뒤 파일 여부를 검사한다. 이번 예제는 이름의 대소문자를 그대로 비교한다. 사람이 기대하는 사전순과 같다고 해석하지는 않는다.

심볼릭 링크는 다른 경로를 가리키는 항목이다. is_file()은 링크가 가리키는 대상이 파일이면 참을 반환할 수 있으므로 is_symlink()를 먼저 확인한다. 이번 프로그램은 링크를 따라가며 정리할 이유가 없다. 하위 폴더 역시 재귀적으로 들어가지 않는다. 탐색 범위를 넓히는 일은 코드 한 줄의 변경처럼 보여도 영향을 받는 파일 수를 크게 늘린다.

확장자는 Path.suffix로 구한다. archive.tar.gz의 마지막 확장자는 .gz이므로 gz로 분류한다. .env처럼 suffix가 비어 있는 이름도 이번 규칙에서는 no_ext로 간다. 숨김 파일을 따로 취급하거나 복합 확장자를 보존하려면 별도의 요구사항이 필요하다.

날짜는 datetime.fromtimestamp()에 UTC를 명시해 구한다. 실행 컴퓨터의 현지 시간에 맡기면 자정 근처 파일이 서로 다른 날짜 폴더로 갈 수 있다. 실습에서는 os.utime()으로 수정 시각도 고정하므로 컴퓨터의 현재 시각이 결과에 섞이지 않는다.

계획과 실행을 나누고 이름을 예약한다

계획은 출발지와 목적지 경로의 목록이다. 계획을 만드는 함수에서는 mkdir(), rename(), write_text()를 호출하지 않는다. 폴더 생성도 파일 시스템을 바꾸는 작업이기 때문이다. 계획을 출력하는 것만으로는 이동이 일어나지 않아야 미리 보기를 신뢰할 수 있다.

계획 계산과 출력 단계는 파일을 바꾸지 않고 실행 단계에서만 이동한다

이름 충돌은 두 종류다. 첫째는 디스크에 이미 같은 목적지가 있는 경우다. 둘째는 디스크에는 아직 없지만 같은 계획의 앞선 항목이 그 목적지를 사용하기로 한 경우다. 후자를 놓치면 미리 보기에는 문제가 없어 보이다가 실행할 때 충돌한다. 이 때문에 계획 함수는 예약 경로를 집합에 저장한다.

존재 여부를 검사할 때는 exists()뿐 아니라 is_symlink()도 확인한다. 대상이 사라진 심볼릭 링크는 exists()가 거짓이어도 경로 이름 자체는 차지한다. 이번 입력에는 그런 링크가 없지만, ‘사용 중인 이름’의 뜻을 함수 하나에 모아 두면 충돌 검사와 되돌리기 검사가 같은 기준을 쓴다.

다음 표는 AI의 첫 답안을 검토할 때 나타나기 쉬운 결함을 재구성한 것이다. 실제 AI 대화나 실행 기록을 인용한 표가 아니다. 답안이 그럴듯하게 보이는지보다 각 요구사항을 어떤 입력으로 확인할지에 초점을 둔다.

첫 답안을 검토할 때 찾을 문제와 수정 방향
문제발견 방법고친 방향
미리 보기에서 목적지 폴더를 만든다.계획 함수의 mkdir 호출과 계획 전후 경로를 확인한다.계획 함수는 경로 목록만 반환한다.
이미 있는 report.txt를 같은 이름으로 옮긴다.목적지에 다른 내용의 report.txt를 미리 만든다.기존 경로를 검사하고 _2부터 번호를 붙인다.
현재 디스크만 검사하고 계획 내부 충돌을 놓친다.후보 이름 검사에 예약 집합이 포함되는지 읽는다.선택한 목적지를 즉시 집합에 넣는다.
시간대와 탐색 순서가 생략되어 있다.fromtimestamp 인자와 iterdir 사용 부분을 검토한다.UTC와 이름순 정렬을 명시한다.

수정을 요청할 때도 관찰한 문제와 바꿀 범위를 적는다. 전체 코드를 새로 만들라고 하면 이미 확인한 규칙까지 달라질 수 있다.

계획 함수 안의 폴더 생성을 실행 함수로 옮겨라. 목적지 선택에는 기존 경로 검사와 예약 집합 검사를 함께 사용하라. 수정한 뒤 계획 전후 파일 목록과 내용이 같은지, 충돌 파일의 내용이 유지되는지 검사 코드를 추가하라. 바뀐 부분은 diff로 확인할 수 있도록 계획 함수와 실행 함수의 책임을 유지하라.

되돌리기 기록과 검사 범위를 정한다

이동 기록에는 출발지, 목적지, 이동 완료 여부를 남긴다. 경로는 임시 폴더 기준의 상대 경로로 저장한다. 임시 폴더 이름이 매번 달라도 기록 형식과 출력은 같아진다. 기록 파일은 입력 폴더 바깥에 두어 정리 대상에 섞이지 않게 한다.

실행 함수는 이동 전에 전체 계획을 기록한다. 파일 하나를 옮길 때마다 완료 여부를 갱신하고 다시 저장한다. 되돌릴 때는 완료한 항목만 역순으로 처리한다. 기록을 읽자마자 옮기지 않고, 모든 복원 출발지가 존재하는지와 복원 목적지가 비어 있는지 먼저 확인한다. 중간 항목에서 충돌을 발견해 일부만 복원되는 상황을 줄이기 위해서다.

이동 기록의 출발지와 목적지를 바꾸어 완료 항목을 역순으로 복원한다

이 기록은 실습 실행을 되돌리는 자료이지 장애 복구 장치는 아니다. 파일 이동과 기록 저장은 하나의 원자적 작업이 아니다. 이동 직후 프로그램이 강제 종료되면 실제 위치와 완료 표시가 어긋날 수 있다. 기록 저장 자체가 중단될 수도 있다. 이번 예제는 한 프로세스가 임시 폴더를 독점하는 동안 정상 실행과 정상 복원을 확인하는 데 범위를 둔다.

검사는 세 군데에 둔다. 먼저 계획 전후의 파일 경로와 바이트 내용을 비교한다. 다음으로 이동 직후 출발지 파일이 없어졌는지, 새 목적지 내용이 원본과 같은지, 기존 충돌 파일이 유지되는지 확인한다. 마지막으로 되돌린 뒤 전체 파일 경로와 내용이 처음과 같은지 비교한다. 이 비교에서는 이동 기록 파일만 제외한다.

여기서 복원의 뜻은 파일 경로와 내용의 복원이다. 정리 과정에서 만든 빈 폴더는 남는다. 접근 시각이나 모든 파일 속성까지 원래 상태로 복원한다고 주장하지 않는다. 무엇을 검사했는지와 무엇을 보장하는지는 같은 범위로 적어야 한다.

코드를 저장한 뒤에는 경고를 오류로 취급하는 컴파일 검사를 먼저 하고 일반 실행을 한다. 아래 명령은 독자가 수행할 검증 절차다. 이 원고에 실제 도구 실행 기록을 덧붙인 것은 아니다.

python3 -W error -m py_compile main.py

python3 main.py

버전 관리에서는 git diff로 계획 함수에 쓰기 작업이 들어가지 않았는지, 사용자 경로를 받는 코드가 추가되지 않았는지, 충돌 시 덮어쓰는 경로가 생기지 않았는지 읽는다. 출력이 예상과 같아도 diff 검토를 생략하지 않는다. 네 개의 입력이 모든 파일 시스템 상황을 대표하지는 않기 때문이다.

완성 코드

다음 코드를 main.py로 저장한다. 외부 입력 인자 없이 실행하며, 임시 폴더를 벗어나는 파일 이동은 수행하지 않는다. 검사용 함수 require()는 조건이 맞지 않으면 예외를 발생시킨다. 일반 assert와 달리 최적화 실행에서도 검사가 제거되지 않는다.

import json
import os
from datetime import datetime, timezone
from pathlib import Path
from tempfile import TemporaryDirectory


FIXED_TIME = datetime(
    2024, 1, 2, 12, 0, tzinfo=timezone.utc
).timestamp()


def require(condition, message):
    if not condition:
        raise RuntimeError(message)


def occupied(path):
    return path.exists() or path.is_symlink()


def snapshot(root):
    return {
        path.relative_to(root).as_posix(): path.read_bytes()
        for path in sorted(root.rglob("*"))
        if path.is_file() and path.name != "moves.json"
    }


def create_sample(root):
    inbox = root / "inbox"
    inbox.mkdir()
    samples = {
        "README": "사용 안내\n",
        "memo.txt": "회의 메모\n",
        "photo.JPG": "사진을 대신하는 실습 자료\n",
        "report.txt": "새 보고서\n",
    }
    for name, content in samples.items():
        path = inbox / name
        path.write_text(content, encoding="utf-8")
        os.utime(path, (FIXED_TIME, FIXED_TIME))

    existing = root / "organized/txt/2024-01-02/report.txt"
    existing.parent.mkdir(parents=True)
    existing.write_text("기존 보고서\n", encoding="utf-8")
    return inbox


def build_plan(inbox, output):
    plan = []
    reserved = set()
    for source in sorted(inbox.iterdir(), key=lambda p: p.name):
        if source.is_symlink() or not source.is_file():
            continue
        category = source.suffix[1:].lower() or "no_ext"
        day = datetime.fromtimestamp(
            source.stat().st_mtime, timezone.utc
        ).date().isoformat()
        folder = output / category / day
        target = folder / source.name
        number = 2
        while occupied(target) or target in reserved:
            target = folder / (
                f"{source.stem}_{number}{source.suffix}"
            )
            number += 1
        reserved.add(target)
        plan.append((source, target))
    return plan


def save_records(journal, records):
    journal.write_text(
        json.dumps(records, ensure_ascii=False, indent=2) + "\n",
        encoding="utf-8",
    )


def execute(root, plan, journal):
    records = [
        {
            "source": source.relative_to(root).as_posix(),
            "target": target.relative_to(root).as_posix(),
            "done": False,
        }
        for source, target in plan
    ]
    save_records(journal, records)
    for (source, target), record in zip(plan, records):
        require(
            source.is_file() and not source.is_symlink(),
            "이동할 일반 파일이 없다.",
        )
        require(not occupied(target), "목적지가 이미 사용 중이다.")
        target.parent.mkdir(parents=True, exist_ok=True)
        source.rename(target)
        record["done"] = True
        save_records(journal, records)
    return len(records)


def undo(root, journal):
    records = json.loads(journal.read_text(encoding="utf-8"))
    pending = [
        record for record in reversed(records) if record["done"]
    ]
    for record in pending:
        source = root / record["source"]
        target = root / record["target"]
        require(
            target.is_file() and not target.is_symlink(),
            "되돌릴 일반 파일이 없다.",
        )
        require(not occupied(source), "원래 위치가 이미 사용 중이다.")

    for record in pending:
        source = root / record["source"]
        target = root / record["target"]
        target.rename(source)
        record["done"] = False
        save_records(journal, records)
    return len(pending)


def main():
    with TemporaryDirectory(prefix="folder-tidy-") as temporary:
        root = Path(temporary)
        inbox = create_sample(root)
        journal = root / "moves.json"
        before = snapshot(root)
        plan = build_plan(inbox, root / "organized")
        require(len(plan) == 4, "계획의 파일 수가 다르다.")
        require(snapshot(root) == before, "계획이 파일을 변경했다.")

        print("[미리 보기]")
        for source, target in plan:
            left = source.relative_to(root).as_posix()
            right = target.relative_to(root).as_posix()
            print(f"{left} -> {right}")
        print("검사: 계획 전후 파일 목록과 내용이 같다.")

        moved = execute(root, plan, journal)
        for source, target in plan:
            key = source.relative_to(root).as_posix()
            require(not occupied(source), "원본 경로가 남아 있다.")
            require(target.read_bytes() == before[key], "내용이 다르다.")
        old_key = "organized/txt/2024-01-02/report.txt"
        require(
            (root / old_key).read_bytes() == before[old_key],
            "기존 충돌 파일이 변경되었다.",
        )
        print(f"[실행] {moved}개 이동, moves.json 기록")
        print("검사: 이동한 내용과 기존 충돌 파일을 확인했다.")

        restored = undo(root, journal)
        require(snapshot(root) == before, "복원 결과가 다르다.")
        records = json.loads(journal.read_text(encoding="utf-8"))
        require(
            all(not record["done"] for record in records),
            "완료 표시가 남아 있다.",
        )
        print(f"[되돌리기] {restored}개 복원")
        print("검사: 처음과 파일 목록과 내용이 같다.")
    print("임시 폴더 정리 완료")


if __name__ == "__main__":
    main()

줄별 해설

코드는 경로 준비, 계획, 실행, 복원, 검사의 순서로 읽으면 된다. 단순한 import 문보다 파일 변경이 일어나는 줄과 경계를 정하는 조건을 중심으로 살펴본다.

코드의 주요 줄과 그 줄이 맡은 책임
코드 위치역할검토할 점
FIXED_TIME입력 파일의 수정 시각을 고정한다.시간대가 명시되어 있다.
occupied()파일·폴더·끊어진 링크의 이름 점유를 확인한다.exists()만으로 판단하지 않는다.
snapshot()상대 경로와 바이트 내용을 사전으로 만든다.폴더 속성과 시각은 비교하지 않는다.
build_plan()출발지와 충돌을 피한 목적지를 계산한다.파일 시스템에 쓰는 호출이 없다.
execute()이동 직전 조건을 재확인하고 기록을 갱신한다.계획 후 상태가 바뀌었으면 중단한다.
undo()완료 항목 전체를 검사한 뒤 역순으로 복원한다.원래 위치의 기존 항목을 덮어쓰지 않는다.

create_sample()은 네 개의 입력을 UTF-8로 쓴다. photo.JPG는 실제 사진 형식이 아니라 파일 이름 분류를 확인하기 위한 텍스트 자료다. 이번 프로그램은 확장자로 분류할 뿐 파일 내부 형식을 판별하지 않는다. 기존 report.txt는 다른 내용을 써 두므로 이동 후 내용 비교로 보존 여부를 확인할 수 있다.

build_plan()의 category 계산에서 suffix의 첫 글자를 잘라 점을 제거하고 lower()를 적용한다. 빈 문자열이면 or 연산자 뒤의 no_ext를 선택한다. 원래 파일 이름 자체는 바꾸지 않으므로 photo.JPG의 대문자 확장자는 목적지 파일 이름에 그대로 남는다.

while 문은 원래 이름부터 검사한다. 충돌하면 stem 뒤에 번호를 붙이고 suffix를 다시 더한다. 다음 검사 때도 계속 충돌하면 번호를 올린다. 반복을 빠져나온 목적지는 reserved에 즉시 저장한다. 이 집합은 실제 폴더를 만들지 않고도 계획 안에서 이미 사용한 이름을 기억한다.

execute()는 상대 경로 문자열만 JSON에 저장한다. Path 객체는 그대로 JSON에 저장할 수 없으므로 as_posix()로 표현을 통일한다. ensure_ascii=False는 한글을 읽기 쉬운 형태로 남기고, encoding="utf-8"은 저장과 읽기의 문자 인코딩을 맞춘다.

rename() 앞의 검사는 계획 이후의 변화를 찾는 보조 장치다. 검사와 이동 사이에 다른 프로세스가 개입하는 경쟁 상황까지 차단하지는 않는다. 그래서 이 프로그램은 전용 임시 폴더 안에서만 실행한다. 실제 공유 폴더로 확장할 때는 이 조건을 그대로 가져가면 안 된다.

undo()는 reversed(records)로 마지막 이동부터 확인한다. 이번 이동들은 서로 독립적이지만, 작업을 되돌리는 순서를 명시해 두면 나중에 동작을 확장할 때 기준이 된다. 복원이 끝나면 done을 거짓으로 바꾸므로 같은 기록으로 다시 undo()를 호출해도 복원 대상은 없다.

main()의 with 문은 끝에 도달하거나 예외로 빠져나갈 때 임시 폴더를 정리한다. moves.json도 함께 삭제된다. 따라서 기록은 이 실행 안에서 되돌리기를 설명하는 용도다. 다음 실행에서 재사용할 기록 보관 기능은 포함하지 않는다.

실행 결과

터미널에서 다음 명령을 실행한다. 명령은 입력 예시이므로 인용으로 표시하고, 프로그램이 내보내는 결과만 실행 결과 블록으로 표시한다.

python3 main.py

[미리 보기]
inbox/README -> organized/no_ext/2024-01-02/README
inbox/memo.txt -> organized/txt/2024-01-02/memo.txt
inbox/photo.JPG -> organized/jpg/2024-01-02/photo.JPG
inbox/report.txt -> organized/txt/2024-01-02/report_2.txt
검사: 계획 전후 파일 목록과 내용이 같다.
[실행] 4개 이동, moves.json 기록
검사: 이동한 내용과 기존 충돌 파일을 확인했다.
[되돌리기] 4개 복원
검사: 처음과 파일 목록과 내용이 같다.
임시 폴더 정리 완료

report_2.txt가 보이면 기존 이름과의 충돌을 피했다는 뜻이다. 그러나 이름만으로 기존 파일 보존을 증명하지는 못한다. 프로그램이 별도로 기존 report.txt의 바이트 내용을 비교하는 이유다. 마지막 메시지는 TemporaryDirectory의 관리 범위를 빠져나온 뒤 출력하므로 복원 검사와 임시 폴더 정리가 구분된다.

실무에서 자주 틀리는 것

미리 보기 안에서 폴더를 만든다

다음 두 코드는 각각 독립적으로 실행할 수 있는 비교 예제다. 첫 코드는 계획을 계산하는 순간 빈 폴더를 만든다. 파일을 옮기지 않았더라도 미리 보기가 파일 시스템을 바꾼 셈이다.

from pathlib import Path
from tempfile import TemporaryDirectory

with TemporaryDirectory() as temporary:
    target = Path(temporary) / "txt"
    target.mkdir()
    print(target.exists())

고친 코드는 경로 객체만 만든다. 폴더 생성은 실행 단계로 미룬다.

from pathlib import Path
from tempfile import TemporaryDirectory

with TemporaryDirectory() as temporary:
    target = Path(temporary) / "txt"
    print(target.exists())

예약한 이름을 다시 선택한다

다음 코드는 디스크에 없는 후보를 두 번 선택한다. 아직 이동하지 않았기 때문에 exists()만으로는 첫 번째 선택을 알 수 없다.

from pathlib import Path
from tempfile import TemporaryDirectory

with TemporaryDirectory() as temporary:
    candidate = Path(temporary) / "memo.txt"
    choices = []
    for _ in range(2):
        if not candidate.exists():
            choices.append(candidate)
    print(len(set(choices)))

고친 코드는 선택한 경로를 예약한다. 두 번째 선택은 번호가 붙은 이름으로 넘어간다.

from pathlib import Path
from tempfile import TemporaryDirectory

with TemporaryDirectory() as temporary:
    folder = Path(temporary)
    reserved = set()
    for _ in range(2):
        candidate = folder / "memo.txt"
        number = 2
        while candidate.exists() or candidate in reserved:
            candidate = folder / f"memo_{number}.txt"
            number += 1
        reserved.add(candidate)
    print(len(reserved))

현지 시간대를 날짜 규칙으로 암묵적으로 사용한다

다음 코드는 실행 컴퓨터의 현지 시간대로 날짜를 계산한다. 요청문이 UTC 날짜를 요구했다면 결과가 우연히 같더라도 기준이 잘못된 코드다.

from datetime import datetime, timezone

stamp = datetime(2024, 1, 2, tzinfo=timezone.utc).timestamp()
day = datetime.fromtimestamp(stamp).date()

고친 코드는 변환할 때도 UTC를 지정한다. 날짜 경계에 가까운 시각으로 검사하면 시간대 누락을 찾기 쉽다.

from datetime import datetime, timezone

stamp = datetime(2024, 1, 2, tzinfo=timezone.utc).timestamp()
day = datetime.fromtimestamp(stamp, timezone.utc).date()

이동한 개수만으로 복원을 확인한다

다음 검사는 파일이 바뀌었는데도 개수가 같아서 통과한다. 업무 파일에서는 같은 개수보다 같은 경로와 같은 내용이 더 중요한 조건이다.

before = {"memo.txt": b"original"}
after = {"memo.txt": b"changed"}
assert len(before) == len(after)

고친 검사는 경로와 내용을 함께 비교한다. 완성 코드에서는 snapshot()이 이 사전을 만들어 준다.

before = {"memo.txt": b"original"}
after = {"memo.txt": b"original"}
if before != after:
    raise RuntimeError("복원 결과가 다르다.")

한눈에 보기

폴더 정리기를 검토할 때 사용할 기준
단계핵심 질문이번 코드의 답
탐색어디까지 읽는가?임시 inbox 바로 아래 일반 파일만 읽는다.
계획미리 보기가 상태를 바꾸는가?경로 목록만 만들고 내용을 비교한다.
충돌어떤 이름을 사용 중으로 보는가?기존 경로와 예약한 경로를 함께 본다.
실행계획 이후 조건을 다시 확인하는가?원본 파일과 목적지 점유 여부를 확인한다.
복원무엇이 원래대로 돌아오는가?파일 경로와 내용이며 빈 폴더는 남는다.
한계어떤 상황은 보장하지 않는가?강제 종료 복구와 동시 변경은 보장하지 않는다.

AI에게 맡길 단위도 이 단계에 맞춘다. 분류 규칙을 바꿀 때는 계획을 먼저 확인하고, 기록 형식을 바꿀 때는 저장과 복원을 함께 검사한다. 수정할 때마다 모든 기능을 새로 생성하기보다, 이미 확인한 경계를 유지하면서 변경 부분의 diff와 실행 결과를 나란히 읽는다.

연습 문제

다음 요청문은 완성 코드를 확장하는 과제다. 모든 변경은 기존 임시 폴더 안에서만 실행하고, 예상 결과를 먼저 적은 뒤 코드를 수정한다.

  1. 충돌 번호를 한 단계 더 확인한다.

    충돌용 기존 파일에 report_2.txt를 추가하라. 새 보고서의 목적지가 report_3.txt가 되는지 검사하고 기존 두 파일의 내용이 유지되는지도 확인하라.

  2. 탐색 제외 규칙을 실행으로 확인한다.

    inbox 안에 하위 폴더와 그 안의 파일을 만들고, inbox 안에 memo.txt를 가리키는 심볼릭 링크도 만들라. 계획은 기존 네 파일만 포함해야 하며 정리와 복원 후 제외 항목이 유지되는지 검사하라.

  3. 되돌리기 충돌을 확인한다.

    이동을 마친 뒤 원래 inbox/memo.txt 위치에 다른 파일을 만들라. undo가 복원을 시작하기 전에 예외를 내는지, 정리된 파일 네 개가 모두 그대로 남아 있는지 검사하라. 충돌 파일을 제거한 뒤 정상 복원하라.

  4. 반복 복원의 동작을 확인한다.

    정상 복원 뒤 undo를 한 번 더 호출하라. 두 번째 반환값이 0인지, 파일 경로와 내용이 달라지지 않는지 확인하라. 이 검사를 추가해도 임시 경로나 현재 시각을 출력하지 말라.

정답과 해설

첫 번째 문제에서는 create_sample()에서 기존 report_2.txt도 만든다. before에 두 기존 파일이 모두 포함되므로 복원 후 전체 비교가 두 파일을 함께 확인한다. 이동 직후에도 두 기존 파일의 바이트 내용을 각각 비교하고, 새 보고서의 target.name이 report_3.txt인지 검사하면 이름 선택과 보존을 구분해 확인할 수 있다.

두 번째 문제의 하위 파일은 iterdir()가 재귀 탐색하지 않으므로 계획에 들어가지 않는다. 링크는 is_symlink() 조건으로 제외된다. 다만 snapshot()은 링크의 정체를 별도로 저장하지 않는다. 따라서 링크는 is_symlink()와 readlink()로 존재와 연결 경로를 따로 확인해야 한다. 기존 검사 함수가 새 요구사항까지 검사한다고 가정하지 않는 것이 핵심이다.

세 번째 문제는 undo()의 사전 검사 효과를 확인한다. 충돌 예외는 RuntimeError로 잡되, 예외가 없으면 검사 실패로 처리한다. 예외 뒤에는 각 목적지 파일의 존재와 내용을 확인한다. 이 코드에서는 실제 복원 반복문보다 앞에서 전체 조건을 검사하므로 충돌 항목이 기록의 어디에 있든 정상적인 사전 검사 동안에는 파일을 옮기지 않는다. 확인을 마친 뒤 자신이 만든 충돌 파일만 삭제하고 다시 복원한다.

네 번째 문제에서는 첫 복원으로 모든 done 값이 거짓이 된다. 두 번째 호출의 pending은 빈 목록이므로 이동 없이 0을 반환한다. 호출 전후 snapshot()도 비교한다. 출력문을 추가한다면 예상 출력에도 반영해야 한다. 코드를 바꾼 뒤에는 컴파일 검사, 실행, diff 검토를 다시 수행한다. AI의 설명이 아니라 이 세 가지 확인 결과를 다음 수정의 출발점으로 삼는다.

오탈자·오류 제보 비공개로 접수되어 원고 수정에 반영됩니다

이메일 등 개인정보는 받지 않습니다. 답변이 필요한 질문은 아래 댓글을 이용해 주세요.

READER FEEDBACK

질문·의견

내용에 관한 질문이나 더 나은 설명을 위한 의견을 남겨 주세요. 오탈자는 위의 제보 양식이 더 빨리 반영됩니다. 이 댓글은 원래 게시글과 같은 자리에 쌓입니다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.