<p style="text-align:justify;">본문은 요즘IT와 번역가 David가 함께 칼 패터슨(Cal paterson)의 글 <<a href="https://calpaterson.com/async-python-is-not-faster.html"><u>Async Python is not faster</u></a>>을 번역한 글입니다. 필자인 칼 패터슨은 데이터 처리, 마이크로서비스 아키텍처에 강점이 있는 시니어 개발자로, 금융, 에너지 분야에서의 풍부한 경험을 보유하고 있습니다. 이 글에서는 파이썬 비동기 방식의 성능에 관해 이야기합니다.</p><p style="text-align:justify;"> </p><p style="text-align:justify;">필자에게 허락을 받고 번역했으며, 글에 포함된 각주(*표시)는 ‘번역자주’입니다. 글에 포함된 링크는 원문에 따라 표시했습니다.</p><div class="page-break" style="page-break-after:always;"><span style="display:none;"> </span></div><blockquote><p style="text-align:justify;">실제 벤치마크 결과를 보면, 비동기 파이썬이 일반 ‘동기식’ 파이썬보다 오히려 더 느립니다. 더 큰 문제는 비동기 프레임워크들이 부하가 걸리면 불안정해진다는 점이죠.</p></blockquote><p style="text-align:justify;"> </p><p style="text-align:justify;">대부분의 개발자들은 비동기 파이썬이 더 높은 동시성을 제공한다는 것을 알고 있죠. 그래서 동적 웹사이트나 웹 API*를 서빙하는 것과 같은 일반적인 작업에서 더 좋은 성능을 보일 거라고 생각하기 쉽습니다.</p><p style="text-align:justify;"><span style="color:#999999;">*API: 서로 다른 프로그램들이 소통하기 위한 약속된 방식</span></p><p style="text-align:justify;"> </p><p style="text-align:justify;">하지만 안타깝게도 비동기는 파이썬 인터프리터의 성능을 마법처럼 높여주는 해결책이 아닙니다. 실제 상황(아래 참조)에서 테스트해 보면, 비동기 웹 프레임워크는 처리량(초당 요청 수) 면에서 오히려 약간 더 안 좋은 성능을 보이고, 지연 시간의 변동 폭은 훨씬 더 큰 것으로 나타났습니다.</p><p style="text-align:justify;"> </p><p style="text-align:justify;"> </p><h3 style="text-align:justify;"><strong>벤치마크 결과</strong></h3><p style="text-align:justify;">다양한 동기식과 비동기식 웹서버 설정으로 테스트를 진행해 봤습니다.</p><p style="text-align:justify;"> </p><figure class="image image_resized" style="width:100%;"><img src="https://yozm.wishket.com/media/news/2853/1.png"></figure><p style="text-align:justify;"> </p><p style="text-align:justify;">50번째와 99번째 백분위 응답 시간은 밀리초 단위로, 처리량은 초당 요청 수로 측정했습니다. 실제 서비스 운영에서 가장 중요하다고 생각되는 P99 기준으로 표를 정렬했습니다.</p><p style="text-align:justify;"> </p><p style="text-align:justify;">주목할 만한 점들을 정리해 보면:</p><ol><li style="text-align:justify;"><strong>가장 좋은 성능을 보인 것은 동기식 프레임워크들입니다.</strong></li></ol><ul><li style="text-align:justify;">다만 플라스크(Flask)는 다른 것들에 비해 처리량이 좀 떨어집니다.</li></ul><p style="text-align:justify;">2. <strong>가장 성능이 안 좋은 것들은 전부 비동기 프레임워크였습니다.</strong></p><p style="text-align:justify;">3. <strong>비동기 프레임워크들은 지연 시간의 변동 폭이 훨씬 컸습니다.</strong></p><p style="text-align:justify;">4. Uvloop 기반 옵션들은 기본 asyncio 루프보다 더 나은 성능을 보였습니다.</p><ul><li style="text-align:justify;">그래서 어쩔 수 없이 asyncio를 써야 한다면, Uvloop를 쓰는 것을 추천합니다.</li></ul><p style="text-align:justify;"> </p><p style="text-align:justify;"> </p><h3 style="text-align:justify;"><strong>벤치마크 결과가 현실을 제대로 반영할까요?</strong></h3><p style="text-align:justify;">저는 가능한 한 실제 환경과 유사하게 벤치마크를 구성하고자 했습니다. 다음은 제가 사용한 아키텍처입니다.</p><p style="text-align:justify;"> </p><figure class="image image_resized" style="width:60%;"><img src="https://yozm.wishket.com/media/news/2853/pic_1.png"></figure><p style="text-align:justify;"> </p><p style="text-align:justify;">실제 배포 환경을 최대한 현실적으로 구현했습니다. 리버스 프록시, 파이썬 코드(변수), 그리고 데이터베이스가 포함되어 있습니다. 또한 실제 웹 애플리케이션 배포 환경(특히 PostgreSQL 사용 시)에서 흔히 볼 수 있는 외부 데이터베이스 커넥션 풀러도 추가했습니다.</p><p style="text-align:justify;"> </p><p style="text-align:justify;">테스트 애플리케이션은 무작위 키로 행을 조회하여, JSON 형태로 반환하는 작업을 수행합니다. 전체 소스 코드는 <a href="https://github.com/calpaterson/python-web-perf"><u>GitHub</u></a>에서 확인할 수 있습니다.</p><p style="text-align:justify;"> </p><h4 style="text-align:justify;"><strong>워커 수가 다양한 이유</strong></h4><p style="text-align:justify;">각 프레임워크의 최적 워커 프로세스 수를 결정하기 위해 다음과 같은 단순한 규칙을 적용했습니다: 단일 워커에서 시작하여 성능이 저하될 때까지 워커 수를 점진적으로 증가시켰습니다. 최적의 워커 수가 동기식과 비동기식 프레임워크 간에 차이를 보이는 이유는 명확합니다. 비동기 프레임워크는 I/O 동시성 덕분에, 단일 워커 프로세스만으로도 CPU를 최대로 활용할 수 있습니다.</p><p style="text-align:justify;"> </p><p style="text-align:justify;">반면, 동기식 워커의 경우는 다릅니다. I/O 작업 시 해당 작업이 완료될 때까지 블로킹되므로, 부하 상황에서 모든 CPU 코어를 최대한 활용하기 위해서는 충분한 수의 워커가 필요합니다. 이에 대한 자세한 내용은 <a href="https://docs.gunicorn.org/en/stable/design.html#how-many-workers"><u>gunicorn 문서</u></a>에서 확인할 수 있습니다.</p><p style="text-align:justify;"> </p><blockquote><p style="text-align:justify;">일반적으로 워커 수는 (2 x CPU 코어 수) + 1을 권장합니다. 엄밀히 과학적인 공식은 아니지만, 이는 각 코어당 한 워커가 소켓 읽기/쓰기를 담당하고 다른 워커가 요청을 처리한다는 가정에 기반합니다.</p></blockquote><p style="text-align:justify;"> </p><h4 style="text-align:justify;"><strong>테스트 환경 사양</strong></h4><p style="text-align:justify;">벤치마크는 Hetzner의 CX31 머신 타입(4 "vCPU"/8GB RAM)에서 실행되었으며, 우분투(Ubuntu) 20.04 환경에서 진행했습니다. 부하 생성기는 별도의 (더 작은) VM(가상머신)*에서 실행했습니다.</p><p style="text-align:justify;"><span style="color:#999999;">*가상머신: 물리적인 컴퓨터 하드웨어를 소프트웨어로 구현한 것</span></p><p style="text-align:justify;"> </p><p style="text-align:justify;"> </p><h3 style="text-align:justify;"><strong>비동기는 왜 성능이 더 안 좋을까?</strong></h3><h4 style="text-align:justify;"><strong>처리량(Throughput)</strong></h4><p style="text-align:justify;">처리량(초당 요청 수) 측면에서 주요 요인은 동기와 비동기의 차이가 아닌, 얼마나 많은 파이썬 코드가 네이티브 코드로 대체되었는가입니다. 간단히 말해, 성능에 민감한 파이썬 코드를 더 많이 대체할수록 더 나은 성능을 보입니다. 이는 오랜 역사를 가진 파이썬 성능 최적화 전략입니다. (numpy가 대표적 사례)</p><p style="text-align:justify;"> </p><h4 style="text-align:justify;"><strong>지연 시간(Latency)</strong></h4><p style="text-align:justify;">지연 시간 문제는 더 근본적입니다. 부하 상황에서 비동기는 좋지 않은 성능을 보이며, 지연 시간이 전통적인 동기식 배포 환경보다 훨씬 더 크게 증가합니다. 이유가 무엇일까요? 비동기 파이썬에서 멀티스레딩*은 <strong>협력적(co-operative)</strong>입니다. 이는 커널과 같은 중앙 관리자에 의해 스레드가 강제로 중단되는 것이 아니라, 스레드가 자발적으로 실행 시간을 다른 스레드에 양보해야 함을 의미합니다. asyncio에서는 ‘await’, ‘async for’, ‘async with’라는 세 가지 키워드를 통해 실행 권한이 양도됩니다.</p><p style="text-align:justify;"><span style="color:#999999;">*멀티스레딩: 하나의 프로그램 안에서 둘 이상의 실행 흐름(스레드)을 동시에 처리하는 기술</span></p><p style="text-align:justify;"> </p><p style="text-align:justify;">이는 실행 시간이 ‘공정하게’ 분배되지 않으며, 한 스레드가 작업을 수행하는 동안 다른 스레드가 의도치 않게 CPU 시간을 할당받지 못할 수 있다는 것을 의미합니다. 이것이 지연 시간이 더 불안정한 이유입니다.</p><p style="text-align:justify;"> </p><p style="text-align:justify;">반면 UWSGI와 같은 전통적인 동기식 파이썬 웹서버는 커널 스케줄러의 <strong>선점적(pre-emptive)</strong> 멀티프로세싱*을 사용합니다. 이는 주기적으로 프로세스의 실행을 교체함으로써 공정성을 보장하려 합니다. 따라서 시간이 더 공정하게 분배되어 지연 시간의 변동 폭이 더 작습니다.</p><p style="text-align:justify;"><span style="color:#999999;">*멀티프로세싱: 여러 개의 프로세서(CPU)가 각각 독립된 프로세스를 동시에 실행하는 방식입니다.</span></p><p style="text-align:justify;"> </p><p style="text-align:justify;"> </p><h3 style="text-align:justify;"><strong>다른 벤치마크에서는 왜 다른 결과가 나올까?</strong></h3><p style="text-align:justify;">대부분의 다른 벤치마크(특히 비동기 프레임워크 제작자들의 벤치마크)는 동기식 프레임워크에 충분한 워커를 설정하지 않습니다. 이로 인해 동기식 프레임워크들이 실제로 사용 가능한 CPU 시간의 대부분을 활용하지 못하게 됩니다.</p><p style="text-align:justify;"> </p><p style="text-align:justify;">다음은 Vibora 프로젝트의 벤치마크 사례입니다. (이 프레임워크는 상대적으로 인지도가 낮아 테스트하지 않았습니다.)</p><p style="text-align:justify;"> </p><figure class="image image_resized" style="width:80%;"><img src="https://yozm.wishket.com/media/news/2853/pic_2.png"><figcaption><출처: Vibora></figcaption></figure><p style="text-align:justify;"> </p><p style="text-align:justify;">Vibora는 플라스크(Flask)보다 500% 높은 처리량을 보인다고 주장합니다. 하지만 그들의 벤치마크 코드를 검토해 보니, Flask를 CPU 당 1개의 워커만 사용하도록 잘못 설정한 것을 발견했습니다. 이를 수정하자 다음과 같은 결과가 나왔습니다.</p><p style="text-align:justify;"> </p><figure class="table" style="text-align:justify;"><table style="border-bottom:none;border-left:none;border-right:none;border-top:none;"><tbody><tr><td style="background-color:#b7b7b7;border-bottom:1pt solid #000000;border-left:1pt solid #000000;border-right:1pt solid #000000;border-top:1pt solid #000000;padding:5pt;vertical-align:top;"><p style="text-align:justify;"><strong>Webserver</strong></p></td><td style="background-color:#b7b7b7;border-bottom:1pt solid #000000;border-left:1pt solid #000000;border-right:1pt solid #000000;border-top:1pt solid #000000;padding:5pt;vertical-align:top;"><p style="text-align:justify;"><strong>Throughput</strong></p></td></tr><tr><td style="border-bottom:1pt solid #000000;border-left:1pt solid #000000;border-right:1pt solid #000000;border-top:1pt solid #000000;padding:5pt;vertical-align:top;"><p style="text-align:justify;">Flask</p></td><td style="border-bottom:1pt solid #000000;border-left:1pt solid #000000;border-right:1pt solid #000000;border-top:1pt solid #000000;padding:5pt;vertical-align:top;"><p style="text-align:justify;">11925 req/s</p></td></tr><tr><td style="border-bottom:1pt solid #000000;border-left:1pt solid #000000;border-right:1pt solid #000000;border-top:1pt solid #000000;padding:5pt;vertical-align:top;"><p style="text-align:justify;">Vibora</p></td><td style="border-bottom:1pt solid #000000;border-left:1pt solid #000000;border-right:1pt solid #000000;border-top:1pt solid #000000;padding:5pt;vertical-align:top;"><p style="text-align:justify;">14148 req/s</p></td></tr></tbody></table></figure><p style="text-align:justify;"> </p><p style="text-align:justify;">또 다른 문제점은 많은 벤치마크가 지연 시간보다 처리량 결과를 우선시한다는 점입니다. (예를 들어, Vibora의 벤치마크는 지연 시간을 아예 언급하지 않음) 그러나 처리량은 머신을 추가함으로써 개선할 수 있지만, 부하 상황에서의 지연 시간은 머신을 추가한다고 해서 개선되지 않습니다. 처리량 증가는 <strong>지연 시간이 허용 가능한 범위 내에 있을 때</strong>만 의미가 있습니다.</p><p style="text-align:justify;"> </p><p style="text-align:justify;"> </p><h3 style="text-align:justify;"><strong>추가적인 고찰과 현장 경험</strong></h3><p style="text-align:justify;">제가 진행한 벤치마크는 구성 요소 면에서는 꽤 현실적이었지만, 실제 워크로드에 비하면 여전히 단순한 편이었습니다. 모든 요청이 동일한 데이터베이스 쿼리를 수행했거든요. 실제 애플리케이션은 훨씬 더 다양합니다. 느린 작업, 빠른 작업, I/O를 많이 쓰는 작업, CPU를 많이 쓰는 작업이 섞여 있죠. 제 경험상 실제 애플리케이션에서는 지연 시간의 변동 폭이 훨씬 더 크게 나타납니다.</p><p style="text-align:justify;"> </p><p style="text-align:justify;">복잡한 환경에서는 비동기 애플리케이션의 성능 저하 문제가 더욱 심화될 것으로 예측됩니다. 여러 현장 사례도 이를 뒷받침합니다.</p><p style="text-align:justify;"> </p><p style="text-align:justify;">엣시(Etsy)의 댄 맥킨리(<a href="https://x.com/mcfunley/status/1194713711337852928?lang=en"><u>Dan McKinley</u></a>)는 트위스티드(Twisted) 기반 시스템 운영 경험을 공유했는데요. 그들의 시스템은 지연 시간이 심각하게 불안정했다고 합니다.</p><p style="text-align:justify;"> </p><blockquote><p style="text-align:justify;">트위스티드 전문가들도 인정했습니다. 트위스티드가 전체적인 처리량은 좋지만, 일부 요청에서 심각한 지연이 발생할 수 있다는 것을 말이죠.엣시시스템에서는 PHP 프론트엔드가 이 기능을 한 번의 웹 요청에서 수백, 수천 번씩 호출했기 때문에 큰 문제였습니다.</p></blockquote><p style="text-align:justify;"> </p><p style="text-align:justify;">또한 SQLAlchemy 제작자인 마이크 베이어(Mike Bayer)는 몇 년 전 ‘<a href="https://techspot.zzzeek.org/2015/02/15/asynchronous-python-and-databases/"><u>비동기 파이썬과 데이터베이스</u></a>’라는 글에서 다른 관점으로 비동기를 분석했는데, 역시 asyncio가 덜 효율적이라는 결과를 얻었습니다.</p><p style="text-align:justify;"> </p><p style="text-align:justify;">Rachel by the Bay에서는 ‘<a href="https://rachelbythebay.com/w/2020/03/07/costly/"><u>파이썬, Gunicorn, Gevent에 관한 고찰</u></a>’이라는 글로 gevent 기반 설정으로 인한 운영 혼란을 설명했습니다. 저도 프로덕션 환경에서 gevent로 고생한 적이 있죠. (성능 문제는 아니었습니다.)</p><p style="text-align:justify;"> </p><p style="text-align:justify;">또 한 가지 언급할 점은 이 벤치마크를 설정하는 과정에서 모든 비동기 구현이 성가신 방식으로 문제를 일으켰다는 겁니다. Uvicorn은 자식 프로세스를 종료하지 않은 채 부모 프로세스가 종료되어서, 8001 포트를 잡고 있는 자식 프로세스들을 일일이 찾아 죽여야 했습니다. AIOHTTP*는 파일 디스크립터 관련 내부 오류를 일으켰는데 종료되지 않았죠. (프로세스 감시자가 재시작할 수 없는 상황으로 치명적인 문제입니다.) Daphne도 로컬에서 문제가 있었는데, 어떻게 해결했는지 기억나지 않네요.</p><p style="text-align:justify;"><span style="color:#999999;">*AIOHTTP: 파이썬의 비동기 기능을 활용한 HTTP 클라이언트/서버 프레임워크</span></p><p style="text-align:justify;"> </p><p style="text-align:justify;">해당 오류들은 SIGKILL*을 통해 일시적으로 해결할 수 있었으나, 프로덕션 환경에서 이러한 라이브러리들에 의존하는 코드를 운영하는 것은 위험 부담이 있습니다. 반면, Gunicorn과 UWSGI의 경우 안정적인 운영이 가능했습니다. 다만 UWSGI의 경우, 애플리케이션 로딩 실패 시 프로세스가 정상적으로 종료되지 않는다는 단점이 있습니다.</p><p style="text-align:justify;"><span style="color:#999999;">*SIGKILL: 프로세스를 즉시 강제 종료하는 시그널(신호)</span></p><p style="text-align:justify;"> </p><p style="text-align:justify;"> </p><h3 style="text-align:justify;"><strong>결론</strong></h3><p style="text-align:justify;">성능 최적화를 위해서는 일반적인 동기식 파이썬을 활용하되, 가능한 한 많은 부분을 네이티브 코드로 구현하는 것을 추천합니다. 웹서버 구현에 있어 높은 처리량이 요구되는 경우, 플라스크 이외의 프레임워크를 고려해 볼 수 있습니다. 다만 UWSGI 기반의 플라스크 구현 역시 우수한 레이턴시 특성을 보여주고 있어, 충분히 경쟁력 있는 선택이 될 수 있습니다.</p><hr><p style="text-align:justify;"><원문></p><p style="text-align:justify;"><a href="https://calpaterson.com/async-python-is-not-faster.html"><u>Async Python is not faster</u></a></p><p style="text-align:justify;"> </p><p style="text-align:center;"><span style="color:#999999;">위 번역글의 원 저작권은 Cal paterson에게 있으며, 저작권법의 보호를 받는 바, 무단 전재와 복사, 배포 등을 금합니다</span></p>