
“FastAPI로 바꿨는데 왜 이렇게 느리지?”라는 질문은 대부분 프레임워크 자체의 문제가 아닙니다. FastAPI가 async인데도 느린 이유는 십중팔구 async def 핸들러 안에 동기(sync) 블로킹 코드가 섞여 들어가 있기 때문입니다. 이 글에서는 이벤트 루프를 막는 대표적인 동기 코드 패턴 3가지와, 실제로 이벤트 루프가 막혔는지 눈으로 확인하는 방법을 코드와 함께 정리합니다. Python 3.9 이상, FastAPI 0.100 이상, Starlette 기반 ASGI 서버(uvicorn) 환경을 기준으로 설명합니다.
async def와 def, FastAPI는 왜 처리 방식이 다른가
uvicorn 같은 ASGI 서버는 기본적으로 워커 프로세스당 이벤트 루프 하나를 돌립니다. async def로 선언한 핸들러의 코드는 이 이벤트 루프 스레드에서 직접 실행되며, 함수 안에 await가 나올 때만 제어권을 다른 코루틴에 넘깁니다. 반대로 await 없이 시간이 걸리는 동기 코드를 실행하면, 그 코드가 끝날 때까지 이벤트 루프는 다른 어떤 요청도 처리하지 못합니다. 동시 접속자가 1명이든 100명이든 전부 줄을 서서 기다리게 됩니다.
반면 일반 def로 선언한 핸들러는 Starlette가 내부적으로 AnyIO의 스레드풀(run_in_threadpool)에 위임해서 실행합니다. 즉 동기 함수를 별도 워커 스레드에서 돌리기 때문에 이벤트 루프 본체는 막히지 않습니다. 이 차이 때문에 “동기 코드는 무조건 def로, 진짜 비동기 I/O만 async def로 써야 한다”는 원칙이 나옵니다. 공식 문서에서도 이 구분을 명확히 안내하고 있습니다. (FastAPI 공식 문서: Concurrency and async/await)
패턴 1 — 동기 DB 드라이버를 async 핸들러에 그대로 꽂는 경우
가장 흔한 사례는 psycopg2, PyMySQL처럼 동기 방식으로 동작하는 드라이버를 async def 안에서 그대로 호출하는 코드입니다.
@app.get("/users/{user_id}")
async def get_user(user_id: int):
conn = psycopg2.connect(DSN)
cur = conn.cursor()
cur.execute("SELECT * FROM users WHERE id = %s", (user_id,)) # 블로킹 호출
row = cur.fetchone()
return row
cur.execute()는 네트워크 소켓에서 응답을 기다리는 동안 스레드를 그대로 점유하는 블로킹 syscall입니다. await 지점이 없으니 이벤트 루프는 쿼리가 끝날 때까지 다른 요청을 전혀 처리하지 못합니다. 쿼리 하나가 50ms만 걸려도 동시 요청 100개가 몰리면 마지막 요청은 5초 가까이 기다려야 합니다.
해결책은 두 가지입니다. 첫째, 해당 엔드포인트를 그냥 def로 바꿔 스레드풀에 위임하는 방법이고, 둘째는 asyncpg나 SQLAlchemy 2.0의 async 엔진처럼 진짜 논블로킹 드라이버로 교체하는 방법입니다. 다만 두 번째 방법은 쿼리 실행 코드 전체를 다시 작성해야 하고, 일부 확장 라이브러리(예: 특정 ORM 플러그인)는 아직 async 엔진을 지원하지 않는 경우가 있어 무조건 우월한 선택은 아닙니다.

