14. 이터레이터와 제너레이터

🎯 이 장의 목표
  • for가 내부에서 어떻게 도는지(iter/next)를 이해한다.
  • 이터레이터와 이터러블의 차이를 안다.
  • yield로 제너레이터 함수를 만든다.
  • 제너레이터가 왜 메모리에 효율적인지(지연 평가) 이해한다.

먼저: 이터레이터가 뭔가요?

초급편 8장에서 for로 리스트·문자열·딕셔너리를 "하나씩" 훑었습니다. 그런데 for는 내부적으로 어떻게 "하나씩" 꺼낼까요?

비밀은 두 내장 함수 iter()next()에 있습니다.

  • 이터러블(iterable): for로 돌 수 있는 것. 리스트·문자열·딕셔너리·range 등.
  • 이터레이터(iterator): 이터러블에서 값을 하나씩 꺼내주는 장치. next()를 부를 때마다 다음 값을 내놓습니다.
PYTHON
nums = [10, 20, 30]
it = iter(nums)         # 리스트에서 이터레이터를 얻음

print(next(it))         # 10   (첫 값)
print(next(it))         # 20   (다음 값)
print(next(it))         # 30   (다음 값)
print(next(it))         # ❌ StopIteration! 더 없음

값이 다 떨어지면 StopIteration 예외가 납니다. 이것이 "끝"의 신호입니다.

📌 핵심
핵심: for는 마법이 아니다. 내부에서 iter()로 이터레이터를 얻고, next()를 반복 호출하다가 StopIteration이 나면 멈춘다.

실제로 for가 하는 일을 손으로 풀어 쓰면 이렇습니다.

PYTHON
# for ch in "abc": print(ch) 와 동일한 동작
it = iter("abc")
while True:
    try:
        ch = next(it)
        print(ch, end=" ")
    except StopIteration:
        break
# a b c
flowchart TD
    Start(["for x in 이터러블"]):::user --> Iter["iter() 로<br/>이터레이터 획득"]:::proc
    Iter --> Next{"next() 호출"}:::proc
    Next -->|값 있음| Body["x에 값 담아<br/>블록 실행"]:::result
    Body --> Next
    Next -->|StopIteration| End(["반복 종료"]):::data

    classDef user fill:#fff3b0,stroke:#e0a800,color:#5c4500
    classDef proc fill:#7fd8d8,stroke:#2a9d8f,color:#14532d
    classDef result fill:#b8e6c1,stroke:#34a853,color:#14532d
    classDef data fill:#a8dadc,stroke:#457b9d,color:#1d3557

이걸 알아두면 좋은 이유는, 다음에 배울 제너레이터가 바로 이 이터레이터를 쉽게 만드는 도구이기 때문입니다.

⭐ 제너레이터: yield로 값을 하나씩 내놓기

이터레이터를 직접 만들려면 클래스에 __iter__·__next__를 구현해야 해서 번거롭습니다. 제너레이터(generator)는 이를 함수처럼 간단히 만드는 방법입니다.

핵심은 return 대신 yield를 쓰는 것입니다. yield가 들어간 함수는 자동으로 제너레이터가 됩니다.

PYTHON
def count_up(n):
    for i in range(1, n + 1):
        yield i             # return이 아니라 yield!

gen = count_up(3)
print(type(gen))            # <class 'generator'>
print(list(gen))            # [1, 2, 3]

returnyield의 결정적 차이는 이렇습니다.

  • return: 값을 돌려주고 함수를 완전히 끝낸다. 한 번만 돌려준다.
  • yield: 값을 내놓고 함수를 잠시 멈춘다(일시정지). 다음 next() 호출 때 멈춘 자리에서 이어서 실행한다.

이 "일시정지" 동작을 직접 보면 이해가 확실해집니다.

PYTHON
def greetings():
    print("[함수 시작]")
    yield "안녕"
    print("[중간 지점]")
    yield "반가워"
    print("[함수 끝]")

g = greetings()
print(next(g))      # [함수 시작] → 안녕     (첫 yield에서 멈춤)
print(next(g))      # [중간 지점] → 반가워   (멈췄던 자리부터 이어 실행)

next(g)를 부를 때마다 함수가 다음 yield까지 실행되고 거기서 멈춥니다. 일반 함수가 한 번에 끝까지 달리는 것과 완전히 다릅니다.

실행 흐름을 한 단계씩 추적하기

