기초

파이썬 제너레이터: yield, 이터레이터, 리스트와 딕셔너리 컴프리헨션

업데이트: · Skillaria.Top

파이썬 제너레이터는 값을 한 번에 하나씩, 필요할 때만 만들어 내는 객체입니다. 함수 안에서 yield 키워드를 쓰거나, 괄호로 감싼 제너레이터 표현식으로 만듭니다. 리스트, 딕셔너리, 집합 컴프리헨션은 한 줄로 컬렉션을 만드는 문법입니다: [x * 2 for x in xs], {k: v for k, v in pairs}, {x for x in xs}.

파이썬 제너레이터는 값을 필요할 때 하나씩 만들어 내며, 전체 순서열을 메모리에 담아 두지 않습니다. 두 가지 방법으로 만들 수 있습니다. yield 키워드를 쓰는 함수, 또는 괄호로 감싼 제너레이터 표현식입니다. 같은 이름의 '리스트 컴프리헨션', '딕셔너리 컴프리헨션', '집합 컴프리헨션'은 컬렉션을 한 줄로 만드는 문법입니다. 이 글에서는 yield가 어떻게 동작하는지, 제너레이터와 이터레이터가 어떻게 다른지, 그리고 컴프리헨션을 읽기 어려워지지 않게 쓰는 방법을 살펴봅니다.

이터레이터: for 반복문의 바탕

제너레이터를 이해하려면 먼저 이터레이터를 알아야 합니다. 이터러블(iterable)은 반복문으로 순회할 수 있는 객체입니다. 리스트, 문자열, 딕셔너리, 파일이 여기에 해당합니다. 이터레이터는 현재 위치를 기억하고, next()를 호출할 때마다 다음 원소를 돌려주는 객체입니다. 원소가 없어지면 StopIteration을 발생시킵니다.

nums = [10, 20, 30]
it = iter(nums)      # 리스트에서 이터레이터 얻기
print(next(it))      # 10
print(next(it))      # 20
print(next(it))      # 30
next(it)             # StopIteration

for 반복문이 하는 일이 바로 이것입니다. iter()를 호출한 뒤 StopIteration이 나올 때까지 next()를 호출합니다. 직접 이터레이터를 만들려면 __iter__와 __next__ 메서드를 가진 클래스를 작성하면 됩니다.

class Countdown:
    def __init__(self, start):
        self.current = start

    def __iter__(self):
        return self

    def __next__(self):
        if self.current <= 0:
            raise StopIteration
        value = self.current
        self.current -= 1
        return value

print(list(Countdown(3)))  # [3, 2, 1]

이 방식도 동작하지만 반복되는 코드가 많습니다. 제너레이터는 같은 일을 세 줄로 합니다.

파이썬 제너레이터란, 그리고 yield의 동작

제너레이터 함수는 본문에 yield가 있는 일반 함수입니다. 이 함수를 호출해도 본문은 실행되지 않고, 제너레이터 객체가 반환됩니다. next()가 호출될 때마다 본문은 다음 yield까지 실행되고, 값을 내보낸 뒤 지역 변수와 함께 멈춥니다. 다음 next()는 바로 그 자리에서 이어서 실행합니다.

def countdown(start):
    while start > 0:
        yield start
        start -= 1

gen = countdown(3)
print(gen)          # <generator object countdown at 0x...>
print(next(gen))    # 3
print(list(gen))    # [2, 1] — 멈췄던 자리에서 이어서 진행

함수 본문이 끝나거나 return에 도달하면 제너레이터는 스스로 StopIteration을 발생시키고, for 반복문은 조용히 끝납니다. return과 yield의 차이는 이것입니다. return은 함수를 끝내고 값 하나를 돌려주고, yield는 함수를 잠시 멈추고 값을 여러 번 내보낼 수 있습니다.

제너레이터와 이터레이터의 차이

모든 제너레이터는 이터레이터입니다. __iter__와 __next__를 가지고 있기 때문입니다. 하지만 모든 이터레이터가 제너레이터는 아닙니다. 차이는 만드는 방법과 기능에 있습니다.

이터레이터(클래스)제너레이터
만드는 방법__iter__와 __next__를 가진 클래스yield가 있는 함수 또는 (x for x in ...)
상태 관리직접 속성에 저장지역 변수에 자동으로 보존
StopIteration직접 발생시킴함수가 끝나면 자동으로 발생
추가 기능직접 구현한 만큼send(), throw(), close()

둘에게 공통된 중요한 성질은 한 번만 순회할 수 있다는 점입니다. 다 소진된 제너레이터를 다시 for로 돌리면 아무것도 나오지 않습니다. 새로 만들어야 합니다.

무한 제너레이터와 큰 파일 읽기

제너레이터가 반드시 끝나야 하는 것은 아닙니다. 무한한 수열도 필요한 만큼만 가져온다면 안전합니다.

