FastAPI를 쓰다 보면 한 번쯤 마주치는 의문들이 있습니다.
async def로 선언했는데 왜 느리지, 워커는 몇 개로 띄워야 하지, def 함수는 써도 되는 건가 등등
이런 것들이 다 한 줄기에서 나온 질문이라는 걸 최근에야 깨달았고, 정리하는 김에 글로 옮겨보았습니다.
1. WSGI vs ASGI
두 표준의 차이는 결국 동기냐 비동기냐이지만, 실제로는 처리 모델 자체가 완전히 다릅니다.
WSGI (동기 표준)
- 한 워커 = 한 요청. 요청이 끝날 때까지 워커가 붙들려 있음
- DB 쿼리가 5초 걸리면 그 5초 동안 워커는 대기
- HTTP만 다룸 (WebSocket, SSE 불가)
- 대표: Flask, 전통적 Django
- 장점: 단순함과 안정성. 디버깅 쉽고 라이브러리 호환성도 거의 완벽
- 단점: 동시 연결이 늘면 워커를 그만큼 늘려야 한다는 것. 메모리도 먹고 끝없이 늘릴 수도 없음
ASGI (비동기 표준)
- async/await 기반에 단일 이벤트 루프 위에서 코루틴 수만 개를 빠르게 오가며 처리
- HTTP뿐 아니라 WebSocket, HTTP/2, SSE 같은 long-lived 연결도 지원
- 핸들러 시그니처가 (scope, receive, send): 한 연결 안에서 메시지를 여러 번 주고받을 수 있는 형태
- 대표: FastAPI, Starlette, Django 3.0+
선택 기준은 의외로 단순합니다.
I/O 바운드에 동시 연결이 많으면 ASGI,
단순 CRUD에 트래픽이 적당하면 WSGI도 충분합니다.
2. 이벤트 루프
ASGI를 이해하려면 이벤트 루프가 핵심인데, 단일 스레드에서 코루틴들을 번갈아 실행하는 구조입니다.
동작 방식
- 코루틴이 await db.query(...) 같은 I/O를 만나면 OS에 I/O 완료 통지를 요청한 뒤 즉시 루프에 제어권 반환
- 루프는 그 사이 대기 중이던 다른 코루틴을 꺼내 실행
- I/O가 완료되면 멈췄던 코루틴이 깨어나 멈췄던 지점부터 이어 실행
여기서 자주 나오는 의문이 멀티스레드면 더 빠를텐데 왜 단일 스레드인지인데 이유는 아래와 같습니다.
왜 단일 스레드인가
- 파이썬은 GIL 때문에 멀티스레드여도 어차피 한 번에 한 스레드만 바이트코드 실행
- 단일 스레드여야 await 사이에 끼어들기가 없어서 락 없이 공유 상태를 안전하게 다룰 수 있음
- 진짜 병렬성이 필요하면 워커 프로세스를 여러 개 생성하면 됨 (uvicorn --workers 4, 보통 코어 수만큼)
컨텍스트 스위칭 비용 차이
- OS 스레드(WSGI): 커널 모드 전환, 레지스터/스택 저장과 복원, CPU 캐시 무효화. 스레드당 메모리도 수 MB
- 코루틴(ASGI): 전부 유저 공간에서. 함수 호출 수준 비용. 코루틴당 메모리도 KB 단위
비유하자면 OS 스레드 스위칭이 사무실 전체를 다른 회사에 넘기는 거라면,
코루틴 스위칭은 책상 위 서류를 잠깐 옆으로 치우는 정도입니다.
대신 결정적인 함정이 하나 있습니다.
단일 스레드라서, 어느 한 코루틴이 await 없이 CPU를 오래 잡거나 동기 블로킹 호출을 하면 그 워커의 모든 요청이 같이 멈춥니다.
3. FastAPI에서 동기 코드 다루기
실전에서 가장 헷갈리는 건 함수를 async def로 써야 할지, def로 써야 할지입니다.
원칙은 단순합니다. 함수 안의 I/O가 진짜로 비동기인지 보면 됩니다.
- 비동기 라이브러리(httpx, 비동기 DB 드라이버 등) 사용 → async def + await
- 동기 라이브러리(requests, 동기 SQLAlchemy 등) 사용 → def로 선언. FastAPI가 자동으로 스레드풀에 보내줌
가장 위험한 안티패턴은 async def 안에서 동기 함수를 호출하는 것입니다.
@app.get("/bad")
async def bad():
time.sleep(5) # 이벤트 루프가 5초간 멈춤
return "done"
위의 경우 같은 워커에 붙어 있던 다른 모든 요청이 같이 5초 멈춥니다.
해결 방법은 세 가지가 있는데, 셋 다 결국 같은 기본 스레드풀에 작업을 던지는 동일 메커니즘입니다.
해결 방법
| def 엔드포인트 | 함수 전체가 동기일 때. 가장 깔끔 |
| asyncio.to_thread(func, ...) | async def 안에서 일부 호출만 위임. contextvars 자동 전파 |
| loop.run_in_executor(...) | 커스텀 executor 필요할 때 (예: ProcessPoolExecutor로 GIL 우회) |
상황별로 정리하면 대략 아래와 같습니다.
상황별 해결 방법
| 비동기 I/O | async def + 비동기 라이브러리 |
| 동기 라이브러리 호출 | def 엔드포인트 |
| async def 안 일부만 동기 | asyncio.to_thread |
| 무거운 CPU 작업 | run_in_executor + ProcessPoolExecutor (별도 프로세스로 분리) |
| 매우 무거운 백그라운드 작업 | Celery 같은 별도 워커 시스템 |
| 처리량 자체 늘리기 | 워커 프로세스 수 늘리기 (코어 수만큼) |
여기서 한 가지 더 알아둘 게 스레드풀의 크기 입니다.
FastAPI/Starlette 기본값은 40인데,
동기 엔드포인트 하나가 오래 걸리는데 동시에 50개 요청이 들어오면 41번째부터는 슬롯을 기다리며 대기합니다.
이벤트 루프는 안 막혔지만 처리량이 막힌 거고, 사용자 체감은 똑같이 느립니다.
진짜 무거운 작업이라면 풀을 키우는 것보다 Celery로 빼는 게 정공법입니다.
마무리
ASGI는 단일 스레드 이벤트 루프로 동시 연결을 싸게 처리하는 모델이고,
그 대가로 "루프를 막지 않는다"는 규율을 지켜야 합니다.
동기 코드는 반드시 스레드풀이나 별도 워커로 위임해야 하고, 진짜 병렬 처리량은 워커 프로세스 수로 얻어야 합니다.
즉, async def라고 선언만 했다고 비동기가 되는 게 아니라,
그 안의 모든 호출이 루프를 양보할 줄 알아야 비로소 비동기라고 할 수 있습니다.
'기타' 카테고리의 다른 글
| 대규모 데이터 처리의 어려운 점 (0) | 2022.04.01 |
|---|---|
| VAE (Auto-Encoding Variational Bayes) 리뷰 (0) | 2022.03.24 |
| Quick DBD (0) | 2021.10.15 |
| [Unix] vi 에디터 사용하기 & Shell 명령 (0) | 2021.09.05 |
| [AWS] 스토리지 활용 웹사이트 만들기 (0) | 2021.08.10 |