[ python ] 07. 제너레이터, 이터레이터 및 비동기 프로그래밍_(1)

박찬영·2024년 5월 13일

파이썬 클린 코드

목록 보기
16/19
post-thumbnail

07. 제너레이터, 이터레이터 및 비동기 프로그래밍

개요

제너레이터는 전통적인 언어와 파이썬을 구분 짓는 또 다른 특징적인 기능이다. 이 장에서는 제너레이터의 이론적 근거와 소개 배경 그리고 이를 통한 문제 해결 사례를 살펴볼 것이다. 또한 제너레이터를 사용해 이상적으로 문제를 해결하는 방법과 제너레이터를 좀 더 파이썬스럽게 구현하는 방법에 대해 알아본다.

제너레이터에 대해서

  • 제너레이터는 파이썬에서 고성능이면서도 메모리를 적게 사용하는 반복을 위한 방법이다.
  • 제너레이터는 한 번에 하나씩 구성요소를 반환하는 이터레이터 객체를 반환하는 함수이다.
  • 제너레이터를 사용하는 주요 목적은 메모리를 절약하는 것이다.(거대한 요소를 한꺼번에 메모리에 저장하는 대신 - 특정 요소를 어떻게 만드는지 아는 객체를 만들어서 필요할 때마다 하나씩만 가져오는 것이다.)

제너레이터 간단 예제

대규모 구매 정보에서 최저 판매가, 최고 판매가, 평균 판매가를 구하는 것을 해보자.
문제의 단순화를 위해 두 개의 필드만 있는 CSV파일이 있다고 가정해보자.
<purchase_data>, <price>
모든 구매 정보를 받아 필요한 지표를 구해주는 객체를 만들어보자. 최솟값이나 최댓값 같은 지표는 min(), max()같은 내장 함수를 사용하여 쉽게 구할 수 있지만 어떤 지표는 단번에 구할 수 없고 모든 구매 이력을 반복해야만 한다.

쉽게 생각하면 지표를 구하는 코드 자체는 간단한다. for 루프의 각 단계에서 각 지표를 업데이트하기만 하면 된다. 일단 다음처럼 간단하게 구현을 하고 제너레이터에 대한 학습을 한 뒤에 훨씬 간단하고 깔끔한 형태의 구현을 다시 해보자

class PurchasesStats:
  def __init__(self, purchases):
    self.purchases = iter(purchases)
    self.min_price: float = None
    self.max_price: float = None
    self._total_purchases_price: float = 0.0
    self._total_pruchases = 0
    self._initialize()

  def _initialize(self):
    try:
      first_value = next(self.purchases)
    except StopIteration:
      raise ValueError("더이상 값이 없음")

    self.min_price = self.max_price = first_value
    self._update_avg(first_value)

  def process(self):
    for purchase_value in self.purchases:
      self._update_min(purchase_value)
      self._update_max(purchase_value)
      self._update_avg(purchase_value)
    return self

  def _update_min(self, new_value:float):
    if new_value < self.min_price:
      self.min_price = new_value

  def _update_max(self, new_value:float):
    if new_value > self.max_price:
      self.max_price = new_value

  @property
  def avg_price(self):
    return self._total_purchases_price / self._total_pruchases

  def _update_avg(self, new_value:float):
    self._total_purchases_price += new_value
    self._total_purchases += 1

  def __str__(self):
    return (
        f"{self.__class__.__name__}({self.min_price}, "
        f"{self.max_price}, {self.avg_price})"
    )

이 객체는 모든 구매 정보를 받아서 필요한 계산을 한다. 이제 이 모든 정보를 로드해서 어딘가에 담아서 반환해주는 함수를 만들어보자. 다음은 첫 번째 버전이다.

def _load_purchases(filename):
  purchases = []
  with open(filename) as f:
    for line in f:
      *_, price_raw = line.partition(",")
      purchases.append(float(price_raw))
  return purchases

이 코드는 정상적인 결과를 반환한다. 파일에서 모든 정보를 읽어서 리스트에 저장한다. 그러나 성능에는 문제가 있다. 파일에 상당히 많은 데이터가 있다면 로드하는데 시간이 오래 걸리고 메인 메모리에 담지 못할 만큼 큰 데이터일 수도 있다. 그런데 앞서 작성한 코드를 살펴보면 한 번에 하나의 데이터만을 사용하고 있다는 것을 알 수 있다.

해결책은 제너레이터를 만드는 것이다. 파일의 전체 내용을 리스트에 보관하는 대신에 필요한 값만 그때그때 가져오는 것이다. 다음과 같이 코드를 수정한다.

def _load_purchases(filename):
  purchases = []
  with open(filename) as f:
    for line in f:
      *_, price_raw = line.partition(",")
      yield float(price_raw)

