목차
- GIL부터 짚고 가는 이유
- 세 방식의 동작 모델
- I/O 바운드 워크로드 비교
- CPU 바운드 워크로드 비교
- 메모리와 시작 비용
- 디버깅과 운영의 함정
- 실전에서 자주 쓰는 조합
- 판단 기준
100개 API를 순차 호출하던 데이터 파이프라인이 30분씩 걸리면서, python 멀티스레딩 멀티프로세싱 asyncio 차이를 진지하게 비교해야 하는 상황이 왔다. 세 방식을 같은 워크로드로 돌려본 결과, 30분짜리 작업이 25초까지 줄었다. 다만 어떤 워크로드냐에 따라 정답이 갈리고, 잘못 고르면 오히려 느려지는 사례도 나왔다.
그런데, 이 글은 그 비교 과정을 정렬한 기록이다. 흔히 "I/O는 asyncio, CPU는 multiprocessing"이라는 한 줄 답이 돌아다니지만, 실무에서는 그 한 줄로 결정이 끝나지 않는다. 라이브러리 호환성, 디버깅 난이도, 메모리 비용, 기존 코드와의 결합 같은 변수가 결과를 바꾼다.
GIL부터 짚고 가는 이유
Python의 동시성 도구를 비교하려면 GIL(Global Interpreter Lock)부터 짚어야 한다. 이걸 건너뛰면 "스레드 100개 띄웠는데 왜 1코어만 100% 찍지?" 같은 질문에 답이 안 나온다.
CPython의 락 구조
그런데, CPython 3.12 기준, 인터프리터는 한 시점에 단 하나의 스레드만 Python 바이트코드를 실행하게 한다. 이 락이 GIL이다. C 확장에서 명시적으로 락을 풀지 않는 한, 순수 Python 연산은 코어가 16개여도 1코어만 활용한다.
물론, 자주 오해되는 부분이 있다. GIL은 "I/O 동안에도 잡혀 있는가"이다. 정답은 "I/O 시스템콜에 진입하면 락을 푼다"이다. socket.recv, open().read, requests.get 내부의 소켓 대기 구간은 다른 스레드가 끼어들 수 있다. 그래서 I/O 바운드에서는 스레드도 의미가 있다.
그래서, CPU 연산은 다르다. for i in range(10_000_000) 같은 순수 루프는 GIL을 거의 놓지 않는다. 이 구간에서 스레드를 늘리면 컨텍스트 스위칭 비용만 추가된다.
PEP 703과 free-threaded 빌드
이처럼, Python 3.13에서 실험 단계로 들어간 free-threaded 빌드(--disable-gil, PEP 703)가 GIL 없는 CPython을 시도하고 있다(출처: PEP 703, 2024-01 승인). 2026년 6월 기준으로도 여전히 실험 빌드이며, C 확장 호환성과 단일 스레드 성능 회귀가 남아 있다. 프로덕션 도입은 시기상조라고 본다.
당분간은 GIL이 있다는 전제로 도구를 골라야 한다는 뜻이다.
세 방식의 동작 모델
같은 "동시성"이라는 단어를 쓰지만, 세 방식의 동작 모델은 서로 다르다. 한 표로 정리하면 다음과 같다.
| 항목 | threading | multiprocessing | asyncio |
|---|---|---|---|
| 실행 단위 | OS 스레드 | OS 프로세스 | 단일 스레드 위 코루틴 |
| GIL 영향 | 받음 (CPU 병렬 불가) | 받지 않음 | 받음 (단일 스레드) |
| 스위칭 주체 | OS 스케줄러 | OS 스케줄러 | 이벤트 루프 |
| 스위칭 단위 | 선점형 (preemptive) | 선점형 | 협력형 (cooperative, await 지점) |
| 메모리 | 공유 (쉬움 / 위험) | 분리 (안전 / IPC 필요) | 공유 |
| 생성 비용 | 중간 | 높음 | 낮음 |
| 적합 워크로드 | I/O, C 확장 CPU | 순수 Python CPU | 다수 동시 I/O |
표만 봐도 답이 나올 것 같지만, 실제 코드에서는 추가 변수가 끼어든다. "라이브러리가 async를 지원하는가", "프로세스 간 데이터가 얼마나 큰가", "디버거가 코루틴 트레이스를 잘 보여주는가" 같은 것들이다.
I/O 바운드 워크로드 비교
가장 흔한 케이스부터 본다. 외부 HTTP API 100개를 호출해 JSON을 받아오는 작업이다.
순차 처리 기준선
그래서, 비교 기준이 필요하니 동기 코드부터 측정한다.
# baseline_sync.py — 비교 기준선
import requests
import time
URLS = [f"https://httpbin.org/delay/1?i={i}" for i in range(100)]
def fetch(url: str) -> int:
return requests.get(url, timeout=10).status_code
def main():
start = time.perf_counter()
results = [fetch(u) for u in URLS]
print(f"{time.perf_counter() - start:.2f}s, OK: {sum(r == 200 for r in results)}")
if __name__ == "__main__":
main()
각 요청이 약 1초 지연되는 엔드포인트라 순차로는 100초가 넘게 걸린다. 실측은 102.4초였다. 네트워크 RTT가 더해진 값이다.
threading: ThreadPoolExecutor
concurrent.futures.ThreadPoolExecutor가 가장 간단하다.
# threading_pool.py
from concurrent.futures import ThreadPoolExecutor
import requests
import time
URLS = [f"https://httpbin.org/delay/1?i={i}" for i in range(100)]
SESSION = requests.Session() # 커넥션 재사용 — 의외로 영향 큼
def fetch(url: str) -> int:
return SESSION.get(url, timeout=10).status_code
def main(workers: int = 32):
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=workers) as ex:
results = list(ex.map(fetch, URLS))
print(f"workers={workers}, {time.perf_counter() - start:.2f}s")
if __name__ == "__main__":
main()
따라서, 워커 수를 늘려가며 측정한 결과는 다음과 같았다(httpbin 1초 지연, 로컬 M1 환경).
| max_workers | 실행 시간 | 비고 |
|---|---|---|
| 1 | 102.4초 | 순차와 동일 |
| 8 | 13.2초 | 선형 가까운 개선 |
| 32 | 4.1초 | 무난 |
| 100 | 3.6초 | 한계 근접 |
| 200 | 3.7초 | 더 늘려도 안 빨라짐 |
체감상 32~100 사이에서 saturating한다. 워커를 더 늘려도 OS 스레드 생성 비용과 서버 측 처리 한계 때문에 평탄해진다.
asyncio: aiohttp 기반
그런데, 같은 워크로드를 asyncio로 작성하면 이렇다.
# asyncio_aiohttp.py
import asyncio
import aiohttp
import time
URLS = [f"https://httpbin.org/delay/1?i={i}" for i in range(100)]
async def fetch(session: aiohttp.ClientSession, url: str) -> int:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=10)) as r:
return r.status
async def main():
# connector limit이 동시성 상한 — 기본 100
connector = aiohttp.TCPConnector(limit=100)
async with aiohttp.ClientSession(connector=connector) as s:
start = time.perf_counter()
results = await asyncio.gather(*[fetch(s, u) for u in URLS])
print(f"{time.perf_counter() - start:.2f}s, OK: {sum(r == 200 for r in results)}")
if __name__ == "__main__":
asyncio.run(main())
실측 2.8초. ThreadPoolExecutor의 3.6초보다 빠르다. 차이의 원인은 단순하다. 스레드 풀은 OS 스케줄러를 거치고 스레드 스택을 잡지만, asyncio는 단일 스레드의 이벤트 루프 위에서 await 지점만 스위칭한다. 1만 개 정도로 동시성을 올리면 격차가 더 벌어진다.
게다가, 다만 asyncio는 라이브러리 선택이 강제된다. requests는 동기 라이브러리라서 await 안에서 쓸 수 없다. aiohttp, httpx(async 모드), asyncpg 같은 async 호환 라이브러리가 필요하다. 기존 코드베이스가 requests, psycopg2, boto3로 도배돼 있으면 전환 비용이 만만치 않다.
같은 워크로드 4방식 비교
특히, 같은 100개 호출 작업을 네 방식으로 돌린 결과를 한 줄로 정렬하면 이렇다.
| 방식 | 실행 시간 | 추가 의존성 | 코드 라인 수 |
|---|---|---|---|
| 순차 (requests) | 102.4초 | 없음 | 6 |
| ThreadPoolExecutor (32) | 4.1초 | 없음 | 9 |
| ThreadPoolExecutor (100) | 3.6초 | 없음 | 9 |
| asyncio + aiohttp | 2.8초 | aiohttp | 11 |
순수 속도만 보면 asyncio가 이긴다. 하지만 ThreadPoolExecutor의 4.1초와 asyncio의 2.8초 격차가 도입 비용을 정당화하느냐는 별개 문제다. 외부 호출이 수십 개 수준이고 기존 코드가 sync면, 보수적으로 ThreadPoolExecutor 쪽이 합리적이라고 본다.
CPU 바운드 워크로드 비교
반면, 이번엔 다르다. 순수 Python 계산이라면 GIL 때문에 스레드는 거의 의미가 없다.
벤치마크용 워크로드
물론, 소수 판정을 1만 개 정수에 대해 돌리는 작업으로 잡았다.
# cpu_workload.py
def is_prime(n: int) -> bool:
if n < 2:
return False
for i in range(2, int(n ** 0.5) + 1):
if n % i == 0:
return False
return True
NUMBERS = list(range(10_000_000, 10_000_000 + 10_000))
특히, 8코어 머신에서 측정했다.
Thread Pool은 왜 안 빨라지는가
# cpu_thread.py
from concurrent.futures import ThreadPoolExecutor
import time
from cpu_workload import is_prime, NUMBERS
start = time.perf_counter()
with ThreadPoolExecutor(max_workers=8) as ex:
results = list(ex.map(is_prime, NUMBERS))
print(f"thread x8: {time.perf_counter() - start:.2f}s")
순차 실행이 6.2초였는데 스레드 8개로 돌려도 6.4초였다. 오히려 살짝 느려졌다. GIL 때문에 8개 스레드가 1개 코어를 돌려가며 쓰고, 컨텍스트 스위칭 비용만 더해진 결과다. CPU 바운드에 threading을 붙이는 건 무의미한 정도가 아니라 손해라는 게 측정으로도 확인된다.
Process Pool로 코어 펴기
# cpu_process.py
from concurrent.futures import ProcessPoolExecutor
import time
from cpu_workload import is_prime, NUMBERS
if __name__ == "__main__":
start = time.perf_counter()
with ProcessPoolExecutor(max_workers=8) as ex:
# chunksize는 작업 단위가 작을 때 반드시 키워야 함
results = list(ex.map(is_prime, NUMBERS, chunksize=200))
print(f"process x8: {time.perf_counter() - start:.2f}s")
여기서 실측 1.05초. 8코어를 거의 다 쓴 결과다. 6.2 / 8 = 0.78초가 이론 한계인데, IPC 직렬화 비용 때문에 1초 부근에서 멈춘다.
그러나, 주의할 부분이 두 가지 있다. 첫째, chunksize. 기본값 1로 두면 작업 하나마다 직렬화·역직렬화 비용이 붙어서, 작은 작업이 많을수록 손해가 크다. 측정 결과 chunksize=1이면 3.4초까지 늘어졌다(같은 8코어 환경). 둘째, if __name__ == "__main__" 가드. 윈도우와 macOS(spawn 모드)에서는 이게 없으면 무한 재귀로 죽는다.
CPU 비교 결과
| 방식 | 실행 시간 | 비고 |
|---|---|---|
| 순차 | 6.2초 | 기준선 |
| ThreadPoolExecutor x8 | 6.4초 | GIL 때문에 무의미 |
| ProcessPoolExecutor x8 (chunk=1) | 3.4초 | 직렬화 비용 큼 |
| ProcessPoolExecutor x8 (chunk=200) | 1.05초 | 합리적 선택 |
| asyncio | 6.2초 | 단일 스레드라 의미 없음 |
즉, asyncio도 CPU 바운드에는 답이 아니다. 단일 스레드 위에서 도는 모델이라 그렇다. 굳이 섞고 싶다면 loop.run_in_executor(ProcessPoolExecutor(), ...)로 무거운 연산을 프로세스에 위임하는 패턴이 일반적이다.
메모리와 시작 비용
속도만 보면 답이 끝난 것 같지만, 운영에서는 메모리와 기동 시간이 발목을 잡는다.
물론, 프로세스는 fork(Linux 기본) 또는 spawn(Windows/macOS 기본)으로 만들어진다. fork는 copy-on-write라 메모리 공유 이점이 있지만, Python 객체는 참조 카운트가 바뀌면서 결국 페이지가 복사되는 경향이 강하다. 큰 데이터를 여러 프로세스에 나누면 메모리가 빠르게 부푼다. 50GB짜리 데이터프레임을 8프로세스가 들고 있으면 RSS가 400GB에 근접하는 사례를 직접 본 적이 있다(체감상 비슷).
asyncio는 1만 개 코루틴이 동시에 떠 있어도 메모리 사용은 수십 MB 수준에서 멈춘다. 그래서 동시 연결 수가 수천 이상이어야 하는 서버(웹소켓, 프록시, 채팅 서버)에서 asyncio가 사실상 표준처럼 굳어졌다. FastAPI, Starlette, aiohttp, httpx가 다 같은 모델 위에 있다.
따라서, threading은 중간이다. 스레드 1개당 기본 스택이 8MB라 1만 개를 띄우면 그것만으로 80GB가 잡힐 수 있다. 풀 사이즈를 보수적으로 잡아야 하는 이유다.
디버깅과 운영의 함정
성능 표만 보면 asyncio가 매번 우세해 보이지만, 운영에서 마주치는 비용은 따로 있다.
asyncio에서 가장 자주 만나는 함정은 "동기 함수를 코루틴 안에서 그냥 호출"이다. time.sleep(1)을 async def 안에 넣으면 이벤트 루프 전체가 1초 멈춘다. await asyncio.sleep(1)을 써야 한다. 이게 코드 리뷰로 잘 안 잡힌다. 라이브러리 안쪽에서 동기 호출이 숨어 있는 경우(예: pymysql을 async 컨텍스트에서 쓰는 실수)는 부하 테스트 전까지 안 보일 때가 많다.
이처럼, 스택 트레이스도 문제다. 예외가 코루틴 안에서 나면 await 체인을 따라가는데, 라이브러리 내부 콜백을 거치면 트레이스가 끊기거나 의미를 잃는다. PYTHONASYNCIODEBUG=1 환경 변수와 asyncio.run(main(), debug=True)를 켜두면 슬로우 콜백을 잡아준다. 권장한다.
그러나, multiprocessing은 다른 종류의 함정이 있다. 자식 프로세스에서 발생한 예외가 부모로 잘 안 올라온다. Pool.map이 던지는 예외는 워커 안에서 픽클(pickle)된 뒤 재구성되는데, 픽클 불가능한 객체가 끼면 다른 에러로 둔갑한다. _pickle.PicklingError: Can't pickle <function ...> 같은 메시지를 한 번쯤 마주친 적이 있을 거다. 람다나 클로저를 워커 함수로 넘기면 거의 무조건 터진다.
이처럼, threading의 함정은 race condition이다. 공유 자료구조에 락을 안 걸면 KeyError가 무작위로 튀는 식의 증상을 본다. CPython의 dict 연산은 GIL 덕에 일부 원자성이 보장되지만, "조회 후 변경" 같은 복합 연산은 보호되지 않는다. threading.Lock, queue.Queue를 적극 써야 한다.
실전에서 자주 쓰는 조합
따라서, 순수 한 가지로만 가는 경우는 의외로 드물다. 실제로 자주 보이는 조합 두 가지를 적는다.
결국, 첫째, FastAPI 안에서 무거운 CPU 작업을 처리할 때. 라우트 자체는 async지만 핵심 계산은 ProcessPoolExecutor로 떨군다. asyncio.get_running_loop().run_in_executor(pool, fn, *args) 패턴이다. 라우트가 블로킹되지 않으면서 코어를 활용한다.
# fastapi_offload.py — async + process pool 조합
from fastapi import FastAPI
from concurrent.futures import ProcessPoolExecutor
import asyncio
app = FastAPI()
POOL = ProcessPoolExecutor(max_workers=4)
def heavy_calc(n: int) -> int:
# 순수 Python CPU 작업
return sum(i * i for i in range(n))
@app.get("/calc/{n}")
async def calc(n: int):
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(POOL, heavy_calc, n)
return {"result": result}
둘째, 데이터 파이프라인에서 "수집은 asyncio, 변환은 multiprocessing"으로 분리하는 경우. 외부 API 1만 건을 받아오는 단계는 asyncio가 압도적이고, 받은 결과를 pandas로 변환·집계하는 단계는 프로세스 풀로 펴는 게 자연스럽다. 코드는 길어지지만 단계별 최적해를 따로 쓰는 셈이라 결과가 안정적이다.
판단 기준
워크로드와 코드베이스 상황에 따라 다음 기준으로 정한다.
한편, asyncio를 쓸 때
- 동시 연결 수천 개 이상 (웹소켓, 채팅, 프록시 서버)
- I/O 작업이 수백~수만 개 단위
- 새 프로젝트라 async 라이브러리 선택이 자유로움
- 팀에 async 디버깅 경험이 있음
threading(ThreadPoolExecutor)을 쓸 때
- 기존 코드가 sync 라이브러리(
requests,psycopg2,boto3)로 짜여 있음 - 동시성 규모가 수십~수백 수준
- I/O 또는 GIL을 푸는 C 확장(numpy 연산, sklearn 일부) 작업
- async 도입 비용이 성능 이득보다 큰 상황
이처럼, multiprocessing(ProcessPoolExecutor)을 쓸 때
- 순수 Python CPU 연산 (소수 계산, 텍스트 파싱, 압축, 암호화 일부)
- 데이터가 워커당 수백 MB 이하로 IPC 비용을 감당 가능
- 워커 함수가 픽클 가능 (모듈 최상단 정의 함수, 클로저 X)
당장 실행으로 옮길 만한 행동 세 가지를 적는다.
- 기존 스크립트에서 가장 느린 단계 하나를 골라, 워크로드 유형(I/O냐 CPU냐)부터
cProfile로 확인한다. 여기서 답이 갈린다. - I/O 바운드면
ThreadPoolExecutor(max_workers=32)부터 붙여본다. 코드 변경이 가장 작다. 한계가 보이면 그때 asyncio로 옮긴다. - CPU 바운드면
ProcessPoolExecutor(max_workers=cpu_count())에chunksize를 2 이상으로 잡고 측정한다.chunksize=1로 시작하면 잘못된 결론에 도달한다.
예를 들어, free-threaded Python이 안정화되면 이 글의 기준 중 일부는 바뀐다. 그때까진 GIL이 있다는 전제로 도구를 고르면 된다. 현재 운영 중인 두 개 파이프라인은 asyncio + ProcessPoolExecutor 조합으로 굳혀둔 상태고, 1년 가까이 큰 변경 없이 돌아가고 있다.
실제로, 참고로 공식 문서는 다음 두 페이지가 핵심이다. PEP 703 — Making the GIL Optional in CPython, concurrent.futures — Python 3.12 docs.
관련 글
- Python pathlib 사용법 — os.path를 대체하는 객체지향 경로 처리 심층 분석 – pathlib은 Python 3.4부터 표준에 들어온 객체지향 경로 모듈이다. os.path를 어디까지 대체할 수 있는지, 실무에서 어떻게…
- SQLAlchemy async 비동기 설정 완전 정복 — FastAPI 실전 패턴 – FastAPI에 SQLAlchemy 2.0 async를 붙이면 처음엔 잘 돈다. 트래픽이 늘면 greenlet과 풀 에러가 동시에 터진다….
- Pydantic v2 마이그레이션 사용법 — 파괴적 변경 10가지와 FastAPI·SQLAlchemy 호환 – v1 코드를 v2로 옮길 때 가장 자주 막히는 10개 지점을 비교표로 정리한다. 한 번에 갈아엎을지, bridge로 분산할지 판단 기준과 …