제너레이터 함수: yield로 만드는 지연 이터레이터
yield 키워드로 제너레이터 함수를 만드는 방법, 실행 흐름의 일시 정지와 재개, 지연 평가의 장점을 실용 예제와 함께 설명합니다.
지난 글에서 iter()와 next() 내장 함수를 살펴봤다. 이터레이터를 직접 구현하려면 클래스에 __iter__와 __next__를 모두 작성해야 했다. 제너레이터 함수는 이 과정을 대폭 단순화하면서 동시에 강력한 지연 평가 기능을 제공한다.
제너레이터 함수란
함수 본문에 yield 키워드가 하나라도 있으면 그 함수는 제너레이터 함수가 된다. 일반 함수와 달리 호출해도 즉시 실행되지 않는다. 대신 제너레이터 객체를 반환한다. 이 객체는 이터레이터 프로토콜을 완전히 구현하고 있다.
def simple_gen():
print("A 실행")
yield 1
print("B 실행")
yield 2
print("C 실행")
gen = simple_gen() # 아직 print 호출 안 됨
print(type(gen)) # <class 'generator'>
print(next(gen)) # "A 실행" 출력 후 1 반환
print(next(gen)) # "B 실행" 출력 후 2 반환
# next(gen) # "C 실행" 출력 후 StopIteration 발생
yield는 값을 반환하고 그 자리에서 실행을 멈춘다. next()가 다시 호출되면 멈춘 곳에서 재개한다. 이 동작이 일반 함수의 return과 결정적으로 다른 점이다.
실행 흐름의 일시 정지와 재개
제너레이터는 다섯 가지 상태를 갖는다.
- 생성됨: 제너레이터 함수를 호출한 직후. 코드는 한 줄도 실행되지 않았다.
- 실행 중:
next()에 의해 재개돼 다음yield를 향해 달려가는 중. - 일시 정지:
yield를 만났을 때. 현재 프레임(지역 변수, 실행 위치)이 저장된다. - 종료: 함수 끝에 도달하거나
return을 만남.StopIteration발생. - 닫힘:
close()를 명시적으로 호출하거나 GC가 수거했을 때.
import inspect
def gen_func():
yield 1
yield 2
g = gen_func()
print(inspect.getgeneratorstate(g)) # GEN_CREATED
next(g)
print(inspect.getgeneratorstate(g)) # GEN_SUSPENDED
list(g) # 소진
print(inspect.getgeneratorstate(g)) # GEN_CLOSED
yield와 return의 차이
return은 함수를 종료하고 값을 단 한 번 반환한다. yield는 값을 반환하고 실행을 일시 정지한다. 제너레이터 함수에서 return은 StopIteration을 발생시키며, 반환 값은 StopIteration 예외 객체의 value 속성에 저장된다.
def gen_with_return():
yield 1
yield 2
return "완료" # StopIteration("완료") 발생
g = gen_with_return()
print(next(g)) # 1
print(next(g)) # 2
try:
next(g)
except StopIteration as e:
print(e.value) # "완료"
return의 값은 일반 for 루프에서는 볼 수 없다. yield from과 함께 서브제너레이터 패턴에서 활용된다 (다음 글 참조).
지연 평가와 메모리 효율
제너레이터의 가장 큰 장점은 **지연 평가(lazy evaluation)**다. 값을 미리 모두 계산하지 않고 요청할 때마다 하나씩 생성한다.
# 1억 개 숫자의 제곱합 — 리스트 사용 시 수 GB 메모리 필요
total = sum(x * x for x in range(100_000_000))
# 제너레이터 식을 활용 → 상수 메모리로 처리
이 코드는 1억 개의 숫자를 한꺼번에 메모리에 올리지 않는다. sum()이 요청할 때마다 하나씩 제너레이터에서 꺼내 더한다.
무한 시퀀스 표현
제너레이터는 논리적으로 끝이 없는 시퀀스를 표현할 수 있다. 리스트로는 불가능하다.
def fibonacci():
a, b = 0, 1
while True: # 무한 루프
yield a
a, b = b, a + b
fib = fibonacci()
for _ in range(10):
print(next(fib), end=" ") # 0 1 1 2 3 5 8 13 21 34
while True 루프를 쓰지만 메모리를 무한정 사용하지 않는다. 제너레이터를 소비하는 쪽이 멈추면 생성도 멈춘다.
파이프라인 구성
제너레이터를 연결하면 데이터 파이프라인을 구성할 수 있다.
def read_lines(path):
with open(path) as f:
for line in f:
yield line.rstrip()
def filter_empty(lines):
for line in lines:
if line:
yield line
def to_upper(lines):
for line in lines:
yield line.upper()
# 파이프라인 구성 — 실제 파일 읽기는 for 루프가 실행될 때 발생
pipeline = to_upper(filter_empty(read_lines("data.txt")))
for line in pipeline:
print(line)
각 단계는 독립적인 제너레이터다. 데이터는 파이프라인을 따라 한 줄씩 처리된다. 파일 전체를 메모리에 올릴 필요가 없다.
제너레이터 표현식
리스트 컴프리헨션과 같은 문법이지만 [] 대신 ()를 쓰면 제너레이터 표현식이 된다.
# 리스트 컴프리헨션 — 즉시 계산, 메모리 O(n)
squares_list = [x**2 for x in range(1000)]
# 제너레이터 표현식 — 지연 계산, 메모리 O(1)
squares_gen = (x**2 for x in range(1000))
print(type(squares_list)) # list
print(type(squares_gen)) # generator
# 바로 sum/max/min 등에 전달할 때 괄호 생략 가능
total = sum(x**2 for x in range(1000))
제너레이터 표현식은 제너레이터 함수의 축약 형태다. 간단한 변환 파이프라인에는 표현식이 더 간결하고, 복잡한 로직에는 함수 형태가 적합하다.
주의: 제너레이터는 한 번만 순회
제너레이터 객체는 소진되면 재사용할 수 없다.
gen = (x for x in range(3))
print(list(gen)) # [0, 1, 2]
print(list(gen)) # [] -- 이미 소진됨
여러 번 순회해야 한다면 제너레이터 함수를 다시 호출하거나, 결과를 리스트에 저장한다.
정리
| 특성 | 일반 함수 | 제너레이터 함수 |
|---|---|---|
| 반환 | 값 | 제너레이터 객체 |
| 실행 | 즉시 전체 | next() 호출마다 yield까지 |
| 메모리 | O(n) | O(1) |
| 무한 시퀀스 | 불가 | 가능 |
| 재사용 | 매번 새로 호출 | 소진 후 재사용 불가 |
제너레이터 함수는 Python의 강력한 도구 중 하나다. 다음 글에서는 제너레이터 간 위임을 가능하게 하는 yield from을 살펴본다.
지난 글: iter()와 next() 내장 함수 심층 탐구
다음 글: yield from: 서브제너레이터 위임과 투명한 전달
읽어주셔서 감사합니다. 😊