본문 바로가기

Python/고성능파이썬

[고성능파이썬] 이터레이터(iterator)와 제네레이터(generator)

반응형

🔊 해당 포스팅은 고성능 파이썬 2판 책 서적을 읽고 개인적인 학습 목적 하에 작성된 글입니다. 포스팅에서 사용되는 자료들은 책의 내용을 참고하되 본인이 직접 재구성한 자료임을 알립니다.

 

출처 : 한빛출판네트워크


이번 포스팅에서는 파이썬에서 반복적인 동작을 수행하는 데 많이 사용되는 iterator(이하 이터레이터)와 generator(이하 제네레이터)에 대해서 배워보도록 하자. 

1. iterator 와 generator 간의 관계

이터레이터와 제네레이터 간의 관계에 대해서는 항상 헷갈리는 것 같다. 이 두 개념 간의 차이점을 설명할 때 서로 동일한 수준의 비교는 적절하지 않은 것 같다. 동일한 수준의 비교라 하면 예를 들어, BMW와 Audi 라는 브랜드는 자동차 브랜드라는 동일한 수준을 갖고 있다고 할 수 있다. 하지만 이터레이터와 제네레이터는 이처럼 동일한 수준을 갖고 있지 않다. 두 개념 간의 관계를 명확히 정의해보기 위해서 예시 코드를 같이 함께 살펴보면서 파악해보자.

 

어떤 수를 인자로 주었을 때, 해당 길이만큼의 피보나치 수열을 생성하는 코드를 일반적인 리스트를 사용하는 방법과 제네레이터를 사용하는 방법 2가지 종류의 코드에 대해서 살펴보자. 먼저 일반적인 리스트를 사용하는 방법이다.

 

# 일반적인 list 사용
def fibonacci_list(n):
    numbers = []
    a, b = 0, 1
    while len(numbers) < n:
        numbers.append(a)
        a, b = b, a+b
    return numbers


for i in fibonacci_list(10000):
    print(i, end=' ')

 

다음은 제네레이터를 사용한 방법이다.

 

# generator를 사용
def fibonacci_gen(n):
    a, b = 0, 1
    while n:
        yield a
        a, b = b, a+b
        n -= 1

for i in fibonacci_gen(10000):
    print(i, end=' ')

 

$n=10000$ 이라고 가정해보자. 일반적인 리스트를 사용한 방법에서는 numbers 라는 빈리스트에 1만번 원소를 추가하는 과정을 수행할 것이다. 이 과정에서 큰 부하가 발생한다. 이렇게 1만개 길이의 배열을 미리 다 계산해놓고 해당 리스트를 반환한 후 리스트의 원소 하나씩을 출력한다.

 

반면에 제네레이터는 yield 키워드를 실행하는 순간 가장 먼저 함수는 yield 뒤의 값을 방출한다. 그리고 다른 값 요청(코드에서 for loop 가 수행될 때인 2,3,4,... 번째 피보나치 수열 원소 값을 요청)이 들어오면 이전 상태를 유지한 채로 실행을 재개하여 yield 키워드 뒤의 새로운 값을 방출한다. 이와 같은 과정을 1만번($n$) 만큼 반복하다가 함수가 끝나면 StopIteration 예외 상태를 발생시켜 생산(generate)할 값이 더이상 없음을 알린다.(예외 상태를 발생시킨다고 해서 에러가 발생하는 것은 아니다)

 

결과적으로, 두 함수는 같은 횟수만큼의 연산을 수행하지만 위 코드 예시의 경우 일반적인 리스트를 사용하는 방법이 메모리를 약 1만 배 이상 더 사용하게 된다.

 

이제 좀 더 세밀하게 일반적인 리스트를 사용한 방법에 대해 분석해보자. 제네레이터를 사용하지 않는 일반적인 리스트를 사용한 방법 즉, for loop를 사용할 때는 반드시 반복할 수 있는 객체가 필요하다. 이를 iterable한 객체라고도 한다. 즉, 반복할 수 있는 객체가 필요하다는 말은 for loop에 들어가기 전 루프 밖에서 이터레이터를 미리 생성해놓은 상태여야 한다는 것이다. 그리고 이 이터레이터를 생성하는 방법은 객체에 iter() 라는 함수를 씌워주면 된다. fibonacci_list를 호출하는 부분을 보면 fibonacci_list 함수를 호출한 뒤 return 되는 값은 numbers 라는 리스트이다. 그리고 이 리스트에 iter() 함수를 적용하여 이터레이터로 생성해 준 뒤 loop를 타기 시작한다.(여기서 fibonacci_list 함수가 반환하는 리스트라는 자료구조가 애초에 iter 라는 메소드가 기본적으로 구현되어 있기 때문에 별도로 iter() 함수를 명시적으로 호출하지는 않았다) 주목해야 하는 점은 fibonacci_list 함수 내부에서 numbers 라는 리스트를 새로 할당하고 1만번의 원소 추가 연산을 하는 등 미리 계산을 모두 다한 뒤에 이터레이터가 생성된다는 점이다.

 

