Python 제너레이터

src/content/documents/python/python-generators-for-streaming-data.json

결론: 값을 저장하지 말고 필요할 때 하나씩 생산한다

generator는 값을 한꺼번에 컨테이너에 담는 대신 소비자가 요청할 때 하나씩 만든다. 파일 행이나 긴 변환 파이프라인처럼 전체 결과를 동시에 보관할 필요가 없을 때 메모리 사용을 줄일 수 있다. 다만 지연 평가가 무조건 더 빠르거나 더 작다는 뜻은 아니다. 요소 크기, 파이프라인, 소비 방식과 구현에 따라 달라지므로 실제 데이터로 측정한다.

  • iterable: iter(obj)로 iterator를 얻을 수 있는 객체다. list는 여러 번 새 iterator를 만들 수 있다.

  • iterator: next(obj)로 다음 값을 돌려주고, 값이 끝나면 StopIteration을 일으키는 상태 있는 객체다. iter(iterator)는 자기 자신을 반환한다.

  • generator: generator function 또는 generator expression이 만드는 iterator다. yield 지점의 실행 상태를 보존한다.

iterator protocol을 직접 관찰하기

numbers = [10, 20]
iterator = iter(numbers)

print(iterator is iter(iterator))
print(next(iterator))
print(next(iterator))
try:
    next(iterator)
except StopIteration:
    print("finished")
True
10
20
finished

for 문은 이 protocol을 대신 처리한다. iterable에서 iterator를 얻고 next를 반복 호출하다 StopIteration을 만나면 정상적으로 반복을 끝낸다. 애플리케이션 코드에서 StopIteration을 직접 처리할 일은 많지 않지만, generator의 일회성 상태를 이해하는 핵심이다.

generator function은 호출할 때 실행되지 않는다

본문에 yield가 있는 함수는 generator function이다. 호출하면 본문을 즉시 실행하지 않고 generator object를 반환한다. 첫 next에서 실행을 시작하고 yield가 값을 내보낸 지점에서 멈춘다. 다음 next는 지역 변수와 실행 위치를 보존한 채 그 다음 문장부터 재개한다.

def countdown(start):
    print("start")
    while start > 0:
        yield start
        start -= 1
    print("end")

steps = countdown(2)
print("created")
print(next(steps))
print(next(steps))
try:
    next(steps)
except StopIteration:
    print("stopped")
created
start
2
1
end
stopped

함수가 return에 도달하거나 끝나면 generator는 StopIteration으로 완료된다. 다른 iterable의 값을 그대로 이어 내보낼 때는 yield from을 사용할 수 있지만, 양방향 제어인 send·throw는 이 글의 범위에서 제외한다.

generator expression과 list comprehension 비교

eager = [number ** 2 for number in range(4)]
lazy = (number ** 2 for number in range(4))

print(eager)
print(next(lazy))
print(list(lazy))
[0, 1, 4, 9]
0
[1, 4, 9]

대괄호 comprehension은 모든 결과를 즉시 계산해 재사용 가능한 list를 만든다. 괄호 expression은 요청받은 항목만 계산하는 generator를 만든다. 첫 값을 next로 이미 소비했으므로 뒤의 list에는 나머지만 들어갔다.

generator는 일회성이며 오류도 소비 시점에 나타난다

