콘텐츠 대표 이미지 - 멀티프로세싱과 멀티스레딩을 활용한병렬 처리 구현
Python · 프로그램개발

멀티프로세싱과 멀티스레딩을 활용한
병렬 처리 구현

파이썬으로 진짜 빠른 코드 짜는 법 🚀 GIL부터 실전 예제까지 다 알려줌

🙋 이런 분들한테 딱 맞는 글이에요! 파이썬 코드 짰는데 왜 이렇게 느리지? 싶었던 분 ㅋㅋㅋ
멀티스레딩이랑 멀티프로세싱 차이가 뭔지 헷갈리는 분
병렬 처리 개념은 알겠는데 실제로 어떻게 쓰는지 모르는 분

이 글 다 읽으면 진짜 실무에서 바로 쓸 수 있는 수준까지 올라갑니다 💪
처리 방식 비교 한눈에 보기 순차 처리 (Sequential) 작업 1 실행 중... 작업 2 대기 중 작업 3 대기 중 작업 4 대기 중 ⏱ 총 시간 = 작업1+2+3+4 느림 😢 멀티스레딩 (Threading) 스레드1 작업1 작업2 스레드2 작업3 작업4 ⚠ GIL로 인해 CPU 작업은 실제 동시 실행 제한됨 ⏱ I/O 작업에 효과적 보통 빠름 🙂 멀티프로세싱 (Multiprocessing) 프로세스1 작업1 작업2 프로세스2 작업3 작업4 ✅ 각 프로세스 독립 메모리 진짜 병렬 실행 가능! ⏱ CPU 집약 작업에 최강 매우 빠름 🚀

🤔 잠깐, 병렬 처리가 뭔데?

솔직히 처음 들으면 좀 어렵게 느껴지는 개념이잖아요 ㅋㅋㅋ
근데 생각해보면 우리 일상에서 이미 다 쓰고 있는 개념이에요.

예를 들어볼게요. 라면 끓이면서 계란 삶고, 동시에 김치 꺼내는 거 있잖아요?
그게 바로 병렬 처리예요. 여러 작업을 동시에 처리하는 것!

반대로 라면 다 끓이고 → 계란 삶고 → 그다음에 김치 꺼내면?
그건 순차 처리(Sequential Processing)고요.

컴퓨터도 마찬가지예요. CPU 코어가 여러 개인데 하나만 쓰면 너무 아깝잖아요?
그래서 나온 개념이 바로 멀티프로세싱이랑 멀티스레딩이에요!

💡 핵심 용어 정리 먼저!
프로세스(Process) : 운영체제에서 독립적으로 실행되는 프로그램 단위. 각자 독립된 메모리 공간을 가짐
스레드(Thread) : 프로세스 내에서 실행되는 작업 단위. 같은 프로세스 내 메모리를 공유함
병렬 처리(Parallel Processing) : 여러 작업을 물리적으로 동시에 실행
동시성(Concurrency) : 여러 작업이 번갈아가며 실행되어 동시처럼 보이는 것

😱 파이썬의 GIL 문제 - 이게 진짜 핵심임

파이썬 멀티스레딩 얘기할 때 절대 빠질 수 없는 게 바로 GIL(Global Interpreter Lock)이에요.
이거 모르면 나중에 "왜 멀티스레딩 써도 안 빨라지지??" 하고 멘붕 옵니다 ㅋㅋㅋ

GIL이 뭐냐면요, CPython(우리가 보통 쓰는 파이썬 인터프리터)에서
한 번에 하나의 스레드만 파이썬 바이트코드를 실행할 수 있도록 제한하는 잠금 장치예요.

즉, 스레드를 10개 만들어도 실제로 CPU 연산은 한 번에 하나씩만 된다는 거죠.
"어? 그럼 멀티스레딩 쓸 이유가 없는 거 아니야?" 라고 생각할 수 있는데,
그건 반만 맞는 말이에요!

⚠️ GIL의 영향 범위
GIL이 문제가 되는 경우 : CPU 집약적 작업 (수학 계산, 이미지 처리, 데이터 분석 등)
→ 이 경우엔 멀티스레딩 써도 오히려 오버헤드 때문에 더 느려질 수 있음 😱

