비동기 코드에서 가장 흔히 터지는 실수
동기 함수 하나가 서버 전체를 멈춘다
2장에서 이벤트 루프는 단일 스레드에서 콜백을 하나씩 순서대로 실행한다고 했습니다. 이 사실이 실전에서 어떤 사고로 이어지는지 재현해보겠습니다.
import asyncio
import time
async def handle_request(n: int):
print(f"요청 {n} 처리 시작")
time.sleep(2) # DB 드라이버가 동기 라이브러리라고 가정
print(f"요청 {n} 완료")
async def main():
await asyncio.gather(*(handle_request(i) for i in range(3)))
asyncio.run(main())time.sleep(2) 는 await 가 아닙니다. 이 줄을 만나면 이벤트 루프는 제어권을 돌려받지 못한 채 그냥 2초간 멈춰 있습니다. 결과적으로 요청 0, 1, 2가 병렬로 처리되는 게 아니라 순서대로 2초씩, 총 6초가 걸립니다 — asyncio.gather 를 썼는데도 말이죠. 더 나쁜 건, 실제 서비스에서는 이 2초 동안 다른 모든 사용자의 요청도 함께 멈춘다는 점입니다. 동기 코드 하나가 이벤트 루프를 막으면 그 순간 서버 전체가 한 사람 대기줄이 됩니다.
흔히 나오는 원인은 psycopg2, requests, 오래된 ORM 드라이버처럼 원래 동기용으로 설계된 라이브러리를 async def 함수 안에서 그대로 호출하는 경우입니다. 함수 앞에 async 를 붙였다고 해서 그 안의 모든 호출이 자동으로 논블로킹이 되는 게 아닙니다. async def 는 "이 함수는 중간에 멈췄다 재개될 수 있다"는 뜻이지, "이 함수 안의 모든 코드가 논블로킹이다"라는 보장이 아닙니다.
해결책은 두 가지입니다. 가능하면 논블로킹 버전(asyncpg, httpx.AsyncClient, aiohttp)으로 바꾸는 것이 최선입니다. 바꿀 수 없는 동기 라이브러리라면 asyncio.to_thread 로 별도 스레드에 떠넘겨 이벤트 루프를 막지 않게 합니다.
async def handle_request(n: int):
await asyncio.to_thread(time.sleep, 2) # 별도 스레드에서 실행이러면 time.sleep 이 여전히 2초를 쓰지만, 그동안 이벤트 루프는 다른 코루틴을 계속 처리할 수 있습니다.
만든 Task가 조용히 사라진다
async def log_event(event: str):
await asyncio.sleep(1)
print(f"기록됨: {event}")
async def handle():
asyncio.create_task(log_event("클릭")) # 참조를 아무 데도 저장 안 함
return "ok"이 코드는 대부분 잘 작동하는 것처럼 보이다가, 가끔 log_event 가 실행되지 않고 사라집니다. asyncio.create_task 가 반환한 Task 객체를 어디에도 저장하지 않으면, 이 태스크는 이벤트 루프 내부적으로만 약한 참조(weak reference)로 유지됩니다. 가비지 컬렉터가 이 객체를 회수해버리면, 아직 완료되지 않은 태스크라도 실행 중간에 취소될 수 있습니다. 공식 문서도 이 점을 명시적으로 경고합니다 — "저장해두지 않은 태스크는 예상치 못한 시점에 가비지 컬렉션될 수 있다"고요.
background_tasks = set()
async def handle():
task = asyncio.create_task(log_event("클릭"))
background_tasks.add(task)
task.add_done_callback(background_tasks.discard)
return "ok"번거로워 보이지만, "발사 후 망각(fire-and-forget)" 스타일의 태스크를 만들 때는 이렇게 어딘가에 강한 참조를 붙잡아둬야 합니다. 이 문제는 재현 확률이 낮아서 로컬 테스트에서는 거의 안 걸리고, 트래픽이 몰리는 운영 환경에서만 간헐적으로 터지는 종류의 버그입니다.
await 없는 동시 실행은 여전히 함정이다
4장에서 다룬 "await를 순서대로 쓰면 동시성이 아니다"는 얘기를 반대로 오해하는 경우도 있습니다. create_task 로 여러 개를 예약해놓고 그중 하나라도 await 하는 걸 잊으면, 그 태스크는 main() 코루틴이 끝나는 순간 완료 여부와 상관없이 함께 종료될 수 있습니다. "만들었으니 알아서 끝까지 실행되겠지"가 아니라, 프로그램의 최상위 코루틴이 끝나는 시점까지 태스크의 생명이 걸려 있다는 걸 항상 염두에 둬야 합니다.