언어·프레임워크/Python

[Python] concurrent.futures: GIL, 동시성, 병렬성 완전 정복

DandyNow 2025. 5. 27. 10:28
728x90
반응형

Python concurrent.futures: GIL, 동시성, 병렬성 완전 정복

Python에서 멀티스레딩과 멀티프로세싱을 다룰 때 가장 혼란스러운 개념 중 하나가 바로 GIL, 동시성, 병렬성이다. 특히 concurrent.futures 모듈의 ThreadPoolExecutor와 ProcessPoolExecutor를 언제 사용해야 하는지 판단하기 어려워하는 개발자들이 많다. 이 글에서는 이러한 개념들을 명확히 정리하고, 실무에서 어떻게 적용해야 하는지 알아보겠다.

GIL(Global Interpreter Lock)이란 무엇인가

GIL은 Python 인터프리터의 핵심 메커니즘 중 하나로, 한 번에 하나의 스레드만 Python 바이트코드를 실행할 수 있도록 제한하는 뮤텍스이다. 이는 Python의 메모리 관리 방식, 특히 레퍼런스 카운팅 시스템의 스레드 안전성을 보장하기 위해 존재한다.

GIL의 존재 이유

Python에서 모든 객체는 레퍼런스 카운트를 가지고 있다. 이 카운트가 0이 되면 해당 객체는 가비지 컬렉션의 대상이 된다. 멀티스레드 환경에서 여러 스레드가 동시에 같은 객체의 레퍼런스 카운트를 변경한다면, 레이스 컨디션이 발생하여 메모리 누수나 프로그램 크래시가 일어날 수 있다.

# 레퍼런스 카운팅 예시
x = [1, 2, 3]  # 레퍼런스 카운트 = 1
y = x          # 레퍼런스 카운트 = 2
del x          # 레퍼런스 카운트 = 1

GIL은 이러한 문제를 해결하기 위해 도입되었으며, Python 인터프리터의 안정성을 보장하는 핵심 역할을 담당한다.

GIL이 해제되는 경우

GIL이 항상 활성화되어 있는 것은 아니다. 다음과 같은 상황에서는 GIL이 해제된다:

  • I/O 작업 수행 시 (파일 읽기/쓰기, 네트워크 통신)
  • time.sleep()과 같은 블로킹 작업 실행 시
  • NumPy, pandas와 같은 C 확장 모듈 사용 시
  • 일정한 바이트코드 명령어 실행 후 (약 100개마다 자동 해제)

이러한 특성 때문에 Python에서도 멀티스레딩이 완전히 무의미하지는 않다.

동시성(Concurrency)의 개념

동시성은 여러 작업이 동시에 실행되는 것처럼 보이지만, 실제로는 빠르게 번갈아가며 실행되는 것을 의미한다. 이는 논리적인 동시성이라고 할 수 있으며, 단일 코어 시스템에서도 구현 가능하다.

동시성의 특징

동시성 모델에서는 시간 분할(time slicing) 기법을 사용하여 여러 작업을 빠르게 전환하면서 실행한다. 각 작업은 짧은 시간 동안 실행되고, 다른 작업으로 컨텍스트가 전환된다. 이러한 전환이 매우 빠르게 일어나기 때문에 사용자는 마치 여러 작업이 동시에 실행되는 것처럼 느끼게 된다.

import threading
import time

def task_a():
    for i in range(5):
        print(f"Task A - {i}")
        time.sleep(0.1)

def task_b():
    for i in range(5):
        print(f"Task B - {i}")
        time.sleep(0.1)

# 동시성 실행 예시
thread1 = threading.Thread(target=task_a)
thread2 = threading.Thread(target=task_b)

thread1.start()
thread2.start()

위 코드를 실행하면 Task A와 Task B가 번갈아가며 출력되는 것을 확인할 수 있다. 이것이 바로 동시성의 실제 모습이다.

동시성이 효과적인 경우

동시성은 주로 I/O 바운드 작업에서 큰 효과를 발휘한다. 파일을 읽거나 네트워크 요청을 보낼 때, 프로그램은 응답을 기다리는 동안 다른 작업을 수행할 수 있다. 이때 GIL이 해제되므로 멀티스레딩의 이점을 충분히 활용할 수 있다.

병렬성(Parallelism)의 개념

