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

파이썬 제너레이터는 특별한 종류의 함수 객체이다. 함수 내부에서 yield 구문을 사용하여 특정 값을 내놓은 후에도 실행을 종료하지 않아 제거되지 않고, 다시 그 자리에서부터 이어서 계산을 반복하고 다시 값을 내놓을 수 있다. rangemap, filter 등의 객체가 제너레이터의 일종이라고 할 수 있다.

아주 먼 옛날, 파이썬 2.5에서 제너레이터에 특별한 기능이 생겼는데, 바로 제너레이터 속으로 값을 전달하는 기능이다. (PEP342) 이는 매우 흥미로운 패턴으로 이어지게 되는데, 실행 중 한 번 yield 문을 만나 자신의 위치를 기억하고 있다가 다시 그 자리에서 실행이 가능하다는 점에서 두 개 이상의 제너레이터가 서로 값을 주고 받으면서 교차식으로 실행하는 것이 가능하다. 이는 일반적인 함수 호출의 패턴인 주 루틴 – 서브 루틴의 관계와 달리 두 개의 루틴이 함께 실행된다는 부분에서 코루틴(coroutine)이라고 부른다.

코루틴은 사실 완전히 새롭게 등장한 개념은 아니었다. 이미 6~70년대에 기반이 닦여진 기술이이었다. 당시에는 작업 흐름의 분산을 위한 여러 가지 개념들이 도입되고 시도되고 있었는데, 이 당시에 이러한 기술들 중에서 가장 환호를 받았던 것은 다름 아닌 멀티스레드였다.

멀티스레드가 큰 인기를 얻고 발전해 나가면서 상대적으로 코루틴은 거의 방치되다 시피하였으나, 규모가 커짐에 따라 멀티 스레드는 자원 경쟁이라든지 동기화문제 등 더 큰 골칫거리를 가져왔다. 이러한 문제로 인해 코루틴 개념은 그린릿(greenlet)이나 경량스레드(lightweight thread)라는 이름으로 재발견되어 주목받는 경우도 있다.

코루틴이 흥미로운 지점은 멀티스레드 없이 하나의 스레드 위에 여러 개의 실행흐름이 존재할 수 있다는 것이다. 즉 멀티스레드에서의 골치 아픈 문제들을 끌어들이지 않고서도 실행 흐름을 분산할 수 있다는 것을 의미한다.

하지만 분산처리와 같은 상황이 아니더라도 제너레이터 혹은 코루틴은 파이썬 프로그래밍에서 매우 중요한 비중을 차지한다. 멀티스레드와 같은 적극적인 동시성이 아니더라도, 제너레이터의 ‘느긋한(lazy)’한 특성은 실제로는 concurrent 하지 않은 작업들을 마치 동시에 진행되는 것처럼 다룰 수 있게 하며, 무거운 연산을 가능한 뒤로 미루어 실행 시간내의 체감 퍼포먼스가 좋은 것처럼 보일 수 있게 한다. (사실 이정도면 충분한 것이, 파이썬의 멀티스레드는 실제로는 동시에 실행되지 않기 때문이다.)

이 외에도 제너레이터와 코루틴은 간단한 클래스를 대체할 수 있으며, 일련의 처리 과정에서의 단위 작업을 구성하고 이들을 선언적으로 연결하는 방법으로도 활용할 수 있다. 오늘 이 글에서는 제너레이터와 코루틴에 대해 알아보도록 하자.

제너레이터

제너레이터는 유한 혹은 무한한 횟수만큼 어떤 값을 만들어 내놓는 객체이다. 제너레이터 함수는 이러한 제너레이터를 생성하는 함수로, 함수 내부에 yield 를 사용한다. 쉽게 생각하면 함수 내부에서 yield가 사용되었다면 제너레이터 함수라고 생각할 수 있다.

yield를 사용한 함수를 제너레이터 함수라고 한다. 엄밀하게 말하면 제너레이터는 이러한 제너레이터 함수를 호출하여 얻게 되는 객체이지, 정의된 함수 자체가 아니다.

간단한 예를 보자. 다음의 countdown() 함수는 특정 값부터 1까지의 정수값을 생성해내는 제너레이터 함수이다.

from typing import Generator


def countdown(n: int) -> Generator[int, None, None]:
    while n > 0:
        yield n
        n -= 1

제너레이터 함수를 호출하면 제너레이터가 생성된다. 생성된 제너레이터는 실행가능한 코드를 담고 있는 상자에 비유할 수 있는데, 생성 즉시 그 내부의 코드가 실행되지 않으며, 외부에서 제너레이터가 시작되도록 액션을 취해야 한다. 파이썬 기본함수 next() 가 이 역할을 담당한다. next() 함수는 제너레이터가 중지되어있는 위치에서 다음번 yield를 만날 때까지 실행하고, yield를 통해서 넘겨지는 값을 받아서 리턴한다. 그리고 해당 제너레이터는 yield 구문 직후에서 실행을 멈춘채 대기한다.

