Python 기초부터 마스터까지 — 9화
스레드, 프로세스, asyncio와 프로파일링 이해하기
본 블로그 작성 글들은, 강의를 목표로 하고 제작되었습니다. 또한 작성된 글의 정보도 틀렸을 수 있으니, 주의해주세요.
여러 작업을 동시에 처리한다고 항상 빨라지는 것은 아니다. 작업이 CPU 계산을 오래 하는지, 파일과 네트워크를 기다리는지에 따라 실행 모델과 병목이 달라진다.
동시성과 병렬성 구분하기
| 도구 | 잘 맞는 작업 | 주의할 점 |
|---|---|---|
ThreadPoolExecutor | 동기식 파일·네트워크 I/O | 공유 상태와 잠금 |
ProcessPoolExecutor | 독립적인 CPU 집약 계산 | 프로세스 시작과 데이터 전달 비용 |
asyncio | 많은 비동기 네트워크·타이머 작업 | 이벤트 루프를 막는 동기 코드 |
동시성은 여러 작업의 진행을 겹치는 구조이고 병렬성은 여러 계산이 실제 같은 시각에 실행되는 것이다. 먼저 순차 코드가 정확하게 동작하는지 확인하고 시간을 측정한 뒤 병목에 맞는 방식을 선택해야 한다.
스레드와 프로세스
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
def file_size(path: Path) -> int:
return path.stat().st_size
paths = list(Path("data").glob("*.json"))
with ThreadPoolExecutor(max_workers=4) as pool:
sizes = list(pool.map(file_size, paths))
여러 작업이 같은 리스트나 카운터를 변경하면 실행 순서에 따라 결과가 달라질 수 있다. 가능하면 공유 상태를 바꾸는 대신 각 작업이 결과를 반환하게 만들고, 꼭 필요한 상태는 명시적인 잠금으로 보호한다.
기본 CPython에서 긴 Python 계산은 GIL의 영향을 받으므로 CPU 작업은 프로세스를 검토할 수 있다. 다만 작은 작업을 너무 잘게 나누면 프로세스 시작과 직렬화 비용이 이득보다 커질 수 있다.
asyncio로 I/O 겹치기
import asyncio
async def load_player(player_id: int) -> str:
await asyncio.sleep(0.1) # 비동기 I/O를 흉내 낸다.
return f"player-{player_id}"
async def main() -> None:
async with asyncio.TaskGroup() as group:
tasks = [
group.create_task(load_player(player_id))
for player_id in range(5)
]
print([task.result() for task in tasks])
asyncio.run(main())
TaskGroup은 Python 3.11 이상에서 사용할 수 있다. 자식 작업 하나가 처리되지 않은 예외로 실패하면 나머지를 취소하고 모든 작업의 정리를 기다린 뒤 오류를 전달한다.
coroutine 안에서 time.sleep이나 긴 계산을 실행하면 이벤트 루프 전체가 멈춘다. 취소될 수 있는 작업은 finally에서 파일과 연결을 정리하고 취소를 정상적인 제어 흐름으로 다뤄야 한다.
추측보다 측정
python -m timeit -s "values=list(range(10000))" "9999 in values"
python -m timeit -s "values=set(range(10000))" "9999 in values"
python -m cProfile -m my_app
timeit은 작은 코드 조각을 비교하고 cProfile은 전체 실행에서 시간이 쌓이는 위치를 찾는 데 사용한다. 입력 크기, Python 버전, 실행 환경, 측정 명령을 함께 기록해야 결과를 다시 비교할 수 있다.
비동기와 병렬 처리는 자동 성능 버튼이 아니다. 실패, 취소, timeout, 공유 상태, 종료 조건까지 설계하고 실제 작업으로 측정했을 때만 복잡성을 늘릴 이유가 생긴다.