본문 바로가기

카테고리 없음

7-4. 멀티스레딩(Multithreading)과 멀티프로세싱(Multiprocessing)

파이썬 멀티스레딩고 멀티프로세싱

안녕하세요! 지난 시간에는 파이썬의 강력한 문법인 데코레이터(Decorator)에 대해 알아보았습니다. 이제 여러분은 함수를 직접 수정하지 않고도 기능을 확장하는 우아한 방법을 이해하셨을 거예요!

이번 시간에는 파이썬에서 여러 작업을 동시에 처리하여 프로그램의 효율성을 높이는 두 가지 중요한 개념인 **멀티스레딩(Multithreading)**과 **멀티프로세싱(Multiprocessing)**에 대해 알아보겠습니다.

단일 코어 CPU에서는 한 번에 하나의 작업만 실행할 수 있지만, 멀티스레딩과 멀티프로세싱은 이러한 제약을 넘어 여러 작업을 '병렬적으로' 또는 '동시에' 실행되는 것처럼 보이게 하거나 실제로 병렬로 실행하여 프로그램의 응답성을 높이거나 처리 시간을 단축시킬 수 있습니다.

마치 하나의 요리사(CPU)가 여러 요리(작업)를 동시에 진행하는(멀티스레딩) 것과, 여러 요리사(CPU 코어)가 각자 다른 요리(작업)를 동시에 진행하는(멀티프로세싱) 것에 비유할 수 있습니다. 그럼, 이 두 가지 개념이 어떻게 작동하는지 함께 살펴볼까요?


Part 1: 멀티스레딩 (Multithreading) - 동시성(Concurrency) 달성

**스레드(Thread)**는 프로세스 내에서 실행되는 가장 작은 실행 단위입니다. 하나의 프로세스는 여러 스레드를 가질 수 있으며, 이 스레드들은 프로세스의 메모리 공간을 공유합니다. 멀티스레딩은 하나의 프로그램(프로세스) 내에서 여러 스레드를 동시에 실행하는 기법입니다.

1. 파이썬의 GIL (Global Interpreter Lock)

파이썬의 CPython 인터프리터에는 **GIL(Global Interpreter Lock)**이라는 메커니즘이 존재합니다. GIL은 한 번에 하나의 스레드만 파이썬 바이트코드를 실행할 수 있도록 강제하는 락(Lock)입니다.

  • 영향: GIL 때문에 파이썬의 멀티스레딩은 **CPU-Bound 작업(CPU 연산이 많은 작업)**에서는 진정한 병렬 처리(Parallelism)를 달성하기 어렵습니다. 즉, 여러 스레드가 동시에 CPU를 사용하여 계산하는 것이 불가능합니다.
  • 활용: 하지만 **I/O-Bound 작업(입출력 작업이 많은 작업)**에서는 여전히 유용합니다. 파일 읽기/쓰기, 네트워크 통신(웹 요청), 데이터베이스 쿼리 등 I/O 작업이 진행되는 동안 GIL은 해제될 수 있으므로, 다른 스레드가 CPU를 사용하여 다른 작업을 수행할 수 있습니다. 이는 프로그램의 응답성을 높이는 데 도움이 됩니다.

2. 멀티스레딩 예시 (I/O-Bound 시뮬레이션)

간단한 웹 페이지 다운로드를 시뮬레이션하는 예제를 통해 멀티스레딩의 동작을 살펴보겠습니다.

Python
 
# 파일 이름: multithreading_example.py
import threading
import time
import requests # 웹 요청을 위한 라이브러리 (설치 필요: pip install requests)

# I/O-bound 작업을 시뮬레이션하는 함수
def download_webpage(url):
    """
    주어진 URL의 웹 페이지를 다운로드하는 작업을 시뮬레이션합니다.
    실제 웹 요청을 보내는 대신, 짧은 시간 지연과 메시지 출력을 사용합니다.
    """
    print(f"[{threading.current_thread().name}] {url} 다운로드 시작...")
    try:
        # 실제 웹 요청 대신 시뮬레이션 (네트워크 지연을 흉내냄)
        # response = requests.get(url, timeout=5)
        time.sleep(2) # 2초간 네트워크 지연 시뮬레이션
        # print(f"[{threading.current_thread().name}] {url} 다운로드 완료. 상태 코드: {response.status_code}")
        print(f"[{threading.current_thread().name}] {url} 다운로드 완료.")
    except Exception as e:
        print(f"[{threading.current_thread().name}] {url} 다운로드 실패: {e}")

