IT·리눅스/Python

제너레이터 vs 이터레이터 - Python yield는 언제 쓰나

jmineekim 2026. 7. 9. 10:00

몇 년 전에 로그 파일 하나가 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() 안 되고 예외는 순회 시점에 터진다는 특성은 항상 염두.
반응형