PYTHON · 심층 가이드
Python 비동기 완전 정리
이벤트 루프와 코루틴의 실제 동작, TaskGroup과 큐·세마포어로 동시성을 제어하는 법, GIL 때문에 멀티프로세싱이 필요한 경계를 구분해 정리합니다.
비동기를 배울 때 가장 먼저 버려야 할 오해는 async가 코드를 빠르게 만든다는 생각입니다. asyncio는 단일 스레드에서 코루틴이 await 지점마다 자발적으로 제어를 넘기는 협력적 동시성이고, 이득은 오직 기다리는 시간을 겹칠 때만 생깁니다. 그래서 네트워크·디스크 대기가 많은 작업에는 극적이고, 순수 계산에는 아무 도움이 되지 않습니다. CPU를 태우는 일은 GIL 때문에 스레드로도 풀리지 않아 multiprocessing 쪽으로 넘겨야 합니다.
asyncio — async/await 기초와 asyncio 이벤트 루프로 실행 모델을 잡은 뒤, TaskGroup (Python 3.11+)에서 여러 작업을 묶는 현대적 방식을 익히는 흐름을 권합니다. 자원 관리는 async 컨텍스트 매니저 & 이터레이터와 비동기 제너레이터가 담당하고, 처리량 조절은 비동기 큐와 동시성 제한 (Semaphore)이 짝을 이룹니다. 실제 워크로드는 aiohttp 비동기 HTTP와 비동기 패턴 (fan-out/fan-in)에서 형태를 갖추고, 비동기 테스트·비동기 디버깅이 운영 단계를 받칩니다.
코루틴 하나가 블로킹 호출을 하면 이벤트 루프 전체가 멈춥니다. time.sleep, 동기 requests, 무거운 파일 읽기가 전형적인 범인이고, 해법은 asyncio.to_thread나 executor로 밀어내는 것입니다. 또 하나 놓치기 쉬운 건 태스크의 수명입니다. create_task의 반환값을 어디에도 담아두지 않으면 이벤트 루프가 약한 참조만 유지해 도중에 회수될 수 있으므로, 결과를 기다리지 않는 백그라운드 작업도 참조를 컬렉션에 보관했다가 완료 시 제거하는 패턴이 필요합니다. TaskGroup을 쓰면 이 문제가 대부분 사라집니다.
01asyncio — async/await 기초
Python의 비동기 I/O 라이브러리. 코루틴으로 I/O 대기 시간을 효율적으로 활용합니다.
Python code
import asyncio
import time
# 기본 코루틴
async def fetch_data(url: str, delay: float) -> str:
print(f"시작: {url}")
await asyncio.sleep(delay) # 비차단 대기
print(f"완료: {url}")
return f"data:{url}"
# asyncio.gather — 병렬 실행
async def main_parallel():
start = time.perf_counter()
results = await asyncio.gather(
fetch_data("url1", 1.0),
fetch_data("url2", 1.5),
fetch_data("url3", 0.5),
)
elapsed = time.perf_counter() - start
print(f"총 소요: {elapsed:.2f}초") # ~1.5초 (병렬)
print(results)
# asyncio.create_task — 태스크 생성
async def main_tasks():
task1 = asyncio.create_task(fetch_data("a", 1.0))
task2 = asyncio.create_task(fetch_data("b", 0.5))
# 여기서 다른 작업 가능
print("태스크 생성 후 바로 실행 가능")
r1 = await task1
r2 = await task2
return r1, r2
# 타임아웃
async def with_timeout():
try:
result = await asyncio.wait_for(
fetch_data("slow", 5.0),
timeout=2.0
)
except asyncio.TimeoutError:
print("타임아웃!")
asyncio.run(main_parallel())asyncio.gather()는 모두 완료될 때까지 기다리고, asyncio.create_task()는 즉시 스케줄링합니다. 태스크는 await 없어도 백그라운드에서 실행됩니다.
time.sleep()은 전체 이벤트 루프를 차단합니다. async 함수 안에서는 반드시 await asyncio.sleep()을 사용하세요.
02async 컨텍스트 매니저 & 이터레이터
async with와 async for로 비동기 리소스를 안전하게 관리합니다.
Python code
import asyncio
from contextlib import asynccontextmanager
# async context manager
class AsyncDB:
async def __aenter__(self):
print("DB 연결")
await asyncio.sleep(0.1)
return self
async def __aexit__(self, *args):
print("DB 연결 해제")
await asyncio.sleep(0.05)
async def query(self, sql: str) -> list:
await asyncio.sleep(0.1)
return [{"id": 1, "name": "Alice"}]
# @asynccontextmanager 데코레이터 방식
@asynccontextmanager
async def managed_connection(url: str):
print(f"연결: {url}")
connection = {"url": url, "open": True}
try:
yield connection
finally:
connection["open"] = False
print(f"해제: {url}")
# async generator — async for 사용
async def paginate(total: int, page_size: int = 10):
for page in range(0, total, page_size):
await asyncio.sleep(0.01) # DB 조회 시뮬레이션
yield list(range(page, min(page + page_size, total)))
async def main():
async with AsyncDB() as db:
rows = await db.query("SELECT * FROM users")
print(rows)
async with managed_connection("postgres://localhost") as conn:
print(f"연결 상태: {conn['open']}")
# async for
async for page in paginate(35):
print(f"페이지: {page}")
asyncio.run(main())@asynccontextmanager는 async with를 지원하는 컨텍스트 매니저를 제너레이터로 간단하게 만들 수 있습니다.
일반 for 루프로 async generator를 순회할 수 없습니다. 반드시 async for를 사용해야 합니다.
03asyncio 고급 — Queue & Semaphore
비동기 큐와 세마포어로 동시성을 제어하고 프로듀서-컨슈머 패턴을 구현합니다.
Python code
import asyncio
# asyncio.Queue — Producer/Consumer 패턴
async def producer(queue: asyncio.Queue, count: int):
for i in range(count):
await asyncio.sleep(0.1)
await queue.put(f"item-{i}")
print(f"생산: item-{i}")
await queue.put(None) # 종료 신호
async def consumer(queue: asyncio.Queue, name: str):
while True:
item = await queue.get()
if item is None:
await queue.put(None) # 다음 컨슈머에게 전달
break
await asyncio.sleep(0.2)
print(f"{name} 소비: {item}")
queue.task_done()
# Semaphore — 동시 실행 수 제한
async def fetch_with_limit(
sem: asyncio.Semaphore,
url: str
) -> str:
async with sem: # 동시에 최대 3개만 실행
await asyncio.sleep(0.1)
return f"data:{url}"
async def main():
# Queue 예제
queue = asyncio.Queue(maxsize=5)
await asyncio.gather(
producer(queue, 5),
consumer(queue, "Worker-1"),
consumer(queue, "Worker-2"),
)
# Semaphore 예제
sem = asyncio.Semaphore(3) # 동시 3개 제한
urls = [f"url-{i}" for i in range(10)]
results = await asyncio.gather(
*[fetch_with_limit(sem, url) for url in urls]
)
print(f"완료: {len(results)}개")
asyncio.run(main())asyncio.Semaphore로 외부 API 호출 횟수를 제한하면 rate limit 오류를 방지할 수 있습니다.
queue.task_done()을 호출하지 않으면 queue.join()이 영원히 대기합니다. get() 후 처리 완료 시 반드시 task_done()을 호출하세요.
04uv 패키지 매니저
pip/poetry를 대체하는 Rust 기반 고속 패키지 매니저
Python code
<span class="cm">// uv 패키지 매니저 예제
// data/prompts.js의 생성 프롬프트로 상세 코드 생성 가능</span>
fun main() { println("uv 패키지 매니저") }PYTHON 공식 문서를 함께 참고하세요.
자주 발생하는 실수에 주의하세요.
05동시성 — multiprocessing & concurrent.futures
GIL 때문에 CPU 바운드 작업은 threading으로 병렬화되지 않습니다. multiprocessing이나 concurrent.futures.ProcessPoolExecutor를 사용하면 멀티코어를 활용한 진정한 병렬 처리가 가능합니다.
Python code
import time
import math
from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed
def is_prime(n):
"""CPU 바운드 작업: 소수 판별"""
if n < 2:
return False
for i in range(2, int(math.sqrt(n)) + 1):
if n % i == 0:
return False
return True
def count_primes(start, end):
"""범위 내 소수 개수 계산"""
return sum(1 for n in range(start, end) if is_prime(n))
def benchmark(executor_class, name, ranges):
start = time.perf_counter()
results = []
with executor_class(max_workers=4) as executor:
futures = {
executor.submit(count_primes, s, e): (s, e)
for s, e in ranges
}
for future in as_completed(futures):
s, e = futures[future]
count = future.result()
results.append(count)
elapsed = time.perf_counter() - start
total = sum(results)
print(f" {name}: {total}개 소수, {elapsed:.3f}초")
return elapsed
if __name__ == '__main__':
RANGE = 500_000
CHUNKS = 4
chunk_size = RANGE // CHUNKS
ranges = [(i * chunk_size, (i+1) * chunk_size) for i in range(CHUNKS)]
print(f"0~{RANGE:,} 범위 소수 계산 (4 workers):")
# 순차 실행
start = time.perf_counter()
total = count_primes(0, RANGE)
seq_time = time.perf_counter() - start
print(f" 순차 실행: {total}개 소수, {seq_time:.3f}초")
# 멀티프로세스 (CPU 바운드에 효과적)
proc_time = benchmark(ProcessPoolExecutor, "프로세스풀", ranges)
# 멀티스레드 (GIL로 인해 CPU 바운드에 비효율)
thread_time = benchmark(ThreadPoolExecutor, "스레드풀 ", ranges)
print(f"\n속도 향상: 프로세스풀 {seq_time/proc_time:.1f}x")ProcessPoolExecutor는 프로세스 간 데이터를 pickle로 직렬화합니다. 큰 데이터는 공유 메모리(multiprocessing.shared_memory)를 사용하세요.
Windows에서 multiprocessing 사용 시 if __name__ == "__main__": 가드가 없으면 무한 재귀로 프로세스가 폭발합니다.
06asyncio 이벤트 루프
asyncio 이벤트 루프의 기본 개념과 실행 방법을 이해합니다.
Python code
import asyncio
async def say(text: str, delay: float):
await asyncio.sleep(delay)
print(f"[{delay}s] {text}")
return text
async def main():
# 순차 실행: 3초
await say("첫 번째", 1)
await say("두 번째", 2)
# 병렬 실행: 2초 (가장 긴 시간)
results = await asyncio.gather(
say("동시-A", 2),
say("동시-B", 1),
say("동시-C", 1.5),
)
print(f"결과: {results}")
# 실행
asyncio.run(main())
# 이벤트 루프 직접 제어 (드물게 필요)
# loop = asyncio.get_event_loop()
# loop.run_until_complete(main())asyncio.run()은 Python 3.7+에서 이벤트 루프를 자동으로 생성하고 정리합니다. 대부분의 경우 이것만으로 충분합니다.
이미 실행 중인 이벤트 루프 안에서 asyncio.run()을 호출하면 에러가 발생합니다. Jupyter에서는 await main()을 사용하세요.
07async/await 심화
async/await의 고급 패턴: 에러 처리, 타임아웃, 동시 실행 제어를 다룹니다.
Python code
import asyncio
async def fetch(url: str, delay: float) -> dict:
await asyncio.sleep(delay) # 네트워크 요청 시뮬레이션
if "error" in url:
raise ConnectionError(f"{url} 연결 실패")
return {"url": url, "status": 200}
async def main():
# 타임아웃 설정
try:
result = await asyncio.wait_for(
fetch("https://slow.api", 10), timeout=2.0
)
except asyncio.TimeoutError:
print("타임아웃 발생!")
# gather로 에러 처리
results = await asyncio.gather(
fetch("https://api1.com", 1),
fetch("https://error.api", 1),
fetch("https://api3.com", 0.5),
return_exceptions=True, # 예외를 결과로 반환
)
for r in results:
if isinstance(r, Exception):
print(f" 에러: {r}")
else:
print(f" 성공: {r}")
# as_completed: 먼저 끝나는 순서대로
tasks = [
asyncio.create_task(fetch(f"https://api{i}.com", 3-i))
for i in range(3)
]
for coro in asyncio.as_completed(tasks):
result = await coro
print(f" 완료: {result['url']}")
asyncio.run(main())return_exceptions=True를 사용하면 하나의 실패가 전체를 중단시키지 않습니다.
await 없이 코루틴을 호출하면 실행되지 않고 코루틴 객체만 반환됩니다. 반드시 await하세요.
08비동기 제너레이터
async for와 비동기 제너레이터로 비동기 스트림 데이터를 처리합니다.
Python code
import asyncio
async def async_range(start: int, stop: int, delay: float = 0.1):
"""비동기 제너레이터"""
for i in range(start, stop):
await asyncio.sleep(delay)
yield i
async def countdown(name: str, n: int):
async for i in async_range(0, n, 0.2):
print(f" {name}: {n - i}")
print(f" {name}: 완료!")
async def main():
# 비동기 컴프리헨션
squares = [x ** 2 async for x in async_range(1, 6)]
print(f"비동기 제곱: {squares}")
# 비동기 필터링
evens = [x async for x in async_range(1, 11) if x % 2 == 0]
print(f"짝수: {evens}")
# 여러 비동기 스트림 동시 처리
await asyncio.gather(
countdown("A", 3),
countdown("B", 5),
)
asyncio.run(main())비동기 제너레이터는 웹소켓 메시지, SSE(Server-Sent Events), 실시간 로그 처리에 적합합니다.
비동기 제너레이터를 일반 for 루프로 순회하면 TypeError가 발생합니다. 반드시 async for를 사용하세요.
09비동기 컨텍스트 매니저
async with로 비동기 리소스의 획득과 해제를 안전하게 관리합니다.
Python code
import asyncio
from contextlib import asynccontextmanager
# 클래스 기반 비동기 컨텍스트 매니저
class AsyncConnection:
def __init__(self, host: str):
self.host = host
async def __aenter__(self):
print(f"연결 중: {self.host}")
await asyncio.sleep(0.5) # 연결 시뮬레이션
print(f"연결 완료: {self.host}")
return self
async def __aexit__(self, exc_type, exc_val, exc_tb):
print(f"연결 해제: {self.host}")
await asyncio.sleep(0.1)
return False # 예외 전파
async def query(self, sql: str) -> str:
await asyncio.sleep(0.2)
return f"결과: {sql}"
# 함수 기반 (asynccontextmanager)
@asynccontextmanager
async def timer(name: str):
import time
start = time.perf_counter()
try:
yield
finally:
elapsed = time.perf_counter() - start
print(f"{name}: {elapsed:.3f}초")
async def main():
async with AsyncConnection("db.example.com") as conn:
result = await conn.query("SELECT * FROM users")
print(result)
async with timer("작업"):
await asyncio.sleep(1)
asyncio.run(main())asynccontextmanager 데코레이터를 사용하면 __aenter__/__aexit__를 직접 구현하지 않아도 됩니다.
__aexit__에서 True를 반환하면 예외가 억제됩니다. 의도하지 않은 예외 억제를 방지하려면 False를 반환하세요.
10aiohttp 비동기 HTTP
aiohttp로 비동기 HTTP 클라이언트를 구현하여 동시에 여러 요청을 처리합니다.
Python code
import asyncio
# aiohttp 없이 시뮬레이션
async def fake_fetch(url: str, delay: float = 1.0) -> dict:
"""HTTP 요청 시뮬레이션"""
await asyncio.sleep(delay)
return {"url": url, "status": 200, "body": f"응답: {url}"}
async def fetch_all(urls: list[str]) -> list[dict]:
"""여러 URL을 동시에 요청"""
tasks = [fake_fetch(url, 0.5) for url in urls]
return await asyncio.gather(*tasks)
async def fetch_with_limit(urls, max_concurrent=3):
"""동시 요청 수 제한"""
semaphore = asyncio.Semaphore(max_concurrent)
async def limited_fetch(url):
async with semaphore:
return await fake_fetch(url, 1.0)
return await asyncio.gather(
*[limited_fetch(url) for url in urls]
)
async def main():
urls = [f"https://api.example.com/item/{i}" for i in range(5)]
import time
start = time.perf_counter()
results = await fetch_all(urls)
elapsed = time.perf_counter() - start
print(f"{len(results)}개 요청 완료: {elapsed:.2f}초")
start = time.perf_counter()
results = await fetch_with_limit(urls, max_concurrent=2)
elapsed = time.perf_counter() - start
print(f"제한 요청 완료: {elapsed:.2f}초")
asyncio.run(main())실제 사용 시 pip install aiohttp 후 aiohttp.ClientSession을 사용하세요. 세션을 재사용하면 연결 풀링 혜택을 받습니다.
aiohttp.ClientSession을 async with로 관리하지 않으면 연결이 제대로 닫히지 않아 리소스 누수가 발생합니다.
11비동기 파일 I/O
파일 I/O를 비동기로 처리하여 이벤트 루프를 블로킹하지 않는 방법입니다.
Python code
import asyncio
# run_in_executor로 동기 I/O를 비동기로
async def read_file_async(path: str) -> str:
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
None, # 기본 ThreadPoolExecutor
lambda: open(path).read() if __import__('os').path.exists(path)
else f"파일 없음: {path}"
)
# 여러 파일 동시 읽기
async def read_many(paths: list[str]):
tasks = [read_file_async(p) for p in paths]
return await asyncio.gather(*tasks)
# aiofiles 패턴 시뮬레이션
class AsyncFile:
def __init__(self, content=""):
self.content = content
async def __aenter__(self):
await asyncio.sleep(0.01) # I/O 시뮬레이션
return self
async def __aexit__(self, *args):
pass
async def read(self):
await asyncio.sleep(0.01)
return self.content
async def write(self, data):
await asyncio.sleep(0.01)
self.content += data
async def main():
# 동시 파일 처리
async with AsyncFile("Hello, World!") as f:
content = await f.read()
print(f"내용: {content}")
print("비동기 파일 I/O 완료")
asyncio.run(main())실제 프로젝트에서는 pip install aiofiles를 사용하세요. async with aiofiles.open() as f:로 간편하게 비동기 파일 I/O를 수행합니다.
일반 open()은 동기적으로 블로킹합니다. 비동기 환경에서 대용량 파일을 다룰 때는 반드시 비동기 I/O를 사용하세요.
12비동기 큐
asyncio.Queue로 생산자-소비자 패턴을 비동기로 구현합니다.
Python code
import asyncio
import random
async def producer(queue: asyncio.Queue, name: str, count: int):
for i in range(count):
item = f"{name}-{i}"
await asyncio.sleep(random.uniform(0.1, 0.5))
await queue.put(item)
print(f" 생산: {item}")
await queue.put(None) # 종료 신호
async def consumer(queue: asyncio.Queue, name: str):
while True:
item = await queue.get()
if item is None:
queue.task_done()
break
await asyncio.sleep(random.uniform(0.1, 0.3))
print(f" 소비 [{name}]: {item}")
queue.task_done()
async def main():
queue: asyncio.Queue = asyncio.Queue(maxsize=5)
# 생산자 2개, 소비자 3개
producers = [
asyncio.create_task(producer(queue, f"P{i}", 3))
for i in range(2)
]
consumers = [
asyncio.create_task(consumer(queue, f"C{i}"))
for i in range(3)
]
await asyncio.gather(*producers)
# 남은 소비자에 종료 신호
for _ in range(len(consumers) - len(producers)):
await queue.put(None)
await asyncio.gather(*consumers)
print("모든 작업 완료")
asyncio.run(main())maxsize를 설정하면 큐가 가득 찰 때 생산자가 자동으로 대기하여 배압(backpressure)을 제어합니다.
종료 신호(sentinel)를 보내지 않으면 소비자가 영원히 대기합니다. None이나 특별한 객체를 종료 신호로 사용하세요.
13동시성 제한 (Semaphore)
asyncio.Semaphore로 동시 실행 수를 제한하여 리소스를 보호합니다.
Python code
import asyncio
import time
async def limited_task(sem: asyncio.Semaphore, task_id: int):
async with sem:
print(f" 시작 Task-{task_id} (t={time.perf_counter():.1f})")
await asyncio.sleep(1) # 작업 시뮬레이션
print(f" 완료 Task-{task_id}")
return task_id
async def main():
sem = asyncio.Semaphore(3) # 최대 3개 동시 실행
start = time.perf_counter()
tasks = [limited_task(sem, i) for i in range(9)]
results = await asyncio.gather(*tasks)
elapsed = time.perf_counter() - start
print(f"결과: {results}")
print(f"소요: {elapsed:.1f}초") # 약 3초 (3배치 × 1초)
# BoundedSemaphore: 릴리스 횟수 초과 방지
bounded = asyncio.BoundedSemaphore(2)
async with bounded:
print("BoundedSemaphore 사용")
# bounded.release() # ValueError! (acquire 없이 release)
asyncio.run(main())API 호출 시 Rate Limiting을 구현할 때 Semaphore가 유용합니다.
Semaphore는 release()를 초과 호출해도 에러가 발생하지 않습니다. 안전하게 사용하려면 BoundedSemaphore를 사용하세요.
14비동기 이터레이터
__aiter__와 __anext__로 비동기 이터레이터 프로토콜을 구현합니다.
Python code
import asyncio
class AsyncCounter:
"""비동기 카운터 이터레이터"""
def __init__(self, start: int, stop: int):
self.current = start
self.stop = stop
def __aiter__(self):
return self
async def __anext__(self):
if self.current >= self.stop:
raise StopAsyncIteration
value = self.current
self.current += 1
await asyncio.sleep(0.1) # 비동기 작업
return value
class AsyncChain:
"""여러 비동기 이터러블을 연결"""
def __init__(self, *iterables):
self._iterables = iterables
def __aiter__(self):
return self._iterate()
async def _iterate(self):
for iterable in self._iterables:
async for item in iterable:
yield item
async def main():
# 기본 사용
async for num in AsyncCounter(0, 5):
print(f" 카운트: {num}")
# 비동기 이터러블 체이닝
chained = AsyncChain(
AsyncCounter(0, 3),
AsyncCounter(10, 13),
)
result = [x async for x in chained]
print(f"체이닝 결과: {result}")
asyncio.run(main())비동기 이터레이터는 데이터베이스 커서, 페이징 API, 실시간 스트림 처리에 적합합니다.
StopAsyncIteration을 발생시키지 않으면 async for 루프가 끝나지 않습니다.
15TaskGroup (Python 3.11+)
asyncio.TaskGroup으로 구조적 동시성을 구현하여 태스크 라이프사이클을 안전하게 관리합니다.
Python code
import asyncio
async def fetch(name: str, delay: float) -> str:
await asyncio.sleep(delay)
if name == "fail":
raise ValueError(f"{name} 실패!")
return f"{name} 완료 ({delay}s)"
async def main():
# TaskGroup: 모든 태스크가 완료될 때까지 대기
async with asyncio.TaskGroup() as tg:
task1 = tg.create_task(fetch("A", 1))
task2 = tg.create_task(fetch("B", 0.5))
task3 = tg.create_task(fetch("C", 1.5))
# TaskGroup 종료 후 결과 접근
print(task1.result())
print(task2.result())
print(task3.result())
# 에러 처리: ExceptionGroup
try:
async with asyncio.TaskGroup() as tg:
tg.create_task(fetch("ok", 0.5))
tg.create_task(fetch("fail", 0.3))
tg.create_task(fetch("ok2", 0.8))
except* ValueError as eg:
for err in eg.exceptions:
print(f" 에러: {err}")
asyncio.run(main())TaskGroup은 gather보다 안전합니다. 하나의 태스크가 실패하면 나머지도 자동 취소됩니다.
TaskGroup에서 발생하는 예외는 ExceptionGroup으로 래핑됩니다. except* 구문(3.11+)으로 처리해야 합니다.
16trio 소개
trio는 구조적 동시성을 핵심 원칙으로 하는 비동기 라이브러리입니다. asyncio의 대안으로 사용됩니다.
Python code
# trio 스타일을 asyncio로 시뮬레이션
import asyncio
async def child_task(name: str, delay: float):
print(f" {name} 시작")
await asyncio.sleep(delay)
print(f" {name} 완료")
return f"{name} 결과"
async def structured_concurrency():
"""trio의 nursery 패턴을 asyncio TaskGroup으로 구현"""
print("부모 태스크 시작")
# trio: async with trio.open_nursery() as nursery:
# asyncio 3.11+:
async with asyncio.TaskGroup() as tg:
t1 = tg.create_task(child_task("작업1", 1))
t2 = tg.create_task(child_task("작업2", 0.5))
t3 = tg.create_task(child_task("작업3", 1.5))
# 여기서 모든 자식 태스크가 완료됨을 보장
print("모든 자식 태스크 완료")
print(f" 결과: {t1.result()}, {t2.result()}, {t3.result()}")
async def cancellation_example():
"""취소 전파 시연"""
async def long_task():
try:
await asyncio.sleep(100)
except asyncio.CancelledError:
print(" 태스크 취소됨, 정리 중...")
raise
task = asyncio.create_task(long_task())
await asyncio.sleep(0.5)
task.cancel()
try:
await task
except asyncio.CancelledError:
print("취소 확인됨")
asyncio.run(structured_concurrency())
asyncio.run(cancellation_example())trio는 pip install trio로 설치합니다. 새 프로젝트라면 trio의 깔끔한 API를 고려해보세요.
CancelledError를 잡고 다시 raise하지 않으면 취소가 전파되지 않아 리소스 누수가 발생합니다.
17비동기 테스트
pytest-asyncio를 사용하여 비동기 코드를 테스트하는 방법입니다.
Python code
import asyncio
# 테스트할 비동기 코드
async def fetch_user(user_id: int) -> dict:
await asyncio.sleep(0.1) # DB 조회 시뮬레이션
if user_id <= 0:
raise ValueError("유효하지 않은 ID")
return {"id": user_id, "name": f"User-{user_id}"}
async def get_users(ids: list[int]) -> list[dict]:
tasks = [fetch_user(uid) for uid in ids]
return await asyncio.gather(*tasks)
# pytest-asyncio 패턴 시뮬레이션
async def test_fetch_user():
user = await fetch_user(1)
assert user["id"] == 1
assert user["name"] == "User-1"
print("test_fetch_user 통과")
async def test_fetch_user_invalid():
try:
await fetch_user(-1)
assert False, "예외가 발생해야 함"
except ValueError as e:
assert "유효하지 않은 ID" in str(e)
print("test_fetch_user_invalid 통과")
async def test_get_users():
users = await get_users([1, 2, 3])
assert len(users) == 3
assert all(u["name"].startswith("User-") for u in users)
print("test_get_users 통과")
async def run_tests():
await test_fetch_user()
await test_fetch_user_invalid()
await test_get_users()
print("모든 비동기 테스트 통과!")
asyncio.run(run_tests())pip install pytest-asyncio 후 테스트 함수에 @pytest.mark.asyncio를 붙이면 자동으로 이벤트 루프를 관리합니다.
비동기 테스트에서 asyncio.run()을 직접 호출하면 이벤트 루프 충돌이 발생할 수 있습니다. pytest-asyncio를 사용하세요.
18비동기 디버깅
비동기 코드의 일반적인 문제를 진단하고 디버깅하는 기법입니다.
Python code
import asyncio
import traceback
# 디버그 모드 활성화
# PYTHONASYNCIODEBUG=1 python script.py
async def buggy_task():
await asyncio.sleep(0.1)
return 42
async def main():
# 1. await 빠뜨린 코루틴 감지
# buggy_task() # RuntimeWarning: coroutine was never awaited
result = await buggy_task()
print(f"결과: {result}")
# 2. 태스크 모니터링
async def monitored_task(name, delay):
await asyncio.sleep(delay)
return f"{name} 완료"
tasks = [
asyncio.create_task(monitored_task("A", 0.5), name="Task-A"),
asyncio.create_task(monitored_task("B", 1.0), name="Task-B"),
]
# 현재 실행 중인 태스크 확인
all_tasks = asyncio.all_tasks()
for t in all_tasks:
print(f" 태스크: {t.get_name()}, done={t.done()}")
await asyncio.gather(*tasks)
# 3. 타임아웃으로 교착 감지
async def maybe_deadlock():
await asyncio.sleep(10)
try:
await asyncio.wait_for(maybe_deadlock(), timeout=1.0)
except asyncio.TimeoutError:
print("교착 상태 가능성 감지 (타임아웃)")
# 4. 예외 추적 개선
async def failing():
raise RuntimeError("비동기 에러")
task = asyncio.create_task(failing())
try:
await task
except RuntimeError:
print(f"스택 트레이스:\n{traceback.format_exc()[:200]}")
asyncio.run(main())asyncio.create_task(coro, name="이름")으로 태스크에 이름을 부여하면 디버깅이 쉬워집니다.
create_task()의 반환값을 저장하지 않으면 태스크가 가비지 컬렉션될 수 있고, 예외도 무시됩니다.
19비동기 패턴 (fan-out/fan-in)
fan-out/fan-in 패턴으로 작업을 분산하고 결과를 수집합니다.
Python code
import asyncio
async def fetch_chunk(chunk_id: int, data: list) -> dict:
"""개별 청크 처리 (fan-out)"""
await asyncio.sleep(0.3)
result = sum(data)
print(f" 청크 {chunk_id}: 합계={result}")
return {"chunk": chunk_id, "sum": result}
def split_data(data: list, n_chunks: int) -> list[list]:
"""데이터를 n개 청크로 분할"""
size = len(data) // n_chunks
return [data[i*size:(i+1)*size] for i in range(n_chunks)]
async def parallel_sum(data: list, n_workers: int = 4) -> int:
"""fan-out/fan-in 패턴"""
# Fan-out: 작업 분산
chunks = split_data(data, n_workers)
tasks = [
fetch_chunk(i, chunk)
for i, chunk in enumerate(chunks)
]
# Fan-in: 결과 수집
results = await asyncio.gather(*tasks)
total = sum(r["sum"] for r in results)
return total
async def pipeline_pattern():
"""비동기 파이프라인"""
q1 = asyncio.Queue()
q2 = asyncio.Queue()
async def stage1():
for i in range(5):
await q1.put(i * 10)
await q1.put(None)
async def stage2():
while (item := await q1.get()) is not None:
await q2.put(item + 1)
await q2.put(None)
async def stage3():
results = []
while (item := await q2.get()) is not None:
results.append(item * 2)
print(f"파이프라인 결과: {results}")
await asyncio.gather(stage1(), stage2(), stage3())
async def main():
data = list(range(100))
total = await parallel_sum(data)
print(f"병렬 합계: {total}") # 4950
await pipeline_pattern()
asyncio.run(main())fan-out/fan-in은 웹 크롤링, 배치 처리, 분산 계산에서 처리량을 극대화합니다.
너무 많은 태스크를 동시에 생성하면 메모리 부족이 발생합니다. Semaphore로 동시성을 제한하세요.
20동기→비동기 변환
기존 동기 코드를 비동기로 전환하는 실용적인 패턴과 전략입니다.
Python code
import asyncio
from concurrent.futures import ThreadPoolExecutor
import time
# 동기 함수 (변경 불가한 라이브러리)
def sync_heavy_io(name: str, seconds: float) -> str:
time.sleep(seconds) # 블로킹 I/O
return f"{name} 완료"
def sync_cpu_bound(n: int) -> int:
return sum(i * i for i in range(n))
# 전략 1: run_in_executor (I/O 바운드)
async def async_io(name: str, seconds: float) -> str:
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
None, sync_heavy_io, name, seconds
)
# 전략 2: ProcessPoolExecutor (CPU 바운드)
async def async_cpu(n: int) -> int:
loop = asyncio.get_running_loop()
from concurrent.futures import ProcessPoolExecutor
with ProcessPoolExecutor() as pool:
return await loop.run_in_executor(pool, sync_cpu_bound, n)
# 전략 3: asyncio.to_thread (Python 3.9+)
async def main():
# I/O 바운드 병렬화
start = time.perf_counter()
results = await asyncio.gather(
asyncio.to_thread(sync_heavy_io, "A", 1),
asyncio.to_thread(sync_heavy_io, "B", 1),
asyncio.to_thread(sync_heavy_io, "C", 1),
)
elapsed = time.perf_counter() - start
print(f"결과: {results} ({elapsed:.1f}초)") # ~1초
asyncio.run(main())asyncio.to_thread()(3.9+)는 run_in_executor의 간편 버전으로, 동기 함수를 스레드에서 실행합니다.
CPU 바운드 작업에 ThreadPoolExecutor를 사용하면 GIL 때문에 성능 향상이 없습니다. ProcessPoolExecutor를 사용하세요.
정리하며
- asyncio는 대기 시간을 겹치는 도구입니다. CPU 바운드는 프로세스 풀로 보냅니다.
- 코루틴 안의 동기 블로킹 호출은
asyncio.to_thread로 밀어내 루프를 막지 않게 합니다. create_task결과는 참조를 보관합니다. 방치하면 태스크가 조용히 사라질 수 있습니다.- 여러 작업을 묶을 땐
gather보다TaskGroup이 취소·예외 전파가 명확합니다.
더 깊이 들어가고 싶다면 Python 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.