스크립트·파일 자동화

파일 크기와 SHA-256으로 중복 파일을 찾고 삭제 전에 검토하기

파일 크기로 비교 대상을 추리고 SHA-256 해시값을 비교한 뒤, 원본을 변경하지 않고 검토용 CSV를 작성합니다. 7개 파일로 구성된 합성 예제를 이용해 보고서에 포함될 경로와 제외될 경로를 확인합니다.

목차 보기

이런 분께중복 가능성이 있는 파일을 찾되, 검색과 삭제 판단을 분리해서 진행하려는 사람을 위한 안내입니다.

준비사항
  • Python 3.12와 해당 버전을 실행하는 터미널 명령이 준비되어 있어야 합니다.
  • outputs 폴더를 만들 수 있는 작업 폴더가 필요합니다.
  • 검색 대상 파일을 읽을 권한이 있어야 하며, 검색 중 다른 프로세스가 파일을 변경하지 않아야 합니다.
  • Python 표준 라이브러리인 csv, hashlib, os, pathlib, stat만 사용합니다.

01보고서가 의미하는 범위 정하기

이 작업은 먼저 파일을 바이트 크기에 따라 묶습니다. 크기가 다르면 바이트 내용이 완전히 같을 수 없으므로, 경로가 여러 개 있는 크기 그룹만 해시를 계산하면 됩니다. 각 크기 그룹 안에서는 SHA-256 해시값이 일치하는 파일을 중복 후보로 분류합니다. 파일명 일치는 필수 조건도 충분조건도 아닙니다.

중복 후보 그룹은 삭제 지시가 아닙니다. 내용이 같아도 서로 다른 폴더에서 다른 용도로 사용될 수 있으며, 해시값이 일치한다고 동일성이 절대적으로 증명되는 것도 아닙니다. 보고서에는 일치하는 경로를 모두 기록하며, 남길 파일을 자동으로 선택하지 않습니다.

02합성 파일 7개 만들기

아래 데이터는 이 글을 위해 작성한 합성 데이터입니다. 코드를 create_duplicate_demo.py로 저장하고 작업 폴더에서 실행하세요. 바이너리 모드로 기록하므로 내용이 정확하게 유지됩니다. 줄바꿈은 추가되지 않으며, 빈 파일은 0바이트입니다.

python
from pathlib import Path

DEMO = Path("outputs") / "duplicate_demo"
FILES = {
    "archive/a_saved.txt": b"abc",
    "empty_a.txt": b"",
    "empty_b.txt": b"",
    "notes/a.txt": b"abc",
    "notes/a_copy.txt": b"abc",
    "notes/other.txt": b"xyz",
    "unique.txt": b"solo",
}

DEMO.parent.mkdir(parents=True, exist_ok=True)
DEMO.mkdir()  # Refuse to reuse an existing destination.
for relative_path, content in FILES.items():
    target = DEMO / relative_path
    target.parent.mkdir(parents=True, exist_ok=True)
    with target.open("xb") as stream:
        stream.write(content)
text
python create_duplicate_demo.py
상대 경로정확한 텍스트 내용바이트 수
archive/a_saved.txtabc3
empty_a.txt비어 있음0
empty_b.txt비어 있음0
notes/a.txtabc3
notes/a_copy.txtabc3
notes/other.txtxyz3
unique.txtsolo4

7개 파일의 내용은 총 16바이트입니다. 크기가 3바이트인 파일은 4개지만, notes/other.txt의 내용은 abc가 들어 있는 3개 파일과 다릅니다. 크기가 같다는 조건만으로는 충분하지 않다는 점을 확인하도록 구성했습니다.

03보고서를 검색 대상 폴더 밖에 두기

  1. 다음 스크립트를 준비용 스크립트와 같은 위치에 find_duplicate_files.py로 저장합니다.
  2. 이 예제에서는 SOURCE를 outputs/duplicate_demo로 유지합니다.
  3. outputs/duplicate_review를 원본 폴더와 분리합니다. 이 대상 폴더는 아직 존재하지 않아야 합니다.
  4. 같은 작업 폴더에서 아래 명령으로 스크립트를 실행합니다.
