백엔드 개발자들이 알아야할 동시성 번외편— Python의 Concurrency

Choi Geonu
13 min readSep 30, 2023

이전 포스팅에서는 Coroutine의 개념과 Coroutine이 어떻게 협력적 스케줄링에 활용될 수 있는지 정리해 보았습니다. 그리고 Python에서의 Coroutine의 흥미로운 역사에 대해 언급했는데, 이번 포스팅에선 Python에서의 Coroutine이라는 주제를 이야기 해볼까 합니다.

Python 로고

Global Interpreter Lock

Python을 production 환경에서 운영해본 경험이 있는 분이라면 대부분 GIL (Global Interpreter Lock)에 대해서 들어보신적이 있을겁니다. GIL이란, 간단하게 이야기 한다면, 여러개의 CPU 코어를 가진 머신에서 하나의 Python Process에서 여러개의 Thread를 동시에 실행한다해도 실제로는 하나의 Thread만 실행된다는 제약 조건이라 할 수 있습니다. 즉, Thread를 이용한 동시성은 시분할로만 동작하고 여러개의 CPU 코어를 동시에 사용할 수 는 없다는 것 입니다.

이 제약사항은 일반적인 웹 서버를 만드는데에도 큰 영향을 끼치게 됩니다. Synchronous이며 Blocking한 I/O를 기반으로 하는 웹서버를 가정 해보겠습니다. 이러한 서버에서 10개의 요청을 동시에 처리 하려면 어떻게 해야할까요? 간단하게 Thread를 10개를 만들면 해결할 수 있을겁니다. 하지만 앞서 이야기했듯이 Python에서는 GIL으로 인해 Thread의 사용이 매우 비효율적입니다. 이 때문에 Thread를 많이 늘리는것은 일반적으로 좋은 생각이 아닙니다.

Thread를 사용하는 대신, Multi-Process를 이용해 GIL을 우회할 수 있습니다. Interpreter 레벨의 Lock이니 Interpreter 자체를 여러개를 사용하는 아이디어죠. 하지만 이것도 좋은 생각이 아닙니다. 앞선 포스팅에서 이야기 한 것 처럼 하나의 Thread는 계속해서 늘리게 되면 메모리 사용량과 Context switch에 대한 비용이 늘어나게 됩니다. 이는 Process에서도 동일하게 적용되는 문제입니다.

일반적으로 (Process를 포함한) Thread의 개수는 CPU 코어의 2배 수준으로 유지합니다. 물론 과학적인 근거가 있는 숫자는 아니고 휴리스틱한 숫자이긴 하죠. 낙관적으로 보아 10배수준의 Thread를 운영한다 해도 4코어의 CPU를 가진 서버에서도 고작 40개의 요청 밖에 처리하지 못합니다. 그렇기 때문에 Python에선 GIL의 영향을 받지 않는 동시성 모델이 필요했죠.

greenlet과 gevent

gevent 로고

async / await가 도입되기 이전, Python에는 yield 키워드를 이용한 Coroutine이 존재하기는 했지만, 표준 라이브러리 레벨에서 Coroutine을 지원하는 네트워킹 도구가 존재하지 않았습니다. 이러한 배경에서 많은 Python 프레임워크는 표준 라이브러리에 기반하여 발전해왔죠.

이러한 환경에서 Synchronous & Blocking I/O를 기반으로 개발된 대부분의 Python 웹서버는 GIL에 힘입어 일반적으로 큰 규모의 트래픽을 처리하기에는 적합하지 않은 선택으로 여겨졌습니다. 하지만 gevent와 greenlet이 이러한 한계를 돌파하게 만들어 주었죠.

greenlet

greenlet은 Python을 위한 경량 Coroutine 라이브러리 입니다. Async Coroutine이 등장하기 이전, Python에는 Generator 기반의 Coroutine이 있기는 했지만, 몇가지 한계로 인해 협력적 스케줄링에 사용하기에 문제점이 많았습니다.

