파이썬 이터레이터·제너레이터: 메모리 실측과 한 번만 도는 함정
__iter__/__next__ 직접 구현부터 제너레이터, yield from, itertools 까지. 두 번째 루프가 빈 결과를 내는 함정과 390 MiB 대 0.3 KiB 메모리 실측을 담았습니다.
시리즈: 파이썬 강의 3/6 (중급). 함수와 반복문은 쓸 줄 알지만, 크롤링 결과나 서버 로그처럼 메모리에 다 올리기 부담스러운 데이터를 다뤄야 하는 분을 위한 글입니다. 다 읽고 나면 이터레이터를 직접 만들고, 제너레이터로 파이프라인을 짜고, 메모리 차이를 감이 아니라 숫자로 증명할 수 있습니다.
수백 MB 짜리 로그를 읽다가 스크립트가 멈춘 적이 있거나, 분명 같은 데이터를 두 번 돌렸는데 두 번째 결과가 비어 있던 적이 있다면 이 글이 그 문제를 다룹니다. 두 현상은 모두 "이터레이터 프로토콜" 하나에서 나오고, 구조가 보이면 고치기도 쉽습니다. 코딩테스트에서 map(int, input().split()) 결과를 두 번 쓰다가 틀리는 것도 같은 원인입니다.
결론부터 말하면, 천만 개의 제곱을 더할 때 제너레이터 표현식의 추적 메모리 피크는 0.3 KiB, 같은 일을 하는 리스트 컴프리헨션은 390 MiB 였고 시간 차이는 5% 이내였습니다. 86 MiB 로그 파일을 스트리밍으로 처리하면 피크가 29.7 KiB, readlines() 로 처리하면 1,027 MiB 에 시간도 3배 걸렸습니다. 모든 측정은 Apple Silicon Mac(M1 Pro)의 Python 3.9 에서 했고, 출력은 로컬 경로만 ~/demo/ 로 바꿨습니다.
이터레이터 프로토콜을 손으로 구현하기
for 문은 리스트를 모릅니다. 먼저 iter(obj) 를 불러 obj.__iter__() 에서 이터레이터를 받고, 그 이터레이터에 StopIteration 이 날 때까지 next()(즉 __next__())를 부를 뿐입니다. 프로토콜은 이게 전부입니다.
손으로 짤 때는 여러 번 돌 수 있는 이터러블과 현재 위치를 기억하는 이터레이터를 분리하는 것이 정석입니다.
class Countdown:
"""Iterable: each for loop gets a fresh iterator."""
def __init__(self, start):
self.start = start
def __iter__(self):
return CountdownIter(self.start)
class CountdownIter:
def __init__(self, n):
self.n = n
def __iter__(self):
return self
def __next__(self):
if self.n <= 0:
raise StopIteration
self.n -= 1
return self.n + 1
c = Countdown(3)
print(list(c), list(c)) # reusable
it = iter(c)
print(next(it), next(it), next(it))
try:
next(it)
except StopIteration:
print("StopIteration raised")
print(next(it, "default"))
[3, 2, 1] [3, 2, 1]
3 2 1
StopIteration raised
default
이터레이터의 __iter__ 는 self 를 돌려주므로 이터러블 자리에 이터레이터를 넣어도 동작합니다. next(it, 기본값) 은 예외 대신 기본값을 돌려주기 때문에 "첫 번째로 맞는 것, 없으면 None" 같은 코드에 편합니다.
흔한 실수: 컨테이너가 자기 자신을 이터레이터로 돌려줌
처음 구현할 때 많이 하는 실수는 컨테이너에 __next__ 를 붙이고 __iter__ 에서 self 를 돌려주는 것입니다.
class Bag:
def __init__(self, items):
self.items, self.i = items, 0
def __iter__(self):
return self # BUG: the container is its own iterator
def __next__(self):
if self.i >= len(self.items):
raise StopIteration
self.i += 1
return self.items[self.i - 1]
b = Bag(["x", "y"])
print(list(b), list(b))
print([(p, q) for p in b for q in b])
['x', 'y'] []
[]
에러는 없고 답만 틀립니다. 두 번째 순회는 비어 있고, 같은 객체로 이중 루프를 돌면 커서 하나를 공유해서 결과가 통째로 사라집니다. 해결: __iter__ 에서 새 이터레이터를 돌려주거나, 더 짧게 def __iter__(self): yield from self.items 처럼 __iter__ 를 제너레이터로 만듭니다.
제너레이터: 프로토콜을 공짜로 얻는 방법
함수 안에 yield 가 있으면 호출 결과가 제너레이터 객체가 됩니다. 본문은 첫 next() 전까지 실행되지 않고, yield 마다 값 하나를 내준 뒤 지역 변수 상태를 그대로 얼려 둡니다.
def countdown(n):
while n > 0:
yield n
n -= 1
g = countdown(3)
print(g)
print(list(g), list(g))
<generator object countdown at 0x1030ce900>
[3, 2, 1] []
클래스 20줄과 같은 결과지만 마지막 줄의 함정도 똑같습니다. 제너레이터는 이터레이터 그 자체라서 한 번 쓰면 끝입니다.
yield from 은 다른 이터러블에 위임합니다. 재귀 제너레이터를 쓸 때 자연스럽고, 안쪽 제너레이터의 return 값도 받아 옵니다.
def walk(tree):
for node in tree:
if isinstance(node, list):
yield from walk(node)
else:
yield node
print(list(walk([1, [2, [3, 4]], 5]))) # [1, 2, 3, 4, 5]
def inner():
yield 1
yield 2
return "inner done"
def outer():
result = yield from inner()
print("outer got:", result)
print(list(outer())) # outer got: inner done → [1, 2]
한 줄짜리 제너레이터 표현식 (x * x for x in data) 도 같은 것입니다. 유일한 인자일 때는 괄호를 생략해 sum(x * x for x in data) 로 씁니다.
함정: 제너레이터 안의 StopIteration 은 RuntimeError 가 된다
Python 3.7(PEP 479)부터 제너레이터 본문 밖으로 새어 나간 StopIteration 은 RuntimeError 로 바뀝니다. 기본값 없이 next() 를 부르는 코드가 걸립니다.
def first_words(lines):
it = iter(lines)
while True:
line = next(it) # raises StopIteration at the end...
yield line.split()[0]
print(list(first_words(["hello world", "good bye"])))
Traceback (most recent call last):
File "~/demo/pep479.py", line 4, in first_words
line = next(it) # raises StopIteration at the end...
StopIteration
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
File "~/demo/pep479.py", line 7, in <module>
print(list(first_words(["hello world", "good bye"])))
RuntimeError: generator raised StopIteration
해결은 for line in lines: yield line.split()[0] 로 바꾸거나, next(it, None) 이 None 이면 명시적으로 return 하는 것입니다.
한 번 쓰면 끝나는 이터레이터 함정 (실제 출력)
반나절을 날리게 만드는 버그입니다. 실수한 줄에서는 아무 일도 일어나지 않기 때문입니다. "메모리를 아끼려고" 제너레이터 표현식으로 파싱한 뒤 두 번 돌면:
rows = ["alice,30", "bob,25", "carol,41"]
parsed = (line.split(",") for line in rows)
names = [p[0] for p in parsed]
ages = [int(p[1]) for p in parsed]
print(names, ages)
print(sum(ages) / len(ages))
['alice', 'bob', 'carol'] []
Traceback (most recent call last):
File "~/demo/trap.py", line 6, in <module>
print(sum(ages) / len(ages))
ZeroDivisionError: division by zero
에러는 진짜 실수보다 두 줄 아래에서, 반복과는 상관없어 보이는 나눗셈 에러로 나타납니다. 파이썬 3 의 map, filter, zip, 파일 객체, csv.reader 도 모두 이터레이터라서 똑같습니다. 코딩테스트에서 자주 보는 패턴으로 재현하면:
nums = map(int, ["3", "1", "2"])
print(max(nums))
print(min(nums))
3
Traceback (most recent call last):
File "~/demo/trap2.py", line 3, in <module>
print(min(nums))
ValueError: min() arg is an empty sequence
비슷한 함정이 두 개 더 있습니다. 3 in iter([1, 2, 3, 4]) 는 True 를 돌려주면서 1, 2, 3 을 소비해 버려서 뒤이은 list() 는 [4] 가 됩니다. len(zip(...)) 은 TypeError: object of type 'zip' has no len() 입니다. 이터레이터는 스스로를 소비하지 않고는 길이를 모릅니다.
해결은 상황에 따라 고릅니다.
| 상황 | 이렇게 |
|---|---|
| 데이터가 작고 두 번 써야 함 | parsed = [line.split(",") for line in rows] (리스트) |
| 집계 두 개를 한 번에 | for 문 하나에서 둘 다 갱신 |
| 스트림을 독립적으로 두 번 순회 | itertools.tee. 단, 두 순회가 멀리 벌어지면 결국 전부 버퍼링됨 |
| 여러 번 돌 수 있는 지연 객체 | __iter__ 를 제너레이터로 쓴 클래스 |
len() 이 필요 |
리스트로 만들거나 돌면서 셈 |
itertools 필수 함수
itertools 는 이터레이터를 받아 이터레이터를 돌려주는 부품 모음이라 중간 리스트 없이 조합됩니다. 외워 둘 만한 것들의 Python 3.9 실제 출력입니다.
import itertools as it
print(list(it.islice(it.count(10, 5), 4)))
print(list(it.chain([1, 2], (3, 4), "ab")))
rows = [("ja", "tokyo"), ("ja", "osaka"), ("ko", "seoul"), ("ja", "nagoya")]
print([(k, [c for _, c in g]) for k, g in it.groupby(rows, key=lambda r: r[0])])
print([(k, [c for _, c in g]) for k, g in it.groupby(sorted(rows), key=lambda r: r[0])])
print(list(it.accumulate([3, 1, 4, 1, 5])))
print(list(it.zip_longest("abc", [1], fillvalue="-")))
[10, 15, 20, 25]
[1, 2, 3, 4, 'a', 'b']
[('ja', ['tokyo', 'osaka']), ('ko', ['seoul']), ('ja', ['nagoya'])]
[('ja', ['nagoya', 'osaka', 'tokyo']), ('ko', ['seoul'])]
[3, 4, 8, 9, 14]
[('a', 1), ('b', '-'), ('c', '-')]
groupby 두 줄을 비교해 보세요. groupby 는 연속된 같은 키만 묶기 때문에 정렬하지 않으면 ja 가 두 번 나옵니다. SQL 의 GROUP BY 를 기대하면 틀립니다. 무한 스트림에서도 동작하게 하려는 설계이므로, 먼저 정렬하거나 메모리에 들어가는 데이터라면 dict/Counter 를 씁니다.
count 는 무한이라 islice 나 takewhile 로 잘라야 합니다. 크롤링 결과를 100개씩 DB 에 넣을 때 쓰는 청크 함수는 어떤 이터레이터에도 동작합니다.
def chunks(iterable, size):
iterator = iter(iterable)
while True:
block = list(it.islice(iterator, size))
if not block:
return
yield block
print(list(chunks(range(7), 3))) # [[0, 1, 2], [3, 4, 5], [6]]
Python 3.12 에 바로 이 용도의 itertools.batched 가, 3.10 에 pairwise 가 추가됐습니다. Python 3.9 에서 itertools.pairwise 는 AttributeError: module 'itertools' has no attribute 'pairwise' 였고, Python 3.11 에서는 동작했지만 batched 는 여전히 없었습니다. 3.9 를 지원해야 하면 직접 만든 함수를 유지하세요.
실측: 리스트 컴프리헨션 대 제너레이터 표현식
메모리는 tracemalloc 의 호출 중 최대 추적 할당량으로, 시간은 추적을 끈 별도 패스에서 5회 중 최솟값으로 쟀습니다. 첫 시도에서 추적을 켠 채 시간을 쟀더니 같은 코드가 10배 넘게 느려졌기 때문입니다(42 ms → 689 ms). 메모리와 시간은 따로 재야 합니다.
"""List vs generator: tracemalloc peak and wall time (Python 3.9)."""
import time, tracemalloc
def sum_list(n): return sum([i * i for i in range(n)])
def sum_gen(n): return sum(i * i for i in range(n))
def peak_kb(fn, n):
tracemalloc.start()
fn(n)
_, peak = tracemalloc.get_traced_memory()
tracemalloc.stop()
return peak / 1024
def best_ms(fn, n, repeat=5):
best = float("inf")
for _ in range(repeat):
t0 = time.perf_counter()
fn(n)
best = min(best, time.perf_counter() - t0)
return best * 1000
for n in (1_000_000, 10_000_000):
for label, fn in (("list comp", sum_list), ("generator", sum_gen)):
print(f"n={n:>10,} {label:<10} peak={peak_kb(fn, n):>11,.1f} KB"
f" best of 5={best_ms(fn, n):8.1f} ms")
n= 1,000,000 list comp peak= 39,372.4 KB best of 5= 42.0 ms
n= 1,000,000 generator peak= 0.7 KB best of 5= 43.0 ms
n=10,000,000 list comp peak= 399,378.7 KB best of 5= 493.5 ms
n=10,000,000 generator peak= 0.3 KB best of 5= 516.5 ms
(1024 로 나눴으므로 출력의 KB 는 KiB 입니다.) 두 번째 실행은 40.2/43.7 ms, 503.1/522.4 ms 였고 메모리 값은 같았습니다.
- 리스트 메모리는 원소당 약 40바이트(리스트 안 포인터 8바이트 + 큰 정수 객체 32바이트)로 선형 증가합니다. 천만 개면 아무것도 하기 전에 390 MiB 입니다.
- 제너레이터 피크는
n과 무관하게 1 KiB 미만입니다. 값이 한 번에 하나만 존재하기 때문입니다. - 속도는 무승부입니다. 원소마다 프레임을 재개하는 비용 때문에 제너레이터가 2~7% 느렸습니다. 제너레이터는 속도가 아니라 메모리와 조기 종료 때문에 고릅니다.
대용량 파일 스트리밍 처리기
실전 용도는 통째로 읽기 부담스러운 로그 파일입니다. 아래 스크립트는 200만 행(86 MiB, seed 7)의 가짜 접근 로그를 만든 뒤, 경로별 HTTP 500 건수를 제너레이터 파이프라인과 readlines() + 리스트 컴프리헨션 두 가지로 셉니다.
"""Streaming a large log file with a generator pipeline (Python 3.9 stdlib)."""
import os, random, sys, time, tracemalloc
from collections import Counter
PATH = "access.log"
def make_log(path, n=2_000_000, seed=7):
rng = random.Random(seed)
paths = ["/", "/api/v1/user", "/api/v1/items", "/static/app.js", "/login"]
codes = [200] * 90 + [304] * 5 + [404] * 3 + [500] * 2
with open(path, "w") as f:
for i in range(n):
f.write(f"2026-11-12T10:{i % 60:02d}:00 GET {rng.choice(paths)} "
f"{rng.choice(codes)} {rng.randint(80, 9000)}ms\n")
def read_lines(path):
with open(path) as f:
for line in f: # the file object is itself a lazy iterator
yield line.rstrip("\n")
def parse(lines):
for line in lines:
_ts, _method, path, code, ms = line.split(" ")
yield path, int(code), int(ms[:-2])
def only_errors(records):
return (r for r in records if r[1] >= 500)
def streaming(path):
return Counter(p for p, _c, _ms in only_errors(parse(read_lines(path))))
def eager(path):
with open(path) as f:
lines = f.readlines() # whole file in memory
records = [l.rstrip("\n").split(" ") for l in lines]
return Counter(r[2] for r in records if int(r[3]) >= 500)
def run(fn):
tracemalloc.start()
res = fn(PATH)
peak = tracemalloc.get_traced_memory()[1]
tracemalloc.stop()
t0 = time.perf_counter(); fn(PATH); dt = time.perf_counter() - t0
print(f"{fn.__name__:<10} peak={peak/1024:11,.1f} KiB time(no tracing)={dt:5.2f} s {res.most_common(2)}")
if __name__ == "__main__":
if not os.path.exists(PATH):
make_log(PATH)
print(f"file: {os.path.getsize(PATH)/2**20:.1f} MiB, Python {sys.version.split()[0]}")
run(streaming)
run(eager)
file: 86.0 MiB, Python 3.9.6
streaming peak= 29.7 KiB time(no tracing)= 1.13 s [('/', 8057), ('/static/app.js', 8044)]
eager peak=1,051,415.9 KiB time(no tracing)= 3.89 s [('/', 8057), ('/static/app.js', 8044)]
5회 실행에서 스트리밍은 1.11~1.37초, 한 번에 읽는 쪽은 3.55~3.89초였고, 둘 다 같은 40,053건의 에러를 찾았습니다. 86 MiB 파일이 eager 버전에서는 1 GiB 의 파이썬 객체가 됩니다. 행마다 str(헤더 포함 약 100바이트)이 되고, 다시 str 다섯 개짜리 리스트가 되기 때문입니다. 스트리밍 파이프라인은 한 번에 한 줄만 들고 있고, 각 단계는 몇 줄짜리 함수라 리스트를 넣어 따로 테스트할 수 있습니다. 소비하는 쪽이 멈추면 읽기도 멈춥니다. Counter(...) 를 next(...) 로 바꾸면 첫 에러까지만 읽습니다. 공유 호스팅처럼 메모리 제한이 빡빡한 서버에서 cron 으로 로그를 집계할 때 이 차이가 곧 "돌아가느냐, OOM 으로 죽느냐" 입니다.
send() 간단히
제너레이터는 값을 받을 수도 있습니다. value = yield x 는 x 를 내주고 멈추며, gen.send(v) 는 value = v 로 재개합니다. 이동 평균 예제입니다.
def running_avg():
total, count, avg = 0.0, 0, None
while True:
value = yield avg
total += value
count += 1
avg = total / count
avg = running_avg()
print(next(avg)) # prime it: runs to the first yield
print(avg.send(10))
print(avg.send(20))
print(avg.send(60))
fresh = running_avg()
fresh.send(5)
None
10.0
15.0
30.0
Traceback (most recent call last):
File "~/demo/send.py", line 15, in <module>
fresh.send(5)
TypeError: can't send non-None value to a just-started generator
마지막 줄이 전형적인 실수입니다. 갓 만든 제너레이터는 아직 yield 에 도달하지 않아서 값을 받을 자리가 없습니다. 먼저 next() 를 한 번 부르세요. async/await 이전의 구식 코루틴이 이 구조 위에 만들어졌지만, 새 코드에서는 메서드 하나짜리 작은 클래스가 대개 더 읽기 쉽습니다. asyncio 는 5강에서 다룹니다.
무엇을 쓸지 고르는 기준
| 상황 | 선택 |
|---|---|
작은 데이터, 여러 번 사용, len()·인덱스 필요 |
리스트 |
| 크거나 끝이 없는 데이터를 한 번 순회 | 제너레이터 표현식·제너레이터 함수 |
| 스트림에 변환 단계가 여러 개 | 제너레이터 함수 파이프라인 |
| 여러 번 순회해야 하는 사용자 정의 컬렉션 | __iter__ 를 제너레이터로 쓴 클래스 |
외부에서 next() 를 부를 명시적 상태 |
__iter__ + __next__ 클래스 |
| 묶기·자르기·잇기·조합 | 직접 만들기 전에 itertools |
솔직한 한계와 버전 메모
- 모든 시간 측정: Python 3.9.6, Apple Silicon Mac(M1 Pro). 메모리 비교는 5회 중 최솟값, 로그 파일은 5회 실행 범위. seed 고정(로그는 7). Python 3.11 은 동작과 에러 메시지 확인에만 썼고 시간 측정에는 쓰지 않았습니다.
tracemalloc은 파이썬 할당자를 통한 메모리만 셉니다. RSS 가 아니므로 인터프리터 자체와 그 밖의 버퍼는 빠져 있습니다. 같은 코드의 두 버전을 비교하는 도구이지, 컨테이너 메모리 한도를 정하는 도구가 아닙니다.- Python 3.11 은 트레이스백의 실패한 식 아래에
~~~~^~~~표시를 덧붙입니다. 그 밖의 메시지는 3.9 와 3.11 에서 같았습니다. - PEP 479(
StopIteration→RuntimeError)는 3.7 부터 기본입니다.itertools.pairwise는 3.10+ (3.9 에 없고 3.11 에 있음을 확인),itertools.batched는 3.12+ 이며 이번에는 실행하지 않았습니다.
시리즈 이동: 이전: 파이썬 강의 2: 함수, 스코프, 기본 인자 · 다음: 파이썬 강의 4: 데코레이터와 컨텍스트 매니저. 같은 "지연 평가" 개념은 C# LINQ 기초의 지연 실행으로도 나오고, 6강 프로파일링과 최적화에서 메모리·시간 측정을 더 깊이 다룹니다.
자주 묻는 질문
제너레이터를 두 번째로 list() 하면 왜 빈 리스트가 나오나요?
제너레이터는 이터레이터이고, 이터레이터는 한 번만 소비할 수 있습니다. 첫 번째 list() 나 for 문에서 끝까지 소비되면 그 뒤로는 에러 없이 아무것도 내놓지 않습니다. 두 번 써야 하면 리스트로 저장하거나, __iter__ 가 매번 새 이터레이터를 돌려주는 클래스를 만드세요.
제너레이터 표현식은 리스트 컴프리헨션보다 메모리를 얼마나 아끼나요?
Python 3.9 에서 sum(i * i for i in range(10_000_000)) 의 tracemalloc 피크는 제너레이터가 0.3 KiB, 리스트 컴프리헨션이 399,379 KiB(약 390 MiB)였습니다. 시간은 0.52초 대 0.50초로 거의 같았습니다.
제너레이터가 리스트보다 빠른가요?
그 자체로는 아닙니다. 단순 합계에서는 제너레이터가 2~7% 느렸습니다. 리스트가 메모리를 압박할 만큼 크거나 중간에 멈출 수 있을 때 이깁니다. 로그 처리 예제가 readlines() 보다 약 3배 빨랐던 것도 1 GiB 의 객체를 만들지 않았기 때문입니다.