패턴 2 — time.sleep, requests처럼 익숙한 블로킹 I/O 함수
time.sleep()과 requests.get()은 동기 코드에서 아주 흔하게 쓰이지만, async def 안에 그대로 두면 각각 이벤트 루프 전체를 지정한 시간만큼, 또는 외부 API 응답이 올 때까지 멈춰 세웁니다.
| 블로킹 패턴 | 문제 원인 | 대안 |
|---|---|---|
time.sleep(n) |
스레드를 통째로 정지 | asyncio.sleep(n) |
requests.get() |
동기 소켓 I/O | httpx.AsyncClient, aiohttp |
psycopg2 cursor.execute() |
동기 C 확장 드라이버 | asyncpg, SQLAlchemy 2.0 async engine |
PIL Image.resize, bcrypt.hashpw |
CPU 바운드 연산 | run_in_threadpool 또는 ProcessPoolExecutor |
외부 API를 호출하는 코드에서 requests를 걷어내고 httpx.AsyncClient로 바꾸는 것만으로도 동시 처리량이 크게 개선되는 경우가 많습니다. 단, httpx.AsyncClient는 요청마다 새로 만들지 않고 앱 생명주기 동안 하나의 인스턴스를 재사용해야 커넥션 풀 이점을 제대로 얻을 수 있다는 점은 트레이드오프로 기억해둘 필요가 있습니다. 매 요청마다 새 클라이언트를 생성하면 TCP 핸드셰이크 비용이 반복되어 오히려 지연이 늘어납니다.
패턴 3 — I/O가 아니라 CPU 연산 자체가 이벤트 루프를 잡아먹는 경우
앞의 두 패턴은 네트워크나 디스크 대기 시간이 문제였지만, 이번 패턴은 대기가 아니라 연산 자체가 오래 걸리는 경우입니다. 대용량 JSON 직렬화, 이미지 리사이즈, 비밀번호 해싱(bcrypt), 정규식 매칭 등이 대표적입니다.
@app.post("/signup")
async def signup(payload: SignupIn):
hashed = bcrypt.hashpw(payload.password.encode(), bcrypt.gensalt(rounds=12)) # CPU 바운드
...
bcrypt.hashpw는 네트워크 대기가 없으니 “블로킹 I/O”는 아니지만, 연산 자체가 수백 ms씩 CPU를 점유합니다. Python의 GIL(Global Interpreter Lock) 특성상 이 연산이 진행되는 동안에는 같은 프로세스 안의 다른 코루틴도, 다른 스레드도 실질적으로 진행하지 못합니다. 스레드풀로 옮기면 최소한 이벤트 루프는 풀리지만, GIL 때문에 CPU 코어를 여러 개 쓰는 것처럼 빨라지지는 않습니다. 진짜 병렬 처리가 필요하다면 loop.run_in_executor에 ProcessPoolExecutor를 넘기거나, Celery·arq 같은 별도 워커로 작업을 넘기는 구조가 필요합니다.
이벤트 루프가 실제로 막혔는지 확인하는 3가지 방법

첫 번째 방법은 헬스체크용 “핑” 엔드포인트를 하나 만들어두고, 의심되는 엔드포인트와 동시에 호출해보는 캐너리 테스트입니다.
@app.get("/ping")
async def ping():
return {"t": time.time()}
@app.get("/blocking")
async def blocking_endpoint():
time.sleep(3) # 이벤트 루프를 3초간 점유
return {"done": True}
워커 1개(uvicorn main:app --workers 1)로 띄운 뒤 /blocking을 호출하고 0.5초 뒤 /ping을 호출하면, /ping은 원래 수 ms 안에 끝나야 할 응답이 /blocking이 끝날 때까지(최대 약 2.5초) 지연됩니다. 이벤트 루프가 정상이라면 /ping은 /blocking과 무관하게 즉시 응답해야 하므로, 이 지연이 곧 블로킹의 증거입니다.
두 번째 방법은 asyncio 디버그 모드를 켜는 것입니다.
PYTHONASYNCIODEBUG=1 uvicorn main:app --workers 1
이 모드에서는 loop.slow_callback_duration(기본값 0.1초)을 초과하는 콜백이 있으면 Executing <Task ...> took 3.002 seconds처럼 경고 로그가 출력됩니다. 이 임계값과 동작 방식은 파이썬 공식 문서에 명시돼 있습니다. (Python 공식 문서: asyncio 이벤트 루프)
세 번째 방법은 운영 중인 프로세스에 py-spy dump --pid <uvicorn PID>로 스택 트레이스를 몇 초 간격으로 여러 번 떠보는 것입니다. 같은 동기 함수(예: cursor.execute, time.sleep) 프레임에 계속 멈춰 있다면 그 지점이 이벤트 루프를 막고 있다는 뜻입니다. 이 방법은 로컬 재현이 어려운 운영 환경 이슈를 진단할 때 특히 유용합니다.
동기 함수를 def로만 바꾸면 다 해결되나요?
부분적으로만 해결됩니다. def로 바꾸면 Starlette가 AnyIO 스레드풀로 실행을 넘겨 이벤트 루프 자체는 풀리지만, 스레드풀 용량은 무한하지 않습니다. 동시에 들어오는 블로킹 요청이 스레드풀 용량을 넘어서면 초과분은 대기열에 쌓여 지연이 발생합니다. 즉 def로의 전환은 “이벤트 루프 전체가 멈추는 최악의 상황”은 막아주지만, 트래픽이 많은 서비스에서는 스레드풀 크기 튜닝이나 진짜 async 드라이버 도입까지 함께 검토해야 근본적으로 해결됩니다. 또한 CPU 바운드 연산은 def 전환만으로는 GIL 문제가 그대로 남는다는 점도 앞서 다룬 대로입니다.
지금 프로젝트의 async def 핸들러를 한 번씩 훑어보면서, 그 안에 await 없이 실행되는 동기 함수 호출이 있는지 점검해보시기 바랍니다. 의심 가는 엔드포인트가 있다면 위에서 소개한 /ping 캐너리 테스트부터 5분 안에 돌려볼 수 있고, 필요하면 PYTHONASYNCIODEBUG=1로 운영 로그에서 느린 콜백을 바로 찾아낼 수 있습니다.
VRAM 8GB로 batch size 256 효과 내기 — Gradient Accumulation 정확 구현과 흔한 3가지 실수
