파이썬 제너레이터와 코루틴

휴먼스케이프

안녕하세요. 휴먼스케이프의 개발자 Bruno입니다.

이 번 포스트에서는 파이썬의 제너레이터와 코루틴에 대해 다루겠습니다.

제너레이터

제너레이터 함수

제너레이터(Generator) 함수란, yield 키워드를 가진 함수입니다. 이 함수는 제너레이터를 만들어서 반환합니다. 즉 제너레이터 함수는 제너레이터 팩토리라고 할 수 있습니다.

그럼 제너레이터는 또 뭘까요?

제너레이터는 외부에서 실행을 관리할 수 있는 객체입니다.

간단한 제너레이터 예시를 보여드리겠습니다.

>>> def gen_123():
...     yield 1
...     yield 2
...     yield 3
...
>>> gen_123   # 제너레이터 함수입니다

>>> gen_123()  # 제너레이터 생성

>>> for i in gen_123():  # 제너레이터는 반복자입니다
...     print(i)
...
1
2
3
>>> g = gen_123()  # 제너레이터를 생성하여 변수 g에 할당
>>> next(g)
1
>>> next(g)
2
>>> next(g)
3
>>> next(g)
Traceback (most recent call last):
  File '', line 1, in 
StopIteration

위 예시에서 볼 수 있듯, 제너레이터는 반복할 수 있으므로 반복자입니다. 그러나 또 다른 특징을 가지고 있습니다. 각 반복을 외부(호출자)에서 관리할 수 있다는 점입니다.

위 예시를 보면 gen_123() 제너레이터 함수를 이용해서 만든 제너레이터 객체를 변수 g에 할당하고 있습니다. 그 후엔 next() 를 이용해서 호출자가 각 반복의 실행을 조절할 수 있죠.

제너레이터: 느긋한 실행

제너레이터를 이용하면 반복의 실행을 호출자가 관리할 수 있게 됩니다. 이는 많은 이득을 가져오는데요. 그 중 하나가 느긋한 실행입니다.

한 문장에서 단어들을 추출하는 클래스 Sentence를 만들어서 예를 들어보겠습니다.

아래는 제너레이터를 사용하지 않는 Sentence입니다.

generator를 사용하지 않는 Sentence 클래스

위 예시는 아주 잘 동작합니다. 주어진 문장을 단어 별로 나눠서 words 리스트에 저장하고, __getitem__() 메소드의 동작을 리스트에 위임해서 반복자로 동작할 수 있게 합니다.

하지만 입력된 text가 아주 큰 경우, words 속성이 너무 많은 메모리를 차지하게 될 수 있다는 단점이 있습니다. 최악의 경우 허용된 메모리를 초과하여 프로그램이 정지할 수도 있습니다.

제너레이터를 이용해서 느긋한 실행, 즉 요청이 있을 때 다음 words를 반환하도록 구현한다면 메모리를 크게 아낄 수 있습니다.

다음은 제너레이터를 사용해서 느긋하게 동작하는 Sentence 클래스입니다.

__iter__() 안에서 yield를 사용해서 제너레이터로 만들었습니다. 이제 next() 혹은 반복문을 이용해서 차례차례 각 단어에 접근할 수 있게 되었습니다.

>>> from sentence_gen2 import Sentence
>>> text = 'Hello Python, Hello Humanscape'
>>> for word in Sentence(text):
...     print(word)
... 
Hello
Python
Hello
Humanscape

yield from

yield from은 파이썬 3.3에서 새로 추가된 키워드로, 제너레이터 내부에서 다른 제너레이터를 호출할 때 유용합니다.

yield from을 사용하면 제너레이터 안에 있는 제너레이터의흐름도 호출자가 제어할 수 있게 됩니다.

다음은 yield from을 사용하지 않고, 호출자가 제너레이터 안의 제너레이터의 흐름을 제어할 수 있도록 하는 예시입니다.

>>> def chain(*iterables):
...     for it in iterables:
...             for i in it:
...                     yield i
...
>>> s = 'ABC'
>>> t = tuple(range(3))
>>> list(chain(s, t))
['A', 'B', 'C', 0, 1, 2]

이렇듯 호출자에서 제너레이터 내부의 제너레이터의 흐름을 제어할 수 있기 위해서는, 처음 제너레이터 안에 다음 제너레이터 흐름을 제어하는 로직을 추가해야 합니다. 즉 호출자에서 흐름을 제어할 수 없습니다.

다음은 yield from을 사용한 예제입니다.

>>> def chain2(*iterables):
...     for i in iterables:
...             yield from i
...
>>> list(chain(s, t))
['A', 'B', 'C', 0, 1, 2]

