FastAPI Shallow Dive (4) — def vs async def, 그리고 worker 몇 개

Published:

FastAPI Shallow Dive 시리즈

  1. ASGI부터 시작
  2. Pydantic — 타입 힌트를 런타임 validator로
  3. Dependency Injection
  4. async와 worker 개수 ← 현재 (마지막)

시리즈의 마지막. 1편에서 “FastAPI는 ASGI 위에서 돈다”고 깔았고, 그 이후로 Pydantic과 DI를 봤다. 이 편은 실제 배포에서 누구나 마주치는 두 결정 — 핸들러를 def로 쓸지 async def로 쓸지, 그리고 Uvicorn worker를 몇 개로 띄울지 — 를 정리한다.

결론부터 말하면, 이 두 결정은 내가 작년 말에 쓴 GIL 시리즈의 프레임워크로 그대로 귀결된다. 이 편은 FastAPI 관점에서 그 프레임워크를 다시 꺼내는 글이다.

FastAPI가 def를 처리하는 방식

FastAPI는 핸들러를 두 가지로 나눠 서로 다른 경로로 실행한다.

@app.get("/a")
async def a():           # 이벤트 루프에서 직접 실행
    return await fetch()

@app.get("/b")
def b():                 # 스레드풀에서 실행
    return requests.get("...").json()
  • async def → 이벤트 루프 위에서 직접 호출. await 지점마다 다른 요청에 양보.
  • def (동기 함수) → Starlette의 스레드풀로 보내짐. 이벤트 루프는 블록되지 않고, 결과는 await로 받는다.

내부적으로 Starlette이 anyio.to_thread.run_sync(handler, ...)를 한다. 기본 스레드 수는 40개(anyio default). 동기 핸들러를 쓰더라도 FastAPI 앱 자체는 async로 유지된다는 게 핵심.

즉 “sync 라이브러리 쓰니까 FastAPI 못 쓴다”는 오해다. def로 쓰면 자동으로 스레드풀로 빠진다. 다만 스레드풀 크기(40)가 곧 동시 처리 한도가 된다는 제약이 붙는다.

제일 큰 함정 — async def에 블로킹을 넣는 것

반대 방향이 훨씬 흔한 실수다.

@app.get("/users/{id}")
async def get_user(id: int, db: Session = Depends(get_db)):
    user = db.query(User).filter(User.id == id).first()  # 동기 SQLAlchemy!
    time.sleep(0.3)  # 혹은 이런 거
    return user

이 핸들러는 async def라서 이벤트 루프에서 직접 돈다. 그런데 안에 동기 블로킹 호출이 들어가있다. 결과: 그 코루틴이 블로킹하는 300ms 동안 전체 프로세스의 다른 요청이 모두 멈춘다.

체감적으로 “이 앱 async인데 왜 느리지?”가 이 원인인 경우가 많다. 트래픽 올리면 latency가 뜬금없이 튀고, p99가 가파르게 치솟음.

규칙 하나로 외우면:

async def 안에서는 await로 기다릴 수 있는 것만 호출한다. requests, time.sleep, 동기 DB 드라이버, 파일 I/O — 전부 금지.

대안:

  • HTTP 호출 → httpx.AsyncClient
  • DB → asyncpg, SQLAlchemy 2.0 async mode, motor(Mongo)
  • 캐시 → redis.asyncio
  • 정말 sync 라이브러리밖에 없음 → 핸들러를 def로 바꿔서 스레드풀로 보내거나, await asyncio.to_thread(blocking_fn) 명시

판단 플로우

실무에서 내가 쓰는 순서:

1) 이 핸들러의 I/O가 전부 async-native인가?
   └ yes → async def
   └ no  → 2로

2) sync 라이브러리를 끊을 수 있나 (httpx, asyncpg 등으로)?
   └ yes → 바꾸고 1로
   └ no  → def (스레드풀 경로)

3) CPU-heavy인가 (이미지 처리, Prophet fit 등)?
   └ yes → def + ThreadPoolExecutor 혹은 ProcessPoolExecutor 분리
           (여기서 GIL 시리즈 프레임워크로 넘어감)
   └ no  → 끝

3번에서 Thread냐 Process냐의 결정은 GIL 시리즈에서 다뤘으니 여기선 요약만.

  • I/O-bound sync 라이브러리 (예: 동기 HTTP SDK) → ThreadPool. GIL이 I/O 대기 동안 풀리니 병렬성 이득 있음.
  • CPU-bound + GIL 해제 라이브러리 (OpenCV, numpy — C 확장) → ThreadPool로도 병렬화됨.
  • CPU-bound + Pure Python → ProcessPool. 단 메모리 비용이 크고 IPC가 있다.

두 프로덕션 서비스(이미지 분석 / Prophet 예측)에서 내가 어떻게 나눴는지는 GIL 시리즈 2편에서 코드로 다뤘다.

Uvicorn worker 개수 — 공식에 가까운 것