GIL이 별로 문제 안 되는 경우 : I/O 집약적 작업 (파일 읽기/쓰기, 네트워크 요청, DB 쿼리 등)
→ I/O 대기 중엔 GIL이 해제되어 다른 스레드가 실행 가능! 멀티스레딩 효과 있음 ✅
GIL 동작 원리 시각화 CPU 집약 작업 (멀티스레딩 비효율) 스레드 1 GIL 보유 · 실행 대기 GIL 보유 · 실행 스레드 2 GIL 대기 중... 실행 GIL 대기 중... → 실제로는 번갈아가며 실행 (진짜 동시 실행 아님!) I/O 집약 작업 (멀티스레딩 효율적) 스레드 1 실행 I/O 대기(GIL해제) 실행 I/O 대기 스레드 2 대기 GIL 획득·실행! 대기 실행! → I/O 대기 중 다른 스레드 실행! 효율 UP ✅ 📌 결론: 언제 뭘 써야 할까? CPU 집약 작업 → 멀티프로세싱 사용! I/O 집약 작업 → 멀티스레딩 사용! 비동기 I/O → asyncio 사용!

🧵 멀티스레딩 실전 구현 - threading 모듈

자 이제 진짜 코드 들어갑니다! 먼저 멀티스레딩부터 볼게요.
파이썬에서는 threading 모듈을 사용해요.

가장 기본적인 사용법부터 시작해서 점점 심화로 가볼게요 ㅋㅋㅋ

📌 기본 스레드 생성 방법
Python
import threading
import time

def worker(name, delay):
    """스레드에서 실행될 함수"""
    print(f"[{name}] 작업 시작!")
    time.sleep(delay)  # I/O 작업 시뮬레이션
    print(f"[{name}] 작업 완료! ({delay}초 걸림)")

# 순차 처리 (비교용)
start = time.time()
worker("작업1", 2)
worker("작업2", 2)
worker("작업3", 2)
print(f"순차 처리 총 시간: {time.time() - start:.2f}초")  # 약 6초

# 멀티스레딩 처리
start = time.time()
threads = []
for i in range(1, 4):
    t = threading.Thread(target=worker, args=(f"작업{i}", 2))
    threads.append(t)
    t.start()  # 스레드 시작!

for t in threads:
    t.join()  # 모든 스레드 완료 대기

print(f"멀티스레딩 총 시간: {time.time() - start:.2f}초")  # 약 2초!

t.join()이 중요한데요, 이게 없으면 메인 스레드가 자식 스레드 끝나기 전에
혼자 먼저 종료해버릴 수 있어요. 꼭 써줘야 해요!

위 예제에서 순차 처리는 6초, 멀티스레딩은 약 2초 걸려요.
왜냐면 time.sleep()이 I/O 대기를 시뮬레이션하는 거라서 GIL이 해제되거든요 ㅎㅎ

📌 Thread 클래스 상속 방법 (더 객체지향적!)
Python
import threading
import time

class DownloadThread(threading.Thread):
    def __init__(self, url, name):
        super().__init__()
        self.url = url
        self.name = name
        self.result = None
    
    def run(self):
        """스레드 실행 시 자동으로 호출되는 메서드"""
        print(f"[{self.name}] {self.url} 다운로드 시작...")
        time.sleep(2)  # 실제로는 requests.get(self.url) 같은 코드
        self.result = f"{self.url} 다운로드 완료!"
        print(f"[{self.name}] 완료!")

# 스레드 생성 및 실행
threads = [
    DownloadThread("https://example.com/file1.zip", "다운로더1"),
    DownloadThread("https://example.com/file2.zip", "다운로더2"),
    DownloadThread("https://example.com/file3.zip", "다운로더3"),
]

for t in threads:
    t.start()

for t in threads:
    t.join()
    print(f"결과: {t.result}")

🔒 스레드 동기화 - 이거 모르면 버그 폭탄 맞음

