Python의 Generator와 yield: 대용량 데이터를 RAM 낭비 없이 처리하기
1. 개요
수 GB의 로그 파일이나 데이터베이스에서 스트리밍되는 수백만 개의 레코드를 처리할 때, “전부 list에 읽어서 처리하는” 방식의 코드는 프로그램이 RAM을 낭비하게 만들고 충돌할 수 있습니다. 이 글은 generator와 yield가 어떻게 동작하는지, 일반적인 list와의 비교, 그리고 큰 파일 읽기, 데이터 처리 파이프라인 같은 실제 문제 해결에 적용하는 방법을 보여줍니다. 이 글을 읽고 나면 generator를 올바르게 작성하고 언제 generator를 사용하지 말아야 하는지 알게 될 것입니다.
2. 전통적인 접근의 문제점
5GB 크기의 로그 파일을 읽어서 “ERROR”가 포함된 줄의 개수를 세어야 한다고 가정해봅시다:
def count_errors_bad(filepath: str) -> int:
with open(filepath, "r") as f:
lines = f.readlines() # 파일 전체를 RAM에 로드
return sum(1 for line in lines if "ERROR" in line)
f.readlines()는 파일 전체의 내용을 메모리에 문자열의 list로 읽어들입니다. 5GB 크기의 파일의 경우 Python은 처리를 시작하기 전에 ~5GB+ 이상의 RAM을 할당합니다 (객체 오버헤드 포함). 컴퓨터에 4GB의 RAM만 있다면 MemoryError가 발생하거나 시스템이 swap으로 인해 멈출 것입니다.
핵심 문제는 한 줄씩만 처리하면 되는데 코드가 컴퓨터에 모든 줄을 동시에 메모리에 유지하도록 강제하는 것입니다.
3. Generator란 무엇인가?
Generator는 특별한 종류의 iterable입니다: 필요에 따라 값을 생성합니다 (lazy evaluation). 미리 모든 값을 계산해서 저장하는 대신 한 번에 하나씩 값을 반환하며, 다음 값이 필요할 때만 계산합니다.
def count_errors_good(filepath: str) -> int:
with open(filepath, "r") as f:
return sum(1 for line in f if "ERROR" in line)
여기서 f (파일 객체)는 그 자체로 이미 iterator입니다 — 루프가 요청할 때 한 줄씩 읽으며 전체 파일을 RAM에 로드하지 않습니다. 메모리 사용은 한 번에 한 줄만이며, 파일이 5GB든 50GB든 상관없습니다.
4. yield 키워드: generator 함수 만들기
함수 내에서 yield를 사용해서 generator를 직접 만들 수 있습니다. 함수에 yield가 있으면 Python은 자동으로 그것을 generator 함수로 간주합니다 — 호출해도 코드가 즉시 실행되지 않고 generator 객체를 반환합니다.
def read_large_file(filepath: str):
with open(filepath, "r") as f:
for line in f:
yield line.strip()
yield line.strip()은 함수를 일시 중지하고line.strip()값을 외부로 반환합니다.- 다음
next()호출 시 함수는 일시 중지된 지점에서 계속 실행되며 처음부터 다시 실행하지 않습니다. - 함수의 상태 (지역 변수, 파일 포인터 위치)는 각
yield사이에서 보존됩니다.
실제로 사용해봅시다:
gen = read_large_file("access.log")
print(type(gen))
print(next(gen))
print(next(gen))
<class 'generator'>
127.0.0.1 - GET /index.html 200
127.0.0.1 - GET /style.css 200
주의: gen = read_large_file(...) 줄은 파일을 열지 않고, 아무것도 읽지 않습니다. 내부의 코드는 처음으로 next(gen)을 호출할 때만 실행됩니다.
5. 메모리 비교: generator vs list
차이를 명확히 보기 위해 sys 모듈을 사용해서 객체 크기를 측정해봅시다:
import sys
def squares_list(n: int) -> list[int]:
return [i * i for i in range(n)]
def squares_gen(n: int):
for i in range(n):
yield i * i
big_list = squares_list(1_000_000)
big_gen = squares_gen(1_000_000)
print(f"List size: {sys.getsizeof(big_list):,} bytes")
print(f"Generator size: {sys.getsizeof(big_gen):,} bytes")
List size: 8,448,728 bytes
Generator size: 208 bytes
100만 개 요소를 가진 list는 약 8.5MB를 차지하지만, generator는 208바이트의 작은 객체일 뿐입니다 — 어떤 값도 저장하지 않고 필요할 때 다음 값을 계산하기 위한 상태만 보관합니다.
주의: Generator는 한 번만 순회할 수 있습니다. 순회를 끝낸 후 다시
next()를 호출하면StopIteration이 발생합니다. 여러 번 순회해야 하면 함수를 다시 호출해서 새로운 generator를 만들어야 합니다.
6. Generator expression: 더 간단한 문법
list comprehension처럼 하지만 대괄호 대신 괄호를 사용하면 generator expression을 만들 수 있습니다 — 별도의 함수를 정의할 필요가 없습니다:
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
even_squares_gen = (n * n for n in numbers if n % 2 == 0)
print(sum(even_squares_gen))
220
[] 대신 ()를 사용하면 Python이 즉시 list를 만드는 대신 generator를 생성합니다. 값을 한 번만 사용할 때 유용합니다. 예를 들어 sum(), max(), any()에 직접 전달할 때 말입니다.
7. Generator로 데이터 처리 파이프라인 구축하기
Generator의 강력한 점은 여러 처리 단계를 연결할 수 있다는 것입니다. 중간 list를 만들 필요가 없습니다. 주문 CSV 파일을 읽어서 → status = "PAID"인 주문 필터링 → amount 합계 계산해야 한다고 가정해봅시다.
import csv
def read_orders(filepath: str):
with open(filepath, newline="") as f:
reader = csv.DictReader(f)
for row in reader:
yield row
def filter_paid(orders):
for order in orders:
if order["status"] == "PAID":
yield order
def extract_amount(orders):
for order in orders:
yield float(order["amount"])
def total_paid_amount(filepath: str) -> float:
orders = read_orders(filepath)
paid_orders = filter_paid(orders)
amounts = extract_amount(paid_orders)
return sum(amounts)
각 함수 read_orders, filter_paid, extract_amount는 모두 generator입니다 — 전체 데이터를 즉시 처리하지 않고 파이프라인으로 연결됩니다. sum(amounts)가 next()를 호출할 때 값은 각 단계를 거치며 흘러갑니다: CSV 한 줄 읽기 → status 확인 → float로 변환 → 합계에 더하기. 어느 시점이든 메모리에는 한 개의 레코드만 존재하며, CSV 파일이 10줄이든 천만 줄이든 상관없습니다.
7.1. pytest로 파이프라인 테스트하기
import pytest
from pathlib import Path
@pytest.fixture
def orders_csv(tmp_path: Path) -> str:
content = (
"order_id,amount,status\n"
"1,100.0,PAID\n"
"2,50.0,PENDING\n"
"3,200.0,PAID\n"
)
file_path = tmp_path / "orders.csv"
file_path.write_text(content)
return str(file_path)
def test_total_paid_amount(orders_csv):
result = total_paid_amount(orders_csv)
assert result == pytest.approx(300.0)
tmp_path는 pytest의 내장 fixture로, 각 테스트를 위해 임시 디렉토리를 만듭니다. 이 테스트는 3개의 주문이 있는 가짜 CSV 파일을 만들고, 그 중 2개가 PAID 상태이며 합계가 정확히 300.0인지 검증합니다.
8. yield from: 다른 generator에 위임하기
한 generator가 다른 generator의 결과를 호출해서 포함해야 할 때, yield from은 수동 루프를 작성하는 것보다 더 깔끔하게 코드를 쓸 수 있게 해줍니다.
def read_file_lines(filepath: str):
with open(filepath) as f:
for line in f:
yield line.strip()
def read_multiple_files(filepaths: list[str]):
for filepath in filepaths:
yield from read_file_lines(filepath)
yield from read_file_lines(filepath)는 for line in read_file_lines(filepath): yield line을 쓰는 것과 동일하지만 더 간결합니다. 또한 내부 generator가 return 값을 가질 때도 올바르게 처리합니다. read_multiple_files 함수는 여러 로그 파일을 하나의 연속적인 스트림처럼 순회할 수 있으며, list를 합칠 필요가 없습니다.
9. send()로 generator에 값 전송하기
Generator는 값을 외부로 보내는 것만이 아닙니다 — send()를 사용해서 generator 내부로 값을 보낼 수 있으며, 이를 통해 간단한 coroutine을 만들 수 있습니다.
def running_average():
total = 0.0
count = 0
average = None
while True:
value = yield average
total += value
count += 1
average = total / count
avg_gen = running_average()
next(avg_gen) # generator 시작, 첫 번째 yield까지 실행
print(avg_gen.send(10)) # 10 전송, average = 10/1
print(avg_gen.send(20)) # 20 전송, average = 30/2
print(avg_gen.send(30)) # 30 전송, average = 60/3
10.0
15.0
20.0
value = yield average 줄은 두 가지를 합니다: average를 외부로 반환하고, 동시에 send()로 전송된 값을 받아서 value에 할당합니다. generator가 아직 yield에 도달하지 않았을 때 처음부터 send()를 호출하면 Python이 TypeError를 발생시키므로 먼저 next(avg_gen)을 호출해야 합니다.
주의:
send()를 사용하면 코드가 일반 generator보다 읽기 어려워집니다. 실제로는asyncio같은 프레임워크가 이 메커니즘을 추상화했기 때문에, 라이브러리를 작성할 때를 제외하고는send()를 직접 작성할 일이 드뭅니다.
10. Generator를 사용하면 안 되는 경우
Generator가 항상 list보다 좋은 것은 아닙니다:
- 임의 접근이나 여러 번 순회가 필요한 경우: generator는
gen[5]같은 인덱싱을 지원하지 않으며 처음부터 다시 순회할 수 없습니다.len()이 필요하거나 슬라이싱을 하거나 데이터를 2-3번 순회해야 한다면 list가 더 적합합니다. - 데이터가 작아서 메모리를 걱정할 필요가 없는 경우: 수천 개 요소 정도는 generator의 상태 관리 오버헤드가 list comprehension보다 느릴 수 있습니다. list는 C 레벨에서 최적화되어 있기 때문입니다.
- 병렬 처리나 여러 worker에 작업을 나누어야 하는 경우: list는 쉽게 작은 청크로 나누어 multiprocessing에 보낼 수 있습니다; generator는 사용 전에 일부를 “구체화”해야 합니다.
- 디버깅이 어려운 경우: list를 출력하면 모든 값을 바로 볼 수 있지만, generator를 출력하면
<generator object ... at 0x...>만 보입니다. 값을 보려면 순회해야 하는데, 순회가 끝나면 generator는 “고갈”되어 다시 사용할 수 없습니다.
확실하지 않다면 간단한 원칙을 따르세요: 데이터가 RAM에 충분히 들어가고 여러 번 사용해야 하면 list를 사용하고; 데이터가 크거나, 순차적으로 한 번만 처리하거나, 스트리밍 파이프라인을 구축할 때는 generator를 사용하세요.
11. 결론
Generator와 yield는 정확한 문제를 해결합니다: 메모리에 모든 데이터를 로드하지 않고 대용량 데이터를 처리하는 것입니다. lazy evaluation 덕분에 — 요청할 때만 값을 계산합니다. 이제 generator 함수를 작성하는 방법, generator expression, yield from으로 여러 generator를 파이프라인으로 연결하는 방법, 그리고 send()로 데이터를 generator에 역방향으로 전송하는 방법까지 배웠습니다. 기억하세요: generator는 메모리를 절약하지만 그 대신 다시 순회하거나 임의 접근을 할 수 없으며, 때로는 코드가 더 읽기 어려워질 수 있습니다. 올바른 도구를 올바른 문제에 사용하세요. “메모리를 절약할 수 있다”고 해서 모든 곳에 generator를 사용하는 것은 아닙니다.