메뉴 닫기

파이썬 멀티프로세싱 최적화: CPU 바운드 작업 병렬 처리 가이드

파이썬 멀티프로세싱 최적화: CPU 바운드 작업 병렬 처리 가이드

💻 파이썬 성능 가속을 위한 multiprocessing 핵심 전략 5가지

파이썬으로 복잡한 수치 계산이나 대규모 데이터 처리 작업을 하다 보면, 프로그램이 싱글 코어에서만 돌면서 CPU 자원을 제대로 활용하지 못하는 답답한 상황을 자주 마주하게 되죠.

특히 CPU의 계산 능력이 필요한 CPU 바운드(CPU-bound) 작업에서는 파이썬의 GIL(Global Interpreter Lock) 때문에 멀티스레딩이 거의 무용지물이라 성능 향상이 어렵습니다.

이런 고민을 해결하고 멀티 코어 환경을 100% 활용할 수 있도록, 파이썬에서 병렬성을 확보하는 가장 확실한 방법인 multiprocessing 모듈 사용법과 함께 최적화 꿀팁들을 정리해봤습니다.

단순히 Process를 띄우는 것을 넘어, 실제 서비스에 적용할 때 발생할 수 있는 피클링 비용 최소화부터 데이터 복사 회피까지, 파이썬 병렬 처리의 성능을 극한으로 끌어올릴 수 있는 핵심 기법들을 지금 바로 만나보세요.

파이썬에서 CPU 바운드 작업을 병렬화하려면, 별도의 인터프리터를 가진 프로세스를 생성하는 multiprocessing 모듈이 필수입니다.

이때 가장 쉽게 접근할 수 있는 방식은 ProcessPoolExecutor를 사용하는 것인데, 단순히 함수를 전달하는 것만으로는 성능 저하를 피할 수 없는 함정이 있습니다.

프로세스 간 데이터 통신을 위해 발생하는 피클링(Pickling) 비용이 예상보다 커서 오히려 싱글 프로세스보다 느려지는 경우가 발생하죠.

따라서 본문에서는 이 피클링 비용을 줄이는 모듈 톱레벨 함수 사용 원칙과, 대용량 데이터를 프로세스 간에 복사하지 않고 공유하여 메모리 효율과 속도를 높이는 shared_memory 활용법에 대해 상세하게 다룹니다.



🚀 CPU 바운드 작업과 GIL: multiprocessing의 필요성

파이썬에서 프로그램의 병목이 어디에서 발생하는지 이해하는 것이 성능 최적화의 첫걸음입니다.

주어진 작업은 크게 두 가지로 분류됩니다.

CPU 바운드 vs I/O 바운드 작업

CPU 바운드(CPU-bound) 작업은 CPU의 계산 능력에 의해 속도가 결정되는 작업입니다.

복잡한 수학 계산, 영상/이미지 처리, 대규모 행렬 연산 등이 여기에 해당하며, CPU 코어를 최대한 활용하는 것이 성능 향상의 핵심입니다.

반면, I/O 바운드(I/O-bound) 작업은 데이터베이스 접근, 네트워크 통신, 파일 입출력 등 입출력 대기 시간에 속도가 좌우되는 작업입니다.

I/O 바운드 작업은 멀티스레딩(Multithreading)이나 비동기 프로그래밍(asyncio)으로도 충분히 성능을 개선할 수 있습니다.

파이썬 GIL이 병렬 처리의 발목을 잡는 이유

파이썬의 CPython 인터프리터에는 GIL (Global Interpreter Lock)이라는 메커니즘이 존재합니다.

GIL은 한 번에 오직 하나의 스레드만이 파이썬 바이트 코드를 실행할 수 있도록 강제하는 락(Lock)입니다.

이 덕분에 파이썬 메모리 관리가 단순해지고 C 확장 모듈을 안전하게 사용할 수 있지만, 멀티 코어 CPU를 가진 환경에서 CPU 바운드 작업을 멀티스레드로 처리하더라도 실질적인 병렬 실행 효과를 볼 수 없게 됩니다.

즉, 아무리 많은 스레드를 만들어도 GIL 때문에 결국 순차적으로 실행되는 것과 같아집니다.

⚠️ 주의: CPU 바운드 작업에 멀티스레딩을 사용하면 GIL 때문에 오히려 잦은 컨텍스트 스위칭 비용만 발생하여 싱글 스레드보다 성능이 더 떨어지는 경우가 많습니다.

해결책: 프로세스 기반 병렬성 확보 (multiprocessing)

