다운로드 폴더, 바탕화면, 외장하드까지 여기저기 저장하다 보면 같은 파일이 이름만 다르게(또는 똑같이) 여러 군데에 중복 저장되는 경우가 많습니다. 파일명만 보고 지우자니 실수로 다른 파일을 지울까 불안하고, 하나하나 열어서 비교하기엔 시간이 너무 걸립니다. 이번 글에서는 파일 내용을 기준으로 진짜 중복 파일만 정확하게 찾아주는 파이썬 스크립트를 만들어보겠습니다.
왜 파일명이 아니라 내용으로 비교해야 할까
“사진.jpg”와 “사진(1).jpg”처럼 이름은 다르지만 내용이 완전히 같은 파일이 있는가 하면, 반대로 이름은 같아도 내용이 다른 파일도 있습니다. 그래서 파일명 대신 파일의 해시값(MD5)을 계산해서 비교하는 방식이 훨씬 정확합니다. 내용이 1비트라도 다르면 해시값도 달라지기 때문에, 해시값이 같은 파일은 사실상 100% 동일한 파일이라고 볼 수 있습니다.
중복 파일을 찾아주는 파이썬 스크립트
아래 스크립트는 지정한 폴더들을 하위 폴더까지 전부 훑으면서 파일 해시값을 계산하고, 같은 해시값을 가진 파일들을 그룹으로 묶어 보여줍니다.
import os, hashlib
from collections import defaultdict
TARGET_DIRS = [r"C:\Users\사용자명\Downloads", r"C:\Users\사용자명\Desktop"]
def get_file_hash(filepath, block_size=65536):
hasher = hashlib.md5()
with open(filepath, "rb") as f:
for block in iter(lambda: f.read(block_size), b""):
hasher.update(block)
return hasher.hexdigest()
def find_duplicates(dirs):
hash_map = defaultdict(list)
for directory in dirs:
for root, _, files in os.walk(directory):
for filename in files:
filepath = os.path.join(root, filename)
try:
file_hash = get_file_hash(filepath)
hash_map[file_hash].append(filepath)
except (PermissionError, FileNotFoundError):
continue
return {h: paths for h, paths in hash_map.items() if len(paths) > 1}
if __name__ == "__main__":
duplicates = find_duplicates(TARGET_DIRS)
for file_hash, paths in duplicates.items():
print(f"[중복 발견] 총 {len(paths)}개")
for p in paths:
print(f" - {p}")

찾은 중복 파일을 자동으로 정리하기
중복 목록을 눈으로 확인만 하고 끝내기보다, 각 그룹에서 가장 먼저 발견된 파일은 남기고 나머지는 별도의 “_dupes” 폴더로 옮겨서 나중에 검토 후 지우는 방식이 안전합니다. 위 find_duplicates 함수 결과를 활용해 아래 코드를 이어서 실행하면 됩니다.
import shutil
DUPE_DIR = r"C:\Users\사용자명\Desktop\_dupes"
def move_duplicates(duplicates):
os.makedirs(DUPE_DIR, exist_ok=True)
for paths in duplicates.values():
for filepath in paths[1:]:
target = os.path.join(DUPE_DIR, os.path.basename(filepath))
shutil.move(filepath, target)
print(f"이동 완료: {filepath} -> {target}")