text
python find_duplicate_files.py

스크립트는 경로를 해석한 결과 보고서 대상 폴더가 원본 폴더 안에 있으면 거부합니다. 검색 과정에 자체 출력물이 포함되는 것을 막기 위한 조치입니다. 상대 경로의 기준은 여전히 터미널의 작업 디렉터리입니다.

04파일을 검색하고 검토용 CSV 작성하기

검색 과정에서 발견한 심볼릭 링크는 건너뛰고, 일반 파일이 아닌 항목도 제외합니다. 디렉터리 탐색이나 파일 읽기 중 오류가 발생하면 실행을 중단합니다. 해시 계산 전후의 메타데이터 비교로 일부 동시 변경을 감지하지만, 파일을 잠근 스냅샷을 만드는 것은 아닙니다.

python
import csv
import hashlib
import os
import stat
from pathlib import Path

SOURCE = Path("outputs") / "duplicate_demo"
OUTPUT_DIR = Path("outputs") / "duplicate_review"
CHUNK_BYTES = 1024 * 1024


def signature(info: os.stat_result) -> tuple[int, ...]:
    return (info.st_dev, info.st_ino, info.st_size, info.st_mtime_ns)


def raise_walk_error(error: OSError) -> None:
    raise error


def file_digest(path: Path, expected: os.stat_result) -> str:
    digest = hashlib.sha256()
    with path.open("rb") as stream:
        before = os.fstat(stream.fileno())
        if not stat.S_ISREG(before.st_mode) or signature(before) != signature(expected):
            raise RuntimeError(f"File changed before hashing: {path}")
        while chunk := stream.read(CHUNK_BYTES):
            digest.update(chunk)
        after = os.fstat(stream.fileno())
    if signature(after) != signature(expected) or signature(path.lstat()) != signature(expected):
        raise RuntimeError(f"File changed during hashing: {path}")
    return digest.hexdigest()


def main() -> None:
    root = SOURCE.resolve(strict=True)
    if not root.is_dir():
        raise ValueError("SOURCE must be a directory.")
    if OUTPUT_DIR.resolve().is_relative_to(root):
        raise ValueError("The report folder must be outside SOURCE.")

    OUTPUT_DIR.parent.mkdir(parents=True, exist_ok=True)
    OUTPUT_DIR.mkdir()  # Stop if the destination already exists.

    by_size = {}
    scanned = 0
    for directory, directories, filenames in os.walk(
        root, followlinks=False, onerror=raise_walk_error
    ):
        base = Path(directory)
        directories[:] = sorted(
            name for name in directories if not (base / name).is_symlink()
        )
        for name in sorted(filenames):
            path = base / name
            info = path.lstat()
            if not stat.S_ISREG(info.st_mode):
                continue
            by_size.setdefault(info.st_size, []).append((path, info))
            scanned += 1

    groups = []
    hashed = 0
    for size, members in sorted(by_size.items()):
        if len(members) < 2:
            continue
        by_hash = {}
        for path, initial in members:
            digest = file_digest(path, initial)
            by_hash.setdefault(digest, []).append(path)
            hashed += 1
        for digest, paths in sorted(by_hash.items()):
            if len(paths) > 1:
                ordered = sorted(paths, key=lambda path: path.as_posix())
                groups.append((size, digest, ordered))

    report = OUTPUT_DIR / "duplicates.csv"
    with report.open("x", encoding="utf-8", newline="") as stream:
        writer = csv.writer(stream)
        writer.writerow([
            "group_id", "size_bytes", "sha256", "relative_path", "review_status"
        ])
        for number, (size, digest, paths) in enumerate(groups, start=1):
            for path in paths:
                writer.writerow([
                    f"G{number:03d}", size, digest,
                    path.relative_to(root).as_posix(), "UNREVIEWED"
                ])

    matched = sum(len(paths) for _, _, paths in groups)
    print(f"Scanned {scanned} regular file paths; hashed {hashed}.")
    print(f"Duplicate candidates: {len(groups)} groups, {matched} paths.")
    print(f"Review CSV: {report.as_posix()}")