병렬성은 여러 작업이 물리적으로 동시에 실행되는 것을 의미한다. 이는 실제로 여러 개의 CPU 코어나 프로세서를 사용하여 각각 독립적인 작업을 수행하는 것이다.

병렬성의 특징

병렬성을 구현하기 위해서는 멀티코어 하드웨어가 필수이다. 각 코어는 완전히 독립적으로 작업을 수행하며, 서로 간섭하지 않는다. Python에서는 multiprocessing 모듈을 통해 병렬성을 구현할 수 있으며, 각 프로세스는 독립적인 메모리 공간과 GIL을 가진다.

import multiprocessing
import time

def cpu_intensive_task(n):
    result = 0
    for i in range(n):
        result += i ** 2
    return result

def compare_execution_times():
    # 순차 실행
    start_time = time.time()
    results = []
    for i in range(4):
        result = cpu_intensive_task(1000000)
        results.append(result)
    sequential_time = time.time() - start_time

    # 병렬 실행
    start_time = time.time()
    with multiprocessing.Pool(processes=4) as pool:
        results = pool.map(cpu_intensive_task, [1000000] * 4)
    parallel_time = time.time() - start_time

    print(f"순차 실행 시간: {sequential_time:.2f}초")
    print(f"병렬 실행 시간: {parallel_time:.2f}초")
    print(f"성능 향상: {sequential_time/parallel_time:.2f}배")

병렬성이 효과적인 경우

병렬성은 CPU 집약적인 작업에서 진가를 발휘한다. 수학적 계산, 이미지 처리, 데이터 분석과 같은 작업들은 병렬 처리를 통해 성능을 크게 향상시킬 수 있다.

concurrent.futures를 활용한 실제 구현

concurrent.futures 모듈은 ThreadPoolExecutor와 ProcessPoolExecutor를 제공하여 동시성과 병렬성을 쉽게 구현할 수 있게 해준다.

ThreadPoolExecutor - 동시성 구현

ThreadPoolExecutor는 스레드 풀을 사용하여 동시성을 제공한다. GIL의 제약으로 인해 실제 병렬 처리는 불가능하지만, I/O 바운드 작업에서는 뛰어난 성능을 보여준다.

import concurrent.futures
import requests
import time

def fetch_url(url):
    response = requests.get(url)
    return f"{url}: {response.status_code}"

urls = [
    'https://httpbin.org/delay/1',
    'https://httpbin.org/delay/1',
    'https://httpbin.org/delay/1',
    'https://httpbin.org/delay/1'
]

# ThreadPoolExecutor를 사용한 동시성 구현
start_time = time.time()
with concurrent.futures.ThreadPoolExecutor(max_workers=4) as executor:
    results = list(executor.map(fetch_url, urls))
execution_time = time.time() - start_time

print(f"동시성 실행 시간: {execution_time:.2f}초")

위 예제에서 4개의 HTTP 요청이 동시에 처리되어 약 1초 만에 완료된다. 만약 순차적으로 실행했다면 4초가 걸렸을 것이다.

ProcessPoolExecutor - 병렬성 구현

ProcessPoolExecutor는 별도의 프로세스를 생성하여 병렬성을 제공한다. 각 프로세스는 독립적인 GIL을 가지므로 진정한 병렬 처리가 가능하다.

import concurrent.futures
import time

def cpu_bound_work(n):
    total = 0
    for i in range(n):
        total += i ** 2
    return total

numbers = [1000000, 1000000, 1000000, 1000000]

# ProcessPoolExecutor를 사용한 병렬성 구현
start_time = time.time()
with concurrent.futures.ProcessPoolExecutor(max_workers=4) as executor:
    results = list(executor.map(cpu_bound_work, numbers))
execution_time = time.time() - start_time

print(f"병렬성 실행 시간: {execution_time:.2f}초")

멀티코어 환경에서 이 코드는 순차 실행 대비 현저한 성능 향상을 보여준다.

언제 무엇을 사용할 것인가

올바른 선택을 위해서는 작업의 특성을 정확히 파악하는 것이 중요하다.

ThreadPoolExecutor를 선택해야 하는 경우

  • 파일 입출력 작업
  • 네트워크 통신 (HTTP 요청, 데이터베이스 쿼리)
  • 웹 스크래핑
  • API 호출이 많은 작업
  • 대기 시간이 길지만 CPU 사용량이 적은 작업

