프로그래밍/Python

병행 및 병렬 처리: 스레딩, 멀티프로세싱, 그리고 async/await

shimdh 2025. 2. 22. 09:12
728x90

1. 스레딩 (Threading)

1.1 스레드란 무엇인가?

스레드는 프로세스 내에서 실행되는 경량의 실행 단위입니다. 하나의 프로세스가 여러 개의 스레드를 가질 수 있으며, 각 스레드는 독립적으로 코드 블록을 실행합니다. 스레드는 메모리 공간을 공유하므로 데이터와 자원에 대한 접근이 빠르지만, 동기화 문제를 유발할 수 있습니다.

1.2 스레딩을 사용하는 이유

  • 효율성 향상: I/O 작업(파일 읽기/쓰기, 네트워크 요청 등)과 같은 시간이 많이 소요되는 작업은 다른 스레드에서 비동기로 처리하여 CPU 사용률을 높일 수 있습니다.
  • 응답성 개선: GUI 애플리케이션에서는 사용자 인터페이스가 멈추지 않도록 배경에서 긴 작업을 수행하기 위해 스레드를 사용할 수 있습니다.
  • 자원 공유: 스레드는 메모리 공간을 공유하므로 데이터를 빠르게 교환할 수 있습니다.

1.3 Python에서의 스레딩 사용법

Python에서는 threading 모듈을 사용하여 쉽게 스레드를 생성하고 관리할 수 있습니다. 스레드는 Thread 클래스를 통해 생성되며, start() 메서드로 실행하고 join() 메서드로 종료를 기다릴 수 있습니다.

예제: 기본적인 스레드 생성

import threading
import time

def worker(thread_name):
    print(f"{thread_name} 시작")
    time.sleep(2)
    print(f"{thread_name} 종료")

# 두 개의 스레드 생성
thread1 = threading.Thread(target=worker, args=("스레드 1",))
thread2 = threading.Thread(target=worker, args=("스레드 2",))

# 시작
thread1.start()
thread2.start()

# 완료 대기
thread1.join()
thread2.join()

print("모든 작업 완료")

1.4 동기화 문제 해결하기

여러 개의 스레드가 동시에 공유 자원에 접근하면 경쟁 조건(race condition)이 발생할 수 있습니다. 이를 방지하기 위해 Lock 객체를 사용하여 한 번에 하나의 스레드만 특정 코드 블록이나 데이터를 수정하도록 제한할 수 있습니다.

예제: Lock 사용

import threading

lock = threading.Lock()
counter = 0

def increment():
    global counter
    for _ in range(100000):
        lock.acquire()   # Lock 획득
        counter += 1     # 공유 자원 수정
        lock.release()   # Lock 해제

threads = []
for i in range(5):
    thread = threading.Thread(target=increment)
    threads.append(thread)
    thread.start()

for thread in threads:
    thread.join()

print(f"최종 카운터 값: {counter}")

1.5 스레딩의 한계

Python의 스레딩은 GIL(Global Interpreter Lock)로 인해 CPU 집약적인 작업에서는 병렬 처리가 제한됩니다. GIL은 한 번에 하나의 스레드만 Python 코드를 실행할 수 있도록 제한하기 때문에, CPU 집약적인 작업에서는 멀티프로세싱을 사용하는 것이 더 효과적입니다.


2. 멀티프로세싱 (Multiprocessing)

2.1 멀티프로세싱의 개요

멀티프로세싱은 Python에서 제공하는 모듈 중 하나로, 여러 프로세스를 생성하여 CPU의 여러 코어를 활용할 수 있게 해줍니다. 이는 특히 CPU 집약적인 작업을 수행할 때 유용합니다. 멀티스레딩과 달리 각 프로세스는 독립된 메모리 공간을 가지기 때문에 GIL의 영향을 받지 않습니다.

2.2 멀티프로세싱 사용 예시

기본 사용법

import multiprocessing
import time

def worker(num):
    print(f'Worker {num} 시작')
    time.sleep(2)
    print(f'Worker {num} 종료')

if __name__ == '__main__':
    processes = []
    for i in range(5):  # 5개의 워커 프로세스 생성
        p = multiprocessing.Process(target=worker, args=(i,))
        processes.append(p)
        p.start()

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

