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

[PYTHON] CPU Bound 작업 해결을 위한 multiprocessing vs threading 선택 방법과 2가지 핵심 차이

by Papa Martino V 2026. 4. 11.
728x90

multiprocessing vs threading
multiprocessing vs threading

 

파이썬을 활용해 고성능 애플리케이션을 개발할 때 가장 빈번하게 마주하는 고민은 "어떻게 하면 연산 속도를 극대화할 수 있는가?"입니다. 특히 데이터 분석, 대규모 수치 계산, 이미지 처리와 같은 CPU Bound 작업에서 잘못된 동시성 모델을 선택하면 오히려 속도가 저하되는 현상을 겪게 됩니다. 본 가이드에서는 파이썬의 구조적 특징인 GIL을 바탕으로, CPU 집약적 작업에서 왜 multiprocessing이 정답이 될 수밖에 없는지, 그리고 실무에서 이를 어떻게 구현하는지 7가지 실전 예제와 함께 심층적으로 분석합니다.


1. CPU Bound vs I/O Bound: 개념적 차이 완벽 정리

먼저 우리가 해결하려는 문제의 성격을 명확히 규정해야 합니다. 작업의 성격에 따라 최적의 도구가 완전히 달라지기 때문입니다.

  • CPU Bound 작업: 프로그램의 실행 속도가 CPU의 계산 능력에 의해 결정되는 작업입니다. 행렬 연산, 암호화, 압축, 복잡한 알고리즘 실행 등이 여기에 해당합니다.
  • I/O Bound 작업: 프로그램의 실행 속도가 데이터 입력/출력(파일 읽기/쓰기, 네트워크 요청, 데이터베이스 쿼리 등)에 의해 결정되는 작업입니다.

2. Multiprocessing과 Threading의 기술적 차이 분석

파이썬에서 두 모듈의 가장 큰 차이점은 "메모리를 공유하는가""GIL의 영향을 받는가"입니다.

비교 항목 Threading (스레딩) Multiprocessing (멀티프로세싱)
메모리 공간 프로세스 내 메모리 공유 독립적인 메모리 공간 소유
GIL 영향 매우 큼 (한 번에 하나만 실행) 없음 (프로세스별 독립 GIL)
자원 소모 적음 (가볍고 빠름) 많음 (새 프로세스 생성 오버헤드)
통신 방식 변수 직접 접근 가능 IPC (Queue, Pipe) 필요
최적 용도 웹 크롤링, 파일 I/O AI 연산, 대규모 수치 계산

3. 왜 CPU Bound에는 Multiprocessing인가?

파이썬의 GIL(Global Interpreter Lock)은 한 프로세스 내에서 여러 스레드가 동시에 파이썬 바이트코드를 실행하는 것을 막습니다. 따라서 스레드를 아무리 많이 만들어도 CPU 코어를 하나밖에 쓰지 못합니다. 반면, multiprocessing은 물리적으로 독립된 프로세스를 생성하므로, 각 프로세스가 서로 다른 코어에 할당되어 진정한 의미의 병렬 처리가 가능해집니다.


4. 실무 적용을 위한 Python 실전 Example (7가지)

다음은 개발자가 현업에서 CPU Bound 문제를 해결할 때 즉시 복사하여 적용할 수 있는 핵심 예제들입니다.

Example 1: 기본적인 Process 활용 방법

가장 기초적인 수준에서 개별 프로세스를 생성하고 실행하는 구조입니다.


from multiprocessing import Process
import os

def heavy_task(name):
    print(f"작업 {name} 시작 (PID: {os.getpid()})")
    count = 0
    for i in range(10**7):
        count += i
    print(f"작업 {name} 완료")

if __name__ == "__main__":
    processes = []
    for i in range(4):
        p = Process(target=heavy_task, args=(i,))
        processes.append(p)
        p.start()

    for p in processes:
        p.join()
    

Example 2: Pool을 활용한 데이터 병렬 처리 최적화

수많은 데이터를 균등하게 분배하여 코어별로 처리할 때 가장 권장되는 방식입니다.


from multiprocessing import Pool
import time

def square_number(n):
    return n * n

if __name__ == "__main__":
    numbers = range(1000000)
    start = time.time()
    
    with Pool(processes=4) as pool:
        result = pool.map(square_number, numbers)
    
    print(f"소요 시간: {time.time() - start:.4f}초")
    

