콘텐츠로 건너뛰기
Home » 파이썬 yield from – 다른 제너레이터에게 위임하기

파이썬 yield from – 다른 제너레이터에게 위임하기

파이썬에서는 어떤 함수 내부에 yield 키워드가 사용됐다면, 이 함수를 무조건 제너레이터 함수로 본다. 제너레이터 함수는 제너레이터를 만드는 함수이고, 다시 제너레이터는 어떤 값을 필요한 만큼 반복적으로 만들어내는 객체이다. 제너레이터는 일반 함수와 비슷하게 생겼지만, 함수가 return 구문을 만나면 실행이 끝나버리는데 비해, 제너레이터는 yield 구문에서 값을 외부로 내보낸 후 “일시정지” 상태가 되었다가 필요할 때 다시 실행 흐름을 이어나갈 수 있다.

필요에 따라서는 일시 정지한 제너레이터를 다시 깨울 때 값을 내부로 전달할 수 있는데, 이런 특징을 활용하면 함수를 띄워놓고 필요한 시점에 반복적으로 값을 집어넣었다가 또 필요한 시점에 결과를 빼내서 쓰는 식으로 활용할 수 있다. 이런 식으로 작동하도록 만든 제너레이터는 특별히 ‘코루틴’이라고 한다. 이 블로그에서도 오래전에 코루틴에 대해서 다룬 글이 있으니 한 번 참고해보는 것도 좋겠다.

사실 asyncio 에서 말하는 “비동기 코루틴” 도 이러한 코루틴의 특성 – 실행을 필요한 만큼 멈춰놓을 수 있는 함수-라는 특성에서 발전한 아이디어로, I/O에 관여하는 작업이 있을 때에는 I/O 장치의 처리가 끝날 때 까지 (즉, 필요한만큼) 해당 함수의 동작을 잠시 멈춰두고 다른 함수의 처리를 하도록하는 것이다. 따라서 코루틴의 특징만 활용해서 멀티스레드처럼 I/O 관련 프로그램의 성능을 끌어 올릴 수 있게 된다.

오늘 이야기할 것은 코루틴이 아니라 제너레이터인데, 제너레이터는 보통 특정한 횟수만큼 혹은 무한히 계속 어떤 값을 만들어 낸다. 이를테면 우리가 많이 쓰는 range() 함수는 for 구문이나 리스트 축약에서 연속된 정수를 만들어내는 제너레이터를 리턴한다. 그런데 이런 range()와 같은 제너레이터 함수나 제너레이터 객체를 내부에서 사용하는 제너레이터 함수를 생각해 볼 수 있을 것이다.

예시로 알아보는 yield from 사용법

다음 예제는 n에서 0까지 감소했다가 다시 n까지 증가하는 일련의 정수를 생성하는 제너레이터 함수이다. n 값을 직접 조작해도 만들 수 있겠지만, 기왕이면 range() 함수를 사용한다면 그렇게도 구현할 수 있는 것 아니겠나.

def foo(n):
  for i in range(n, 0, -1):
    yield i
  for i in range(n):
    yield i + 1
list(foo(5))
# -> [5, 4, 3, 2, 1, 0, 1, 2, 3, 4]

그런데 너무 단순한 두 구문의 연결이 깔끔하다는 느낌이 들지는 않는다. 물론 이걸 깔끔하게(?) for 루프 하나로 다시 작성할 수도 있겠지만, 우선 여기서 range() 객체를 순회하면서 yield 구문을 사용한 것에 주목해보자. 이것은 결국 제너레이터가 그 내부에서 다른 제너레이터로부터 가져온 값을 다시 yield 하는 것이라고 말할 수 있는데, yield from 은 딱 그럴 때 사용하라고 있는 것이다.

yield from GENERATOR 를 사용하면 지정한 제너레이터가 yield 할 때마다 그 값을 yield 하는 형태로 작동하기에 위의 오르락내리락 카운터는 아래와 같이 구현할 수 있다.

def foo(n):
  yield from range(n, 0, -1)
  yield from range(n + 1)

사실 yield from은 실용적인 맥락에서는 별로 쓰일일이 없다. Python 3.5에서 비동기 코루틴의 결과를 기다리는 await 문이 추가되기 전에 같은 기능을 위한 문법으로 추가된 것이기 때문이다.

위 짧은 예제에서도 살펴볼 수 있었지만, for > yield 구조를 간단하게 표현할 수 있어서 한 제너레이터와 다른 제너레이터의 연계 뿐만 아니라, 재귀적으로 yield from 을 쓸 수 있기 때문에 이 구문을 사용함으로써 단순히 코드를 깔끔하게 줄이는 것 외에, 복잡할 수 있는 로직을 보다 편하게 구현할 수 있는 장점이 있다.

