요약: FastAPI의 async def 엔드포인트 안에서 time.sleep, requests, 동기 DB 드라이버 같은 동기 코드를 실행하면 이벤트 루프가 막혀 모든 요청이 순차 처리됩니다. async를 빼고 def로 선언하거나, httpx·asyncio.sleep 같은 비동기 라이브러리로 교체하거나, asyncio.to_thread로 블로킹 구간만 스레드로 넘기면 해결됩니다. CPU 바운드 작업은 프로세스로 분리하세요.
🔍 검색 키워드: FastAPI async 블로킹, FastAPI 이벤트 루프 막힘, FastAPI 응답 느림 async def, FastAPI run_in_threadpool, asyncio to_thread 사용법, FastAPI 동시 요청 처리 지연
증상: async def 엔드포인트인데 동시 요청이 순서대로 처리된다
FastAPI는 비동기 프레임워크인데, 엔드포인트 하나가 느려지면 전혀 관계없는 /health 같은 다른 API까지 함께 느려지거나 타임아웃이 나는 경우가 있습니다. 부하 테스트를 해 보면 동시 요청 수만큼 응답 시간이 선형으로 늘어납니다.
$ hey -n 20 -c 20 http://localhost:8000/report
Response time histogram:
0.503 [1] |■
1.004 [1] |■
...
10.012 [1] |■
Total: 10.0121 secs # 0.5초짜리 작업 20개가 순차 실행됨
WARNING: Task exception was never retrieved / uvicorn: Timeout in /health probe원인: 이벤트 루프 스레드를 동기 코드가 점유한다
async def 함수는 이벤트 루프 스레드 하나에서 실행됩니다. 이 안에서 await 없이 오래 걸리는 동기 코드를 실행하면, 그동안 루프가 멈춰 다른 모든 요청이 대기합니다. 대표적인 원인은 다음과 같습니다.
- time.sleep(): 비동기 sleep이 아니라 스레드 전체를 멈춤
- requests 라이브러리: 동기 HTTP 호출이 루프를 블로킹
- 동기 DB 드라이버: psycopg2, pymysql, 동기 SQLAlchemy 세션 사용
- CPU 바운드 작업: 이미지 처리, pandas 대용량 연산, 암호화 해시 등
- 파일 I/O: 대용량 파일을 open().read()로 동기 처리
import time
import requests
from fastapi import FastAPI
app = FastAPI()
@app.get("/report")
async def report():
time.sleep(0.5) # 루프 전체 블로킹
r = requests.get("https://api.example.com/data") # 동기 호출도 블로킹
return r.json()해결방법 1: async를 빼고 def로 선언하기
FastAPI는 일반 def 엔드포인트를 스레드풀에서 실행합니다. 내부에서 동기 라이브러리를 쓸 수밖에 없다면 async를 제거하는 것이 가장 간단한 해결책입니다.
@app.get("/report")
def report(): # async 제거 → 스레드풀에서 실행
time.sleep(0.5)
return requests.get("https://api.example.com/data").json()해결방법 2: 진짜 비동기 라이브러리로 교체
async def를 유지하려면 내부 호출도 모두 await 가능한 라이브러리여야 합니다. requests는 httpx, time.sleep은 asyncio.sleep으로 바꿉니다.
import asyncio
import httpx
@app.get("/report")
async def report():
await asyncio.sleep(0.5)
async with httpx.AsyncClient() as client:
r = await client.get("https://api.example.com/data")
return r.json()해결방법 3: 블로킹 구간만 스레드로 넘기기
async def 안에 동기 코드가 일부만 섞여 있다면 해당 구간만 to_thread 또는 run_in_threadpool로 감쌉니다.
import asyncio
from starlette.concurrency import run_in_threadpool
@app.get("/report")
async def report():
data = await asyncio.to_thread(load_legacy_data) # Python 3.9+
text = await run_in_threadpool(render_pdf_text, data) # Starlette 제공
return {"text": text}해결방법 4: CPU 바운드는 프로세스로 분리
스레드는 GIL 때문에 CPU 연산을 가속하지 못합니다. 무거운 연산은 ProcessPoolExecutor나 Celery 같은 워커로 분리하고, uvicorn 워커 수도 함께 늘립니다.
from concurrent.futures import ProcessPoolExecutor
pool = ProcessPoolExecutor(max_workers=4)
@app.get("/heavy")
async def heavy():
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(pool, cpu_heavy_job, 42)
return {"result": result}
# 실행: uvicorn main:app --workers 4블로킹 위치 진단하기
asyncio 디버그 모드를 켜면 루프를 일정 시간 이상 붙잡은 코루틴을 경고로 알려 줍니다.
PYTHONASYNCIODEBUG=1 uvicorn main:app
# Executing <Task ...> took 0.503 seconds ← 이 시간만큼 루프가 막혔다는 뜻정리표
| 원인 | 증상 | 해결 |
|---|---|---|
| time.sleep, requests 등 동기 I/O | 동시 요청이 순차 처리됨 | def로 선언 또는 httpx/asyncio.sleep으로 교체 |
| 동기 DB 드라이버 | DB 쿼리 중 다른 API 정지 | asyncpg, async SQLAlchemy 또는 to_thread |
| CPU 바운드 연산 | 연산 중 health check 타임아웃 | ProcessPool, Celery, uvicorn --workers |
| 원인 불명 지연 | 특정 요청 후 전체 지연 | PYTHONASYNCIODEBUG=1로 블로킹 코루틴 추적 |
핵심은 async def 안에서는 await 없이 오래 걸리는 코드를 실행하지 않는다는 원칙입니다. 동기 코드가 섞여 있다면 def로 선언하거나 스레드·프로세스로 넘겨 이벤트 루프를 항상 비워 두세요.
자주 묻는 질문(FAQ)
FastAPI에서 async def와 def 중 무엇을 써야 하나요?
함수 안에서 호출하는 라이브러리가 모두 await 가능한 비동기 라이브러리(httpx, asyncpg 등)라면 async def를, requests나 동기 DB 드라이버처럼 동기 라이브러리를 쓴다면 def를 사용하세요. def 엔드포인트는 FastAPI가 스레드풀에서 실행하므로 이벤트 루프를 막지 않습니다.
async def 안에서 동기 함수를 호출하면 왜 전체 API가 느려지나요?
async def는 이벤트 루프 스레드 하나에서 실행되기 때문입니다. await 없이 오래 걸리는 동기 코드가 실행되는 동안 루프가 멈춰, 같은 프로세스의 다른 모든 요청도 대기합니다.
CPU를 많이 쓰는 작업은 to_thread로 해결되나요?
대부분 해결되지 않습니다. 파이썬의 GIL 때문에 스레드는 CPU 연산을 병렬로 가속하지 못합니다. ProcessPoolExecutor, Celery 같은 별도 워커, 또는 uvicorn --workers로 프로세스를 늘리는 방식이 필요합니다.
이벤트 루프를 막는 코드는 어떻게 찾나요?
PYTHONASYNCIODEBUG=1 환경변수로 서버를 실행하면 일정 시간 이상 루프를 점유한 코루틴이 경고로 출력됩니다. 출력된 Task 위치에서 동기 I/O나 긴 연산을 찾으면 됩니다.
관련 글: Go goroutine leak 완벽 해결 — 고루틴이 계속 늘어나는 원인과 pprof 진단법
참고 문서: FastAPI Concurrency and async / await
최종 업데이트: 2026-10-01 · 작성: 내멋스레 사는 이야기