Python Async vs Threads: asyncio를 사용해야 할 때
Python에서 동시성이 중요한 이유
여러 API에서 데이터를 가져오는 Python 스크립트가 있습니다. 작동은 하지만 느립니다. 각 요청이 이전 요청이 끝날 때까지 기다립니다. asyncio와 스레드에 대해 들어보셨을 텐데, 어떤 것을 사용해야 할까요? 잘못된 모델을 선택하면 코드가 불필요하게 복잡해지거나 끔찍하게 느려질 수 있습니다.
이 가이드는 혼란을 해소합니다. Python의 async와 threading 모델을 비교하고, 각각 언제 사용해야 하는지 정확히 보여주며, 자신의 프로젝트에 적용할 수 있는 실용적인 예제를 제공합니다.
Python의 동시성 지형 이해하기
Python은 세 가지 주요 동시성 모델을 제공합니다:
- 스레드 – 단일 프로세스 내에서 여러 실행 스레드, OS에 의해 관리됨. CPU 바운드 작업의 경우 Global Interpreter Lock(GIL)에 의해 제한됨.
- Async (asyncio) –
await지점에서 작업을 전환하는 단일 스레드 이벤트 루프. I/O 바운드 작업에 탁월함. - 멀티프로세싱 – 각각 자체 Python 인터프리터와 메모리 공간을 가진 별도 프로세스. 진정한 병렬 처리를 위해 GIL을 우회함.
GIL은 여러 스레드가 동시에 Python 바이트코드를 실행하는 것을 방지하는 뮤텍스입니다. 즉, 스레드는 CPU 바운드 작업을 빠르게 하지 못하지만, I/O 작업 중에 GIL이 해제되므로 I/O 바운드 작업에는 도움이 됩니다.
asyncio를 사용해야 할 때
asyncio는 프로그램이 대부분의 시간을 외부 이벤트(네트워크 요청, 파일 I/O, 데이터베이스 쿼리 또는 타이머)를 기다리는 데 보낼 때 빛을 발합니다. 단일 스레드를 사용하기 때문에 스레드 생성 및 컨텍스트 전환 오버헤드를 피할 수 있습니다. 이벤트 루프는 수천 개의 동시 연결을 효율적으로 관리합니다.
다음과 같은 경우 asyncio를 사용하세요:
- 많은 동시 연결을 처리하는 웹 서버나 클라이언트를 구축할 때 (예: FastAPI, aiohttp).
- 작업이 I/O 바운드이고 높은 동시성(수백 또는 수천 개의 작업)이 필요할 때.
- 작업 스케줄링 및 취소를 세밀하게 제어하고 싶을 때.
- 이미 async 프레임워크나 라이브러리를 사용 중일 때.
다음은 여러 URL을 동시에 가져오는 간단한 예제입니다:
import asyncio
import aiohttp
async def fetch(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(url) for url in urls]
results = await asyncio.gather(*tasks)
print(f'Fetched {len(results)} pages')
asyncio.run(main())
스레드를 사용해야 할 때
스레드는 async에 상응하는 것이 없는 블로킹 I/O 작업이 있거나 async 친화적이지 않은 레거시 코드로 작업할 때 적합합니다. 또한 적은 수의 작업에 대해서는 추론하기 더 간단합니다.
다음과 같은 경우 스레드를 사용하세요:
- 여러 파일을 읽거나 동기 API 호출을 하는 등 블로킹 I/O를 병렬로 실행해야 할 때.
- async를 지원하지 않는 라이브러리와 통합할 때.
- 적당한 수의 동시 작업(수천 개가 아닌 수십 개)이 있을 때.
- 코드를 동기식으로 유지하면서 I/O를 겹치고 싶을 때.
concurrent.futures.ThreadPoolExecutor를 사용한 예제:
from concurrent.futures import ThreadPoolExecutor
import requests
def fetch(url):
response = requests.get(url)
return response.text
urls = [
'https://example.com',
'https://example.org',
'https://example.net',
]
with ThreadPoolExecutor(max_workers=10) as executor:
results = list(executor.map(fetch, urls))
print(f'Fetched {len(results)} pages')
asyncio와 스레드 비교
결정을 돕기 위한 나란히 비교입니다:
| 측면 | asyncio | 스레드 |
|---|---|---|
| 동시성 모델 | 단일 스레드 이벤트 루프 | 다중 OS 스레드 |
| 최적 용도 | I/O 바운드, 높은 동시성 | I/O 바운드, 블로킹 호출 |
| CPU 바운드 성능 | 나쁨 (GIL) | 나쁨 (GIL) |
| 확장성 | 수천 개의 작업 | 수백 개의 스레드 |
| 복잡성 | async/await 구문 필요 | 익숙한 동기 스타일 |
| 디버깅 | 트레이스백으로 까다로울 수 있음 | 표준 디버깅 도구 |
| 생태계 | 성장하는 async 지원 | 보편적 |
CPU 바운드 작업은 어떨까요?
GIL 때문에 asyncio나 스레드 모두 Python에서 CPU 집약적 작업을 병렬화하는 데 도움이 되지 않습니다. 숫자 계산, 이미지 처리 또는 데이터 압축과 같은 CPU 바운드 작업에는 multiprocessing 또는 concurrent.futures.ProcessPoolExecutor를 사용하세요.
예제:
from concurrent.futures import ProcessPoolExecutor
def cpu_heavy(n):
return sum(i * i for i in range(n))
if __name__ == '__main__':
with ProcessPoolExecutor() as executor:
results = list(executor.map(cpu_heavy, [10**6, 10**6, 10**6]))
print(results)
모범 사례와 함정
- 블로킹 호출을 asyncio와 혼합하지 마세요. async 작업 내에서 블로킹 함수를 호출하면 전체 이벤트 루프가 차단됩니다. 블로킹 코드를 스레드 풀에서 실행하려면
asyncio.to_thread()를 사용하세요. - 스레드 풀 크기를 제한하세요. 너무 많은 스레드를 생성하면 컨텍스트 전환 오버헤드가 발생합니다.
ThreadPoolExecutor(max_workers=N)을 사용하세요. - 예외를 적절히 처리하세요. asyncio에서 작업의 처리되지 않은 예외는 unnoticed될 수 있습니다.
asyncio.gather(..., return_exceptions=True)를 사용하거나 콜백을 추가하세요. - 새로운 I/O 중심 프로젝트에는 asyncio를 선호하세요. 생태계가 성숙했고 FastAPI와 aiohttp 같은 프레임워크가 쉽게 만들어줍니다.
- 레거시 또는 간단한 스크립트에는 스레드를 사용하세요. 몇 개의 블로킹 호출을 병렬화하기만 하면 된다면 스레드가 덜 침습적입니다.
FAQ
asyncio와 스레드를 함께 사용할 수 있나요?
네. asyncio.to_thread() 또는 loop.run_in_executor()를 사용하여 스레드 풀에서 블로킹 코드를 실행할 수 있습니다. 이는 이벤트 루프를 차단하지 않고 async 코드에서 동기 라이브러리를 호출해야 할 때 유용합니다.
asyncio는 GIL과 함께 작동하나요?
네, asyncio는 단일 스레드에서 실행되며 GIL의 적용을 받습니다. 그러나 I/O 바운드 작업을 위해 설계되었기 때문에 I/O 작업 중에 GIL이 해제되어 다른 작업이 실행될 수 있습니다. CPU 바운드 작업의 경우 asyncio는 속도 향상을 제공하지 않습니다.
asyncio와 스레드 중 어느 것이 더 빠른가요?
많은 동시 작업이 있는 I/O 바운드 작업의 경우 asyncio가 스레드 오버헤드를 피하기 때문에 일반적으로 더 빠르고 확장성이 좋습니다. 적은 수의 블로킹 작업의 경우 스레드가 더 간단하고 비슷한 성능을 낼 수 있습니다. 둘 다 CPU 바운드 작업에는 도움이 되지 않습니다.
올바른 선택하기
먼저 병목 현상이 I/O인지 CPU인지 식별하세요. I/O이고 높은 동시성이 필요하면 asyncio를 선택하세요. I/O이지만 블로킹 라이브러리나 더 간단한 스크립트를 다루고 있다면 스레드가 좋은 선택입니다. CPU 바운드 작업에는 멀티프로세싱을 사용하세요.
이 모델들을 종종 결합할 수 있다는 것을 기억하세요. 예를 들어 네트워크 작업에는 asyncio를, 파일 I/O에는 스레드 풀을 사용할 수 있습니다. 핵심은 트레이드오프를 이해하고 특정 문제에 맞는 도구를 선택하는 것입니다.
동시 API 호출에서 반환된 JSON 데이터를 빠르게 포맷하거나 검증해야 할 때, JSON Formatter를 사용하여 쉽게 예쁘게 출력하고 디버그하세요.