c = countdown(10) # - 1

print(next(c)) # - 2
10

print(next(c)) # -3
9
  1. countdown(10)을 호출하여 새로운 제너레이터를 생성했다. 제너레이터의 시작지점은 while n > 0: 바로 앞, 그러니까 함수의 시작 부분이다.
  2. next(c)가 호출되면 해당 제너레이터가 실행을 시작한다. yield n 까지 실행하고 n값(10)이 리턴됐다.
  3. 다시 next(c)가 호출됐다. yield n 바로 다음부터 실행되어 n 값을 1 감소시킨 후 while루프를 돌아서 다시 yield n에서 중지되고, 이번에는 9가 리턴됐다.

이런 식으로 제너레이터는 아주 간단하게 만들 수 있는 이터레이터가 됐다. 실제로 제너레이터는 이터레이터와 동일하게 동작한다. 위 코드에서 while문을 빠져나와서 제너레이터가 리턴하게 되면 어떤일이 벌어질까? StopIteration 예외가 발생한다.

...
print(next(c))
1

print(next(c))
StopIteration:

우리가 rangemap, filter, zip 등의 객체를 for ... in 문에 사용할 수 있는 것처럼, 이렇게 간단하게 생성한 임의의 제너레이터 역시 for 반복문에 사용될 수 있다. for 문은 이터레이터의 StopIteration 예외를 내부적으로 깔끔하게 처리하므로, 예외가 발생했다는 메시지는 볼 수 없을 것이다.

for n in countdown(10):
  print(n)

실제로 for ... in 반복문은 반복가능 객체의 StopIteration 예외를 단순히 무시하는 것이 아니라 특별하게 취급하여 처리한다. 바로 else: 문이다. for: ... else:로 반복문을 작성하면 StopIteration 예외를 만나는 지점에서 else: 구문이 실행된다. 우리는 이것을 “for 루프가 break 없이 완료되었을 때” 작동할 코드를 넣을 지점으로 활용할 수 있다.


좀 더 실용적인 예제를 살펴보자. 다음은 tail -f 명령의 간략한 파이썬 구현이라 할 수 있다. 파일 핸들러를 받아서 파일의 끝으로 이동한 다음, 0.1초마다 새 라인을 읽어서 내놓은 제너레이터를 다음과 같이 만들 수 있다. 또 이를 사용하면 간단히 for 문으로 로그를 모니터링할 수 있다.

from time import sleep


def follow(fd):
    fd.seek(0, 2) # 파일의 맨 끝으로 이동
    while True:
        line = thefile.readline()
        if not line:
            time.sleep(0.1)
            continue
        yield line


with open('access.log') as log:
  for message in follow(log):
    print(line)

yield 구문의 변화

파이썬 2.5에서 yield는 표현식의 기능을 겸하게 되었다. 이전까지 yield란 제너레이터가 어떤 루프 속에서 잠시 멈추면서 값을 내놓는 지점을 설정하는 용도로 쓰인 ‘구문’이었다. 그런데 표현식이 되었다는 것은 대입문의 우변이 될 수 있다는 말이며, 이는 곧, ‘값으로 평가된다’는 말이다. 이 말은 yield 에서 일시 중지된 제너레이터 코드는 다음번 실행시에 yield를 평가하면서 재시작된다는 뜻이다. 알고보면 이는 매우 혁명적인 변화이다.

yield가 어떤 값으로 평가될 때 그 값은 외부에서 주입한 값이 될 것이다. 즉 실행중인 어떤 코드에게 중간에 값을 전달해줄 수 있는 방법이 생긴것이다. 이제 단순히 일방적으로 정해진 규칙에 따라 값을 만들어내기 보다는 외부로부터 입력을 받아 처리하는 별도의 루틴이 되었다. 이런 관점에서 값을 입력 받을 수 있는 제너레이터를 코루틴이라고 부른다.

이전에는 제너레이터는 next() 함수에 대응하기 위한 __next__()라는 속성을 갖고 있었다. 코루틴은 여기에 send()라는 메소드를 추가로 갖고 있다. 이 메소드는 __next()__와 기본적으로 동일하지만, 인자값을 받을 수 있고, 해당 인자값이 yield의 값으로 평가된다.

간단한 예제를 보자. 다음 코드에서 생성하는 제너레이터는 문자열을 전달받아 특정한 포맷에 맞게 출력하는 일을 수행한다.

def printer():
    while True:
        line = (yield)  # <- 입력을 받는 지점
        w = len(line) + 4
        l = '*' * w
        print('\n'.join([l, line.center(w), l, '']))