위 예시를 보면, yield from을 사용해서 간단하게 제어 흐름을 호출자에서 할 수 있도록 만들 수 있습니다.

이 외에도 yield from은 호출자와 하위 제너레이터 간의 데이터를 매개해주는 역할도 할 수 있습니다. 관련 내용은 아래 코루틴(corouine)을 설명하면서 추가하겠습니다.

코루틴

코루틴(coroutine)은 문법상으로는 제너레이터와 다르지 않습니다. 내부에 yield 키워드가 있는 함수입니다.

제너레이터와 다른 점이라면 호출자가 실행을 컨트롤할 뿐만 아니라, 내부에 데이터를 전달할 수 있다는 점입니다. 코루틴은 호출자가 next() 대신 값을 전송하는 send()를 호출하면 코루틴이 호출자로부터 데이터를 받을 수 있습니다.

다음은 간단한 코루틴 예시입니다.

>>> def simple_coroutine():
...     print('-> 코루틴 시작')
...     x = yield  # 호출자에서 주는 데이터를 받을 수 있음
...     print(f'-> 코루틴 이 받은 데이터:{x}')
...
>>> my_coro = simple_coroutine()
>>> my_coro

>>> next(my_coro)  # 코루틴 기동(priming)
-> 코루틴 시작
>>> my_coro.send(42)  # 코루틴에 데이터 전달
-> 코루틴 이 받은 데이터:42
Traceback (most recent call last):
  File '', line 1, in 
StopIteration

simple_coroutine()을 보면 내부 yield가 위에 제너레이터 예시들과는 다르게 = 연산자 오른쪽에 위치합니다. 이 코루틴은 호출자가 send()를 이용해서 데이터를 전달하면 yield 문에서 그 데이터를 받아서 x 변수에 할당하고 그 다음 연산을 진행합니다.

코루틴 기동

코루틴을 사용하기 위해선 기동(priming)하는 단계가 필요합니다. 위 예시 중간에 next(my_coro)가 바로 기동하는 단계입니다. 마치 클래스에서 __init__이 호출되듯, 코루틴의 기본 값을 세팅하는 과정을 거친다고 생각하셔도 될 것 같습니다.

처음 next()를 호출하면, 코루틴은 None을 반환하며 첫 yield 문 전까지 실행하고 멈춥니다.

코루틴 종료

마지막에 발생한 StopIteration 예외는 제너레이터에서도 동일하게 발생하는 예외입니다. 바로 모든 yield가 끝나고 코루틴이 종료했을 때 raise 해주는 예외입니다.

코루틴의 상태

코루틴은 다음과 같은 총 네 가지 상태를 가집니다.

GEN_CREATED: 실행을 시작하기 위해 대기하고 있는 상태 GEN_RUNNING: 현재 실행하고 있는 상태. 다중스레드에서만 볼 수 있다 GEN_SUSPENDED: 현재 yield 문에서 대기하고 있는 상태 GEN_CLOSED: 실행이 완료된 상태

코루틴 예제: 이동 평균 계산

이동 평균을 계산하는 코루틴을 만들어서 예를 들어 보겠습니다.

사용예

>>> coro_avg = averager()
>>> next(coro_avg)  # 코루틴 기동
>>> coro_avg.send(10)
10.0
>>> coro_avg.send(30)
20.0
>>> coro_avg.send(5)
15.0

이렇게 매 번 값을 전달 받아서, 값들의 이동평균을 반환하는 코루틴을 만들어봤습니다.

코루틴을 사용하면 total과 count를 지역변수로 사용할 수 있는 장점이 있습니다. 객체 속성이나 별도의 클로저 없이 평균을 구하는 데 필요한 값을 유지할 수 있습니다.

코루틴을 기동하기 위한 테커레이터

코루틴을 사용하기 위해서는 기동(priming) 과정을 거쳐야 합니다. 위 예시에서는 next(coro_avg)에 해당하는 과정입니다.

이를 단순화하기 위해서 기동하는 데커레이터가 종종 사용됩니다.

위는 코루틴을 기동하는 데커레이터의 구현입니다. 이를 데커레이터로 사용해서 코루틴을 구현하면 기동 과정없이 바로 코루틴을 사용할 수 있게 됩니다.

위처럼 위에서 정의한 코루틴을 기동하는 데커레이터 corouine을 사용해보겠습니다.

