파일을 복사할 때 흔히 쓰는 open() + read()/write() 루프는 데이터가 커널 버퍼 → 유저스페이스 버퍼 → 커널 버퍼로 두 번 오간다. os.copy_file_range()(Linux 4.5+, Python 3.8+)는 파일 디스크립터 두 개를 넘기면 커널이 그 안에서 직접 데이터를 옮겨 유저스페이스 왕복을 생략한다. 이 글에서는 정확성, 실제 시스템 콜 횟수, 그리고 체감 속도까지 있는 그대로 재본다.
기본 사용법과 정확성 확인
import os, hashlib
def sha256(path):
h = hashlib.sha256()
with open(path, "rb") as f:
while chunk := f.read(1024 * 1024):
h.update(chunk)
return h.hexdigest()
def copy_read_write(src, dst, bufsize=1024 * 1024):
with open(src, "rb") as fi, open(dst, "wb") as fo:
while chunk := fi.read(bufsize):
fo.write(chunk)
def copy_cfr(src, dst):
src_fd = os.open(src, os.O_RDONLY)
dst_fd = os.open(dst, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
remaining = os.fstat(src_fd).st_size
try:
while remaining > 0:
copied = os.copy_file_range(src_fd, dst_fd, remaining)
if copied == 0:
break
remaining -= copied
finally:
os.close(src_fd)
os.close(dst_fd)
src_hash = sha256("src_large.bin") # 500MB 랜덤 데이터
copy_read_write("src_large.bin", "dst_readwrite.bin")
copy_cfr("src_large.bin", "dst_cfr.bin")
print("read+write 해시 일치:", sha256("dst_readwrite.bin") == src_hash)
print("copy_file_range 해시 일치:", sha256("dst_cfr.bin") == src_hash)read+write 해시 일치: True
copy_file_range 해시 일치: True
copy_file_range()는 한 번 호출로 요청한 만큼을 다 못 옮길 수도 있어서(반환값이 요청보다 작을 수 있음), 남은 만큼 반복 호출하는 루프가 필요하다.
시스템 콜 횟수 비교
20MB 파일로 strace -c를 떠서 실제 시스템 콜 횟수를 비교했다.
strace -c -f python3 small_readwrite.py 2>&1 | grep -E "read|write|total"
strace -c -f python3 small_cfr.py 2>&1 | grep -E "read|write|copy_file_range|total"=== read+write ===
56.26 0.011288 564 20 write
17.85 0.003582 49 73 read
100.00 0.020063 32 613 78 total
=== copy_file_range ===
68.97 0.012098 12098 1 copy_file_range
3.18 0.000558 10 52 read
100.00 0.017542 31 562 76 total
read+write 방식은 데이터 전송에 write 20번 + read 73번, 총 93번의 시스템 콜을 쓴 반면 copy_file_range는 단 1번으로 20MB 전체를 옮겼다. (남은 read 호출들은 파이썬 인터프리터 자체의 시작 과정에서 나온 것으로, 복사 로직과는 무관하다.) 시스템 콜 하나당 유저-커널 컨텍스트 전환 비용이 드는 걸 감안하면, 파일 개수가 많거나 자주 복사하는 워크로드일수록 이 차이가 누적된다.
실제로 더 빠른가 — 벤치마크
500MB 파일을 5회씩 복사해 평균을 냈다.
read+write 평균: 0.202초 ( [0.379, 0.148, 0.167, 0.164, 0.151] )
copy_file_range 평균: 0.218초 ( [0.3, 0.203, 0.176, 0.233, 0.18] )
솔직히 예상과 달랐다 — copy_file_range가 오히려 근소하게 더 느렸다. 시스템 콜은 확실히 줄었는데 벽시계 시간은 개선되지 않은 것이다. 이 글의 테스트 환경(WSL2, 파일이 이미 페이지 캐시에 올라온 상태)에서는 애초에 두 방식 다 메모리 대역폭이 병목이라 유저-커널 카피 생략의 이득이 묻혔을 가능성이 크고, WSL2의 가상화된 스토리지 계층이 copy_file_range의 진짜 커널 내부 이동 경로를 온전히 살리지 못했을 수도 있다. 시스템 콜 횟수 감소는 이 환경에서도 그대로 재현되는 확실한 사실이지만, 그게 항상 체감 속도 개선으로 이어진다고 단정할 수는 없다 — 실제 배포 환경(베어메탈, 콜드 캐시, 대용량 반복 복사)에서 다시 재보는 걸 권한다.
부분 복사
offset_src를 지정하면 파일 중간 구간만 옮길 수 있다.
src_fd = os.open("src_small.bin", os.O_RDONLY)
dst_fd = os.open("partial.bin", os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o644)
# 소스의 1MB~3MB 구간(2MB)만 복사
copied = os.copy_file_range(src_fd, dst_fd, 2 * 1024 * 1024, offset_src=1024 * 1024)
print("복사된 바이트:", copied)복사된 바이트: 2097152
결과 파일 크기: 2097152
내용 일치: True
주의사항
- 모든 파일시스템이 커널 내부 zero-copy 경로를 지원하는 건 아니다. 지원하지 않으면 커널이 조용히 일반 read+write 방식으로 폴백한다(문서 기준 사실이며, 이 글에서 비지원 파일시스템으로 직접 재현하지는 않았다) — 폴백되면 시스템 콜 이득도 사라진다.
- 서로 다른 파일시스템 간 복사도 시도는 되지만 상황에 따라
EXDEV로 실패할 수 있다. 프로덕션 코드라면EXDEV/EOPNOTSUPP를 잡아 일반 복사로 폴백하는 경로를 같이 둬야 한다. - 이 글의 벤치마크는 페이지 캐시가 이미 데워진 상태 기준이다. 콜드 캐시(디스크에서 처음 읽는 상황)나 네트워크 스토리지에서는 결과가 달라질 수 있다.
마무리
os.copy_file_range()가 시스템 콜을 93번에서 1번으로 줄인다는 건 이 환경에서도 명확하게 재현됐지만, 그게 곧바로 체감 속도 개선으로 이어지지는 않았다 — 적어도 페이지 캐시가 데워진 WSL2 환경에서는 근소하게 더 느리기까지 했다. 정확성은 두 방식 모두 문제없었으니, 대량의 파일을 반복 복사하는 배치 작업처럼 시스템 콜 오버헤드가 누적되는 워크로드에서 실제 배포 환경 기준으로 다시 재보고 도입 여부를 판단하는 게 맞다.