def run_multithreading():
    """
    멀티스레딩을 사용하여 여러 웹 페이지 다운로드 작업을 실행합니다.
    """
    urls = [
        "https://example.com/page1",
        "https://example.com/page2",
        "https://example.com/page3",
        "https://example.com/page4"
    ]
    threads = []
    start_time = time.time()

    print("\n--- 멀티스레딩 시작 ---")
    for i, url in enumerate(urls):
        # 각 URL에 대해 새로운 스레드 생성
        thread = threading.Thread(target=download_webpage, args=(url,), name=f"Thread-{i+1}")
        threads.append(thread)
        thread.start() # 스레드 시작

    # 모든 스레드가 종료될 때까지 기다림
    for thread in threads:
        thread.join()

    end_time = time.time()
    print(f"--- 멀티스레딩 종료. 총 소요 시간: {end_time - start_time:.2f}초 ---")

if __name__ == "__main__":
    run_multithreading()

코드 설명:

  • download_webpage 함수는 time.sleep(2)를 사용하여 네트워크 I/O 지연을 시뮬레이션합니다.
  • threading.Thread를 사용하여 각 download_webpage 함수를 별도의 스레드에서 실행합니다.
  • thread.start()는 스레드를 시작하고, thread.join()은 해당 스레드가 종료될 때까지 메인 스레드가 기다리도록 합니다.
  • 이 예제는 각 다운로드 작업이 독립적으로 진행되는 동안 다른 스레드가 시작될 수 있음을 보여주며, 총 소요 시간이 단일 스레드로 각 2초씩 4번 실행하는 것(총 8초)보다 훨씬 짧게 나옵니다. (약 2초대)

 

[VS Code 터미널 출력]

파이썬 멀티스레딩 예시

Part 2: 멀티프로세싱 (Multiprocessing) - 진정한 병렬 처리(Parallelism) 달성

**프로세스(Process)**는 운영체제로부터 독립적인 메모리 공간을 할당받아 실행되는 프로그램의 인스턴스입니다. 각 프로세스는 자체적인 스레드와 자원을 가집니다. 멀티프로세싱은 여러 개의 독립적인 프로세스를 동시에 실행하는 기법입니다.

1. GIL 우회 및 활용

  • 각 프로세스는 독립적인 파이썬 인터프리터를 가지므로, GIL의 영향을 받지 않습니다.
  • 따라서 멀티프로세싱은 CPU-Bound 작업에서 진정한 병렬 처리(Parallelism)를 달성하여 성능 향상을 기대할 수 있습니다. 다중 코어 CPU의 모든 코어를 효율적으로 활용할 수 있습니다.
  • 단점: 프로세스 간 통신은 스레드 간 통신보다 복잡하고 오버헤드가 더 큽니다 (메모리를 공유하지 않기 때문). 또한, 프로세스 생성 비용이 스레드 생성 비용보다 높습니다.

2. 멀티프로세싱 예시 (CPU-Bound 시뮬레이션)

복잡한 계산을 시뮬레이션하는 예제를 통해 멀티프로세싱의 동작을 살펴보겠습니다.

Python
 
# 파일 이름: multiprocessing_example.py
import multiprocessing
import time

# CPU-bound 작업을 시뮬레이션하는 함수
def cpu_heavy_task(number):
    """
    주어진 숫자에 대해 복잡한 계산을 시뮬레이션합니다.
    (예: 매우 큰 숫자의 제곱근 계산을 반복)
    """
    print(f"[{multiprocessing.current_process().name}] {number} 계산 시작...")
    result = 0
    for i in range(1, 5_000_000): # 5백만 번 반복하는 무거운 연산
        result += (i * number) ** 0.5 # 제곱근 계산
    print(f"[{multiprocessing.current_process().name}] {number} 계산 완료. 결과 일부: {result:.2f}")
    return result

def run_multiprocessing():
    """
    멀티프로세싱을 사용하여 여러 CPU-bound 작업을 실행합니다.
    """
    numbers_to_process = [10, 20, 30, 40]
    processes = []
    start_time = time.time()

    print("\n--- 멀티프로세싱 시작 ---")
    for i, num in enumerate(numbers_to_process):
        # 각 숫자에 대해 새로운 프로세스 생성
        process = multiprocessing.Process(target=cpu_heavy_task, args=(num,), name=f"Process-{i+1}")
        processes.append(process)
        process.start() # 프로세스 시작

    # 모든 프로세스가 종료될 때까지 기다림
    for process in processes:
        process.join()

    end_time = time.time()
    print(f"--- 멀티프로세싱 종료. 총 소요 시간: {end_time - start_time:.2f}초 ---")