여러 스레드가 같은 데이터에 동시에 접근하면 어떻게 될까요?
바로 경쟁 조건(Race Condition)이 발생해요!
이게 진짜 찾기 어려운 버그를 만들어내는 주범이에요 ㅠㅠ

🚨 경쟁 조건 예시 - 이런 코드 쓰면 안 됨!
Python (잘못된 예시)
import threading

counter = 0  # 공유 변수

def increment():
    global counter
    for _ in range(100000):
        counter += 1  # 이 연산은 원자적이지 않음!

threads = [threading.Thread(target=increment) for _ in range(5)]
for t in threads: t.start()
for t in threads: t.join()

print(f"예상값: 500000, 실제값: {counter}")
# 실제값이 매번 다르게 나옴! 😱
📌 Lock으로 동기화 해결!
Python
import threading

counter = 0
lock = threading.Lock()  # 잠금 객체 생성

def increment_safe():
    global counter
    for _ in range(100000):
        with lock:  # 잠금 획득 (자동으로 해제됨)
            counter += 1

threads = [threading.Thread(target=increment_safe) for _ in range(5)]
for t in threads: t.start()
for t in threads: t.join()

print(f"예상값: 500000, 실제값: {counter}")
# 이제 항상 500000 출력! ✅

with lock: 구문을 쓰면 블록 진입 시 자동으로 잠금을 획득하고,
블록 종료 시 자동으로 해제해줘요. 예외가 발생해도 안전하게 해제됩니다!

Lock 외에도 다양한 동기화 도구들이 있어요:

동기화 도구 설명 사용 상황
threading.Lock 기본 잠금. 한 번에 하나의 스레드만 접근 단순 공유 자원 보호
threading.RLock 재진입 가능한 잠금. 같은 스레드가 여러 번 획득 가능 재귀 함수에서 잠금 필요 시
threading.Semaphore N개의 스레드까지 동시 접근 허용 동시 접속 수 제한 (DB 커넥션 풀 등)
threading.Event 스레드 간 신호 전달 특정 조건 충족 시 스레드 깨우기
threading.Condition 조건 변수. wait/notify로 스레드 협력 생산자-소비자 패턴
📌 Semaphore 실전 예제 - 동시 요청 수 제한
Python
import threading
import time

# 최대 3개의 스레드만 동시에 실행 허용
semaphore = threading.Semaphore(3)

def api_request(request_id):
    with semaphore:  # 세마포어 획득 (3개 초과 시 대기)
        print(f"요청 {request_id}: API 호출 중...")
        time.sleep(1)  # API 응답 대기 시뮬레이션
        print(f"요청 {request_id}: 완료!")

# 10개의 요청을 동시에 보내지만 최대 3개만 동시 실행
threads = [threading.Thread(target=api_request, args=(i,)) for i in range(10)]
for t in threads: t.start()
for t in threads: t.join()

⚡ ThreadPoolExecutor - 이게 진짜 실무에서 많이 씀

스레드를 직접 만들고 관리하는 건 좀 번거롭잖아요?
그래서 파이썬 3.2부터 concurrent.futures 모듈이 생겼어요!
ThreadPoolExecutor를 쓰면 스레드 풀을 쉽게 관리할 수 있어요 ㅎㅎ

📌 ThreadPoolExecutor 기본 사용법
Python
from concurrent.futures import ThreadPoolExecutor, as_completed
import time

def fetch_data(url):
    """URL에서 데이터 가져오기 시뮬레이션"""
    time.sleep(1)  # 네트워크 요청 시뮬레이션
    return f"{url}의 데이터"

urls = [f"https://api.example.com/data/{i}" for i in range(10)]

# 방법 1: map() 사용 - 순서 보장
with ThreadPoolExecutor(max_workers=5) as executor:
    results = list(executor.map(fetch_data, urls))
    print("모든 결과:", results)

# 방법 2: submit() + as_completed() - 완료 순서대로 처리
with ThreadPoolExecutor(max_workers=5) as executor:
    future_to_url = {executor.submit(fetch_data, url): url for url in urls}
    
    for future in as_completed(future_to_url):
        url = future_to_url[future]
        try:
            result = future.result()
            print(f"완료: {url} → {result}")
        except Exception as e:
            print(f"오류 발생: {url} → {e}")