GIL의 제약을 우회하고 CPU 바운드 작업의 성능을 극적으로 개선하는 유일한 방법은 멀티프로세싱(Multiprocessing)을 사용하는 것입니다.

멀티프로세싱은 스레드가 아닌 독립된 프로세스를 생성합니다.

각 프로세스는 자신만의 파이썬 인터프리터와 메모리 공간을 가지므로, GIL로부터 완전히 독립되어 각 CPU 코어에서 진정한 병렬 실행이 가능해집니다.

파이썬의 multiprocessing 모듈은 이러한 프로세스 기반 병렬 처리를 매우 쉽게 구현할 수 있도록 도와주는 핵심 도구입니다.

⚙️ ProcessPoolExecutor를 사용한 병렬 처리의 기본

파이썬에서 멀티프로세싱을 구현하는 가장 현대적이고 쉬운 방법은 concurrent.futures 모듈ProcessPoolExecutor를 사용하는 것입니다.

이는 작업(Task)을 풀(Pool)에 제출하고 그 결과를 Future 객체로 받아볼 수 있게 해주는 고수준 인터페이스(High-Level Interface)입니다.

ProcessPoolExecutor는 내부적으로 워커(Worker) 프로세스 풀을 관리하며, 개발자가 복잡한 프로세스 생성과 관리 코드를 직접 작성할 필요가 없도록 해줍니다.

ProcessPoolExecutor의 기본 사용 구조

일반적으로 context manager (with 구문)를 사용하여 풀을 생성하고, .map() 또는 .submit() 메서드로 작업을 분배합니다.

특히 .map()은 반복 가능한 객체(Iterable)의 각 요소에 함수를 적용하여 결과를 순서대로 반환할 때 매우 유용합니다.

풀의 크기(최대 프로세스 수)는 일반적으로 os.cpu_count()를 사용해 시스템의 논리 코어 수만큼 설정하는 것이 가장 효율적입니다.

CODE BLOCK
import concurrent.futures
import os
import time

def cpu_intensive_task(n):
    # 실제 CPU 계산 작업 시뮬레이션
    return sum(i*i for i in range(n))

if __name__ == '__main__':
    data_list = [10000000 + i * 1000000 for i in range(8)]
    
    start_time = time.time()
    
    # 시스템 코어 수에 맞게 최대 워커 수 설정
    max_workers = os.cpu_count()
    
    with concurrent.futures.ProcessPoolExecutor(max_workers=max_workers) as executor:
        # map을 사용하여 작업 분배 및 결과 수집
        results = executor.map(cpu_intensive_task, data_list)
        
    end_time = time.time()
    
    print(f"총 실행 시간: {end_time - start_time:.4f}초")
    for data, result in zip(data_list, results):
        print(f"데이터 {data}: 결과 {result}")

일반 multiprocessing.Process 사용 시 장단점

ProcessPoolExecutor 외에도 multiprocessing.Process 클래스를 직접 사용할 수도 있습니다.

이는 더 세밀한 제어가 가능하고, 풀링 메커니즘이 필요 없는 단발성 프로세스를 생성할 때 유용합니다.

하지만 프로세스 간 통신(IPC)을 위해 QueuePipe 같은 저수준 도구를 직접 다뤄야 하므로, 복잡도가 증가하고 에러 처리도 까다로워집니다.

대부분의 병렬 계산 작업에서는 ProcessPoolExecutor가 편리성 및 효율성 면에서 더 좋은 선택지입니다.

💡 TIP: max_workers를 명시적으로 설정하지 않으면 파이썬은 기본값으로 os.cpu_count()를 사용합니다.

I/O 바운드 작업 시에는 코어 수보다 훨씬 많은 스레드를 설정할 수 있지만, CPU 바운드에서는 코어 수 이상으로 설정하는 것은 컨텍스트 스위칭 비용만 늘릴 뿐 성능 개선 효과가 거의 없습니다.



💰 숨겨진 비용, 피클링(Pickling)을 최소화하는 방법

multiprocessing을 사용하여 병렬 처리를 할 때 가장 흔하게 발생하는 성능 저하의 원인은 바로 피클링(Pickling) 비용입니다.

프로세스는 독립된 메모리 공간을 가지기 때문에, 메인 프로세스에서 워커 프로세스로 데이터를 전달하거나(입력) 워커 프로세스에서 결과를 다시 메인 프로세스로 전달할 때(출력)는 반드시 직렬화(Serialization) 및 역직렬화(Deserialization) 과정이 필요합니다.

