Python 작업에 적합한 동시성 모델 선택하기
Daniel Hayes
Full-Stack Engineer · Leapcell

소개
소프트웨어 개발 세계에서 응답성과 효율성은 무엇보다 중요합니다. 웹 서버를 구축하든, 대규모 데이터셋을 처리하든, 인터넷에서 정보를 스크래핑하든, 애플리케이션이 여러 작업을 동시에 처리할 수 있는 능력은 성능과 사용자 경험에 상당한 영향을 미칠 수 있습니다. Python은 풍부한 생태계를 통해 여러 강력한 동시성 모델을 제공합니다: multiprocessing
, threading
, asyncio
. 각각의 미묘한 차이를 이해하고, 더 중요하게는 언제 어떤 것을 선택해야 하는지를 아는 것은 고성능 애플리케이션을 작성하고자 하는 모든 Python 개발자에게 중요한 기술입니다. 이 글에서는 이러한 동시성 모델을 명확히 하고, 원리를 안내하며, 특정 사용 사례에 대한 정보에 입각한 결정을 내릴 수 있도록 돕겠습니다.
동시성의 핵심 개념
각 모델의 구체적인 내용을 살펴보기 전에 Python에서 동시성을 뒷받침하는 몇 가지 근본적인 개념에 대해 명확히 이해해 봅시다.
동시성 vs 병렬성: 동시성은 여러 작업을 동시에 처리하는 것이고, 병렬성은 여러 작업을 동시에 수행하는 것입니다. 싱글 코어 CPU는 빠른 작업 전환(컨텍스트 스위칭)을 통해 동시적일 수 있으며, 동시 실행의 환상을 줍니다. 반면에 병렬성은 진정한 동시 실행을 위해 여러 처리 장치(CPU 코어)를 필요로 합니다.
CPU 바운드 vs I/O 바운드 작업:
- CPU 바운드 작업은 대부분의 시간을 계산 수행에 소비하고 CPU 속도에 의해 제한되는 작업입니다. 예로는 높은 수준의 수학 계산, 이미지 처리 또는 데이터 압축 등이 있습니다.
- I/O 바운드 작업은 네트워크 요청, 디스크 읽기/쓰기 또는 데이터베이스 쿼리와 같이 외부 리소스의 응답을 기다리는 데 대부분의 시간을 소비하는 작업입니다. 이 대기 시간 동안 CPU는 대체로 유휴 상태입니다.
전역 인터프리터 잠금 (GIL): GIL은 Python 객체에 대한 접근을 보호하는 뮤텍스로, 여러 네이티브 스레드가 동시에 Python 바이트코드를 실행하는 것을 방지합니다. 이는 멀티코어 프로세서에서도 한 번에 하나의 스레드만 Python 바이트코드를 실행할 수 있음을 의미합니다. GIL은 C 확장 개발 및 메모리 관리를 단순화하지만, 단일 Python 프로세스 내에서 CPU 바운드 작업에 대한 진정한 병렬성을 제한합니다.
스레딩: 공유 메모리를 사용한 동시성
threading
을 사용하면 단일 프로세스 내에서 프로그램의 여러 부분을 동시에 실행할 수 있습니다. 스레드는 동일한 메모리 공간을 공유하므로 데이터 공유가 쉽지만, 신중하게 관리하지 않으면 경합 상태와 데드락과 같은 잠재적인 문제도 발생할 수 있습니다.
작동 방식
새로운 스레드를 생성하면 메인 스레드와 동시에 별도의 함수를 실행합니다. 운영 체제가 이러한 스레드의 스케줄링을 관리합니다.
예제
여러 URL에서 데이터를 가져오는 것과 같은 I/O 바운드 작업을 고려해 보겠습니다.
import threading import requests import time def fetch_url(url): print(f"Starting to fetch {url}") try: response = requests.get(url, timeout=5) print(f"Finished fetching {url}: Status {response.status_code}") except requests.exceptions.RequestException as e: print(f"Error fetching {url}: {e}") urls = [ "https://www.google.com", "https://www.bing.com", "https://www.yahoo.com", "https://www.amazon.com", "https://www.wikipedia.org" ] start_time = time.time() threads = [] for url in urls: thread = threading.Thread(target=fetch_url, args=(url,)) threads.append(thread) thread.start() for thread in threads: thread.join() # 모든 스레드가 완료될 때까지 기다립니다 end_time = time.time() print(f"All URLs fetched in {end_time - start_time:.2f} seconds using threading.")
스레딩 사용 시기
threading
은 I/O 바운드 작업에 가장 적합합니다. GIL이 진정한 멀티코어 CPU 병렬성을 방해하지만, 스레드가 I/O 작업을 (예: 네트워크 데이터 대기) 수행할 때 GIL이 해제되어 다른 스레드가 실행될 수 있습니다. 이는 외부 리소스를 기다리는 작업에 threading
을 효과적으로 만듭니다.
반대로 CPU 바운드 작업의 경우, GIL 때문에 threading
은 성능 이점을 거의 또는 전혀 제공하지 못하며, 컨텍스트 전환으로 인한 오버헤드가 발생하여 단일 스레드 접근 방식보다 프로그램이 느려질 수 있습니다.
멀티프로세싱: 별도 프로세스를 사용한 진정한 병렬성
multiprocessing
을 사용하면 각기 자체 Python 인터프리터와 메모리 공간을 가진 새 프로세스를 생성할 수 있습니다. 이는 GIL이 문제가 되지 않으며, 여러 CPU 코어에 걸쳐 CPU 바운드 작업의 진정한 병렬 실행을 가능하게 함을 의미합니다.
작동 방식
multiprocessing
을 사용하면 새로운 OS 프로세스가 생성됩니다. 이러한 프로세스는 메모리를 직접 공유하지 않으므로 GIL 제한을 피합니다. 프로세스 간 통신은 일반적으로 파이프 또는 큐와 같은 명시적인 메커니즘을 통해 이루어집니다.
예제
multiprocessing
을 시연하기 위해 소수 계산과 같은 CPU 바운드 작업을 살펴보겠습니다.
import multiprocessing import time def is_prime(n): if n < 2: return False for i in range(2, int(n**0.5) + 1): if n % i == 0: return False return True def find_primes_in_range(start, end): primes = [n for n in range(start, end) if is_prime(n)] # print(f"Found {len(primes)} primes between {start} and {end}") return primes if __name__ == "__main__": nums_to_check = range(1000000, 10000000) # 더 나은 시연을 위한 더 큰 범위 num_processes = multiprocessing.cpu_count() # CPU 코어 수만큼 프로세서 사용 chunk_size = len(nums_to_check) // num_processes chunks = [] for i in range(num_processes): start_idx = i * chunk_size end_idx = (i + 1) * chunk_size if i < num_processes - 1 else len(nums_to_check) chunks.append((nums_to_check[start_idx], nums_to_check[end_idx-1] + 1)) start_time = time.time() with multiprocessing.Pool(num_processes) as pool: all_primes = pool.starmap(find_primes_in_range, chunks) # 리스트의 리스트를 평탄화합니다 total_primes = [item for sublist in all_primes for item in sublist] end_time = time.time() print(f"Found {len(total_primes)} primes in {end_time - start_time:.2f} seconds using multiprocessing.") # 비교를 위해 싱글 스레드 실행 (실행하려면 주석을 해제하세요) # start_time_single = time.time() # single_primes = find_primes_in_range(nums_to_check[0], nums_to_check[-1] + 1) # end_time_single = time.time() # print(f"Found {len(single_primes)} primes in {end_time_single - start_time_single:.2f} seconds using single-thread.")
멀티프로세싱 사용 시기
multiprocessing
은 CPU 바운드 작업에 대한 솔루션입니다. 여러 CPU 코어를 활용함으로써 GIL의 제한을 극복하고 진정한 병렬 실행을 달성하여 계산 집약적인 작업에 대한 상당한 속도 향상을 제공합니다.
I/O 바운드 작업에도 사용할 수 있지만, 프로세스를 생성하고 관리하는 오버헤드는 일반적으로 스레드보다 높기 때문에 이러한 시나리오에서는 threading
또는 asyncio
가 더 효율적인 경우가 많습니다.
asyncio: 고성능 동시성을 위한 협력적 멀티태스킹
asyncio
는 async
/await
구문을 사용하여 동시 코드를 작성하기 위한 Python 라이브러리입니다. 이 라이브러리는 단일 스레드에서 협력적 멀티태스킹을 가능하게 하며, 여기서 작업은 자발적으로 이벤트 루프에 제어권을 반환하여 다른 작업이 실행될 수 있도록 합니다. 이는 많은 수의 동시 I/O 작업을 효율적으로 처리하는 데 특히 강력합니다.
작동 방식
asyncio
는 이벤트 루프를 기반으로 작동합니다. await
표현식(일반적으로 I/O 작업)을 만나면 현재 작업이 일시 중단되고 이벤트 루프에 제어가 반환됩니다. 이벤트 루프는 준비된 다른 작업이나 외부 이벤트(예: 네트워크 응답)를 확인하고 예약합니다. 기다리던 I/O 작업이 완료되면 원래 작업이 다시 시작됩니다.
예제
URL 가져오기 예제를 asyncio
를 사용하여 다시 살펴보겠습니다.
import asyncio import aiohttp # 비동기 HTTP 클라이언트 import time async def fetch_url_async(url, session): print(f"Starting to fetch {url}") try: async with session.get(url, timeout=5) as response: status = response.status print(f"Finished fetching {url}: Status {status}") return status except aiohttp.ClientError as e: print(f"Error fetching {url}: {e}") return None async def main(): urls = [ "https://www.google.com", "https://www.bing.com", "https://www.yahoo.com", "https://www.amazon.com", "https://www.wikipedia.org", "https://www.example.com", # 더 나은 시연을 위해 더 추가 "https://www.test.org" ] start_time = time.time() async with aiohttp.ClientSession() as session: tasks = [fetch_url_async(url, session) for url in urls] results = await asyncio.gather(*tasks) # 작업을 동시에 실행 end_time = time.time() print(f"All URLs fetched in {end_time - start_time:.2f} seconds using asyncio.") # print(f"Results: {results}") if __name__ == "__main__": asyncio.run(main())
Asyncio 사용 시기
asyncio
는 많은 수의 동시 연결 또는 작업을 많은 스레드 또는 프로세스를 생성하는 오버헤드 없이 관리해야 하는 I/O 바운드 작업에 탁월합니다. 단일 스레드에서 작동하기 때문에 컨텍스트 전환은 스레드보다 훨씬 가볍고, GIL 문제로 인한 I/O에 대한 영향을 피합니다. 웹 서버, 데이터베이스 프록시 또는 장기 폴링 클라이언트를 생각해보십시오.
CPU 집약적인 단일 작업이 모든 기타 협력적 작업의 실행을 방지하므로 CPU 바운드 작업에는 generally 적합하지 않습니다. asyncio
애플리케이션에서 CPU 바운드 작업을 처리할 때는 이벤트 루프 차단을 방지하기 위해 일반적으로 multiprocessing.Pool
또는 ThreadPoolExecutor
로 오프로드해야 합니다.
올바른 모델 선택
다음은 빠른 요약 및 의사 결정 프레임워크입니다.
- CPU 바운드 작업: **
multiprocessing
**을 사용하세요. GIL을 우회하여 계산 집약적인 작업에 대해 여러 코어에 걸쳐 진정한 병렬 실행을 가능하게 합니다. - I/O 바운드 작업:
- 적당한 수의 동시 작업 또는 비동기 대안이 없는 차단 I/O 라이브러리를 다룰 때 **
threading
**이 좋은 선택입니다. 많은 전통적인 I/O 시나리오에서asyncio
보다 구현하기가 더 간단합니다. - 매우 많은 수의 동시 I/O 작업, 특히 네트워크 호출과 비동기 라이브러리(예:
aiohttp
,asyncpg
)를 사용할 때는 **asyncio
**가 거의 모든 동시 I/O 작업에 대해 협력적 멀티태스킹과 낮은 오버헤드로 인해 훨씬 더 효율적입니다.
- 적당한 수의 동시 작업 또는 비동기 대안이 없는 차단 I/O 라이브러리를 다룰 때 **
- 혼합 작업 (CPU 바운드 및 I/O 바운드): 종종 하이브리드 접근 방식이 가장 좋습니다. I/O 바운드 부분에는
asyncio
를 사용하고 CPU 바운드 계산은multiprocessing.Pool
(asyncio
컨텍스트에서loop.run_in_executor
사용)로 오프로드하여 이벤트 루프를 차단하지 않도록 합니다.
결론
Python은 각기 강점과 이상적인 사용 사례를 가진 동시 애플리케이션을 구축하기 위한 강력한 도구를 제공합니다. Threading
은 적당한 동시성을 가진 I/O 바운드 작업에 적합하며, multiprocessing
은 진정한 병렬성을 요구하는 CPU 바운드 작업의 챔피언이고, asyncio
는 매우 동시적인 I/O 바운드 작업을 위한 우아하고 효율적인 솔루션을 제공합니다. 이러한 차이점을 이해함으로써 개발자는 가장 적절한 동시성 모델을 자신 있게 선택하여 Python 애플리케이션이 응답성과 성능을 모두 갖추도록 할 수 있습니다. 핵심은 동시성 모델을 작업의 본질에 맞추는 것입니다.