as_completed()는 완료된 순서대로 결과를 반환해줘요.
빠른 작업 결과를 먼저 처리하고 싶을 때 유용해요!

with 블록을 벗어나면 자동으로 모든 스레드가 완료될 때까지 기다려줘요.
join() 안 써도 되니까 훨씬 편하죠 ㅋㅋㅋ


🚀 멀티프로세싱 실전 구현 - multiprocessing 모듈

이제 진짜 CPU 코어를 여러 개 쓰는 멀티프로세싱이에요!
CPU 집약적인 작업 (이미지 처리, 데이터 분석, 머신러닝 전처리 등)에서
진짜 위력을 발휘해요 💪

API는 threading이랑 거의 비슷해서 배우기 쉬워요!

📌 기본 프로세스 생성
Python
import multiprocessing
import time
import os

def cpu_heavy_task(n):
    """CPU 집약적 작업 시뮬레이션"""
    print(f"프로세스 ID: {os.getpid()}, 작업 {n} 시작")
    result = sum(i * i for i in range(10**7))  # 무거운 계산
    print(f"프로세스 ID: {os.getpid()}, 작업 {n} 완료")
    return result

if __name__ == "__main__":  # 이 부분 중요!! Windows에서 필수!
    # 순차 처리
    start = time.time()
    results = [cpu_heavy_task(i) for i in range(4)]
    print(f"순차 처리: {time.time() - start:.2f}초")
    
    # 멀티프로세싱
    start = time.time()
    processes = []
    for i in range(4):
        p = multiprocessing.Process(target=cpu_heavy_task, args=(i,))
        processes.append(p)
        p.start()
    
    for p in processes:
        p.join()
    
    print(f"멀티프로세싱: {time.time() - start:.2f}초")
    # CPU 코어 수에 따라 4배 가까이 빨라질 수 있음!
💡 if __name__ == "__main__": 왜 필수인가요?
Windows에서 멀티프로세싱은 새 프로세스를 생성할 때 현재 스크립트를 다시 import해요.
이 가드가 없으면 무한히 새 프로세스를 생성하는 무한 루프에 빠져버려요 😱
Linux/Mac은 fork() 방식이라 상관없지만, 크로스 플랫폼 코드를 위해 항상 써주는 게 좋아요!

📦 프로세스 간 데이터 공유 - 이게 멀티프로세싱의 핵심 난이도

멀티스레딩은 메모리를 공유하지만, 멀티프로세싱은 각 프로세스가 독립된 메모리를 가져요.
그래서 데이터를 주고받으려면 특별한 방법이 필요해요!

📌 Queue로 프로세스 간 통신 (IPC)
Python
import multiprocessing
import time

def producer(queue, items):
    """데이터를 생성해서 큐에 넣는 프로세스"""
    for item in items:
        print(f"생산: {item}")
        queue.put(item)
        time.sleep(0.5)
    queue.put(None)  # 종료 신호

def consumer(queue):
    """큐에서 데이터를 꺼내 처리하는 프로세스"""
    while True:
        item = queue.get()
        if item is None:  # 종료 신호 받으면 종료
            break
        print(f"소비: {item} 처리 완료!")

if __name__ == "__main__":
    queue = multiprocessing.Queue()
    items = ["데이터1", "데이터2", "데이터3", "데이터4", "데이터5"]
    
    p1 = multiprocessing.Process(target=producer, args=(queue, items))
    p2 = multiprocessing.Process(target=consumer, args=(queue,))
    
    p1.start()
    p2.start()
    
    p1.join()
    p2.join()
    print("생산자-소비자 패턴 완료!")
📌 Value와 Array로 공유 메모리 사용
Python
import multiprocessing

def update_counter(counter, lock, n):
    for _ in range(n):
        with lock:
            counter.value += 1