이렇게 수정하면 메모리 사용량이 급격하게 떨어지는 것을 볼 수 가 있다. 결과를 담을 리스트가 필요 없어졌으며 return문 또한 사라졌다.

이 경우 load_purchases함수를 제너레이터 함수 또는 단순히 제너레이터라고 부른다. 파이썬에서 어떤 함수라도 yield 키워드를 사용하면 제너레이터 함수가 된다. 이 제너레이터를 호출하면 인스턴스를 만드는 것 외에도 아직 아무 작업도 하지 않는다. next() 내장 함수를 사용하여 다음 요소를 불러올 수 있다.

모든 제너레이터 객체는 이터러블이다. 이터러블은 for 루프와 함께 사용할 수 있다는 정도로 알아두면 된다. 이터러블을 사용하여 for루프와 쉽게 호환이 가능한 강력한 추상화를 이룰 수 있었다. 이터러블 인터페이스를 따르기만 하면 투명하게 객체의 요소를 반복 가능한 것이다.

제너레이터 표현식

제너레이터를 사용하면 많은 메모리를 절약할 수 있다. 또한 제너레이터는 이터레이터이므로 리스트나 튜플, 세트처럼 많은 메모리를 필요로 하는 이터러블이나 컨테이너의 대안이 될 수 있다.
컴프리헨션 기법에 대해서 유독 제너레이터에 대해서는 제너레이터 컴프리헨션이 아니라 제너레이터 표현식이라 부르고 있다. 리스트 컴프리헨션, 딕셔너러리 컴프리헨션에 의해 정의될 수 있는 리스트나 딕셔너리처럼 제너레이터도 제너레이터 표현식으로 정의할 수 있다.

리스트 컴프리헨션에서 사용하는 대괄호를 괄호로 교체하면 표현식의 결과로부터 제너레이터가 생성된다. 제너레이터 표현식은 sum()이나 max()와 같이 이터러블 연산이 가능한 함수에서 직접 사용할 수도 있다.

### 리스트 컴프리헨션
[x**2 for x in range(10)]

# [0, 1, 4, 9, 16, 25, 36, 49, 64, 81]


### 제너레이터 표현식
(x**2 for x in range(10))

# <generator object <genexpr> at 0x78e34e0c4f20>


### 이터러블 연산 가능한 함수에 직접 사용
sum(x**2 for x in range(10))

# 285

이터러블을 파라미터로 받는 min(), max(), sum() 같은 함수를 사용할 때는 리스트 컴프리헨션 대신에 항상 제너레이터 표현식을 사용하자, 그렇게 하는 것이 보다 효율적이고 파이썬스러운 방식이다. 즉 제너레이터를 사용할 수 있는 함수에 리스트를 전달하지 말자는 것이다.

제너레이터는 반복을 완료하면 소모된 상태가 된다는 것을 기억하자. 왜냐하면 제너레이터는 모든 데이터를 메모리에 가지고 있지 않기 때문이다.

이상적인 반복

이번에는 파이썬에서 반복을 할 때 유용하게 사용할 수 있는 관용적인 코드를 살펴볼 것이다.

관용적인 반복 코드

파이썬 내장 함수인 enumerate()와 비슷한 객체를 만들어보자. 이 객체는 단순히 시작 값을 입력하면 무한 시퀀스를 만드는 역할을 한다.

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

  def next(self):
    current = self.current
    self.current += 1
    return current

이 인터페이스에 기반을 두어 클라이언트를 작성하면 명시적으로 next() 함수를 호출해야 한다.

그러나 이 코드를 enumerate() 함수처럼 사용하도록 작성할 수는 없다. 왜냐하면 일반 파이썬의 for 루프를 사용하기 위한 인터페이스를 지원하지 않기 때문이다. 이는 또한 이터러블 형태의 파라미터로는 사용할 수 없다는 뜻이다.

문제는 NumberSequence가 반복을 지원하지 않는다는 것이다. 이 문제를 해결하려면 __iter__() 매직 메서드를 구현하여 객체가 반복 가능하게 만들어야 한다. 또한 next() 메서드를 수정하여 __next__ 매직 메서드를 구현하면 객체는 이터레이터가 된다.

class SequenceOfNumbers:

  def __init__(self, start=0):
    self.current = start

  def __next__(self):
    current = self.current
    self.current += 1
    return current

  def __iter__(self):
    return self

이렇게 하면 요소를 반복할 수 있을 뿐 아니라 next() 메서드를 호출할 필요도 없다. 왜냐하면 __next__() 메서드를 구현했으므로 next() 내장 함수를 사용할 수 있기 때문이다.

next() 함수

next() 내장 함수는 이터레이터를 다음 요소로 이동시키고 기존의 값을 반환한다.

이터레이터가 더 이상의 값을 가지고 있지 않다면 StopIteration 예외가 발생한다.

