중복 파일 찾기 - 크기와 해시로 두 단계 거르기
이 장에서 배우는 것
문서 사본과 백업 파일이 쌓이면 이름만으로는 같은 파일을 찾기 어렵다. 이름이 달라도 내용이 같을 수 있고, 이름과 크기가 같아도 내용이 다를 수 있다. 이 장에서는 파일 크기로 후보를 줄이고, 남은 파일의 내용을 해시(hash)로 비교하는 프로그램을 만든다. 프로그램은 중복 후보의 목록만 출력하며 파일을 삭제하거나 옮기지 않는다.
앞 장에서 응답 자료를 요약했다면, 이번에는 파일 자체를 비교 대상으로 삼는다. 입력은 프로그램이 임시 폴더에 직접 만든다. 따라서 개인 폴더를 준비하지 않아도 같은 조건으로 실행하고 결과를 검사할 수 있다.
- 파일 크기가 다른 대상을 먼저 제외해 불필요한 읽기를 줄인다.
- hashlib의 SHA-256으로 파일 내용의 지문을 계산한다.
- 큰 파일을 일정한 크기로 나누어 읽어 메모리 사용을 제한한다.
- 빈 파일, 같은 크기의 다른 파일, 하위 폴더를 포함해 결과를 검사한다.
- 상대 경로와 정렬 기준을 고정해 실행할 때마다 같은 목록을 얻는다.
문제 상황
공동 작업 자료를 여러 번 내려받은 폴더를 생각해 보자. 파일명 뒤에 ‘사본’이나 날짜가 붙어 있지만, 어느 것이 같은 내용인지는 분명하지 않다. 다른 폴더로 복사하면서 이름이 바뀐 파일도 있다. 이름으로 묶으면 이런 중복을 놓치고, 확장자만으로 묶으면 서로 다른 파일이 한데 섞인다.
이번 프로그램의 질문은 단순하다. “이 폴더에서 내용이 같을 가능성이 매우 높은 파일은 어떤 것들인가?”다. 어느 파일을 보관할지 결정하는 일은 포함하지 않는다. 경로가 다르다는 사실만으로 지워도 되는 사본이라고 판단할 수 없기 때문이다. 같은 내용이더라도 서로 다른 작업에서 필요한 위치에 놓여 있을 수 있다.
실제 폴더를 받는 기능부터 만들지 않는다. 먼저 답을 아는 예제 폴더를 만들어 비교 절차를 검증한다. 예제에는 빈 파일 두 개, 큰 파일 두 개, 내용이 같은 메모 두 개, 메모와 크기만 같은 파일 하나, 크기가 혼자 다른 파일 하나를 넣는다. 총 여덟 파일 중 일곱 파일이 해시 계산 대상이 되고, 세 묶음이 출력되어야 한다.
요구사항은 다음과 같이 고정한다. 비교 범위는 임시 폴더 아래의 일반 파일이다. 하위 폴더도 탐색하되 심볼릭 링크(symbolic link)는 따라가지 않는다. 파일은 비교하는 동안 바뀌지 않는다고 가정한다. 크기가 같은 파일이 둘 이상일 때만 내용을 읽고, 결과에는 임시 폴더의 임의 이름 대신 상대 경로를 표시한다.
크기로 후보를 줄이고 해시로 묶기
내용이 같은 파일은 바이트 수가 같다. 이 성질을 이용하면 모든 파일의 내용을 읽을 필요가 없다. 먼저 파일의 크기를 조사해 크기별 목록을 만든다. 어떤 크기의 파일이 하나뿐이면, 현재 비교 범위 안에는 그 파일과 내용이 같은 다른 파일이 없다. 그 파일은 다음 단계에서 제외한다.
같은 크기라는 조건은 중복의 증거가 아니다. 예를 들어 ‘meeting memo’와 ‘budget draft’는 마지막 줄바꿈까지 포함하면 각각 13바이트지만 내용은 다르다. 크기는 후보를 거르는 조건이고, 내용 비교는 별도 단계다. 이 구분이 흐려지면 정상 파일을 중복으로 잘못 표시하게 된다.
두 번째 단계에서는 후보 파일마다 SHA-256을 계산한다. 같은 크기 안에서 해시값까지 같은 파일들을 한 묶음으로 모으고, 묶음의 파일 수가 둘 이상일 때만 결과에 넣는다. 파일명과 확장자는 이 판정에 참여하지 않는다.
해시값이 같다는 사실은 수학적으로 내용이 같다는 증명은 아니다. 서로 다른 내용이 같은 해시값을 갖는 충돌 가능성이 있기 때문이다. 이 장의 도구는 검토용 중복 후보를 찾는 용도로 SHA-256을 사용한다. 출력에서도 ‘중복 후보’라는 표현을 쓴다. 바이트 단위의 최종 대조가 필요한 업무라면 같은 크기와 해시로 모인 파일을 다시 직접 비교하는 단계를 추가한다.
| 단계 | 확인하는 값 | 통과 조건 | 주의점 |
|---|---|---|---|
| 크기 분류 | 파일의 바이트 수 | 같은 크기의 파일이 둘 이상 | 크기가 같아도 내용은 다를 수 있다 |
| 해시 분류 | 내용의 SHA-256 | 같은 해시의 파일이 둘 이상 | 검토할 중복 후보로 취급한다 |
| 결과 표시 | 정렬한 상대 경로 | 후보 묶음에 속한 파일 | 보관할 파일을 자동으로 정하지 않는다 |
크기 조사도 비용이 없는 작업은 아니다. 파일마다 메타데이터를 읽어야 하고, 하위 폴더를 순회해야 한다. 다만 파일의 전체 내용을 읽는 작업을 줄일 수 있다. 모든 파일의 크기가 같다면 후보가 줄지 않는다. 성능 개선의 정도는 자료의 구성에 달려 있다.
큰 파일은 나누어 읽고 경로는 정렬하기
파일 전체를 한 번에 읽으면 파일 크기에 비례하는 메모리가 필요하다. 대신 일정한 양을 반복해서 읽고 같은 해시 객체에 차례대로 전달한다. 해시 객체는 앞에서 받은 내용에 이어 다음 내용을 반영하므로, 읽는 조각의 경계가 달라도 바이트 순서가 같으면 결과는 같다.
완성 코드의 기본 읽기 크기는 65,536바이트다. 마지막 조각은 이보다 작을 수 있다. 읽은 결과가 빈 바이트열이면 파일 끝에 도달한 것이므로 반복을 끝낸다. 빈 파일에서는 첫 읽기부터 빈 바이트열이 나오며, 아무 내용도 전달하지 않은 SHA-256 객체의 결과를 그대로 사용한다.
여기서 줄어드는 것은 파일 내용 버퍼에 필요한 메모리다. 파일 경로 목록과 분류용 사전은 여전히 파일 수에 따라 커진다. 따라서 ‘큰 파일을 처리한다’와 ‘파일이 수천만 개여도 같은 메모리로 처리한다’는 서로 다른 요구다. 이번 예제는 전자에 초점을 맞춘다.
출력 순서도 명세에 포함한다. 운영체제가 파일을 돌려주는 순서는 결과의 정렬 기준으로 삼지 않는다. 각 묶음 내부는 상대 경로순으로, 묶음끼리는 첫 번째 상대 경로순으로 정렬한다. 하위 폴더 구분자는 as_posix()로 표현해 출력 형식을 통일한다. 임시 폴더의 실제 경로와 파일 수정 시각은 출력하지 않는다.
AI에게 요청하고 검사로 수정하기
요청문에는 구현 방법뿐 아니라 확인 가능한 예제와 제외할 동작을 함께 적는다. 아래 요청은 특정 제품 기능에 의존하지 않는다. 답변을 받으면 바로 채택하지 않고 예제의 기대값과 대조한다.
Python 3.12 이상 표준 라이브러리만 사용하는 main.py를 작성하라. 실행하면 tempfile.TemporaryDirectory로 예제 폴더를 만들고, 하위 폴더의 일반 파일에서 중복 후보를 찾아 목록만 출력하라. 삭제와 이동 기능은 넣지 마라.
먼저 파일 크기로 분류하고, 같은 크기의 파일이 둘 이상일 때만 SHA-256을 계산하라. 파일은 65,536바이트씩 나누어 읽어라. 빈 파일도 비교하고 심볼릭 링크는 제외하라. 비교 중 파일은 바뀌지 않는다고 가정하라.
예제에는 빈 파일 두 개, 1,200,000바이트의 같은 파일 두 개, 같은 메모 두 개, 메모와 크기는 같지만 내용은 다른 파일 하나, 크기가 혼자 다른 파일 하나를 만들어라. 상대 경로와 묶음 순서를 정렬하고 자체 검사 후 결과를 출력하라. python3 main.py 한 번으로 끝나야 한다.
첫 결과를 검토할 때는 “대체로 작동한다”는 인상보다 경계 조건을 먼저 확인한다. 다음 표는 1차 구현에서 발견해 수정할 만한 구체적인 문제를 정리한 것이다. AI 답변이 성공을 선언했더라도 각 항목은 실행과 코드 검토로 따로 확인한다.
| 문제 | 발견 방법 | 고친 방향 |
|---|---|---|
| 크기가 같은 메모 세 개를 모두 중복으로 표시했다 | 내용이 다른 notes/c.txt가 결과에 포함되는지 확인했다 | 크기별 후보 안에서 해시로 다시 분류했다 |
| 빈 파일을 크기 0이라는 이유로 제외했다 | empty/a.txt와 empty/b.txt의 묶음이 빠지는지 확인했다 | 크기의 참·거짓 대신 후보 개수로 판단했다 |
| read()에 크기를 지정하지 않았다 | 파일 읽기 부분의 변경 내역을 검토했다 | 읽기 크기를 인자로 받고 반복해서 갱신했다 |
| 절대 경로와 탐색 순서를 그대로 출력했다 | 임시 폴더 이름이 출력에 섞이고 정렬 코드가 없는지 확인했다 | 상대 경로로 바꾸고 두 수준의 정렬을 추가했다 |
수정 요청도 관찰 결과를 중심으로 쓴다. “더 안전하게 만들어라”보다 어느 파일이 잘못 묶였는지 알려 주는 편이 결과를 검토하기 쉽다.
notes/c.txt는 notes/a.txt와 크기만 같고 내용은 다르므로 결과에서 빠져야 한다. 빈 파일 두 개는 하나의 후보 묶음이어야 한다. 크기 분류 뒤에 해시 분류를 추가하고, 크기 0을 제외하는 조건을 제거하라. 출력은 상대 경로순으로 고정하라. 수정한 부분과 자체 검사 항목도 설명하라.
검사는 최종 묶음뿐 아니라 중간 결과도 다룬다. 전체 파일은 여덟 개이고, 해시 계산 대상은 일곱 개여야 한다. 묶음에 속한 파일은 여섯 개여야 하며, 각 묶음의 크기도 예상과 같아야 한다. 큰 파일은 기본 읽기 크기와 다른 크기로 읽어도 같은 해시가 나오는지 확인한다.
수정 전후 차이(diff)를 볼 때는 파일 쓰기와 읽기를 구분한다. 예제 생성 함수만 임시 폴더에 파일을 쓰고, 검색 함수는 읽기만 해야 한다. unlink(), remove(), rename() 같은 호출이 검색 과정에 들어오지 않았는지도 확인한다. TemporaryDirectory가 실행 종료 때 예제 폴더를 정리하는 동작은 중복 파일 삭제 기능과 별개다.
완성 코드
다음 내용을 main.py로 저장한다. 읽기 실패는 숨기지 않고 예외로 드러나게 두었다. 따라서 정상 완료한 결과와 일부 파일을 읽지 못한 결과가 같은 모양으로 출력되지 않는다. 자체 검사 역시 실패하면 결과 목록 출력 전에 실행을 멈춘다.
import hashlib
import os
import tempfile
from collections import defaultdict
from pathlib import Path
CHUNK_SIZE = 65_536
def make_examples(root: Path) -> None:
memo = b"meeting memo\n"
large = b"ABCD" * 300_000
files = {
"empty/a.txt": b"",
"empty/b.txt": b"",
"large/a.bin": large,
"large/b.bin": large,
"notes/a.txt": memo,
"notes/b.txt": memo,
"notes/c.txt": b"budget draft\n",
"unique.txt": b"only\n",
}
for name, content in files.items():
path = root / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content)
def file_hash(path: Path, chunk_size: int = CHUNK_SIZE) -> str:
if chunk_size <= 0:
raise ValueError("읽기 크기는 양수여야 한다")
digest = hashlib.sha256()
with path.open("rb") as stream:
while True:
chunk = stream.read(chunk_size)
if not chunk:
break
digest.update(chunk)
return digest.hexdigest()
def collect_files(root: Path) -> list[Path]:
paths = []
def stop_on_error(error: OSError) -> None:
raise error
for folder, directories, names in os.walk(
root, followlinks=False, onerror=stop_on_error
):
base = Path(folder)
directories[:] = sorted(
name for name in directories
if not (base / name).is_symlink()
)
for name in sorted(names):
path = base / name
if path.is_symlink():
continue
if path.is_file():
paths.append(path)
return sorted(paths, key=lambda p: p.relative_to(root).as_posix())
def find_duplicates(
root: Path,
) -> tuple[list[tuple[int, list[str]]], int, int]:
paths = collect_files(root)
by_size = defaultdict(list)
for path in paths:
by_size[path.stat().st_size].append(path)
groups = []
hashed_count = 0
for size, candidates in sorted(by_size.items()):
if len(candidates) < 2:
continue
by_hash = defaultdict(list)
for path in candidates:
value = file_hash(path)
hashed_count += 1
relative = path.relative_to(root).as_posix()
by_hash[value].append(relative)
for names in by_hash.values():
if len(names) >= 2:
groups.append((size, sorted(names)))
groups.sort(key=lambda group: group[1][0])
return groups, len(paths), hashed_count
def check_examples(
root: Path,
groups: list[tuple[int, list[str]]],
scanned_count: int,
hashed_count: int,
) -> None:
expected = [
(0, ["empty/a.txt", "empty/b.txt"]),
(1_200_000, ["large/a.bin", "large/b.bin"]),
(13, ["notes/a.txt", "notes/b.txt"]),
]
if groups != expected:
raise AssertionError("중복 후보 묶음이 예상과 다르다")
if scanned_count != 8 or hashed_count != 7:
raise AssertionError("전체 파일 수 또는 해시 대상 수가 다르다")
empty = root / "empty/a.txt"
if file_hash(empty) != hashlib.sha256(b"").hexdigest():
raise AssertionError("빈 파일의 해시가 다르다")
large = root / "large/a.bin"
expected_hash = hashlib.sha256(b"ABCD" * 300_000).hexdigest()
for chunk_size in (CHUNK_SIZE, 100_003):
if file_hash(large, chunk_size) != expected_hash:
raise AssertionError("읽기 크기에 따라 해시가 달라졌다")
try:
file_hash(empty, 0)
except ValueError:
pass
else:
raise AssertionError("잘못된 읽기 크기를 허용했다")
def main() -> None:
with tempfile.TemporaryDirectory(prefix="duplicate-demo-") as folder:
root = Path(folder)
make_examples(root)
groups, scanned_count, hashed_count = find_duplicates(root)
check_examples(root, groups, scanned_count, hashed_count)
print("자체 검사: 통과")
print(f"전체 파일: {scanned_count}개")
print(f"해시 계산 대상: {hashed_count}개")
print(f"중복 후보 묶음: {len(groups)}개")
print("파일을 삭제하거나 옮기지 않습니다.")
for number, (size, names) in enumerate(groups, start=1):
print(f"\n[{number}] {size:,}바이트 / {len(names)}개")
for name in names:
print(f" {name}")
if __name__ == "__main__":
main()
줄별 해설
가져오기와 CHUNK_SIZE. hashlib는 해시 계산, os는 폴더 순회, tempfile은 예제 작업 공간을 담당한다. defaultdict(list)는 새 분류 키에 빈 목록을 준비한다. Path는 경로 조합과 파일 입출력에 사용한다. 숫자 사이의 밑줄은 읽기 편의를 위한 표기이며 값에 영향을 주지 않는다.
make_examples(). 사전의 키는 상대 경로이고 값은 저장할 바이트열이다. 문자열을 운영체제 기본 인코딩으로 저장하지 않고 바이트를 직접 써서 예제의 크기를 고정했다. parents=True는 하위 폴더의 부모까지 만들고, exist_ok=True는 이미 만들어진 폴더를 다시 만났을 때 계속 진행하게 한다.
large 변수. 큰 예제는 네 바이트를 300,000번 반복해 만든다. 예제 생성에서는 내용을 메모리에 준비하지만, 실제 검색 함수는 이 변수를 전달받지 않는다. 검색은 디스크에 저장된 파일을 다시 나누어 읽는다. 예제 준비 방식과 검색 알고리즘의 메모리 사용을 구분해서 읽어야 한다.
file_hash()의 입력 검사. 읽기 크기가 0이면 첫 읽기부터 빈 결과가 나와 실제 내용이 무시된다. 음수는 파일 전체를 읽는 동작으로 이어질 수 있다. 둘 다 나누어 읽는다는 계약에 어긋나므로 파일을 열기 전에 ValueError를 발생시킨다.
rb와 digest.update(). 파일은 이진 모드로 연다. 텍스트로 읽으면 디코딩과 줄바꿈 처리가 개입할 수 있지만 여기서는 저장된 바이트 그대로가 비교 대상이다. 반복마다 같은 digest 객체를 갱신하고, 파일 끝에 도달한 뒤 한 번만 hexdigest()를 호출한다.
collect_files(). os.walk()는 현재 폴더와 하위 폴더 목록, 파일 이름 목록을 준다. directories[:]에 대입하면 실제로 다음에 방문할 폴더 목록을 바꿀 수 있다. 여기서는 링크인 폴더를 빼고 순서를 정한다. 파일 이름에 대해서도 링크를 먼저 제외한 뒤 일반 파일인지 확인한다.
stop_on_error(). 폴더 탐색 중 생긴 오류를 다시 발생시킨다. 탐색 오류를 무시하면 읽지 못한 하위 폴더가 있어도 정상 검색처럼 보일 수 있다. 파일을 여는 과정이나 크기를 조사하는 과정의 오류도 잡아서 없애지 않는다. 이 예제는 실패 사실을 감추지 않는 쪽을 선택했다.
by_size와 by_hash. 첫 사전의 키는 바이트 수다. 후보가 둘 이상인 크기만 두 번째 사전으로 넘어간다. 두 번째 사전의 키는 SHA-256 문자열이다. 크기별 반복 안에서 새 사전을 만들기 때문에 최종 묶음은 크기와 해시가 모두 같은 파일로 구성된다.
hashed_count. 해시 함수를 호출한 파일마다 하나씩 더한다. 전체 파일 수와 이 값을 비교하면 크기 필터가 실제로 일부 파일을 제외했는지 확인할 수 있다. 자체 검사를 위해 추가로 계산하는 해시는 이 집계에 포함하지 않는다. 이 숫자는 검색 단계의 작업량을 뜻한다.
check_examples(). 예상 상대 경로와 크기를 함께 비교한다. 이 한 검사로 빈 파일 누락, 같은 크기의 다른 파일 혼입, 묶음 순서 오류를 확인한다. 큰 파일은 서로 다른 읽기 크기로 계산한 결과를 메모리의 원본 바이트에서 계산한 값과 대조한다. 검사를 통과했다는 문구는 모든 확인이 끝난 뒤에 출력한다.
main(). 임시 폴더 생성, 예제 작성, 검색, 검사, 출력 순서로 실행한다. with 블록을 벗어나면 임시 예제가 정리된다. 출력에 폴더의 실제 이름을 넣지 않으므로 실행마다 달라지는 임시 경로가 결과에 영향을 주지 않는다.
실행 결과
터미널에서 다음 명령으로 문법을 확인한 뒤 실행한다. 컴파일 검사는 문법 문제를 찾고, 프로그램 안의 자체 검사는 준비한 사례에 대한 동작을 확인한다. 두 검사는 역할이 다르다.
python3 -W error -m py_compile main.py
python3 main.py
컴파일 검사가 성공하면 별도 출력이 없다. 프로그램의 예상 출력은 다음과 같다.
자체 검사: 통과
전체 파일: 8개
해시 계산 대상: 7개
중복 후보 묶음: 3개
파일을 삭제하거나 옮기지 않습니다.
[1] 0바이트 / 2개
empty/a.txt
empty/b.txt
[2] 1,200,000바이트 / 2개
large/a.bin
large/b.bin
[3] 13바이트 / 2개
notes/a.txt
notes/b.txt
notes/c.txt는 13바이트이므로 해시 계산 대상에는 포함되지만 결과 묶음에는 없다. unique.txt는 같은 크기의 다른 파일이 없으므로 내용 읽기를 건너뛴다. 빈 파일 두 개도 같은 내용을 가진 파일이므로 첫 번째 후보 묶음으로 나온다.
자체 검사 통과는 준비한 사례가 기대와 일치했다는 뜻이다. 비교 중 파일이 변경되는 상황이나 모든 권한 오류를 처리한다는 뜻은 아니다. 실제 폴더로 범위를 넓히기 전에는 이런 조건을 별도 요구사항으로 추가해야 한다.
실무에서 자주 틀리는 것
크기가 같으면 바로 중복으로 판단한다
다음 함수는 크기만 비교하므로 메모와 다른 내용의 같은 크기 파일도 참으로 판단한다. 잘못된 판단 방식을 보여 주는 실행 가능한 함수다.
def same_candidate_wrong(left, right):
return left.stat().st_size == right.stat().st_size
수정한 함수는 크기로 먼저 거르고 해시까지 비교한다. 완성 코드의 file_hash()와 함께 사용할 수 있다. 반환값은 여전히 검토할 중복 후보 여부를 뜻한다.
def same_candidate_fixed(left, right):
if left.stat().st_size != right.stat().st_size:
return False
return file_hash(left) == file_hash(right)
완성 프로그램은 모든 파일 쌍에 이 함수를 호출하지 않는다. 사전으로 분류해서 같은 파일을 여러 쌍에서 반복해서 읽는 일을 피한다.
크기 0과 후보 없음의 의미를 섞는다
크기를 조건식에 그대로 넣으면 빈 파일이 제외된다. 아래 함수는 그 잘못된 기준을 드러낸다.
def should_hash_wrong(size, candidates):
return bool(size) and len(candidates) >= 2
다음 단계로 넘길 기준은 크기의 값이 아니라 같은 크기의 후보 수다.
def should_hash_fixed(size, candidates):
return len(candidates) >= 2
매개변수 size는 두 함수를 비교하기 위해 남겨 두었다. 실제 완성 코드에서는 필요한 후보 개수만 직접 검사한다.
파일 전체를 읽거나 조각마다 해시를 새로 만든다
다음 구현은 짧지만 파일 전체를 메모리에 올린다. 작은 예제만 실행하면 문제를 알아채기 어렵다.
def hash_wrong(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
수정할 때는 해시 객체를 반복문 밖에서 만들고, 순서대로 읽은 모든 조각을 같은 객체에 전달한다.
def hash_fixed(path):
digest = hashlib.sha256()
with path.open("rb") as stream:
while True:
chunk = stream.read(65_536)
if not chunk:
break
digest.update(chunk)
return digest.hexdigest()
조각별 해시 문자열을 이어 붙이는 방식은 파일 전체의 SHA-256과 다른 계산이다. 큰 파일 하나가 여러 번의 읽기를 거치도록 예제를 구성해야 이런 변경도 검사할 수 있다.
탐색 순서를 그대로 출력한다
다음 함수는 전달받은 순서에 의존한다. 탐색 코드가 바뀌거나 파일 시스템이 다른 순서로 항목을 돌려주면 출력도 바뀔 수 있다.
def names_wrong(root, paths):
return [path.relative_to(root).as_posix() for path in paths]
상대 경로 문자열을 만든 뒤 명시적으로 정렬한다. 완성 코드에서는 묶음 내부와 묶음 사이에 모두 정렬 기준을 적용한다.
def names_fixed(root, paths):
return sorted(
path.relative_to(root).as_posix()
for path in paths
)
또한 링크 제외 검사는 변화하는 폴더를 보호하는 보안 경계가 아니다. 검사 직후 경로가 교체될 수도 있다. 이 도구는 파일이 바뀌지 않는 작업 공간을 전제로 한다. 실시간으로 쓰이는 폴더를 다룬다면 읽기 전후 상태 확인과 변경된 파일의 별도 보고 등을 설계해야 한다.
한눈에 보기
| 항목 | 구현 기준 | 확인할 결과 |
|---|---|---|
| 검색 범위 | 하위 폴더의 일반 파일, 링크 제외 | 예제 파일 여덟 개 수집 |
| 첫 번째 필터 | 크기별로 둘 이상인 파일만 통과 | 일곱 파일의 해시 계산 |
| 내용 비교 | 이진 읽기와 SHA-256 | 크기만 같은 notes/c.txt 제외 |
| 읽기 방식 | 고정 크기로 반복 읽기 | 읽기 크기가 달라도 같은 해시 |
| 빈 파일 | 일반 파일과 같은 절차 적용 | 0바이트 후보 묶음 생성 |
| 출력 | 상대 경로와 명시적 정렬 | 세 묶음을 일정한 순서로 표시 |
| 변경 범위 | 검색은 읽기와 보고만 수행 | 삭제·이동 코드가 없음 |
이 도구의 핵심은 비교 비용이 다른 조건을 순서대로 적용하는 데 있다. 값싼 조건으로 후보를 줄이고, 남은 대상에 더 많은 읽기가 필요한 비교를 적용한다. 각 단계의 대상 수를 함께 확인하면 결과뿐 아니라 의도한 처리 흐름도 검증할 수 있다.
연습 문제
- unique.txt와 내용이 같은 unique-copy.txt를 예제에 추가하라. 전체 파일 수, 해시 계산 대상 수, 후보 묶음 수를 먼저 예상하고 자체 검사와 출력을 수정하라.
- 큰 예제 파일의 마지막에 한 바이트를 덧붙이도록 두 파일을 함께 바꾸라. 기본 읽기 크기로 읽을 때 마지막 조각의 크기를 계산하고, 자체 검사의 예상 크기와 해시를 수정하라.
- 각 묶음에서 한 파일만 남긴다고 가정할 때 겹치는 내용의 바이트 수를 계산해 출력하라. 실제 디스크에서 확보되는 공간과 같은 값이라고 설명하지 않도록 출력 문구를 정하라.
- 같은 크기와 해시로 묶인 후보를 바이트 단위로 다시 대조하는 확장 요청문을 작성하라. 해시가 우연히 같아도 내용이 다른 파일이 한 묶음에 남지 않도록 분류 기준과 검사 방법을 포함하라.
정답과 해설
1. 전체 파일은 아홉 개, 해시 계산 대상도 아홉 개, 후보 묶음은 네 개가 된다. 원래 해시를 계산하지 않았던 unique.txt까지 새 후보와 함께 계산하므로 해시 대상은 두 개 늘어난다. 예제 사전에 unique-copy.txt와 b"only\n"을 추가하고, 예상 목록의 끝에 5바이트 묶음을 추가한다. 그 묶음 내부에서는 unique-copy.txt가 unique.txt보다 먼저 온다. 자체 검사의 전체 수와 해시 대상 수도 모두 9로 고쳐야 한다.
2. 두 큰 파일의 내용은 b"ABCD" * 300_000 + b"X"로 바꾼다. 파일 크기는 1,200,001바이트가 된다. 65,536바이트를 18번 읽으면 1,179,648바이트이므로 마지막 조각은 20,353바이트다. 예상 묶음의 크기와 expected_hash를 만드는 바이트열을 함께 수정한다. 검색 코드의 읽기 반복문은 바꿀 필요가 없다.
3. 묶음의 파일 수가 n이고 크기가 s라면, 한 파일을 제외한 내용의 합은 s × (n − 1)이다. 다음 함수는 완성 코드가 반환하는 groups에 적용할 수 있다.
def repeated_content_bytes(groups):
return sum(
size * (len(names) - 1)
for size, names in groups
)
원래 예제의 계산값은 1,200,013바이트다. 출력 문구는 ‘묶음마다 한 파일을 제외한 내용 합계’처럼 계산의 의미를 드러내면 된다. 링크 관계, 저장 장치의 압축, 파일 시스템의 할당 단위에 따라 실제 공간은 달라질 수 있다. 이 값으로 삭제 후 확보 공간을 단정하지 않는다.
4. 요청문에는 최종 확인 방법뿐 아니라 하나의 해시 묶음을 다시 여러 내용 묶음으로 나눌 수 있어야 한다는 조건도 넣는다. 첫 파일과 다른 파일을 모두 버리는 방식은 나머지 파일끼리 같은 경우를 놓칠 수 있다.
현재 중복 후보 검색기에 바이트 단위 최종 비교를 추가하라. 같은 크기와 SHA-256으로 모인 파일들을 대상으로, 각 내용 묶음의 대표 파일과 새 파일을 일정 크기로 나누어 직접 비교하라. 일치하는 대표가 없으면 새 내용 묶음을 만들고, 파일이 둘 이상인 묶음만 출력하라.
파일 삭제와 이동 기능은 추가하지 마라. 비교 중 파일은 바뀌지 않는다고 가정하고 이 전제를 설명하라. 검사에서는 해시 함수를 주입할 수 있게 하여 모든 파일에 같은 해시를 돌려주는 대체 함수를 사용하라. 같은 크기의 서로 다른 내용이 최종적으로 분리되고, 같은 내용의 파일들은 다시 모이는지 확인하라. 빈 파일과 마지막 조각이 짧은 파일도 검사하라.
확장 뒤에도 실행 결과와 변경 내역을 함께 검토한다. 특히 최종 비교가 파일 전체를 한 번에 읽도록 되돌아가지 않았는지, 정렬 기준이 유지되는지 확인한다. 다음 장에서는 숫자 야구를 만들며, 파일 비교 대신 게임 규칙과 상태 변화를 명세와 검사로 연결한다.