if __name__ == "__main__":
    # 공유 메모리 변수 생성 (타입코드 'i' = 정수)
    counter = multiprocessing.Value('i', 0)
    lock = multiprocessing.Lock()
    
    processes = [
        multiprocessing.Process(target=update_counter, args=(counter, lock, 10000))
        for _ in range(4)
    ]
    
    for p in processes: p.start()
    for p in processes: p.join()
    
    print(f"최종 카운터: {counter.value}")  # 40000
    
    # 배열 공유
    shared_array = multiprocessing.Array('d', [1.0, 2.0, 3.0, 4.0, 5.0])
    print(f"공유 배열: {list(shared_array)}")

🏊 ProcessPoolExecutor - 멀티프로세싱도 풀로 편하게!

ThreadPoolExecutor처럼 프로세스도 풀로 관리할 수 있어요!
API가 완전히 동일해서 스레드 ↔ 프로세스 전환이 엄청 쉬워요 ㅋㅋㅋ

📌 ProcessPoolExecutor 실전 예제
Python
from concurrent.futures import ProcessPoolExecutor, as_completed
import time

def heavy_computation(n):
    """CPU 집약적 계산"""
    return sum(i ** 2 for i in range(n))

if __name__ == "__main__":
    numbers = [10**6, 2*10**6, 3*10**6, 4*10**6, 5*10**6]
    
    # 순차 처리
    start = time.time()
    results = [heavy_computation(n) for n in numbers]
    print(f"순차 처리: {time.time() - start:.2f}초")
    
    # ProcessPoolExecutor (CPU 코어 수만큼 자동 설정)
    start = time.time()
    with ProcessPoolExecutor() as executor:
        results = list(executor.map(heavy_computation, numbers))
    print(f"멀티프로세싱: {time.time() - start:.2f}초")
    
    # 결과 확인
    print(f"결과 샘플: {results[0]}")
💡 max_workers 설정 팁!
CPU 집약 작업 : max_workers = os.cpu_count() (코어 수와 동일하게)
I/O 집약 작업 : max_workers = os.cpu_count() * 2~5 (코어 수보다 많이 설정 가능)

I/O 작업은 대기 시간이 많아서 스레드가 코어 수보다 많아도 효율적이에요!
CPU 작업은 코어 수 이상으로 늘려봤자 오버헤드만 증가해요.
병렬 처리 방식 선택 플로우차트 작업 시작 CPU 집약적 작업인가요? YES NO multiprocessing 또는 ProcessPoolExecutor 동시 요청 많은가요? YES NO threading 또는 ThreadPoolExecutor 단순 순차 처리로도 충분합니다 ✅ 🎯 실무 선택 기준 요약 이미지/영상 처리 → ProcessPoolExecutor 웹 크롤링/API 호출 → ThreadPoolExecutor 대규모 비동기 I/O → asyncio + aiohttp ※ 실제 성능은 항상 프로파일링으로 측정 후 결정하세요!

🎯 실전 예제 1 - 웹 크롤링 (멀티스레딩)

실무에서 가장 많이 쓰는 케이스 중 하나예요!
여러 URL에서 데이터를 동시에 가져오는 거죠.
재능넷 같은 플랫폼에서도 여러 API를 동시에 호출할 때 이런 패턴을 써요 ㅎㅎ

📌 실전 웹 크롤링 - ThreadPoolExecutor + requests
Python
import requests
from concurrent.futures import ThreadPoolExecutor, as_completed
import time

def fetch_url(url):
    """URL에서 데이터 가져오기"""
    try:
        response = requests.get(url, timeout=10)
        response.raise_for_status()
        return {
            "url": url,
            "status": response.status_code,
            "length": len(response.content),
            "success": True
        }
    except requests.RequestException as e:
        return {
            "url": url,
            "error": str(e),
            "success": False
        }

def crawl_urls(urls, max_workers=10):
    """여러 URL을 병렬로 크롤링"""
    results = []
    
    with ThreadPoolExecutor(max_workers=max_workers) as executor:
        future_to_url = {executor.submit(fetch_url, url): url for url in urls}
        
        for future in as_completed(future_to_url):
            result = future.result()
            results.append(result)
            
            if result["success"]:
                print(f"✅ {result['url']}: {result['length']} bytes")
            else:
                print(f"❌ {result['url']}: {result['error']}")
    
    return results

