공공 API 100개를 순서대로 호출하면 100초가 걸린다. 그런데 스레드를 4개 만들어 CPU 연산을 나눠도 속도가 거의 그대로인 경우가 있다. 두 상황은 반대로 보이지만 원인은 하나다 — Python(CPython)에는 GIL(Global Interpreter Lock) 이라는 제약이 있고, 이 제약이 언제 문제가 되는지는 작업의 성격에 달려 있다.
한 줄 정의
동시성(concurrency) 은 여러 작업을 번갈아 진행해 기다리는 시간을 겹쳐 쓰는 것이고, 병렬성(parallelism) 은 여러 작업을 물리적으로 동시에 실행하는 것이다. CPython은 GIL 때문에 한 프로세스 안에서 한 번에 하나의 스레드만 Python 바이트코드를 실행할 수 있어, 이 둘을 구현하는 도구가 갈린다.
동시성 vs 병렬성 — 작업 성격으로 나눈다
먼저 작업을 두 종류로 구분한다.
구분 | I/O-bound | CPU-bound |
|---|---|---|
병목 | 네트워크 응답, 디스크 읽기 등 대기 시간 | 연산 자체에 걸리는 시간 |
예 | API 호출, 파일 다운로드, DB 쿼리 | 수치 계산, 이미지 처리, 대용량 전처리 |
적합한 도구 |
|
|
GIL 영향 | 대기 중 GIL을 놓으므로 영향이 작다 | 연산 내내 GIL을 쥐고 있어 영향이 크다 |
데이터 분석에서는 두 성격이 자주 섞인다. 공공 API를 100번 호출해 수집하는 작업은 I/O-bound, 수집한 수십 GB CSV를 전처리하는 작업은 CPU-bound다. 같은 파이프라인 안에서도 단계마다 맞는 도구가 다르다는 뜻이다.
GIL이 하는 일
CPython 객체는 참조 횟수(reference count)로 메모리를 관리한다. 여러 스레드가 동시에 같은 객체의 참조 횟수를 늘리고 줄이면 경쟁 상태(race condition)로 값이 깨질 수 있다. GIL은 이 문제를 막기 위해 한 번에 하나의 스레드만 Python 바이트코드를 실행하도록 강제하는 락이다.
그 결과 스레드를 여러 개 만들어도 CPU 연산 자체는 번갈아 조금씩 실행될 뿐 동시에 진행되지 않는다.
import threading
def cpu_task():
return sum(range(10**7))
threads = [threading.Thread(target=cpu_task) for _ in range(4)]
for t in threads:
t.start()
for t in threads:
t.join()
# 스레드 4개를 만들어도 GIL 때문에 실질적으로는 한 번에 하나씩 실행된다
I/O 대기(
time.sleep, 네트워크 응답 등)에 들어가는 순간에는 스레드가 GIL을 놓는다. 그래서 I/O-bound 작업에는 GIL이 병목이 되지 않는다.
실측: threading은 CPU-bound에서 왜 소용없는가
연산 20,000,000개짜리 합을 4번 반복하는 작업을 순차 실행, threading, multiprocessing 세 가지 방식으로 걸리는 시간을 재본다.
import threading
import time
from multiprocessing import Pool
def cpu_task(n):
return sum(range(n))
def run_sequential(n, times):
start = time.perf_counter()
for _ in range(times):
cpu_task(n)
return time.perf_counter() - start
def run_threaded(n, times):
start = time.perf_counter()
threads = [threading.Thread(target=cpu_task, args=(n,)) for _ in range(times)]
for t in threads:
t.start()
for t in threads:
t.join()
return time.perf_counter() - start
def run_multiprocess(n, times):
start = time.perf_counter()
with Pool(processes=times) as pool:
pool.map(cpu_task, [n] * times)
return time.perf_counter() - start
if __name__ == "__main__":
N, TIMES = 40_000_000, 4
print(f"순차 실행: {run_sequential(N, TIMES):.2f}초")
print(f"threading: {run_threaded(N, TIMES):.2f}초")
print(f"multiprocessing: {run_multiprocess(N, TIMES):.2f}초")
순차 실행: 0.75초
threading: 0.74초
multiprocessing: 0.22초
threading 은 순차 실행과 시간이 거의 같다 — GIL 때문에 스레드 4개가 번갈아 실행됐을 뿐 동시에 계산하지 못했다. multiprocessing 은 프로세스마다 별도의 Python 인터프리터와 GIL을 갖기 때문에 실제로 네 개의 연산이 동시에 진행되어 약 3배 이상 빨라졌다.
같은 조건, I/O-bound에서는 정반대
이번에는 네트워크 응답을 흉내 낸 0.5초 대기를 4번 반복한다.
def io_task():
time.sleep(0.5) # 네트워크 응답을 기다리는 상황을 흉내
# 순차 실행: 4 × 0.5초 = 2초
# threading: 4개가 동시에 대기 → 약 0.5초
순차 실행: 2.01초
threading: 0.50초
대기하는 동안에는 GIL을 붙잡고 있지 않으므로 스레드 4개가 동시에 기다릴 수 있다. 같은 threading 모듈인데도 작업 성격에 따라 결과가 정반대로 나오는 이유다.
multiprocessing으로 큰 데이터를 나눠 처리하기
실제 전처리는 리스트 하나를 코어 수만큼 나누고, ProcessPoolExecutor 로 각 조각을 병렬로 처리하는 형태가 흔하다.
from concurrent.futures import ProcessPoolExecutor
import multiprocessing as mp
def transform(row):
return row * row
def process_chunk(chunk):
return [transform(row) for row in chunk]
def split_chunks(data, n):
size = len(data) // n
return [data[i * size:(i + 1) * size] for i in range(n)]
if __name__ == "__main__":
all_data = list(range(12))
n_cores = min(4, mp.cpu_count())
chunks = split_chunks(all_data, n_cores)
with ProcessPoolExecutor(max_workers=n_cores) as exe:
results = list(exe.map(process_chunk, chunks))
print(results)
[[0, 1, 4], [9, 16, 25], [36, 49, 64], [81, 100, 121]]
max_workers 는 보통 mp.cpu_count() 를 상한으로 잡는다. 코어 수보다 많은 프로세스를 띄워도 실제로 동시에 실행되는 개수는 코어 수를 넘지 못하고, 프로세스를 만들고 데이터를 주고받는 비용만 늘어난다.
예외: NumPy는 GIL 밖에서 계산한다
행렬 곱셈처럼 NumPy가 내부적으로 BLAS 같은 C/Fortran 라이브러리를 호출하는 연산은 계산 도중 GIL을 놓는다. 그래서 순수 Python 반복문과 달리 NumPy 연산은 스레드로 병렬 이득을 볼 수 있다.
import numpy as np
A = np.random.rand(1200, 1200)
B = np.random.rand(1200, 1200)
C = np.dot(A, B) # BLAS가 내부적으로 멀티스레드로 계산, GIL은 그 동안 풀려 있다
이 성질 때문에 "Python은 느리다"는 말은 순수 Python 반복문에만 해당한다. pandas·NumPy로 벡터화한 연산은 이미 GIL의 제약 밖에서 돈다.
선택 기준
상황 | 선택 |
|---|---|
API를 여러 개 동시에 호출한다 |
|
레거시 코드에서 I/O 여러 개를 동시에 처리한다 |
|
대용량 데이터를 CPU로 나눠 계산한다 |
|
pandas·NumPy 벡터 연산으로 충분하다 | 그대로 사용 (이미 GIL 밖에서 돈다) |
자주 발생하는 문제
지역 함수를 multiprocessing에 넘긴다. 각 프로세스는 별도 메모리를 쓰므로 작업 함수를 pickle로 직렬화해 전달한다. 함수 안에 정의한 지역 함수는 pickle이 이름으로 찾아갈 수 없어 실패한다.
def make_pool_call():
def local_square(n): # 지역 함수 -- 피클링 불가
return n * n
with Pool(processes=2) as pool:
return pool.map(local_square, [1, 2, 3])
AttributeError: Can't pickle local object 'make_pool_call.<locals>.local_square'
모듈 최상위에 정의한 함수만 넘긴다. 클래스 메서드나 람다도 같은 이유로 문제가 될 수 있다.
if __name__ == "__main__": 을 빼먹는다. multiprocessing 은 새 프로세스를 만들 때 현재 모듈을 다시 import한다. 이 보호구문이 없으면 자식 프로세스가 같은 코드를 실행하다가 또 자식을 만드는 무한 생성으로 이어질 수 있다. macOS와 Windows의 기본 시작 방식(spawn)에서는 특히 필수다.
스레드 수를 늘릴수록 빨라질 거라 기대한다. CPU-bound 작업에서는 스레드를 늘려도 GIL 때문에 한계가 있고, 오히려 컨텍스트 전환 비용만 늘어 더 느려지는 경우도 있다. 위 실측처럼 먼저 작업 성격을 CPU-bound/I/O-bound로 나눈 뒤 도구를 고른다.
관련 문서
threading·multiprocessing 은 둘 다 표준 라이브러리 모듈이다. import와 모듈 구조 자체가 낯설다면 모듈과 표준 라이브러리 사용법 을 먼저 본다. I/O-bound 작업을 실제 코드로 옮기는 방법은 다음 편 asyncio·httpx 문서에서 이어진다.
참고 자료
Python 공식 문서 — threading (2026-08-10 확인)
Python 공식 문서 — multiprocessing (2026-08-10 확인)
Python 공식 문서 — concurrent.futures (2026-08-10 확인)
Python 공식 문서 — Global Interpreter Lock 용어집 (2026-08-10 확인)
댓글 0
댓글을 불러오는 중…