[Python] anyio - 한 층 더 강화된 비동기 패러다임

반응형

어느덧 실무에서 Python 백엔드 엔지니어로 보낸지 1년이 조금 넘었습니다. 많은 고민과 고난, 그리고 이 자리에 오기까지 수많은 반성과 노력으로 파이썬 백엔드 엔지니어로써의 자리를 잡았던 것 같습니다. 

 

오늘 이야기를 위해 단도직입적으로 말씀드리자면 Python은 Java의 Spring과 달리 동기 처리보다는 비동기 처리가 더 나은 빛의 성능을 보인다는 것입니다. 하지만 파이썬의 비동기 패러다임은 그 역사가 매우 복잡하다고 할 수 있을 정도로 수많은 라이브러리가 있고 그 마저도 쓰기 어려운 부분에 속합니다.

 

 

Coroutine과 asyncio

Python의 동시성 처리는 Thread, Process와 같이 동기적인 방법으로 수행되었습니다. 하지만 Process를 이용한 동시성 처리는 Context switching과 같은 병목을 유발했고, 그를 대체해 나온 것이 바로 Thread 이지만 공교롭게도 Python의 멀티 스레딩은 GIL로 인한 자원 점유 이슈 솔루션 때문에 통상적인 멀티 스레드 방식 동시성 처리보다 느린 성능을 보입니다.

 

그래서 나타난 것이 바로 Coroutine입니다. Coroutine은 탈출 지점(return 지점)이 자유로운 함수를 일컫는 말로 우리가 통상 구현하는 함수는 return 지점이 정해져 있어, 그 지점을 빠져 나가야만 다음 작업을 할 수 있는 반면 Coroutine은 그 지점이 어느 시점에서든지 동작합니다.

 

하지만 Python의 Coroutine은 사용하기 간단하지 않았습니다. (어쩌면 Javascript의 Promise 보다도 더 복잡할지도..) 이 코루틴이 간단해지기까지 Python 진영에서는 3가지 코루틴 구현 방법이 존재하고 있습니다.

 

(아래의 항목들을 클릭하면 자세한 코드를 볼 수 있습니다.)

 

  • 더보기

    Python의 Generator는 값을 생성하는 함수로 yield 키워드를 사용하는 코드입니다. 함수가 값을 반환한 후 스코프를 소멸한 다음, 다시 함수를 호출하면 처음부터 다시 시작하는 기능인데, 이 기능을 일시 중지로 사용하며 코루틴을 구현한 방법이 바로 Generator based corutine 입니다.

    하지만 이러한 syntax는 이 문법이 함수인지, 제네레이터인지, 혹은 코루틴인지를 구분하기 어렵게 합니다. 위의 코드는 코루틴이며 함수 컨텍스트에서 데이터를 가져올 때 yield를 통해 일시중지하고 그 값을 가져오는 모습입니다.

    이를 우리는 제네레이터 기반 코루틴이라고 합니다.

  • 더보기

    asyncio의 모듈을 이용하여 코루틴을 구현하는 방법인데, 사실상 제네레이터 기반 코루틴과 다른점은 asyncio 모듈 위에서 동작시킨 다는 것의 차이입니다.

    제네레이터 기반의 코루틴이 파이썬의 스코프에 의해 동작되는 것이라면 asyncio는 이벤트 루프에 해당하며 큐를 이용해 태스크를 적재하고 일시 중지가 발생하면 상태를 저장한 다음 그 상태에 따라 함수를 실행시켜주는 방식입니다. 

    위 코드를 보면 yield from 키워드가 보이는데, yield from은 yield 반복문을 간단하게 하기 위한 syntax로 본래는 for x in asyncio.sleep(random.randint(0, 5)): yield x 코드를 줄인 것입니다. 

    이처럼 asyncio 모듈을 이용해 비동기를 구현하는 방법을 asyncio 모듈 기반 코루틴이라고 합니다.

  • 더보기

    네이티브 코루틴은 Python 3.5에서 등장한 비동기 처리 매커니즘으로 async-await 키워드가 여기에 해당합니다.

    함수의 선언은 def가 아닌 async def를 이용하고 있으며 값의 반환 역시 yield나 yield from이 아닌 await를 사용하고 있는 모습입니다. 지금 대부분의 파이썬 개발자 분들이 사용하는 방식일 것입니다.

 