yield가 어떻게 "멈췄다 이어지는지" 번호를 매겨 추적해 봅시다. 제너레이터를 비디오 재생에 비유하면, next()는 "다음 yield까지 재생하고 일시정지" 버튼입니다.

PYTHON
def steps():
    print("  [A] 함수 시작")
    yield 1
    print("  [B] 첫 yield 다음")
    yield 2
    print("  [C] 둘째 yield 다음")

g = steps()                    # ① 아직 아무것도 실행 안 됨!
print("② next →"); v = next(g) # ② [A] 출력 후 yield 1 에서 멈춤, v=1
print("③ next →"); v = next(g) # ③ [B] 출력 후 yield 2 에서 멈춤, v=2
print("④ next →")              # ④ [C] 출력 후 함수 끝 → StopIteration

실행하면 이런 순서로 출력됩니다.

TEXT
① (제너레이터 생성, 출력 없음)
② next →
  [A] 함수 시작        ← 첫 next에서야 [A]가 실행됨!
③ next →
  [B] 첫 yield 다음    ← 멈췄던 yield 1 자리부터 이어서 [B]
④ next →
  [C] 둘째 yield 다음  ← 다시 yield 2 자리부터 이어서 [C], 그 후 StopIteration

여기서 두 가지를 꼭 짚으세요.

  1. 생성 시점엔 함수 본문이 한 줄도 안 돕니다. g = steps()는 제너레이터 객체만 만들 뿐, [A]조차 출력하지 않습니다. 첫 next()가 와야 비로소 실행이 시작됩니다.
  2. 함수가 자기 위치를 기억합니다.next()마다 "멈췄던 yield 다음 줄"부터 이어 달립니다. 변수 값도 그대로 유지된 채로요. 마치 책갈피를 끼워둔 책처럼, 다음에 펼치면 그 자리부터 읽습니다.
📌 핵심
핵심: yield는 "값을 내놓고 그 자리에서 멈췄다가, 다음 호출 때 이어서 달리는" 키워드다. 제너레이터는 자기가 어디까지 실행했는지그때의 변수 값을 기억한다. 이 덕분에 값을 하나씩, 필요할 때 만들어낼 수 있다.

왜 제너레이터를 쓸까: 지연 평가와 메모리

제너레이터의 가장 큰 장점은 값을 미리 다 만들지 않고, 요청받을 때 하나씩 만든다는 것입니다. 이를 지연 평가(lazy evaluation)라고 합니다.

리스트는 모든 값을 즉시 메모리에 올립니다. 100만 개면 100만 개분의 메모리를 씁니다. 제너레이터는 한 번에 하나만 메모리에 두므로, 아무리 큰 범위라도 메모리를 거의 안 씁니다.

PYTHON
def squares(n):
    for i in range(n):
        yield i ** 2

# 1억 개라도 메모리 폭발 없이 합산 가능
# (값을 하나 만들어 더하고 버리고, 다음 값을 만들어 더하고...)
total = sum(squares(100))
print(total)        # 328350
리스트 [...]제너레이터 yield / (...)
값 생성 시점즉시 전부요청할 때 하나씩
메모리전체를 담음한 번에 하나
재사용여러 번 순회 가능한 번 쓰면 소진
적합한 경우여러 번 보거나 인덱싱 필요한 번만 훑거나 매우 큰/무한 데이터

무한 수열도 가능

리스트로는 불가능한 "무한 수열"을 제너레이터는 표현할 수 있습니다. 끝없이 만들 수 있지만 필요한 만큼만 꺼내면 되니까요.

PYTHON
def infinite():
    n = 0
    while True:         # 무한 루프지만...
        yield n
        n += 1

inf = infinite()
print([next(inf) for _ in range(5)])   # [0, 1, 2, 3, 4]   (5개만 꺼냄)

피보나치 수열처럼 "필요한 만큼만" 생성하는 데 제격입니다.

PYTHON
def fib(limit):
    a, b = 0, 1
    while a < limit:
        yield a
        a, b = b, a + b     # 초급편의 동시 할당!

print(list(fib(50)))    # [0, 1, 1, 2, 3, 5, 8, 13, 21, 34]
⚠️ 흔한 실수
흔한 함정 — 한 번 쓰면 끝: 제너레이터는 일회용입니다. 한 번 끝까지 순회하면 소진되어 다시 못 씁니다.
```python
g = count_up(3)
print(list(g)) # [1, 2, 3]
print(list(g)) # [] ← 이미 소진! 빈 리스트
```
여러 번 순회해야 한다면 리스트로 받아두거나(data = list(g)), 제너레이터를 다시 만들어야 합니다.

