월요일 아침, 로그 분석 스크립트를 돌리던 서버가 갑자기 멈췄다. 파일은 고작 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은 다른 이터러블의 값을 그대로 넘겨주는 문법이다. 데이터는 파일에서 한 줄씩 읽혀 필터를 지나 집계까지 흐르며, 어느 시점에도 전체가 메모리에 올라오지 않는다.

AD

알아 두면 덜 당황하는 함정 세 가지

첫째, 제너레이터는 한 번만 쓸 수 있다. 끝까지 소비하면 비어 버리고, 다시 돌려도 아무것도 나오지 않는다. 두 번 필요하면 함수를 다시 호출해 새로 만들어야 한다.

둘째, 길이를 바로 알 수 없다. 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로 필요한 만큼만 꺼내 쓰는 습관을 들이면, 같은 서버로도 훨씬 큰 데이터를 다룰 수 있다. 오늘 작성한 스크립트 중 [...]로 큰 데이터를 모으는 곳이 있다면 소괄호로 바꿔 보자. 작은 변화가 새벽의 장애 전화를 막아 줄지도 모른다.