공유 데이터와 큐

멀티프로세싱 환경에서 데이터를 공유해야 할 경우 Queue를 사용할 수 있습니다. Queue는 프로세스 간 안전하게 데이터를 교환할 수 있는 방법을 제공합니다.

from multiprocessing import Process, Queue

def square_numbers(queue):
    for i in range(10):
        queue.put(i * i)

if __name__ == '__main__':
    queue = Queue()
    process = Process(target=square_numbers, args=(queue,))

    process.start()

    process.join()

    while not queue.empty():
        print(queue.get())  # 큐에서 결과 가져오기

풀(Pool) 사용하기

Pool 클래스를 이용하면 간편하게 다수의 프로세스를 관리할 수 있습니다. Pool은 작업을 자동으로 분배하고 결과를 수집하는 데 유용합니다.

from multiprocessing import Pool

def cube(x):
    return x * x * x

if __name__ == '__main__':
    with Pool(processes=4) as pool:  # 최대 4개의 프로세스를 사용할 풀 생성
        results = pool.map(cube, range(10))

    print(results)  # [0, 1, 8, ..., 729]

2.3 멀티프로세싱의 장단점

  • 장점:
    • GIL의 영향을 받지 않아 CPU 집약적인 작업에 적합합니다.
    • 각 프로세스는 독립된 메모리 공간을 가지므로 안정성이 높습니다.
  • 단점:
    • 프로세스 생성 및 관리에 추가적인 오버헤드가 발생합니다.
    • 메모리 사용량이 높아질 수 있습니다.

3. async/await

3.1 비동기 프로그래밍의 기본 개념

비동기 프로그래밍은 프로그램이 특정 작업(예: 파일 읽기, 네트워크 요청 등)을 기다리는 동안 다른 작업을 동시에 수행할 수 있도록 합니다. 이를 통해 CPU 자원을 효율적으로 사용할 수 있으며, 대기 시간 동안 프로그램이 멈추지 않도록 할 수 있습니다.

3.2 async와 await의 역할

  • async: 함수 앞에 async 키워드를 붙이면 해당 함수가 비동기로 작동함을 나타냅니다. 이 함수는 항상 코루틴(coroutine)으로 반환됩니다.
  • await: await 키워드는 비동기 함수를 호출할 때 사용되며, 해당 호출이 완료될 때까지 기다립니다. 이때 다른 코루틴이나 태스크를 실행할 수 있습니다.

3.3 예제 코드

다음은 간단한 웹 페이지에서 데이터를 가져오는 예제입니다.

import asyncio
import aiohttp

async def fetch_data(url):
    async with aiohttp.ClientSession() as session:
        async with session.get(url) as response:
            return await response.text()

async def main():
    urls = [
        'https://example.com',
        'https://example.org',
        'https://example.net'
    ]

    tasks = [fetch_data(url) for url in urls]

    # 모든 태스크를 동시에 실행하고 결과를 기다림
    results = await asyncio.gather(*tasks)

    for result in results:
        print(result[:100])  # 각 응답의 처음 100자 출력하기

# 이벤트 루프 실행
if __name__ == '__main__':
    asyncio.run(main())

3.4 장점과 활용 사례

  • 장점:
    • I/O 바운드 작업(네트워크 요청 등)을 효율적으로 처리하여 성능 향상.
    • 코드가 더욱 직관적이고 깔끔해짐 (콜백 지옥 방지).
  • 활용 사례:
    • 웹 크롤러나 스크래퍼.
    • 실시간 데이터 스트리밍 애플리케이션.
    • 대량의 API 요청 처리 시 유용하게 사용됨.

4. 결론 및 추가 고려 사항

4.1 결론

Python에서 병행 및 병렬 처리를 구현하는 방법은 여러 가지가 있으며, 각 방법은 특정 상황에 적합합니다. 스레딩은 I/O 바운드 작업에 적합하며, 멀티프로세싱은 CPU 집약적인 작업에 유용합니다. async/await는 비동기 프로그래밍을 통해 I/O 바운드 작업을 효율적으로 처리할 수 있게 해줍니다. 각 방법의 특징을 이해하고 상황에 맞게 활용하면, 더 나은 성능과 유지보수성을 갖춘 애플리케이션을 개발할 수 있습니다.