비동기 처리 매커니즘에 대해서는 당시 Javascript 진영에서도 화두가 되었던 것이었습니다. 하지만 이런 다양한 매커니즘들은 오히려 레거시 처리를 어렵게 하는 부분이 있고, 이벤트 루프 모듈에 대한 사용법을 알아야하는 등 파이썬으로 비동기를 개발하는 데 있어서 높은 러닝 커브를 보이게 했습니다.

 

이와 비슷하게 Javascript V8의 경우도 싱글 스레드의 비동기 처리를 이용하지만 이러한 이벤트 루프에 대해서는 개발자가 신경쓰지 않아도 되어 사실상 개발자가 쉽개 서버 개발에 집중할 수 있도록 해주는 점과 다소 차이를 보입니다.

 

 

 

asyncio의 복잡성

asyncio는 얼핏 이 글로만 보면 단순한 이벤트 루프처럼 보이지만 그 내부에서는 무수히 많은 기능들을 포함하고 있습니다. 분명 asyncio는 모듈 자체는 훌륭했지만 장벽 높은 접근성은 개발자들에게 오히려 거리감만 주게 되었습니다.

 

저 또한 asyncio 모듈을 사용하고 있지만 asyncio는 단순한 이벤트 루프가 아니었습니다. 실제로 제가 생각하는 이벤트 루프, 여러분들이 생각하는 이벤트 루프는 각 단일 스레드 당 하나의 이벤트 루프를 가지는 것으로 생각하실 겁니다.

 

 

하지만 Python의 asyncio는 이와 같이 동작하지 않습니다. 메인 스레드에서 get_event_loop 함수를 이용해 이벤트 루프를 생성할 수 있는데, 이를 다른 서브 스레드를 만들고 생성하면 오류가 발생합니다.

 

만약 서브 스레드에서 이벤트 루프를 사용하고 싶다면 set_event_loop 함수를 통해 메인 스레드에서 생성한 이벤트 루프를 바인딩하여 사용할 수 있습니다. 이는 메인 스레드와 서브 스레드가 같은 이벤트 루프를 사용할 수도 있다는 것입니다.

 

서브 스레드에서 독립된 다른 이벤트 루프를 사용하고자 하는 경우 new_event_loop 함수를 호출해 사용할 수 있습니다.

그런데, 문제점은 이런 경우 서브 스레드에서 get_event_loop를 호출했을 때 위에서 생성한 새로운 이벤트 루프가 반환되어야 하지만 그렇지 않다는 것입니다.

 

이러한 문제는 오히려 asyncio를 이해하기 어렵다는 피드백으로 남게 됩니다.

 

 

 

 

anyio

asyncio가 파이썬 비동기에 거의 표준 라이브러리로 게재된 것은 맞지만 여전히 파이썬에서 비동기를 구현할 수 있는 다양한 라이브러리가 존재합니다. 그 중에서 anyio는 현재 FastAPI(정확히는 오리지널 프레임워크인 Starlette)에서 사용 중인 비동기 모듈에 해당합니다.

 

anyio는 새로이 개척된 비동기 모듈이 아닌 asyncio 혹은 trio 위에서 동작하는 그들의 구현체에 해당합니다. asyncio는 그 기능이 훌륭했지만 사용이 어려워 늘 부정적인 피드백을 받았고, trio 역시 간단한 사용성으로 내세웠지만 기능이 부실해 차가운 시선을 받았던 라이브러리입니다.

 

anyio의 특징은 trio가 가진 async-await의 아주 간단명료한 개념을 asyncio에 접목 시킨 라이브러리입니다. 제네레이터 코루틴이나 모듈화 코루틴과 같은 구조화된 동시성 개념이 없어 사용이 편리하고 개발에 더욱 집중할 수 있게 해줍니다.

 

먼저 설치부터 차근차근 해보고 무엇이 다른지를 알아보도록 하겠습니다.

 

 

 

Installation

