FastAPI Shallow Dive (1) — ASGI부터 시작
Published:
FastAPI Shallow Dive 시리즈
- ASGI부터 시작 ← 현재
- Pydantic — 타입 힌트를 런타임 validator로
- Dependency Injection
- async와 worker 개수
FastAPI를 두 개 서비스에서 썼고, 이제 “왜 이게 Flask가 아니라 FastAPI였어야 했나”를 정리하고 싶어졌다. 쓰는 법은 docs가 충분히 잘 써있어서, 이 시리즈는 “왜 이렇게 설계됐나” 쪽에 집중한다.
첫 편은 런타임. FastAPI는 Python 웹의 실행 모델을 바꿔 끼운 프레임워크고, 그 바꾸기를 가능하게 한 게 ASGI다. 여기부터 안 짚고 넘어가면 뒤에 나올 async def, Depends, Pydantic 얘기가 다 허공에 뜬다.
WSGI — Python 웹의 오랜 계약
Flask, Django(구 버전), Bottle… 이것들은 전부 WSGI 위에 서있다. WSGI는 2003년에 나온 계약서로, 한 줄로 요약하면 이렇다.
서버가
environ딕셔너리랑start_response콜백을 넘기면, 앱은 응답 바디(iterable)를 돌려준다.
def app(environ, start_response):
start_response("200 OK", [("Content-Type", "text/plain")])
return [b"hello"]
여기서 중요한 건 동기(sync) 라는 점. 함수가 요청 하나 처리하는 동안 해당 worker 스레드/프로세스는 다른 일을 못 한다. DB 쿼리 200ms가 걸리면 그 200ms 동안 그 worker는 할 일이 없어도 막혀있다.
그래서 WSGI 서버들(gunicorn, uWSGI)은 대체로 요청당 하나의 worker를 쓴다.
- process worker (gunicorn의 기본): 요청 처리 중이면 그 프로세스는 점유됨. 동시 처리 = worker 수.
- thread worker (
--threads): 프로세스 안에서 스레드로 나눔. 그런데 Python은 GIL이 있어서 CPU 바운드는 안 빨라지고, I/O 바운드만 이득. - gevent / eventlet: monkey-patch로 socket을 non-blocking으로 바꿔서 동시성 흉내. 동작하긴 하는데 라이브러리 호환성 이슈가 꾸준히 있었다.
어쨌든 이 모델로 “동시 접속 10,000”을 버티려면 worker가 수백~수천 개 필요하거나, 매우 기민한 튜닝이 필요하다. 그리고 장기 연결(WebSocket, Server-Sent Events, 긴 스트리밍 응답)은 WSGI 모델 안에서 잘 안 맞는다. 요청 하나가 worker를 점유하는 시간이 “몇 분”이 되는 순간 worker 풀이 금방 바닥난다.
ASGI — 계약을 한 층 확장
ASGI는 WSGI의 정신적 후계자다. 2017~2018년쯤 Django 쪽에서 본격화됐고, 공식 스펙은 asgi.readthedocs.io에 있다. 한 줄 요약:
서버가
scope,receive,send세 가지를 넘기면, 앱은 코루틴으로 응답한다.
async def app(scope, receive, send):
assert scope["type"] == "http"
await send({
"type": "http.response.start",
"status": 200,
"headers": [(b"content-type", b"text/plain")],
})
await send({"type": "http.response.body", "body": b"hello"})
달라진 포인트 셋:
- 앱이
async def다. 코루틴 기반. I/O 대기 중에는 이벤트 루프가 다른 요청으로 넘어간다. scope에type이 있다. HTTP 말고도websocket,lifespan같은 타입이 있다. 즉 WebSocket/SSE를 1급 시민으로 다룬다.receive/send가 메시지 스트림이다. 요청/응답을 “하나의 큰 덩어리”가 아니라 “이벤트의 흐름”으로 본다. 그래서 streaming, chunked, duplex가 자연스럽다.
ASGI 서버(Uvicorn, Hypercorn, Daphne)는 보통 uvloop + httptools 위에 돌아간다. 단일 프로세스가 수천 개의 동시 연결을 한 스레드에서 이벤트 루프로 돌린다. DB 쿼리 200ms 동안 해당 코루틴은 await에서 멈추고, 이벤트 루프는 다른 요청의 일을 처리하러 간다.
WSGI vs ASGI 정리
| 구분 | WSGI | ASGI |
|---|---|---|
| 모델 | sync, 1 요청 = 1 worker | async, 1 프로세스 = 수천 연결 |
| I/O 대기 중 | worker 점유 | 이벤트 루프가 다른 일 |
| 지원 프로토콜 | HTTP | HTTP, WebSocket, Lifespan, (향후) |
| 대표 서버 | gunicorn, uWSGI | Uvicorn, Hypercorn, Daphne |
| 대표 프레임워크 | Flask, Django(<3) | FastAPI, Starlette, Django(≥3 채널) |
| CPU-bound 작업 | worker 수만큼 병렬 | 한 프로세스 안에선 직렬 (GIL) |
맨 아래 줄 주의. ASGI가 CPU를 병렬화해주는 건 아니다. 이벤트 루프는 한 스레드에서 도니까, CPU-heavy 루프를 async def에 박으면 전체가 멈춘다. 이 얘기는 4편에서 ThreadPoolExecutor와 함께 다시 꺼낸다.
FastAPI = Starlette + Pydantic + 타입 기반 DI
여기까지가 “런타임” 얘기였다. 그런데 실제로 async def 앱을 손으로 짜는 건 별로다. scope/receive/send 레벨을 매번 만지는 건 WSGI에서 environ 찍어내는 것만큼 재미없다.
그래서 Starlette이 있다. ASGI 위에 라우팅, 미들웨어, 예외 핸들러, TestClient를 얹은 미니 프레임워크. Flask의 ASGI 버전 같은 느낌. FastAPI는 이 Starlette을 그대로 가져다 쓴다(from fastapi import FastAPI 하면 내부적으로 Starlette 앱).
FastAPI가 Starlette 위에 더 얹은 게 세 가지다:
- Pydantic 통합 — 요청/응답 스키마를 타입 힌트로 선언하면 validation, serialization, OpenAPI 생성까지 자동.
- Dependency Injection —
Depends(...)로 DB 세션, 인증, 설정 등을 타입으로 주입. - 자동 OpenAPI — 위 두 가지의 부산물.
/docs가 그냥 따라온다.
정리하면:
ASGI spec ← 런타임 계약
└ Uvicorn ← ASGI 서버 (이벤트 루프 + HTTP/WS 파싱)
└ Starlette ← ASGI 앱 토대 (라우팅, 미들웨어)
└ FastAPI ← + Pydantic + DI + OpenAPI
Flask와 비교하면 이렇게 된다:
WSGI spec
└ gunicorn
└ Werkzeug ← Flask의 하부 (라우팅, 요청 객체)
└ Flask ← + 템플릿, Blueprint, 컨텍스트
같은 층위에서 갈라진다. Flask/Werkzeug = WSGI, FastAPI/Starlette = ASGI. 그 위에 얹은 건 각자 스타일대로.
첫 앱 — 3줄짜리
from fastapi import FastAPI
app = FastAPI()
@app.get("/")
async def root():
return {"hello": "world"}
돌리기:
uv pip install fastapi uvicorn
uvicorn main:app --reload
이것만 봐도 차이가 보인다. 핸들러가 async def. return한 dict이 자동으로 JSON 직렬화. /docs에 가면 Swagger UI가 이미 그려져 있다. 세 번째 부분(OpenAPI)이 “어떻게 자동으로?”에 답하려면 2편의 Pydantic 얘기가 먼저 나와야 한다.
그래서 FastAPI는 언제 쓰고 언제 안 쓰나
사내에서 “이거 FastAPI로 갈까, Flask로 갈까” 논의가 있을 때 내가 쓰는 기준:
FastAPI가 맞는 경우
- 외부 API 호출, DB, 메시지 큐 같은 I/O가 지배적이고 높은 동시성이 필요할 때
- WebSocket/SSE — 실시간 알림, 진행률 스트리밍
- API 스키마가 계약인 서비스 (프론트/파트너가 OpenAPI 스펙으로 개발)
- 팀이 타입 힌트에 익숙하거나 타입 힌트를 강제하고 싶을 때
Flask/Django가 맞는 경우
- 기존 sync 라이브러리가 핵심이고 async 대응이 아직 미비 (일부 ORM, SDK)
- 서버 사이드 렌더링 + 관리자 페이지 중심 (Django admin이 답인 경우)
- 팀이 async/await 멘탈 모델이 없고, 트래픽이 낮아 worker 수로 충분할 때
- 짧은 내부 스크립트/어드민 툴 — 러닝 커브가 오버킬
“FastAPI가 더 빠르다”는 이유만으로 고르는 건 별로다. I/O가 별로 없거나 트래픽이 낮으면 Flask로 100 RPS 찍고 끝나는 경우가 태반이다. FastAPI의 진짜 이득은 속도가 아니라, async I/O · 타입 계약 · 자동 문서 이 세 개가 맞물릴 때 나온다.
정리
- WSGI는 sync 계약, ASGI는 async 계약. ASGI가 I/O 동시성과 WebSocket 같은 장기 연결을 1급으로 다룬다.
- Uvicorn은 ASGI 서버, Starlette은 ASGI 앱 토대, FastAPI는 그 위에 Pydantic + DI + OpenAPI.
- ASGI가 CPU를 병렬화해주는 건 아니다. GIL은 그대로 있고, 이건 4편에서 다시 다룬다.
- FastAPI의 이득은 raw 속도가 아니라 async + 타입 계약 + 문서의 결합.
다음 편은 Pydantic. “타입 힌트를 썼을 뿐인데 왜 런타임 validator가 되어있는가”라는 마법의 뒷면.
