파이썬 클린코드 | 7장 제너레이터, 이터레이터 및 비동기 프로그래밍

날짜
Jan 31, 2024
태그
python
설명
프로그램의 성능을 향상시키는 파이썬의 위대한 기능
 
💡
- 프로그램의 성능을 향상시키는 제너레이터 만들기 - 이터레이터가 파이썬에 어떻게 완전히 통합되었는지 확인 - 이터레이션 문제를 이상적으로 해결하는 방법 - 제너레이터가 어떻게 코루틴과 비동기 프로그래밍의 기반이 되는 역할을 하는지 확인 - 코루틴을 지원하기 위한 yield from, await, async def와 같은 문법의 세부 기능 확인
 
 

제너레이터 만들기

제너레이터는 한 번에 하나씩 구성요소를 반환하는 이터레이터 객체를 반환하는 함수이다. 제너레이터를 사용하는 주요 목적은 메모리를 절약하는 것이다.

제너레이터 개요

대규모의 구매 정보에서 최저 판매가, 최고 판매가, 평균 판매가를 구하는 예제
 
class PurchaseStats: def __init__(self, purchases): self.purchases = iter(purchases) self.min_price: float = None self.max_price: float = None self._total_purchases_price: float = None self._total_purchases = 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_value: self.min_value = new_value def _update_max(self, new_value: float): if new_value > self.max_price: self.max_value = new_value @property def avg_price(self): return self._total_purchases_price / self._total_purchases 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})" )
간단히 생각하면 지표를 구하는 코드는 간단하다. for 루프의 반복문(process)에서 각 지표를 업데이트 하기만 하면 된다.
 
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): with open(filename) as f: for line in f: *_, price_raw = line.partition(", ") yield float(price_raw)
모든 정보를 리스트에 저장하는 대신, 필요한 값만 그때마다 가져온다. 이러한 함수를 제너레이터라고 부른다.
 
>>> load_purchases("file") <generator object load_purchases at 0X....> # 이터러블한 객체라고 부른다.
 
제너레이터를 제대로 사용하면 메모리 사용량을 혁신적으로 줄일 수 있다. 결과를 담을 리스트와 반환문이 사라졌다. 변경된 load_purchases 함수는 제너레이터 함수 라고 부른다. 어떤 함수라도 yield 키워드를 사용하면 제너레이터 함수가 된다.
 
 

제너레이터 표현식

