시작하게 된 계기
백엔드 개발을 하다 보면 "성능을 높이려면 병렬로 처리하면 되지 않을까?"라는 생각을 자연스럽게 하게 된다. 다른 언어라면 스레드를 여러 개 띄우는 게 첫 번째 선택지겠지만, 파이썬에서는 이야기가 조금 다르다. GIL(Global Interpreter Lock) 때문이다.
이번 글에서는 GIL이 정확히 무엇이고 왜 멀티스레딩의 발목을 잡는지, 그리고 실무에서는 이를 어떻게 우회해서 병렬/동시 처리를 하는지 — 특히 FastAPI가 이 문제를 어떻게 다루는지까지 정리해본다.
GIL이란 무엇인가
GIL은 CPython(우리가 흔히 쓰는 파이썬 인터프리터)이 한 번에 하나의 스레드만 파이썬 바이트코드를 실행하도록 강제하는 락(lock)이다. 즉, 프로세스 안에 스레드를 아무리 여러 개 만들어도 실제로 파이썬 코드를 실행하는 시점에는 항상 하나의 스레드만 CPU를 점유한다. 멀티코어 CPU를 쓰고 있어도 파이썬 스레드들은 서로 번갈아가며 GIL을 넘겨받는 방식으로 동작하기 때문에, CPU 코어를 동시에 여러 개 활용하는 것이 불가능하다.
왜 이런 설계를 했을까
GIL이 존재하는 이유는 크게 두 가지로 알려져 있다.
- 메모리 관리의 안전성: 파이썬 객체는 참조 카운팅(reference counting) 방식으로 가비지 컬렉션을 하는데, 여러 스레드가 동시에 참조 카운트를 변경하면 레이스 컨디션이 발생할 수 있다. GIL은 이를 막기 위한 가장 단순하고 확실한 방법이었다.
- C 확장 모듈과의 호환성: 파이썬 초창기부터 수많은 C 확장 라이브러리가 GIL이 있다는 전제로 작성되어 왔기 때문에, GIL을 제거하는 것은 생태계 전체에 영향을 주는 큰 변화다.
참고로 파이썬 3.13부터는 GIL을 선택적으로 비활성화할 수 있는 실험적 빌드(PEP 703, free-threaded 모드)가 나오기 시작했지만, 아직 프로덕션에서 널리 쓰이는 단계는 아니다.
그럼 멀티스레딩은 아예 쓸모없는가?
여기서 중요한 구분이 필요하다. 작업의 성격에 따라 GIL의 영향이 다르다.
- CPU-bound 작업 (연산, 이미지 처리, 데이터 가공 등): GIL 때문에 스레드를 여러 개 만들어도 실질적인 성능 향상이 없다. 오히려 컨텍스트 스위칭 비용 때문에 더 느려지는 경우도 있다.
- I/O-bound 작업 (네트워크 요청, 파일 읽기/쓰기, DB 쿼리 대기 등): I/O 대기 중에는 GIL을 놓아주기 때문에, 다른 스레드가 그 틈에 작업을 할 수 있다. 즉 이 경우엔 멀티스레딩이 여전히 유효하다.
정리하면, "파이썬은 멀티스레드를 못 쓴다"가 아니라 "CPU 연산을 병렬화하는 용도로는 못 쓴다"가 더 정확한 표현이다.
실제 병렬/동시 처리 방법
그렇다면 각 상황에서 실무적으로 어떤 선택지를 쓸 수 있는지 정리해봤다.
1. multiprocessing — CPU-bound 작업의 정석
프로세스 자체를 여러 개 띄우는 방식이다. 각 프로세스는 독립된 파이썬 인터프리터와 메모리 공간, 즉 자신만의 GIL을 가지기 때문에 진짜 병렬 실행이 가능하다.
from multiprocessing import Pool
def heavy_computation(n):
return sum(i * i for i in range(n))
if __name__ == "__main__":
with Pool(processes=4) as pool:
results = pool.map(heavy_computation, [10_000_000] * 4)
다만 프로세스 간에는 메모리를 공유하지 않기 때문에 데이터를 주고받으려면 직렬화(pickle) 비용이 들고, 프로세스 생성 자체의 오버헤드도 스레드보다 크다.
2. concurrent.futures — 더 깔끔한 인터페이스
ThreadPoolExecutor와 ProcessPoolExecutor를 상황에 맞게 골라 쓸 수 있는 상위 레벨 API다.
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
# CPU-bound → ProcessPoolExecutor
with ProcessPoolExecutor() as executor:
results = list(executor.map(heavy_computation, [10_000_000] * 4))
# I/O-bound → ThreadPoolExecutor
with ThreadPoolExecutor(max_workers=10) as executor:
results = list(executor.map(fetch_url, url_list))
같은 코드 스타일로 CPU-bound와 I/O-bound 작업을 상황에 맞게 골라 쓸 수 있다는 게 장점이다.
3. asyncio — I/O-bound 작업의 현대적 해법
싱글 스레드에서 이벤트 루프 기반으로 동작하기 때문에 GIL 이슈 자체가 없다. I/O 대기 시간 동안 다른 코루틴에게 제어권을 넘기는 방식이라, 수천 개의 동시 연결도 스레드 방식보다 훨씬 가볍게 처리할 수 있다.
import asyncio
import httpx
async def fetch(client, url):
response = await client.get(url)
return response.status_code
async def main(urls):
async with httpx.AsyncClient() as client:
tasks = [fetch(client, url) for url in urls]
return await asyncio.gather(*tasks)
FastAPI는 이걸 어떻게 다룰까
여기서부터가 최근에 실제로 궁금해서 찾아본 부분이다. FastAPI는 asyncio 기반이라 기본적으로 비동기 프레임워크지만, 동시에 동기 함수도 자연스럽게 지원한다. 이 두 가지가 내부적으로 어떻게 처리되는지가 흥미로웠다.
async def vs def
@app.get("/async-endpoint")
async def async_handler():
result = await some_async_db_call()
return result
@app.get("/sync-endpoint")
def sync_handler():
result = some_blocking_db_call()
return result
- async def로 선언한 핸들러는 메인 이벤트 루프에서 바로 실행된다. 이 안에서 await 없이 블로킹 코드를 호출하면 이벤트 루프 전체가 멈춰버리기 때문에 주의가 필요하다.
- 반대로 일반 def로 선언한 핸들러는 FastAPI(정확히는 Starlette)가 자동으로 별도의 스레드풀에서 실행해준다. 그래서 내부에서 블로킹 코드를 써도 이벤트 루프를 막지 않는다.
즉 "동기 코드를 써도 되게" 스레드풀로 감싸주는 구조인데, 이 스레드풀 실행도 결국 GIL의 영향을 받는 I/O-bound 상황에 최적화된 선택이라는 걸 알면 왜 이런 구조를 택했는지 이해가 된다.
진짜 CPU-bound 작업이 껴 있다면
FastAPI 안에서 무거운 연산(이미지 리사이징, 대량 데이터 가공 등)을 처리해야 한다면, 스레드풀만으로는 GIL 문제를 피할 수 없다. 이럴 땐 ProcessPoolExecutor를 이벤트 루프와 함께 써서 별도 프로세스로 위임하는 방식을 쓴다.
from concurrent.futures import ProcessPoolExecutor
import asyncio
process_pool = ProcessPoolExecutor()
@app.get("/cpu-heavy")
async def cpu_heavy_endpoint():
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(process_pool, heavy_computation, 10_000_000)
return {"result": result}
그렇다면 uvicorn 워커는?
애플리케이션 레벨의 동시성과는 별개로, 배포 시에는 uvicorn --workers 4 처럼 워커 프로세스를 여러 개 띄우는 것도 일반적이다. 이건 각 워커가 독립된 프로세스이기 때문에 GIL의 제약과 무관하게 진짜 멀티코어를 활용하는 방법이다. 실무에서는 이벤트 루프 기반 동시성(비동기 I/O) + 멀티프로세스 워커를 함께 조합해서 쓰는 경우가 많다는 걸 알게 됐다.
정리
| CPU-bound 연산 | multiprocessing, ProcessPoolExecutor |
| I/O-bound (수십~수백 동시 요청) | threading, ThreadPoolExecutor |
| I/O-bound (대규모 동시 연결) | asyncio |
| FastAPI 내 동기 코드 | 자동 스레드풀 위임 |
| FastAPI 내 CPU 연산 | run_in_executor + ProcessPoolExecutor |
| 배포 시 멀티코어 활용 | uvicorn --workers N |
GIL은 "파이썬의 한계"라기보다는, 어떤 작업을 어떤 도구로 처리해야 하는지 명확하게 구분해서 생각하게 만드는 제약에 가깝다는 생각이 들었다. 특히 FastAPI처럼 비동기를 전제로 설계된 프레임워크를 쓸 때는, 핸들러 안에서 내가 지금 짜고 있는 코드가 I/O-bound인지 CPU-bound인지를 항상 의식하는 게 성능에 직결된다는 걸 이번에 정리하면서 다시 확인할 수 있었다.
'🍞 AI' 카테고리의 다른 글
| Claude Code를 어떻게 토큰 낭비없이 쓸 수 있을까? (1) | 2026.03.23 |
|---|