파이썬에서는 이 역할을 pickle 모듈이 수행합니다.

데이터 전송 시 발생하는 성능 병목

데이터를 파이썬 객체에서 바이트 스트림으로 변환하는 과정을 피클링(Pickling)이라 하고, 반대로 바이트 스트림을 파이썬 객체로 복원하는 것을 언피클링(Unpickling)이라고 합니다.

작업 부하에 비해 전달해야 할 데이터의 양이 매우 클 때, 이 피클링 및 언피클링에 소요되는 시간과 데이터 복사 시간이 전체 병렬 처리 시간의 대부분을 차지하게 됩니다.

만약 하나의 작업이 1초가 걸리는데, 데이터 직렬화 및 전송에 0.5초가 걸린다면, 병렬화로 인한 성능 이점은 크게 상쇄될 수밖에 없습니다.

이것이 병렬 처리 오버헤드(Overhead)이며, 프로세스 생성 비용과 피클링 비용이 여기에 포함됩니다.

피클 비용을 줄이는 세 가지 핵심 전략

성능 최적화를 위해 피클링 비용을 최소화하는 것이 중요하며, 다음의 세 가지 전략을 사용해야 합니다.

  • 작은 단위의 데이터만 전달: 가능한 한 워커 프로세스로 전달되는 데이터의 크기를 줄입니다. 대용량 데이터는 뒤에 설명할 shared_memory로 처리합니다.
  • 피클 친화적인 자료형 사용: NumPy 배열이나 Pandas DataFrame 같은 최적화된 자료형은 파이썬 리스트 등에 비해 훨씬 빠르게 피클링됩니다.
  • 객체 복사 회피: 함수 자체나 대규모 환경 변수를 워커에게 반복적으로 전달하지 않도록 모듈 톱레벨에 함수를 정의합니다.

특히, 피클링은 파이썬 객체뿐만 아니라 실행할 함수 객체 자체를 워커 프로세스에 전달할 때도 발생하므로, 함수 정의 위치에 대한 이해가 필수적입니다.

💡 성능 최적화: 함수를 모듈 톱레벨에 정의해야 하는 이유

multiprocessing을 사용할 때 처리할 함수를 어디에 정의하는가는 피클링 비용에 직접적인 영향을 미칩니다.

파이썬 프로세스는 함수를 실행하기 위해 해당 함수를 워커 프로세스로 전달해야 합니다.

이 전달 과정 역시 피클링을 수반합니다.

모듈 톱레벨 함수 사용 원칙

가장 성능이 좋고 권장되는 방법은 함수를 모듈의 톱레벨(Top-Level)에 정의하는 것입니다.

‘톱레벨’이란 클래스나 다른 함수 내부에 중첩되지 않고, 파일(모듈)이 시작될 때 바로 정의되는 위치를 의미합니다.

multiprocessing은 톱레벨에 정의된 함수를 워커 프로세스에게 피클링으로 전달하는 대신, 해당 모듈을 import 하도록 명령합니다.

이 방식은 피클링/언피클링 과정이 매우 단순화되므로, 중첩된 함수를 전달할 때 발생하는 복잡하고 비싼 피클링 오버헤드를 완전히 회피할 수 있습니다.

💡 TIP: 윈도우 환경에서는 프로세스 시작 방식(Spawn) 때문에 톱레벨 함수 정의가 사실상 필수적입니다.

리눅스/macOS의 기본 방식(Fork)은 메모리를 복사하기 때문에 중첩 함수도 가능하지만, 크로스 플랫폼 호환성과 최적의 성능을 위해 톱레벨 함수를 고수하는 것이 좋습니다.

중첩 함수 (Nested Function) 사용의 위험성

만약 함수를 다른 함수 내부에 중첩하여 정의하면, 해당 중첩 함수(Closure)는 외부 함수의 환경(스코프)에 있는 변수들을 참조하게 됩니다.

multiprocessing은 이 중첩 함수를 워커 프로세스로 보내기 위해 함수 객체뿐만 아니라, 외부 함수의 환경(참조하는 모든 변수)까지 통째로 피클링하려 시도합니다.

이 과정에서 피클링 불가능한 객체(예: 람다 함수, 파일 핸들, 일부 C 객체)가 포함되면 프로그램이 실패합니다.

또한, 참조하는 변수의 크기가 클 경우, 불필요하게 큰 데이터가 매번 복사되어 엄청난 성능 저하를 초래합니다.

