본문 바로가기
Artificial Intelligence/60. Python

[PYTHON] 고성능 병렬 처리를 위한 ThreadPoolExecutor와 ProcessPoolExecutor의 5가지 차이와 선택 방법

by Papa Martino V 2026. 3. 30.
728x90

 

고성능 병렬 처리 ThreadPoolExecutor와 ProcessPoolExecutor
병렬 처리(Parallelism) 와  동시성(Concurrency)

 

파이썬으로 대규모 데이터를 처리하거나 고성능 서버를 구축할 때, 우리는 필연적으로 병렬 처리(Parallelism)동시성(Concurrency)이라는 숙제에 직면하게 됩니다. 파이썬은 concurrent.futures라는 표준 라이브러리를 통해 이를 우아하게 해결할 수 있는 두 가지 강력한 도구, ThreadPoolExecutorProcessPoolExecutor를 제공합니다. 하지만 이 두 도구는 이름만 비슷할 뿐, 작동 메커니즘과 리소스 활용 방식에서 천양지차를 보입니다. 단순히 "여러 개를 한꺼번에 돌린다"는 생각으로 잘못된 선택을 하면, 오히려 싱글 스레드보다 느려지는 성능 역전 현상을 경험하게 됩니다. 본 포스팅에서는 파이썬의 독특한 제약 사항인 GIL(Global Interpreter Lock)을 기반으로, 각 Executor를 언제 사용해야 하는지에 대한 정답과 실무 해결 전략을 제시합니다.


1. 두 Executor의 핵심 아키텍처 및 기술적 차이 비교

ThreadPoolExecutor는 '스레드'를 기반으로 하며 메모리를 공유합니다. 반면 ProcessPoolExecutor는 별개의 '프로세스'를 띄우며 독립된 메모리 공간을 가집니다. 이 차이가 성능의 향방을 결정합니다.

비교 항목 ThreadPoolExecutor (스레드 풀) ProcessPoolExecutor (프로세스 풀)
리소스 단위 스레드 (Lightweight) 프로세스 (Heavyweight)
메모리 공유 프로세스 내 자원 공유 가능 독립적 메모리 (IPC 필요)
GIL 영향 직격탄 (한 번에 하나의 스레드만 실행) 우회 가능 (프로세스당 개별 GIL)
주요 오버헤드 컨텍스트 스위칭 비용 프로세스 생성 및 데이터 피클링 비용
최적 작업군 I/O 바운드 (네트워크, 파일 읽기) CPU 바운드 (수치 연산, 이미지 처리)

2. 언제 무엇을 사용해야 하는가? (Selection Logic)

1) ThreadPoolExecutor: 기다림의 미학 (I/O Bound)

네트워크 API 호출, 데이터베이스 쿼리 대기, 파일 시스템 접근과 같이 CPU는 놀고 있는데 시스템이 외부 응답을 기다려야 하는 경우입니다. GIL은 I/O 대기 중에 잠시 해제되므로, 여러 스레드가 동시에 대기 작업을 수행하여 전체 효율을 높일 수 있습니다.

2) ProcessPoolExecutor: 계산의 미학 (CPU Bound)

암호화, 대규모 행렬 계산, 압축, 이미지 필터링과 같이 CPU 코어를 100% 사용해야 하는 연산 작업입니다. 파이썬의 GIL 때문에 스레드로는 다중 코어 성능을 낼 수 없으므로, 여러 프로세스를 띄워 물리적인 CPU 코어를 모두 활용해야 성능 문제가 해결됩니다.


3. 실무 개발자를 위한 7가지 고성능 병렬 처리 Example

Example 1: ThreadPoolExecutor를 이용한 다중 웹 크롤링

네트워크 대기 시간이 긴 작업을 효율적으로 처리하는 방법입니다.

from concurrent.futures import ThreadPoolExecutor
import requests

def fetch_url(url):
    response = requests.get(url)
    return f"{url}: {len(response.content)} bytes"

urls = ["https://www.google.com", "https://www.python.org", "https://www.github.com"]

with ThreadPoolExecutor(max_workers=5) as executor:
    results = list(executor.map(fetch_url, urls))
    for res in results:
        print(res)

Example 2: ProcessPoolExecutor를 이용한 대규모 소수 판별 연산