직접 돌려 보니 — 1GB에서 1.46초가 0.17초로
위 스크립트를 1GB 남짓한 시험 데이터로 돌려 봤습니다. 환경은 Windows 11, Python 3.12.10, Ryzen 9 6900HX 노트북에 NVMe SSD입니다. Downloads와 Desktop 역할의 시험 폴더 두 개에 10KB~2MB 크기의 무작위 파일 1,000개를 나눠 만들었습니다. 그중 60개는 다른 폴더에 다른 이름(사본_3_doc0412.bin 같은)으로 복사해 두었습니다. 파일은 모두 1,060개, 1,032.5MB입니다.
원래 스크립트는 파일 1,060개를 전부 열어 끝까지 MD5를 계산합니다. 그런데 내용이 같은 파일은 크기도 반드시 같습니다. 그래서 크기부터 비교해 같은 크기가 하나뿐인 파일은 열지 않게 바꿨습니다. 크기가 겹치는 파일만 앞 64KB를 해시해 한 번 더 거르고, 그래도 같으면 전체를 해시합니다. 두 방식을 같은 데이터에 세 번씩 돌린 결과입니다. 디스크 캐시 영향을 줄이려고 측정 전에 한 번 미리 돌렸습니다.
| 방식 | 찾은 중복 | 걸린 시간(3회) |
|---|---|---|
| 원래 스크립트 (전체 파일 MD5) | 60그룹, 파일 120개 | 1.47초 · 1.45초 · 1.47초 |
| 크기 → 앞 64KB → 전체 SHA-256 | 60그룹, 파일 120개 (결과 동일) | 0.17초 · 0.17초 · 0.17초 |
약 8.6배 빨라졌고 찾은 그룹은 완전히 같았습니다. 차이가 나는 이유는 단순합니다. 1,060개 중 다른 파일과 크기가 겹치는 파일은 120개뿐이었고, 그게 정확히 중복 파일들이었습니다. 나머지 940개는 크기만 보고 후보에서 빠지니 열 필요가 없었습니다. 실제 다운로드 폴더처럼 파일 크기가 제각각인 곳일수록 효과가 큽니다. 반대로 같은 크기로 저장되는 파일(고정 크기 로그, 같은 해상도의 BMP 등)이 많다면 앞 64KB 단계가 걸러 주는 비중이 커집니다.
MD5가 더 빠를 줄 알았는데
해시 함수 자체의 속도도 재 봤습니다. 같은 1,060개 파일(1GB)을 1MB 단위로 읽으며 끝까지 해시한 시간입니다.
| 해시 함수 | 1GB 전체 해시 시간 |
|---|---|
| md5 | 1.96초 |
| sha1 | 1.24초 |
| sha256 | 1.25초 |
| blake2b | 2.16초 |
흔히 “MD5가 가볍고 빠르다”고 하지만, 이 PC에서는 SHA-256이 MD5보다 빨랐습니다. CPU에 따라 순서가 바뀔 수 있으니 절대적인 값으로 보지는 마세요. 다만 속도 차이가 크지 않다면 충돌 걱정이 없는 SHA-256을 쓰지 않을 이유가 없어서, 고친 판은 SHA-256으로 바꿨습니다.
직접 걸린 함정 세 가지
속도보다 중요한 건 엉뚱한 파일을 옮기지 않는 것입니다. 세 가지 상황을 일부러 만들어 원래 코드로 돌렸습니다.
| 상황 | 원래 코드 결과 | 고친 코드 결과 |
|---|---|---|
빈 파일 4개 (__init__.py 2개, .gitkeep, 빈 메모.txt) | 4개가 한 그룹으로 묶임. 옮기면 파이썬 패키지 폴더가 깨짐 | 0바이트 파일은 검사에서 제외 |
세 폴더에 같은 이름 report.csv | 2개를 옮겼는데 _dupes에는 1개만 남음. 어디서 왔는지 기록 없음 | report.csv, report (1).csv + 이동기록.csv |
_dupes가 검사 폴더(바탕화면) 안에 있음 | 두 번째 실행 때 _dupes 안의 파일이 다시 중복으로 잡힘 | skip_dirs로 제외, 두 번째 실행 0그룹 |
빈 파일은 내용이 없으니 해시값이 전부 같습니다. 원래 코드는 프로젝트 폴더의 __init__.py, 깃의 .gitkeep, 빈 메모 파일을 “중복 4개”로 묶었습니다. 이 상태로 정리를 실행하면 첫 번째를 뺀 나머지가 옮겨지고, 파이썬 패키지나 빈 폴더를 유지하던 파일이 사라집니다. 빈 파일은 지워도 용량이 줄지 않으니 아예 검사에서 빼는 게 맞습니다.
이름 충돌은 오류 없이 지나가서 더 알아채기 어렵습니다. 세 폴더의 report.csv 중 두 개를 _dupes로 옮겼는데, 두 번째 파일이 첫 번째를 덮어써서 하나만 남았습니다. 내용이 같은 파일이라 데이터가 날아간 건 아니지만, 어느 폴더에서 온 파일인지 알 수 없게 됩니다. 나중에 “이거 원래 USB에 있던 건데” 하며 되돌리려 해도 단서가 없습니다. 고친 판은 같은 이름이면 번호를 붙이고, 원래 위치·옮긴 위치·남긴 원본을 이동기록.csv에 남깁니다.
_dupes 위치도 문제였습니다. 원래 코드의 _dupes는 바탕화면 안에 있고, 바탕화면은 검사 대상입니다. 그래서 두 번째로 돌리면 이미 옮겨 둔 파일이 원본과 다시 한 그룹으로 잡힙니다. 이번 시험에서는 같은 자리로 옮기는 동작이라 실제 피해는 없었지만, 매번 결과에 가짜 중복이 섞여서 목록을 믿기 어려워집니다.
고친 전체 코드
세 가지를 모두 반영하고 크기 우선 비교를 넣은 버전입니다. 기본값으로는 목록만 출력하고, 확인한 뒤 마지막 줄의 주석을 풀어야 파일을 옮깁니다.
from collections import defaultdict
from datetime import datetime
import csv
import hashlib
import os
import shutil
def file_hash(path, limit=None, block_size=1 << 20):
"""limit을 주면 앞부분 limit 바이트만 해시한다."""
h = hashlib.sha256()
with open(path, "rb") as f:
if limit:
h.update(f.read(limit))
else:
for block in iter(lambda: f.read(block_size), b""):
h.update(block)
return h.hexdigest()
def find_duplicates_fast(dirs, skip_dirs=(), min_size=1):
skip = {os.path.normcase(os.path.abspath(d)) for d in skip_dirs}
# 1단계: 크기로 묶기 (파일을 열지 않아 거의 공짜)
by_size = defaultdict(list)
for directory in dirs:
for root, subdirs, files in os.walk(directory):
subdirs[:] = [d for d in subdirs if os.path.normcase(os.path.abspath(os.path.join(root, d))) not in skip]
for name in files:
path = os.path.join(root, name)
try:
size = os.path.getsize(path)
except OSError:
continue
if size >= min_size: # 0바이트 파일은 전부 같은 해시라 제외
by_size[size].append(path)
# 2단계: 크기가 같은 것끼리만 앞 64KB 해시
by_head = defaultdict(list)
for size, paths in by_size.items():
if len(paths) < 2:
continue
for p in paths:
try:
by_head[(size, file_hash(p, limit=65536))].append(p)
except OSError:
continue
# 3단계: 앞부분까지 같은 것만 전체 해시
result = defaultdict(list)
for key, paths in by_head.items():
if len(paths) < 2:
continue
for p in paths:
try:
result[file_hash(p)].append(p)
except OSError:
continue
return {h: ps for h, ps in result.items() if len(ps) > 1}
def move_duplicates(duplicates, dupe_dir):
os.makedirs(dupe_dir, exist_ok=True)
log_path = os.path.join(dupe_dir, "이동기록.csv")
new_log = not os.path.exists(log_path)
with open(log_path, "a", newline="", encoding="utf-8-sig") as f:
w = csv.writer(f)
if new_log:
w.writerow(["시각", "원래 위치", "옮긴 위치", "남긴 원본"])
for paths in duplicates.values():
keep = paths[0]
for filepath in paths[1:]:
name, ext = os.path.splitext(os.path.basename(filepath))
target = os.path.join(dupe_dir, name + ext)
n = 1
while os.path.exists(target): # 같은 이름이 있으면 번호를 붙인다
target = os.path.join(dupe_dir, f"{name} ({n}){ext}")
n += 1
shutil.move(filepath, target)
w.writerow([datetime.now().isoformat(timespec="seconds"), filepath, target, keep])
if __name__ == "__main__":
TARGET_DIRS = [r"C:\Users\사용자명\Downloads", r"C:\Users\사용자명\Desktop"]
DUPE_DIR = r"C:\Users\사용자명\Desktop\_dupes"
dupes = find_duplicates_fast(TARGET_DIRS, skip_dirs={DUPE_DIR})
for h, paths in dupes.items():
print(f"[중복 {len(paths)}개]", *paths, sep="\n ")
# 목록을 확인한 뒤 아래 줄의 주석을 풀어 실행
# move_duplicates(dupes, DUPE_DIR)
시험 데이터에서 이 코드는 빈 파일 세 개를 건너뛰었습니다. 같은 이름 report.csv 두 개는 report.csv와 report (1).csv로 나눠 옮겼고, 다시 돌렸을 때 중복 그룹은 0개였습니다. 이동기록.csv는 utf-8-sig로 저장해서 엑셀에서 바로 열어도 한글이 깨지지 않습니다.
실행 방법
- 두 코드를
find_duplicates.py파일 하나에 순서대로 저장합니다. TARGET_DIRS에 검사하고 싶은 폴더 경로를 원하는 만큼 추가합니다.- 터미널에서
python find_duplicates.py를 실행하면 중복 파일 목록이 출력됩니다. - 결과가 원하는 대로 나오면
move_duplicates(duplicates)호출을 코드 마지막에 추가해 실제로 이동시킵니다.
사용할 때 주의할 점
- 큰 파일이 많으면 전체 해시에 시간이 걸립니다. 위의 크기 우선 비교를 쓰고, 폴더 범위도 필요한 곳으로 좁혀서 실행하세요.
- 바로 삭제하지 말고 반드시 “_dupes” 폴더로 옮긴 뒤, 내용을 확인하고 나서 지우는 것을 권장합니다.
- 시스템 폴더나 프로그램 설치 폴더는 검사 대상에서 제외하세요. 실행 중인 프로그램 파일이 중복으로 인식되어 옮겨지면 프로그램이 망가질 수 있습니다.
파일명이 아니라 내용 기준으로 비교하니 이름만 보고 지울 때보다 훨씬 안전합니다. 다만 빈 파일과 이름 충돌, 정리 폴더 위치 세 가지는 직접 막아 두어야 합니다. 다음 글에서는 이번 스크립트와 짝을 이루는, 오래된 파일을 기준을 정해 자동으로 삭제하거나 백업하는 방법을 다뤄보겠습니다.