반면에 제네레이터를 사용한 함수를 살펴보자. 제네레이터 함수가 호출되는 순간 반환되는 것에서 이미 이터레이터로 변형되는 제네레이터를 생성한다. 결국 제네레이터 함수가 반환하는 것이 이미 이터레이터이다. 따라서 fibonacci_gen 함수는 fibonacci_list 함수에 비해 리스트를 새로 할당하는 것도, 그 리스트에 넣을 원소를 계산하는 연산도 미리 해두지 않아도 되는 것이다.

 

물론 모든 상황에서 제네레이터가 리스트를 사용하는 방법보다 우월한 것은 아니다. 만약 생성한 피보나치 수열들의 원소들을 참조해야 하는 상황이라면? 그 때는 미리 계산해두는 리스트를 사용하는 리스트를 사용하는 방법이 더 적절할 수 있다. 따라서 상황에 따라서 적절한 옵션을 선택하는 것이 좋다.

 

또 한가지 제네레이터가 더 권장되는 케이스를 살펴보자. 바로 특정 배열의 길이를 구하는 사례다. 먼저 리스트는 list comprehension을 이용할 수 있다.

 

divisible_length = len([i for i in fibonacci_gen(100_000) if i % 3 == 0])
print(divisible_length)  # 25000

 

위와 같은 경우, 재네레이터 함수의 결과값을 리스트로 받기 때문에 여러가지 원소가 든 배열을 저장하기 위한 메모리가 확보되어야 한다. 필요한 것은 단지 25,000이라는 길이 값인데, 불필요하게 배열을 저장해놓은 메모리가 낭비되는 것이다. 

 

이를 제네레이터로 변경하면 다음과 같다.

 

divisible_length_gen = sum(1 for i in fibonacci_gen(100_000) if i % 3 == 0)
print(divisible_length_gen)

 

제네레이터는 리스트와 다르게 __len__ 이라는 속성이 없기 때문에 위처럼 조건에 해당하는 경우일 때마다 1을 매번 더해서 길이를 구하는 트릭을 사용했다. 이렇게 리스트를 활용하는 것 중에 제네레이터로 대체할 수 있는 케이스도 있음을 알아두자.

 

참고로 리스트, 튜플 등과 같은 시퀀스 자료구조에 적용할 수 있는 파이썬 내장 함수는 보통 그 자체가 제네레이터인 경우가 많다. 예를 들어, 특정 범위의 숫자들을 가져오는 데 많이 사용하는 range() 라는 함수, 그리고 map, zip, filter, reversed, enumerate 함수가 있다. 이러한 함수들은 전체 결과를 저장하지 않고 요청할 때마다 연산을 수행한다.

 

결국, 정리해보면 이터레이터와 제네레이터 간의 관계는 제네레이터 안에 이터레이터가 포함된다고 할 수 있다. 왜냐하면 제네레이터로 어쨌건 이터레이터로 변형되지만, 필요 시(요청 시) 마다 연산을 수행하는 특성(이를 지연 계산인 laze execution 이라고도 함)을 갖는 것이기 때문이다.

 

이터레이터와 제네레이터의 관계

2. 제네레이터의 지연 계산의 단점을 극복해주자: itertools 라이브러리

직전 목차에서 제네레이터의 장점으로 필요 시마다 그때그때 연산을 수행해주는 지연 계산이라고 설명했다.이 지연 계산은 제네레이터가 메모리 사용을 매우 덜하기 때문에 큰 장점이 있다. 하지만 제네레이터는 지연 계산이라는 점 때문에 현잿값만 사용할 수만 있고, 미리 처리한 값들에 대한 참조를 할 수는 없다.

 