4.2 추가 고려 사항

  • 스레딩과 멀티프로세싱의 선택 기준:
    • 스레딩은 I/O 바운드 작업에 적합하며, 멀티프로세싱은 CPU 집약적인 작업에 적합합니다.
  • async/await의 활용 전략:
    • I/O 바운드 작업에 적합하며, 이벤트 루프를 효율적으로 관리할 수 있습니다.
  • 성능 최적화를 위한 팁:
    • 프로파일링 도구를 사용하여 성능 병목 현상을 식별하고, 적절한 동시성 모델을 선택하여 성능을 최적화할 수 있습니다.

마무리

병행 및 병렬 처리는 현대 프로그래밍에서 필수적인 기술입니다. Python은 스레딩, 멀티프로세싱, async/await와 같은 다양한 도구를 제공하여 개발자가 다양한 상황에 맞는 최적의 솔루션을 선택할 수 있도록 합니다. 이번 포스트를 통해 각 방법의 특징과 사용 사례를 이해하고, 실제 프로젝트에 적용해 보시길 바랍니다. 더 나아가, 이러한 기술들을 혼합하여 사용함으로써 더욱 강력하고 효율적인 애플리케이션을 개발할 수 있을 것입니다.

이제 여러분의 프로젝트에 병행 및 병렬 처리를 적용하여 성능을 극대화해 보세요!


5. 추가 예제 및 활용 사례

5.1 스레딩 활용 예제: 파일 다운로드

스레딩을 사용하여 여러 파일을 동시에 다운로드하는 예제입니다.

import threading
import requests

def download_file(url, filename):
    print(f"{filename} 다운로드 시작")
    response = requests.get(url)
    with open(filename, 'wb') as file:
        file.write(response.content)
    print(f"{filename} 다운로드 완료")

urls = [
    ('https://example.com/file1.zip', 'file1.zip'),
    ('https://example.com/file2.zip', 'file2.zip'),
    ('https://example.com/file3.zip', 'file3.zip')
]

threads = []
for url, filename in urls:
    thread = threading.Thread(target=download_file, args=(url, filename))
    threads.append(thread)
    thread.start()

for thread in threads:
    thread.join()

print("모든 파일 다운로드 완료")

5.2 멀티프로세싱 활용 예제: 이미지 처리

멀티프로세싱을 사용하여 여러 이미지를 동시에 처리하는 예제입니다.

from multiprocessing import Pool
from PIL import Image, ImageFilter

def process_image(image_path):
    print(f"{image_path} 처리 시작")
    image = Image.open(image_path)
    image = image.filter(ImageFilter.BLUR)
    image.save(f"processed_{image_path}")
    print(f"{image_path} 처리 완료")

if __name__ == '__main__':
    image_paths = ['image1.jpg', 'image2.jpg', 'image3.jpg']
    with Pool(processes=3) as pool:
        pool.map(process_image, image_paths)

    print("모든 이미지 처리 완료")

5.3 async/await 활용 예제: 비동기 파일 읽기

async/await를 사용하여 여러 파일을 비동기적으로 읽는 예제입니다.

import asyncio

async def read_file(file_path):
    print(f"{file_path} 읽기 시작")
    with open(file_path, 'r') as file:
        content = await file.read()
    print(f"{file_path} 읽기 완료")
    return content

async def main():
    file_paths = ['file1.txt', 'file2.txt', 'file3.txt']
    tasks = [read_file(file_path) for file_path in file_paths]
    results = await asyncio.gather(*tasks)
    for result in results:
        print(result[:100])  # 각 파일의 처음 100자 출력하기

if __name__ == '__main__':
    asyncio.run(main())

이러한 예제들을 통해 각 방법의 실제 활용 사례를 확인하고, 프로젝트에 적용해 보시길 바랍니다. 병행 및 병렬 처리를 통해 더욱 효율적이고 강력한 애플리케이션을 개발할 수 있습니다!

728x90