if __name__ == "__main__":
    # Windows에서는 multiprocessing 모듈을 사용할 때 if __name__ == "__main__": 블록이 필수적입니다.
    # 그렇지 않으면 무한 루프가 발생할 수 있습니다.
    run_multiprocessing()

코드 설명:

  • cpu_heavy_task 함수는 반복적인 제곱근 계산을 통해 CPU 연산을 많이 소모하도록 시뮬레이션합니다.
  • multiprocessing.Process를 사용하여 각 cpu_heavy_task 함수를 별도의 프로세스에서 실행합니다.
  • process.start()는 프로세스를 시작하고, process.join()은 해당 프로세스가 종료될 때까지 메인 프로세스가 기다리도록 합니다.
  • 이 예제는 CPU 코어 수에 따라 단일 프로세스로 각 작업을 순차적으로 실행하는 것보다 훨씬 빠르게 완료될 수 있습니다. (예: 4코어 CPU에서 각 4초 걸리는 작업 4개를 병렬로 실행하면 총 4초대에 완료될 수 있음)

 

[VS Code 터미널 출력]

파이썬 멀티프로세싱 예시

Part 3: 멀티스레딩 vs 멀티프로세싱 비교

두 가지 동시성/병렬성 기법의 주요 차이점을 요약하고, 언제 어떤 것을 선택해야 할지 알아보겠습니다.

특징 멀티스레딩 (Multithreading) 멀티프로세싱 (Multiprocessing)
실행 단위 스레드 (프로세스 내의 경량 실행 단위) 프로세스 (독립적인 실행 환경)
메모리 공유 동일 프로세스 내에서 메모리 공유 (데이터 공유 용이) 각 프로세스가 독립적인 메모리 공간 가짐 (데이터 공유 복잡)
GIL 영향 GIL의 영향을 받음 (CPU-bound 작업에서 진정한 병렬성 어려움) GIL의 영향을 받지 않음 (CPU-bound 작업에서 진정한 병렬성 가능)
생성 비용 낮음 (스레드 생성 및 전환 비용이 적음) 높음 (새로운 프로세스 생성 및 메모리 할당 비용이 큼)
통신 공유 메모리를 통한 직접 통신 (락/뮤텍스 필요) 파이프(Pipe), 큐(Queue), 공유 메모리 등 복잡한 IPC(Inter-Process Communication) 메커니즘 필요
오버헤드 낮음 높음
적합한 경우 I/O-Bound 작업 (네트워크 통신, 파일 I/O 등) CPU-Bound 작업 (복잡한 계산, 데이터 처리 등)
예시 웹 서버, GUI 애플리케이션, 웹 크롤러 과학 계산, 이미지/비디오 처리, 대규모 데이터 분석
Sheets로 내보내기

실제 성능 비교 예시:

아래 코드는 CPU-Bound 작업과 I/O-Bound 작업을 각각 멀티스레딩과 멀티프로세싱으로 실행하여 GIL의 영향을 시각적으로 보여줍니다.

Python
 
# 파일 이름: performance_comparison.py
import threading
import multiprocessing
import time
import os

# --- CPU-Bound 작업 시뮬레이션 ---
def cpu_work(n):
    """CPU를 많이 사용하는 작업 시뮬레이션"""
    sum_val = 0
    for i in range(n):
        sum_val += i * i
    return sum_val

# --- I/O-Bound 작업 시뮬레이션 ---
def io_work(n):
    """I/O 대기를 시뮬레이션하는 작업"""
    time.sleep(n / 1000) # 밀리초 단위로 지연
    return f"I/O 작업 {n}ms 완료"

# --- 단일 스레드/프로세스 실행 ---
def run_single(task_func, args_list, task_name):
    print(f"\n--- 단일 {task_name} 실행 시작 ---")
    start_time = time.time()
    results = []
    for args in args_list:
        results.append(task_func(args))
    end_time = time.time()
    print(f"--- 단일 {task_name} 실행 종료. 총 소요 시간: {end_time - start_time:.2f}초 ---")
    # print(f"결과: {results[:3]}...") # 결과가 너무 길면 일부만 출력

# --- 멀티스레딩 실행 ---
def run_threaded(task_func, args_list, task_name):
    print(f"\n--- 멀티스레딩 {task_name} 실행 시작 ---")
    threads = []
    start_time = time.time()
    for args in args_list:
        thread = threading.Thread(target=task_func, args=(args,))
        threads.append(thread)
        thread.start()
    for thread in threads:
        thread.join()
    end_time = time.time()
    print(f"--- 멀티스레딩 {task_name} 실행 종료. 총 소요 시간: {end_time - start_time:.2f}초 ---")

