본문으로 건너뛰기
김신건의 로그

[FastAPI] Async / Sync Endpoints

· 수정 · 📖 약 3분 · 909자/단어 #python #fastapi #async #asyncio #concurrency
FastAPI Async, FastAPI async def, FastAPI sync def, run_in_threadpool, async endpoint, FastAPI 비동기, FastAPI 이벤트 루프

정의

FastAPI 는 ASGI 프레임워크 이므로 endpoint 를 async def 로 정의해 이벤트 루프에서 실행하거나, def 로 정의해 스레드풀에서 실행할 수 있습니다. 어느 쪽을 선택하는지가 성능과 정확성 모두에 큰 영향을 미칩니다.

async def vs def

@app.get("/sync")
def sync_endpoint():
    # 스레드풀 (기본 40 워커) 에서 실행
    return blocking_operation()

@app.get("/async")
async def async_endpoint():
    # 이벤트 루프에서 실행
    return await async_operation()

결정 트리

  • await 할 것이 있음 (async DB, async HTTP): async def
  • Blocking I/O 만 있음 (sync ORM, requests, 파일 IO): def
  • CPU-bound 계산: def (스레드풀 이용, GIL 은 문제지만 이벤트 루프는 안 막음) or 별도 프로세스풀

잘못된 조합의 위험

위험 1: async def 안의 blocking I/O

import time
import requests

@app.get("/danger1")
async def danger1():
    time.sleep(1)                    # 이벤트 루프 1초 정지 (모든 요청 대기)
    return requests.get("...").json()  # 마찬가지 정지

이벤트 루프는 모든 요청을 처리하는 단일 스레드. 여기서 blocking 하면 모든 요청이 대기. p99 latency 급등.

위험 2: def 안의 async 코드

@app.get("/danger2")
def danger2():
    result = asyncio.run(some_coro())   # 새 이벤트 루프 생성, 매 요청마다 비용
    return result

asyncio.run 은 새 루프. 이미 부모 프로세스에 루프가 있는데 별도로 만들어 리소스 낭비. 아예 async def 로 바꾸는 게 맞음.

올바른 조합

@app.get("/sync")
def sync_ep():
    return sync_db.query(User).all()   # 스레드풀에서 blocking

@app.get("/async")
async def async_ep(db: Annotated[AsyncSession, Depends(get_async_db)]):
    result = await db.execute(select(User))
    return result.scalars().all()

run_in_threadpool 브릿지

async def 안에서 blocking 함수를 호출해야 할 때:

from fastapi.concurrency import run_in_threadpool

@app.get("/mixed")
async def mixed_ep():
    async_data = await fetch_async()
    blocking_data = await run_in_threadpool(blocking_function)
    return {"a": async_data, "b": blocking_data}

run_in_threadpool 은 함수를 default thread pool (AnyIO 기반) 에서 실행하고 결과를 await 가능한 형태로 반환. 이벤트 루프 blocking 없음.

스레드풀 크기

기본은 40 워커 (Starlette + AnyIO). CPU 코어 수에 무관.

import anyio

@app.on_event("startup")
async def resize_threadpool():
    anyio.to_thread.current_default_thread_limiter().total_tokens = 100

또는 lifespan:

from contextlib import asynccontextmanager

@asynccontextmanager
async def lifespan(app: FastAPI):
    limiter = anyio.to_thread.current_default_thread_limiter()
    limiter.total_tokens = 100
    yield

app = FastAPI(lifespan=lifespan)

Dependency 도 async / sync

Dependency 도 함수든 async 함수든 가능. FastAPI 가 알아서 처리.

def get_db():                # sync dep
    db = SessionLocal()
    try: yield db
    finally: db.close()

async def get_async_db():    # async dep
    async with AsyncSessionLocal() as db:
        yield db

Async DB (SQLAlchemy 2.0)

from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker

engine = create_async_engine("postgresql+asyncpg://...")
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)

async def get_async_db() -> AsyncSession:
    async with AsyncSessionLocal() as session:
        yield session

@app.get("/users/{id}")
async def read_user(
    id: int,
    db: Annotated[AsyncSession, Depends(get_async_db)],
):
    result = await db.execute(select(User).where(User.id == id))
    user = result.scalar_one_or_none()
    if not user:
        raise HTTPException(404)
    return user

Async HTTP client (httpx)

import httpx

async def get_http_client() -> httpx.AsyncClient:
    async with httpx.AsyncClient(timeout=10) as client:
        yield client

@app.get("/external/{id}")
async def call_external(
    id: int,
    client: Annotated[httpx.AsyncClient, Depends(get_http_client)],
):
    resp = await client.get(f"https://api.example.com/{id}")
    return resp.json()

성능 팁: AsyncClient 를 lifespan 에서 하나 만들어 dependency 로 재사용 (매 요청 새 클라이언트 생성 회피). Connection pooling.

from contextlib import asynccontextmanager

@asynccontextmanager
async def lifespan(app: FastAPI):
    app.state.http = httpx.AsyncClient(timeout=10)
    yield
    await app.state.http.aclose()

async def get_http() -> httpx.AsyncClient:
    return app.state.http

asyncio.gather 로 병렬 요청

import asyncio

@app.get("/aggregate")
async def aggregate(client: Annotated[httpx.AsyncClient, Depends(get_http)]):
    a_task = client.get("https://a")
    b_task = client.get("https://b")
    c_task = client.get("https://c")
    a, b, c = await asyncio.gather(a_task, b_task, c_task)
    return {"a": a.json(), "b": b.json(), "c": c.json()}

