파이썬 threading·multiprocessing 비교: CPU와 I/O 병렬 처리

파이썬 threading과 multiprocessing의 차이, CPU 바운드와 I/O 바운드 판단법, ThreadPoolExecutor·ProcessPoolExecutor 사용법과 안전한 병렬 처리 원칙을 설명합니다.

본문 상단 광고 구역 (승인 후 자동 노출됩니다)
파이썬 threading multiprocessing CPU I/O 병렬 처리 비교 대표 이미지
파이썬 스레드와 프로세스를 이용한 CPU·I/O 병렬 처리 비교

파이썬 병렬 처리 도구는 작업 성격에 맞춰 선택해야 합니다. 네트워크 요청, 파일 읽기, 데이터베이스 대기처럼 기다리는 시간이 긴 I/O 바운드 작업은 threading이나 ThreadPoolExecutor가 적합하고, 이미지 변환·수학 계산처럼 CPU를 계속 사용하는 작업은 multiprocessing이나 ProcessPoolExecutor가 일반적으로 유리합니다. 무조건 작업자 수를 늘리기보다 작은 값부터 측정하는 것이 핵심입니다.

파이썬 공식 Executor 문서 확인하기

핵심 요약

  • I/O 바운드는 스레드, CPU 바운드는 프로세스를 우선 검토합니다.
  • CPython의 기본 GIL 환경에서는 여러 스레드가 Python 바이트코드를 동시에 실행하는 데 제약이 있습니다.
  • 초보자는 저수준 Thread·Process보다 Executor의 submit()map()부터 익히기 좋습니다.
  • 스레드는 메모리를 공유해 가볍지만 동기화 오류에 주의해야 하고, 프로세스는 격리되지만 생성·직렬화 비용이 큽니다.
  • 처리량뿐 아니라 메모리, 실패 복구, 작업 순서와 중복 실행까지 함께 측정해야 합니다.

동시성·병렬성과 CPU/I/O 바운드 구분

동시성은 여러 작업이 겹치는 시간 동안 진행되도록 구성하는 개념이고, 병렬성은 실제로 여러 작업이 같은 순간에 계산되는 것을 뜻합니다. 스레드는 한 프로세스 안에서 메모리를 공유하며 여러 실행 흐름을 만들고, 프로세스는 독립된 Python 인터프리터와 메모리 공간에서 실행됩니다.

작업 주된 대기 대상 먼저 검토할 도구
웹 API 여러 개 호출 네트워크 응답 ThreadPoolExecutor 또는 asyncio
여러 파일 다운로드 네트워크·디스크 ThreadPoolExecutor
대용량 이미지 변환 CPU 계산 ProcessPoolExecutor
복잡한 수치 연산 CPU 계산 ProcessPoolExecutor
짧고 가벼운 작업 거의 없음 순차 처리부터 측정

작업 유형은 코드 모양만 보고 결정하면 안 됩니다. 예를 들어 CSV 처리도 파일을 기다리는 구간이 큰지, 파싱과 계산이 대부분인지에 따라 선택이 달라집니다. 먼저 순차 실행 시간을 측정하고 CPU 사용률과 대기 시간을 관찰하십시오.

GIL은 무엇을 제한하나요?

기본 CPython 환경의 전역 인터프리터 잠금(GIL)은 한 시점에 하나의 스레드만 Python 바이트코드를 실행하도록 제한합니다. 따라서 순수 Python CPU 계산을 여러 스레드로 나눠도 코어를 충분히 활용하지 못할 수 있습니다. 반면 I/O를 기다리는 동안에는 다른 스레드가 실행될 수 있어 네트워크·파일 작업의 처리량이 개선될 수 있습니다.

일부 네이티브 라이브러리는 계산 중 GIL을 해제하며, Python에는 GIL을 끌 수 있는 free-threaded 빌드도 존재하지만 기본 빌드와 호환성 조건을 구분해야 합니다. 일반적인 입문 판단에서는 CPU 바운드에 프로세스, I/O 바운드에 스레드라는 기준이 여전히 유용합니다.

threading과 multiprocessing 비교

항목 threading multiprocessing
실행 단위 한 프로세스 안의 여러 스레드 독립된 여러 프로세스
메모리 기본적으로 공유 기본적으로 분리
생성 비용 상대적으로 작음 상대적으로 큼
데이터 전달 공유 객체와 Queue 직렬화·Queue·Pipe 등
대표 용도 I/O 대기 작업 CPU 계산 작업
주요 위험 경쟁 상태·교착 상태 직렬화 실패·메모리 증가

작업 하나가 매우 짧으면 실행 단위를 만들고 데이터를 전달하는 비용이 실제 작업보다 커질 수 있습니다. 이런 경우 작은 작업을 묶어 전달하거나 순차 처리를 유지하는 편이 빠를 수 있습니다.