from itertools import islice

def ids():
    n = 1
    while True:
        yield f"ID-{n:04d}"
        n += 1

print(list(islice(ids(), 3)))  # ['ID-0001', 'ID-0002', 'ID-0003']

가장 실용적인 사용 예는 큰 파일을 줄 단위로 처리하는 것입니다. 제너레이터들을 이어 붙여 파이프라인을 만들면, 메모리에는 언제나 한 줄만 올라옵니다. 파일 전체가 올라오지 않습니다.

def read_lines(path):
    with open(path, encoding="utf-8") as f:
        for line in f:
            yield line.rstrip("\n")

def only_errors(lines):
    for line in lines:
        if "ERROR" in line:
            yield line

for line in only_errors(read_lines("app.log")):
    print(line)

yield from과 send

yield from은 다른 이터러블의 모든 값을 그대로 전달합니다. 중첩된 반복문을 줄여 줍니다.

def flatten(groups):
    for g in groups:
        yield from g

print(list(flatten([[1, 2], [3], [4, 5]])))  # [1, 2, 3, 4, 5]

메서드 send()는 제너레이터 안으로 값을 보냅니다. 보낸 값은 yield 표현식의 결과가 됩니다. 첫 send 전에는 next()로 제너레이터를 시작해야 합니다. 예를 들어 평균을 누적하는 제너레이터는 이렇게 씁니다.

def averager():
    total, count = 0, 0
    avg = None
    while True:
        value = yield avg
        total += value
        count += 1
        avg = total / count

a = averager()
next(a)            # 첫 yield까지 진행
print(a.send(10))  # 10.0
print(a.send(20))  # 15.0

실무 코드에서 send는 드물게 쓰입니다. 하지만 면접에서 자주 묻는 주제입니다.

range는 제너레이터인가

아닙니다. range는 지연 평가되는 수열입니다. 숫자를 모두 저장하지 않지만, 인덱싱, len(), in 검사를 지원하고, 여러 번 순회할 수 있습니다. 제너레이터는 이런 기능이 없습니다.

r = range(5)
print(r[2], len(r), 3 in r)  # 2 5 True
print(list(r), list(r))      # 두 번 모두 [0, 1, 2, 3, 4]

리스트 컴프리헨션

리스트 컴프리헨션은 [표현식 for x in 컬렉션] 형태입니다. 새 리스트를 만들며 원본은 바꾸지 않습니다. 왼쪽에서 오른쪽으로 읽으면 됩니다. 먼저 '무엇을 넣을지', 그다음 '어디서 가져올지'입니다.

amounts = [100, 250, 400]

# 일반 반복문
with_markup = []
for a in amounts:
    with_markup.append(a * 1.2)

# 컴프리헨션으로 같은 결과
with_markup = [a * 1.2 for a in amounts]
print(with_markup)  # [120.0, 300.0, 480.0]

이름에 '제너레이터'가 들어 있지만, 리스트 컴프리헨션의 결과는 일반 리스트입니다. 모든 원소가 한꺼번에 계산됩니다.

if와 if else가 붙은 리스트 컴프리헨션

여기서 가장 많이 헷갈립니다. 필터 조건 if는 뒤에 붙고, 원소를 걸러냅니다. 선택 조건(삼항 연산자) if else는 앞에 붙고, 원소를 두 값 중 하나로 바꿉니다. 원소를 버리지는 않습니다.

amounts = [100, 250, 400]

big = [a for a in amounts if a > 200]                   # [250, 400]
labels = ["많음" if a > 200 else "적음" for a in amounts]
# ['적음', '많음', '많음']

# 필터와 선택을 함께 쓰기
odd_marks = ["짝" if n % 2 == 0 else "홀" for n in range(6) if n > 2]
# ['홀', '짝', '홀']

[a for a in amounts if a > 200 else 0]처럼 쓰면 문법 오류입니다. else는 앞쪽 조건에만 올 수 있습니다.

중첩 컴프리헨션과 2차원 리스트

두 번의 for는 중첩된 반복문과 같은 순서로, 위에서 아래로 씁니다. 리스트의 리스트를 펼칠 때 순서를 헷갈리지 않게 해 줍니다.

groups = [[1, 2], [3], [4, 5]]
flat = [x for g in groups for x in g]
# 같은 의미:
# for g in groups:
#     for x in g:
#         flat.append(x)
print(flat)  # [1, 2, 3, 4, 5]

2차원 리스트(행렬)는 컴프리헨션 안에 컴프리헨션을 넣어 만듭니다. 흔한 실수는 리스트에 곱셈을 하는 것입니다. 이렇게 하면 모든 행이 같은 객체가 됩니다.

bad = [[0] * 3] * 2
bad[0][0] = 1
print(bad)   # [[1, 0, 0], [1, 0, 0]] — 두 행이 모두 바뀌었습니다