anyio를 사용하기 위해서는 Python 3.6.2 버전 이상을 사용해야 하지만 모든 기능을 안전하게 동작하길 원한다면 Python 3.7 버전 이상 사용을 권장합니다.

$ pip install anyio
$ pip install anyio[trio]

asyncio가 아닌 trio를 백엔드로 사용하고자 하는 경우 extra 옵션에 trio를 넣을 수 있습니다.

 

 

 

Create Task

간단한 태스크를 하나 만들어보겠습니다.

 

 

anyio에서 작업을 순차적으로 생성해 비동기 처리가 되는지를 보기 위해서 create_task_group을 이용할 수 있습니다. 이 task_group은 파이썬의 비동기 컨텍스트 관리자를 사용하여 구현할 수 있고, 함수에 인자를 넣으려면 task_group의 start_soon 메서드를 이용하면 됩니다.

 

그러면 순차적으로 Task 0 ~ 5 running이 실행되었다가 1초 뒤 finished가 나타나는 것을 볼 수 있습니다.

 

 

 

Handling Exception

동시성 프로그래밍을 하다보면 동시 다발적으로 오류가 발생할 수도 있는데, 이런 다발적 오류를 anyio에서는 한 번 오류 발생으로 끝나는 것이 아닌 모든 오류를 내뱉어 줘야한다. 즉, 동시 다발적인 오류는 그 역추적을 모두 포함해야 한다라는 마인드를 가지고 동작합니다.

 

anyio는 바로 위의 task_group과 비슷한 ExceptionGroup을 사용합니다.

 

 

코드 구현대로 running 까지는 모두 동작합니다. 그 다음에 나오는 것이 바로 ExceptionGroup인데, 그 밑으로 어떤 오류가 나타났는지를 알려줍니다.

 

구현대로 몇 번째 라인에서 오류가 발생했는지를 보여줍니다. 

 

여기서 주의해야할 점은 비동기 처리이기 때문에 오류 발생의 순서는 보장되지 않는다는 점입니다. 화면에서는 running이 모두 print 되고 난 다음 Exception이 핸들링 되었지만 때로는 모두 finished 된 다음에 Error가 핸들링 되기도 합니다.

 

 

 

Synchronization primitives

모든 동시성 라이브러리와 마찬가지로 anyio에도 Lock(락), Semaphore(세마포어), Event(이벤트) 등 작업을 조정하기 위한 모든 동기화 요소를 제공합니다. 

 

  • Event (이벤트)
  • Semaphore (세마포어)
  • Lock (락)
  • Condition (컨디션)
  • Capacity Limiter (용량 리미터)

여기서 우리는 다소 생소한 Event와 Capacity Limiter 두 가지만을 예시로 다뤄보도록 하겠습니다.

 

위 코드는 10개의 Task가 event라는 자원 한 개에 대한 동시 접근 제한 코드입니다. Event는 재사용되지 못하고 한 번 사용한 뒤엔 버려지게 됩니다. 만약 해당 이벤트를 재사용하고자 한다면 똑같은 이벤트 인스턴스를 한 개 더 만들어 실행해야 합니다.

 

Capacity Limiter의 정확한 해석은 어떻게 되는지 모르겠지만 사용하는 방법은 세마포어와 비슷합니다. 표현한다면 여러개의 세마포어를 하나의 객체로 사용한다는 것이 맞을 수도 있을 것 같네요.

 

리미터를 2개로 주고 10개의 Task를 실행시킵니다. 그러면 최초로 받은 2개의 Task가 실행됩니다.

 

그런 뒤에는 큐에 쌓인대로 마지막에 들어간 Task가 그 다음 자원을 확보하고 순차적으로 실행합니다. 

 

anyIO에 대해 더 알아보고 싶다면 아래의 링크를 참고해보세요.

 

https://anyio.readthedocs.io/en/stable/index.html

 

AnyIO — AnyIO 3.4.0 documentation

© Copyright 2018, Alex Grönholm. Revision 5376d62a.

anyio.readthedocs.io

 

 

마치며..

