몇 년 전에 로그 파일 하나가 20GB짜리인데 이걸 통째로 readlines()로 읽어서 처리하는 코드를 짠 적이 있어요. 로컬에서 500MB 짜리로 테스트할 때는 잘 돌더니 운영에서 메모리 부족으로 프로세스가 죽었죠. 원인은 20GB를 리스트로 다 올린 것. 그때 yield로 한 줄씩 뽑아주는 형태로 바꿨더니 메모리 20MB로도 잘 돌아갔습니다. 오늘은 이터레이터·제너레이터가 뭐고, 언제 yield를 써야 하는지 정리해볼게요.
개념 / 원리
먼저 용어부터 갈라봅시다.
- 이터러블(Iterable):
for로 순회할 수 있는 모든 것.__iter__메서드가 있다. 예: list, dict, 문자열, 파일 객체. - 이터레이터(Iterator):
next()가 호출될 때마다 다음 값을 하나씩 뱉는 것.__iter__와__next__를 둘 다 가진 객체. - 제너레이터(Generator): 이터레이터를 만드는 편한 방법. 함수 안에
yield를 쓰면 자동으로 이터레이터가 된다.
즉 제너레이터는 이터레이터의 부분집합이에요. 이터레이터를 클래스로 직접 만들려면 __iter__·__next__를 다 구현해야 하는데, 제너레이터는 함수에 yield 하나 넣는 것으로 끝납니다.
핵심 특성 두 가지가 실무에서 왜 중요한지를 결정합니다.
첫째, 한 번에 하나씩만 계산합니다(lazy evaluation). 10억 개 숫자를 만들어도 메모리엔 지금 뽑히는 그 하나만 있어요.
둘째, 한 번 순회하면 끝입니다. 리스트는 여러 번 순회할 수 있지만 이터레이터는 소진(exhausted)되면 그걸로 끝. 다시 돌리려면 새로 만들어야 합니다.
리스트 컴프리헨션과 제너레이터 표현식의 차이가 여기서 나옵니다.
sum([x*x for x in range(10**8)]) # 리스트 통째로 만들어 메모리 폭발 위험
sum(x*x for x in range(10**8)) # 제너레이터, 메모리 상수
괄호 하나 차이인데 메모리 사용량이 GB 단위로 갈립니다.
실전 예제
가장 흔한 패턴은 큰 파일을 한 줄씩 처리하는 것.
def read_lines(path):
with open(path, encoding="utf-8") as f:
for line in f: # 파일 객체 자체가 이터레이터
yield line.rstrip("\n")
# 20GB 파일도 메모리 걱정 없이 처리
error_count = sum(1 for line in read_lines("app.log") if "ERROR" in line)
DB에서 대량 행을 처리할 때도 같은 패턴이에요.
def fetch_users(cursor, batch=1000):
cursor.execute("SELECT id, email FROM users")
while rows := cursor.fetchmany(batch):
yield from rows
for user_id, email in fetch_users(cur):
process(user_id, email)
fetchall()이 아니라 배치로 나눠 뽑기 때문에 100만 행이든 1억 행이든 메모리 사용량이 일정합니다.
이터레이터를 직접 만들고 싶으면 클래스로도 가능은 합니다.
class Counter:
def __init__(self, start, end):
self.cur, self.end = start, end
def __iter__(self):
return self
def __next__(self):
if self.cur >= self.end:
raise StopIteration
v = self.cur
self.cur += 1
return v
for n in Counter(1, 4):
print(n) # 1, 2, 3
똑같은 걸 제너레이터로 쓰면 두 줄이에요.
def counter(start, end):
for i in range(start, end):
yield i
특별한 상태 관리가 필요한 게 아니라면 대부분 제너레이터 쪽이 짧고 명확합니다.
yield from은 다른 이터러블을 통째로 뱉을 때 유용해요.
def flatten(nested):
for sub in nested:
yield from sub
list(flatten([[1, 2], [3], [4, 5]])) # [1, 2, 3, 4, 5]
주의할 점
첫째, 이터레이터는 한 번 순회하면 끝입니다.
g = (x*x for x in range(3))
print(list(g)) # [0, 1, 4]
print(list(g)) # [] — 이미 소진됐다
여러 번 순회해야 하면 리스트로 감싸거나(list(g)), 매번 새로 만드는 함수를 준비하세요.
둘째, len()이 안 됩니다. 제너레이터는 몇 개인지 미리 모릅니다. 개수가 꼭 필요하면 sum(1 for _ in g)로 세거나(단, 이러면 소진됨), 애초에 리스트를 쓰세요.
셋째, 제너레이터의 예외·print는 순회 시점에 터집니다. yield가 있는 함수는 호출한 순간에는 아무 일도 안 일어나요. 호출 시점에는 제너레이터 객체만 반환되고, 실제 코드는 next() 또는 for가 값을 요구할 때 처음 실행됩니다. 그래서 신입 때 함수 안에 print를 넣어놓고 "왜 로그가 안 찍히지?" 하는 실수를 자주 해요. 예외 스택 트레이스도 호출부가 아니라 순회부에 찍히니 디버깅할 때 이 특성을 감안하세요.
넷째, 파일 객체는 자동 이터레이터. for line in file:이 이미 한 줄씩 뽑는 lazy 방식입니다. readlines()는 리스트라 통째로 메모리에 올리니 큰 파일에선 피하세요.
다섯째, itertools를 활용하세요. chain, islice, groupby, tee, takewhile 등이 다 이터레이터 기반이라 큰 데이터에도 메모리 상수. 표준 라이브러리 중 가장 저평가된 모듈입니다. 예를 들어 이런 패턴이 자주 유용해요.
from itertools import islice, chain
# 무한 시퀀스에서 앞 100개만
def naturals():
n = 1
while True:
yield n
n += 1
first_100 = list(islice(naturals(), 100))
# 여러 파일을 하나의 스트림처럼 이어붙여 순회
def all_lines(paths):
return chain.from_iterable(open(p, encoding="utf-8") for p in paths)
두 패턴 모두 리스트로 다 올리지 않고 필요할 때만 값을 뽑기 때문에, 파일이 몇 개든 무한 시퀀스든 메모리를 상수로 유지할 수 있어요. islice는 슬라이싱([:100])이 안 되는 이터레이터에 대한 슬라이싱이라고 생각하면 이해가 쉽습니다.
마무리
정리하면 세 줄입니다.
- 이터레이터는
next()로 한 번에 하나씩 뽑는 프로토콜, 제너레이터는 그걸yield로 편하게 만드는 문법. - 큰 파일·대량 DB 행·긴 시퀀스는 제너레이터로 처리해 메모리를 상수로 유지.
- 대신 한 번 순회하면 끝,
len()안 되고 예외는 순회 시점에 터진다는 특성은 항상 염두.
'IT·리눅스 > Python' 카테고리의 다른 글
| dataclass vs Pydantic - 언제 뭘 써야 할까 (0) | 2026.06.23 |
|---|---|
| multiprocessing vs threading - GIL 때문에 헷갈리는 두 모듈 정리 (0) | 2026.06.09 |
| 데코레이터 만들기와 활용 - @ 한 줄 뒤에 숨은 클로저 (0) | 2026.06.03 |
| asyncio - 코루틴은 GIL을 어떻게 피해 다니는가 (0) | 2026.05.27 |
| GIL의 정체 - 왜 멀티스레드가 안 빠를까 (0) | 2026.05.26 |