세 요청을 병렬로. 순차 대비 3배 빠름 (외부 latency 지배 시).

CPU-bound 처리

CPU-bound 작업은 def + threadpool 이 아니라 process pool 이 정답. GIL 회피.

from concurrent.futures import ProcessPoolExecutor
import asyncio

process_pool = ProcessPoolExecutor(max_workers=4)

async def heavy_compute(x: int) -> int:
    loop = asyncio.get_running_loop()
    return await loop.run_in_executor(process_pool, cpu_heavy_fn, x)

@app.get("/compute/{x}")
async def compute(x: int):
    return await heavy_compute(x)

또는 Lambda 로 오프로드.

AnyIO 를 통한 크로스 백엔드

Starlette 는 AnyIO 를 통해 asyncio + trio 모두 지원. FastAPI 의 default 는 asyncio. Trio 사용은 드묾.

언제 어느 것을?

상황권장
Async DB (SQLAlchemy 2.0 async, Tortoise, Motor)async def
Sync DB (전통 SQLAlchemy, Django ORM)def
Async HTTP (httpx, aiohttp)async def
Sync HTTP (requests)def (또는 httpx.Client)
파일 IO (동기)def 또는 async def + run_in_threadpool
WebSocketasync def (Starlette 요구)
BackgroundTasks함수 시그니처는 자유롭게
CPU 계산def + process pool
캐시 조회 (Redis async)async def

성능 벤치마크 감

  • Async ORM + async HTTP: 최대 처리량 (수천 RPS/instance)
  • Sync ORM + async endpoint: 스레드풀 병목, 40 워커 상한
  • Sync ORM + sync endpoint: 스레드풀 병목, 40 워커 상한 (같은 임계)
  • Async endpoint + blocking call in threadpool: 정상 처리

함정

WARNING

async def 안에서 절대 time.sleep, requests.get, sync ORM 호출 금지. 이벤트 루프 정지 = p99 폭발.

CAUTION

스레드풀 default 40 은 sync endpoint 병목. 동시 sync 요청이 40 을 넘으면 나머지는 대기. 스레드풀 크기 조정 또는 async 로 이행.

WARNING

asyncio.run 을 endpoint 안에서 호출하지 마세요. 이미 있는 루프와 충돌. 그냥 async def 로 만들거나 run_in_threadpool 사용.

IMPORTANT

AsyncSession commit/rollback. async with 로 세션을 감싸고 반드시 명시적으로 await session.commit(). Yield 방식 dependency 에서 expire_on_commit=False 를 잊으면 응답 직렬화 시 lazy load 로 async 오류.

CAUTION

Middleware 는 async 로 작성. Sync middleware 는 자동으로 스레드풀에 넣기지만 별도 오버헤드.

관련 위키

이 글의 용어 (9개)
[AWS] Lambda: 서버리스 함수, 트리거, 동시성cloud
정의 AWS Lambda = 서버리스 함수 실행. 이벤트 트리거 → 함수 실행 → 결과 / 비동기 처리. 서버 관리 0. 사용 상황 | 상황 | Lambda 적합성 | |---|…
[FastAPI] Background Tasksfastapi
정의 FastAPI 에서 응답 반환 후 실행하는 짧은 작업 은 , 장기/신뢰성 있는 작업 은 외부 task queue (Celery, Arq, RQ) 로 오프로드합니다. 두 카테…
[FastAPI] Dependency Injectionfastapi
정의 FastAPI Dependency Injection 은 로 함수를 endpoint 에 자동 주입하는 시스템입니다. DB 세션, 인증 사용자, 설정, 서비스 계층 등을 end…
[FastAPI] Middlewarefastapi
정의 FastAPI Middleware 는 모든 요청과 응답을 가로채 로깅, 인증, 압축, CORS 등 공통 처리를 하는 계층입니다. Starlette 의 ASGI middlew…
[Python] Async Generator, async for, async withpython
정의 Python의 비동기 모델은 코루틴 + iterator의 조합으로 확장된다. 동기 generator가 lazy 시퀀스를 만들듯, async generator는 각 값 사이에…
[Python] asyncio: 비동기 프로그래밍python
정의 asyncio 는 Python 의 표준 비동기 I/O 라이브러리 (3.4+). event loop 위에서 coroutine 을 스케줄링. 로 정의한 함수가 코루틴이 되고, …
[Python] asyncio: 이벤트 루프, async/awaitpython
정의 는 Python 표준 라이브러리의 단일 스레드 이벤트 루프 기반 비동기 I/O 프레임워크다. 로 코루틴을 정의하고 로 다른 코루틴/awaitable 완료를 기다린다. I/O…
[Python] concurrent.futures: ThreadPoolExecutor, ProcessPoolExecutorpython
정의 는 과 위에 고수준 Executor 인터페이스를 제공한다. 워커 풀, Future 객체, map/submit API로 동기 코드처럼 병렬 작업을 작성할 수 있다. 두 Exe…
[Python] FastAPIfastapi
정의 FastAPI 는 Python 3.8+ 을 위한 ASGI 기반 현대 웹/API 프레임워크 입니다. Sebastián Ramírez (tiangolo) 가 2018년 발표했고…

💬 댓글

사이트 검색 / 명령어

검색

스크롤 = 확대/축소 · 드래그 = 이동 · 0 = 원래 크기 · ESC = 닫기