if __name__ == "__main__":
    urls = [
        "https://httpbin.org/delay/1",
        "https://httpbin.org/delay/2",
        "https://httpbin.org/delay/1",
        "https://httpbin.org/get",
        "https://httpbin.org/ip",
    ]
    
    start = time.time()
    results = crawl_urls(urls, max_workers=5)
    elapsed = time.time() - start
    
    success_count = sum(1 for r in results if r["success"])
    print(f"\n완료! {success_count}/{len(urls)} 성공, 소요 시간: {elapsed:.2f}초")

🎯 실전 예제 2 - 이미지 처리 (멀티프로세싱)

이미지 처리는 CPU를 엄청 많이 써요.
수백 장의 이미지를 리사이즈하거나 필터 적용할 때 멀티프로세싱이 진짜 빛을 발해요!

📌 이미지 배치 처리 - ProcessPoolExecutor + Pillow
Python
from PIL import Image
from concurrent.futures import ProcessPoolExecutor
import os
import time
from pathlib import Path

def process_image(args):
    """이미지 처리 함수 (리사이즈 + 그레이스케일)"""
    input_path, output_path, size = args
    
    try:
        with Image.open(input_path) as img:
            # 리사이즈
            img_resized = img.resize(size, Image.LANCZOS)
            # 그레이스케일 변환
            img_gray = img_resized.convert('L')
            # 저장
            img_gray.save(output_path, quality=85)
        return f"✅ {input_path} → {output_path}"
    except Exception as e:
        return f"❌ {input_path}: {e}"

def batch_process_images(input_dir, output_dir, size=(800, 600)):
    """디렉토리 내 모든 이미지 병렬 처리"""
    input_path = Path(input_dir)
    output_path = Path(output_dir)
    output_path.mkdir(exist_ok=True)
    
    # 처리할 이미지 목록 생성
    image_files = list(input_path.glob("*.jpg")) + list(input_path.glob("*.png"))
    
    if not image_files:
        print("처리할 이미지가 없습니다!")
        return
    
    # 인자 리스트 생성
    args_list = [
        (str(img), str(output_path / img.name), size)
        for img in image_files
    ]
    
    print(f"총 {len(image_files)}개 이미지 처리 시작...")
    start = time.time()
    
    # CPU 코어 수만큼 프로세스 사용
    with ProcessPoolExecutor(max_workers=os.cpu_count()) as executor:
        results = list(executor.map(process_image, args_list))
    
    elapsed = time.time() - start
    
    for result in results:
        print(result)
    
    print(f"\n처리 완료! 소요 시간: {elapsed:.2f}초")
    print(f"평균 처리 속도: {len(image_files)/elapsed:.1f}장/초")

if __name__ == "__main__":
    batch_process_images("./input_images", "./output_images")

🎯 실전 예제 3 - 혼합 패턴 (멀티프로세싱 + 멀티스레딩)

실제 복잡한 시스템에서는 두 가지를 같이 쓰는 경우도 많아요!
예를 들어 여러 CPU 코어에서 각각 여러 I/O 작업을 처리하는 패턴이죠.
이걸 하이브리드 병렬 처리라고 해요.

📌 하이브리드 패턴 - 프로세스 내 스레드 풀
Python
import multiprocessing
from concurrent.futures import ThreadPoolExecutor
import time
import requests

def fetch_batch(urls):
    """하나의 프로세스에서 여러 URL을 스레드로 처리"""
    def fetch(url):
        try:
            # 실제로는 requests.get(url) 사용
            time.sleep(0.5)  # 네트워크 요청 시뮬레이션
            return f"{url}: 성공"
        except Exception as e:
            return f"{url}: 실패 - {e}"
    
    # 프로세스 내에서 스레드 풀 사용
    with ThreadPoolExecutor(max_workers=5) as executor:
        results = list(executor.map(fetch, urls))
    return results

def split_list(lst, n):
    """리스트를 n개로 균등 분할"""
    k, m = divmod(len(lst), n)
    return [lst[i*k+min(i,m):(i+1)*k+min(i+1,m)] for i in range(n)]

