FastAPI Shallow Dive (1) — ASGI부터 시작

Published:

FastAPI Shallow Dive 시리즈

  1. ASGI부터 시작 ← 현재
  2. Pydantic — 타입 힌트를 런타임 validator로
  3. Dependency Injection
  4. 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"})

달라진 포인트 셋:

  1. 앱이 async def다. 코루틴 기반. I/O 대기 중에는 이벤트 루프가 다른 요청으로 넘어간다.
  2. scopetype이 있다. HTTP 말고도 websocket, lifespan 같은 타입이 있다. 즉 WebSocket/SSE를 1급 시민으로 다룬다.
  3. receive/send가 메시지 스트림이다. 요청/응답을 “하나의 큰 덩어리”가 아니라 “이벤트의 흐름”으로 본다. 그래서 streaming, chunked, duplex가 자연스럽다.

ASGI 서버(Uvicorn, Hypercorn, Daphne)는 보통 uvloop + httptools 위에 돌아간다. 단일 프로세스가 수천 개의 동시 연결을 한 스레드에서 이벤트 루프로 돌린다. DB 쿼리 200ms 동안 해당 코루틴은 await에서 멈추고, 이벤트 루프는 다른 요청의 일을 처리하러 간다.

WSGI vs ASGI 정리

구분WSGIASGI
모델sync, 1 요청 = 1 workerasync, 1 프로세스 = 수천 연결
I/O 대기 중worker 점유이벤트 루프가 다른 일
지원 프로토콜HTTPHTTP, WebSocket, Lifespan, (향후)
대표 서버gunicorn, uWSGIUvicorn, 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 위에 더 얹은 게 세 가지다:

  1. Pydantic 통합 — 요청/응답 스키마를 타입 힌트로 선언하면 validation, serialization, OpenAPI 생성까지 자동.
  2. Dependency InjectionDepends(...)로 DB 세션, 인증, 설정 등을 타입으로 주입.
  3. 자동 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가 되어있는가”라는 마법의 뒷면.