FastAPI에서 async def를 썼는데도 빨라지지 않는 이유를 다시 생각해봤다

FastAPI를 처음 사용하다 보면 defasync def 중 무엇을 써야 하는지 한 번쯤 고민하게 된다.

나 역시 FastAPI 기반 검색·추천 API를 만들면서 대부분의 요청 처리 코드를 비동기로 구성했다. PostgreSQL에는 asyncpg를 사용했고, 데이터베이스 연결도 Connection Pool을 만들어 사용하는 구조였다.

그래서 처음에는 상당히 단순하게 생각했다.

@app.get("/search")
async def search():
    ...

일반적인 def가 아니라 async def를 사용했고, 데이터베이스 라이브러리 역시 비동기를 지원한다면 동시 요청을 처리할 때 자연스럽게 더 좋은 성능을 낼 것이라고 생각하기 쉽다.

그런데 코드를 계속 보다 보니 한 가지가 걸렸다.

함수 앞에 async를 붙이는 것 자체가 어떻게 코드를 빠르게 만드는 것일까?

생각해보면 조금 이상하다.

CPU가 수행해야 하는 계산량이 똑같고, PostgreSQL에서 같은 SQL을 실행하며, 외부 API에서도 같은 데이터를 받아와야 한다면 defasync def로 바꿨다는 이유만으로 실제 작업량이 줄어드는 것은 아니다.

이 질문에서부터 비동기 코드를 다시 보게 됐다.

그리고 내가 이해한 핵심은 async하나의 요청을 무조건 더 빨리 끝내는 기술이라기보다, 어떤 작업이 기다리고 있는 동안 다른 요청을 처리할 수 있도록 만드는 방식이라는 것이었다.

FastAPI 공식 문서 역시 네트워크, 데이터베이스, 파일 시스템 등 결과를 기다려야 하는 작업과 async/await의 관계를 설명하고 있으며, 일반 def로 선언된 path operation은 서버를 막지 않도록 외부 thread pool에서 실행한다.

이 차이를 이해하고 나니 async def가 def보다 빠르다라는 질문 자체가 조금 부족하게 느껴졌다.

정말 먼저 봐야 할 것은 함수 안에서 무엇을 하고 있는가였다.


처음에는 await를 “비동기 함수를 호출하는 문법” 정도로 생각하기 쉬웠다

예를 들어 내가 사용한 asyncpg를 생각해보면 다음과 같은 코드가 나온다.

async with pool.acquire() as conn:
    rows = await conn.fetch(
        "SELECT * FROM books LIMIT 10"
    )

처음 이런 코드를 작성할 때는:

asyncpg가 비동기 라이브러리니까 await를 붙인다.

정도로 이해해도 기능을 만드는 데는 문제가 없다.

하지만 왜 await가 필요한지를 생각해보면 이 코드의 의미가 조금 달라진다.

conn.fetch()를 호출한다고 Python이 혼자서 SQL 결과를 계산하는 것은 아니다.

실제 Query는 PostgreSQL에서 처리된다.

애플리케이션은 PostgreSQL에 요청을 보내고 결과가 돌아오기를 기다려야 한다.

단순화하면 다음과 같다.

FastAPI
   ↓
SQL 요청 전송
   ↓
PostgreSQL에서 Query 처리
   ↓
결과가 올 때까지 대기
   ↓
결과 수신

여기에서 중요한 시간이 바로 대기시간이다.

FastAPI 프로세스가 PostgreSQL 결과를 기다리는 동안 CPU가 해당 요청을 위해 계속 계산하고 있어야 하는 것은 아니다.

그렇다면 그 시간에 다른 요청을 처리할 수 있다면 서버 입장에서는 훨씬 효율적이다.

await를 이해할 때 내가 중요하게 보게 된 부분이 이것이다.

Python asyncio의 event loop는 task가 멈추거나 제어권을 돌려주면 다른 작업을 실행할 수 있는 구조를 가지고 있다. 반대로 한 작업이 제어권을 오래 잡고 있으면 다른 작업들도 실행 기회를 얻지 못할 수 있다.

await가 중요한 이유는 단순히:

비동기 함수를 호출하기 위한 문법

이 아니라,

지금 이 작업은 결과를 기다려야 한다.