grid = [[0] * 3 for _ in range(2)]
grid[0][0] = 1
print(grid)  # [[1, 0, 0], [0, 0, 0]]

# 전치(행과 열 바꾸기)
m = [[1, 2, 3], [4, 5, 6]]
print([[row[i] for row in m] for i in range(3)])  # [[1, 4], [2, 5], [3, 6]]

딕셔너리와 집합 컴프리헨션

같은 방식이 다른 컬렉션에도 적용됩니다. 콜론이 있는 중괄호는 딕셔너리(dict comprehension), 콜론이 없는 중괄호는 집합(set comprehension)을 만듭니다.

spent = [("식비", 500), ("택시", 300), ("식비", 200)]

categories = {cat for cat, _ in spent}
print(categories)  # {'식비', '택시'} — 집합은 순서를 보장하지 않습니다

last = {cat: amount for cat, amount in spent}
print(last)        # {'식비': 200, '택시': 300}

squares = {n: n ** 2 for n in range(4)}  # {0: 0, 1: 1, 2: 4, 3: 9}
swapped = {v: k for k, v in squares.items()}

last를 보세요. 같은 키가 반복되면 마지막 값이 남아서 500이 사라졌습니다. 합계를 내야 한다면 컴프리헨션은 맞지 않습니다. 반복문을 쓰거나 collections.Counter, defaultdict를 쓰세요.

튜플 컴프리헨션은 없나요

파이썬에는 튜플 컴프리헨션이 따로 없습니다. 괄호는 제너레이터 표현식이 되고, 튜플은 그것을 tuple()에 넘겨서 만듭니다.

t = tuple(n * 2 for n in range(3))  # (0, 2, 4)

제너레이터 표현식: 저장하지 않고 하나씩 내보내기

대괄호를 소괄호로 바꾸면 제너레이터 표현식이 됩니다. 값을 미리 계산하지 않고 하나씩 내보냅니다. 백만 개의 원소를 다룰 때 메모리 차이는 매우 큽니다.

import sys

as_list = [n for n in range(1_000_000)]
as_gen = (n for n in range(1_000_000))
print(sys.getsizeof(as_list))  # 약 8MB
print(sys.getsizeof(as_gen))   # 수백 바이트

제너레이터 표현식은 대부분 집계 함수 안에서 쓰입니다. 인수가 제너레이터 하나뿐이라면 추가 괄호를 생략할 수 있습니다. any()와 all()은 답이 정해지는 순간 멈추고, 나머지는 순회하지 않습니다.

spent = [("식비", 500), ("택시", 300), ("식비", 200)]

total = sum(a for _, a in spent)            # 1000
has_big = any(a > 400 for _, a in spent)    # True, 500에서 바로 멈춥니다
biggest = max(spent, key=lambda p: p[1])    # ('식비', 500)

선택 기준은 간단합니다. 결과를 여러 번 쓰거나, 인덱싱이나 len()이 필요하면 리스트를 씁니다. 한 번만 순회하며 계산하면 제너레이터를 씁니다.

스코프: 컴프리헨션 변수는 밖으로 새지 않습니다

숙련된 개발자도 헷갈리는 부분입니다.

x = 10
squares = [x for x in range(3)]
print(x)  # 10

컴프리헨션 안의 반복 변수는 자기만의 스코프에 있으며, 바깥의 같은 이름 변수를 덮어쓰지 않습니다(파이썬 3 기준. 파이썬 2에서는 달랐습니다). 일반 for 반복문은 끝난 뒤에도 x에 마지막 값인 2를 남깁니다. Python 3.12부터는 컴프리헨션이 바깥 함수에 직접 포함되어 더 빨라졌지만(PEP 709), 변수 가시성 규칙은 그대로입니다.

Python 3.15의 * 와 ** 언패킹 (PEP 798)

함수 호출과 리스트 리터럴에서는 별표 언패킹을 오래전부터 쓸 수 있었지만, 컴프리헨션 안에서는 SyntaxError였습니다. Python 3.15에서 이 제한이 풀렸습니다. *원소는 '이 원소의 모든 값을 여기에 넣어라'라는 뜻입니다. 리스트, 집합, 제너레이터에서 동작하며, 비동기 버전도 포함됩니다.

# Python 3.15 이상
groups = [[1, 2], [3], [4, 5]]
flat = [*g for g in groups]        # [1, 2, 3, 4, 5]
uniq = {*g for g in groups}        # 집합
lazy = (*g for g in groups)        # 지연 제너레이터

이중 별표는 딕셔너리에 같은 방식으로 적용됩니다. 기본값 → 파일 → 플래그 순서로 여러 단계의 설정을 합칠 때 편리합니다. 각 단계는 앞 단계의 키를 덮어씁니다.