Example 3: Queue를 이용한 프로세스 간 데이터 통신 (IPC)

서로 다른 메모리 공간을 가진 프로세스끼리 안전하게 데이터를 주고받는 방법입니다.


from multiprocessing import Process, Queue

def producer(q):
    data = [i for i in range(5)]
    q.put(data)
    print("데이터를 Queue에 담았습니다.")

def consumer(q):
    data = q.get()
    print(f"Queue에서 데이터를 가져왔습니다: {data}")

if __name__ == "__main__":
    q = Queue()
    p1 = Process(target=producer, args=(q,))
    p2 = Process(target=consumer, args=(q,))
    p1.start()
    p2.start()
    p1.join()
    p2.join()
    

Example 4: 공유 메모리(Value, Array) 사용으로 오버헤드 줄이기

IPC 통신 비용이 부담될 때, 제한적으로 공유 메모리 영역을 설정하여 속도를 높이는 방법입니다.


from multiprocessing import Process, Value, Array

def update_shared_data(n, a):
    n.value = 3.14159
    for i in range(len(a)):
        a[i] = i * i

if __name__ == "__main__":
    num = Value('d', 0.0)
    arr = Array('i', range(10))

    p = Process(target=update_shared_data, args=(num, arr))
    p.start()
    p.join()

    print(f"공유 값: {num.value}")
    print(f"공유 배열: {arr[:]}")
    

Example 5: CPU 코어 수 자동 감지 및 할당

하드웨어 사양에 유연하게 대응하여 최대 성능을 뽑아내는 실무 팁입니다.


import multiprocessing

def check_cpu():
    core_count = multiprocessing.cpu_count()
    print(f"이 시스템의 CPU 코어 수는 {core_count}개입니다.")
    # 보통 코어 수 혹은 코어 수 - 1개로 프로세스 할당
    return core_count

if __name__ == "__main__":
    cores = check_cpu()
    # pool = multiprocessing.Pool(processes=cores)
    

Example 6: 대규모 행렬 연산 시 Multiprocessing과 NumPy 결합

AI 및 데이터 사이언스에서 가장 많이 사용되는 패턴입니다.


import numpy as np
from multiprocessing import Pool

def matrix_power(data):
    return np.linalg.matrix_power(data, 3)

if __name__ == "__main__":
    # 대규모 행렬 리스트 생성
    matrix_list = [np.random.rand(500, 500) for _ in range(10)]
    
    with Pool(processes=multiprocessing.cpu_count()) as pool:
        results = pool.map(matrix_power, matrix_list)
    print("병렬 행렬 연산 완료")
    

Example 7: 비정상 종료 방지를 위한 Sentinel(종료 신호) 패턴

무한 루프나 지속적인 스트리밍 데이터를 처리할 때 프로세스를 안전하게 종료하는 기법입니다.


from multiprocessing import Process, Queue

def worker(q):
    while True:
        item = q.get()
        if item is None: # 종료 신호 감지
            break
        print(f"처리 중: {item}")
    print("Worker 종료")

if __name__ == "__main__":
    q = Queue()
    p = Process(target=worker, args=(q,))
    p.start()

    for item in ["A", "B", "C"]:
        q.put(item)
    
    q.put(None) # 종료 신호 전송
    p.join()
    

5. 결론: 어떤 상황에서 무엇을 고를 것인가?

결론적으로, CPU의 연산력이 중요한 작업에는 무조건 Multiprocessing을 사용하십시오. 스레딩은 오직 I/O 대기 시간이 길어 CPU가 놀고 있는 상황에서만 유효합니다. 최신 파이썬 버전에서는 concurrent.futures 모듈을 통해 두 방식을 동일한 인터페이스로 다룰 수 있으므로, 상황에 맞춰 ProcessPoolExecutorThreadPoolExecutor를 교체하며 성능을 테스트해보는 것이 좋습니다.


6. 내용의 출처 및 참고 자료

  • Python 공식 문서 (multiprocessing module)
  • Python Wiki (GlobalInterpreterLock)
  • "Effective Python" by Brett Slatkin - 스레딩과 프로세싱의 베스트 프랙티스 섹션
  • Stack Overflow 고성능 컴퓨팅 커뮤니티 토론 데이터
728x90