if __name__ == "__main__":
    main()

05예상 그룹과 비교하기

보고서는 헤더와 데이터 5행으로 구성됩니다. 그룹은 크기와 해시 순으로, 각 그룹의 경로는 정렬되어 있습니다. 아래 표에서는 읽기 쉽도록 SHA-256 열을 생략했지만 생성된 CSV에는 포함되며 실행 시 값을 확인했습니다.

group_idsize_bytesrelative_pathreview_status
G0010empty_a.txtUNREVIEWED
G0010empty_b.txtUNREVIEWED
G0023archive/a_saved.txtUNREVIEWED
G0023notes/a.txtUNREVIEWED
G0023notes/a_copy.txtUNREVIEWED

해시 계산 대상은 빈 파일 2개와 3바이트 파일 4개로 총 6개입니다. 4바이트 파일은 크기가 같은 다른 파일이 없어 건너뜁니다. 실제 콘솔 출력은 예상과 일치했습니다.

text
Scanned 7 regular file paths; hashed 6.
Duplicate candidates: 2 groups, 5 paths.
Review CSV: outputs/duplicate_review/duplicates.csv

06바이트 일치뿐 아니라 용도도 검토하기

  1. 보고서의 각 경로가 여전히 존재하고, 검색 이후 원본이 변경되지 않았는지 확인합니다.
  2. 삭제처럼 되돌리기 어려운 결정을 내리기 전에 후보 파일을 바이트 단위로 비교합니다. 별도로 사용할 수 있는 백업이 있는지도 확인합니다.
  3. 폴더의 용도, 소유자, 다른 파일에서 참조하는지 여부, 응용 프로그램의 요구사항을 확인합니다. 내용이 같다는 이유만으로 서로 대체할 수 있는 것은 아닙니다.
  4. 별도의 검토용 사본에 제안하는 조치를 기록합니다. 그룹의 첫 번째 경로를 자동으로 남겨야 하는 파일로 해석하지 않습니다.

빈 파일은 의도적으로 만든 자리표시자일 수 있습니다. 내용이 같다고 불필요한 것은 아니며, 삭제해도 파일 내용에 해당하는 바이트가 줄어드는 것은 아닙니다.

07보호 동작을 직접 확인하기

  • 빈 파일 2개와 abc 파일 3개가 모두 표시되고, notes/other.txt와 unique.txt는 표시되지 않는지 확인합니다.
  • 각 그룹의 크기가 같고, 64자리 16진수로 표시되는 SHA-256 해시값도 같은지 확인합니다.
  • OUTPUT_DIR을 변경하지 않고 다시 실행합니다. 다른 보고서를 쓰기 전에 FileExistsError로 중단되어야 합니다.
  • 모든 파일의 크기가 서로 다른 별도 원본 폴더로 실행해 봅니다. 헤더만 있는 보고서가 생성되고, 해시를 계산한 파일 수는 0이어야 합니다.

이 항목들은 독자가 직접 수행할 확인 사항이며, 이 글을 작성하면서 실행한 시험이 아닙니다. 검색을 완료했지만 그룹이 0개인 보고서와 예외가 발생해 중단된 검색은 구분해야 합니다.

08자주 발생하는 오류 확인하기

증상확인할 사항
FileNotFoundError준비용 스크립트를 먼저 실행하고, 터미널의 작업 디렉터리를 기준으로 SOURCE를 확인하세요.
FileExistsError이전 대상 폴더를 검토하고 outputs 아래의 새 폴더를 선택하세요. 이미 존재하는 빈 대상 폴더도 사용할 수 없습니다.
PermissionError 또는 다른 읽기 오류접근 문제를 해결하거나 검색 범위를 명시적으로 줄이세요. 중단된 검색을 완료된 것으로 취급하지 마세요.
해시 계산 전이나 도중 파일 변경 감지원본을 수정하는 응용 프로그램을 중지한 뒤, 새 출력 대상 폴더를 지정해 다시 검색하세요.
예상과 다른 바이트 크기텍스트 편집기가 줄바꿈을 추가했거나 인코딩을 바꿨을 수 있습니다. 바이너리로 기록하는 준비용 스크립트로 예제를 다시 만드세요.
실패 후 빈 폴더나 일부만 작성된 보고서가 남음중단된 실행은 대상 폴더를 실행 전 상태로 되돌리지 않습니다. 해당 결과를 완료된 보고서와 분리해 두세요.