컴프리헨션 표현식과 같이 제너레이터도 제너레이터 표현식으로 정의할 수 있다.
>>> [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 0x....> >>> sum(x**2 for x in range(10)) 285
대괄호를 괄호로 교체하면 표현식의 결과로 제너레이터가 생성된다. sum()과 같은 이터러블 연산이 가능한 함수에서 직접 사용도 가능하다.
컴프리헨션 방식처럼 제너레이터 표현식도 한 번만 사용이 가능하다(재사용되지 못한다). 상황에 알맞게 리스트를 사용할 지, 제너레이터 방식을 사용할 지 결정해야 한다.
💡
제너레이터는 반복을 완료하면 소모된 상태가 된다. 왜냐하면 제너레이터는 모든 데이터를 메모리에 가지고 있지 않기 때문이다.
 

이상적인 반복

 

관용적인 반복 코드

내장함수인 enumerate() 를 사용한 관용적인 코드를 보여준다.
>>> list(enumerate("abcdef")) [(0, "a"), (1, "b"), (2, "c"), (3, "d"), (4, "e"), (5, "f")]
이터러블을 입력받아서 인덱스 번호와 원본의 원소를 튜플 형태로 변환하여 enumerate 객체를 반환환다.
 
좀 더 저수준의 유사한 객체를 만들어본다.
class NumberSequence: def __init__(self, start=0): self.current = start def next(self): current = self.current self.current += 1 return current
객체의 next 함수를 호출할 때마다 다음 시퀀스 값을 무제한 출력해준다.
 
>>> seq = NumberSequence() >>> seq.next() 0 >>> seq.next() 1 >>> seq2 = NumberSequence(10) >>> seq2.next() 10 >>> seq2.next() 11
이 코드는 일반 파이썬의 for 루프를 사용할 수 없다. 아직 인터페이스를 지원하지 않고 있기 때문이다.
 
>>> list(zip(NumberSequence(), "abcdef")) Traceback (most recent call last): File "...", line 1, in <module> TypeError: zip argument #1 must support iteration
NumberSequence가 반복을 지원하지 않아서 생기는 오류이다.
 
class SequenceOfNumber: def __init__(self, start=0): self.current = start def __next__(self): current = self.current self.current += 1 return current def __iter__(self): return self
iter 함수를 구현해 객체가 반복가능하게 만들었다. 또한 next 메서드를 구현해 이터레이터를 만들었다.
 
>>> list(zip(SequenceOfNumber(), "abcdef")) [(0, "a"), (1, "b"), (2, "c"), (3, "d"), (4, "e"), (5, "f")] >>> seq = SequenceOfNumber(100) >>> next(seq) 100 >>> next(seq) 101
전과 동일한 기능을 하면서 for 루프를 사용할 수 있게 되었다.
이터레이션 프로토콜은 __iter__, __next__ 함수에 의존한다. 이러한 프로토콜을 사용했을 때의 장점은 관용적이라는 것이다. 사용자는 단지 그 규약에 맞춰서 적절히 구현하면 된다.
 

next() 함수

>>> word = iter("hello") >>> next(word) "h" >>> next(word) "e" ... >>> next(word) "o" >>> next(word) Traceback (most recent call last): File "...", line 1, in <module> StopIteration
더 이상 값을 가지고 있지 않다면 StopIteration 에러를 반환한다
 
>>> next(word, "default value") "default value"
StopIteration 예외를 캐치하는 방법과 default value 를 지정하는 방법이 있다.
대부분의 경우에 런타임 시 에러가 나지 않는 방향으로 기본값을 사용하는 것이 좋다.
 

제너레이터 사용하기

제너레이터를 사용하면 코드를 더 간단히 작성할 수 있다.
def sequence(start=0): while True: yield start start += 1
yield 키워드가 함수를 제너레이터 함수로 만들어준다. 무한 루프를 사용해도 안전하다.
 
>>> seq = sequence(10) >>> next(seq) 10 >>> next(seq) 11 >>> list(zip(sequence(), "abcdef") [(0, "a"), (1, "b"), (2, "c"), (3, "d"), (4, "e"), (5, "f")]
제너레이터 함수가 호출되면 yield 문장을 만나기 전까지 실행된다. 값을 생성하고 그 자리에서 멈춘다.
 
제너레이터 함수를 사용할 수도, 이터러블 객체를 사용해서 할 수도 있다. 문법적으로 더 간단하고 이해하기도 쉽기 때문에 가급적 제너레이터 함수를 사용하는 것이 권장된다.
 

Itertools

이터러블로 작업하면 코드가 파이썬 자체와 더 잘 어울린다. itertools 모듈을 사용하면 그 기능을 온전히 활용할 수 있다.
 
# 위 예제 중 구매 이력에서 지표를 계산하는 과정 # 만약 특정 기준을 넘은 값에 대해서만 연산을 하고 싶다면? def process(self): for purchase in self.purchases: if purchase > 1000.0: ...
이 방법은 파이썬스럽지 못하다.
기준 가격 수치가 변경된다면? 파라미터가 추가된다면? 이러한 의문에 융통성이 없다는 것을 엄격하다고 표현하고 있다.
 

이터레이터를 사용한 코드 간소화

 
여러번 반복하기
def process_purchases(purchases): min_, max_, avg = itertools.tee(purchases, 3) return min(min_), max(max_), median(avg)
itertools 모듈을 사용해 코드를 단순화했다.
tee 함수는 원래의 이터러블을 세 개의 새로운 이터레이터로 분할한다. 즉, 제너레이터를 사용한 for 문이 여러 개 있는 것과 비슷하다. 이 때 개별 요소의 크기가 크고, 이터러블 여러개를 복사한다고 하면 사용에 유의해야 한다.
 
💡
반복을 여러 번 해야 되는 경우에는 itertools.tee를 사용한다.
 
중첩 루프
def search_nexted_bad(array, desired_value): coords = None for i, row in enumerate(array): for j, cell in enumerate(row): if cell == desired_value: coords = (i, j) break if coords is not None: break if coords is None: raise ValueError(f"{desired_value} 값을 찾을 수 없음") logger.info("[%i, %i]에서 값 %r 발견", *coords, desired_value) return coords
중첩 루프의 일반적 형태
 
def _iterate_array2d(array2d): for i, row in enumerate(array2d): for j, cell in enumerate(row): yield (i, j), cell def search_nested(array, desired_value): try: coord = next( coord for (coord, cell) in _iterate_array2d(array) if cell == desired_value ) except StopIteration: raise ValueErro("{desired_value} 값을 찾을 수 없음") logger.info("[%i, %i]에서 값 %r 발견", *coords, desired_value) return coords
종료 플래그를 사용하지 않고, 보조 제너레이터(_iterate_array2d)를 사용해 반복을 추상화했다. 나중에 더 많은 차원의 배열을 사용하는 경우에도 기존 코드를 그대로 사용할 수 있다.
 
제너레이터는 단순 메모리를 절약하기 위한 수단이 아니다. 코드를 더욱 컴팩트하게 만들어 줄 수도 있다.
💡
최대한 중첩 루프를 제거하고 추상화하여 반복을 단순화한다.
 

파이썬의 이터레이터 패턴

이터레이터를 구현하기 위해서 일반적으로 __iter__, __next__ 를 구현하지만, 엄밀히 말하면 항상 두 가지를 모두 구현해야 하는 것은 아니다. __iter__를 구현한 이터러블 객체와 __next__ 를 구현한 이터레이터 객체를 비교해보자.
 
이터레이션 인터페이스
개념
매직 메서드
비고
이터러블(iterable)
__iter__
이터레이터와 함께 반복 로직을 만든다. 이것을 구현한 객체는 for .. in .. 구문을 사용할 수 있다.
이터레이터(iterator)
__next__
한 번에 하나씩 값을 생산하는 로직을 정의한다. 더 이상 생산할 값이 없을 경우는 StopIteration 예외를 발생시킨다. 내장된 next() 함수를 사용해 하나씩 값을 읽어올 수 있다.
 
이터러블이 가능한 시퀀스 객체
__iter__ 를 구현한 객체는 for 루프를 사용할 수 있다. 하지만 꼭 이러한 형태로만 반복이 가능한 것은 아니다. 파이썬이 for 루프를 만나면 객체가 __iter__ 를 구현했는지 확인하고, 없다면 __getitem____len__ 메서드를 구현했는지 확인한다. 즉, 객체가 시퀀스인 경우, 반복이 가능하다. 이 경우, IndexError 예외가 발생할 때까지 순서대로 값을 제공한다.
 
class MappedRange: """특정 숫자 범위에 대해 맵으로 변환""" def __init__(self, transformation, start, end): self._transformation = transformation self._wrapped = range(start, end) def __getitem__(self, index): value = self._wrapped.__getitem__(index) result = self._transformation(value) logger.info("Index %d: %s", index, result) return result def __len__(self): return len(self._wrapped)
일반적인 for 루프로만 접근이 가능하도록 작성한 코드. next()로 접근 시, TypeError 를 반환한다.
 
>>> mr = MappedRange(abs, -10, 5) >>> mr[0] Index 0: 10 10 >>> mr[-1] Index -1: 4 4 >>> list(mr) Index 0: 10 Index 1: 9 Index 2: 8 ... Index 9: 1 Index 10: 0 Index 11: 1 Index 12: 2 ... Index 14: 4 [10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0, 1, 2, 3, 4]
__iter__ 없이 시퀀스로만 반복하는 코드.
 
💡
객체가 시퀀스여서 우연히 반복이 가능할 수 있지만, 기본적으로 반복을 위한 객체를 디자인 할 때는 __iter__ 메서드를 구현하여 정식 이터러블 객체를 만들어야 한다.
 

 

코루틴

코루틴의 핵심은 특정 시점에 실행을 일시 중단했다가 나중에 재시작할 수 있는 함수를 만드는 것이다. 파이썬은 코루틴을 생성하기 위해 제너레이터를 활용한다. 제너레이터는 중지 가능한 객체이므로 코루틴이 되기 위한 좋은 성질을 가지고 있다. 하지만 코드의 일부를 일시 중단하는 것으로 충분하지 않고, 그것과 통신하는 수단이 필요하기 때문에(데이터를 전달하거나 컨텍스트가 변경되었음을 알려주기 위해 신호를 보내는 것이 필요) 아래의 메서드가 추가되었다.

제너레이터 인터페이스의 메서드

close

이 메서드가 호출되면 제너레이터에서 GeneratorExit 예외가 발생한다. 따로 처리하지 않으면 제너레이터가 더 이상 값을 생성하지 않고 반복이 중지된다. 즉, 종료를 지정하는데 사용될 수 있다.
 
def stream_db_records(db_handler): try: while True: yield db_handler.read_n_records(10) except GeneratorExit: db_handler.close()
코루틴을 사용하여 DB 연결을 유지한 상태에서 특정 크기의 페이지를 스트리밍한다.
제너레이터를 호출할 때마다 데이터베이스 핸들러에서 얻은 10개의 레코드를 반환하고, 명시적으로 반복을 끝나기 위해 close()를 호출하면 데이터베이스 연결도 함께 종료한다.
 
>>> streamer = stream_db_records(DBHandler("testdb")) >>> next(streamer) [(0, 'row 0'), (1, 'row 1'), (2, 'row 2'), (3, 'row 3')....] >>> next(streamer) [(0, 'row 0'), (1, 'row 1'), (2, 'row 2'), (3, 'row 3')....] >>> streamer.close() INFO:....: 'testdb' 데이터 베이스 연결 종료
💡
제너레이터에서 작업을 종료할 때는 close() 메서드를 사용한다.
 
이 메서드는 리소스를 정리하기 위해 사용하는 것으로, 컨텍스트 관리자를 사용하지 않았거나 자동으로 정리가 어려운 경우에 수동으로 리소스를 해제하기 위해 호출한다.
 

throw(ex_type[, ex_value[, ex_traceback]])

이 메서드는 제너레이터가 중단된 현재 위치에서 예외를 던진다. 예외를 처리했으면 except 절의 코드가 호출되고, 예외를 처리하지 않았으면 예외가 호출자에게 전파된다.
 
class CustomException(Exception): """처리하려는 에러 유형""" def stream_data(db_handler): while True: try: yield db_handler.read_n_records(10) except CustomException as e: logger.warning("%r 에러 발생 후 계속 진행", e) except Exception as e: logger.error("%r 에러 발생 후 중단", e) db_handler.close() break
 
>>> streamer = stream_data(DBHandler("testDB")) >>> next(streamer) [(0, 'row 0'), (1, 'row 1'), (2, 'row 2'), (3, 'row 3')....] >>> next(streamer) [(0, 'row 0'), (1, 'row 1'), (2, 'row 2'), (3, 'row 3')....] >>> streamer.throw(CustomerException) WARNING: CustomException() 에러 발생 후 계속 진행 [(0, 'row 0'), (1, 'row 1'), (2, 'row 2'), (3, 'row 3')....] >>> streamer.throw(RuntimeError) ERROR: RuntimeError() 발생 후 중단 INFO: 'testdb' 데이터베이스 연결 종료 Trackback (most recent call last): .... StopIteration
 

send(value)

제너레이터의 주요 기능은 고정된 수의 레코드를 읽는 것이다. next() 는 읽어올 개수를 파라미터로 받을 수 없다. send() 는 파라미터를 사용해 읽어올 개수를 설정할 수 있다.
 
def stream_db_record(db_handler): retrieved_data = None previous_page_size = 10 try: while True: page_size = yield retrieved_data if page_size is None: page_size = previous_page_size previous_page_size = page_size retrieved_data = db_handler.read_n_records(page_size) except GeneratorExit: db_handler.close()
yield 키워드를 사용해 인자 값을 받아, 다른 곳으로 할당할 수 있다.
 
receive = yield produced
yield 는 두가지 역할을 한다.
  • produced 값을 호출자에게 보내고 그 곳을 멈춘다. 호출자는 next() 메서드를 호출해 다음 라운드에서 값을 가져올 수 있다.
  • 호출자로부터 send() 메서드를 통해 전달된 produced 값을 받는 것이다. 이렇게 입력된 값은 receive 변수에 할당된다.
 
코루틴에 값을 전달하는 것은 yield 구문이 멈춘 상태에서만 가능하다. 그러려면 코루틴을 해당 상태까지 이동시켜야 한다. 유일한 방법은 next() 를 호출하는 것이다. 코루틴에게 무언가 요청하기 위해서는 반드시 next() 를 거쳐야 한다는 의미이다.
 
>>> def coro(): y = yield .... .... >>> c = coro() >>> c.send(1) TypeError: can't send non-None Value to a just-started generator
코루틴을 멈추지 않고 무언가를 보내면 에러가 발생한다. 즉, send() 메서드를 호출하려면 항상 next()를 먼저 호출해야 한다.
 
def stream_db_records(db_handler): retrieved_data = None page_size = 10 try: while True: page_size = (yield retrieved_data) or page_size retrieved_data = db_handler.read_n_records(page_size) except GeneratorExit: db_handler.close()
데이터베이스에서 읽을 레코드의 길이를 파라미터로 받도록 수정했다.
 
제너레이터에서 처음 next() 를 호출하면 yield 를 포함하는 위치까지 이동한다. 그리고 현재 상태의 변수 값을 반환하고, 거기에 멈춘다. 변수의 초기값은 None이고, 처음 next()를 호출하면 None을 반환한다.
여기서 옵션이 2가지가 있다.
  • next()를 호출하면 기본값인 10을 반환. 평소처럼 작업이 계속된다. 여기서 next()send(None) 과 같다.
  • send(<value>)를 통해 명시적인 값을 제공하면 yield 문의 반환 값으로 page_size의 변수에 설정된다. 기본 값이 아닌 사용자가 지정한 값이 page_size로 설정되고, 해당 크기만큼 데이터 베이스에서 읽어온다. 이어지는 호출에 대해서도 같은 값이 적용된다
 
 

코루틴 고급 주제

코루틴은 조금 더 향상된 제너레이터다. 하지만 많은 코루틴을 동시에 사용하는 등의 처리에서 새로운 문제를 맞이한다. 예외 처리는 물론이고 서브 코루틴의 값을 어디에서든 사용하도록 해야 하고, 여러 코루틴을 스케줄링 해야 한다.

코루틴에서 값 반환하기

반복이란 StopIteration 예외가 발생할 때까지 next() 메서드를 계속해서 호출하는 메커니즘이다.
위에서는 for 루프의 각 단계에서 생성하는 값에 대해서 알아봤었다.
 
코루틴은 기술적으로 제너레이터이지만 반복을 염두에 두고 만든 것이 아니라 나중에 코드가 실행될 때까지 코드의 실행을 멈추는 것을 목표로 한다. 코루틴은 반복보다는 상태를 중단하는 것에 초점을 맞추고 있다.
 
제너레이터가 값을 반환하게 하려면 어떻게 해야 할까? 분명 반복을 중단한 뒤에야 값을 가져올 수 있을 것이다. 값을 반환하면 반복이 즉시 중단된다. 문법의 통일성을 위해 StopIteration 예외는 계속 발생한다. 반환하려는 값는 exception 객체에 저장된다. 해당 값을 처리하는 책임은 호출자에게 있다.
 
>>> def generator(): yield 1 yield 2 yield 3 .... .... >>> value = generator() >>> next(value) 1 >>> next(value) 2 >>> try: next(value) except StopIteration as e: print(f"최종 반환값: {e.value}") 최종 반환값 3
예외를 어떻게 처리하고, 예외에서 어떻게 값을 구하는지 확인한다.
StopIteration 예외를 사용하는 것이 가장 깔끔한 방법은 아니겠지만, 문법적 하위 호환이 되기 때문에 제너레이터의 인터페이스를 바꾸지 않아도 된다는 장점을 가진다.
 

작은 코루틴에 위임하기 - yield from 구문

가장 간단한 yield from 사용 예

가장 간단한 형태의 yield from 구문은 제너레이터 체인에서 살펴볼 수 있다.
def chain(*iterables): for it in iterables: for value in it: yield value
여러 개의 이터러블을 받아서 하나의 스트림으로 반환해준다.
여러 개의 이터러블을 받아 중첩 루프를 사용해 리스트를 튜플과 비교하는 것처럼 직접 비교가 어려운 자료형에 대해서도 한 번에 처리할 수 있어 편리하다.
 
def chain(*iterables): for it in iterables: yield from it
yield from 구문을 사용해 코드를 단순화 했다.
 
>>> list(chain("hello", ["world"], ("tuple", "of", "values."))) ["h", "e", "l", "l", "o", "world", "tuple", "of", "values"]
제너레이터를 사용한 것과 yield from을 사용한 것의 값은 동일하다
 

서브 제너레이터에서 반환된 값 구하기

 

서브 제너레이터와 데이터 송수신하기

 
 

비동기 프로그래밍

 

비동기 매직 메서드

 

비동기 컨텍스트 관리자

 

다른 매직 메서드

 

비동기 반복

 

비동기 제너레이터

 
 
 
 
 

댓글

guest