그동안 다른 일을 처리할 수 있도록
event loop에 실행권을 돌려준다.

라는 의미를 가질 수 있기 때문이다.

이렇게 생각하고 나니 async의 장점이 조금 더 명확해졌다.


한 요청을 1초에서 0.5초로 만드는 것과는 다른 문제였다

예를 들어 PostgreSQL Query가 결과를 반환하는 데 1초가 걸린다고 가정해보자.

async를 사용한다고 PostgreSQL Query 자체가 갑자기 0.5초 만에 끝나는 것은 아니다.

DB가 1초 동안 해야 하는 작업은 여전히 존재한다.

대신 차이가 날 수 있는 부분은 여러 요청이 동시에 들어왔을 때다.

설명을 위해 요청 A와 B가 거의 동시에 들어왔다고 하자.

요청 A가 PostgreSQL 결과를 기다리는 동안 아무것도 할 수 없다면:

요청 A
[ DB 대기........ ]
                  ↓
                완료

요청 B
                  [ DB 대기........ ]
                                    ↓
                                  완료

처럼 처리될 수 있다.

반면 A가 DB 응답을 기다리는 시점에 실행권을 넘길 수 있다면:

요청 A
DB 요청 → 대기 ─────────→ 응답

요청 B
          DB 요청 → 대기 ─────→ 응답

처럼 대기 시간을 겹쳐 사용할 여지가 생긴다.

이걸 보고 나서야 왜 FastAPI 같은 웹 API에서 비동기가 특히 자주 등장하는지 이해하기 쉬웠다.

웹 API에는 생각보다 기다리는 작업이 굉장히 많기 때문이다.

예를 들어 내 검색·추천 서비스만 생각해도 한 요청 안에 다음과 같은 작업이 들어갈 수 있다.

PostgreSQL 조회

Elasticsearch 검색

Embedding API 요청

LLM API 요청

로그 저장

이 중 상당수는 Python CPU가 직접 결과를 만들어내기보다 다른 시스템에 요청한 뒤 응답을 기다리는 성격을 가지고 있다.

따라서 비동기의 장점은:

하나의 Query를 마법처럼 빠르게 만든다.

보다는:

하나의 요청이 외부 시스템을 기다리는 동안 다른 요청이 처리될 기회를 만든다.

로 이해하는 것이 더 정확했다.


그렇다면 async def로 전부 바꾸면 되는 것 아닐까?

여기까지 이해하면 또 다른 생각이 든다.

대기시간을 효율적으로 사용할 수 있다면 모든 API를 async def로 바꾸면 되는 것처럼 보인다.

나도 처음에는 이 방향이 훨씬 자연스럽다고 생각했다.

하지만 async def 안에 무엇이 들어 있는지를 다시 생각하면 문제가 생긴다.

예를 들어 다음 코드를 보자.

import time

@app.get("/test")
async def test():
    time.sleep(5)
    return {"status": "ok"}

함수 선언만 보면 분명히 비동기다.

async def test():

그런데 내부에서 사용하는 time.sleep(5)는 비동기 대기가 아니다.

현재 실행 흐름을 그대로 5초 동안 멈춘다.

이 구조에서는:

async def
    ↓
blocking operation
    ↓
event loop가 다른 일을 처리할 기회를 얻지 못함

이라는 문제가 생길 수 있다.

Python 공식 asyncio 문서에서도 blocking I/O나 CPU-bound 작업을 event loop에서 그대로 실행하면 event loop를 막을 수 있으며, 필요할 경우 executor에서 실행하는 방법을 설명한다.

이 부분에서 한 가지 중요한 결론이 나왔다.

async def라고 적혀 있다고 그 안의 모든 코드가 비동기로 실행되는 것은 아니다.


처음에는 함수 선언만 봤지만 이제는 내부 라이브러리부터 본다

예전에는 코드를 보면 먼저 이것을 봤다.

async def

지금은 그보다 안쪽을 먼저 본다.

예를 들어:

async def get_book():
    result = some_library.get_data()
    return result

라고 되어 있다고 하자.

여기에서 중요한 질문은:

get_book()async def인가?

보다:

some_library.get_data()가 호출되는 동안 실행권을 돌려줄 수 있는가?

이다.

