Python 강좌 : 제 41강 - 반복자
반복자(Iterator)
반복자(Iterator)란 반복 가능한(iterable) 형식의 멤버를 순차적으로 반환할 수 있는 객체를 의미합니다.
배열 등의 요솟값을 순차적으로 접근할 때 사용합니다.
배열에서 색인(index)으로 값을 접근할 때는 데이터 메모리 레이아웃에서 배열의 시작 주소값과 오프셋(offset) 값으로 배열의 요소에 접근합니다.
즉, 배열에 접근할 때에는 배열 요소의 크기와 개별 요소에 접근하는 방식 등을 모두 고려해 접근합니다.
하지만, 반복자를 사용하면 다음 값을 요청할 때마다 값을 하나씩 생성하거나 계산해 반환할 수 있습니다.
이를 느긋한 계산법(Lazy evaluation)이라 하며, 실제 값이 필요할 때까지 계산을 미루게 되므로 불필요한 계산과 메모리 사용량을 줄일 수 있습니다.
또한, 데이터 내부 구조나 접근 방식 등에 상관 없이 요솟값에 접근할 수 있습니다.
제 15강에서 사용한 for 반복문은 리스트, 문자열, 사전, 파일 등 종류가 다른 객체를 모두 같은 문법으로 순회합니다. 이것이 가능한 이유는 for 문이 객체의 내부 구조를 직접 다루지 않고, 객체에게 반복자를 요청한 뒤 “다음 값”만 계속 받아 오기 때문입니다.
즉, 반복자는 Python에서 “순서대로 하나씩 꺼내기”를 표현하는 공통 약속(프로토콜)입니다. sum(), list(), zip(), map(), in 연산자 등 많은 기능이 이 약속 위에서 동작합니다.
이번 강좌에서는 iter()와 next()로 반복자를 직접 다루는 방법, 반복자가 내부에 무엇을 저장하고 언제 소진되는지, for 문의 실제 동작, 반복자 클래스를 직접 구현하는 방법, 그리고 itertools 모듈을 다룹니다.
- Tip :
반복 가능한(iterable)객체는__iter__()메서드로 반복자를 만들어 줄 수 있는 객체이며(리스트, 문자열, 사전 등),반복자(iterator)는__iter__()와__next__()메서드를 모두 가지고 값을 하나씩 꺼내 주는 객체입니다.
반복자(Iterator) 사용하기
반복자는 iter() 함수로 만들고, next() 함수로 값을 하나씩 꺼냅니다.
for 문을 사용하면 이 과정이 자동으로 이루어지지만, next()를 직접 호출하면 원하는 시점에 원하는 개수만큼 값을 꺼낼 수 있습니다.
import numpy as np
_str = iter("1234")
_tuple = iter((1, 2, 3, 4))
_list = iter([1, 2, 3, 4])
_dict = iter({"a": 1, "b": 2, "c": 3, "d": 4})
_set = iter({1, 2, 3, 4})
_array = iter(np.array([[1, 2], [3, 4]]))
print(next(_str))
print(next(_str))
print(next(_str))- 결과
- 1
2
3
반복자(Iterator)는 iter() 함수에 반복 가능한(iterable) 객체를 전달해 생성할 수 있습니다.
문자열, 튜플, 리스트, 사전, 집합, Numpy 배열 등 반복 가능한 객체라면 모두 반복자로 만들 수 있으며, next() 함수로 다음 번째 요솟값을 참조할 수 있습니다.
예제에서는 문자열 "1234"로 만든 반복자에서 next()를 세 번 호출했으므로 1, 2, 3이 차례대로 출력되고, 아직 꺼내지 않은 4는 반복자 안에 남아 있습니다.
next()마다 다음 요솟값을 참조하게 되며, 마지막 요솟값 참조 이후에도 다음 값을 호출하는 경우엔 StopIteration 예외를 발생시킵니다.
- Tip :
next(반복자, 기본값)의 형태로 기본값을 전달하면, 더 이상 반환할 값이 없을 때StopIteration예외 대신 기본값을 반환합니다.
import numpy as np
print(list(iter({"a": 1, "b": 2})))
print(list(iter({3, 1, 2})))
print(list(iter(np.array([[1, 2], [3, 4]]))))- 결과
- [‘a’, ‘b’]
[1, 2, 3]
[array([1, 2]), array([3, 4])]
반복자가 무엇을 하나씩 꺼내는지는 원본 객체의 종류가 결정합니다.
사전의 반복자는 키를 꺼내며, 값이 필요하면 values(), 키와 값이 함께 필요하면 items()의 반복자를 사용합니다.
집합은 순서가 없는 자료형이므로, 반복자가 꺼내는 순서는 집합 내부의 저장 방식에 따라 정해집니다. 위 결과의 순서에 의존하는 코드를 작성해서는 안 됩니다.
2차원 Numpy 배열의 반복자는 원소 하나가 아니라 행(1차원 배열) 을 하나씩 꺼냅니다.
it = iter([10])
print(next(it))
print(next(it, "없음"))
try:
next(it)
except StopIteration as e:
print(type(e).__name__, e.args)- 결과
- 10
없음
StopIteration ()
요소가 하나뿐인 반복자에서 next()를 세 번 호출한 예제입니다.
첫 번째 호출은 10을 반환하고, 두 번째 호출은 더 이상 값이 없지만 기본값 "없음"을 전달했으므로 예외 대신 없음을 반환합니다.
세 번째 호출은 기본값이 없으므로 StopIteration 예외가 발생합니다. StopIteration은 오류라기보다 “값이 끝났다”는 신호로 사용되는 예외입니다.
반복자와 반복 가능한 객체의 차이
반복 가능한 객체와 반복자는 이름이 비슷하지만 역할이 다릅니다.
반복 가능한 객체는 데이터를 담고 있는 쪽이고, 반복자는 그 데이터를 어디까지 꺼냈는지 기억하며 하나씩 내어 주는 쪽입니다. 책과 책갈피의 관계로 생각하면 쉽습니다.
lst = [1, 2, 3]
print(iter(lst) is lst, hasattr(lst, "__next__"))
it = iter([1, 2])
print(iter(it) is it)
print(next(it), next(it))
try:
next(it)
except StopIteration:
print("StopIteration")- 결과
- False False
True
1 2
StopIteration
리스트는 반복 가능한 객체이지만 반복자는 아닙니다. 그러므로 __next__() 메서드가 없으며, iter(lst)를 호출할 때마다 새로운 반복자가 만들어집니다.
반면에 반복자에 iter()를 호출하면 자기 자신을 반환합니다.
반복자도 __iter__()를 가지고 있으므로 반복 가능한 객체이기도 합니다. 그래서 for 문에 리스트를 넣어도, 반복자를 넣어도 똑같이 동작합니다.
반복자는 한 번 끝까지 소비되면 다시 처음으로 돌아가지 않으므로, 다시 순회하려면 반복자를 새로 만들어야 합니다.
from operator import length_hint
lst = [10, 20, 30]
it = iter(lst)
print(type(it).__name__, length_hint(it))
print(next(it), length_hint(it))
lst.append(40)
print(next(it), length_hint(it))
print(list(it), length_hint(it))- 결과
- list_iterator 3
10 2
20 2
[30, 40] 0
반복자가 내부에 무엇을 저장하고 있는지 단계별로 확인하는 예제입니다. length_hint()는 반복자에 남은 요소의 개수를 알려 주는 함수로, 제 43강에서 자세히 다룹니다.
iter(lst)로 만든list_iterator는 리스트를 복사하지 않고, 원본 리스트에 대한 참조와 다음에 꺼낼 위치(0) 만 저장합니다. 남은 개수는 3입니다.next(it)가10을 반환하고 위치가 1로 이동하므로 남은 개수는 2가 됩니다.- 원본 리스트에
40을 추가한 뒤next(it)를 호출하면20이 반환됩니다. 반복자는 원본을 참조하고 있으므로 남은 개수는30,40의 2개입니다. list(it)는 남은 값[30, 40]을 모두 꺼내고, 반복자는 소진되어 남은 개수가 0이 됩니다.
즉, 반복자의 상태는 “무엇을(원본 참조) 어디까지(위치)” 꺼냈는지이며, 값은 next()가 호출되는 순간에 원본에서 읽어 옵니다.
- Tip : 반복 도중에 원본 리스트에 요소를 추가하거나 삭제하면, 위 예제처럼 반복 결과가 바뀌거나 요소를 건너뛸 수 있습니다. 반복 중에는 원본을 수정하지 않거나,
lst[:]처럼 복사본을 순회합니다.
it = iter([1, 2, 3])
print(sum(it), sum(it))
it = iter([1, 2, 3, 4])
print(2 in it, list(it))
m = map(str, [1, 2])
print(list(m), list(m))- 결과
- 6 0
True [3, 4]
[‘1’, ‘2’] []
반복자를 사용할 때 가장 자주 하는 실수는 이미 소진된 반복자를 다시 사용하는 것입니다.
첫 줄에서 첫 번째 sum(it)이 모든 값을 꺼내 6을 계산했으므로, 두 번째 sum(it)은 꺼낼 값이 없어 0을 반환합니다. 오류가 발생하지 않으므로 알아채기 어렵습니다.
두 번째 줄의 2 in it도 2를 찾을 때까지 값을 꺼내므로, 이후 list(it)에는 2 뒤에 남은 [3, 4]만 들어 있습니다.
세 번째 줄처럼 map(), filter(), zip(), enumerate() 등이 반환하는 객체도 반복자이므로 한 번만 순회할 수 있습니다.
- Tip : 결과를 여러 번 사용해야 한다면
list()로 변환해 저장해 두고 사용합니다.
반복문과 동시 사용
next()와 for 문은 같은 반복자를 함께 사용할 수 있습니다. 두 방법 모두 같은 위치 정보를 공유하므로, 한쪽에서 꺼낸 값은 다른 쪽에서 다시 나오지 않습니다.
이 성질을 이용하면 CSV 파일의 첫 줄(머리글)만 따로 꺼낸 뒤 나머지를 반복하는 것처럼, 앞부분을 먼저 처리하고 나머지를 반복하는 코드를 간단하게 작성할 수 있습니다.
_dict = iter({"a": 1, "b": 2, "c": 3, "d": 4})
print(next(_dict))
for i in _dict:
print(i)- 결과
- a
b
c
d
for 반복문은 내부적으로 iter()로 반복자를 얻은 다음, StopIteration 예외가 발생할 때까지 next()를 반복해서 호출합니다.
next() 함수로 요솟값에 먼저 접근한 다음 반복자에 반복문을 사용한다면, 이미 첫 번째 값을 꺼냈기 때문에 나머지 값부터 순차적으로 출력됩니다.
결과의 a는 print(next(_dict))가 출력한 값이고, b, c, d는 for 문이 출력한 값입니다.
반복문 이후에 next() 함수로 다음 값을 호출한다면, 더 이상 참조할 값이 없어 StopIteration 예외를 발생시킵니다.
it = iter([1, 2, 3])
while True:
try:
x = next(it)
except StopIteration:
break
print(x)- 결과
- 1
2
3
for x in [1, 2, 3]: print(x)가 실제로 하는 일을 while 문으로 풀어 쓴 코드입니다.
먼저 iter()로 반복자를 한 번 만들고, 매 회차마다 next()로 값을 꺼내 x에 대입한 뒤 본문을 실행합니다. StopIteration이 발생하면 예외를 조용히 처리하고 반복을 끝냅니다.
그러므로 for 문에서는 StopIteration 예외를 직접 볼 일이 없으며, 반복 가능한 객체라면 무엇이든 같은 방식으로 순회할 수 있습니다.
import io
f = io.StringIO("A\nB\n\nC\n")
for line in iter(f.readline, ""):
print(repr(line))- 결과
- ‘A\n’
‘B\n’
‘\n’
‘C\n’
iter() 함수는 iter(호출 가능한 객체, 종료값)의 형태로도 사용할 수 있습니다.
이 형태는 next()가 호출될 때마다 함수를 인수 없이 호출해 그 반환값을 내어 주고, 반환값이 종료값과 같아지면 반복을 멈춥니다.
예제의 io.StringIO는 문자열을 파일처럼 다루는 객체이며, readline()은 파일 끝에서 빈 문자열 ""을 반환합니다. 그러므로 파일 끝까지 한 줄씩 읽게 됩니다.
세 번째 줄의 '\n'은 내용이 없는 줄이지만 빈 문자열이 아니므로 반복이 멈추지 않습니다.
반복자(Iterator) 구현하기
직접 만든 클래스도 반복자 프로토콜을 따르면 for 문, list(), sum() 등에서 그대로 사용할 수 있습니다.
필요한 것은 두 개의 매직 메서드뿐이며, 상태(다음에 반환할 값)는 인스턴스 속성으로 저장합니다.
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
for n in Countdown(3):
print(n)- 결과
- 3
2
1
클래스에 __iter__()와 __next__() 메서드를 정의하면 직접 반복자를 구현할 수 있습니다. 이를 반복자 프로토콜(Iterator Protocol)이라 합니다.
__iter__()는 반복자 자신을 반환하며, __next__()는 다음 값을 반환하거나 더 이상 값이 없을 때 StopIteration 예외를 발생시킵니다.
for 문은 먼저 iter(Countdown(3))을 호출해 __iter__()가 반환한 자기 자신을 반복자로 사용합니다.
이후 매 회차마다 __next__()가 호출되며, self.current가 3 → 2 → 1 → 0으로 줄어드는 동안 3, 2, 1을 반환합니다. self.current가 0이 되면 StopIteration이 발생해 반복이 끝납니다.
- Tip : 반복자는
생성자(Generator)를 이용하면 더 간단하게 구현할 수 있습니다. 생성자는 제 42강에서 다룹니다.
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
c = Countdown(3)
print(list(c), list(c))- 결과
- [3, 2, 1] []
위의 Countdown은 자기 자신이 반복자이므로, 상태(self.current)가 인스턴스 하나에 저장됩니다.
첫 번째 list(c)가 값을 모두 꺼내면서 self.current가 0이 되었으므로, 두 번째 list(c)는 빈 리스트를 반환합니다. 리스트처럼 여러 번 순회할 수 있는 객체가 아닙니다.
class Countdown:
def __init__(self, start):
self.start = start
def __iter__(self):
return CountdownIterator(self.start)
class CountdownIterator:
def __init__(self, current):
self.current = current
def __iter__(self):
return self
def __next__(self):
if self.current <= 0:
raise StopIteration
value = self.current
self.current -= 1
return value
c = Countdown(3)
print(list(c), list(c))
it = iter(c)
print(next(it), list(c))- 결과
- [3, 2, 1] [3, 2, 1]
3 [3, 2, 1]
여러 번 순회할 수 있게 하려면 리스트처럼 반복 가능한 객체와 반복자를 별도의 클래스로 나눕니다.
Countdown은 시작값만 저장하고, __iter__()가 호출될 때마다 새로운 CountdownIterator를 만들어 반환합니다. 진행 상태(current)는 각 반복자가 따로 가집니다.
그러므로 list(c)를 두 번 호출해도 매번 새 반복자가 만들어져 [3, 2, 1]을 두 번 얻을 수 있습니다.
두 번째 줄처럼 반복자 it에서 3을 꺼낸 상태에서도, list(c)는 별도의 반복자를 사용하므로 영향을 받지 않습니다.
itertools 모듈
itertools 모듈은 반복자를 만들고 조합하는 함수들을 모아 둔 표준 라이브러리입니다.
모든 함수가 느긋한 계산법으로 동작하므로, 무한한 데이터나 매우 큰 데이터도 필요한 만큼만 계산할 수 있습니다.
from itertools import count
infinite = count()
print(next(infinite))
for i in infinite:
print(i)- 결과
- 0
1
2
…
8893
…
itertools 모듈의 count() 함수를 사용한다면, 크기가 무한한 형태의 반복자를 구현할 수 있습니다.
반복문을 종료하거나 프로세스를 직접 종료하지 않는다면 count가 무한히 증가합니다. 위 예제는 끝나지 않으므로 Ctrl + C 등으로 직접 중단해야 합니다.
count()는 모든 정수를 미리 만들어 두지 않고, 현재 값 하나만 저장해 두었다가 next()가 호출될 때마다 그 값을 반환하고 1을 더합니다. 그러므로 무한한 수열을 표현해도 메모리를 거의 사용하지 않습니다.
이를 통해, 무한히 반복하거나 매우 큰 횟수의 반복을 구현할 수 있습니다.
- Tip :
range()도 값을 미리 만들어 두지 않는 느긋한 객체이므로range(10 ** 100)처럼 큰 범위도 즉시 생성됩니다. 다만range()는 끝 값이 정해져 있어야 하며,10 ** 10000000000000000처럼 터무니없이 큰 정수는 정수 자체를 계산하는 데 큰 시간과 메모리가 필요해 사용할 수 없습니다.
from itertools import count, islice
print(list(islice(count(10, 5), 4)))- 결과
- [10, 15, 20, 25]
count(시작값, 간격)의 형태로 시작값과 증가 간격을 지정할 수 있습니다.
무한 반복자는 islice(반복자, 개수)로 필요한 개수만큼만 잘라서 사용하는 것이 안전합니다.
islice()는 리스트의 슬라이싱 [:4]과 비슷하지만, 반복자는 색인으로 접근할 수 없으므로 앞에서부터 값을 꺼내며 개수를 셉니다. islice(반복자, 시작, 끝, 간격)처럼 시작과 간격도 지정할 수 있습니다.
from itertools import count, takewhile
print(list(takewhile(lambda x: x < 20, count(1, 7))))- 결과
- [1, 8, 15]
개수가 아니라 조건으로 무한 반복자를 멈추려면 takewhile(조건 함수, 반복자)를 사용합니다.
count(1, 7)이 1, 8, 15, 22, ...를 만들어 내는 동안 조건 x < 20이 참인 값만 반환하고, 처음으로 조건이 거짓이 되는 22에서 반복을 멈춥니다.
from itertools import cycle, repeat, chain, islice
print(list(islice(cycle("AB"), 5)))
print(list(repeat(7, 3)))
print(list(chain([1, 2], (3,))))- 결과
- [‘A’, ‘B’, ‘A’, ‘B’, ‘A’]
[7, 7, 7]
[1, 2, 3]
cycle()은 반복 가능한 객체를 무한히 순환하며, repeat(값, 횟수)는 같은 값을 지정한 횟수만큼 반환합니다.
chain()은 여러 반복 가능한 객체를 하나로 이어서 순회합니다.
cycle("AB")도 끝이 없으므로 islice()로 5개만 꺼냈습니다. repeat()에 횟수를 생략하면 같은 값을 무한히 반환합니다.
chain()은 리스트를 +로 이어 붙이는 것과 결과는 같지만, 새 리스트를 만들지 않고 첫 번째 객체를 다 꺼낸 다음 두 번째 객체로 넘어가는 방식이므로 리스트와 튜플처럼 서로 다른 자료형도 이을 수 있습니다.
from itertools import pairwise, batched
print(list(pairwise([1, 4, 9, 16])))
print(list(batched("ABCDEFG", 3)))
try:
list(batched("ABCDEFG", 3, strict=True))
except ValueError as e:
print("ValueError:", e)- 결과
- [(1, 4), (4, 9), (9, 16)]
[(‘A’, ‘B’, ‘C’), (‘D’, ‘E’, ‘F’), (‘G’,)]
ValueError: batched(): incomplete batch
pairwise()는 Python 3.10 이상에서 사용할 수 있으며, 연속된 두 요소를 (앞, 뒤) 쌍으로 반환합니다.
batched(반복 가능한 객체, n)은 Python 3.12 이상에서 사용할 수 있으며, 요소를 n개씩 묶은 튜플로 반환합니다. 마지막 묶음은 n개보다 적을 수 있습니다.
Python 3.13 이상에서는 strict=True를 전달하면 마지막 묶음의 개수가 부족할 때 ValueError를 발생시킵니다.
pairwise()는 [4 - 1, 9 - 4, 16 - 9]처럼 이웃한 값의 차이를 계산할 때, batched()는 많은 데이터를 일정한 크기로 나눠 처리할 때 유용합니다.
- Tip :
itertools모듈의 함수들은 모두 반복자를 반환하므로, 결과를 한 번에 확인하려면list()로 변환합니다.
정리
| 이름 | 설명 | 반환값/특징 |
|---|---|---|
| iter(객체) | 반복 가능한 객체에서 반복자를 생성 | 반복자에 호출하면 자기 자신을 반환 |
| iter(함수, 종료값) | 함수의 반환값이 종료값이 될 때까지 반복 | 반복자 |
| next(반복자, 기본값) | 다음 값을 반환 | 값이 없으면 StopIteration 또는 기본값 |
| __iter__() | 반복자를 반환하는 메서드 | 반복 가능한 객체와 반복자 모두 정의 |
| __next__() | 다음 값을 반환하는 메서드 | 반복자만 정의 |
| count / cycle / repeat | 무한(또는 반복) 반복자 | islice, takewhile로 제한 |
| chain / pairwise / batched | 반복자를 잇기 / 쌍으로 묶기 / n개씩 묶기 | 반복자 |
- 반복자는 원본 참조와 현재 위치만 기억하며, 값은
next()가 호출될 때 하나씩 계산하거나 읽어 옵니다. - 반복자는 한 번만 순회할 수 있습니다. 여러 번 순회해야 한다면 리스트로 저장하거나, 반복 가능한 객체에서 반복자를 새로 만듭니다.
for문은iter()와next(),StopIteration처리를 자동으로 해 주는 문법입니다.
댓글 남기기