반면, greenlet은 Python에 몇가지 마법을 더해 일반적인 용도로 사용할 수 있는 Coroutine을 제공했습니다. yieldawait 같은 명시적(explicit)인 키워드 없이 암시적(implicit)인 방법을 통해 Coroutine을 정의할 수 있는 API를 제공했습니다. 다음은 greenlet을 이용한 Coroutine 코드의 예시입니다.

>>> from greenlet import greenlet

>>> def test1():
... print("[gr1] main -> test1")
... gr2.switch()
... print("[gr1] test1 <- test2")
... return 'test1 done'

>>> def test2():
... print("[gr2] test1 -> test2")
... gr1.switch()
... print("This is never printed.")

>>> gr1 = greenlet(test1)
>>> gr2 = greenlet(test2)
>>> gr1.switch()
[gr1] main -> test1
[gr2] test1 -> test2
[gr1] test1 <- test2
'test1 done'
>>> gr1.dead
True
>>> gr2.dead
False

위와 같이 특별한 문법적 키워드 없이 Coroutine으로서 동작하는 것을 볼 수 있습니다. 문법적 요소 없이 암시적으로 동작한다는 것이 greenlet의 중요한 특징입니다.

greenlet에서 어떻게 이러한 마법이 가능한가는 greenlet의 소스코드Stackoverflow 질문을 읽어보시면 이해하는데에 도움이 됩니다.

gevent

gevent는 greenlet을 기반으로 작성된 네트워킹 라이브러리입니다. greenlet을 이용해 Coroutine을 사용할 수 있게 되어 협력적 스케줄링을 지원하도록 작성되었죠. 또한 Coroutine을 관리하는 event loop는 libev, libuv를 이용해 Asynchoronous한 I/O를 제공하여 Non-blocking & Asynchronous I/O를 지원하는 라이브러리라고 볼 수 있습니다.

gevent 에서 제공하는 가장 강력한 기능은 Monkey patching입니다. gevent는 Python 표준 라이브러리에서 제공하는 대부분의 네트워킹 도구를 제공하고 있습니다. 그렇다는 것은 표준 라이브러리의 도구를 사용하는 대부분의 네트워킹 라이브러리는 gevent에 있는 도구를 대신 사용해도 괜찮다는 것 입니다.
Monkey patching은 표준 라이브러리의 구현을 gevent의 구현으로 바꿔치기 하여 강제로 gevent를 사용하도록 만드는 기능입니다. 즉, Blocking & Synchronous I/O 기반으로 작성된 코드를 Non-blocking & Asynchronous I/O 코드로 바꿔치기 하는 것 입니다. 이러한 일들은 greenlet이 암시적으로 동작한다는 특징 덕분에 가능한 일들이었습니다.

다음은 gevent를 이용해 HTTP 요청을 하는 간단한 예제입니다. (출처)

import gevent
from gevent import monkey

# patches stdlib (including socket and ssl modules) to cooperate with other greenlets
monkey.patch_all()

import requests

# Note that we're using HTTPS, so
# this demonstrates that SSL works.
urls = [
'https://www.google.com/',
'https://www.apple.com/',
'https://www.python.org/'
]



def print_head(url):
print('Starting %s' % url)
data = requests.get(url).text
print('%s: %s bytes: %r' % (url, len(data), data[:50]))

jobs = [gevent.spawn(print_head, _url) for _url in urls]

gevent.wait(jobs)

이렇게 Monkey patching 만으로도 requests 라이브러리가 Asynchronous하게 동작하는 것을 볼 수 있습니다.

네트워킹 라이브러리를 Python 표준 라이브러리가 아닌 C 라이브러리를 Python으로 바인딩해 사용하는 경우에는 gevent로 patch 되지 않을 수 있습니다. 대표적으로 MySQL C Connector를 바인딩 해 만들어진 mysqlclient는 gevent와 호환되지 않아 gevent를 사용할 때에는 순수 Python으로 구현된 PyMySQL으로 대체합니다.

