Python 3.14의 free-threaded(무 GIL) 인터프리터를 FastAPI/Flask와 Granian으로 벤치마크해 ASGI/WSGI에서 처리량, 지연, 메모리 사용을 비교한다. I/O 중심 워크로드에선 GIL-free가 비슷하거나 더 낫고, CPU 중심 WSGI에선 큰 이득이 있으며, ASGI에선 메모리 확장 없이 동시성이 쉬워진다는 점을 보여준다.
Python 3.14가 이달 초에 출시됐다. 이번 릴리스가 특히 흥미로웠던 이유는 인터프리터의 "free-threaded"(GIL 없는) 변형에 대한 개선 때문이다.
구체적으로, Python 3.13의 free-threaded 변형과 비교했을 때 두 가지 큰 변화가 있다:
Miguel Grinberg가 Python 3.14의 성능에 관한 좋은 글을 정리했는데, free-threaded 변형과 GIL 변형을 비교한 섹션이 있다. 그의 결과는 3.13t 대비 3.14t에서 설득력 있는 성능 향상을 보여준다. 매우 고무적이다!
그의 벤치마크는 피보나치 수열 계산과 버블 정렬 알고리즘처럼 CPU 바운드 작업에 초점이 맞춰져 있지만, 내 Python 경험의 큰 부분은 웹 개발에 집중돼 있다. 내가 유지보수하는 주요 오픈소스 프로젝트가 파이썬용 웹 프레임워크와 웹 서버이기도 하고. 그래서 웹 애플리케이션에서 free-threaded와 GIL 파이썬 인터프리터를 제대로 비교해 보고 싶었다. 세상 대부분의 웹 서비스는 99.9999%가 I/O 바운드다. 데이터베이스와 상호작용하거나 다른 서비스에 요청을 보내니까. 그럼에도 동시성은 핵심 요소이며, 우리는 수십 년 동안 multiprocessing 모듈로 기괴한 일을 해 왔다. 더 많은 일을 병렬로 더 많이 하기 위해서 말이다. 이제 드디어 한 번에 1개 이상의 요청을 처리하려고 기가바이트 단위의 메모리를 낭비하는 일을 멈출 수 있는 때가 온 걸까?
인정하자. 우리는 벤치마크를 이해하는 데 늘 어려움을 겪어 왔다. 특히 그것이 웹 기술과 관련되어 있을 때는 더더욱. 인터넷에는 벤치마크를 둘러싼 논쟁이 넘쳐난다. 방법론, 코드, 환경 등 모든 측면을 두고 논쟁이 벌어진다. 가장 흔한 반응은 "근데 왜 X도 테스트하지 않았나요" 혹은 "내 앱은 그 라이브러리 쓰는데 저렇게 스케일 안 돼요, 거짓말이네요" 같은 말들이다. 이런 댓글이 벌써부터 들리는 것 같다.
하지만 이는 우리가 벤치마크를 일반화 하는 경향이 있기 때문이다. 사실 그러면 안 된다. 내 관점에서 좋은 벤치마크란 아주 자기완결적이며, 실제이자 훨씬 더 넓은 맥락에서 아주 작은 것 하나만을 테스트하는 것이다. 왜 그럴까? 좋은 벤치마크는 가능한 한 노이즈 를 줄여야 하기 때문이다. 나는 X 프레임워크가 Y보다 빠르다 같은 전쟁에는 전혀 관심이 없다. 그런 주장은 보통 무엇에 대해 빠른지라는 부분이 빠져 있고, 방대한 테스트 케이스 매트릭스가 필요하다는 데에도 크게 신경 쓰지 않는다.
나는 정말로, 딱 하나의 ASGI 웹 애플리케이션과 딱 하나의 WSGI 애플리케이션을 같은 일을 하도록 두고, 표준 Python 3.14와 그 free-threaded 변형 사이에 차이가 있는지 보고, 그 결과에 기반해 논의를 하고 싶을 뿐이다. 아래 숫자들을 볼 때 이 점을 꼭 염두에 두기 바란다.
앞서 말했듯이, 아이디어는 Python의 두 주요 애플리케이션 프로토콜인 ASGI와 WSGI를 Python 3.14에서 GIL을 켠 경우와 끈 경우로 테스트하되, 나머지 요소는 모두 고정하는 것이다. 서버, 코드, 동시성, 이벤트 루프까지.
그래서 FastAPI를 사용해 ASGI 애플리케이션, Flask를 사용해 WSGI 애플리케이션을 만들었다. 왜 이 프레임워크들이냐고? 그냥 가장 인기 있기 때문이다. 엔드포인트는 둘이다. 단순한 JSON 응답과 가짜 I/O 바운드 엔드포인트. FastAPI 버전 코드는 다음과 같다:
import asyncio
from fastapi import FastAPI
from fastapi.responses import PlainTextResponse, JSONResponse
app = FastAPI()
@app.get("/json")
async def json_data():
return JSONResponse({"message": "Hello, world!"})
@app.get("/io")
async def io_fake():
await asyncio.sleep(0.01)
return PlainTextResponse(b"Hello, waited 10ms")
Flask 버전 코드는 다음과 같다:
import json
import time
import flask
app = flask.Flask(__name__)
app.config["JSONIFY_PRETTYPRINT_REGULAR"] = False
@app.route("/json")
def json_data():
return flask.jsonify(message="Hello, world!")
@app.route("/io")
def io_fake():
time.sleep(0.01)
response = flask.make_response(b"Hello, waited 10ms")
response.content_type = "text/plain"
return response
보시다시피 가짜 I/O 엔드포인트는 10ms를 대기한다. 데이터베이스 쿼리 결과를 기다리는 상황 같은 것을 시뮬레이션하려는 의도다. 그래, 데이터베이스와 통신할 때 직렬화/역직렬화 부분은 무시하고 있다는 걸 안다. 그리고 JSON 엔드포인트도 실제 애플리케이션에서 흔한 형태는 아니다. 하지만 – 다시 말하지만 – 여기서 그게 핵심은 아니다.
이 애플리케이션들을 Granian으로 서비스하고 rewrk로 요청을 잔뜩 쏜다. 왜 Granian이냐고? 첫째, 내가 프로젝트를 유지보수한다. 하지만 – 더 중요한 이유로 – free-threaded Python에서 워커를 실행할 때 프로세스 대신 스레드를 사용하는 유일한(내가 아는) 서버이기 때문이다.
다음 사양의 단일 머신에서 모든 것을 실행했다:
uv로 설치한 CPython 3.14 및 3.14tFastAPI 애플리케이션을 워커 1개와 2개로 각각 실행했고, 첫 번째는 동시성 128, 두 번째는 256으로 했다. Granian과 rewrk 명령은 다음과 같다:
granian --interface asgi --loop asyncio --workers {N} impl_fastapi:apprewrk -d 30s -c {CONCURRENCY} --host http://127.0.0.1:8000/{ENDPOINT}| Python | 워커 | RPS | 평균 지연 | 최대 지연 | CPU | RAM |
|---|---|---|---|---|---|---|
| 3.14 | 1 | 30415 | 4.20ms | 45.29ms | 0.42 | 90MB |
| 3.14t | 1 | 24218 | 5.27ms | 59.25ms | 0.80 | 80MB |
| 3.14 | 2 | 59219 | 4.32ms | 70.71ms | 1.47 | 147MB |
| 3.14t | 2 | 48446 | 5.28ms | 68.17ms | 1.73 | 90MB |
숫자에서 보이듯 free-threaded 구현이 약 20% 느리지만, 메모리 사용량이 더 적다는 장점이 있다.
| Python | 워커 | RPS | 평균 지연 | 최대 지연 | CPU | RAM |
|---|---|---|---|---|---|---|
| 3.14 | 1 | 11333 | 11.28ms | 40.72ms | 0.41 | 90MB |
| 3.14t | 1 | 11351 | 11.26ms | 35.18ms | 0.38 | 81MB |
| 3.14 | 2 | 22775 | 11.22ms | 114.82ms | 0.69 | 148MB |
| 3.14t | 2 | 23473 | 10.89ms | 60.29ms | 1.10 | 91MB |
여기서는 두 구현이 처리량 면에서 매우 비슷하고, free-threaded 쪽이 약간 더 좋다. 역시 free-threaded 구현이 메모리를 덜 먹는다.
같은 구성으로 – 아주 낮은 – CPU 바운드 엔드포인트와 I/O 바운드 엔드포인트를 모두 포함하는 WSGI 애플리케이션을 실행하는 것은 훨씬 더 복잡하다. 왜일까? GIL 인터프리터에서는 CPU 바운드 엔드포인트의 경우 GIL 경합을 최대한 피해야 하므로 스레드를 가능한 적게 두고, 반면 I/O 바운드 워크로드에선 한 요청이 I/O 대기 중일 때 다른 작업을 하려면 적절한 양의 스레드가 필요하기 때문이다.
이 점을 분명히 하기 위해, Granian에서 워커는 하나로 두고 스레드 수만 다르게 했을 때 GIL Python 3.14에서 두 엔드포인트에 무슨 일이 일어나는지 보자:
| 엔드포인트 | 스레드 | RPS | 평균 지연 | 최대 지연 |
|---|---|---|---|---|
| JSON | 1 | 19377 | 6.60ms | 28.35ms |
| JSON | 8 | 18704 | 6.76ms | 25.82ms |
| JSON | 32 | 18639 | 6.68ms | 33.91ms |
| JSON | 128 | 15547 | 8.17ms | 3949.40ms |
| I/O | 1 | 94 | 1263.59ms | 1357.80ms |
| I/O | 8 | 781 | 161.99ms | 197.73ms |
| I/O | 32 | 3115 | 40.82ms | 120.61ms |
| I/O | 128 | 11271 | 11.28ms | 59.58ms |
보시다시피 스레드를 늘릴수록 I/O 엔드포인트는 기대한 결과에 가까워지지만, 동시에 JSON 엔드포인트는 점점 나빠진다. WSGI 애플리케이션을 배포할 때는 대개 GIL 경합과 적절한 병렬성 사이의 균형을 찾는 데 많은 시간을 들인다. 지난 20년 동안 사람들이 WSGI 애플리케이션에서 사용할 스레드 수를 두고 그렇게 많은 논의가 있었던 이유도 여기에 있다. 심지어 2*CPU+1 같은 완전히 경험적인 값을 쓰는 경우도 여럿 있었고, asyncio 이전에 gevent가 각광받았던 이유이기도 하다.
free-threaded 측면에서는 이런 걱정을 접어도 된다. 각 스레드가 GIL을 기다리지 않고 실제로 코드를 병렬로 실행할 수 있기 때문이다. 다만 다른 점을 고민하게 된다. 워커 수를 늘려야 할까, 스레드를 늘려야 할까? 결국 워커도 스레드 아닌가? JSON 엔드포인트로 조금 실험해 보자:
| 워커 | 스레드 | RPS | 평균 지연 | 최대 지연 |
|---|---|---|---|---|
| 1 | 2 | 28898 | 4.42ms | 86.96ms |
| 2 | 1 | 28424 | 4.49ms | 75.80ms |
| 1 | 4 | 54669 | 2.33ms | 112.06ms |
| 4 | 1 | 53532 | 2.38ms | 121.91ms |
| 2 | 2 | 55426 | 2.30ms | 124.16ms |
워커를 늘리면 약간의 오버헤드가 붙는 듯하다. 그럴 법하다. 그리고 스위트 스폿 은 워크로드에 따라 둘을 균형 있게 맞추는 것이다. I/O 엔드포인트를 지원하려면 여전히 높은 스레드 수가 필요하다는 점 – GIL 구현에서 Granian이 애플리케이션이 I/O 대기 중인지 알 수 없듯 free-threaded 구현에서도 알 수 없다는 점 – 을 고려하면, 이 관찰이 크게 중요하진 않지만 흥미롭긴 했다.
위 모든 것을 감안해 Flask 애플리케이션은 워커 1개, 2개 두 경우로 실행하되 워커당 스레드 수는 64로 고정했다. ASGI 벤치마크와 마찬가지로 첫 번째는 동시성 128, 두 번째는 256을 사용했다. 명령은 다음과 같다:
granian --interface wsgi --workers {N} --blocking-threads 64 impl_flask:apprewrk -d 30s -c {CONCURRENCY} --host http://127.0.0.1:8000/{ENDPOINT}| Python | 워커 | RPS | 평균 지연 | 최대 지연 | CPU | RAM |
|---|---|---|---|---|---|---|
| 3.14 | 1 | 18773 | 6.11ms | 27446.19ms | 0.53 | 101MB |
| 3.14t | 1 | 70626 | 1.81ms | 311.76ms | 6.50 | 356MB |
| 3.14 | 2 | 36173 | 5.73ms | 27692.21ms | 1.31 | 188MB |
| 3.14t | 2 | 60138 | 4.25ms | 294.55ms | 6.56 | 413MB |
CPU 바운드 워크로드에서는 free-threaded 버전이 처리량 측면에서 확실히 유리하다. 놀랄 일은 아니다. 훨씬 더 많은 CPU를 활용할 수 있으니까. 다만 free-threaded 버전의 메모리 사용량은 훨씬 높다. 이것이 더 높은 동시성 때문인지, 아니면 이 맥락에서 Python의 가비지 컬렉터가 덜 효율적으로 동작하기 때문인지는 이 벤치마크만으로는 명확하지 않다.
| Python | 워커 | RPS | 평균 지연 | 최대 지연 | CPU | RAM |
|---|---|---|---|---|---|---|
| 3.14 | 1 | 6282 | 20.34ms | 62.28ms | 0.40 | 105MB |
| 3.14t | 1 | 6244 | 20.47ms | 164.59ms | 0.42 | 216MB |
| 3.14 | 2 | 12566 | 20.33ms | 88.34ms | 0.65 | 180MB |
| 3.14t | 2 | 12444 | 20.55ms | 124.06ms | 1.18 | 286MB |
I/O 바운드 워크로드에서는 두 구현이 매우 비슷하다. 다시 한 번, free-threaded 구현의 메모리 사용량이 상대보다 훨씬 높다.
순수 파이썬 코드 실행은 free-threaded Python 3.14에서 최대 20% 정도 느려 보이지만, free-threaded 측 에서 여러 장점을 확인할 수 있었다.
ASGI 같은 비동기 프로토콜에서는 동시성 모델이 크게 바뀌진 않았다. 프로세스당 이벤트 루프 하나에서 스레드당 이벤트 루프 하나로 바뀐 정도다. 하지만 더 이상 CPU를 더 쓰려고 메모리 할당을 스케일링할 필요가 없다는 사실만으로도 엄청난 개선 이다. 메모리가 CPU에 비해 싸다고 해도, 최신 하드웨어에서는 이 차이가 대규모 배포나 단일 VM에서 동작하는 프로젝트 모두에서 비용에 큰 차이를 만든다. 이제는 한 대의 머신에 더 많은 것 을 올려두고 CPU가 한계에 다다르면 그때 스케일하면 된다. 얼마나 많은 RAM이 필요한지부터 걱정할 필요가 없다.
처리량 차이에 관해, 위 벤치마크는 모두 표준 라이브러리의 asyncio 이벤트 루프 구현을 사용했다는 점도 주목할 만하다. uvloop이나 rloop 같은 프로젝트가 앞으로 처리량과 지연 개선에 기여할 수 있을 것이다. 하지만 또 한편으로, 위 벤치마크에서 I/O 바운드 워크로드의 지연은 free-threaded 구현이 실제로 더 좋다. 그리고 DHH의 말을 인용하자면, 우리는 모두 CRUD 원숭이 다. 즉, 대부분의 시간 동안 애플리케이션은 데이터베이스를 기다리고 있을 뿐이다. 그런 점에서 free-threaded Python 구현은 이미 오늘 당장 ASGI 애플리케이션에 쓰기에 더 나을 수도 있다.
WSGI 같은 동기 프로토콜에서는 메모리 사용 때문에 복잡한 감정이 들 수 있다. 하지만 이 시점에서는 Granian 쪽에서 파이썬 측 가비지 컬렉션을 더 잘 일으키도록 몇 가지 변경이 필요할 가능성이 충분하다. 만약 그렇다면 WSGI는 다시 재미있어졌다. 스레드 균형을 고민하는 일도, gevent를 쓰려고 앱을 몽키패치하는 일도, asyncio로 갈아타자 는 리라이트 계획도 접을 수 있다. 이제는 블로킹 연산에 대해 더 이상 걱정하지 않아도 된다는 사실에 의존하면 된다.
나처럼 방대한 인프라에서 수천 개의 ASGI/WSGI 파이썬 컨테이너를 운영하는 사람들에게 – 혹시 모르니 말하지만 나는 Sentry에서 일한다 – free-threaded Python은 엄청난 삶의 질 개선 을 가져올 잠재력이 있다. 그리고 파이썬으로 웹 애플리케이션을 만드는 모든 이들에게도 마찬가지다. 이러한 애플리케이션의 동시성 패러다임과 배포 과정을 단순화하는 것은 좋은 일 이다.
아마 gilectomy 전체가 웹 애플리케이션을 염두에 두고 계획된 것은 아닐 것이다. 그 지점에 도달하려면 시간이 더 걸릴 수도 있다. 하지만 내게는 파이썬 웹 서비스의 미래가 GIL 없이 보인다.