CODE BLOCK
# 비권장: 함수를 메인 함수 내부에 정의
def run_parallel_bad():
    large_data = [i for i in range(1000000)] # 불필요하게 복사되는 데이터
    
    def process_item(item): # 중첩 함수
        # large_data를 참조하지 않아도 피클링 시도됨
        return item * 2 

    # ProcessPoolExecutor가 process_item과 large_data를 피클링 시도
    # ... 실행 코드 ...

# 권장: 함수를 모듈 톱레벨에 정의
def process_item_good(item): # 톱레벨 함수
    return item * 2

def run_parallel_good():
    # ... 실행 코드 ...
    executor.map(process_item_good, data_list)



💾 shared_memory로 대용량 데이터 복사 회피 및 공유

병렬 처리의 가장 큰 성능 병목은 대용량 데이터의 복사 및 피클링입니다.

수백 MB 이상의 데이터를 프로세스마다 복사해서 전달하는 것은 메모리 낭비와 시간 지연을 초래합니다.

파이썬 3.8부터 정식 도입된 multiprocessing.shared_memory 모듈은 이러한 문제를 해결하고 제로-복사(zero-copy) 방식으로 프로세스 간 통신을 가능하게 합니다.

공유 메모리(Shared Memory)의 기본 개념

공유 메모리는 운영체제가 제공하는 IPC(Inter-Process Communication) 기법 중 하나로, 여러 프로세스가 특정 메모리 영역을 직접 읽고 쓸 수 있도록 허용하는 방식입니다.

이를 통해 데이터 복사 없이 메모리 주소만 공유하여 프로세스 간 데이터 전달 속도를 극대화할 수 있습니다.

SharedMemory 클래스를 사용하여 특정 크기의 공유 메모리 블록을 생성하고, 고유한 이름(name)을 부여하면 다른 프로세스가 이 이름을 통해 해당 메모리 블록에 접근할 수 있습니다.

SharedMemory 자체는 바이트 데이터만 저장할 수 있으므로, 보통 NumPy 배열과 함께 사용하여 대규모 수치 데이터를 효율적으로 공유합니다.

NumPy 배열의 버퍼(buffer)를 공유 메모리에 연결하면, 각 프로세스는 복사본이 아닌 원본 데이터를 직접 읽고 쓰는 효과를 얻게 됩니다.

shared_memory 사용 시 핵심 절차

SharedMemory를 사용할 때는 생성(Create), 접근(Access), 정리(Cleanup) 세 가지 단계를 정확히 지켜야 메모리 누수나 오류를 방지할 수 있습니다.

  • 1️⃣생성(Creator Process): 메인 프로세스에서 SharedMemory(create=True, size=…)를 호출하여 블록 생성. NumPy 배열의 .nbytes를 크기로 활용합니다.
  • 2️⃣접근 및 매핑(Worker Process): 워커 프로세스는 SharedMemory(name=…)를 호출하여 기존 블록에 연결합니다. 이후 np.ndarray(…, buffer=shm.buf)로 NumPy 배열로 매핑하여 사용합니다.
  • 3️⃣정리(Cleanup): 모든 프로세스는 SharedMemory 인스턴스를 더 이상 사용하지 않을 때 .close()를 호출해야 합니다. 최종적으로, 생성자 프로세스(또는 책임 프로세스)에서 .unlink()를 호출하여 공유 메모리 블록 자체를 시스템에서 해제해야 합니다.

⚠️ 주의: 공유 메모리는 동시 접근 시 데이터의 일관성 문제가 발생할 수 있습니다. 데이터를 읽기만 한다면 문제가 없으나, 여러 프로세스가 동시에 데이터를 수정해야 한다면 Lock과 같은 동기화 메커니즘을 반드시 사용해야 합니다.

자주 묻는 질문 (FAQ)