제너레이터 표현식 (복습+연결)

13장에서 본 제너레이터 표현식 (식 for ...)도 제너레이터를 만듭니다. yield 함수의 간단한 버전이라 보면 됩니다.

PYTHON
# 아래 두 줄은 사실상 같은 제너레이터
gen1 = (x ** 2 for x in range(5))

def make_squares():
    for x in range(5):
        yield x ** 2
gen2 = make_squares()

print(sum(gen1))    # 30
print(sum(gen2))    # 30
  • 간단한 변환·필터라면 → 제너레이터 표현식 (...)
  • 복잡한 로직(여러 단계, 조건 분기, 무한 수열)이라면 → yield 함수

나쁜 예 ❌ vs 좋은 예 ✅

큰 파일이나 큰 데이터를 한 번 훑을 때, 리스트로 전부 올리는 것은 낭비입니다.

PYTHON
# ❌ 나쁜 예: 1천만 개를 전부 리스트로 만들어 메모리 점유
def get_squares_list(n):
    result = []
    for i in range(n):
        result.append(i ** 2)
    return result
total = sum(get_squares_list(10_000_000))   # 메모리 많이 씀

# ✅ 좋은 예: 제너레이터로 하나씩 (메모리 거의 안 씀)
def get_squares(n):
    for i in range(n):
        yield i ** 2
total = sum(get_squares(10_000_000))        # 메모리 효율적
💡 팁
합산·최댓값·검색처럼 "한 번 훑고 끝"인 작업에서는 제너레이터가 거의 항상 유리합니다.

이 장에서 배운 것

  • for는 내부에서 iter()로 이터레이터를 얻고 next()를 반복하다 StopIteration에서 멈춘다.
  • 이터러블은 "돌 수 있는 것", 이터레이터는 "값을 하나씩 꺼내주는 장치"다.
  • yield가 든 함수는 제너레이터가 된다. yield는 값을 내놓고 그 자리에서 멈췄다 이어 달린다.
  • 제너레이터는 지연 평가로 값을 하나씩 만들어 메모리에 효율적이고, 무한 수열도 표현할 수 있다.
  • 제너레이터는 일회용이다. 여러 번 순회하려면 리스트로 받아두어야 한다.

🧪 실습 문제

문제 1. 다음 코드의 출력 순서를 적으세요.

PYTHON
def g():
    yield "A"
    yield "B"
    yield "C"

it = g()
print(next(it))
print(next(it))

문제 2. 1부터 n까지 정수를 하나씩 내놓는 제너레이터 함수 up_to(n)을 작성하고, list(up_to(5))를 출력하세요.

문제 3. 다음 코드는 왜 두 번째 출력이 빈 리스트일까요?

PYTHON
nums = (x for x in range(3))
print(list(nums))
print(list(nums))

문제 4. 짝수만 무한히 내놓는 제너레이터 even_numbers()를 작성하고(0, 2, 4, ...), 앞 5개만 리스트로 출력하세요. (힌트: while True + yield)

문제 5. 리스트 [3, 1, 4, 1, 5, 9, 2, 6]에서 3보다 큰 수의 합을 제너레이터 표현식으로 구하세요. (리스트를 따로 만들지 말 것)

<details>

<summary>✅ 정답·해설 보기</summary>

1. AB. 첫 next가 첫 yield("A")까지 실행하고 멈추고, 둘째 next가 다음 yield("B")까지 실행합니다. "C"는 아직 안 나옵니다.

2.

PYTHON
def up_to(n):
    for i in range(1, n + 1):
        yield i
print(list(up_to(5)))   # [1, 2, 3, 4, 5]

3. 제너레이터는 일회용이기 때문입니다. 첫 list(nums)가 모든 값을 꺼내 소진시켰고, 두 번째는 더 꺼낼 게 없어 []가 됩니다.

4.

PYTHON
def even_numbers():
    n = 0
    while True:
        yield n
        n += 2

gen = even_numbers()
print([next(gen) for _ in range(5)])   # [0, 2, 4, 6, 8]

5.

PYTHON
nums = [3, 1, 4, 1, 5, 9, 2, 6]
total = sum(n for n in nums if n > 3)
print(total)   # 24  (4+5+9+6)

</details>

◀️ 이전 장: 13. 컴프리헨션 | ▶️ 다음 장: 15. 모듈과 패키지