# --- 멀티프로세싱 실행 ---
def run_processed(task_func, args_list, task_name):
    print(f"\n--- 멀티프로세싱 {task_name} 실행 시작 ---")
    processes = []
    start_time = time.time()
    for args in args_list:
        process = multiprocessing.Process(target=task_func, args=(args,))
        processes.append(process)
        process.start()
    for process in processes:
        process.join()
    end_time = time.time()
    print(f"--- 멀티프로세싱 {task_name} 실행 종료. 총 소요 시간: {end_time - start_time:.2f}초 ---")

if __name__ == "__main__":
    # CPU-Bound 작업 테스트 설정
    CPU_TASK_ITERATIONS = 50_000_000 # 각 작업의 반복 횟수
    CPU_TASKS = 4 # 실행할 작업의 개수
    cpu_args = [CPU_TASK_ITERATIONS] * CPU_TASKS

    # I/O-Bound 작업 테스트 설정
    IO_TASK_DELAY_MS = 500 # 각 작업의 지연 시간 (ms)
    IO_TASKS = 4 # 실행할 작업의 개수
    io_args = [IO_TASK_DELAY_MS] * IO_TASKS

    print(f"현재 시스템의 CPU 코어 수: {os.cpu_count()}")

    # CPU-Bound 작업 테스트
    run_single(cpu_work, cpu_args, "CPU-Bound 작업 (단일)")
    run_threaded(cpu_work, cpu_args, "CPU-Bound 작업 (멀티스레딩)")
    run_processed(cpu_work, cpu_args, "CPU-Bound 작업 (멀티프로세싱)")

    # I/O-Bound 작업 테스트
    run_single(io_work, io_args, "I/O-Bound 작업 (단일)")
    run_threaded(io_work, io_args, "I/O-Bound 작업 (멀티스레딩)")
    run_processed(io_work, io_args, "I/O-Bound 작업 (멀티프로세싱)")

기대 결과 (예시, 시스템 환경에 따라 다름):

  • CPU-Bound 작업:
    • 단일: 오래 걸림 (예: 15초)
    • 멀티스레딩: 단일 스레드와 비슷하거나 약간 더 오래 걸림 (GIL 때문에 병렬성 없음) (예: 15~16초)
    • 멀티프로세싱: CPU 코어 수에 비례하여 빨라짐 (예: 4코어에서 4초)
  • I/O-Bound 작업:
    • 단일: 오래 걸림 (예: 2초 * 4 = 8초)
    • 멀티스레딩: I/O 대기 중 GIL이 해제되어 빨라짐 (예: 2초대)
    • 멀티프로세싱: 스레딩과 비슷하게 빨라지지만, 프로세스 생성 오버헤드 때문에 스레딩보다 약간 더 오래 걸릴 수 있음 (예: 2.5초)

이 결과를 통해 GIL의 영향과 각 기법의 장단점을 명확히 이해할 수 있습니다.


 

[VS Code 터미널 출력 (예시, 시스템 환경에 따라 다름)]

파이썬 멀티스레딩과 멀티프로세싱 성능 비교 예시

마무리하며

이번 시간에는 파이썬에서 여러 작업을 동시에 처리하는 **멀티스레딩(Multithreading)**과 **멀티프로세싱(Multiprocessing)**에 대해 자세히 알아보았습니다.

  • 멀티스레딩: GIL 때문에 I/O-Bound 작업에 주로 사용되며, 응답성 향상에 기여합니다.
  • 멀티프로세싱: GIL을 우회하여 CPU-Bound 작업에서 진정한 병렬 처리를 가능하게 하며, 다중 코어 CPU를 최대한 활용합니다.

두 기법은 각각의 장단점과 적합한 사용 사례가 있으므로, 여러분의 프로그램이 어떤 종류의 병목 현상을 겪는지 (CPU-Bound인지, I/O-Bound인지) 파악하여 올바른 방법을 선택하는 것이 중요합니다.

이것으로 '파이썬 고급 문법 & 실전 예제' 챕터의 첫 번째 포스팅이 마무리됩니다. 다음 포스팅에서는 파이썬의 패키지와 모듈을 효과적으로 관리하는 **가상 환경(Virtual Environment)**의 개념과 사용법에 대해 알아보겠습니다.


궁금한 점이 있다면 언제든지 질문해주세요! 다음 포스팅에서 만나요!

반응형