이번 포스트에서는 Python으로 1년 동안 백엔드 엔지니어링하면서 얻은 비동기 지식, 그리고 그에 대한 간단한 후기를 포함하여 현 Python의 현황에 대해 적어보게 되었습니다.

 

아마 Java나 Javascript를 이용하고 계신 백엔드 엔지니어 분들이 이 글을 보신다면 "내가 알고 있는 Python이 이렇게 어려운 거였나..?"라는 말씀이나 생각을 하실지도 모르겠습니다. 하지만 과거에 비하면 많이 쉬워지고 있다는 것은 맞고, 다른 언어 생태계에 비하면 많이 부족하다는 것도 부정하기는 어렵습니다.

 

이번에 나온 anyio는 asyncio에서 가지고 있었던 많은 불편함 그리고 동시성 프로그래밍을 위한 매커니즘을 더욱 간단하게 사용하고 여태까지의 비동기 패러다임에 있어 불편한 점들을 한대모아 개선했다는 점을 가장 큰 주요점으로 뽑고 있습니다.

 

그나마 다행인 것은 anyio라는 이런 좋은 패러다임이 기존의 asyncio오 호환된다는 것은 다행인 일입니다. 하지만 이미 asyncio 기반으로 개발된 SQLAlchemy 1.4나 aiohttp와 같은 라이브러리들이 이들로 리팩터링해 바꿔나가는 것은 그만큼 많은 시간이 소모되며 아직까지도 파이썬 내 비동기 패러다임은 통합되지 못하고 분산되었다는 것입니다.

 

파이썬이 그만큼 자유로운 언어인 것은 보장받을 수 있지만 애플리케이션을 개발하는 한 엔지니어로써 이러한 모습은 오히려 깊은 레거시를 더욱 유발하고 관리하기 어려운 프로덕트를 만드는 데 원인이 되는 언어가 되지 않아 우려스럽습니다.

 

 

 

 

참고: AnyIO 공식 문서 (https://anyio.readthedocs.io/en/stable/index.html)

반응형
  • 익명

    비밀댓글입니다

    • Favicon of https://blog.neonkid.xyz BlogIcon Neon K.I.D

      안녕하세요. 우선, anyio는 포스트에 적은그대로 비동기 백엔드가 아닌 asyncio를 백엔드로 하고 있는 구현체입니다.

      따라서 asyncio에서 어렵게 사용했던 다중 태스크 등을 태스크 그룹 등을 통해서 쉽게 여러 작업을 한 스레드에서 돌릴 수 있도록 해주며,

      또한 asyncio에서 다루기 어려웠던 에러 핸들링 또한 ExceptionGroup을 통해 각 태스크에서 발생한 Exception을 전부 핸들링하고 보여줄 수 있도록 개선한 것이 바로 anyio입니다.

      말씀주신 이벤트 루프를 신경써야하는 번거로움은 여전히 파이썬에서 비동기하기 위해 존재하는 단점 중 하나입니다.

      다만 JS 등의 다른 언어들은 이벤트 루프에 대해 깊게 알지 않아도 잘 구현되어 있는 반면 파이썬에서는 여러 이벤트 루프들이 존재하고 그를 통해 만들어진 프레임워크들이 있다보니 잘 사용하기 위해서는 asyncio를 그만큼 잘 알아야 할 필요가 있다는 것을 포스트에서 전달하고자 했던 것입니다.

      점점 개선은 되어지고 있지만 여전히 부족한 점이 있고, 그나마 좀 개선된 부분 중에 하나인 anyio를 이야기 하기 위해 적어봤습니다 ^^..

    • BlogIcon ekzm8523

      답변 감사합니다 ㅎㅎ anyio의 유연한 사용법을 보고 python 3.10 부터 그 사용법을 그대로 적용시킨 것 같다는 느낌이 들었어요! FastAPI 관련된 포스팅중에서 가장 퀄리티가 높고 내공이 느껴지는 자료들인 것 같습니다 !! 앞으로도 열심히 볼게요 감사합니다.

    • Favicon of https://blog.neonkid.xyz BlogIcon Neon K.I.D

      덧붙여서 작성자님께서 올리신 asyncio.run 함수는 Python 3.7부터 제공되었던 것입니다. 참고바랍니다.