# Python 3.15 이상
layers = [{"debug": False, "port": 80}, {"port": 8080}, {"debug": True}]
config = {**layer for layer in layers}
# {'debug': True, 'port': 8080}

강의에서 주의하라고 한 점이 두 가지 있습니다. 조건식을 괄호 없이 별표와 함께 쓰면 문법 오류입니다. 예를 들어 [*w if w else 0 for w in weeks]는 안 되며, 조건식을 괄호로 감싸야 합니다. 그리고 이 코드는 3.14 이하에서는 실행되지 않습니다. 이전 버전도 지원해야 하는 라이브러리라면 이중 for나 itertools.chain.from_iterable을 쓰세요.

비동기 제너레이터

async def 함수 안에 yield가 있으면 비동기 제너레이터가 됩니다. 이것은 async for로 순회하며, 내부에서는 await로 네트워크나 디스크를 기다릴 수 있습니다. 비동기 컴프리헨션도 있습니다.

import asyncio

async def ticks(n):
    for i in range(n):
        await asyncio.sleep(0.1)
        yield i

async def main():
    result = [i async for i in ticks(3)]
    print(result)  # [0, 1, 2]

asyncio.run(main())

자주 하는 실수

  • 3중 컴프리헨션. for 두 개, if 두 개, 삼항 연산자를 한 줄에 넣으면 일주일 뒤에는 아무도 읽지 못합니다. 한 줄에 들어가지 않으면 평범한 반복문을 쓰세요. PEP 8도 짧음보다 읽기 쉬움을 우선합니다.
  • 제너레이터를 두 번 순회. 두 번째 sum(gen)은 0을 반환합니다. 제너레이터는 이미 소진되었기 때문입니다.
  • 부수 효과를 위한 컴프리헨션. [print(x) for x in xs]는 None으로 이루어진 쓸모없는 리스트를 만듭니다. 동작을 실행하려면 for 반복문을 쓰세요.
  • 제너레이터 표현식에 인덱스 사용. (x for x in xs)[0]은 TypeError입니다. 제너레이터에는 인덱스가 없습니다.

연습 문제

  1. 무한히 피보나치 수를 만드는 제너레이터 fib()를 작성하고, islice로 처음 10개를 가져오세요.
  2. 세 글자보다 긴 단어에 대해 '단어 → 길이' 딕셔너리를 한 줄로 만드세요.
  3. 리스트를 만들지 않고, 백만 이하 짝수의 제곱합을 구하세요.
  4. 리스트를 n개씩 잘라 내는 제너레이터 chunks(seq, n)을 작성하세요.

정리

이터레이터는 next()로 원소를 하나씩 내줍니다. 제너레이터는 이터레이터를 만드는 가장 간단한 방법으로, yield가 있는 함수나 괄호 표현식으로 만듭니다. 리스트, 딕셔너리, 집합 컴프리헨션은 컬렉션을 한 줄로 만듭니다. 필터 if는 뒤에, 선택 if else는 앞에 둡니다. 메모리를 아끼거나 데이터 흐름을 한 번 처리할 때는 제너레이터를, 결과를 여러 번 써야 할 때는 리스트를 고르세요. 컴프리헨션, 제너레이터, 그리고 Python 3.15의 새 언패킹은 지출 관리기 예제로 강의에서 실습합니다.

자주 묻는 질문

제너레이터 함수에서 return을 써도 되나요?

네. 값 없이 return하면 제너레이터가 끝납니다. 값과 함께 return하면 그 값은 StopIteration의 value 속성에 담기며, yield from 표현식이 그 값을 받습니다.

제너레이터를 리스트로 바꾸려면 어떻게 하나요?

list(gen)으로 감싸면 됩니다. 이후 제너레이터는 소진되며, 모든 값이 메모리에 올라갑니다.

리스트 컴프리헨션과 append를 쓰는 반복문 중 어느 쪽이 빠른가요?

보통 컴프리헨션이 조금 빠릅니다. 매번 append 메서드를 호출하지 않기 때문입니다. 다만 차이는 대개 결정적이지 않으니 읽기 쉬운 쪽을 고르세요.

객체가 제너레이터인지 어떻게 확인하나요?

객체는 inspect.isgenerator(obj), 함수는 inspect.isgeneratorfunction(f)로 확인합니다. 이터레이터인지는 isinstance(obj, collections.abc.Iterator)로 확인할 수 있습니다.

제너레이터 표현식과 리스트 컴프리헨션은 무엇이 다른가요?

대괄호 컴프리헨션은 전체 리스트를 메모리에 바로 만들고, 소괄호 제너레이터 표현식은 요청할 때마다 원소를 계산하며 한 번만 순회할 수 있습니다.

영상으로 이 주제 배우기

이 주제는 “Python 3.15: от новичка до профи” 강의에서 실습과 함께 자세히 다룹니다.

강의로 이동