🐍 FastAPI와 asyncio: 비동기 처리 완전 정복
FastAPI는 요즘 Python 웹 개발에서 가장 많이 쓰이는 프레임워크 중 하나다.
나는 오늘 FastAPI가 어떻게 요청을 처리하는지 궁금해서 직접 실험해봤고, 비동기(async) 와 동기(sync) 의 처리 방식 차이를 완벽히 이해할 수 있었다.
특히, FastAPI가 동기 함수도 병렬 처리하는 내부 구조를 보고 정말 흥미로웠다.
✅ FastAPI는 왜 비동기 프레임워크인가?
FastAPI는 ASGI(Asynchronous Server Gateway Interface) 기반이다.
ASGI 덕분에 FastAPI는 async def
로 작성한 비동기 엔드포인트를 단일 스레드 이벤트 루프에서 처리할 수 있다.
🧠 asyncio와 이벤트 루프 이해하기
🌀 코루틴(Coroutine)이란?
코루틴은 실행 중간에 멈췄다가 다시 이어서 실행할 수 있는 함수다.
예제 코드를 보자.
@app.get("/asyncio-test/{user_id}")
async def asyncio_test(user_id: int):
print(f"▶️ User {user_id} START")
await asyncio.sleep(3)
print(f"✅ User {user_id} END")
return {"user": user_id, "status": "done"}
여기서 await asyncio.sleep(3)
구문은 단순한 딜레이가 아니다. 이벤트 루프가 이 코루틴을 일시 중단하고 다른 요청들을 처리한다. 3초 후에 다시 이 코루틴으로 돌아와 이어서 실행한다.
🏎️ 실제 I/O 작업도 동일
asyncio.sleep(3)
대신 API 호출, DB 쿼리, 파일 읽기 등 실제 I/O 작업도 같은 방식으로 처리된다.
async def fetch_data():
print("📡 서버 요청 시작")
async with aiohttp.ClientSession() as session:
async with session.get("https://api.example.com/data") as resp:
result = await resp.text()
print("📡 서버 응답 완료")
return result
여기서도 API 응답을 기다리는 동안 이벤트 루프는 다른 요청들을 실행한다.
syncio 이벤트 루프 동작 다이어그램
+--------------------+
| 이벤트 루프 |
+--------------------+
↑ ↑
│ │
[코루틴 A] [코루틴 B]
↓ ↓
+----+----+ +----+----+
| await I/O| | await DB|
+---------+ +---------+
🧵 그렇다면 동기(sync) 함수는?
다음은 동기(sync) 엔드포인트이다.
@app.get("/sync-test/{user_id}")
def sync_test(user_id: int):
print(f"▶️ User {user_id} START")
time.sleep(3) # 동기 블로킹
print(f"✅ User {user_id} END")
return {"user": user_id, "status": "done"}
만약 이 동기 함수를 이벤트 루프에서 그대로 실행하면, 한 요청이 블로킹될 때 모든 요청이 멈춘다. FastAPI는 이 문제를 해결하기 위해 ThreadPoolExecutor를 사용한다.
🌱 ThreadPoolExecutor 내부 처리
- 동기 함수는 이벤트 루프에서 실행하지 않고 스레드 풀로 위임된다.
- 각 요청은 스레드 풀의 스레드 하나를 차지한다.
- 기본 max_workers는 CPU 코어 수 × 5
- 요청이 스레드 풀 한계를 초과하면 초과 요청은 대기 상태
🔥 테스트: 비동기 vs 동기 요청 처리 시간
📝 테스트 환경
- 요청 개수: 100개 동시 요청
- 서버: Uvicorn (workers=1)
- 엔드포인트: 위의
asyncio-test
와sync-test
테스트 앱(FastAPI, app.py)
from fastapi import FastAPI
import asyncio
import time
app = FastAPI()
@app.get("/asyncio-test/{user_id}")
async def asyncio_test(user_id: int):
print(f"▶️ User {user_id} START - {time.strftime('%X')}")
await asyncio.sleep(3) # 3초 동안 대기
print(f"✅ User {user_id} END - {time.strftime('%X')}")
return {"user": user_id, "status": "done"}
@app.get("/sync-test/{user_id}")
def sync_test(user_id: int):
print(f"▶️ User {user_id} START - {time.strftime('%X')}")
time.sleep(3) # 동기 블로킹
print(f"✅ User {user_id} END - {time.strftime('%X')}")
return {"user": user_id, "status": "done"}
앱서버 실행
uvicorn app:app --reload --workers 1
test.sh
)
🏃♂️ 테스트 스크립트 (#!/bin/bash
# 테스트할 FastAPI 서버 주소
BASE_URL="http://127.0.0.1:8000"
# 요청 개수
NUM_REQUESTS=100
# ========================
# 1. Async 엔드포인트 테스트
# ========================
echo "🚀 Testing ASYNC endpoint with $NUM_REQUESTS requests..."
START_TIME=$(date +%s)
for i in $(seq 1 $NUM_REQUESTS); do
curl -s "$BASE_URL/asyncio-test/$i" > /dev/null &
done
wait
END_TIME=$(date +%s)
ASYNC_DURATION=$((END_TIME - START_TIME))
echo "✅ ASYNC completed in $ASYNC_DURATION seconds"
# ========================
# 2. Sync 엔드포인트 테스트
# ========================
echo ""
echo "🚀 Testing SYNC endpoint with $NUM_REQUESTS requests..."
START_TIME=$(date +%s)
for i in $(seq 1 $NUM_REQUESTS); do
curl -s "$BASE_URL/sync-test/$i" > /dev/null &
done
wait
END_TIME=$(date +%s)
SYNC_DURATION=$((END_TIME - START_TIME))
echo "✅ SYNC completed in $SYNC_DURATION seconds"
# ========================
# 3. 결과 비교
# ========================
echo ""
echo "======================="
echo "🏁 TEST RESULTS"
echo "======================="
echo "ASYNC total time: ${ASYNC_DURATION}s"
echo "SYNC total time: ${SYNC_DURATION}s"
⏱️ 결과
엔드포인트 | 처리 시간 | 처리 방식 |
---|---|---|
asyncio-test | 약 3초 | 이벤트 루프에서 코루틴 번갈아 실행 |
sync-test | 약 9초 | ThreadPoolExecutor 스레드 배치 처리 |
🚀 Testing ASYNC endpoint with 100 requests...
✅ ASYNC completed in 3 seconds
🚀 Testing SYNC endpoint with 100 requests...
✅ SYNC completed in 9 seconds
=======================
🏁 TEST RESULTS
=======================
ASYNC total time: 3s
SYNC total time: 9s
📦 핵심 요약
구분 | 비동기(async) | 동기(sync) |
---|---|---|
처리 방식 | 이벤트 루프 + 코루틴 | ThreadPoolExecutor로 스레드 실행 |
I/O 대기 중 | 다른 코루틴 실행 | 다른 스레드 처리 가능 |
동시 처리 한계 | 없음 (I/O 바운드에 최적) | 스레드 풀 크기 한계 있음 |
CPU 바운드 처리 | GIL로 병렬 불가 (멀티프로세스 필요) | 스레드 풀 덕분에 어느 정도 가능 |
내부구조
+------------------+
Incoming Request →| ASGI 서버(Uvicorn)|
+------------------+
│
┌───────────────┴───────────────┐
│ │
async def 처리 def 처리
(이벤트 루프 직접 실행) (ThreadPoolExecutor 위임)
🧠 오늘의 깨달음
✅ FastAPI는 비동기 프레임워크지만 동기 함수도 안전하게 처리한다. ✅ 하지만 동기 함수는 스레드 풀 크기에 따라 병목이 생길 수 있다. ✅ I/O 바운드 작업은 비동기로 작성하는 것이 훨씬 효율적이다. ✅ CPU 바운드 작업은 멀티프로세스 처리가 필요하다.