Python 비동기 작업에서 ThreadPoolExecutor vs ProcessPoolExecutor 선택 기준

문제 상황

데이터 처리 API에서 이미지 리사이징 작업이 병목이 되고 있었다. 기존에는 단순 반복문으로 처리했는데, 동시에 여러 이미지를 처리할 때 응답 시간이 선형적으로 증가했다.

ThreadPoolExecutor 시도

처음에는 간단하게 ThreadPoolExecutor를 적용했다.

from concurrent.futures import ThreadPoolExecutor
from PIL import Image

def resize_image(image_path, size):
    img = Image.open(image_path)
    img.thumbnail(size)
    return img

with ThreadPoolExecutor(max_workers=4) as executor:
    futures = [executor.submit(resize_image, path, (800, 600)) 
               for path in image_paths]
    results = [f.result() for f in futures]

결과는 기대 이하였다. 10개 이미지 처리 시간이 5.2초에서 4.8초로 소폭 개선에 그쳤다.

ProcessPoolExecutor로 전환

Python의 GIL(Global Interpreter Lock) 때문에 CPU 집약적 작업은 멀티스레딩 효과가 제한적이다. ProcessPoolExecutor로 변경했다.

from concurrent.futures import ProcessPoolExecutor

def process_image(args):
    image_path, size, output_path = args
    img = Image.open(image_path)
    img.thumbnail(size)
    img.save(output_path)
    return output_path

if __name__ == '__main__':
    tasks = [(path, (800, 600), f'output_{i}.jpg') 
             for i, path in enumerate(image_paths)]
    
    with ProcessPoolExecutor(max_workers=4) as executor:
        results = list(executor.map(process_image, tasks))

동일 조건에서 2.1초로 단축됐다. 약 2.5배 성능 향상을 확인했다.

선택 기준 정리

ThreadPoolExecutor 사용

  • I/O 바운드 작업 (네트워크 요청, 파일 읽기)
  • 작업 간 메모리 공유 필요
  • 가벼운 작업 다수 처리

ProcessPoolExecutor 사용

  • CPU 바운드 작업 (이미지 처리, 데이터 변환)
  • 독립적인 작업 단위
  • 프로세스 생성 오버헤드를 상쇄할 만한 무거운 작업

주의할 점은 ProcessPoolExecutor는 pickle 직렬화가 가능한 객체만 전달할 수 있다는 것이다. 람다 함수나 로컬 함수는 사용할 수 없어서 모듈 레벨 함수로 분리해야 했다.