파일 크기가 물리 메모리를 넘어서면 open().read()로 통째로 읽는 순간 프로세스 메모리가 그만큼 늘어나 OOM killer에 걸릴 수 있다. Python 표준 라이브러리 mmap은 파일을 프로세스의 가상 주소 공간에 매핑해서, 실제로 접근한 부분만 커널이 페이지 캐시에서 채워 넣는다. 이 글에서는 mmap으로 대용량 파일을 랜덤 접근하는 예제, 실제 상주 페이지 수를 mincore(2)로 확인하는 방법, 프로세스 간 메모리 공유, 그리고 일반 파일 읽기와의 실측 성능·메모리 비교를 정리한다.
mmap 기본 개념
mmap.mmap(fileno, length, access=...)로 매핑을 만든다. length=0이면 파일 전체 크기로 매핑한다.
| access 값 | 실제 매핑 방식 | 특징 |
|---|---|---|
mmap.ACCESS_READ | PROT_READ + MAP_PRIVATE | 읽기 전용, 파일 수정 불가 |
mmap.ACCESS_WRITE | PROT_READ|WRITE + MAP_SHARED | 매핑에 쓰면 파일과 다른 프로세스에 즉시 반영 |
mmap.ACCESS_COPY | PROT_READ|WRITE + MAP_PRIVATE(COW) | 매핑에 써도 파일은 그대로, 프로세스별 사본 |
파일 전체를 안 올리고 랜덤 접근하기
8,000,000개의 64바이트 레코드로 구성된 488MB 바이너리 파일을 ACCESS_READ로 매핑하고 임의의 레코드 5개를 조회했다.
import mmap
import random
import struct
import resource
RECORD_SIZE = 64
NUM_RECORDS = 8_000_000 # 64B * 8,000,000 = 488MB
def rss_mb():
return resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
print(f"시작 시 RSS: {rss_mb():.1f} MB")
with open("bigfile.dat", "rb") as f:
mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
random.seed(42)
for _ in range(5):
idx = random.randrange(NUM_RECORDS)
offset = idx * RECORD_SIZE
value = struct.unpack("<Q", mm[offset:offset + 8])[0]
print(f"record #{idx:>9,} -> offset {offset:>12,} -> 값 {value:,}")
mm.close()
print(f"종료 시 RSS: {rss_mb():.1f} MB (파일 크기: 488 MB)")시작 시 RSS: 10.5 MB
record #5,363,900 -> offset 343,289,600 -> 값 5,363,900
record # 933,912 -> offset 59,770,368 -> 값 933,912
record # 209,805 -> offset 13,427,520 -> 값 209,805
record #6,220,576 -> offset 398,116,864 -> 값 6,220,576
record #2,307,113 -> offset 147,655,232 -> 값 2,307,113
종료 시 RSS: 10.9 MB (파일 크기: 488 MB)
488MB를 매핑했지만 RSS는 10.5MB → 10.9MB로 거의 그대로다. mincore(2)로 실제 상주 페이지를 직접 세봤다.
import ctypes
import ctypes.util
import mmap
import os
import random
import struct
RECORD_SIZE = 64
NUM_RECORDS = 8_000_000
PAGE_SIZE = mmap.PAGESIZE
libc = ctypes.CDLL(ctypes.util.find_library("c"))
def resident_pages(mm):
n_pages = (len(mm) + PAGE_SIZE - 1) // PAGE_SIZE
vec = (ctypes.c_uint8 * n_pages)()
addr = ctypes.addressof(ctypes.c_char.from_buffer(mm))
libc.mincore(ctypes.c_void_p(addr), ctypes.c_size_t(len(mm)), vec)
return sum(1 for b in vec if b & 1), n_pages
# 페이지 캐시를 비운 뒤(cold) 측정 시작
fd = os.open("bigfile.dat", os.O_RDONLY)
os.posix_fadvise(fd, 0, 0, os.POSIX_FADV_DONTNEED)
os.close(fd)
with open("bigfile.dat", "rb") as f:
mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_COPY) # ctypes 주소 접근용
resident, total = resident_pages(mm)
print(f"매핑 직후: {resident:,}/{total:,} 페이지 상주 "
f"({resident * PAGE_SIZE / 1024 / 1024:.2f} MB / 488 MB)")
random.seed(99)
for _ in range(5):
idx = random.randrange(NUM_RECORDS)
struct.unpack("<Q", mm[idx * RECORD_SIZE: idx * RECORD_SIZE + 8])
resident, total = resident_pages(mm)
print(f"레코드 5개(320B) 접근 후: {resident:,}/{total:,} 페이지 상주 "
f"({resident * PAGE_SIZE / 1024 / 1024:.2f} MB / 488 MB)")
mm.close()매핑 직후: 0/125,000 페이지 상주 (0.00 MB / 488 MB)
레코드 5개(320B) 접근 후: 10,240/125,000 페이지 상주 (40.00 MB / 488 MB)
320바이트만 읽었는데 상주 페이지는 40MB다. 커널의 readahead 때문이지만 488MB 전체에 비하면 8% 수준이다. 접근 패턴이 파일 일부에만 머무는 한 mmap은 실제로 필요한 만큼만 메모리를 쓴다.
MAP_SHARED로 프로세스 간 메모리 공유하기
ACCESS_WRITE로 매핑하면 MAP_SHARED가 적용돼 여러 프로세스가 같은 물리 페이지를 공유한다. writer는 8바이트 카운터를 0.4초마다 증가시키고, reader는 0.2초마다 폴링한다.
import mmap
import struct
import time
with open("shared.dat", "r+b") as f:
mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_WRITE)
for i in range(1, 6):
time.sleep(0.4)
mm[0:8] = struct.pack("<Q", i)
mm.flush()
print(f"[writer] 카운터 -> {i}")
mm.close()import mmap
import struct
import time
with open("shared.dat", "r+b") as f:
mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
last = None
for _ in range(14):
value = struct.unpack("<Q", mm[0:8])[0]
if value != last:
print(f"[reader] 카운터 변경 감지 -> {value}")
last = value
time.sleep(0.2)
mm.close()두 스크립트를 동시에 실행하면 파이프나 소켓 없이 파일 하나로 값이 전달된다.
$ python3 shared_writer.py & python3 shared_reader.py & wait
[reader] 카운터 변경 감지 -> 0
[reader] 카운터 변경 감지 -> 1
[writer] 카운터 -> 1
[writer] 카운터 -> 2
[reader] 카운터 변경 감지 -> 2
[writer] 카운터 -> 3
[reader] 카운터 변경 감지 -> 3
[writer] 카운터 -> 4
[reader] 카운터 변경 감지 -> 4
[writer] 카운터 -> 5
[reader] 카운터 변경 감지 -> 5
open().read() vs mmap 실측 비교
같은 488MB 파일에서 무작위 인덱스 20,000개를 조회하는 두 방식을, 콜드/웜 캐시 상태 각각 비교했다.
import struct
import random
import time
import mmap
import resource
RECORD_SIZE = 64
NUM_RECORDS = 8_000_000
N_LOOKUPS = 20_000
random.seed(7)
indices = [random.randrange(NUM_RECORDS) for _ in range(N_LOOKUPS)]
t0 = time.perf_counter()
f = open("bigfile.dat", "rb")
mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
t1 = time.perf_counter()
checksum = 0
for idx in indices:
offset = idx * RECORD_SIZE
checksum ^= struct.unpack("<Q", mm[offset:offset + 8])[0]
t2 = time.perf_counter()
peak_rss_mb = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
mm.close()
f.close()
print(f"매핑 생성: {t1 - t0:.4f}s")
print(f"랜덤 조회 {N_LOOKUPS:,}회: {t2 - t1:.4f}s")
print(f"peak RSS: {peak_rss_mb:.1f} MB")open().read() 버전은 매핑 대신 f.read()로 전체를 읽어들인 뒤 같은 인덱스로 슬라이싱한다. 4가지 조합의 결과다.
| 시나리오 | 로드/매핑 | 랜덤 조회 20,000회 | 합계 | Peak RSS |
|---|---|---|---|---|
| read() · cold | 0.4607s | 0.0092s | 0.4699s | 499.6MB |
| mmap · cold | 0.0001s | 0.2660s | 0.2661s | 462.6MB |
| read() · warm | 0.2830s | 0.0083s | 0.2913s | 499.7MB |
| mmap · warm | 0.0000s | 0.0325s | 0.0326s | 462.7MB |
웜 상태에서는 mmap이 압도적으로 빠르고(0.0326s vs 0.2913s), 콜드 상태에서는 반대로 read()가 더 빠르다 — 488MB를 한 번에 순차로 읽는 게 20,000번의 개별 page fault보다 효율적이어서다. RSS는 두 방식 다 파일 크기에 근접했는데, 무작위 조회 2만 번이면 readahead까지 겹쳐 결국 대부분의 페이지를 건드리기 때문이다.
주의사항
- mmap의 메모리 절감은 “접근한 부분만 상주한다”는 전제가 지켜질 때 유효하다. 대량의 랜덤 접근을 하면 readahead가 결국 파일 대부분을 RSS에 올린다.
ACCESS_WRITE(MAP_SHARED)로 쓰기 매핑을 열면 다른 프로세스도 즉시 변경을 본다. 여러 필드를 함께 갱신해야 한다면fcntl.flock같은 별도 락이 필요하다.- 매핑이 살아있는 동안 다른 프로세스가 파일을 매핑 크기보다 작게 줄이면, 잘려나간 영역 접근 시
SIGBUS가 발생한다. - 32비트 프로세스는 가상 주소 공간이 제한적이라 대용량 파일을 한 번에 매핑하기 어렵다. 64비트 환경이면 문제되지 않는다.
마무리
mmap은 “필요한 부분만 그때그때 페이지 캐시에서 채운다”는 lazy loading과 “여러 프로세스가 같은 페이지를 공유한다”는 두 특성을 활용하는 도구다. 파일 전체를 훑어야 한다면 open().read()가 더 단순하고 콜드 상태에선 오히려 빠르지만, 일부만 산발적으로 조회하거나 프로세스 간 실시간 공유가 필요하면 mmap이 유리하다. 어느 쪽이 나은지는 접근 패턴과 캐시 상태에 따라 갈리므로 실제 적용 전 벤치마크가 안전하다.