ThreadPoolExecutor로 I/O 작업 처리하기

concurrent.futures의 Executor는 스레드와 프로세스 풀에 비슷한 인터페이스를 제공합니다. 다음 예시는 여러 URL을 동시에 요청하되, 응답 시간 제한과 오류 반환을 포함한 기본 구조입니다.

from concurrent.futures import ThreadPoolExecutor, as_completed
import requests

def fetch(url):
    response = requests.get(url, timeout=10)
    response.raise_for_status()
    return url, len(response.content)

urls = [
    "https://example.com/a",
    "https://example.com/b",
    "https://example.com/c",
]

results = []
with ThreadPoolExecutor(max_workers=4) as executor:
    futures = {executor.submit(fetch, url): url for url in urls}
    for future in as_completed(futures):
        url = futures[future]
        try:
            results.append(future.result())
        except Exception as exc:
            results.append((url, f"실패: {exc}"))

as_completed()는 제출 순서가 아니라 끝난 순서대로 Future를 반환합니다. 결과 순서가 중요하면 입력 인덱스를 함께 저장하거나 map()의 특성을 활용해야 합니다. 공유 세션 객체의 스레드 안전성은 사용하는 라이브러리 문서를 확인하고, 불확실하면 작업자별로 생성하는 방법을 검토하십시오.

Thread를 직접 사용할 때

장시간 유지되는 소비자 작업, 이벤트 기반 종료, Queue 중심 파이프라인처럼 생명주기를 세밀하게 제어해야 할 때는 threading.Thread가 유용합니다. start()로 시작하고 join()으로 종료를 기다립니다. daemon 스레드는 프로그램 종료 시 갑자기 중단될 수 있으므로 파일 저장이나 데이터베이스 반영처럼 반드시 마쳐야 하는 작업에 의존하지 않는 편이 안전합니다.

ProcessPoolExecutor로 CPU 작업 처리하기

독립 프로세스는 기본 CPython의 GIL 제약을 피해 여러 CPU 코어에서 계산할 수 있습니다. 다음은 정수 범위의 제곱합을 여러 프로세스에 나누는 구조입니다.

from concurrent.futures import ProcessPoolExecutor

def sum_of_squares(start_end):
    start, end = start_end
    return sum(number * number for number in range(start, end))

if __name__ == "__main__":
    chunks = [
        (0, 2_000_000),
        (2_000_000, 4_000_000),
        (4_000_000, 6_000_000),
        (6_000_000, 8_000_000),
    ]
    with ProcessPoolExecutor(max_workers=4) as executor:
        total = sum(executor.map(sum_of_squares, chunks))
    print(total)

특히 Windows와 프로세스 시작 방식의 영향을 받는 환경에서는 실행 진입점을 if __name__ == "__main__":으로 보호해야 합니다. 프로세스에 전달하는 함수와 인수는 직렬화 가능한 형태여야 하며, 대형 객체를 매 작업마다 복사하면 성능과 메모리 사용량이 악화될 수 있습니다.

CPU를 많이 사용하는 외부 라이브러리가 내부적으로 이미 여러 스레드를 사용한다면 프로세스 수까지 크게 늘릴 때 과도한 경쟁이 발생할 수 있습니다. 라이브러리의 병렬 설정과 함께 조정하십시오.

공유 데이터와 동기화 안전성

스레드가 같은 리스트나 딕셔너리를 동시에 변경하면 실행 순서에 따라 결과가 달라지는 경쟁 상태가 생길 수 있습니다. 단순한 한 줄 연산처럼 보여도 여러 단계로 이루어질 수 있으므로, 공유 상태를 최소화하고 작업 결과를 메인 스레드에서 합치는 구조가 이해하기 쉽습니다.

  • Lock: 한 번에 하나의 스레드만 임계 구역에 들어가게 합니다.
  • Queue: 생산자와 소비자 사이에서 데이터를 안전하게 전달합니다.
  • Event: 작업 중지나 상태 전환 신호를 전달합니다.
  • Semaphore: 동시에 접근할 수 있는 작업 수를 제한합니다.

잠금을 여러 개 사용하면 서로 상대의 잠금을 기다리는 교착 상태가 발생할 수 있습니다. 잠금 순서를 통일하고 임계 구역을 짧게 유지하십시오. 프로세스끼리는 일반 객체가 자동 공유되지 않으므로 Queue, Pipe, 공유 메모리 또는 외부 저장소를 명시적으로 사용해야 합니다.

작업자 수와 성능 측정 방법

