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를 사용하는 것은 아닙니다.

pythongeneratoryieldperformancememory