외부 API를 호출하는 라이브러리라고 해도 동기 방식으로 통신한다면 네트워크 응답이 올 때까지 현재 실행 흐름을 붙잡고 있을 수 있다.

반대로 비동기 HTTP client를 사용하고:

result = await client.get(...)

처럼 실제 I/O 대기 구간에서 await할 수 있다면 그 시간에 다른 task가 실행될 수 있다.

FastAPI 공식 문서도 사용하는 서드파티 라이브러리가 await를 지원한다면 async def와 함께 사용하고, await를 지원하지 않는 blocking 라이브러리를 사용할 경우 일반 def path operation을 사용할 수 있다고 설명한다. 일반 def path operation은 FastAPI가 외부 thread pool에서 실행한다.

이걸 이해한 뒤에는 async가 더 빠른가보다 내가 사용하는 라이브러리가 비동기 실행을 실제로 지원하는가가 훨씬 중요한 질문이 됐다.


FastAPI에서 일반 def가 무조건 나쁜 것도 아니었다

처음에는 FastAPI 프로젝트 안에서 다음 코드를 보면:

def get_data():
    ...

이걸 전부:

async def get_data():
    ...

로 바꿔야 성능이 좋아질 것 같은 느낌이 있었다.

하지만 FastAPI의 동작을 보면 그렇게 단순하지 않다.

FastAPI는 일반 def로 선언된 path operation을 event loop에서 직접 실행해 전체 서버를 막는 대신 외부 thread pool에서 실행한다.

즉 다음과 같은 구조를 생각할 수 있다.

async def endpoint

→ event loop에서 실행
→ await 가능한 I/O와 잘 맞음

반대로:

def endpoint

→ FastAPI가 thread pool에서 실행
→ blocking code가 event loop를 직접 잡지 않도록 처리

이 차이를 모르고 모든 함수를 async def로 변경하면 오히려 blocking 코드를 event loop 안으로 직접 가져오는 상황도 만들 수 있다.

그래서 지금은 def를 보면:

비동기가 아니니까 느리다.

라고 판단하지 않는다.

먼저 그 함수가 어떤 작업을 하고 있는지 본다.


내가 가장 조심하게 된 것은 “겉만 async인 코드”다

개인적으로 더 위험하다고 느낀 코드는 이런 형태다.

@app.get("/recommend")
async def recommend():
    data = blocking_function()

    result = await async_function(data)

    return result

코드에 async, await가 모두 있으니 전체적으로 비동기 처리가 잘 되어 있는 것처럼 보인다.

하지만 실제 흐름을 보면:

요청 시작
   ↓
blocking_function()
   ↓
여기서 event loop가 오래 잡힐 수 있음
   ↓
그 뒤에야 await

가 된다.

즉 아래쪽에 await가 존재한다고 앞에서 발생한 blocking이 없어지는 것은 아니다.

이 부분 때문에 이제는 비동기 코드를 볼 때 함수 전체보다 각 작업이 어디에서 기다리고 어디에서 CPU를 사용하는지를 나눠서 본다.


await가 많다고 좋은 코드도 아니다

반대 방향의 오해도 생길 수 있다.

비동기 코드가 좋다고 하니 코드 곳곳에 await가 많으면 효율적인 코드라고 생각하기 쉽다.

하지만 await의 개수 자체는 성능 지표가 아니다.

예를 들어:

result1 = await api_a()
result2 = await api_b()
result3 = await api_c()

라고 되어 있다고 하자.

세 요청이 서로의 결과에 의존한다면 이 순서가 맞다.

하지만 서로 완전히 독립적인 요청이라면 지금 구조는:

A 완료 기다림
↓
B 시작
↓
B 완료 기다림
↓
C 시작

처럼 동작한다.

비동기 함수를 사용하고 있기는 하지만 서로 독립적인 대기를 순차적으로 수행하고 있는 셈이다.

이 경우에는 실제 요구사항에 따라 독립 작업을 concurrent하게 실행할 수 있는지 검토할 수도 있다.

중요한 것은:

await를 썼는가?

가 아니라:

어떤 작업은 반드시 순서대로 기다려야 하고, 어떤 작업은 동시에 기다릴 수 있는가?

이다.

비동기 프로그램을 설계할 때 이 구분이 더 중요하다고 느끼게 됐다.


