FastAPI Shallow Dive (4) — def vs async def, 그리고 worker 몇 개
Published:
FastAPI Shallow Dive 시리즈
- ASGI부터 시작
- Pydantic — 타입 힌트를 런타임 validator로
- Dependency Injection
- 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.0async 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편은 거기서 이어진다.