이제 모든 준비물들이 등장했고, 다시 웹서버 이야기로 돌아가보겠습니다.

gunicorn

gunicorn 로고

gunicorn은 Django와 Flask같은 WSGI 애플리케이션을 실행하기 위한 WSGI 서버입니다. 기본적으로 Thread를 기반으로한 웹서버이기에 위에서 이야기한 GIL의 제약사항을 모두 가지지만 gunicorn은 gevent worker를 제공하여 이러한 문제점을 해결해줍니다.

gevent worker는 gevent로 구현된 웹서버를 실행하고, WSGI 애플리케이션인 Django혹은 Flask 코드는 Monkey patching을 이용해 gevent 호환 코드로 바꿔치기합니다. 이러한 마법을 이용해 개발자들은 조금 과장해서 코드 한줄 안바꾸고 자신의 서버를 고성능 웹서버로 변환할 수 있었습니다.

하지만 언제까지나 이러한 마법에 의존할수는 없었죠, 그로 인해 나온것이 asyncio입니다.

asyncio

asyncio는 Python의 표준 라이브러리 레벨에서 Asynchronous 네트워킹을 지원하기 위해 만들어진 라이브러리로 Python 3.4에 공식적으로 표준 라이브러리가 되었습니다. (Python 2.x에서는 Trollius라는 back-port가 있었습니다.)

Python 2 시절을 기억하는 사람이라면, 약간 의아할 수 있습니다. asyncio를 사용하기 위해선 Async Coroutine의 async / await 를 필요로 하기 때문인데 말이죠. 사실 asyncio는 처음부터 Async Coroutine 위에서 구현되지 않았습니다. 이전에는 Generator를 기반으로 구현되었죠. 하지만 Python 2에선 yield from 구문이 존재하지 않아 별도의 back-port로 trollius.From 이라는 도구를 사용했지만요. 이후 Python 3.5에서 Coroutine이 등장하면서 지금의 asyncio가 완성되었죠.

지금은 asyncio를 기반으로한 많은 웹 프레임워크들이 등장했습니다. Sanic, FastAPI, AIOHTTP 등 태생부터 asyncio로 구현된 웹 프레임워크들이 있고 FlaskDjango도 이제는 asyncio를 공식적으로 지원하고 있죠. SQLAlchemy와 같은 데이터베이스 도구들 또한 asyncio지원을 시작하며 생태계는 asyncio로 크게 이동하고 있습니다.

이처럼 Python으로 시작하는 새로운 프로젝트는 이제 asyncio를 사용하지 않을 이유가 없습니다. 하지만 Asynchronous & Non-blocking I/O가 만능은 아닙니다. Python에서는 어떤 경우에 asyncio, gevent, multi-thread 서버를 선택해야할까요?

Python 웹서버에서 네트워킹 도구의 선택

CPU-heavy한 서버를 운영할 때

지금까지 이야기를 전개할때에는 Asynchronous I/O가 유리한 경우인 I/O Bound한 서버를 전제로 하고 이야기를 했습니다. 하지만 서버가 CPU Bound한 기능을 제공한다면 이야기가 달라집니다.

asyncio나 gevent과 같이 협력적 스케줄링을 사용하는 서버에서 긴 CPU 작업이 이루어진다면 성능에 오히려 좋지 않은 영향을 줍니다. 협력적이지 않은 작업인거죠. 주로 머신러닝 모델을 이용해 추론을 하는 서비스가 이러한 특징을 가집니다. 만약 머신러닝 모델을 서빙하기 위한 서비스를 작성한다면 asyncio나 gevent는 피하는것이 좋습니다. Multi Thread와 Multi Process를 적절히 조합해 최적의 조합을 찾아내 서비스하면 됩니다.

