본문 바로가기

기타

WSGI, ASGI, 이벤트 루프 정리

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라고 선언만 했다고 비동기가 되는 게 아니라,

그 안의 모든 호출이 루프를 양보할 줄 알아야 비로소 비동기라고 할 수 있습니다.