prn = printer()
next(prn)
prn.send('hello')
*********
  hello
*********

다만, 코루틴에서 주의해야 할 것은 yield 구문까지는 일단 진행해서 멈춘 상태여야 send()를 호출할 수 있기 때문에 생성 즉시 next()를 한 번 호출해야 한다는 점이다.

보통은 이 과정을 빼먹기 쉬우므로 시작되지 않은 제너레이터에 send를 호출했다는 에러를 만나는 실수를 범하기 쉽다. 코루틴은 생성 직후 무조건 next()를 통해서 시작해야하므로, 코루틴인 경우에 자동으로 next()를 수행하도록 하는 데코레이터를 만들어두면 좀 간편하게 사용할 수 있을 것이다.

from functools import wraps


def coroutine(f):
    @wraps(f)
    def start(*args, **kwarg):
        co = f(*args, **kwargs)
        next(co)
        return co
    return start

이제 다음과 같이 바로 사용하는 것이 문제 없다.

@coroutine
def printer():
    while True:
        line = (yield)  # <- 입력을 받는 지점
        w = len(line) + 4
        l = '*' * w
        print('\n'.join([l, line.center(w), l, '']))

p = printer()
p.send('hello')

파이썬 3.5에서 async def 가 생기기 이전에 비동기 코루틴을 생성하는 방법도 @asyncio.coroutine 데코레이터를 사용하는 것이었다.

파이프라인

우리는 일련의 데이터 처리 과정에서 단위 작업을 함수로 만들어서 함수와 함수를 연결하는 식으로 프로세스를 완성한다. 코루틴도 이와 비슷하게 작성될 수 있다.

앞서 만든 follow 코루틴은 파일 디스크립터를 통해 생성되어 새롭게 추가되는 라인을 뽑아내는 역할을 하는 제너레이터였다. 만약 뽑아낸 라인을 처리할 코루틴을 이 제너레이터가 알고 있다면 생성한 데이터를 해당 코루틴에게 전달하여 처리하게 할 수 있을 것이다.

yield line 에서 데이터를 yield 하기 전에 다시 컨베이어 벨트의 다음 과정에 있는 코루틴이 있다면 해당 코루틴에게 값을 넘겨서 처리하도록 하는 것이다. follow를 여기에 맞게 수정해보자.

def follow(fd, target=None):
    fd.seek(0, 2) #Go to the end of the file
    while True:
        line = fd.readline()
        if not line:
            time.sleep(0.1)
            continue
        
        # target이 있다면 발견한 라인을 전달
        if target:
          yield target.send(line)
        else:
          yield line

간단한 필터 코루틴도 하나 만들어보자. 여기서 등장하는 대부분의 코루틴은 ‘다음번 타자’를 지정한다는 것에 주의한다.

@coroutine
def filter_co(term, target):
  while True:
    line = yield
    if term in line:
      target.send(line)

이제 우리가 가진 것을 살펴보자.

  1. 파일의 끝에 새로 추가되는 라인을 감지하는 제너레이터
  2. 특정한 단어가 포함되는지를 체크하는 필터 (코루틴)
  3. 화려한(?) 장식과 함께 문자열을 출력하는 코루틴

그리고 이들을 연결하여 하나의 프로그램을 만들 수 있을 것이다. 이를 테면 특정한 파일을 지켜보다가 원하는 단어가 있을 때 출력하는 것이다.

with open('a.txt') as fd:
  prn = printer()
  fco = filter_co('python', prn)
  for _ in follow(fd, fco): pass

물론 코루틴이 아닌 함수를 사용해서 이것과 똑같은 프로그램을 만드는데에는 제한이 없다. 하지만 비록 코루틴이 입출력 측면에서는 함수 호출과 다른 점이 별로 없다고 하더라도 여기에는 주요한 차이가 하나 있다. 위 예제에서도 printer()filter_co()는 짧은 시간 내에 반복해서 호출된다. 이 때 데이터가 함수의 호출 연쇄를 따라 움직인다면 상대적으로 많은 비용이 들기 때문이다. (함수 호출에는 스택 영역의 생성과 복사 등 여러 작업이 수반된다.)

코루틴은 처음 생성될 때 자신이 사용할 메모리를 할당받은 상태이고, 내부 코드에서 사용하는 지역 변수들의 메모리도 완전히 리턴하기 전까지는 유지된다. 따라서 send()를 통해서 값을 전달하는 것은 단순한 ‘점프’ 수준의 동작이 되며 함수 호출에 따르는 비용을 수반하지 않는다.

그 외에도 생성한 코루틴은 객체로 존재하기 때문에 이리저리 들고 다니기도 간편하다. 예를 들어 포함된 키워드가 하나가 아니라 여러 단어라면?

보너스 – 브로드캐스팅