예를 들어, 위 목차에서 제네레이터로 피보나치 수열을 계산하는 코드를 봐보면 피보나치의 수열 1,2,3,...번째 값을 그때그때 마다 출력은 가능했다. 하지만 피보나치 수열의 10번째 값을 출력하는데 그 때 갑자기 수열의 1번째 값을 참조해야 된다거나 하는 로직이 필요하다면 그 때는 제네레이터가 한계점에 부딪힌다. 이렇게 현잿값만 사용할 수 있고 데이터를 참조하지 못하는 알고리즘을 단일 패스(single pass) 또는 온라인(online)이라고도 한다.

 

이러한 제네레이터의 단점을 보완하기 위해서 여러가지 모듈과 함수가 지원이 되는데, 그 중 대표적인 표준 라이브러리가 바로 itertools 라이브러리다. 예전에 제네레이터와 itertools 라이브러리를 같이 묶어서 포스팅한 적이 있는데, 그 때 당시에는 왜 같이 묶이는지는 잘 몰랐는데 이번 기회에 알게 되었다.

 

itertools의 여러가지 함수는 이전에 소개한 포스팅에서 살펴보자. 여기서는 이전에 소개한 포스팅에서는 못봤던 함수 2개에 대해서만 살펴보자.

 

먼저 islice 라는 함수이다. 이는 제네레이터에 대한 슬라이싱 기능을 제공한다. isiice 함수가 받을 수 있는 인자는 islice(iterable 객체, start, stop, step) 이다. 참고로 islice는 keyword argument를 지원하지 않기 때문에 인자를 넣어줄 때 start=0 이런 식으로는 넣어줄 수는 없다.

 

from itertools import islice

temp = [0,1,2,3,4,5]
for i in islice(temp, 1, 5):
    print(i, end=' ') 
# 1 2 3 4

 

해석은 간단하다. temp 라는 리스트의 1번째부터 4(5-1)번째의 인덱스에 있는 값들을 출력해달라는 뜻이다. 물론 iterable한 객체에 제네레이터 객체를 넣어도 된다. 

 

temp = range(0, 6)
for i in islice(temp, 1, 5):
    print(i, end=' ')

 

한 가지 특징이 있다. islice 함수의 stop 인자에 iterable한 객체의 길이보다 큰 숫자를 넣어도 IndexError가 발생하지 않는다. 보통 리스트의 인덱싱은 아래의 코드를 실행하면 IndexError가 발생한다.

 

temp = ['one','two','three','four','five']
for i in range(0, len(temp)+1):
    print(temp[i], end=' ')
# IndexError: list index out of range

 

하지만 islice를 사용하면 에러가 발생하지 않고 그냥 iterable 객체의 가장 끝 인덱스에 있는 값을 출력하고 정상 종료된다.

 

temp = ['one','two','three','four','five']
for i in islice(temp, 0, len(temp)+100):
    print(i, end=' ')

 

다음은 cycle 이라는 함수이다. cycle 함수는 반복 가능한 요소가 모두 소모되면 소모될 때 저장해놓았던 사본에서 요소를 리턴한다. 일단 cycle 함수를 사용하지 않은 예시를 보자. 두 개의 길이가 다른 리스트가 있고, 이를 zip 으로 묶어서 각 원소를 출력하도록 해보자.

 

numbers = ['one','two','three','four','five']
chars = ['foo','bar','baz']

for n, c in zip(numbers, chars):
    print(n, c)
# one foo
# two bar
# three baz

 

출력을 보면 numbers에 원소가 five까지 있음에도 불구하고 짧은 길이인 chars 리스트의 원소가 소모될 때까지만 출력되고 종료된다. 그런데 numbers 원소도 끝까지 출력시키되 만약 chars 원소가 소모되었다면 다시 첫번째 원소로 순환(cycle)해서 출력하도록 해주고 싶을 수 있다. 이럴 때 짧은 길이의 리스트에만 cycle 함수를 사용하면 된다. 예시는 아래와 같다.

 

from itertools import cycle

numbers = ['one','two','three','four','five']
chars = ['foo','bar','baz']

for n, c in zip(numbers, cycle(chars)):
    print(n, c)
# one foo
# two bar
# three baz
# four foo
# five bar

지금까지 제네레이터와 이터레이터에 대한 관계와 제네레이터의 특징에 대해 살펴보았다. 다음 포스팅에서는 드디어 본인에게 미지의 세계(?)인 파이썬에서의 비동기 I/O에 대해 알아보도록 하자.

반응형