작업 폴더를 외장 디스크나 다른 드라이브에 백업할 때 매번 폴더째 통째로 복사하면, 바뀐 건 몇 개뿐인데도 전체를 다시 씁니다. 이 글에서는 폴더 동기화 파이썬 스크립트를 표준 라이브러리만으로 만들어, 두 폴더를 비교한 뒤 새로 생기거나 바뀐 파일만 복사하고 필요하면 원본에서 지운 파일도 정리하게 해 봅니다. 직접 2,000개 파일(약 100MB) 폴더로 돌려 보니 처음 전체 복사는 1.774초, 그중 35개를 바꾼 뒤 다시 동기화할 때는 비교 0.108초에 복사·삭제 0.034초로 끝났습니다. 반대로 복사 함수 하나를 잘못 고르면 아무것도 안 바꿨는데 500개 전부를 다시 복사하는 것도 확인했습니다. 환경은 윈도우 11, Python 3.12.10입니다.
바뀐 파일을 어떻게 알아내나 — 크기와 수정시각
두 폴더에 같은 이름의 파일이 있을 때 “바뀌었는지”를 가장 빠르게 판단하는 방법은 파일 내용을 읽지 않고 크기와 수정시각만 비교하는 것입니다. 운영체제가 이미 들고 있는 정보라서 os.stat() 한 번이면 됩니다. 먼저 폴더 하나를 훑어 “상대경로 → (크기, 수정시각)” 사전을 만듭니다.
import os
def scan(root):
"""상대경로 -> (크기, 수정시각ns)"""
files = {}
for dirpath, dirnames, filenames in os.walk(root):
for name in filenames:
full = os.path.join(dirpath, name)
st = os.stat(full)
files[os.path.relpath(full, root)] = (st.st_size, st.st_mtime_ns)
return files
상대경로를 키로 쓰는 이유는 원본이 D:\작업이고 백업이 E:\백업이어도 사진\a.jpg처럼 같은 키로 맞춰 비교하기 위해서입니다. 수정시각은 st_mtime(소수 초) 대신 st_mtime_ns(정수 나노초)를 썼습니다. 정수라서 부동소수점 반올림 때문에 같은 시각이 다르게 비교될 걱정이 없습니다.
두 사전이 있으면 분류는 간단합니다. 원본에만 있으면 새 파일, 대상에만 있으면 원본에서 지워진 파일, 둘 다 있는데 (크기, 수정시각)이 다르면 바뀐 파일입니다.
전체 스크립트 — 미리보기가 기본, –run일 때만 복사
실수로 대상 폴더를 망가뜨리지 않도록 기본 동작은 무엇을 할지 목록만 보여주는 미리보기로 했습니다. --run을 붙여야 실제로 복사하고, 대상에만 있는 파일 삭제는 --delete까지 붙였을 때만 합니다. --hash는 뒤에서 설명할 내용 비교 모드입니다.
"""폴더 단방향 동기화: src에서 바뀐 파일만 dst로 복사 (표준 라이브러리만)"""
import hashlib
import os
import shutil
import sys
def scan(root):
files = {}
for dirpath, dirnames, filenames in os.walk(root):
for name in filenames:
full = os.path.join(dirpath, name)
st = os.stat(full)
files[os.path.relpath(full, root)] = (st.st_size, st.st_mtime_ns)
return files
def sha256(path):
h = hashlib.sha256()
with open(path, 'rb') as f:
for chunk in iter(lambda: f.read(1024 * 1024), b''):
h.update(chunk)
return h.hexdigest()
def make_plan(src, dst, mode='mtime'):
s, d = scan(src), scan(dst)
new = sorted(r for r in s if r not in d)
gone = sorted(r for r in d if r not in s)
changed = []
for r in sorted(s.keys() & d.keys()):
if mode == 'hash':
if s[r][0] != d[r][0] or sha256(os.path.join(src, r)) != sha256(os.path.join(dst, r)):
changed.append(r)
elif s[r] != d[r]: # 크기나 수정시각이 다르면 바뀐 것으로 본다
changed.append(r)
return new, changed, gone
def apply_plan(src, dst, new, changed, gone, delete=False, copy=shutil.copy2):
for r in new + changed:
target = os.path.join(dst, r)
os.makedirs(os.path.dirname(target), exist_ok=True)
copy(os.path.join(src, r), target)
if delete:
for r in gone:
os.remove(os.path.join(dst, r))
if __name__ == '__main__':
# 사용법: python sync.py 원본폴더 대상폴더 [--run] [--delete] [--hash]
src, dst = sys.argv[1], sys.argv[2]
run, delete = '--run' in sys.argv, '--delete' in sys.argv
mode = 'hash' if '--hash' in sys.argv else 'mtime'
new, changed, gone = make_plan(src, dst, mode)
for r in new:
print('새 파일 ', r)
for r in changed:
print('바뀜 ', r)
for r in gone:
print('삭제 대상' if delete else '대상에만 있음', r)
print(f'새 파일 {len(new)}개, 바뀜 {len(changed)}개, 대상에만 있음 {len(gone)}개')
if run:
apply_plan(src, dst, new, changed, gone, delete)
print('동기화 완료')
else:
print('미리보기만 했습니다. 실제로 복사하려면 --run 을 붙이세요.')
원본·백업 폴더에 파일 4개를 똑같이 만든 뒤 원본에서 보고서.txt를 고치고, 사진\c.jpg를 추가하고, 메모.txt를 지운 상태로 돌려 봤습니다.
$ python -X utf8 sync.py demo/원본 demo/백업
새 파일 사진\c.jpg
바뀜 보고서.txt
대상에만 있음 메모.txt
새 파일 1개, 바뀜 1개, 대상에만 있음 1개
미리보기만 했습니다. 실제로 복사하려면 --run 을 붙이세요.
$ python -X utf8 sync.py demo/원본 demo/백업 --run --delete
새 파일 사진\c.jpg
바뀜 보고서.txt
삭제 대상 메모.txt
새 파일 1개, 바뀜 1개, 대상에만 있음 1개
동기화 완료
$ python -X utf8 sync.py demo/원본 demo/백업
새 파일 0개, 바뀜 0개, 대상에만 있음 0개
미리보기만 했습니다. 실제로 복사하려면 --run 을 붙이세요.
미리보기에서 세 가지 변화를 정확히 잡았고, --run --delete 뒤 다시 비교하니 0개였습니다. 한글 폴더명·파일명도 문제없었습니다. 미리보기 한 번은 0.073초 걸렸습니다. 출력에 한글이 들어가니 윈도우 콘솔에서는 python -X utf8로 실행하는 편이 안전합니다. 예전 글을 쓸 때 이 옵션 없이 돌렸다가 한글이 전부 깨진 적이 있어 이번엔 처음부터 붙였습니다.
2,000개 파일로 실측 — 전체 복사 vs 바뀐 것만
규모를 키워 20개 하위 폴더에 1~100KB짜리 파일 2,000개(99.9MB)를 만들고, 빈 대상 폴더로 첫 동기화를 한 다음 원본에서 30개 내용 수정, 5개 추가, 3개 삭제를 하고 다시 동기화했습니다.
$ python -X utf8 bench.py
원본: 파일 2,000개, 99.9MB
1회차 비교: 새 2000 / 바뀜 0 / 대상에만 0 (0.062초)
1회차 복사: 1.774초
2회차(변경 없음) 비교: 새 0 / 바뀜 0 / 대상에만 0 (0.119초)
3회차(수정 후) 비교: 새 5 / 바뀜 30 / 대상에만 3 (0.108초)
대상에만 있는 파일: ['folder12\\file0692.txt', 'folder17\\file1777.txt', 'folder18\\file1138.txt']
3회차 복사·삭제: 0.034초
3회차 직후 재비교: 새 0 / 바뀜 0 / 대상에만 0 (0.155초)
| 단계 | 처리한 파일 | 걸린 시간 |
|---|---|---|
| 첫 동기화 (전체 복사) | 2,000개 복사 | 1.774초 (2차 실행 1.676초) |
| 변경 없음, 비교만 | 0개 | 0.119초 (2차 0.110초) |
| 35개 복사 + 3개 삭제 | 38개 | 비교 0.108초 + 처리 0.034초 |
바뀐 35개만 복사하는 데는 0.034초, 같은 스크립트를 한 번 더 돌린 2차 실행에서는 0.036초였습니다. 전체를 다시 복사하는 1.7초 내외와 비교하면 50배쯤 차이가 납니다. 파일이 늘어도 비교 비용은 os.stat() 2,000번 수준이라 0.1초 언저리에 머물렀습니다. 폴더가 커질수록, 그리고 대상이 USB 메모리나 네트워크 드라이브처럼 느린 곳일수록 이 차이는 더 벌어질 것입니다.
shutil.copy를 쓰면 매번 전부 다시 복사된다
스크립트를 쓰면서 가장 신경 쓴 부분은 복사 함수입니다. 일부러 shutil.copy와 shutil.copy2로 각각 500개 파일을 첫 동기화한 뒤, 아무것도 바꾸지 않고 다시 비교했습니다.
$ python -X utf8 pitfall.py
[copy] 원본 mtime 1793235699128732700 / 복사본 mtime 1793235699432360600 / 같음 False
[copy] 아무것도 안 바꾸고 다시 비교: 새 0 / 바뀜 500
[copy2] 원본 mtime 1793235699916285400 / 복사본 mtime 1793235699916285400 / 같음 True
[copy2] 아무것도 안 바꾸고 다시 비교: 새 0 / 바뀜 0
shutil.copy는 내용과 권한만 복사하고 수정시각은 복사하지 않습니다. 그래서 복사본의 수정시각이 “복사한 시각”이 되어, 다음 비교에서 500개 전부가 바뀐 것으로 잡혔습니다. 매일 돌리는 백업이라면 매일 전체 복사를 하는 셈이라 동기화 스크립트를 만든 의미가 없어집니다. shutil.copy2는 수정시각까지 나노초 단위로 똑같이 옮겨서 다시 비교하면 0개였습니다. 수정시각으로 비교하는 스크립트라면 반드시 copy2를 써야 합니다.
filecmp.dircmp로 하면 안 되나
파이썬에는 폴더 비교용 filecmp.dircmp가 이미 있습니다. 하위 폴더의 파일 하나를 고친 뒤 결과를 비교했습니다.
dircmp.diff_files (최상위만): []
subdirs까지 재귀로 모은 diff_files: ['folder3/file003.txt']
sync.py make_plan 바뀜: ['folder3\\file003.txt']
dircmp(...).diff_files는 빈 목록이었습니다. 최상위 폴더의 파일만 보기 때문이고, 하위 폴더 변경은 subdirs를 직접 재귀로 돌아야 나왔습니다. 결과 자체는 직접 만든 make_plan과 같았습니다. 비교 결과만 잠깐 확인할 때는 dircmp도 쓸 만하지만, 복사·삭제 목록을 만들어 실행까지 할 거라면 os.walk로 직접 훑는 쪽이 코드가 더 단순했습니다.
크기와 수정시각이 같으면 내용이 달라도 못 잡는다
위 dircmp 시험에서 예상하지 못한 결과가 하나 더 있었습니다. 최상위에 원본은 “top”, 대상은 “TOP”인 3바이트 파일을 연달아 만들었는데, dircmp도 제 스크립트도 다르다고 잡지 못했습니다. 크기가 같은 건 당연하니 수정시각을 의심해서, 같은 방식으로 두 파일을 연달아 쓰는 걸 200번 반복해 봤습니다.
$ python -X utf8 tick.py
200쌍 중 수정시각이 완전히 같은 쌍: 124
다른 쌍의 차이(ns) 예: [349600, 357300, 999000, 999300, 1002000]
200쌍 중 124쌍의 수정시각이 나노초 단위까지 완전히 같았고, 달라도 0.35~1ms 차이였습니다. 즉 아주 짧은 간격에 같은 크기로 내용만 바뀌면 크기+수정시각 비교로는 구분이 안 됩니다. 그래서 내용을 직접 읽어 SHA-256으로 비교하는 --hash 모드를 넣었습니다. 2,000개 폴더에서 파일 하나의 첫 바이트만 덮어쓰고 수정시각을 원래대로 되돌린 뒤 두 모드를 비교했습니다.
해시 모드 비교(변경 없음): 새 0 / 바뀜 0 / 대상에만 0 (2.388초)
내용만 바꾼 뒤 mtime 모드: 새 0 / 바뀜 0 / 대상에만 0
내용만 바꾼 뒤 hash 모드: 새 0 / 바뀜 1 / 대상에만 0 (2.454초)
hash 모드가 찾은 파일: ['folder01\\file0001.txt']
기본 모드는 놓쳤고 해시 모드만 그 파일을 찾았습니다. 대신 해시 모드는 양쪽 약 200MB를 모두 읽어야 해서 2.2~2.5초가 걸렸습니다. 기본 모드의 0.1초 언저리보다 약 20배 느립니다. 평소에는 기본 모드로 매일 돌리고, 가끔 한 번 --hash로 전체 검증을 하는 식으로 나눠 쓰는 걸 권합니다.
주의할 점
- 복사는 반드시 shutil.copy2로. shutil.copy는 수정시각을 안 옮겨서 다음 회차에 500개가 전부 “바뀜”으로 나왔습니다.
- –delete는 미리보기로 목록을 확인한 뒤에. 원본 폴더 경로를 잘못 넣으면 대상 파일 전체가 “대상에만 있음”이 됩니다. 기본값을 미리보기로 둔 이유입니다.
- 원본과 대상 순서를 바꾸지 마세요. 이 스크립트는 단방향입니다. 순서가 바뀌면 백업 쪽 옛 파일이 원본을 덮어씁니다.
- 크기·수정시각 비교는 완벽하지 않습니다. 실측에서 연달아 쓴 파일 200쌍 중 124쌍의 수정시각이 같았습니다. 중요한 자료는 가끔 –hash로 검증하세요.
- 빈 폴더는 옮기지 않습니다. 파일 기준으로만 비교하므로, 빈 폴더까지 맞춰야 한다면 os.walk의 dirnames도 따로 비교해야 합니다.
마무리
통째로 복사하는 백업을 “바뀐 것만” 복사로 바꾸면, 2,000개 기준 1.7초가 0.03초로 줄었습니다. 핵심은 크기+수정시각 비교와 copy2 두 가지입니다.
가장 작은 첫걸음은 이렇습니다.
- 위
sync.py를 저장하고, 백업하고 싶은 폴더와 백업 폴더 경로를 넣어--run없이 미리보기부터 돌려 봅니다. - 목록이 예상과 맞으면
--run으로 실제 동기화를 하고, 바로 한 번 더 미리보기를 돌려 0개가 나오는지 확인합니다. - 익숙해지면 작업 스케줄러 같은 도구로 매일 정해진 시각에 돌리고, 한 달에 한 번쯤
--hash로 전체 검증을 합니다.
다음 글에서는 이렇게 파일을 하나씩 처리하는 반복 작업을 multiprocessing으로 병렬로 돌리면 실제로 얼마나 빨라지는지 직접 재 보겠습니다.