내 검색 서비스에서도 순서가 필요한 작업과 그렇지 않은 작업이 있었다

예를 들어 자연어 검색을 생각해보면 다음 과정은 순서가 필요하다.

사용자 검색어
      ↓
Embedding 생성
      ↓
그 Embedding으로 KNN 검색

Elasticsearch KNN 검색을 하려면 Query Vector가 먼저 있어야 한다.

따라서:

Embedding 완료 전
KNN 실행

은 불가능하다.

이 경우에는 기다리는 것이 정상이다.

반면 검색 결과가 이미 준비된 뒤 서로 독립적인 부가 데이터를 여러 곳에서 조회해야 한다면 이야기가 달라질 수 있다.

이 차이를 생각하면서 비동기 설계를:

무조건 동시에 실행한다

가 아니라:

의존성이 있는 작업은 순차

독립적인 I/O는 필요하면 동시 처리

로 보게 됐다.

이런 관점이 없으면 비동기로 작성했는데도 전체 작업은 여전히 대부분 순차적으로 실행될 수 있다.


Database Connection Pool도 async와 별개의 제한을 가지고 있었다

asyncpg를 사용하면 많은 요청을 비동기로 처리할 수 있다.

그렇다고 PostgreSQL Connection이 무한히 생기는 것은 아니다.

내가 사용하던 구조에서는 Connection Pool에 최대 크기를 두고 있었다.

예를 들면:

pool = await asyncpg.create_pool(
    dsn=DATABASE_URL,
    min_size=1,
    max_size=50
)

여기에서 생각해야 하는 부분이 있다.

FastAPI가 동시에 500개의 요청을 받아 처리할 수 있다고 해도 모든 요청이 동시에 PostgreSQL Connection을 확보할 수 있다는 뜻은 아니다.

Pool에서 사용할 수 있는 Connection이 모두 사용 중이라면 나머지 요청은 Connection이 반환되기를 기다려야 한다.

동시 API 요청 다수
        ↓
DB 작업 필요
        ↓
Connection Pool
        ↓
사용 가능한 Connection이 있으면 실행

없으면
        ↓
Connection 반환 대기

처음에는 이런 대기를 보면 Pool을 더 크게 만들면 해결될 것처럼 느껴질 수 있다.

하지만 앞에서 PostgreSQL 연결 문제를 다루면서 생각했던 것처럼 Pool 역시 크게 할수록 무조건 좋은 설정은 아니다.

FastAPI가 비동기로 많은 요청을 받을 수 있다는 것과 PostgreSQL이 동시에 많은 Query를 안정적으로 처리할 수 있다는 것은 서로 다른 문제이기 때문이다.

애플리케이션의 concurrency와 데이터베이스의 처리 용량을 동일하게 보면 안 된다.


비동기는 “대기를 없애는 것”이 아니라 “대기를 활용하는 것”이었다

이 문장이 내가 async를 이해하면서 가장 중요하게 느낀 부분이다.

예를 들어 DB가 1초 걸리는 작업을 비동기로 만든다고 1초라는 DB 처리시간 자체가 사라지는 것은 아니다.

외부 LLM API가 결과를 보내는 데 3초가 걸린다면 await를 붙였다고 3초가 0초가 되는 것도 아니다.

그 대신:

DB를 기다리는 1초

LLM을 기다리는 3초

같은 시간 동안 Python 서버가 다른 요청을 처리할 수 있는 여지를 만드는 것이다.

그래서 async를 성능 최적화라고 표현할 때도 조금 조심해야 한다고 생각한다.

한 요청의 latency를 줄이는 관점과 서버 전체의 concurrency를 높이는 관점은 다르기 때문이다.


CPU 작업에서는 이야기가 다시 달라진다

여기까지는 대부분 I/O 대기에 관한 이야기였다.

그런데 API 안에서 CPU를 오래 사용하는 작업을 실행한다면 비동기의 장점이 그대로 적용되지 않을 수 있다.

예를 들어 설명을 위해 다음과 같은 작업이 있다고 하자.

def heavy_calculation():
    result = 0

    for i in range(100_000_000):
        result += i * i

    return result

이 작업은 외부 서버의 응답을 기다리고 있는 것이 아니다.

CPU가 계속 계산해야 한다.