if __name__ == "__main__":
    # 100개의 URL 처리
    all_urls = [f"https://api.example.com/item/{i}" for i in range(100)]
    
    cpu_count = multiprocessing.cpu_count()
    url_batches = split_list(all_urls, cpu_count)
    
    print(f"CPU 코어: {cpu_count}개, 배치당 URL: {len(url_batches[0])}개")
    
    start = time.time()
    
    with multiprocessing.Pool(processes=cpu_count) as pool:
        all_results = pool.map(fetch_batch, url_batches)
    
    # 결과 평탄화
    flat_results = [item for batch in all_results for item in batch]
    
    print(f"총 {len(flat_results)}개 처리 완료!")
    print(f"소요 시간: {time.time() - start:.2f}초")

📊 성능 비교 및 프로파일링

"그래서 얼마나 빨라지는데?" 라는 질문에 답하려면 직접 측정해봐야 해요!
무작정 병렬화한다고 다 빨라지는 게 아니거든요 ㅋㅋㅋ
오버헤드가 더 클 수도 있어요!

📌 성능 측정 유틸리티
Python
import time
import functools
from concurrent.futures import ThreadPoolExecutor, ProcessPoolExecutor
import os

def benchmark(func, *args, **kwargs):
    """함수 실행 시간 측정 데코레이터"""
    start = time.perf_counter()
    result = func(*args, **kwargs)
    elapsed = time.perf_counter() - start
    return result, elapsed

def compare_approaches(task_func, data_list, label="작업"):
    """순차, 멀티스레딩, 멀티프로세싱 성능 비교"""
    print(f"\n{'='*50}")
    print(f"📊 {label} 성능 비교 ({len(data_list)}개 항목)")
    print(f"{'='*50}")
    
    # 1. 순차 처리
    _, seq_time = benchmark(lambda: [task_func(d) for d in data_list])
    print(f"순차 처리:        {seq_time:.3f}초")
    
    # 2. 멀티스레딩
    def thread_run():
        with ThreadPoolExecutor(max_workers=os.cpu_count()*2) as ex:
            return list(ex.map(task_func, data_list))
    _, thread_time = benchmark(thread_run)
    print(f"멀티스레딩:       {thread_time:.3f}초 (속도향상: {seq_time/thread_time:.2f}x)")
    
    # 3. 멀티프로세싱
    def process_run():
        with ProcessPoolExecutor(max_workers=os.cpu_count()) as ex:
            return list(ex.map(task_func, data_list))
    _, proc_time = benchmark(process_run)
    print(f"멀티프로세싱:     {proc_time:.3f}초 (속도향상: {seq_time/proc_time:.2f}x)")
    
    print(f"\n🏆 최적 방법: ", end="")
    times = {"순차": seq_time, "스레딩": thread_time, "프로세싱": proc_time}
    best = min(times, key=times.get)
    print(f"{best} ({times[best]:.3f}초)")

# CPU 집약 작업 테스트
def cpu_task(n):
    return sum(i**2 for i in range(n))

# I/O 집약 작업 테스트
def io_task(n):
    time.sleep(0.1)
    return n * 2

if __name__ == "__main__":
    compare_approaches(cpu_task, [500000]*8, "CPU 집약 작업")
    compare_approaches(io_task, list(range(20)), "I/O 집약 작업")

⚠️ 자주 하는 실수들 - 이것만 피해도 반은 성공

