async with : 비동기 컨텍스트 매니저

파이썬의 컨텍스트 매니저with 블럭을 적용할 수 있는 객체를 말한다. 이러한 객체들은 with 절에서 마치 블럭에 대한 데코레이터처럼 동작한다. 가장 흔한 예가 open() 함수로 생성하는 파일 입출력스트림으로, with 구문 내에서 쓰이면 블럭을 빠져나갈 때 파일을 닫는 동작을 자동으로 수행하게 된다.

with open('data.txt') as f:
  for line in f:
    print(line)

컨텍스트 매니저 객체는 __enter__(), __exit__() 두 개의 내장 메소드를 가지고 있는 것으로 간주된다. 위 코드에서는 with 다음에 나오는 open('data.txt') 라는 코드는 파일에 대한 입출력 스트림을 반환한다. 그리고 with 문을 빠져나갈 때, 파일에 대해 __exit__()가 호출되고 여기서 파일이 닫힐 것이다.

async with

파이썬 3.5에서부터 비동기 코루틴을 사용하는 문법이 async def 로 추가되는 등, non-block I/O에 관련된 기능들이 적극적으로 언어차원에서 도입되고 있다. 이 중 async with 문은 이른바 비동기 컨텍스트 매니저로 소개되는데, 이건 어디에 쓰는 것일까?

컨텍스트 매니저에 대해서 with를 적용하면 코드 블럭을 진입하는 시점과 빠져나오는 시점에 약속된 동작을 수행하게 된다. 그런데 이 약속된 동작들이 만약 처리 시간이 많이 걸리는 IO 작업이라면? asyncio의 세계에서는 이러한 작업을 비동기처리해서 await 하고, 이렇게 기다리는 동안에는 다른 코루틴들이 진행될 수 있도록 한다. 즉 이러한 전환이 with의 앞/뒤에서도 똑같이 일어날 수 있게 하려는 것이고 이것이 비동기 컨텍스트 매니저가 등장한 배경이다.

아쉽게도 비동기 컨텍스트 매니저는 아직 contextlib에는 추가되지 않은 듯 하고, 직접 구현해야 한다. 극적(?)인 효과를 위해서 먼저 주어진 메시지를 매우 천천히 출력하는 코루틴 (즉,시간이 오래걸리는 출력작업) 을 하나 작성하고 시작하자.

async def log(msg, l=10, f='.'):
  for i in range(l*2+1):
    if i == l:
      for c in msg:
        sys.stdout.write(c)
        sys.stdout.flush()
        await asyncio.sleep(0.05)
    else:
      sys.stdout.write(f)
      sys.stdout.flush()
    await asyncio.sleep(0.2)
  sys.stdout.write('\n')
  sys.stdout.flush()

위 코루틴은 메시지를 넣어주면 지정된 길이만큼의 장식 문자를 앞뒤로 덧붙여 출력하는데, 괜히 오래걸리는 것처럼 보이게 하려고 한글자씩 찍으면서 지연을 두게 하였다.

비동기 컨텍스트 매니저

이제 비동기 컨텍스트 매니저를 하나 만들어보자. 비동기 컨텍스트 매니저는 __enter__(), __exit__() 대신에 “a”가 붙은 __aenter__(), __aexit__()를 구현해야 한다. 이 둘은 모두 그 내부에서 await를 할 수 있는 awaitable이어야 하므로, async 키워드를 써서 비동기 코루틴으로 작성되어야 함에 유의하자. 여기서 작성하고자 하는 컨텍스트 매니저 클래스는 별로 하는 일은 없고 그저 with 블럭 진입시와 탈출시에 메시지를 출력한다. 단, 위에서 작성한 log() 코루틴을 사용하기 때문에 진입과 탈출에 제법 많은 시간이 걸릴 것이다.

class AsyncCM:
  def __init__(self, i):
    self.i = i

  async def __aenter__(self):
    await log('Entering Context')
    return self

  async def __aexit__(self, *args):
    await log('Exiting Context')
    return self

이제 이 AsyncCM의 인스턴스 객체는 async with 구문과 함게 사용될 수 있다. 주의할 것은 async with 구문은 비동기 구문이며, 따라서 비동기 코루틴 내에서만 사용할 수 있다는 점이다. 일반 함수나 메소드 작성 중 이 코드를 사용하면 구문 오류(Syntax Error)가 뜬다. 비동기 컨텍스트 매니저를 사용하는 예제를 다음과 같이 만들고 테스트해보자. for 구문을 돌면서 나오는 숫자의 앞뒤로 매우 느긋하게 출력되는 문자들이 보일 것이다. 만약 2개 이상의 작업이 돌아간다면 메시지의 각 글자가 출력되는 와중에 다른 작업으로 전환하는 것을 볼 수 있다.

async def main1():
  '''Test Async Context Manager'''
  async with AsyncCM(10) as c:
    for i in range(c.i):
      print(i)

## 실행
loop = asyncio.get_event_loop()
loop.run_until_complete(main1())

특히 대부분의 asyncio 관련 예제들은 asyncio.sleep()만 주구장창 쓰기 때문에, 이걸 지연효과를 보려고 만든 것인가? 싶은 예제들이 많을 것이다. 아니다, 이들은 IO를 기다리기 때문에 이만큼 시간이 오래 걸릴 것이라는 것을 묘사하는 셈이다. 그리고 그 시점에 런루프에 등록된 다른 코루틴이 있으면, 이 대기시간 동안 다른 작업을 처리할 수 있다는 것을 의미한다.


