Python 백엔드의 동시성 논의는 오랫동안 “I/O는 asyncio 또는 thread, CPU는 process”라는 간단한 도식으로 정리됐습니다. 이 도식은 지금도 유용하지만, Python 3.14에서는 선택지가 하나 더 늘었습니다. free-threaded CPython은 GIL을 끈 build를 공식 지원하고, 표준 라이브러리는 InterpreterPoolExecutor와 multiple interpreter API를 제공하기 시작했습니다. 이제 순수 Python CPU 작업도 같은 프로세스 안에서 여러 코어를 활용하는 경로를 시험할 수 있습니다.
그러나 이 변화를 “GIL이 사라졌으니 thread를 많이 늘리면 된다”로 받아들이면 위험합니다. free-threading은 병렬 실행의 문을 열 뿐, 공유 딕셔너리의 업무 정합성·외부 라이브러리 호환성·DB 커넥션 포화·context 전파를 대신 설계해 주지 않습니다. Java Virtual Thread와 Spring 실행 모델, API rate limit·backpressure, ThreadLocal context 전파와 정리에서 다룬 원칙은 Python에서도 그대로 유효합니다. 실행 단위가 가벼워졌다고 downstream 자원이 무한해지는 것은 아닙니다.
참고한 공식 문서:
- Python: free-threading 지원 가이드 — GIL 비활성 build 식별, 런타임 재활성화, extension 호환성
- Python 3.14
concurrent.futures—InterpreterPoolExecutor의 격리·직렬화 모델 - Python: asyncio와 free-threaded Python — thread별 event loop와 cross-thread 규칙
- PEP 703 — optional GIL 설계의 배경과 제한
이 글에서 얻는 것
- free-threaded build, thread pool, process pool, subinterpreter를 서로 다른 문제에 연결할 수 있습니다.
- “병렬 실행 가능”과 “공유 상태가 올바름”을 분리해 설계할 수 있습니다.
- C extension 호환성, 직렬화 비용, connection pool, tail latency를 포함한 파일럿 기준을 세울 수 있습니다.
- 기존 asyncio 서비스의 기본 경로를 무리하게 바꾸지 않고 CPU-bound 구간부터 검증하는 순서를 얻습니다.
핵심 개념/이슈
1) free-threading은 선택 build이며, 실제 GIL 상태를 확인해야 한다
free-threaded CPython은 --disable-gil로 빌드하거나 제공되는 free-threaded binary를 사용해 실행할 수 있습니다. 하지만 서버가 그 build로 시작했다는 사실과, 요청 처리 중에도 GIL이 꺼져 있다는 사실은 다릅니다. 공식 가이드에 따르면 free-threading을 명시적으로 지원하지 않는 C-API extension을 import하면 호환성을 위해 GIL이 자동 활성화될 수 있습니다. 따라서 Docker image 태그나 Python 버전만 보고 다중 코어 효과를 가정하면 안 됩니다.
파일럿의 첫 검증은 benchmark가 아니라 runtime 증거입니다. 배포 이미지는 sysconfig.get_config_var("Py_GIL_DISABLED")로 build 지원 여부를 기록하고, 실행 프로세스는 sys._is_gil_enabled() 결과를 startup metric 또는 진단 endpoint에 남깁니다. 이후 실제 요청 경로가 import하는 wheel 목록, native extension, profiler·APM agent까지 호환성 목록에 넣어야 합니다. 개발자 노트북에서만 GIL이 꺼지고 production image에서는 특정 extension 때문에 다시 켜지는 상황을 초기에 잡을 수 있습니다.
중요한 점은 GIL이 원래도 업무 원자성을 보장하지 않았다는 것입니다. balance = balance - amount처럼 읽기·검증·쓰기의 여러 단계는 GIL이 있던 환경에서도 도메인 락, DB transaction, unique constraint가 필요했습니다. GIL을 없앤 뒤에는 그 경쟁 조건이 더 자주 드러날 수 있지만, 해결책은 전역 lock을 무작정 추가하는 것이 아니라 상태를 소유한 저장소의 조건부 update와 낙관·비관·원자적 업데이트를 명시하는 것입니다.
2) subinterpreter는 공유 메모리 thread pool의 가벼운 대체재가 아니다
InterpreterPoolExecutor는 각 worker가 별도 interpreter와 별도 GIL을 가져 진짜 멀티코어 병렬 실행을 할 수 있습니다. 다만 worker는 module import, sys.stdout, 전역 변수, mutable object를 다른 worker와 공유하지 않습니다. 작업 함수·인자·반환값도 worker 경계를 넘을 때 직렬화됩니다. 즉 같은 프로세스라는 점만 제외하면, 설계 감각은 process pool에 더 가깝습니다.
이 격리는 단점만은 아닙니다. 전역 캐시·singleton·환경 설정을 암묵적으로 공유해 생기는 race를 줄이고, 작업 입력과 결과를 명확한 값으로 만들게 합니다. 반대로 대형 NumPy 배열, ORM session, 열린 socket, 요청 객체를 매 작업마다 넘기면 pickle 비용과 초기화 비용이 병렬성 이득을 삼킬 수 있습니다. worker가 참조할 모델·사전·규칙을 initializer에서 각자 로드할지, process pool이나 외부 worker로 분리할지 측정으로 결정해야 합니다.
| 작업 특성 | 우선 검토 | 이유 | 피해야 할 선택 |
|---|---|---|---|
| 많은 socket·DB I/O, CPU는 작음 | asyncio | 적은 worker로 대기 시간을 숨김 | 요청마다 thread를 새로 생성 |
| 순수 Python CPU 계산, 공유 상태가 작음 | free-threaded thread 또는 interpreter 파일럿 | 멀티코어 활용 가능성 | 측정 없이 worker 수를 코어 수의 수십 배로 확대 |
| native extension 의존 CPU 작업 | 기존 라이브러리 release·GIL 동작 확인 후 process pool 비교 | extension이 GIL을 재활성화할 수 있음 | free-threaded build를 성능 보장으로 취급 |
| 큰 mutable state·격리된 작업 | process/subinterpreter 또는 외부 job worker | 상태 경계를 강제하기 쉬움 | ORM session·socket을 pickle해 전달 |
| 짧고 지연 민감한 HTTP 요청 | 동기 또는 asyncio 기본 경로 유지 | 직렬화·thread 전환 비용이 더 클 수 있음 | 모든 handler를 병렬 executor로 우회 |
3) asyncio의 역할은 줄지 않는다
free-threading이 있어도 한 event loop는 한 코어에서 task를 스케줄합니다. network I/O가 대부분인 API gateway, webhook receiver, proxy는 여전히 non-blocking I/O와 backpressure가 먼저입니다. 공식 asyncio 문서는 free-threaded 환경에서 thread별 event loop를 둘 수 있다고 설명하지만, task와 future를 다른 thread에서 직접 조작해서는 안 된다고도 명시합니다. thread 밖에서 넘길 때는 loop.call_soon_threadsafe()나 asyncio.run_coroutine_threadsafe() 같은 API를 써야 하며, cross-thread producer/consumer에는 asyncio.Queue가 아닌 thread-safe queue.Queue가 맞습니다.
따라서 실무 구조는 “HTTP I/O는 event loop에 남기고, CPU가 충분히 큰 구간만 제한된 executor에 보낸다”가 안전합니다. 예를 들어 파일 업로드의 checksum, 큰 JSON 검증, 규칙 엔진 계산은 offload 후보가 될 수 있습니다. 반면 DB query가 느린 문제를 CPU executor로 감추면 연결이 더 오래 잡혀 커넥션 풀 포화와 timeout이 악화됩니다. executor는 병목을 옮길 뿐, 느린 SQL이나 외부 의존성을 고치지 않습니다.
4) 평가는 평균 처리량보다 실패 경계에서 해야 한다
free-threaded build가 이득인지 보려면 “초당 요청 수가 조금 올랐다”보다, 피크에서 p99가 어떻게 변하고 오류가 어떻게 전파되는지 봐야 합니다. CPU 병렬성이 늘어도 메모리 압박, GC pause, 외부 API quota, DB pool 대기가 함께 증가하면 고객 체감은 나빠질 수 있습니다. Tail latency 엔지니어링처럼 평균과 p95만 보지 말고, queue time·executor active worker·context switch·DB wait를 같은 trace에서 연결해야 합니다.
다음과 같은 작은 파일럿 gate가 현실적입니다.
| 지표 | 확대 조건 | 중단·롤백 조건 |
|---|---|---|
| CPU-bound endpoint p95 | 기존 대비 10% 이상 개선 | 개선 없음 + CPU 사용률만 15%p 이상 증가 |
| end-to-end p99 | 기존 대비 +5% 이내 | +15% 이상이 10분 지속 |
| 오류율 | 기준선 대비 +0.05%p 미만 | serialization·race 관련 새 오류가 1건이라도 재현 |
| GIL 상태 | 대상 worker에서 disabled 확인 | 지원하지 않는 extension import로 의도 밖 재활성화 |
| downstream saturation | DB pool·외부 API quota 기준선 이내 | pool wait 또는 429가 기준선의 2배 |
숫자는 서비스 SLO에 맞춰 조정합니다. 다만 first rollout에서 처리량만 보고 확대하지 않는 원칙은 고정하는 편이 좋습니다. 성능이 15% 좋아도 재현되지 않는 간헐 오류 하나가 결제·권한 경로에 들어가면 이득은 사라집니다.
실무 적용
1) ‘어디가 CPU-bound인가’부터 증명한다
프로파일 없이 executor를 추가하는 것은 DB 병목에 서버를 증설하는 것과 비슷합니다. request trace에 handler CPU time, event-loop lag, executor queue wait, serialization time, DB wait, external call 시간을 남기고 상위 endpoint 3개만 고릅니다. CPU 비중이 낮은 endpoint는 목록에서 제외합니다. CPU가 높아도 JPEG·암호화·ML 라이브러리처럼 이미 GIL을 풀거나 native thread를 쓰는 경우가 있으므로, Python bytecode가 실제 병목인지도 구분해야 합니다.
그다음 workload fixture를 고정합니다. 10KB JSON과 10MB JSON, 균등한 tenant와 hot tenant, 정상 요청과 cancellation을 모두 넣고 부하 테스트 전략으로 baseline을 만듭니다. traffic, Python build, dependency lockfile, worker 수, CPU limit을 같은 리포트에 남겨야 다음 주 benchmark와 비교할 수 있습니다.
2) 하나의 격리된 작업에서만 두 경로를 비교한다
처음부터 웹 서버 전체를 free-threaded build로 갈아타지 않습니다. 이미지 변환, 문서 parsing, 해시 계산처럼 입력·출력 계약이 좁고 side effect가 없는 job 하나를 고릅니다. 기존 process pool 또는 단일 실행 기준선과 free-threaded thread·interpreter pool을 같은 입력으로 비교합니다. worker 수는 1, 코어 수의 절반, 코어 수 정도의 세 점에서만 시작합니다. 그보다 크게 늘리기 전에 queue wait와 memory 사용량이 안정적인지 확인합니다.
각 경로에서 다음을 negative test로 만듭니다.
- worker initializer 실패 시 요청이 무한 대기하지 않고 제한된 오류로 끝나는가
- timeout·취소 뒤 job이 계속 CPU를 쓰거나 결과를 늦게 쓰지 않는가
- 같은 입력을 동시에 두 번 넣어도 DB·object storage의 side effect가 중복되지 않는가
- trace ID, tenant context, rate-limit 분류가 executor 경계를 지나도 섞이지 않는가
- unsupported extension import 때 GIL 상태 변화가 운영자에게 보이는가
이 테스트는 free-threading 기능 확인보다 더 중요합니다. 병렬성 도입의 실패는 보통 “코어를 못 썼다”가 아니라 “취소된 작업이 결과를 덮었다”, “한 tenant가 모든 worker를 점유했다”처럼 경계 관리에서 나옵니다.
3) context와 자원 예산을 명시적으로 전달한다
thread·interpreter 경계에서는 request-local 값이 저절로 안전하게 전달된다고 믿지 않는 편이 낫습니다. trace ID, tenant ID, deadline, permission scope 같은 최소 metadata를 불변 입력으로 만들고, worker 내부에서 필요한 logger·meter를 초기화합니다. ContextVar가 있어도 interpreter 사이에서는 독립 상태라는 점을 가정해야 합니다. 민감한 bearer token이나 전체 request object를 작업 인자로 넘기는 방식은 피하고, worker가 필요한 권한은 더 짧고 좁은 credential으로 따로 발급합니다.
또한 concurrency limit은 executor의 max_workers 하나로 끝나지 않습니다. tenant별 동시 작업 수, endpoint별 CPU budget, queue 길이, deadline, DB connection pool을 함께 둬야 합니다. 큐가 길어질 때는 더 많은 worker를 넣기보다 먼저 low-priority 작업을 지연·거절하는 것이 복구에 유리합니다. 이 판단은 admission control과 concurrency limit의 원칙과 같습니다.
트레이드오프/주의점
free-threading은 코드가 thread-safe해지는 기능이 아닙니다. 데이터 구조가 내부적으로 손상되지 않더라도, 두 요청이 같은 한도를 동시에 통과하거나 같은 외부 결제를 두 번 시작하는 업무 경쟁은 별개입니다. DB 조건부 update, idempotency key, unique constraint, domain lock처럼 효과를 보호하는 장치는 그대로 필요합니다.
또한 extension 생태계의 준비도는 서비스마다 다릅니다. Python 패키지 이름이 같아도 wheel·운영체제·CPU 아키텍처·APM agent 조합에 따라 GIL 동작과 성능이 달라질 수 있습니다. 특히 C extension을 많이 쓰는 data processing 서비스는 free-threading 파일럿이 기존 process pool보다 느리거나, 메모리를 더 많이 쓰는 결과도 자연스럽습니다. 호환성을 억지로 맞추기 위해 핵심 라이브러리를 임시 fork하는 것은 보통 파일럿 범위를 넘어서는 비용입니다.
마지막으로 concurrency를 늘리면 CPU만 쓰는 것이 아닙니다. 더 많은 동시 parsing은 heap과 GC를, 더 많은 job completion은 DB write를, 더 많은 retry는 외부 API quota를 누릅니다. 병렬화의 승격 기준은 “코어를 80% 썼는가”가 아니라, 제한된 자원 안에서 오류·tail latency·공정성이 유지되는가여야 합니다.
체크리스트 또는 연습
현재 Python 서비스 또는 worker 하나를 골라 아래를 점검해 보세요.
- CPU-bound 후보 endpoint 또는 job을 profile 근거로 1개만 골랐는가?
- production image에서
Py_GIL_DISABLED와 실제 GIL 상태를 각각 기록했는가? - native extension, profiler, APM agent, wheel 버전의 호환성 목록이 있는가?
- asyncio I/O 경로와 CPU offload 경로를 분리하고 deadline·취소를 양쪽에 전파하는가?
-
InterpreterPoolExecutor에 넘기는 함수·인자·반환값이 직렬화 가능하며 mutable 공유를 가정하지 않는가? - p95뿐 아니라 p99, queue wait, executor saturation, DB pool wait, 429를 같은 실험에서 비교하는가?
- race, duplicate side effect, context 혼선, initializer 실패를 negative test로 만들었는가?
- 처리량 상승보다 SLO·오류율·rollback 조건을 먼저 정했는가?
Python 동시성의 다음 단계는 “async를 버리고 thread로 갈아타기”가 아닙니다. 각 작업이 I/O·CPU·공유 상태·외부 효과 중 어디에 비용을 내는지 드러내고, 가장 작은 격리 경계에서 병렬성을 증명하는 것입니다. free-threading과 subinterpreter는 그 증명을 위한 좋은 도구이지만, 운영 설계의 지름길은 아닙니다.
💬 댓글