ProcessPoolExecutor를 선택해야 하는 경우

  • 수학적 계산이 많은 작업
  • 이미지, 비디오 처리
  • 데이터 분석 및 머신러닝
  • 암호화/복호화 작업
  • CPU 집약적인 알고리즘 실행

성능 최적화를 위한 고려사항

워커 수를 결정할 때는 다음 원칙을 따르는 것이 좋다:

  • ThreadPoolExecutor: I/O 대기 시간에 따라 조절. 일반적으로 CPU 코어 수의 2-4배
  • ProcessPoolExecutor: CPU 코어 수와 동일하거나 약간 적게 설정
import os

# CPU 코어 수 확인
cpu_count = os.cpu_count()
print(f"사용 가능한 CPU 코어 수: {cpu_count}")

# 권장 워커 수
thread_workers = cpu_count * 2  # I/O 바운드 작업용
process_workers = cpu_count     # CPU 바운드 작업용

실무 적용 사례

실제 프로젝트에서 이러한 개념들을 어떻게 적용할 수 있는지 살펴보자.

웹 크롤링 프로젝트

import concurrent.futures
import requests
from bs4 import BeautifulSoup

def crawl_page(url):
    try:
        response = requests.get(url, timeout=10)
        soup = BeautifulSoup(response.text, 'html.parser')
        title = soup.find('title').text.strip()
        return {'url': url, 'title': title, 'status': 'success'}
    except Exception as e:
        return {'url': url, 'error': str(e), 'status': 'failed'}

urls = ['https://example.com'] * 100

# I/O 바운드 작업이므로 ThreadPoolExecutor 사용
with concurrent.futures.ThreadPoolExecutor(max_workers=20) as executor:
    results = list(executor.map(crawl_page, urls))

success_count = sum(1 for r in results if r['status'] == 'success')
print(f"성공: {success_count}/{len(urls)}")

데이터 처리 프로젝트

import concurrent.futures
import pandas as pd
import numpy as np

def process_data_chunk(data_chunk):
    # CPU 집약적인 데이터 처리 작업
    result = data_chunk.apply(lambda x: x**2 + np.sin(x) + np.log(x+1))
    return result.sum()

# 큰 데이터를 청크로 분할
large_data = pd.Series(range(1000000))
chunk_size = 100000
chunks = [large_data[i:i+chunk_size] for i in range(0, len(large_data), chunk_size)]

# CPU 바운드 작업이므로 ProcessPoolExecutor 사용
with concurrent.futures.ProcessPoolExecutor(max_workers=4) as executor:
    results = list(executor.map(process_data_chunk, chunks))

total_result = sum(results)
print(f"처리 결과: {total_result}")

주의사항과 최적화 팁

메모리 사용량 고려

ProcessPoolExecutor는 각 프로세스가 독립적인 메모리 공간을 가지므로 메모리 사용량이 크게 증가할 수 있다. 대용량 데이터를 처리할 때는 특히 주의해야 한다.

오버헤드 고려

작은 작업들을 병렬 처리할 때는 프로세스 생성과 통신 오버헤드가 실제 작업 시간보다 클 수 있다. 이런 경우에는 순차 처리가 더 효율적일 수 있다.

디버깅의 어려움

멀티프로세싱 환경에서는 디버깅이 어려워진다. 로깅을 적극 활용하고, 작은 단위로 테스트하는 것이 중요하다.

결론

Python에서 concurrent.futures를 효과적으로 사용하기 위해서는 GIL, 동시성, 병렬성의 개념을 정확히 이해하는 것이 필수이다. ThreadPoolExecutor는 I/O 바운드 작업에서 동시성을 제공하고, ProcessPoolExecutor는 CPU 바운드 작업에서 병렬성을 제공한다는 핵심 원칙을 기억하자.

작업의 특성을 정확히 파악하고 적절한 도구를 선택한다면, Python에서도 멀티코어 시스템의 성능을 최대한 활용할 수 있다. 성능 최적화는 항상 측정과 분석을 바탕으로 이루어져야 하며, 맹목적인 병렬화보다는 병목 지점을 정확히 파악하여 해결하는 것이 중요하다.

728x90
반응형