35. 반복이 동작하는 방식: 이터러블과 이터레이터
이 책 전반에 걸쳐 여러분은 for 반복문(loop)을 사용해 리스트, 문자열, 딕셔너리, 그리고 다른 컬렉션을 반복(iteration)해 왔습니다. 여러분은 for item in my_list: 같은 코드를 수없이 작성해 봤을 것입니다. 그런데 Python이 for 반복문을 실행할 때 실제로는 내부에서 어떤 일이 일어날까요? Python은 서로 다른 타입의 컬렉션을 어떻게 순서대로 진행(step)해야 하는지 어떻게 알까요?
이 장에서는 for 반복문이 동작하게 만드는 메커니즘인 Python의 반복 프로토콜(iteration protocol)을 살펴봅니다. 여러분은 이터러블(iterable)(반복할 수 있는 객체)과 이터레이터(iterator)(실제로 값들을 하나씩 진행하며 꺼내는 객체)에 대해 배우게 됩니다. 이 둘의 차이를 이해하면 Python이 어떻게 동작하는지에 대한 지식이 깊어지고, 36장에서 제너레이터(generator)를 다루기 위한 준비가 됩니다.
35.1) 객체가 이터러블이라는 것의 의미
35.1.1) 이터러블성의 개념
이터러블(iterable)은 for 반복문으로 반복할 수 있는 모든 Python 객체입니다. 여기서 “반복할 수 있다”는 말은 Python이 객체로부터 항목을 한 번에 하나씩, 순서대로 가져올 수 있다는 뜻입니다.
여러분은 이미 많은 이터러블을 다뤄 봤습니다:
# 리스트는 이터러블입니다
numbers = [1, 2, 3, 4, 5]
for num in numbers:
print(num) # Output: 1, 2, 3, 4, 5 (on separate lines)
# 문자열은 이터러블입니다
text = "Python"
for char in text:
print(char) # Output: P, y, t, h, o, n (on separate lines)
# 딕셔너리는 이터러블입니다(기본적으로 키를 대상으로 반복)
student = {"name": "Alice", "age": 20, "grade": "A"}
for key in student:
print(key) # Output: name, age, grade (on separate lines)이 모든 객체들—리스트, 문자열, 딕셔너리, 튜플, 집합, range, 파일—은 Python의 iteration protocol(Python이 이들을 순회할 수 있게 하는 규칙 집합)을 지원하기 때문에 iterable입니다.
35.1.2) 객체를 이터러블로 만드는 것
객체가 이터러블이 되려면 __iter__()라는 특별한 메서드를 구현해야 합니다. 이 메서드는 이터레이터(iterator) 객체를 반환합니다. 아직 자세한 내용은 걱정하지 마세요—다음 섹션에서 이터레이터를 살펴보겠습니다.
내장 함수 iter()를 사용해 객체로부터 이터레이터를 얻어보면, 객체가 이터러블인지 확인할 수 있습니다:
# 객체가 이터러블인지 테스트하기
numbers = [1, 2, 3]
iterator = iter(numbers) # 동작함 - 리스트는 이터러블입니다
print(type(iterator)) # Output: <class 'list_iterator'>
text = "Hello"
iterator = iter(text) # 동작함 - 문자열은 이터러블입니다
print(type(iterator)) # Output: <class 'str_iterator'>
# 이터러블이 아닌 객체로 시도하기
value = 42
try:
iterator = iter(value) # 실패함 - 정수는 이터러블이 아닙니다
except TypeError as e:
print(f"Error: {e}") # Output: Error: 'int' object is not iterable이터러블 객체에 iter()를 호출하면 Python은 해당 객체의 __iter__() 메서드를 호출하고 이터레이터를 반환합니다. 객체에 이 메서드가 없으면 TypeError가 발생합니다.
35.1.3) 이터러블 vs 시퀀스
모든 이터러블이 시퀀스(sequence)는 아니라는 점을 이해하는 것이 중요합니다. 시퀀스는 인덱싱을 지원하고, 정해진 순서가 있는 특정한 타입의 이터러블입니다.
# 시퀀스는 인덱싱을 지원합니다
my_list = [10, 20, 30]
print(my_list[0]) # Output: 10
my_string = "Python"
print(my_string[2]) # Output: t
# 세트는 이터러블이지만 시퀀스가 아닙니다(인덱싱 없음, 순서 보장 없음)
my_set = {1, 2, 3}
for item in my_set:
print(item) # 동작함 - 세트는 이터러블입니다
# 하지만 인덱싱은 동작하지 않습니다
try:
print(my_set[0]) # 실패함 - 세트는 인덱싱을 지원하지 않습니다
except TypeError as e:
print(f"Error: {e}") # Output: Error: 'set' object is not subscriptable핵심 구분: 모든 시퀀스(리스트, 튜플, 문자열, range)는 이터러블이지만, 모든 이터러블이 시퀀스인 것은 아닙니다. 세트와 딕셔너리는 이터러블이지만 인덱싱을 지원하지 않기 때문에 시퀀스가 아닙니다.
35.1.4) 이터러블성이 중요한 이유
이터러블성을 이해하면 다음에 도움이 됩니다:
- 무엇을 반복할 수 있는지 알기: 어떤 이터러블이든
for반복문에서 동작합니다 - 에러 메시지 이해하기: "object is not iterable"은
for반복문에서 사용할 수 없다는 뜻입니다 - 컴프리헨션(comprehension) 사용하기: 리스트/세트/딕셔너리 컴프리헨션은 어떤 이터러블과도 동작합니다
- 내장 함수 활용하기:
sum(),max(),min(),sorted()같은 많은 내장 함수는 어떤 이터러블이든 받을 수 있습니다
# 이 모든 것은 이터러블을 받기 때문에 동작합니다
numbers = [1, 2, 3, 4, 5]
print(sum(numbers)) # Output: 15
text = "Python"
print(max(text)) # Output: y (highest alphabetically)
# 세트에서도 동작합니다
unique_values = {10, 5, 20, 15}
print(sorted(unique_values)) # Output: [5, 10, 15, 20]35.2) Python에서 흔히 보는 이터레이터(파일, range, 딕셔너리 등)
35.2.1) 이터레이터란 무엇인가
이터레이터(iterator)는 데이터 스트림을 나타내는 객체입니다. 다음 항목을 요청할 때마다 한 번에 하나의 값을 반환합니다. 이터레이터가 모든 값을 반환하고 나면 소진(exhausted)되어 재사용할 수 없습니다.
이터레이터를 책의 책갈피처럼 생각해 보세요:
- 시퀀스에서 현재 위치를 기억합니다
- 다음 항목을 요청할 수 있습니다
- 끝에 도달하면, 새 이터레이터를 만들지 않는 한 되돌아갈 수 없습니다
이터러블과 이터레이터의 핵심 차이는 다음과 같습니다:
- 이터러블(iterable)은 반복할 수 있는 대상입니다(예: 리스트)
- 이터레이터(iterator)는 실제로 반복을 수행하는 객체입니다(리스트를 따라가며 값을 꺼내는 메커니즘)
# 리스트는 이터러블입니다
numbers = [1, 2, 3]
# 이터러블에서 이터레이터 얻기
iterator = iter(numbers)
# 이터레이터는 별도의 객체입니다
print(type(numbers)) # Output: <class 'list'>
print(type(iterator)) # Output: <class 'list_iterator'>35.2.2) for 반복문에서의 이터레이터
for 반복문을 작성하면 Python은 내부적으로 자동으로 이터레이터를 생성합니다:
numbers = [10, 20, 30]
# 여러분이 작성하는 코드:
for num in numbers:
print(num)
# Python이 내부적으로(개념적으로) 하는 일:
# 1. iter(numbers)를 호출해 이터레이터를 얻습니다
# 2. 이터레이터에 next()를 반복 호출합니다
# 3. 이터레이터가 StopIteration을 발생시키면 멈춥니다이를 명시적으로 작성하면 다음과 같습니다:
numbers = [10, 20, 30]
# 수동 반복(for가 자동으로 하는 일)
iterator = iter(numbers)
try:
print(next(iterator)) # Output: 10
print(next(iterator)) # Output: 20
print(next(iterator)) # Output: 30
print(next(iterator)) # Would raise StopIteration
except StopIteration:
print("No more items") # Output: No more itemsfor 반복문은 StopIteration 예외를 자동으로 처리하기 때문에, 일반적인 코드에서는 이를 볼 일이 없습니다.
35.2.3) 이터레이터로서의 파일 객체
파일 객체는 이터레이터의 훌륭한 예입니다. 파일을 반복하면 한 번에 한 줄씩 읽습니다:
# 샘플 파일 만들기
with open("students.txt", "w") as file:
file.write("Alice\n")
file.write("Bob\n")
file.write("Charlie\n")
# 파일을 한 줄씩 읽기
with open("students.txt", "r") as file:
for line in file:
print(line.strip()) # Output: Alice, Bob, Charlie (on separate lines)파일 객체는 이터러블이면서 동시에 이터레이터입니다. 파일 객체에 iter()를 호출하면 자기 자신을 반환합니다:
with open("students.txt", "r") as file:
iterator = iter(file)
print(file is iterator) # Output: True (same object)
# 줄을 수동으로 읽기
print(next(iterator)) # Output: Alice
print(next(iterator)) # Output: Bob
print(next(iterator)) # Output: Charlie이는 Python이 파일 전체를 메모리에 올리지 않고, 요청할 때마다 한 줄씩 읽기 때문에 메모리 효율적입니다.
35.2.4) 이터레이터로서의 range 객체
range 객체는 필요할 때 숫자를 생성하는 이터러블입니다:
# range는 이터러블입니다
numbers = range(1, 4)
print(type(numbers)) # Output: <class 'range'>
# range에서 이터레이터 얻기
iterator = iter(numbers)
print(type(iterator)) # Output: <class 'range_iterator'>
# 이터레이터 사용하기
print(next(iterator)) # Output: 1
print(next(iterator)) # Output: 2
print(next(iterator)) # Output: 3range는 모든 숫자를 메모리에 저장하지 않고 요청될 때 각 숫자를 계산하기 때문에 메모리 효율적입니다:
# 이 range는 100만 개의 숫자를 나타내지만 메모리는 최소로 사용합니다
large_range = range(1000000)
print(type(large_range)) # Output: <class 'range'>
# 이터레이터 얻기
iterator = iter(large_range)
print(next(iterator)) # Output: 0
print(next(iterator)) # Output: 1
# ... 100만 개 값에 대해 계속할 수 있습니다35.2.5) 딕셔너리 이터레이터
딕셔너리는 키, 값, 아이템에 대해 서로 다른 이터레이터를 제공합니다:
student = {"name": "Alice", "age": 20, "grade": "A"}
# 키를 대상으로 반복(기본 동작)
for key in student:
print(key) # Output: name, age, grade (on separate lines)
# 키 이터레이터를 명시적으로 얻기
keys_iterator = iter(student.keys())
print(next(keys_iterator)) # Output: name
print(next(keys_iterator)) # Output: age
# 값을 대상으로 반복
values_iterator = iter(student.values())
print(next(values_iterator)) # Output: Alice
print(next(values_iterator)) # Output: 20
# 아이템(키-값 쌍)을 대상으로 반복
items_iterator = iter(student.items())
print(next(items_iterator)) # Output: ('name', 'Alice')
print(next(items_iterator)) # Output: ('age', 20)35.2.6) 이터레이터는 소진됩니다
이터레이터의 중요한 성질은 한 번만 사용할 수 있다는 것입니다. 한 번 소진되면 초기화되지 않습니다:
numbers = [1, 2, 3]
iterator = iter(numbers)
# 이터레이터를 처음 한 번 통과하기
print(next(iterator)) # Output: 1
print(next(iterator)) # Output: 2
print(next(iterator)) # Output: 3
# 이제 이터레이터는 소진되었습니다
try:
print(next(iterator)) # Raises StopIteration
except StopIteration:
print("Iterator exhausted") # Output: Iterator exhausted
# 다시 반복하려면 새 이터레이터를 만듭니다
iterator = iter(numbers)
print(next(iterator)) # Output: 1 (fresh start)이는 이터러블 자체와는 다릅니다. 이터러블은 여러 번 반복할 수 있습니다:
numbers = [1, 2, 3]
# 첫 번째 반복
for num in numbers:
print(num) # Output: 1, 2, 3
# 두 번째 반복(문제없음 - 새 이터레이터를 만듭니다)
for num in numbers:
print(num) # Output: 1, 2, 335.3) iter()와 next()로 이터러블을 한 단계씩 진행하기
35.3.1) iter() 함수
iter() 함수는 이터러블을 받아 이터레이터를 반환합니다. 이것이 반복 프로토콜의 첫 단계입니다:
# 서로 다른 이터러블에서 이터레이터 만들기
numbers = [10, 20, 30]
iterator = iter(numbers)
print(type(iterator)) # Output: <class 'list_iterator'>
text = "Hi"
text_iterator = iter(text)
print(type(text_iterator)) # Output: <class 'str_iterator'>
my_set = {1, 2, 3}
set_iterator = iter(my_set)
print(type(set_iterator)) # Output: <class 'set_iterator'>각 이터러블 타입은 자신만의 특화된 이터레이터 타입을 반환하지만, 동작 방식은 모두 같습니다—next()를 호출해 다음 값을 얻습니다.
35.3.2) next() 함수
next() 함수는 이터레이터에서 다음 항목을 가져옵니다. 더 이상 항목이 없으면 StopIteration을 발생시킵니다:
colors = ["red", "green", "blue"]
iterator = iter(colors)
# 한 번에 하나씩 항목 가져오기
print(next(iterator)) # Output: red
print(next(iterator)) # Output: green
print(next(iterator)) # Output: blue
# 더 이상 항목이 없음
try:
print(next(iterator)) # Raises StopIteration
except StopIteration:
print("No more colors") # Output: No more colors35.3.3) next()에 기본값 제공하기
next()에 두 번째 인수로 기본값을 제공할 수 있습니다. Iterator가 소진되면 StopIteration 예외를 발생시키는 대신, next()는 지정한 기본값을 반환합니다:
numbers = [1, 2, 3]
iterator = iter(numbers)
print(next(iterator)) # Output: 1
print(next(iterator)) # Output: 2
print(next(iterator)) # Output: 3
print(next(iterator, "Done")) # Output: Done (default value, no exception)
print(next(iterator, "Done")) # Output: Done (still exhausted)이는 예외 처리를 하지 않고도 반복의 끝을 부드럽게 처리하고 싶을 때 유용합니다:
35.4) __iter__와 __next__로 커스텀 이터레이터 만들기
35.4.1) 커스텀 이터레이터를 만드는 이유
Python의 내장 이터러블(리스트, 문자열, 파일)은 대부분의 일반적인 경우를 커버합니다. 하지만 때로는 특수한 동작을 위해 자신만의 이터러블 객체를 만들어야 합니다:
- 커스텀 로직으로 시퀀스 생성하기
- 직접 설계한 데이터 구조를 반복하기
- 대규모 데이터셋을 메모리 효율적으로 반복하기
- 지연 평가(lazy evaluation) 구현하기(필요할 때만 값 계산)
커스텀 이터레이터를 만들려면 __iter__()와 __next__()라는 두 개의 특별한 메서드를 구현해야 합니다.
35.4.2) 이터레이터 프로토콜
객체를 이터레이터로 만들려면 다음을 구현해야 합니다:
__iter__(): 이터레이터 객체 자기 자신을 반환합니다(보통self)__next__(): 시퀀스의 다음 값을 반환하거나, 끝나면StopIteration을 발생시킵니다
class SimpleCounter:
"""start부터 end까지 세는 이터레이터입니다."""
def __init__(self, start, end):
self.current = start
self.end = end
def __iter__(self):
"""이터레이터 객체(self)를 반환합니다."""
return self
def __next__(self):
"""다음 값을 반환하거나 StopIteration을 발생시킵니다."""
if self.current > self.end:
raise StopIteration
value = self.current
self.current += 1
return value
# 커스텀 이터레이터 사용하기
counter = SimpleCounter(1, 5)
for num in counter:
print(num)
# Output: 1
# Output: 2
# Output: 3
# Output: 4
# Output: 5어떤 일이 일어나는지 분해해 봅시다:
for반복문은iter(counter)를 호출하고, 이는counter.__iter__()를 호출해counter자체를 돌려받습니다- 반복문은
next(counter)를 반복 호출하며, 이는counter.__next__()를 호출합니다 __next__()가 호출될 때마다 다음 숫자를 반환하고current를 증가시킵니다current > end가 되면__next__()가StopIteration을 발생시키고 반복문이 멈춥니다
35.4.3) 커스텀 이터레이터의 수동 사용
커스텀 이터레이터도 iter()와 next()로 수동으로 사용할 수 있습니다:
counter = SimpleCounter(10, 13)
# 이터레이터 얻기(자기 자신을 반환)
iterator = iter(counter)
print(iterator is counter) # Output: True
# 값을 수동으로 얻기
print(next(iterator)) # Output: 10
print(next(iterator)) # Output: 11
print(next(iterator)) # Output: 12
print(next(iterator)) # Output: 13
# 이제 소진됨
try:
print(next(iterator))
except StopIteration:
print("Counter exhausted") # Output: Counter exhausted35.4.4) 이터레이터는 소진됩니다(다시 보기)
이터레이터는 한 번만 사용할 수 있다는 점을 기억하세요:
counter = SimpleCounter(1, 3)
# 첫 번째 반복
for num in counter:
print(num) # Output: 1, 2, 3
# 두 번째 반복(동작하지 않음 - 이터레이터가 소진됨)
for num in counter:
print(num) # Nothing printed - iterator is already exhausted다시 반복하려면 새 인스턴스를 만들어야 합니다:
# 반복마다 새 counter 만들기
for num in SimpleCounter(1, 3):
print(num) # Output: 1, 2, 3
for num in SimpleCounter(1, 3):
print(num) # Output: 1, 2, 3 (new iterator)35.4.5) (이터레이터가 아니라) 이터러블 클래스 만들기
종종 여러분은 이터러블이면서, 매번 새 이터레이터를 만들어 주는 클래스를 원합니다. 이를 위해 이터러블과 이터레이터를 분리합니다:
class CounterIterable:
"""매번 새 카운터 이터레이터를 만드는 이터러블입니다."""
def __init__(self, start, end):
self.start = start
self.end = end
def __iter__(self):
"""매번 새 이터레이터를 반환합니다."""
return CounterIterator(self.start, self.end)
class CounterIterator:
"""실제로 카운팅을 수행하는 이터레이터입니다."""
def __init__(self, start, end):
self.current = start
self.end = end
def __iter__(self):
return self
def __next__(self):
if self.current > self.end:
raise StopIteration
value = self.current
self.current += 1
return value
# 이제 여러 번 반복할 수 있습니다
counter = CounterIterable(1, 3)
# 첫 번째 반복
for num in counter:
print(num) # Output: 1, 2, 3
# 두 번째 반복(__iter__가 새 이터레이터를 만들기 때문에 동작)
for num in counter:
print(num) # Output: 1, 2, 3이 패턴은 관심사를 분리합니다:
CounterIterable는 이터러블로서—이터레이터를 만드는 방법을 압니다CounterIterator는 이터레이터로서—값을 어떻게 진행하며 꺼낼지 압니다
35.4.6) 실용 예제: 커스텀 데이터 구조 반복하기
커스텀 데이터 구조를 위한 이터레이터를 만들어 봅시다—간단한 재생목록(playlist)입니다:
class Playlist:
"""반복 가능한 음악 재생목록입니다."""
def __init__(self):
self.songs = []
def add_song(self, title, artist):
"""재생목록에 곡을 추가합니다."""
self.songs.append({"title": title, "artist": artist})
def __iter__(self):
"""재생목록을 위한 이터레이터를 반환합니다."""
return PlaylistIterator(self.songs)
class PlaylistIterator:
"""재생목록의 곡들을 하나씩 진행하기 위한 이터레이터입니다."""
def __init__(self, songs):
self.songs = songs
self.index = 0
def __iter__(self):
return self
def __next__(self):
if self.index >= len(self.songs):
raise StopIteration
song = self.songs[self.index]
self.index += 1
return song
# 재생목록 사용하기
playlist = Playlist()
playlist.add_song("Imagine", "John Lennon")
playlist.add_song("Bohemian Rhapsody", "Queen")
playlist.add_song("Hotel California", "Eagles")
# 곡을 반복하기
print("Now playing:")
for song in playlist:
print(f" {song['title']} by {song['artist']}")
# Output: Now playing:
# Output: Imagine by John Lennon
# Output: Bohemian Rhapsody by Queen
# Output: Hotel California by Eagles
# 다시 반복 가능(새 이터레이터를 만듭니다)
print("\nReplay:")
for song in playlist:
print(f" {song['title']}")
# Output: Replay:
# Output: Imagine
# Output: Bohemian Rhapsody
# Output: Hotel California35.4.7) 커스텀 이터레이터를 사용해야 할 때
다음과 같은 경우에 커스텀 이터레이터를 만드세요:
- 지연 평가가 필요할 때: 모든 값을 저장하지 않고 필요할 때 생성합니다
- 커스텀 데이터 구조가 있을 때:
for반복문과 함께 동작하도록 이터러블로 만듭니다 - 특별한 반복 로직이 필요할 때: 항목 건너뛰기, 값 변환, 복잡한 진행 규칙 구현 등
- 메모리 효율이 중요할 때: 큰 시퀀스를 저장하지 않고 생성합니다
하지만, 36장에서는 yield 키워드를 사용해 훨씬 간단하게 이터레이터를 만들 수 있는 제너레이터(generator)를 배우게 됩니다. 제너레이터는 보통 __iter__()와 __next__()를 직접 구현하는 것보다 더 간결하고 이해하기 쉬워서 선호됩니다.
커스텀 이터레이터를 만드는 방법을 이해하면, 비록 종종 제너레이터를 대신 사용하게 되더라도 Python의 반복 프로토콜이 어떻게 동작하는지에 대한 통찰을 얻을 수 있습니다. 여기서 배운 개념들—__iter__(), __next__(), StopIteration—은 다음 장에서 다룰 제너레이터와 다른 고급 반복 기법을 이해하는 데 기본이 됩니다.