>>> coro_avg = averager()
>>> from inspect import getgeneratorstate
>>> getgeneratorstate(coro_avg)  # 바로 GEN_SUSPENDED 상태 돌입
'GEN_SUSPENDED'
>>> coro_avg.send(10)  # 기동 과정을 거칠 필요가 없다
10.0
>>> coro_avg.send(30)
20.0
>>> coro_avg.send(5)
15.0

기동 과정 없이 편리하게 사용할 수 있게 되었습니다.

코루틴 종료와 예외처리

코루틴 역시, 제너레이터와 같이, 모든 yield문이 끝나게 되면 StopIteration 예외를 발생시키며 종료합니다. 이 외에도 코루틴을 종료하는 방법이 있습니다.

처리되지 않은 예외 발생

generator.thow(exc_type[, exc_value[, traceback]]) 을 이용해서 제너레이터가 중단한 곳의 yield 문에 예외 전달. 이 때, 예외를 처리되지 않은 예외라면 코루틴 또는 제너레이터가 종료됩니다.

generator.close() 을 이용하면 현재 suspend된 yield 문에서 GeneratorExit 예외를 발생시킵니다. 제너레이터가 예외를 처리하지 않거나 StopIteration 예외를 발생시키면, 아무런 에러도 호출자에게 전달되지 않습니다.

코루틴에서 값 반환하기

코루틴은 완료시에 값을 반환하게 할 수 있습니다. 이런 특성을 이용해서, 중간에는 의미 있는 값을 생성하지는 않지만, 최후에 어떤 의미 있는 값을 반환하는 코루틴도 가능합니다.

다음은 값을 반환하는 코루틴 예시입니다.

위 코루틴은 호출자에서 전달 받은 값들의 개수와 평균을 namedtuple 형태로 반환합니다.

>>> coro_avg = averager()
>>> next(coro_avg)  # 코루틴 기동
>>> coro_avg.send(10)  # 값을 반환하지 않음
>>> coro_avg.send(30)
>>> coro_avg.send(6.5)
>>> coro_avg.send(None)  # 종료 조건
Traceback (most recent call last):
  File '', line 1, in 
StopIteration: Result(count=3, average=15.5)

StopIteration 예외와 함께 값을 반환합니다. 아래처럼 이 예외를 잡아서 값을 처리하는 방법이 코루틴이 반환하는 값을 사용하는 정석입니다.

>>> try:
...     coro_avg.send(None)
... except StopIteration as exc:
...     result = exc.value
... 
>>> result
Result(count=3, average=15.5)

코루틴과 yield from

코루틴에서 yield from은 호출자와 하위 제너레이터 간의 양방향 채널을 열어주는 역할을 합니다.

상위 제너레이터에서 yield from을 사용하여 하위 제너레이터를 호출하면 호출자에서 하위 제너레이터의 흐름을 제어하면서 데이터를 전달하고 또 받을 수 있습니다.

다음은 yield를 이용한 averager() 코루틴 예제입니다.

위처럼 grouper()라는 상위 코루틴에서 yield from을 이용해서 averager()라는 하위 코루틴을 호출하게 되면, 호출자에서 상위 코루틴을 통해서 값을 전달할 수 있고, 또 하위 코루틴에서 값을 호출자로 전달할 수 있게 됩니다.

아래는 위 코드를 실행하는 예제입니다.

>>> data = {
...     'girls;kg':
...         [40.9, 38.5, 44.3, 42.2, 45.2, 41.7, 44.5, 38.0, 40.6, 44.5],
...     'girls;m':
...         [1.6, 1.51, 1.4, 1.3, 1.41, 1.39, 1.33, 1.46, 1.45, 1.43],
...     'boys;kg':
...         [39.0, 40.8, 43.2, 40.8, 43.1, 38.6, 41.4, 40.6, 36.3],
...     'boys;m':
...         [1.38, 1.5, 1.32, 1.25, 1.37, 1.48, 1.25, 1.49, 1.46],
... }
>>> main(data)
 9 boys  averaging 40.42kg
 9 boys  averaging 1.39m
10 girls averaging 42.04kg
10 girls averaging 1.43m

감사합니다.

이 포스트는 루시아누 하말류의 책 [Fluent Python]을 참고해서 작성했습니다.

Get to know us better! Join our official channels below.

Telegram(EN) : t.me/Humanscape KakaoTalk(KR) : open.kakao.com/o/gqbUQEM Website : humanscape.io Medium : medium.com/humanscape-ico Facebook : www.facebook.com/humanscape Twitter : twitter.com/Humanscape_io Reddit : https://www.reddit.com/r/Humanscape_official Bitcointalk announcement : https://bit.ly/2rVsP4T Email : support@humanscape.io

기업문화 엿볼 때, 더팀스

로그인

/