다음은 yield from 을 사용해서, 같은 것을 포함할 때의 순열의 경우를 생성하는 제너레이터를 구현한 것이다. 재밌는 것은 재귀 함수를 사용하는 기법과 달리 제너레이터/코루틴은 호출스택을 공유하지 않기 때문에 재귀의 깊이에 제한을 받지 않게 된다는 것이다.

from collections import Counter

def perms(xs):
    def helper(prefix, ws):
        if all(v == 0 for v in ws.values()):
            yield prefix
        for w, v in ws.items:
            if v > 0:
                yield from helper((*prefix, w), {**ws, w:v-1})
    yield from helper((), Counter(ws))   

코루틴에서의 yield from

제너레이터 내에서 yield from 을 사용하는 것은 마치 다른 제너레이터가 외부와 소통하게 하고 자신은 잠시 쉬는 것처럼 보이기도 한다. 제너레이터와 코루틴은 사실상 기술적으로 동일하기에 이 관점을 코루틴에게 도입한다면 좀 더 흥미로운 관점이 만들어진다.

일반적인 함수의 호출은 메인 루틴이 서브 루틴을 호출하고, 서브 루틴의 실행 흐름이 끝나면 다시 메인루틴이 서브루틴을 호출한 곳으로 되돌아가는 구조의 실행 흐름을 가진다. 코루틴은 보통 두 개의 루틴이 서로 데이터를 주고 받으면서 번갈아 실행되는 구조를 가진다.

그런데, yield from 을 코루틴에서 사용하는 것은 코루틴이 다른 코루틴에게 실행 흐름에 대신 참가하라고 위임하는 것인데, 이를 모식화해보면 다음과 같다. 두 코루틴 A, B 가 있어서 A가 실행되면서 B에게 데이터를 넘겨주면 다시 B가 실행되면서 A에게 값을 돌려준다. A > B > A > B … 의 흐름이 반복되던 중에 B가 또다른 코루틴 C에게 실행을 위임하는 것이다. 이 시점 이후부터는 A > B > A > C > A > C …로 상호작용의 대상이 변경된다. 하지만 코루틴 B는 실행이 완료된 것이 아니라 잠시 멈춘 것이며, yield from 구문이 끝난 후에는 다시 실행 흐름이 A > C > A > B > A > B 로 돌아올 것이다.

만약 코루틴 B가 파일시스템이나 네트워크로부터 어떤 데이터를 읽어오는 처리를 해야하는 상황이고, 이 때문에 전체적인 흐름이 멈춰야 하는 상황이라면, 전체 프로그램이 IO 작업을 기다리면서 블록되는 대신에 코루틴 C로하여금 다른 처리 가능한 일을 처리하게 하여 전체적인 효율을 높일 수 있을 것이다. asyncio 는 이러한 아이디어로부터 시작된 것이고 (사실 하늘에서 뚝 떨어졌다기보다는, 이러한 비동기 코루틴을 구현했던 파이썬 라이브러리들이 있었고 이런 라이브러리들의 아이디어를 차용해서 도입했다고 보는 것이 옳을 것이다.) 문법적으로 큰 변화가 없던 도입초기에는 await 문을 yield from 으로 썼었다.

반드시 비동기코루틴만 이런 방식으로 사용되라는 법은 없다. 2022년 현재 Python 3.10에서는 이미 async / await 문법이 정착된지도 오래되었다. 그래서 일반 코루틴을 이런식으로 결합하여 사용하는 법을 알아보자. 아래 예제는 PEP380에 등장하는 예제이다.

전달받은 숫자들을 계속더한 누적합을 내놓는 코루틴 acc() 가 있고, gather()acc()를 사용해서 숫자들을 모은다. 대신에 next(g) 가 호출되면 gather()는 위임에 사용했던 acc() 를 폐기하고, 다시 루프를 돌아 새 acc() 제너레이터를 생성하고 위임한다. 이런 방식으로 코루틴 간의 전환이 이루어지게 해서 작업을 관리하는 기초적인 아이디어를 확인할 수 있다.


def acc():
    s = 0
    while True:
        n = yield s
        if n is None:
            return s
        s += n



def gather(xs):
    while True:
        subtotal = yield from acc()
        xs.append(subtotal)

ns = []
g = gather(ns)
next(g)  # 코루틴을 최초로 준비하게 하는 동작
# yield from acc() 를 호출했기 때문에 
# 아래 send()는 실질적으로 acc()를 통해 생성된 제너레이터에 위임된다.
g.send(10) # -> 10
g.send(20) # -> 30
g.send(30) # -> 60

next(g)  # 기존 제너레이터를 폐기하고, 루프를 돌아 새 제너레이터를 만든다.
# 0        세 제너레이터의 기본값
print(ns) # => [60]

g.send(1) # -> 1
g.send(7) # -> 8
next(g)
# 0
print(ns) # => [60, 8]