월요일 아침, 로그 분석 스크립트를 돌리던 서버가 갑자기 멈췄다. 파일은 고작 3GB였는데 메모리는 8GB나 되는 서버에서 MemoryError가 터진 것이다. 원인은 단 한 줄, lines = f.readlines()였다. 이런 일은 파이썬을 쓰다 보면 한 번쯤 꼭 겪는다. 해법은 서버 증설이 아니라 제너레이터라는 작은 개념 하나다.
리스트는 전부 담고, 제너레이터는 하나씩 꺼낸다
리스트는 모든 값을 메모리에 한꺼번에 올려 둔다. 반면 제너레이터는 "다음 값을 만드는 방법"만 기억하고 있다가, 요청이 올 때마다 하나씩 만들어 건넨다. 식당에 비유하면 리스트는 뷔페 접시에 음식을 전부 쌓아 두는 것이고, 제너레이터는 주문이 들어올 때마다 한 접시씩 조리해 내는 주방이다.
제너레이터는 값을 "저장"하지 않고 "만드는 법"을 저장한다.
import sys
nums_list = [i * i for i in range(1_000_000)]
nums_gen = (i * i for i in range(1_000_000))
print(sys.getsizeof(nums_list)) # 약 8MB 이상
print(sys.getsizeof(nums_gen)) # 약 200바이트 안팎같은 백만 개의 제곱수인데, 하나는 수 메가바이트이고 다른 하나는 수백 바이트도 되지 않는다. 대괄호 []를 소괄호 ()로 바꾼 것만으로 생긴 차이다.
yield 한 단어로 함수를 제너레이터로 바꾸기
함수 안에서 return 대신 yield를 쓰면 그 함수는 제너레이터가 된다. yield는 값을 내보내고 그 자리에서 잠시 멈췄다가 다음 호출 때 이어서 실행한다.
def read_errors(path):
with open(path, encoding="utf-8") as f:
for line in f: # 파일 객체 자체가 이미 한 줄씩 읽는 이터레이터
if "ERROR" in line:
yield line.rstrip()
for row in read_errors("app.log"):
print(row)여기서 핵심은 for line in f다. 파일 객체는 원래 한 줄씩 읽어 오기 때문에, readlines()만 피해도 메모리 문제의 절반은 해결된다. 앞서 멈춰 버린 스크립트도 이 구조로 바꾸자 메모리 사용량이 수십 MB 수준에서 일정하게 유지됐다.
파이프라인처럼 이어 붙이기
제너레이터의 진짜 힘은 여러 단계를 연결할 때 나온다. 각 단계가 데이터를 한 줄씩만 흘려보내므로, 중간 결과를 담을 거대한 리스트가 생기지 않는다.
def read_lines(path):
with open(path, encoding="utf-8") as f:
yield from f
def only_errors(lines):
for line in lines:
if "ERROR" in line:
yield line
def count_by_code(lines):
counts = {}
for line in lines:
code = line.split()[2]
counts[code] = counts.get(code, 0) + 1
return counts
result = count_by_code(only_errors(read_lines("app.log")))
print(result)yield from은 다른 이터러블의 값을 그대로 넘겨주는 문법이다. 데이터는 파일에서 한 줄씩 읽혀 필터를 지나 집계까지 흐르며, 어느 시점에도 전체가 메모리에 올라오지 않는다.
알아 두면 덜 당황하는 함정 세 가지
첫째, 제너레이터는 한 번만 쓸 수 있다. 끝까지 소비하면 비어 버리고, 다시 돌려도 아무것도 나오지 않는다. 두 번 필요하면 함수를 다시 호출해 새로 만들어야 한다.
둘째, 길이를 바로 알 수 없다. len(gen)은 오류가 난다. 개수가 필요하면 sum(1 for _ in gen)처럼 한 번 훑어야 한다.
셋째, 인덱싱이 안 된다. gen[0]은 불가능하고, 앞부분만 필요하면 itertools.islice(gen, 10)으로 처음 열 개만 꺼낼 수 있다.
from itertools import islice
first_ten = list(islice(read_errors("app.log"), 10))언제 리스트를, 언제 제너레이터를 쓸까
| 상황 | 추천 |
|---|---|
| 데이터가 작고 여러 번 반복해서 읽는다 | 리스트 |
| 인덱스 접근·정렬이 필요하다 | 리스트 |
| 파일·DB 결과처럼 크기가 크거나 알 수 없다 | 제너레이터 |
| 한 번 훑고 끝나는 변환·집계 | 제너레이터 |
모든 곳에 제너레이터를 쓸 필요는 없다. 데이터가 수천 건 수준이라면 리스트가 더 읽기 쉽고 빠르다. 다만 "이 데이터가 커질 수 있을까?"라는 질문이 떠오르는 순간이 바로 제너레이터를 떠올릴 때다.
정리하며
핵심은 간단하다. readlines()나 거대한 리스트 컴프리헨션 대신 for line in f와 yield로 필요한 만큼만 꺼내 쓰는 습관을 들이면, 같은 서버로도 훨씬 큰 데이터를 다룰 수 있다. 오늘 작성한 스크립트 중 [...]로 큰 데이터를 모으는 곳이 있다면 소괄호로 바꿔 보자. 작은 변화가 새벽의 장애 전화를 막아 줄지도 모른다.


