FastAPI Uvicorn 동작 원리 - 100-hours-a-week/5-yeosa-wiki GitHub Wiki
0. WSGI vs ASGI: FastAPI의 기반 이해
a. WSGI (Web Server Gateway Interface)
- 기존 Flask, Django (구버전) 등이 사용했던 동기 기반 Python 웹 서버 인터페이스 표준
- 요청-응답이 동기적으로 한 번에 처리됨 → 비동기 처리 불가
- 실시간 통신(WebSocket 등), 높은 I/O 동시성 처리가 어려움
b. ASGI (Asynchronous Server Gateway Interface)
- 비동기 처리를 지원하는 웹 서버 인터페이스 표준
- HTTP 요청뿐만 아니라 WebSocket, SSE 등 실시간 양방향 통신 지원
async def
기반의 코루틴 처리를 통해 수천 개의 요청을 동시에 처리할 수 있음- FastAPI는 이 ASGI 표준을 채택한 대표적인 프레임워크
1. FastAPI의 구조
- FastAPI는 Python으로 작성된 ASGI 앱이며, 일반적으로
uvicorn
이라는 ASGI 서버 위에서 실행 uvicorn
은 단일 프로세스 안에서 단일 스레드 기반의 asyncio 이벤트 루프를 실행하며, 이 루프가 모든 HTTP 요청 수신, 처리, 응답을 관리uvicorn main:app
명령어로 실행하면,main.py
에서 정의된 FastAPI 인스턴스인app
을 가져와 이벤트 루프에 등록하여 요청을 처리
2. FastAPI의 동작 원리
a. 전체 요청 수신 및 처리 흐름
- 클라이언트가
http://0.0.0.0:8000/test
로 요청을 보냄 - OS 커널이 해당 요청을 TCP 레벨에서 수신 → TCP 수신 버퍼에 저장
- Uvicorn의 이벤트 루프가
epoll
또는 **select
**해 소켓 이벤트를 감지 - 소켓에 데이터가 존재하면
uvicorn
이 ASGI 인터페이스 (scope
,receive
,send
)를 통해 FastAPI 앱에 요청 전달 - FastAPI는 내부적으로 Starlette의 라우터 시스템을 이용해 URL과 메서드에 따라 적절한 핸들러를 찾음
- 해당 핸들러가
async def
이면 코루틴으로 이벤트 루프에서 직접 실행되고,def
이면 별도의 스레드(ThreadPoolExecutor
)에서 실행됨 - 실행 완료 후 FastAPI가 send() 를 호출, 이벤트 루프가 실제 TCP 응답으로 전송
async def
와 def
의 차이
b. async def
(코루틴): 이벤트 루프에서 직접 실행되며,await
지점마다 다른 코루틴 작업으로 전환 가능def
: 코루틴이 아니기 때문에 루프에서 실행할 수 없음 → 자동으로 ThreadPoolExecutor에 offload됨
3. FastAPI의 장점 (ASGI 기반의 이점)
- 비동기 프로그래밍을 지원하여, I/O 바운드 작업(DB, 파일, 외부 API 호출 등)을 매우 효율적으로 처리 가능
- WebSocket, HTTP2, Server-Sent Events 같은 실시간 프로토콜을 지원
async def
기반의 코루틴을 통해 수천 개의 요청을 처리할 수 있어 고성능 웹 서버를 구성할 수 있음- Starlette 기반으로 구성되어 있어 라우팅, 미들웨어, 요청/응답 모델 유효성 검증이 강력함
4. GIL 환경에서 FastAPI 비동기 작업이 실행되는 방식
a. GIL 환경
- Python은 GIL(Global Interpreter Lock)이라는 구조 때문에, 한 시점에 하나의 스레드만 Python 바이트코드를 실행할 수 있음
- 하지만 Python 인터프리터는 일정 주기마다 (기본 5ms, 또는 100 bytecode 실행마다) GIL을 다른 스레드에게 넘김
- PyTorch, NumPy 등 C 확장 모듈은 내부에서 GIL을 해제하고 연산하므로 병렬 실행이 가능(Python GIL 해제 연산의 병렬 처리 구조)
b. GIL의 작동 방식과 스레드 전환
- GIL(Global Interpreter Lock)은 CPython 인터프리터에서 한 시점에 하나의 스레드만 Python 바이트코드를 실행하도록 제한한다.
- 하지만 다음 조건 중 하나를 만족할 경우 GIL은 다른 스레드에게 양도된다:
- 기본적으로 5ms마다 자동 양도 (또는 100 bytecode 실행 후)
- I/O 작업이나 sleep처럼 GIL을 자동 해제하는 C 함수 호출 시
- 명시적으로 GIL을 해제한 확장 모듈(Pytorch, NumPy 등) 내부 실행 시
c. FastAPI의 def, async def 처리 흐름에서 GIL의 영향
실행 유형 | 실행 위치 | GIL 영향 | 실행 흐름 |
---|---|---|---|
def 라우터 |
ThreadPoolExecutor (스레드) | GIL 필요. 단, C 연산 시 해제됨 | 스레드 생성 후 함수 실행. Python 코드만 있을 경우 GIL 점유. C 확장 호출 시 병렬 가능 |
async def 라우터 |
asyncio 이벤트 루프 (메인 스레드) | GIL 필요 | 코루틴으로 스케줄링되며 await마다 다른 task로 전환 가능 |
def
함수는 별도의 스레드에서 실행되며, 해당 스레드가 GIL을 점유하지만 일정 주기마다 양도되거나 C 연산 중 해제된다.async def
는 이벤트 루프에서 실행되며,await
를 만나면 루프는 같은 스레드 내에서 다른 코루틴으로 전환한다 (스레드 간 전환 아님).
d. await 시 실제로 무슨 일이 일어나는가?
@app.get("/compute")
async def compute():
await asyncio.sleep(1) # 이 지점에서 다른 코루틴 실행 기회 제공
return {"result": "ok"}
await
는 해당 코루틴의 실행을 일시 중단하고 이벤트 루프에 제어권을 반환함- 이 시점에 루프는 다른 코루틴(예: 다른
async def
요청 처리)을 실행함 - 스레드를 전환하는 것이 아니라, 동일한 이벤트 루프 안에서 실행 가능한 코루틴을 스케줄링함
예시: PyTorch GIL 해제 동작
def infer():
return model.encode_image(image_tensor) # 내부는 C++ 연산 → GIL 영향 X
def
라우터에서 실행되면 FastAPI가 ThreadPoolExecutor에 넘기므로 이벤트 루프 점유는 없음- encode_image의 내부는 C++연산으로, GIL의 영향을 받지 않아 여러 연산이 동시에 실행될 수 있음
5. CPU Bound Task를 async def로 처리했을 때의 문제점
a. 잘못된 예시
@app.get("/block")
async def block():
for _ in range(10**9): # CPU 바운드 연산
pass
return {"ok": True}
b. 이때 발생하는 현상
- 이벤트 루프가
epoll()
을 돌리지 못함 → 다른 요청이 들어와도 감지하지 못함 - TCP 소켓은 커널 버퍼에 도착하지만, Python 애플리케이션이 수신을 못 해서 응답 없음
- 일정 시간 뒤 클라이언트에서 timeout 발생
- 대기 요청이 많아지면 TCP 수신 버퍼가 차서 TCP RST로 연결 종료됨
6. FastAPI에서 CPU Bound Task를 효율적으로 실행하는 전략
asyncio.to_thread()
사용
a. @app.get("/infer")
async def infer():
result = await asyncio.to_thread(model.encode_image, image_tensor)
return result
- CPU 바운드 작업을 기본 ThreadPoolExecutor에 offload하면서 이벤트 루프는 자유롭게 유지됨
b. 세마포어를 이용한 병렬 제한
sem = asyncio.Semaphore(4)
@app.get("/infer")
async def infer():
async with sem:
result = await asyncio.to_thread(model.encode_image, image_tensor)
return result
- 동시에 실행될 수 있는 CPU 작업의 개수를 제한하여 ThreadPoolExecutor의 과도한 스레드 점유를 방지
- Python의 스레드는 많아질수록 컨텍스트 스위칭 비용, 메모리 소비, GIL 경쟁이 증가하게 된다.
- 특히 CPU 바운드 연산이 많을 경우, 스레드 수가 많다고 해서 성능이 선형적으로 향상되지 않으며, 오히려 스케줄링 비용으로 서버 전체 응답 성능이 저하될 수 있다.
- 따라서 세마포어를 사용해 동시 실행 수를 제한함으로써 서버 자원 소비를 예측 가능하게 유지하고, 과도한 부하로부터 안정적인 운영을 도모할 수 있다.
c. ThreadPoolExecutor 커스터마이징
from concurrent.futures import ThreadPoolExecutor
loop = asyncio.get_event_loop()
loop.set_default_executor(ThreadPoolExecutor(max_workers=8))
- 전역으로 스레드풀의 크기를 조정해 자원 사용량을 통제
d. 적절한 병렬성 파라미터 조정 전략
가. 서버 CPU 코어 수 확인
os.cpu_count()
로 사용 가능한 논리 코어 수를 확인- 일반적으로 병렬 처리 스레드 수는 이 수를 초과하지 않는 것이 좋음
- 단, PyTorch와 같은 내부 병렬 연산 라이브러리가 있다면 내부 스레드 수까지 고려해야 함
나. PyTorch 내부 병렬성 조절
- PyTorch는 연산마다 내부적으로 멀티스레드를 사용할 수 있으므로,
torch.set_num_threads(n)
를 통해 조절해야 한다 - FastAPI의 외부 병렬성과 PyTorch 내부 병렬성이 충돌하지 않도록 주의해야 한다
- 예:
ThreadPoolExecutor(max_workers=8)
+torch.set_num_threads(4)
는 최악의 경우 8개의 요청이 동시에 처리되며 각 요청당 4개의 내부 스레드를 사용 → 총 32개 이상의 스레드가 동시에 돌게 되어 시스템 컨텍스트 스위칭 오버헤드 증가
- 예:
- 스레드 수를 어디서 제한할 것인가?
- 일반적으로는 ThreadPoolExecutor 또는 세마포어를 통해 외부 병렬도를 우선 제한하고, PyTorch의 내부 스레드는 1~2로 낮추는 것이 안정적이다
- 이유: FastAPI는 다수의 요청을 짧게 처리하는 서버 성격이므로, 짧고 많은 외부 요청을 빠르게 처리하는 데 적합한 구조가 필요함
- 반대로, 요청 수가 매우 적고 한 요청당 연산량이 매우 크다면 PyTorch의 내부 스레드를 높게 설정하는 것이 유리할 수 있다 (ex. 배치 인퍼런스 서버)
- 일반적으로는 ThreadPoolExecutor 또는 세마포어를 통해 외부 병렬도를 우선 제한하고, PyTorch의 내부 스레드는 1~2로 낮추는 것이 안정적이다
- 그럼 어디서 결정해야 할까?
- 요청당 연산 시간, TPS, 서버 CPU 수, 평균 동시 요청 수 등을 기준으로 조절해야 한다
- 예시 지표 기준:
- CPU 코어 수: 8
- TPS: 10, 요청당 처리 시간: 0.5s → 동시 처리량 5
- PyTorch 연산이 무거우면
torch.set_num_threads(2)
+Semaphore(4)
- PyTorch 연산이 가볍고 TPS가 높으면
torch.set_num_threads(1)
+Semaphore(8)
- 또한
torch.get_num_threads()
,psutil.Process().num_threads()
같은 수치를 통해 실시간 스레드 사용량을 추적해 조정 가능
- PyTorch처럼 GIL을 해제하는 C 확장 모듈이 아닌, 일반 Python 작업이라면 Uvicorn에서 스레드가 많아질수록 CPU 사용량도 늘어나는가?
- 답: 보통은 '예', 하지만 ‘비례적 증가’는 아니다.
-
순수 Python 작업은 GIL의 영향을 받는다
- Python에서
def
함수는ThreadPoolExecutor
를 통해 스레드풀에서 실행됨 - 하지만 이 작업이 Python 바이트코드만 수행한다면 GIL을 점유한 스레드만 실행 가능
- 여러 스레드가 있어도 한 번에 하나의 스레드만 실행 가능 (동시에는 안 됨)
- Python에서
-
그럼에도 불구하고 CPU 사용량은 올라간다
- 이유: 모든 스레드가 GIL을 얻기 위해 경쟁하고, 계속 깨어 있으며, 컨텍스트 스위칭이 계속 발생하기 때문
- 예를 들어, 스레드 A가 GIL을 얻어 실행되고 B, C, D가 대기하면서 CPU를 사용하지는 않지만 일정 시간마다 스레드 전환(GIL 양도)이 일어나며, 이 과정이 CPU 자원을 소모함
-
즉, GIL이 있음에도 스레드 수가 늘어나면 CPU 사용률이 “일정 수준까지는” 올라간다
- 하지만 스레드 수만큼 병렬 처리되진 않기 때문에 CPU 전체 코어를 100%로 꽉 채우는 일은 PyTorch 같은 C 연산만큼 극적이지 않음
-
비교: GIL 없는 작업과 있는 작업
작업 종류 GIL 해제됨 병렬 실행 CPU 점유 증가 PyTorch/Numpy 연산 ✅ ✅ ✅ (코어 수만큼) 순수 Python for-loop ❌ ❌ ⚠️ (스레드 수 증가에 따라 일부 상승) asyncio I/O 대기 작업 ✅ (실행 안 됨) ❌ ❌ (거의 없음)
-
- 결론
- 순수 Python 연산을 하는 작업이 Uvicorn 스레드풀에 많아지면, 스레드는 GIL 때문에 실제 병렬 실행은 못 해도, 컨텍스트 스위칭과 GIL 경쟁으로 인해 CPU 사용률은 증가한다.
- 다만 그 증가폭은 PyTorch처럼 C 코드 병렬 실행되는 경우보다 훨씬 낮다.
- 순수 Python 연산을 하는 작업이 Uvicorn 스레드풀에 많아지면, 스레드는 GIL 때문에 실제 병렬 실행은 못 해도, 컨텍스트 스위칭과 GIL 경쟁으로 인해 CPU 사용률은 증가한다.
- 답: 보통은 '예', 하지만 ‘비례적 증가’는 아니다.
다. 병렬 작업 처리 시간과 TPS 분석
- 한 요청당 평균 처리 시간(
t
)과 예상되는 초당 요청량(TPS,r
)을 기준으로 동시 처리 수 ≒ t × r - 예: 요청당 평균 1.2초, TPS가 5면 → 약 6~7개의 동시 작업 슬롯 필요
- 이를 기준으로 세마포어 수(
Semaphore(n)
)를 조정하며 부하를 제어할 수 있음
라. 부하 테스트를 통한 실측 최적화
- 적절한 세마포어 수나 병렬성 설정을 찾기 위해, 동시 요청 수를 하나씩 늘려가며 부하 테스트를 실행하는 것이 중요하다. 이때 다음과 같은 지표와 현상을 관찰해야 한다:
- 관찰할 주요 지표
- 평균 응답 시간: 증가하는 지점이 병목 발생 시점
- p95, p99 응답 지연: tail latency 증가 여부 확인
- 오류율: HTTP 5xx 오류, 타임아웃 발생 여부
- CPU 사용률: 100%에 근접할 경우 스레드 경쟁 과다
- 시스템 스레드 수 및 컨텍스트 스위칭 횟수:
psutil
또는top
/htop
으로 확인
- '적절한 세마포어 수' 판단 기준
- 성능 지표가 선형으로 증가하다가 꺾이는 지점
- 응답 시간 증가 없이 최대 TPS를 유지할 수 있는 지점
- CPU 사용률이 100% 가까워지기 직전
- 오류율 없이 처리 가능한 최대 동시 요청 수
- 테스트 시나리오 예시
- 세마포어 수를 1부터 시작하여 2씩 증가 (예: 1 → 3 → 5 → 7 ...)
- 각 설정마다 30~60초 동안 지속 부하를 주고 위 지표들을 측정
- p95 응답 시간 급증 또는 오류율이 1% 이상 되는 순간 이전 값으로 되돌림
- 최종적으로 "성능 효율 vs 안정성" 균형점에서 고정
- 이러한 방식으로 설정된 세마포어 수는 특정 머신 사양, 모델 크기, 요청 부하에 따라 최적화된 병렬 처리 수가 된다.
k6
,Locust
같은 부하 테스트 도구를 이용해 다양한 설정으로 요청을 시뮬레이션- 주요 관측 지표:
- 평균 응답 시간, p95/p99 응답 지연
- 오류율 (타임아웃 또는 연결 끊김)
- CPU 사용률 및 시스템 컨텍스트 스위칭
- 주요 관측 지표:
- 테스트 결과를 기반으로 병렬도 수치를 증가/감소시키며 안정성과 처리량의 균형점 찾기(
os.cpu_count()
) - PyTorch의 내부 쓰레드 수 조절 (
torch.set_num_threads(n)
) → 과도한 내부 병렬성 방지 - 평균 요청 처리 시간과 초당 요청량(TPS)을 기준으로 세마포어 수 조정
- 실제 트래픽 시뮬레이션(k6, Locust 등)으로 최적 병렬성 검증
7. 그 외 핵심 개념 요약
a. 이벤트 루프 점유 여부 비교
상황 | 이벤트 루프 점유 여부 |
---|---|
def 라우터 |
❌ (스레드에서 실행) |
async def 내부에서 동기 함수 직접 실행 |
✅ (루프 블로킹됨) |
async def 내부에서 await to_thread() |
❌ (스레드에서 실행) |
b. 이벤트 루프 점유 시의 실제 현상
- 요청이 도착해도 루프가
epoll
을 돌리지 못해서 요청 수신 자체가 지연됨 - 응답 없는 서버처럼 보이며 timeout 발생
to_thread()
의 장점
c. - GIL이 없는 연산(C 코드)와 결합 시 매우 효과적
- 이벤트 루프를 유지하며 고성능 처리 가능
- 세마포어 등으로 병렬성 제어까지 가능
8. 요약
- FastAPI에서 CPU 바운드 연산은 반드시 이벤트 루프 외부에서 처리해야 하며,
- 가장 실용적이고 안전한 기본 전략은
await asyncio.to_thread(...)
- 필요 시
Semaphore
, 커스텀 ThreadPoolExecutor, 또는multiprocessing
전략을 함께 도입하면, - 고동시성 + 고부하 상황에서도 안정적인 응답성을 확보할 수 있다.
- 이러한 전략을 통해 FastAPI는 Python의 GIL 제약 속에서도 훌륭한 성능을 내는 서버 프레임워크가 된다.