이 예외는 반복이 끝났음을 나타내며 사용할 수 있는 요소가 더 이상 없음을 나타낸다.
이 문제를 해결하고 싶다면 StopIteration예외를 캐치하는 것 외에도 next() 함수의 두 번째 파라미터에 기본 값을 제공할 수도 있다. 이 값을 제공하면 StopIteration을 발생시키는 대신 기본 값을 반환한다.

제너레이터 사용하기

앞의 코드는 제너레이터를 사용하면 훨씬 간단하게 작성할 수 있다. 제너레이터를 사용하면 클래스를 만드는 대신 다음과 같이 필요한 값을 yield하는 함수를 만들면 된다.

def sequence(start=0):
  while True:
    yield start
    start += 1

Itertools

이터러블로 작업하면 코드가 파이썬 자체와 더 잘 어울리는 장점이 있다. 왜냐하면 이터레이션이 언어의 중요한 컴포넌트이기 때문이다. 또한 itertools을 사용하면 그 기능을 온전히 활용할 수 있다.
이터레이터, 제너레이터, itertools와 관련해서 좋은 점은 이들을 서로 연결하여 새로운 객체를 만들 수 있다는 점이다.

예를 들어 처음의 예제로 돌아가서 구매 이력에서 지표를 계산하는 과정을 다시 살펴볼 때, 만약 특정 기준을 넘은 값에 대해서만 연산을 한다고 생각해보자

예를 들어 1,000개 넘게 구매한 이력의 처음 10개만 처리하려고 하면 다음과 같이 하면 된다.

from itertools import islice
purchase = islice(filter(lambda p:p > 1000,0, purchases), 10)
stats = PurchasesStats(purchase).process()

이런 식으로 필터링을 해도 메모리의 손해는 없다. 왜냐하면 모든 것이 제너레이터이기 때문이다. 즉 마치 전체에서 필터링한 값으로 연산을 한 것처럼 보이지만, 실제로는 하나씩 가져와서 모든 것을 메모리에 올릴 필요가 없는 것이다.

제너레이터를 사용하면 메모리를 덜 사용하지만 CPU 자원은 더 많이 사용할 수도 있다. 그러나 유지보수성을 높이면서 메모리에서 많은 객체를 처리해야 하는 상황이라면 대부분 허용되는 수준이다.

이제 살펴봤던 모든 것을 활용해서 처음 예제를 간소화하면 다음과 같다.

def process_purchases(purchases):
  min_, max_, avg = itertools.tee(purchases, 3)
  return min(min_), max(max_), median(avg)

itertools.tee는 원래의 이터러블을 세 개의 새로운 이터러블로 분할한다. 따라서 반복을 여러 번 해야 되는 경우 itertools.teefmf tkdydgksek.

이터레이션 인터페이스

이터러블은 반복을 지원하는 객체로 크게 보면 아무 문제없이 for 루프를 실행할 수 있다는 것을 뜻한다. 그러나 이터러블과 이터레이터는 다르다. 일반적으로 이터러블은 단지 반복할 수 있는 어떤 것을 말하고, 실제 반복 작업은 이터레이터에서 이뤄진다. 즉, __iter__매직 메서드는 이터레이터를 반환하고, 실제 반복은 __iter__에서 반환된 이터레이터의 __next__메서드에서 이뤄진다.

이터레이터는 내장 next() 함수 호출 시 일련의 값에 대해 한 번에 하나씩만 어떻게 생성하는지 알고 있는 객체이다. 이터레이터를 호출하지 않은 상태에서 다음 값을 요청 받기 전까지 그저 얼어있는 상태일 뿐이다. 이런 의미에서 모든 제너레이터는 이터레이터이다.

이터러블 - __iter__ - 이터레이터와 함께 반복 로직을 만든다. 이것을 구현하면 for loop 구문에서 사용할 수 있다.
이터레이터 - __next__ - 한 번에 하나씩 값을 생산하는 로직을 정의한다. 더 이상 생산할 값이 없을 경우 StopIteration 예외를 발생시킨다. 내장 next()함수를 사용해 하나씩 값을 읽어 올 수 있다.

다음은 이터러블하지 않은 이터레이터의 예시이다.

class SequenceIterator:
	def __init__(self, start=0, step=1):
    	self.current = start
        self.step = step
    
    def __next__(self):
    	value = self.current
        self.current += self.step
        return value

시퀀스에서 하나씩 값을 가져올 수 있지만, 반복할 수는 없다.

객체가 시퀀스여서 우연히 반복이 가능할 수 있지만, 기본적으로 반복을 위한 객체를 디자인할 때, __iter__ 매직 메서드를 구현하여 정식 이터러블 객체를 만들어야 한다.

마치며..

이번 포스팅에서는 제너레이터와 이터레이터, 그리고 이터러블의 개념과 간단한 예시를 살펴보았다.

profile
안녕하세요 박찬영입니다.

0개의 댓글