FastAPI에서 동기/비동기 처리에 대한 벤치마크 살펴보기
개요
Python에서 API 개발을 가장 편하게 할 수 있는 라이브러리는 아무래도 FastAPI 입니다. 스테디셀러인 Flask와 Django도 좋은 선택지라고 할 수 있지만 속도와 러닝 커브 측면, 그리고 구현 편이성을 모두 고려했을 때 FastAPI가 더 나은 선택이라는 생각이 듭니다. 물론 두 가지 모두 지금까지 남겨진 많은 레퍼런스 덕분에 많이 사용되고 있지만요.
FastAPI에 대해 개인적으로 생각하는 장점은 비동기 처리입니다. 다른 라이브러리도 지금은 비동기 처리가 유연하게 적용되지만 아무래도 편의성 등을 따져보면 FastAPI의 큰 장점이라고 할 수 있죠. 하지만 모든 경우에 비동기 처리를 적용할 수 있는 것은 아닙니다. 또한 비동기 처리가 항상 답이 되는 것도 아니고요. 어떤 경우엔 동기 처리를, 어떤 경우엔 비동기 처리를 적절하게 섞어 적용하는 것이 매우 중요합니다.
아무튼 FastAPI에 비동기 처리를 고민하고 있던 중 꽤 재미있는 벤치마크를 발견하여 짧게 관련 내용을 정리해보고 싶어서 본 글을 작성했습니다. 이 글에서는 동기/비동기를 이해하는 데 가장 중요한 두 개의 큰 개념을 먼저 살펴보고, 그 개념 사이의 조합에 대한 장단점을 살펴볼 예정입니다. 그리고 그 내용을 토대로 위에서 언급한 벤치마크를 살펴보고자 합니다.
관련 개념
Blocking vs Non-Blocking
동기/비동기를 이해함에 있어 중요한 개념 중 하나가 바로 Blocking과 Non-blocking 입니다. 이 두 개념은 blocking 이라는 단어의 의미처럼 어떤 것을 차단하는 것에 초점이 맞춰져 있습니다. 우선 Blocking은 이렇게 이해할 수 있습니다.
A가 B에게 무언가를 요청하였고, B가 요청 받은 일을 끝내기 전까지는 A는 아무 것도 하지 못하는 것
즉, 호출된 함수가 자신이 할 일을 모두 끝낼 때까지 제어권을 계속 갖고 있으면서 호출한 함수에게 제어권을 돌려주지 않는 것입니다. 따라서 낮은 동시성이 특징이며, 코드도 매우 간단하게 작성할 수 있습니다.
반대로 Non-blocking은 자신이 할 일을 마치는 것과 상관 없이 제어권을 돌려주는 것입니다.
A가 B에게 무언가 요청하였고, B는 요청 받은 일을 하는 동안 A에게 할 일을 하라고 하는 것
즉, 호출된 함수가 자신이 할 일을 끝내지 않았더라도 호출한 함수에게 제어권을 돌려줘서 다른 작업을 수행할 수 있게 하는 것입니다.
특징 | Blocking | Non-blocking |
---|---|---|
작업 진행 방식 | 작업이 끝날 때까지 대기 | 작업을 시작하고 결과는 나중에 처리 |
동시성 | 낮음 | 높음 |
코드 작성 난이도 | 간단함 | 복잡함 |
응답성 | 오래 걸리는 작업으로 인해 전체 응답성 저하 | 작업 중 다른 요청을 처리하여 높은 응답성 유지 |
Sync vs Async
Blocking과 Non-blocking의 개념과 비슷해 보이지만 관점의 차이가 조금 있습니다. Blocking과 Non-blocking은 제어권에 초점을 맞추고 있는 반면, Sync과 Async는 시점과 순서, 그리고 독립성에 초점을 맞추고 있습니다.
Sync (Synchronous, 동기)는 작업을 요청한 순서와 작업이 끝나 응답하는 순서가 같습니다. 반대로 Async (Asynchronous, 비동기)는 서로 다른 작업이 독립적으로 실행되기 때문에 요청 순서와 응답 순서가 다를 수 있습니다.
케이스 분류
위 두 개념을 조합하면 다음 네 개의 조합을 얻을 수 있습니다.
- Sync + Blocking I/O
- Sync + Non-blocking I/O
- Async + Blocking I/O
- Async + Non-blocking I/O
각 조합에 대한 예시는 이 블로그 아티클에서 재밌게 설명하고 있습니다.
Sync + Blocking I/O
가장 간단한 조합이자 일반적인 조합입니다. 프로그램 실행이 요청의 결과에 완전히 의존하며, 요청이 끝날 때까지 다른 작업이 진행되지 않습니다. 보통 전통적인 파일 입출력이나 네트워크 요청, 데이터베이스 질의에 사용합니다.
Sync + Non-blocking I/O
위와 다르게 제어권을 반환해주기 때문에 요청이 끝나지 않더라도 다른 작업을 진행할 수 있습니다. 하지만 처음 요청한 작업이 끝났는지 끝없이 확인해야 합니다. 이런 작업을 폴링(polling)이라고 합니다. I/O의 차단 없이 다른 작업을 병렬적으로 실행할 수 있어서 조금 더 효율적인 리소스 활용이 가능하다는 장점이 있습니다. 하지만 동기 흐름에서 작업 완료를 기다려야 하는 경우 비효율적이므로 자주 사용하지 않습니다.
Async + Blocking I/O
네 가지 조합 중에서 최악의 효율성을 가진 혼종 중의 혼종입니다. 비동기는 애초에 작업이 완료되기 전에 다른 작업을 진행하도록 설계되었지만 Blocking I/O 와 개념적으로 모순이 발생합니다. 결국 높은 동시성과 효율성이라는 비동기의 장점을 살릴 수 없을 뿐더러 작업을 비동기적으로 시작해도 I/O가 차단되므로 전혀 효율적이지 않은 상황이 됩니다.
네 가지 조합 중에서 가장 추천하지 않는 조합이지만 쉽게 찾아볼 수는 없는 조합입니다. 간혹 비동기로 작업하는 라이브러리와 비동기를 지원하지 않는 DB 드라이버가 만나 발생하는 경우가 있습니다. 부하가 걸리는 경우에는 요청 차단이 잦아지며 Read Timeout 에러가 자주 발생하기도 합니다.
Async + Non-blocking I/O
가능하다면 가장 추천하는 조합입니다. I/O의 차단도 없고 작업 완료 후에 결과는 콜백이나 이벤트 루프를 통해 처리되기 때문에 작업의 흐름이 완전히 비동기적일 수 있습니다. 따라서 병렬성과 동시성을 최대로 활용할 수 있습니다.
조합 | 설명 | 장점 | 단점 | 추천도 |
---|---|---|---|---|
Sync + Blocking | 순차적, 차단 방식 | 단순하고 직관적임 | 동시성 부족, 느린 응답성 | ⭐️⭐️⭐️ |
Sync + Non-blocking | 차단 없이 동기적 흐름 유지 | 응답성 향상 | 코드 복잡성 증가, 효율성 제한 | ⭐️⭐️⭐️ |
Async + Blocking | 비동기적 요청, 하지만 차단 발생 | 동시성 일부 제공 | 비효율적 리소스 사용, 비동기 효과 제한 | ⭐️ |
Async + Non-blocking | 완전히 비동기적 | 확장성과 성능 최적화 | 코드 복잡, 디버깅 및 유지보수 어려움 | ⭐️⭐️⭐️⭐️⭐️ |
벤치마크 결과 살펴보기
지금까지 정리한 개념을 토대로 FastAPI에 대한 동기/비동기 처리 벤치마크 결과를 살펴보도록 하겠습니다.
이 벤치마크에서는 총 세 가지의 실험을 진행했습니다.
- Sync 엔드포인트 + Sync I/O
- Async 엔드포인트 + Async I/O
- Async 엔드포인트 + Sync I/O
당연하게도 2번의 경우가 가장 빨랐고, 그 다음으론 1번, 3번 순서로 빨랐습니다. 1번과 3번의 차이를 조금 더 자세히 보면 좋은데요.
1
2
3
4
@app.get('/http/sync')
def http_sync():
with httpx.Client() as http:
return http.get('http://165.227.149.214:8090?waitms=1000').content
우선 1번의 경우 Sync I/O는 Non-blocking 이었다고 합니다. 따라서 I/O 가 blocking인 경우보다는 높은 응답성을 보일텐데요. 만약에 FastAPI + SQLAlchemy 시나리오에서 DB 드라이버가 비동기 지원이 되지 않는다면 1번과 같은 세팅이 꽤 좋은 효과를 보일 것 같습니다.
1
2
3
4
@app.get('/http/async/sync')
async def http_sync():
with httpx.Client() as http:
return http.get('http://165.227.149.214:8090?waitms=1000').content
하지만 3번의 경우는 Async 엔드포인트였음에도 불구하고 Blocking I/O 였다고 합니다. Blocking이 일어난 경우에 거의 Timeout error가 발생하였는데, 구현되어 있는 코드를 살펴보았을 때 대기 시간을 걸어놔서인지 꽤 많은 비율로 오류가 발생한 것을 확인할 수 있습니다.
이 내용을 정리해보면 FastAPI로 엔드포인트를 작성할 때 Async I/O가 불가능하다면 애초에 엔드포인트를 Sync로 처리하는 것이 더 나을 것 같습니다.