async for – 비동기 for 루프

파이썬 3.5에서는 비동기 컨텍스트 매니저 외에 비동기 이터레이터도 도입되었다. 이터레이터가 뭐냐면, for … in 구문에 사용될 수 있는 객체라고 대충 생각하면 좋은데, 내부적으로 __iter__() 메소드와 __next__() 메소드를 가지는 객체이다. (사실 엄밀하게 따지면 이터레이터는 __next__() 메소드만 구현한 것이며, __iter__()는 이러한 이터레이터를 리턴하도록 약속된다.) 그래서 __iter__()를 가지고 있으면 ‘반복가능’하다고 한다. 대부분의 반복가능 객체들은 자기 스스로가 이터레이터인 경우가 많다.

이터레이터의 __next__()메소드는 next() 에 의해서 호출되고, 이것은 주로 for … in 구문에 의해서 사용되는 매커니즘으로, 이터레이터의 구현이 느긋하다면 시스템 자원을 효율적으로 사용할 수 있다. 대표적인 예가 range() 함수로 우리가 range(10_000_000) 를 쓰더라도 정수객체는 필요할 때마다 하나씩 생성될 뿐, 천만개의 정수를 한꺼번에 생성하지 않는다는 것이다.

비동기 이터레이터는 이렇게 개별 원소를 생성하는데 비동기 작업이 요구되는 상황에 사용될 수 있다. 예를 들어 루프를 돌아야하는 대상 데이터가 백만개쯤된다면 이것을 모두 메모리에 로드할 수는 없을 것이다. 따라서 인덱스 값등을 사용해서 루프를 돌면서 매 원소를 DB 액세스 등을 통해서 읽어와야 할 수 있을 것이다. 백만번의 루프를 도는 전체 시간 중에는 아마 높은 확률로 IO 대기시간이 대부분의 시간을 차지할 것이기 때문에 이런 경우에 유용할 것이다.

컨텍스트 매니저와 마찬가지로 이터레이터 역시 두 개의 메소드 __aiter__(), __anext__()를 갖는 것으로 간주된다. (이 중 __anext__()는 awaitable하므로 비동기 코루틴이다.)

다음은 일정한 시간 지연을 두고 정수값을 만들어내는 비동기 이터레이터의 간단한 구현이다.

class Ticker:
    def __init__(self, to, interval=0.5):
        self.value = 0
        self.to = to
        self.interval = interval

    def __aiter__(self):
        return self

    async def __anext__(self):
        if self.value >= self.to:
            raise StopAsyncIteration
        await asyncio.sleep(self.interval)
        r, self.value = self.value, self.value + 1
        return r

이를 다음과 같이 테스트 해볼 수 있다.

async def test():
    t = Ticker(10)
    async for i in t:
        print(i)

if __name__ == '__main__':
    fs = [test() for _ in range(3)]
    asyncio.run(asyncio.wait(fs))

기대했던 것과 같이 약간의 지연 후에 숫자가 출력되는데, 각각의 이터레이터가 지연되는 사이에 다른 이터레이터가 실행되므로 숫자가 한꺼번에 출력된다.

혼동하지 말아야 할 것은 async for 는 for 루프 전체가 비동기로 동작하는 것이라는 점이다. 즉 async for 의 루프를 도는 동안 await가 있으면 이를 기다리는 동안 다른 비동기 코루틴을 수행하는 것이지, 그 다음번 루프 회차가 미리 실행되지는 않는다는 것이다.


비동기 제너레이터

제너레이터는 이터레이터와 비슷하게 동작하는 객체로 주로 “제너레이터 함수”에 의해 실행된다. 제너레이터 함수는 사실상 전통적인 코루틴으로 return 대신 yeild 키워드를 사용해서 값을 내놓을 수 있으며, 값을 내놓은 후에 그 상태에서 일시정지 한 후 next() 함수에 의해서 정지한 지점에서 다시 실행을 재게할 수 있다.

비동기 제너레이터는 async def를 사용하여 선언되는데, 내부에서 yield 키워드를 사용한다.이는 클래스를 따로 작성하지 않고 간단하게 비동기 이터레이터를 구현하는 것과 사실상 동일하게 사용할 수 있다. 앞서 작성한 Ticker를 제너레이터로 구현하면 다음과 같다.

import asyncio


async def aticker(a, interval=0.5):
    i = 0
    while i < a:
        await asyncio.sleep(interval)
        yield i
        i += 1


async def test():
    async for i in aticker(10):
        print(i)


if __name__ == '__main__':
    fs = [test() for _ in range(3)]
    asyncio.run(asyncio.wait(fs))

비동기 축약

async for 는 파이썬 3.6부터 리스트 축약(list comprehension) 구문 내에서도 사용 가능하다. 비동기 이터레이터나 제너레이터를 사용하여 리스트, 사전, 집합을 만들 수 있다.

async def sum10():
  print('start')
  xs = [x + i async for in aticker(10, delay=0.1)
  print(sum(xs))

정리

이상으로 비동기 컨텍스트 매니저에서부터 시작하여 제너레이터, for 반복문, 축약 표현에 이르기까지 새로운 문법과 기능을 사용하여 awaitable로 구현하는 방법에 대해 살펴보았다. asyncio만 달랑 처음 도입되었을 때보다도 이를 적용할 수 있는 부분이 더 많아지면서 단일 스레드 비동기 모델을 적용하여 보다 깔끔한 코드를 작성하는데 도움이 될 것 같다.

참고자료