🔍 검색 키워드: 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로 선언하거나 스레드·프로세스로 넘겨 이벤트 루프를 항상 비워 두세요.
댓글 없음:
댓글 쓰기