이를 단순히:

async def heavy_calculation():
    ...

로 바꾼다고 계산량이 줄지 않는다.

더 큰 문제는 이 계산을 event loop에서 오래 실행하면 그 시간 동안 다른 coroutine이 실행될 기회를 잃을 수 있다는 점이다.

Python 공식 문서 역시 CPU-bound 작업은 event loop를 block할 수 있으며, 이런 작업에는 일반적으로 process pool 같은 별도 실행 방식을 고려하도록 설명한다.

그래서 이제는 작업을 크게 두 종류로 나누려고 한다.

기다리는 시간이 긴 작업

DB
외부 API
네트워크
일부 파일 I/O

이런 작업에서는 async가 잘 맞을 가능성이 높다.

CPU가 계속 계산해야 하는 작업

대규모 데이터 계산
이미지 처리
무거운 변환
복잡한 CPU 연산

이런 작업은 단순히 async def로 감싼다고 해결되지 않는다.


async def인데 서버가 같이 느려진다면 이렇게 생각할 수 있다

만약 비동기로 작성한 API 한 개가 오래 실행될 때 다른 요청들까지 함께 느려진다면 나는 지금 다음 순서로 볼 것 같다.

처음부터:

FastAPI가 느리다.

라고 생각하지 않는다.

먼저 함수 안쪽을 본다.

async def
   ↓
어떤 라이브러리를 호출하는가?

그 다음:

실제로 await 가능한 I/O인가?

아니면 blocking 함수인가?

를 본다.

그것도 아니라면:

CPU를 오래 잡고 있는 계산은 없는가?

를 확인한다.

DB 문제라면:

asyncpg Pool에서 Connection을 기다리고 있는가?

도 본다.

외부 API라면:

상대 서버 자체가 느린 것인가?

도 분리한다.

이렇게 보면 같은 “API가 느리다”라는 현상 안에서도 원인이 완전히 다를 수 있다.


응답시간 하나만 기록하는 것도 부족하다

앞의 LLM 스트리밍 글에서 응답시간을 여러 구간으로 나눠야 한다고 정리했다.

FastAPI 비동기 처리도 마찬가지다.

예를 들어 검색 요청 하나가 4초 걸렸다고 하자.

전체 응답시간 = 4초

이 정보만 가지고는 async 문제인지 알 수 없다.

실제로는:

Embedding      0.5초
Elasticsearch  0.2초
PostgreSQL     0.1초
LLM            3.0초
기타           0.2초

일 수도 있다.

이 경우 FastAPI의 async def를 수정하는 것보다 LLM 호출이 전체 latency의 대부분을 차지한다는 사실을 먼저 보는 것이 맞다.

반대로 특정 Python 처리 단계에서 3초를 CPU로 사용한다면 이야기가 달라진다.

그래서 성능을 개선하려면:

비동기냐 동기냐

를 먼저 보는 것보다:

실제 시간은 어디에서 소비되고 있는가?

를 확인해야 한다.


동시 요청 테스트를 해야 async의 차이를 볼 수 있는 이유

비동기 코드를 테스트하면서 요청을 한 번만 보내는 것도 부족할 수 있다.

예를 들어:

요청 1개

동기 코드 = 1.0초
비동기 코드 = 1.0초

라는 결과가 나왔다고 하자.

그러면:

async를 사용해도 아무 차이가 없네.

라고 결론 내리기 쉽다.

하지만 비동기의 장점이 대기시간 동안 다른 작업을 처리하는 것이라면 요청 하나만 있을 때는 그 장점이 나타날 상황 자체가 적다.

따라서 실제로 확인하고 싶은 것은 동시 요청이 늘어날 때다.

예를 들어:

1 concurrent request

10 concurrent requests

50 concurrent requests

100 concurrent requests

처럼 요청 수를 늘려본다.

그리고 단순 평균 response time뿐 아니라:

전체 처리량

p50

p95

오류 수

DB Connection 사용량

같은 값도 볼 수 있다.

그래야 비동기 구조가 실제 동시 요청 상황에서 어떤 차이를 만드는지 확인할 수 있다.


그런데 동시 요청 테스트도 잘못하면 다른 것을 측정하게 된다

여기서 또 한 단계 생각해야 한다.