CPU 집약적인 작업을 다중 코어에 분산하여 실행 시간을 단축합니다.

from concurrent.futures import ProcessPoolExecutor

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 = [10**12 + 7, 10**12 + 9, 10**12 + 37, 10**12 + 39]

if __name__ == "__main__":
    with ProcessPoolExecutor() as executor:
        results = list(executor.map(is_prime, numbers))
        print(results)

Example 3: as_completed를 이용한 작업 완료 순서대로 결과 처리

모든 작업이 끝날 때까지 기다리지 않고, 끝나는 대로 즉시 후속 처리를 하는 고도화된 방법입니다.

from concurrent.futures import ThreadPoolExecutor, as_completed
import time

def task(n):
    time.sleep(n)
    return f"Task {n} finished"

with ThreadPoolExecutor() as executor:
    futures = [executor.submit(task, n) for n in [3, 1, 2]]
    for future in as_completed(futures):
        print(future.result()) # 1, 2, 3 순서대로 출력

Example 4: 다중 파일 압축 처리 (ProcessPool 활용)

파일 I/O와 CPU 연산이 결합된 경우, 연산 비중이 높다면 프로세스 풀이 유리합니다.

import gzip
from concurrent.futures import ProcessPoolExecutor

def compress_file(filename):
    with open(filename, 'rb') as f_in:
        with gzip.open(f'{filename}.gz', 'wb') as f_out:
            f_out.writelines(f_in)
    return f"{filename} compressed"

if __name__ == "__main__":
    files = ['data1.log', 'data2.log', 'data3.log']
    with ProcessPoolExecutor() as executor:
        executor.map(compress_file, files)

Example 5: Executor와 함께 timeout 설정하여 무한 대기 방지

네트워크 지연 등으로 인해 전체 시스템이 멈추는 것을 방지하는 견고한 설계입니다.

with ThreadPoolExecutor() as executor:
    future = executor.submit(requests.get, "https://slow-api.com", timeout=10)
    try:
        result = future.result(timeout=5)
    except TimeoutError:
        print("작업 시간이 너무 길어 취소되었습니다.")

Example 6: initializer 파라미터를 이용한 전역 리소스 초기화

각 워커 스레드나 프로세스가 시작될 때 DB 연결 등을 미리 생성해두는 방법입니다.

def init_db():
    print("Worker initializing: connecting to DB...")

with ThreadPoolExecutor(max_workers=3, initializer=init_db) as executor:
    executor.submit(print, "Working...")

Example 7: Pandas 연산 병렬화로 데이터 프레임 처리 속도 개선

대규모 데이터 프레임을 청크(Chunk)로 나누어 병렬로 계산합니다.

import pandas as pd
import numpy as np
from concurrent.futures import ProcessPoolExecutor

def parallel_func(df):
    return df.apply(lambda x: x**2)

if __name__ == "__main__":
    df = pd.DataFrame(np.random.randint(0, 100, size=(10000, 4)))
    chunks = np.array_split(df, 4)
    with ProcessPoolExecutor() as executor:
        results = pd.concat(executor.map(parallel_func, chunks))

4. 최종 가이드: 선택 시 주의사항 및 결론

  • 데이터 크기: ProcessPoolExecutor는 데이터를 다른 프로세스로 보낼 때 '피클링(Pickling)' 과정을 거칩니다. 데이터가 너무 크면 병렬 처리 이득보다 통신 오버헤드가 커질 수 있습니다.
  • 상태 공유: 스레드 풀은 전역 변수 공유가 쉽지만 Race Condition을 방지하기 위해 Lock이 필요합니다. 프로세스 풀은 QueueManager를 통해 명시적으로 데이터를 주고받아야 합니다.
  • 메모리: 프로세스 풀은 각 워커마다 파이썬 인터프리터가 새로 뜨므로 메모리 사용량이 급증할 수 있음을 인지해야 합니다.

5. 내용 출처 및 기술 참조

  • Python Software Foundation. "concurrent.futures — Launching parallel tasks." Python Official Documentation.
  • Brett Slatkin. "Effective Python: 90 Specific Ways to Write Better Python."
  • "GIL (Global Interpreter Lock) in CPython." Python Wiki.
  • Luciano Ramalho. "Fluent Python, 2nd Edition." O'Reilly Media.
728x90