배포할 때 거의 항상 묻는 질문.

uvicorn main:app --workers 4
# 혹은 프로덕션에서는
gunicorn main:app -w 4 -k uvicorn.workers.UvicornWorker

worker 하나 = 프로세스 하나 = 독립된 Python 인터프리터 + 이벤트 루프. GIL은 프로세스 안의 문제니까, worker가 N개면 진짜로 N개의 CPU 코어에서 병렬 실행된다.

일반적인 출발점:

workers = (2 × CPU 코어 수) + 1

이건 gunicorn docs가 제안하는 WSGI 공식인데, ASGI에선 조금 다르게 봐야 한다.

  • Pure async + async-native I/O (DB 호출 전부 async): worker 하나가 수천 연결 처리 가능. 코어 수만큼이면 충분. workers = CPU 코어.
  • I/O가 sync인 def 핸들러 위주 (스레드풀 경유): worker당 스레드 40개라 실질 동시성은 workers × 40. 위 gunicorn 공식이 더 맞음.
  • CPU-heavy: worker 수를 올려도 코어 이상으론 의미 없음. workers = CPU 코어 고정하고, 느린 작업은 외부(Celery, ARQ)로.
  • 메모리 큰 모델 로드 (예: OpenCV + 머신러닝 모델): worker마다 메모리 복제. 메모리가 먼저 병목. 내 이미지 분석 API는 이 이유로 worker 4개가 한계였다.

“일단 2×CPU+1 박고, 메트릭 보고 조절”이 현실적인 시작점. 메모리가 병목이면 workers 줄이고 worker당 스레드풀을 키우는 쪽(anyio는 limiter로 조절 가능)이 나을 때가 많다.

이벤트 루프 블로킹 디버깅

async def 안에 숨은 블로킹을 찾는 게 운영에서 힘들다. 내가 쓰는 방법들:

1. Uvicorn 기본 access log의 latency 분포 관찰

p50은 괜찮은데 p99가 이상하게 튄다면 이벤트 루프 블로킹 의심.

2. asyncio.get_event_loop().set_debug(True)

루프가 “너무 오래 점유된” 코루틴을 경고로 띄운다. 개발/스테이징 환경에서 켜두면 블로킹 구간이 로그에 찍힘.

3. aiomonitor 같은 툴

루프에 붙어서 실행 중인 task, 각 task의 스택을 떠볼 수 있다. 프로덕션에는 권장 안 하지만 스테이징 디버깅에 유용.

4. 의심 코드 run_in_threadpool로 감싸보기

의심되는 한 줄을 await anyio.to_thread.run_sync(that_line, ...)로 감싸서 배포해본다. p99가 떨어지면 그게 범인.

흔한 미신 정리

“FastAPI가 Flask보다 빠르다.” — 부분적으로만 맞다. I/O가 async-native일 때 빠르다. 벤치마크는 대부분 async def + async DB 상황. 팀이 sync 라이브러리만 쓰면 FastAPI도 스레드풀로 도는 거라 Flask + gunicorn –threads와 비슷하거나 약간 낫다.

“worker를 많이 띄우면 더 빠르다.” — CPU 코어를 넘어서면 메모리만 쓰고 이득 없음. 컨텍스트 스위칭 비용이 오히려 커짐.

async def를 쓰기만 하면 non-blocking이다.” — 위에서 본 제일 큰 함정. async def 안에서 sync 호출하면 이벤트 루프 전체가 멈춘다.

“ASGI는 CPU도 병렬화해준다.” — 아니다. 단일 프로세스의 이벤트 루프는 한 스레드라서 CPU-bound는 GIL에 걸린다. ThreadPool(GIL 해제 구간 있을 때) 또는 ProcessPool로 따로 빼야 한다. 이 얘기는 GIL 시리즈가 전부.

시리즈 마무리

4편에 걸쳐서 정리한 구조:

  • 1편 — FastAPI는 ASGI 런타임 위의 프레임워크. async def가 가능한 이유가 여기서 나온다.
  • 2편 — Pydantic이 타입 힌트를 런타임 validator로 바꾼다. response_model까지.
  • 3편 — Dependency Injection은 “함수 + Depends + 요청 범위 캐시”로 끝. 단순함이 의도된 설계.
  • 4편def/async def는 내부 경로가 다르고, worker 개수는 I/O 종류 · CPU 사용 · 메모리 비용의 함수다.

이 시리즈를 쓰면서 확인한 건 FastAPI가 “빠른 프레임워크”가 아니라 “Python의 현대적인 타입 힌트와 async 모델을 1급으로 받아낸 프레임워크”라는 점이다. 속도는 부수적 결과고, 본질은 타입 계약 · async I/O · DI의 단순함 이 세 축.

worker 수와 CPU-bound 분리에 대해 더 파고들고 싶으면 GIL 시리즈로. 이 시리즈의 4편은 거기서 이어진다.