FastAPI와 같은 일부 프레임워크들은 Thread Pooling을 이용해 synchronous한 엔드포인트를 Multi Thread로 넘겨주기에 일정 스케일까지는 큰 문제 없이 사용할 수 있기도 합니다.

다만 Multi Thread 서버는 많은 양의 트래픽에 취약하기 때문에 서버로 들어오는 트래픽의 양을 철저하게 통제하고 적극적이 수평 스케일링을 동반해야합니다.

레거시 시스템의 성능 개선을 원할 때

2023년에 asyncio를 사용하지 않은 Flask나 Django 코드를 마주할일은 레거시 시스템을 유지보수하는 상황일 것 입니다. 혹은 의존하고 있는 라이브러리가 asyncio를 지원하지 않아 마이그레이션 하지 못하는 상황일 수도 있죠.

이 경우에는 gevent의 도입을 적극적으로 고민해볼만 합니다. 약간의 수정만으로 큰 성능의 도약을 얻을 가능성이 있습니다. 이미 Reddit, Pinterest 등에서 수많은 성공 사례를 남겨주었습니다.

gevent를 도입할만한 대표적인 시그널들을 살펴보면 다음과 같은 사례들이 있습니다.

DB의 성능 저하가 웹서버의 성능 저하로 이어진다.
Synchronous Python 서버에서 가장 흔하게 겪는 사례입니다. 모종의 이유로 DB에서 성능 저하가 일어났을 때 웹서버에서 DB를 사용하는 API 엔드포인트가 느려지는건 당연한 일입니다. 하지만 이러한 성능 저하가 DB를 사용하지 않는 다른 API 엔드포인트로 전파되는 경우가 종종 발생합니다.

DB를 사용하는 엔드포인트가 DB의 성능 저하로 인해 DB로의 I/O wait가 길어지면 엔드포인트는 Thread를 더 길게 점유할 것 입니다. GIL등의 문제로 안그래도 적은 Python의 Thread를 I/O wait가 점유하고 있다면 DB와 관계없는 다른 API까지 I/O wait를 기다리게 되는 상황이 발생할 수 있죠. DB 장애로 인한 성능 저하가 웹서버의 Healthcheck API로 전파되어 웹서버까지 다운 되면 전면 장애로까지 이어질 수 있을겁니다.

이 상황에서 gevent를 도입한다면 I/O wait는 Thread를 점유하지 않게 되기에 다른 API로 빠르게 Context Switch 될 것 입니다. 또한 Thread와 달리 동시성을 수천 단위로 설정하여 요청을 처리할수도 있죠.

엉뚱한 API 엔드포인트의 응답 속도가 느려진다.
위와 비슷한 사례로, I/O wait로 인해 특정 API 엔드포인트가 느려진다면 다른 API 엔드포인트의 성능에도 영향을 줄 수 있습니다. 단일 엔드포인트에서는 성능 저하가 일어날만 한 포인트가 없는데 504 Gateway Timeout 응답과 같은 성능 저하가 일어난다면 성능 저하의 전파를 의심해볼만 합니다.

새로운 프로젝트를 시작할 때

이제는 새로운 프로젝트를 시작할 때 asyncio를 사용하지 않을 이유가 거의 없다고 봅니다. FastAPI와 같이 인기있는 프로젝트도 등장했고, SQLAlchemy와 같은 데이터베이스 툴킷들도 asyncio를 지원합니다. 특별한 이유가 없다면 겁먹지 말고 asyncio를 도입해보세요.

지금까지 Python에서의 동시성에 대해서 집중적으로 이야기 해보았습니다. 저는 꽤나 긴 시간동안 Python으로 웹개발을 해왔는데요. 위와 같은 변천사를 몸소 겪다보니 꼭 한번 Python만을 주제로 길게 이야기를 해보고 싶었습니다. 다음번 포스팅은 번외가 아닌 일반적인 동시성을 주제로 다시 돌아오겠습니다.

--

--