values = (10 // number for number in [2, 0, 5])
print("generator created")
print(next(values))
try:
    next(values)
except ZeroDivisionError:
    print("error while consuming")

remaining = (number * 2 for number in [1, 2])
print(list(remaining))
print(list(remaining))
generator created
5
error while consuming
[2, 4]
[]

생성 시점에는 0으로 나누는 식이 아직 평가되지 않았다. 지연 파이프라인의 오류는 해당 항목을 소비할 때 발생하므로 생성 코드와 실제 실패 지점이 떨어질 수 있다. 단계별 테스트와 입력 식별 정보를 포함한 오류 메시지가 중요하다. 다시 순회하려면 generator expression이나 generator function을 새로 호출해야 한다.

itertools로 지연 반복의 경계를 만든다

itertools는 빠르고 메모리 효율적인 iterator 도구를 제공한다. islice는 iterator의 일부만 소비하고, chain은 여러 iterable을 순서대로 하나처럼 연결한다.

from itertools import chain, count, islice

first_five_even = list(islice((n * 2 for n in count()), 5))
combined = list(chain(["A", "B"], (str(n) for n in range(2))))

print(first_five_even)
print(combined)
[0, 2, 4, 6, 8]
['A', 'B', '0', '1']

count는 끝이 없는 iterator다. list(count())처럼 전체를 수집하면 종료되지 않는다. 무한 generator에는 islice, takewhile 또는 명시적 break처럼 반드시 소비 종료 경계를 둔다.

CSV를 안전하게 행 단위로 처리하기

CSV는 필드 안의 쉼표와 따옴표 규칙이 있으므로 line.split(',')로 파싱하지 않는다. 파일의 수명은 호출자가 with로 관리하고, generator function은 열린 file-like iterable을 받아 csv.DictReader로 행을 해석하도록 분리하면 소유권이 분명하다.

import csv
import io

def valid_amounts(file_object):
    for row in csv.DictReader(file_object):
        if row["status"] == "paid":
            yield float(row["amount"])

csv_text = "name,amount,status\nA,10.5,paid\nB,7.0,pending\nC,2.5,paid\n"
with io.StringIO(csv_text, newline="") as file_object:
    print(sum(valid_amounts(file_object)))
13.0

실제 파일은 with open(path, newline="", encoding="utf-8") as file_object:처럼 연다. generator를 with 블록 밖에서 소비하면 닫힌 파일을 읽게 되므로 반드시 블록 안에서 최종 집계까지 끝낸다. generator 내부에서 파일을 열었다가 일부만 소비하고 버리는 설계는 자원 해제를 garbage collection 시점에 기대게 만들 수 있다. 필요하면 generator를 명시적으로 close하되, 이 예제처럼 호출자가 파일을 관리하는 구조가 더 단순하다.

필터 → 변환 → 집계 파이프라인

rows = [
    {"active": True, "amount": "10.5"},
    {"active": False, "amount": "100"},
    {"active": True, "amount": "2.5"},
]

active_rows = (row for row in rows if row["active"])
amounts = (float(row["amount"]) for row in active_rows)
total = sum(amounts)
print(total)
13.0

각 단계는 다음 단계가 요청한 행만 처리하고 sum이 최종 소비자가 된다. 중간 결과를 재사용하거나 여러 차례 분석해야 한다면 한 번 list로 materialize하는 편이 더 명확할 수 있다. generator 체인은 중간값 확인이 어려우므로 복잡한 분석에서는 이름 있는 함수와 작은 테스트를 함께 둔다.

언제 list가 더 나은가

전체 결과가 작고 여러 번 순회해야 하거나, 인덱싱·길이 확인·역순 접근이 필요하거나, 즉시 계산해 오류를 가까운 위치에서 발견하고 싶다면 list가 단순하다. generator의 장점은 무조건적인 성능이 아니라 지연 생산과 스트리밍 구성 가능성이다. 최종 소비자가 결국 전체를 list로 만들고 모든 값이 동시에 필요하다면 메모리 이점도 줄어든다.

흔한 실수와 핵심 체크리스트

☐ iterable, iterator, generator의 포함 관계와 iter·next·StopIteration 역할을 구분한다.

☐ generator function의 본문은 호출이 아니라 첫 소비 시점부터 실행됨을 안다.

☐ 한 번 소비한 generator를 재사용하지 않고 필요하면 새로 생성한다.

☐ 지연 평가의 예외가 생성 시점이 아닌 소비 시점에 발생할 수 있음을 테스트한다.

☐ 무한 iterator에 명시적인 종료 경계를 두고, 파일 generator는 자원 소유권과 소비 범위를 맞춘다.

☐ CSV는 split이 아니라 csv 모듈로 파싱하고 newline과 encoding을 명시한다.

다음 글: Python 문법 종합 실습

지금까지 배운 조건문, 반복문, 자료구조, 컴프리헨션, 문자열과 generator를 결합해 다음 글에서는 작은 데이터 정제 문제를 처음부터 끝까지 해결한다. 입력 검증, 변환, 집계와 출력 형식을 하나의 재현 가능한 프로그램으로 구성한다.

참고 자료

댓글 0

댓글을 불러오는 중…