IT·리눅스/Python

multiprocessing vs threading - GIL 때문에 헷갈리는 두 모듈 정리

jmineekim 2026. 6. 9. 10:00

파이썬으로 처음 병렬 처리를 시도했을 때 저는 별생각 없이 threading을 썼다가, 속도가 한 톨도 안 빨라져서 한참 헤맸습니다. 이게 다 GIL 때문이라는 걸 알게 되고, 그래서 multiprocessing이 따로 있다는 것까지 깨닫는 데 일주일이 걸렸어요. 지금은 두 모듈을 상황별로 골라 쓰는데, 오늘은 그 기준을 정리해 보겠습니다.

개념 / 원리

두 모듈의 차이는 결국 GIL(Global Interpreter Lock) 한 가지에서 출발합니다.

  • threading: 하나의 프로세스 안에서 여러 스레드를 만듭니다. 모든 스레드가 같은 GIL을 공유해서 동시에 파이썬 바이트코드를 실행하는 건 한 번에 하나뿐입니다.
  • multiprocessing: 별도의 프로세스를 띄웁니다. 각 프로세스는 자기만의 파이썬 인터프리터와 GIL을 가지므로 진짜 병렬로 CPU를 쓸 수 있습니다.

그래서 핵심 기준은 작업의 성격입니다.

작업 성격 적합한 모듈 이유
I/O bound (네트워크, 파일) threading I/O 대기 중에는 GIL을 풀어줘서 다른 스레드 실행 가능
CPU bound (계산, 인코딩) multiprocessing GIL 우회해서 멀티코어 활용

비유하자면 threading은 한 사람이 여러 작업을 번갈아 처리하는 거고, multiprocessing은 사람을 여러 명 고용해서 동시에 시키는 겁니다. 단순 계산이면 사람 여럿이 빠르고, 전화 받기처럼 대기가 많으면 한 명이 번갈아 받아도 충분히 빠른 거죠.

실전 예제

I/O bound — threading 효과 큼

URL 여러 개를 동시에 받아오는 코드입니다.

import time
import threading
import urllib.request

URLS = ["https://example.com"] * 10

def fetch(url):
    with urllib.request.urlopen(url, timeout=5) as r:
        r.read()

start = time.time()
threads = [threading.Thread(target=fetch, args=(u,)) for u in URLS]
for t in threads:
    t.start()
for t in threads:
    t.join()
print(f"threading: {time.time() - start:.2f}s")

10개를 순차 실행하면 10초 걸릴 일이, 스레드로 동시에 던지면 1초대로 끝납니다.

CPU bound — multiprocessing 효과 큼

소수 판정을 4개 숫자에 대해 병렬로 돌려봅니다.

import time
from multiprocessing import Pool

def is_prime(n):
    if n < 2:
        return False
    for i in range(2, int(n ** 0.5) + 1):
        if n % i == 0:
            return False
    return True

NUMBERS = [99999989, 99999971, 99999959, 99999941]

if __name__ == "__main__":
    start = time.time()
    with Pool(processes=4) as p:
        result = p.map(is_prime, NUMBERS)
    print(f"multiprocessing: {time.time() - start:.2f}s, {result}")

같은 작업을 threading으로 돌리면 GIL 때문에 거의 직렬과 똑같은 시간이 나옵니다. 직접 비교해 보시면 차이가 확실히 느껴져요.

둘을 같은 인터페이스로 - concurrent.futures

요즘은 이걸 더 권장합니다.

from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor

# I/O bound
with ThreadPoolExecutor(max_workers=10) as ex:
    list(ex.map(fetch, URLS))

# CPU bound
with ProcessPoolExecutor(max_workers=4) as ex:
    list(ex.map(is_prime, NUMBERS))

주의할 점

multiprocessing을 쓸 때 신입이 자주 빠지는 함정이 몇 개 있습니다.

첫째, 윈도우에서는 if __name__ == "__main__": 가드가 필수입니다. 안 붙이면 자식 프로세스가 또 모듈을 임포트하면서 무한 루프에 빠집니다. 리눅스도 spawn 방식을 쓰면 마찬가지라 그냥 항상 붙여두는 게 안전합니다.

둘째, 데이터 직렬화 비용이 있습니다. 프로세스 간 통신은 pickle로 직렬화하기 때문에, 거대한 numpy 배열 같은 걸 매번 인자로 던지면 오히려 직렬화 시간이 더 걸려서 단일 프로세스보다 느려지기도 합니다. 큰 데이터는 공유 메모리(Value, Array) 또는 multiprocessing.shared_memory를 고려하세요.

셋째, pickle 불가능한 객체는 못 보냅니다. 람다, 내부 함수, 일부 클로저는 직렬화가 안 됩니다. 모듈 최상위에 정의된 함수만 안전하게 쓸 수 있어요.

저는 예전에 Pool.map에 람다를 넘겨서 PicklingError로 한참 헤맸던 경험이 있습니다. 에러 메시지가 친절하지 않으니 미리 알고 가시는 게 좋습니다.

마무리

정리하면 세 줄입니다.

  • I/O 대기가 많으면 threading, CPU 빡센 계산이면 multiprocessing.
  • multiprocessing은 pickle 직렬화 비용과 __main__ 가드 꼭 챙기기.
  • 새 코드라면 concurrent.futuresThreadPoolExecutor/ProcessPoolExecutor가 가장 깔끔.
반응형