[Python] 대용량 파일을 메모리 터지지 않고 읽는 패턴
거의 모든 개발자가 한 번쯤 겪는 상황이 있어. 몇 기가짜리 로그 파일을 열었더니 메모리가 터져버리는 거지. open().read() 한 번 쓰면 끝이라고 생각했는데, 실제 운영 환경에서 대용량 파일을 다루다 보면 금세 한계에 부딪혀. 특히 데이터 파이프라인이나 로그 분석 작업을 할 때 이 문제는 피할 수가 없어.
파이썬은 기본적으로 파일 전체를 메모리에 올리는 방식으로 읽는 경우가 많아. 작은 파일이면 상관없지만, 수백 메가를 넘기 시작하면 서버가 버티질 못해. 특히 클라우드 환경에서 메모리 제한이 있는 인스턴스를 쓰고 있다면 더더욱. 이럴 때 필요한 게 바로 청크 스트리밍이야. 파일을 한 번에 다 읽는 게 아니라, 필요한 만큼만 잘라서 처리하는 방식이지.
이 글에서는 제너레이터를 활용해서 대용량 파일을 안전하게 읽는 패턴을 정리해보려고 해. 단순히 코드 몇 줄 보여주는 게 아니라, 왜 이 방식이 메모리를 절약하는지, 그리고 실전에서 어떻게 적용할 수 있는지까지 같이 다룰 거야. 끝까지 읽고 나면 몇 기가짜리 파일도 마음 편하게 처리할 수 있을 거야.
1. 왜 read()는 위험한가
가장 기본적인 파일 읽기 방식부터 살펴보자. 파이썬에서 파일을 읽을 때 가장 흔히 쓰는 방법이 f.read()야. 이 메서드는 파일 전체의 내용을 한 번에 문자열로 반환해. 10KB짜리 설정 파일이면 아무 문제없지. 하지만 2GB짜리 CSV 파일이라면 얘기가 달라져.
운영체제는 파일을 읽을 때 해당 내용을 메모리(RAM)에 올려. read()를 호출하면 파일 크기만큼의 메모리가 즉시 할당되는 거야. 2GB 파일이면 최소 2GB의 RAM이 필요하고, 파이썬 문자열 오버헤드까지 고려하면 실제로는 그보다 더 먹어. 서버에 4GB RAM이 장착되어 있고 다른 서비스도 돌고 있다면, 이거 하나로 전체 시스템이 불안정해질 수 있어.
# 이렇게 쓰면 위험해
with open("huge_log.txt", "r") as f:
content = f.read() # 파일 전체를 메모리에 로드
for line in content.split("\n"):
process(line)
또 한 가지 간과하기 쉬운 문제는 가비지 컬렉션 타이밍이야. content 변수가 스코프를 벗어나기 전까지 메모리가 해제되지 않아. 함수 안에서 읽었는데 리턴값으로 content를 넘기면, 참조가 사라질 때까지 메모리가 계속 잡혀있어. 대용량 파일 처리에서 이건 치명적이야.
결론은 명확해. 대용량 파일을 다룰 때는 read() 전체 호출을 피해야 해. 대신 "필요한 만큼만 읽기" 원칙을 적용해야지. 이게 바로 스트리밍의 핵심이고, 파이썬에서는 제너레이터가 이 역할을 완벽하게 수행해줘.
2. 제너레이터로 한 줄씩 읽기
파이썬의 파일 객체 자체가 이미 이터레이터야. 그래서 for line in f: 구문으로 파일을 한 줄씩 순회할 수 있는 거지. 이 방식은 내부적으로 버퍼를 사용해서 한 번에 적절한 크기의 데이터만 메모리에 올려. 즉, 파일 크기에 관계없이 거의 일정한 메모리만 사용해.
하지만 단순히 for 루프로 순회하는 것만으로는 부족할 때가 많아. 재사용 가능한 구조가 필요하고, 파이프라인 형태로 다른 처리 단계와 연결하고 싶을 때가 있지. 이럴 때 제너레이터 함수를 만들면 깔끔한 추상화가 가능해.
def read_lines(file_path):
"""파일을 한 줄씩 읽는 제너레이터"""
with open(file_path, "r", encoding="utf-8") as f:
for line in f:
yield line.rstrip("\n")
# 사용법
for line in read_lines("huge_log.txt"):
if "ERROR" in line:
print(line)
이 패턴의 핵심은 yield 키워드야. 일반 함수는 return으로 값을 한 번에 반환하고 끝나지만, 제너레이터는 호출될 때마다 하나의 값만 내놓고 상태를 유지해. 다음 요청이 올 때까지 대기하는 거지. 덕분에 10GB 파일이어도 메모리에는 항상 한 줄분의 데이터만 올라가 있어.
여기에 몇 가지 개선점을 추가할 수 있어. 예를 들어 빈 줄을 건너뛰거나, 인코딩 에러를 처리하거나, 진행 상황을 로깅하는 거지. 제너레이터 안에서 이 처리를 해두면 호출 쪽은 깔끔하게 유지할 수 있어.
def read_lines_safe(file_path, skip_empty=True):
"""에러 처리가 포함된 안전한 라인 리더"""
with open(file_path, "r", encoding="utf-8") as f:
for line_num, line in enumerate(f, 1):
stripped = line.rstrip("\n")
if skip_empty and not stripped:
continue
yield line_num, stripped
# 사용법: 줄 번호와 함께 반환
for line_num, content in read_lines_safe("server.log"):
if "CRITICAL" in content:
print(f"[{line_num}] {content}")
이런 식으로 제너레이터를 감싸면 재사용성도 높아지고, 호출하는 코드도 훨씬 읽기 쉬워져. 특히 로그 분석이나 데이터 전처리 파이프라인에서 이 패턴을 자주 만나게 될 거야. 한 줄씩 읽는 것만으로도 대부분의 대용량 텍스트 파일 문제는 해결 가능해.
3. 청크 단위로 읽기: 바이너리와 큰 블록 처리
텍스트 파일은 한 줄 단위로 읽으면 되지만, 바이너리 파일이나 고정 크기 블록으로 나눠야 할 때는 다른 접근이 필요해. 예를 들어 이미지 파일을 분석하거나, 대용량 바이너리 데이터를 파싱하거나, 네트워크로 스트리밍할 때는 청크(chunk) 단위로 읽어야 해.
파이썬의 f.read(size) 메서드에 크기를 지정하면 해당 바이트만큼만 읽어와. 이걸 반복 호출하면 원하는 크기의 청크로 파일을 나눠서 처리할 수 있지. 이것도 제너레이터로 감싸면 훨씬 깔끔해져.
def read_in_chunks(file_path, chunk_size=8192):
"""지정된 크기만큼 청크로 읽는 제너레이터"""
with open(file_path, "rb") as f:
while True:
chunk = f.read(chunk_size)
if not chunk:
break
yield chunk
# 사용법: 8KB씩 읽어서 처리
total_bytes = 0
for chunk in read_in_chunks("large_data.bin"):
total_bytes += len(chunk)
# 청크 단위로 처리 (예: 해시 계산, 압축 등)
print(f"총 {total_bytes:,} 바이트 처리 완료")
chunk_size는 상황에 따라 조절하면 돼. 너무 작으면 I/O 호출 횟수가 늘어나서 느려지고, 너무 크면 메모리 사용량이 올라가. 보통 4KB~64KB 사이에서 시작하는 게 적절해. 운영체제의 페이지 크기(보통 4KB)에 맞추면 I/O 효율이 좋아진다는 점도 알아두면 좋을 거야.
실전에서 자주 쓰이는 패턴 중 하나가 파일 해시 계산이야. 대용량 파일의 무결성을 확인할 때 전체를 메모리에 올리지 않고 청크 단위로 해시를 계산해야 해.
import hashlib
def compute_file_hash(file_path, algorithm="sha256", chunk_size=65536):
"""청크 단위로 파일 해시를 계산"""
h = hashlib.new(algorithm)
with open(file_path, "rb") as f:
while chunk := f.read(chunk_size):
h.update(chunk)
return h.hexdigest()
# 10GB 파일도 메모리 걱정 없이 해시 계산 가능
file_hash = compute_file_hash("huge_database_dump.sql")
print(f"SHA-256: {file_hash}")
이 방식의 장점은 파일 크기와 무관하게 항상 chunk_size만큼의 메모리만 사용한다는 거야. 10GB 파일이든 100GB 파일이든 동일한 메모리 패턴을 보여. 서버 리소스를 예측 가능하게 관리할 수 있어서 운영 환경에서 특히 유용해. walrus 연산자(:=)를 쓰면 코드도 훨씬 간결해진다는 것도 참고해.
4. 제너레이터 체이닝: 파이프라인 구성하기
제너레이터의 진짜 강점은 체이닝(chain)이야. 여러 제너레이터를 연결해서 데이터 파이프라인을 구성할 수 있어. 각 단계가 자신의 역할만 수행하고, 데이터가 흐르는 대로 처리되는 구조지. Unix의 파이프(|) 개념과 비슷해.
예를 들어 로그 파일에서 특정 패턴을 가진 줄만 추출하고, 파싱하고, 집계하는 과정을 제너레이터 체인으로 만들 수 있어. 각 단계가 독립적이어서 테스트하기도 쉽고, 재조합하기도 편해.
def read_lines(file_path):
"""1단계: 파일 읽기"""
with open(file_path, "r", encoding="utf-8") as f:
for line in f:
yield line.strip()
def filter_errors(lines):
"""2단계: 에러 로그만 필터링"""
for line in lines:
if "ERROR" in line or "CRITICAL" in line:
yield line
def parse_timestamp(lines):
"""3단계: 타임스탬프 추출"""
import re
pattern = r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}"
for line in lines:
match = re.search(pattern, line)
if match:
yield {"timestamp": match.group(), "message": line}
# 파이프라인 구성
lines = read_lines("app.log")
errors = filter_errors(lines)
parsed = parse_timestamp(errors)
for entry in parsed:
print(f"[{entry['timestamp']}] {entry['message']}")
이 구조의 핵심은 지연 평가(lazy evaluation)야. 체인의 끝에서 값을 요청할 때만 각 단계가 실제로 실행돼. read_lines가 전체 파일을 한꺼번에 메모리에 올리는 게 아니라, parse_timestamp가 한 개씩 요청할 때마다 한 줄씩 읽고 필터링하고 파싱하는 거지. 메모리 사용량은 항상 최소 수준으로 유지돼.
실전에서는 itertools 모듈을 함께 활용하면 더 강력해져. itertools.islice로 처음 N개만 가져오거나, itertools.chain으로 여러 파일을 이어서 처리하거나, itertools.groupby로 그룹화할 수 있지. 특히 여러 로그 파일을 날짜별로 순회하면서 한 번에 분석해야 할 때 chain.from_iterable이 아주 유용해.
from itertools import chain, islice
def read_multiple_files(file_paths):
"""여러 파일을 하나의 스트림으로 연결"""
for path in file_paths:
with open(path, "r", encoding="utf-8") as f:
for line in f:
yield line.strip()
# 여러 날짜의 로그를 이어서 읽고, 처음 100개 에러만 추출
log_files = ["app_2024-01-01.log", "app_2024-01-02.log", "app_2024-01-03.log"]
all_lines = read_multiple_files(log_files)
errors = filter_errors(all_lines)
first_100_errors = islice(errors, 100)
for err in first_100_errors:
print(err)
이런 패턴으로 구성하면 수백 개의 로그 파일도 메모리 걱정 없이 한 번에 처리할 수 있어. 각 제너레이터는 독립적인 단위이기 때문에 디버깅도 쉽고, 중간 단계를 추가하거나 빼기도 편해. 일단 이 구조에 익숙해지면 대용량 데이터 처리의 거의 모든 문제를 이 패턴으로 풀 수 있을 거야.
마무리
대용량 파일 처리에서 메모리 문제는 결국 "얼마나 데이터를 한꺼번에 들고 있느냐"의 싸움이야. read() 전체 호출은 파일 크기만큼의 메모리를 확보하지만, 제너레이터와 청크 스트리밍은 거의 일정한 메모리만으로 어떤 크기의 파일이든 처리할 수 있어.
핵심 원칙 세 가지만 기억해. 첫째, 파일을 한 번에 읽지 말고 필요한 만큼만 읽어. 둘째, 제너레이터로 지연 평가 구조를 만들어. 셋째, 여러 제너레이터를 체이닝해서 파이프라인을 구성해. 이 세 가지면 대부분의 대용량 파일 처리 시나리오를 커버할 수 있어.
특히 데이터 엔지니어링이나 서버 로그 분석 작업을 하고 있다면, 이 패턴은 거의 매일 쓰게 될 거야. 처음엔 익숙하지 않을 수 있지만, 한 번 제대로 이해하고 나면 자연스럽게 손에 익을 거야. 다음번에 대용량 파일을 만나면 주저하지 말고 제너레이터부터 꺼내 쓰자.
긴 글 읽어줘서 고마워. 궽한 점 있으면 댓글 남겨줘!