파이썬의 GIL(Global Interpreter Lock)은 언제 해제되나요?
GIL은 기본적으로 일정 주기(인터벌)마다 해제되지만, 주요 I/O 작업(파일 읽기/쓰기, 네트워크 통신)이나 C/C++ 확장 모듈의 계산 작업이 시작될 때 자발적으로 해제됩니다. 이 때문에 I/O 바운드 작업에서는 멀티스레딩으로도 병렬성을 확보할 수 있습니다.
ProcessPoolExecutor와 ThreadPoolExecutor 중 무엇을 선택해야 하나요?
CPU 바운드 작업이라면 GIL의 영향을 받지 않는 ProcessPoolExecutor를 사용해야 합니다. I/O 바운드 작업이거나 스레드 간 데이터 공유가 빈번하여 복사 오버헤드가 적은 경우에만 ThreadPoolExecutor를 고려해야 합니다.
multiprocessing 환경에서 클래스 메서드를 병렬 처리 함수로 사용할 수 있나요?
네, 가능하지만 클래스 자체와 인스턴스가 피클링이 가능해야 합니다. 이때 클래스 인스턴스 전체가 워커 프로세스로 복사되기 때문에, 인스턴스의 크기가 크다면 톱레벨에 정의된 정적 메서드나 일반 함수를 사용하는 것이 성능상 유리합니다.
NumPy 배열이 파이썬 리스트보다 피클링 비용이 낮은 이유는 무엇인가요?
NumPy 배열은 메모리에 연속적으로 저장되는 C 언어 스타일의 데이터 버퍼를 가집니다. 피클링 시 NumPy는 이 버퍼를 효율적으로 직렬화하는 특별한 프로토콜을 사용하지만, 파이썬 리스트는 객체 포인터와 메타데이터를 포함하여 복잡하게 직렬화해야 하므로 오버헤드가 더 큽니다.
shared_memory를 사용할 때 .close()와 .unlink()의 차이점은 무엇인가요?
.close()는 해당 프로세스에서 공유 메모리 객체와의 연결을 끊는 것이고, .unlink()공유 메모리 블록 자체를 시스템에서 영구히 제거하는 명령입니다. unlink는 반드시 한 번만 호출되어야 하며, 일반적으로 공유 메모리를 생성한 메인 프로세스가 모든 작업이 끝난 후 호출합니다.
병렬 처리 작업 부하가 적을 때도 multiprocessing을 사용하는 것이 좋나요?
작업 부하가 매우 작거나(밀리초 단위), 데이터 크기가 작은 경우에는 프로세스 생성 및 피클링 오버헤드가 계산 시간을 상회하여 오히려 순차 실행보다 느려질 수 있습니다. 따라서 병렬 처리는 충분히 큰 CPU 바운드 작업에 대해서만 적용하는 것이 좋습니다.
multiprocessing에서 파이썬 객체를 공유 메모리에 직접 저장할 수 있나요?
shared_memory는 기본적으로 바이트 버퍼만 제공하기 때문에, NumPy 배열과 같은 저수준 데이터 구조를 통해 접근하는 것이 일반적입니다. 파이썬 객체를 저장하려면 객체를 먼저 피클링하여 바이트로 변환한 후 저장해야 하며, 이는 다시 피클링 오버헤드를 발생시킵니다.
ProcessPoolExecutor의 .map()과 .submit()은 어떤 상황에 주로 사용하나요?
.map()은 데이터 리스트에 동일한 함수를 적용하고 결과를 순서대로 받을 때 편리합니다. .submit()은 다양한 함수와 인수를 가진 개별적인 작업을 제출하고 Future 객체를 통해 비동기적으로 결과를 처리하거나 예외를 관리할 때 사용됩니다.

✨ 파이썬 병렬 처리, 성능을 결정짓는 핵심 최적화 포인트

파이썬에서 CPU 바운드 작업을 최적화하는 여정은 GIL의 제약을 이해하고, 이를 극복하기 위한 멀티프로세싱(multiprocessing)의 효율적인 사용법을 마스터하는 데 달려 있습니다.

우리는 ProcessPoolExecutor를 통해 쉽게 병렬 처리를 구현할 수 있지만, 진정한 성능 향상을 위해서는 그 이면에 숨겨진 데이터 직렬화(피클링) 비용을 최소화하는 것이 절대적으로 중요합니다.

함수를 모듈 톱레벨에 정의함으로써 불필요한 환경 변수의 복사를 막고, 함수 전달의 오버헤드를 줄이는 것이 기본적인 최적화 원칙입니다.

더 나아가, 대용량 데이터 셋을 다룰 때는 multiprocessing.shared_memory를 활용하여 프로세스 간 데이터 복사를 회피하고, 메모리 접근 효율을 극대화하는 것이 파이썬 병렬 처리의 궁극적인 목표가 됩니다.

이러한 핵심 원칙들을 적용한다면, 기존 싱글 프로세스 환경에서 느리게 작동하던 CPU 바운드 작업의 처리 속도를 논리 코어 수에 비례하여 극적으로 가속화할 수 있을 것입니다.


🏷️ 관련 태그 : 파이썬성능,멀티프로세싱,CPU바운드,ProcessPoolExecutor,파이썬최적화,GIL,피클링비용,shared_memory,병렬처리,파이썬가속