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

URL: https://skillaria.top/ko/guides/python/python-generators

파이썬 제너레이터는 값을 한 번에 하나씩, 필요할 때만 만들어 내는 객체입니다. 함수 안에서 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:
        yield start
        start -= 1

gen = countdown(3)
print(gen)          #
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의 새 언패킹은 지출 관리기 예제로 강의에서 실습합니다.

## FAQ

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

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

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

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

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

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

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

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

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

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


---
Source: Skillaria.Top (https://skillaria.top)