CPU 작업의 프로세스 수는 사용 가능한 코어 수와 다른 프로그램의 부하를 고려해 시작합니다. I/O 작업은 더 많은 스레드를 활용할 수 있지만 대상 서버의 제한, 네트워크 연결 수, 메모리와 파일 디스크립터가 병목이 될 수 있습니다.

  1. 먼저 순차 처리 시간을 기준값으로 기록합니다.
  2. 작업자 수를 작은 값부터 단계적으로 늘립니다.
  3. 전체 소요 시간, 처리량, 오류율, CPU와 메모리를 함께 기록합니다.
  4. 개선이 멈추거나 오류가 늘어나는 지점에서 더 늘리지 않습니다.
  5. 실제 운영 데이터 크기와 환경에서 다시 검증합니다.

Future에서 발생한 예외는 result()를 호출할 때 다시 발생합니다. 예외를 확인하지 않으면 일부 작업이 실패했는데도 전체가 끝난 것처럼 보일 수 있습니다. 작업 ID별 성공·실패와 재시도 횟수를 기록하고, 같은 입력을 두 번 처리해도 문제가 없도록 멱등성을 고려하십시오.

실수 방지 체크리스트

  • 작업이 CPU 바운드인지 I/O 바운드인지 측정으로 확인합니다.
  • 순차 처리 결과와 시간을 기준값으로 남깁니다.
  • 초보 단계에서는 ThreadPoolExecutor와 ProcessPoolExecutor부터 검토합니다.
  • 프로세스 코드의 실행 진입점을 main 보호문으로 감쌉니다.
  • 프로세스에 전달할 함수와 데이터가 직렬화 가능한지 확인합니다.
  • 공유 상태를 최소화하고 Queue나 결과 반환 방식으로 합칩니다.
  • 모든 Future의 결과와 예외를 확인합니다.
  • 작업자 수, 타임아웃, 재시도 횟수에 상한을 둡니다.
  • 종료 시 Executor, 파일, 네트워크 연결이 정리되는지 확인합니다.

자주 묻는 질문

1. threading은 진짜 병렬 실행이 아닌가요?

기본 CPython에서는 GIL 때문에 순수 Python CPU 코드를 여러 스레드가 동시에 실행하는 데 제약이 있습니다. 다만 I/O 대기 중 다른 스레드가 진행될 수 있어 동시 처리에 유용합니다.

2. CPU 바운드 작업은 무조건 multiprocessing이 빠른가요?

아닙니다. 작업이 짧거나 데이터 전달 비용이 크면 프로세스 생성과 직렬화 비용 때문에 순차 처리보다 느릴 수 있습니다.

3. Thread와 ThreadPoolExecutor 중 무엇을 쓰나요?

독립 작업 묶음을 처리한다면 Executor가 간결합니다. 생명주기, Queue 소비, 종료 신호를 세밀하게 제어해야 한다면 Thread 직접 사용을 검토합니다.

4. ProcessPoolExecutor에서 main 보호문은 왜 필요한가요?

새 프로세스가 모듈을 다시 불러오는 환경에서 프로세스 생성 코드가 반복 실행되는 것을 막기 위해 필요합니다.

5. max_workers는 크게 설정할수록 좋은가요?

아닙니다. CPU 경쟁, 메모리 증가, 연결 제한과 대상 서버 부하로 오히려 느려지거나 실패가 늘 수 있습니다.

6. 스레드끼리 리스트에 append해도 안전한가요?

특정 구현에서 개별 연산이 원자적으로 보일 수 있어도 복합 로직 전체의 안전을 보장하지 않습니다. 공유 변경을 최소화하고 명시적인 동기화나 결과 합치기를 사용하십시오.

7. 프로세스끼리 일반 전역 변수를 공유하나요?

기본적으로 각 프로세스는 독립 메모리를 사용합니다. Queue, Pipe, 공유 메모리, Manager 또는 외부 저장소가 필요합니다.

8. map과 submit의 차이는 무엇인가요?

map()은 반복 입력을 간결하게 처리하고 입력 순서에 맞춰 결과를 소비하기 쉽습니다. submit()은 작업별 Future를 다루며 완료 순서와 개별 오류를 세밀하게 제어하기 좋습니다.

9. asyncio와 threading 중 무엇이 낫나요?

비동기 API를 지원하는 대량 I/O에는 asyncio가 효율적일 수 있습니다. 기존의 블로킹 라이브러리를 활용하거나 작업 수가 많지 않다면 스레드 풀이 단순할 수 있습니다.

10. 병렬 처리 결과 순서가 바뀌어도 괜찮나요?

완료 순서로 수집하면 입력 순서와 달라질 수 있습니다. 순서가 중요하면 입력 인덱스나 고유 ID를 함께 보관해 재정렬하십시오.

공식 출처

공식 문서 확인일: 2026년 8월 30일. Python 버전과 실행 환경에 따라 기본 시작 방식, 작업자 수와 GIL 관련 동작이 달라질 수 있으므로 실제 환경의 최신 문서를 함께 확인하시기 바랍니다.

본문 하단 광고 구역