🚨 실수 1: 너무 많은 스레드/프로세스 생성
스레드/프로세스 생성 자체에도 비용이 들어요.
1000개 작업에 1000개 스레드 만들면 오히려 더 느려질 수 있어요!
→ 해결책: 풀(Pool)을 사용하고 적절한 max_workers 설정
🚨 실수 2: 멀티프로세싱에서 람다/중첩 함수 사용
multiprocessing은 함수를 pickle로 직렬화해서 전달해요.
람다나 중첩 함수는 pickle이 안 돼서 오류가 발생해요!
→ 해결책: 모듈 레벨의 일반 함수 사용
🚨 실수 3: 데드락(Deadlock)
두 스레드가 서로 상대방의 잠금이 해제되길 기다리는 상황이에요.
→ 해결책: 잠금 획득 순서를 일관되게 유지, timeout 설정, RLock 사용
🚨 실수 4: 공유 상태 없이 결과 수집
멀티프로세싱에서 전역 변수에 결과를 저장하면 메인 프로세스에 반영 안 돼요!
→ 해결책: return 값 사용, Queue, Manager 사용
💡 베스트 프랙티스 정리
✅ 항상 if __name__ == "__main__": 가드 사용 (멀티프로세싱)
✅ 풀(Pool/Executor) 사용으로 리소스 관리
✅ with 구문으로 자동 정리
✅ 예외 처리 반드시 포함
✅ 실제 성능은 프로파일링으로 측정 후 결정
✅ 작은 작업은 오버헤드 때문에 순차 처리가 더 빠를 수 있음
✅ 공유 상태 최소화 (불변 데이터 선호)

🔥 Python 3.12+ 새로운 기능 - GIL 드디어 없어진다?!

이거 진짜 핫한 소식인데요 ㅋㅋㅋ
Python 3.12에서 PEP 703 - Making the Global Interpreter Lock Optional이 승인됐어요!

Python 3.13부터는 실험적으로 GIL 없는 빌드를 사용할 수 있어요.
python3.13t (t = free-threaded) 빌드를 쓰면 GIL 없이 진짜 멀티스레딩이 가능해요!

아직 실험적 단계라 프로덕션에 바로 쓰기는 좀 이르지만,
앞으로 파이썬 멀티스레딩의 판도가 완전히 바뀔 수도 있어요!

💡 Python 3.13 free-threaded 빌드 사용법
Shell
# GIL 비활성화 확인
import sys
print(sys._is_gil_enabled())  # False면 GIL 비활성화됨

# 환경 변수로 GIL 비활성화 (Python 3.13+)
# PYTHON_GIL=0 python3.13t your_script.py

🎓 마무리 - 어떤 걸 선택해야 할까?

지금까지 멀티스레딩이랑 멀티프로세싱에 대해 엄청 많이 알아봤는데요!
마지막으로 실무에서 바로 쓸 수 있는 의사결정 가이드를 정리해드릴게요 😊

재능넷에서 파이썬 관련 프리랜서 작업을 하거나 의뢰할 때도
이런 병렬 처리 개념을 알고 있으면 훨씬 더 효율적인 코드를 만들 수 있어요!

상황 추천 방법 이유
웹 크롤링, API 다중 호출 ThreadPoolExecutor I/O 대기 중 GIL 해제, 가벼운 오버헤드
이미지/영상 처리 ProcessPoolExecutor CPU 집약, 진짜 병렬 실행 필요
데이터 분석 (pandas, numpy) ProcessPoolExecutor CPU 집약, 대용량 데이터 분할 처리
DB 다중 쿼리 ThreadPoolExecutor I/O 집약, 커넥션 풀과 함께 사용
실시간 채팅/소켓 asyncio 이벤트 루프 기반, 수천 개 연결 처리
머신러닝 데이터 전처리 ProcessPoolExecutor CPU 집약, 독립적 배치 처리
파일 시스템 작업 ThreadPoolExecutor I/O 집약, 간단한 구현
🎉 🚀 💪 ✅ 🔥
📝 핵심 요약
GIL : CPython의 전역 잠금. CPU 작업에서 멀티스레딩 효과 제한
멀티스레딩 : I/O 집약 작업에 효과적. threading, ThreadPoolExecutor 사용
멀티프로세싱 : CPU 집약 작업에 효과적. multiprocessing, ProcessPoolExecutor 사용
동기화 : Lock, Semaphore, Event 등으로 공유 자원 보호
IPC : Queue, Pipe, Value, Array로 프로세스 간 통신
성능 측정 : 항상 프로파일링으로 실제 효과 확인 후 적용

threading multiprocessing concurrent.futures GIL ThreadPoolExecutor ProcessPoolExecutor Lock Queue 병렬처리 Python
댓글 작성

이 글에 대한 여러분의 생각을 들려주세요

댓글 0
댓글을 불러오는 중입니다.

아직 댓글이 없습니다.