수요일

FastAPI async 블로킹 완벽 해결 — async def인데 요청이 순서대로 처리되는 이유와 이벤트 루프 막힘 해결법

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

댓글 없음:

댓글 쓰기