09한계 이해하기

해시 계산에서는 내용을 일정 크기씩 읽지만, 파일 메타데이터와 경로, 그룹 정보는 메모리에 저장합니다. 따라서 파일 수와 관계없이 일정한 메모리만 사용하는 목록 작성 방식은 아닙니다. 시각적 유사성이나 문서의 의미가 아니라 전체 바이트를 비교하므로, 겉보기에는 같은 문서도 해시값이 다를 수 있습니다.

보고서는 독립적인 물리적 사본 수가 아니라 경로 수를 셉니다. 하드 링크는 내용을 별도로 저장하지 않으면서 여러 경로가 보고되게 할 수 있으므로, 보고된 크기만큼 디스크 공간이 절약된다고 보장할 수 없습니다. Windows 정션, 마운트 지점, 악의적인 동시 변경에는 현재의 심볼릭 링크 검사 외에 추가 처리가 필요합니다.

실행·검증 기록

ChatGPT Python tool · Python 3.13.5 · standard library csv/hashlib/os/pathlib/stat

  • 설정 과정에서 정확히 7개의 일반 원본 파일이 생성되었으며 총 크기는 16바이트였다. 크기 0인 파일 2개, 크기 3인 파일 4개, 크기 4인 파일 1개였다.
  • 주 실행에서 정확히 'Scanned 7 regular file paths; hashed 6.', 'Duplicate candidates: 2 groups, 5 paths.', 'Review CSV: outputs/duplicate_review/duplicates.csv'가 출력되었다.
  • duplicates.csv에는 헤더 외에 정확히 5개의 데이터 행이 포함되었다.
  • G001에는 empty_a.txt와 empty_b.txt가 포함되었으며 둘 다 크기는 0이고 SHA-256은 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855였다.
  • G002에는 archive/a_saved.txt, notes/a.txt, notes/a_copy.txt가 포함되었으며 모두 크기는 3이고 SHA-256은 ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad였다.
  • notes/other.txt와 unique.txt는 중복 보고서에 올바르게 포함되지 않았다.
  • 해시가 계산된 파일은 6개였다. 크기 0인 두 파일과 크기 3인 네 파일 모두가 대상이었으며, unique.txt는 같은 크기의 다른 파일이 없어 건너뛰었다.
  • 스캔 전후로 원본 파일 내용과 해시는 동일했다.
  • outputs/duplicate_review가 이미 존재하여 두 번째 실행은 FileExistsError로 중단되었다.
  • 파일 크기가 1, 2, 3바이트인 원본에서는 'hashed 0', 중복 그룹 0개, 헤더만 있는 CSV 보고서가 생성되었다.
  • OUTPUT_DIR을 SOURCE 하위에 배치하면 ValueError: 'The report folder must be outside SOURCE.'가 발생했다.
  • 원본에 심볼릭 링크 파일과 심볼릭 링크 디렉터리를 추가해도 스캐너는 여전히 일반 파일 경로 7개와 동일한 중복 그룹 2개만 보고했다.
검증 범위의 한계
  • 권한 오류, 해시 계산 중 파일 변경, 하드 링크 집계, Windows junction 또는 mount point, 중단된 쓰기, 악의적인 파일시스템 경쟁 조건, 대규모 디렉터리 성능은 테스트하지 않았다.

참고 출처

설명과 예제는 직접 작성했습니다. 관련 동작과 개념은 아래 공식 자료에서 확인할 수 있습니다.