여러 각 라인에 대해서 하나 이상의 단어를 매칭해서 출력하려는 경우를 상정해보자. 어떤 경우에는 ‘apple’ 이라는 단어에 대해서는 A가 동작해야 하고, ‘python’이라는 단어에 대해서는 B가 동작해야 하는 경우도 있을 것이다. 이러한 스위칭을 위해 하나의 값을 받아서 여러 개의 타깃 코루틴으로 값을 전송해주는 broadcast 코루틴을 작성할 것이다.

@coroutine
def broadcast(targets):
  while True:
    msg = yield
    for t in targets:
      t.send(msg)


# 출력, 파일에쓰기 등으로 구분된 동작을 만든다.
# file_writer()는 있다고 가정하자.
prn = printer()
frt = file_writer('python.txt')
frt2 = file_write('banana.txt')

keywords = ('apple', 'python', 'banana')
workers = (prn, frt, frt2)
filters = [filter_co(x, y) for x, y in zip(keywords, workers)]
broad = broadcast(filters)

for _ in follow(log_file, broad ):
  pass

코루틴 vs 클래스

코루틴은 반복작업에서 함수가 가지는 오버헤드를 우회할 수 있는 좋은 방법인 동시에, 해야할 일을 적절하게 감싸서, ‘나중에 실행하게될’ 일을 객체화하는 것처럼 사용할 수 있게 해준다. 물론 이러한 방식의 디자인은 클래스를 사용하여 정의하는 것도 충분히 가능하다. 그렇다면 코루틴을 사용하는 것이 클래스보다도 좋은 점이 있을까? 물론이다.

  1. 각 역할에 맞는 클래스를 생성하려면, 당연히 정의해야 한다. 다만 이 때 작성해야하는 코드의 분량 측면에서는 코루틴이 훨씬 유리하다. 왜냐하면 클래스에서는 최소 2개의 메소드를 정의할 필요가 있다. (__init__(), send()에 해당하는 메소드) 이에 비해 코루틴은 하나의 함수만 정의하면 된다. 즉, 코드량에서 일단 유리하다.
  2. 여러 가지 연관 메소드를 가지고 있는 큰 클래스를 만들 것이 아니고, 한 개의 인스턴스만 만들 것이라면 성능면에서도 여전히 코루틴은 유리하다. 객체 인스턴스의 메소드도 결국은 함수 호출이며, 객체 내의 속성을 참조해야 하는 상황이라면 여기에 속성 이름을 lookup 하는 비용까지 함께 수반되어야 하기 때문이다.

비동기 코루틴

코루틴은 스레드 1개에서 실행가능한 경로들이 흩어져 있는 상태로 볼 수 있다. 또한 동시성 처리를 위한 솔루션으로도 검토되었던 시기가 있다고 했었다. 지금까지 살펴본 예제들은 코루틴들이 각자 처리하는 일이 다르기는 하지만 하나의 스레드에서 한 번에 한 코드가 실행되는 구조로 작동하고 있다. 멀티 스레드가 아닌 이상 현재의 구조에서는 코루틴만 사용한다 하더라도 CPU 타임을 계속해서 사용하기 때문에 실행 시간을 단축시킬 수 있는 방법은 없을 것 같다.

하지만 이런 코드에서 살펴보면 대부분의 시간은 CPU보다는 IO에 집중되는 경향이 있다. 우리가 작성한 예제들도 거의 대부분의 시간은 file.readline()이나 time.sleep()에 걸릴 것이며, 이는 이 대부분의 시간동안 CPU는 놀고 있을 것이라는 말이다.

비동기 코루틴은 주로 IO가 많은 작업에 있어서 CPU가 노는 시간동안 ‘다른 일’을 하도록 해서 전체적인 수행 시간을 줄이고 성능을 끌어올릴 수 있는 좋은 해결책이 될 수 있다. 파이썬의 asyncio 가 그러하다. python 3.5부터는 async def, await와 같은 신규 키워드가 추가되었지만, 그 이전의 asyncio는 이러한 비동기 작업을 비동기 코루틴을 통해서 구현했다.

일반적인 코루틴이 yield 키워드를 만날 때마다 실행하던 것을 멈추고 다른 일을 하는데, 이 때 문맥의 전환은 자신의 send()를 호출한 곳으로 돌아가게 된다. 비동기 코루틴은 이렇게 순차적으로 코루틴의 실행 순서를 오가는 것을, 런루프를 통해서 대기하고 있는 작업 중 하나가 실행기회를 얻도록 한다. 현대의 OS 구조는 IO 작업이 CPU와 별개로 돌아갈 수 있으므로 디스크에 있는 파일을 액세스하거나 네트워크 소켓을 액세스하는 시간동안 다른 작업을 처리해서 마치 동시에 여러 작업을 처리하는 것 같은 효과를 낸다.