예를 들어 DB를 사용하는 API에 500개의 요청을 동시에 보냈다고 하자.

결과가 느려졌다.

그렇다고 바로:

FastAPI async 성능이 부족하다.

고 판단할 수 없다.

DB Pool이 최대 20개라면 대부분의 요청이 Connection을 기다린 결과일 수 있다.

또 PostgreSQL Query 자체가 병목일 수도 있다.

외부 LLM API에 rate limit이 걸렸을 수도 있다.

결국 부하 테스트에서 얻는 숫자 하나도 어디에서 기다리는지와 함께 보지 않으면 의미가 부족하다.

이 부분 때문에 성능 테스트는 숫자를 만드는 작업이라기보다 병목 위치를 설명할 수 있게 만드는 작업에 가깝다고 생각한다.


지금 다시 FastAPI API를 작성한다면 판단 기준은 단순하다

이제는 defasync def를 선택할 때:

무엇이 더 빠르지?

부터 묻지 않을 것 같다.

대신 다음부터 본다.

첫째, 사용하는 라이브러리가 비동기 API를 제공하는가?

예를 들어 asyncpg처럼 await 가능한 DB 작업을 사용한다면 async def 흐름이 자연스럽다.

둘째, 함수 내부에 오래 걸리는 blocking 코드가 있는가?

있다면 async def 안에 그대로 넣는 것이 적절한지 다시 본다.

셋째, CPU-bound 작업인가?

CPU 계산이 주된 작업이라면 asyncio만으로 해결하려고 하지 않는다.

넷째, 동시에 실행할 수 있는 독립적인 I/O가 있는가?

순차적으로 기다릴 필요가 없는 작업이라면 concurrency 구조를 검토한다.

다섯째, 실제 병목은 어디인가?

FastAPI가 아니라 DB Pool, PostgreSQL, Elasticsearch, 외부 API일 수도 있다.


결국 async는 문법보다 실행 흐름의 문제였다

처음 FastAPI에서 async def를 사용했을 때는 이것을 일종의 성능 옵션처럼 생각했다.

def
보다
async def가 빠르다.

정도로 단순화해서 이해하기 쉬웠다.

그런데 실제 검색·추천 API처럼 PostgreSQL, Elasticsearch, Embedding, LLM API가 한 요청 안에서 이어지는 구조를 보다 보니 이 구분만으로는 설명되지 않는 부분이 너무 많았다.

중요한 것은 결국:

어디에서 CPU를 사용하는가?

어디에서 외부 결과를 기다리는가?

기다리는 동안 실행권을 돌려줄 수 있는가?

다른 요청과 동시에 진행할 수 있는 작업인가?

어떤 자원에서 동시 실행 수가 제한되는가?

였다.

그래서 지금은 async def를 다음처럼 이해하는 편이 가장 편하다.

코드를 자동으로 빠르게 만들어주는 문법이 아니라, 기다림이 많은 프로그램에서 그 대기시간을 다른 작업에 활용할 수 있도록 만드는 실행 방식이다.

그리고 이 관점으로 보면 왜 async def를 사용했는데도 API가 빨라지지 않을 수 있는지도 설명된다.

DB Query 자체가 느리면 Query를 개선해야 한다.

Connection Pool이 가득 찼다면 Pool과 DB 용량을 봐야 한다.

외부 API가 느리다면 그 시간을 별도로 측정해야 한다.

동기 blocking 라이브러리가 event loop를 붙잡고 있다면 호출 구조를 바꿔야 한다.

CPU 계산이 오래 걸린다면 async가 아니라 실행 환경 자체를 다시 생각해야 한다.

결국 async는 해결책 하나가 아니라 병목이 어디에 있는지를 이해하고 나서 선택해야 하는 도구였다.

이 사실을 이해하고 나니 FastAPI 성능 문제를 볼 때도 이제는 함수 선언부의 async부터 보지 않는다.

먼저 그 함수 안에서 무엇을 기다리고 있고, 그 기다림 동안 서버가 다른 일을 할 수 있는 구조인지부터 확인한다.

그 질문에 답할 수 있어야 async def를 사용한 이유도 설명할 수 있고, 실제로 성능 문제가 발생했을 때 어디부터 확인해야 할지도 정할 수 있기 때문이다.