asyncio 태스크가 작업 도중 중단될 수 있습니다

많은 Python 서비스들이 백그라운드 작업을 실행하기 위해 다음과 같은 패턴을 사용합니다:

async def create_order(order):
    await save(order)
    asyncio.create_task(send_webhook(order))
    return {"status": "created"}

목표는 클라이언트를 기다리게 하지 않고 웹훅을 보내는 것입니다. 개발 환경에서는 잘 작동하지만, 운영 환경에서는 무작위로 실패합니다.

문제는 가비지 컬렉션(garbage collection)입니다.

asyncio.create_task를 호출하고 그 결과를 저장하지 않으면, 해당 태스크는 가비지 컬렉션 대상이 됩니다. 이벤트 루프는 태스크에 대해 약한 참조(weak reference)만 유지합니다. 만약 다른 곳에서 해당 태스크를 가리키고 있지 않다면, Python은 태스크가 실행 중임에도 불구하고 이를 회수할 수 있습니다.

작업이 그냥 멈춰버립니다. 트래커에는 아무런 에러도 남지 않습니다. 코드는 완벽합니다. 태스크가 그냥 사라져 버리는 것입니다.

stderr에서 다음과 같은 로그를 볼 수도 있습니다: Task was destroyed but it is pending!

이 에러는 실제 실패가 발생한 시점보다 훨씬 나중에 나타나는 경우가 많아 찾아내기가 어렵습니다.

해결 방법:

참조를 유지하기 위해 백그라운드 태스크 세트(set)를 사용하세요.

background_tasks = set()

def fire_and_forget(coro):
    task = asyncio.create_task(coro)
    background_tasks.add(task)
    task.add_done_callback(background_tasks.discard)
    return task

세트(set)가 태스크를 살아있게 유지합니다. 콜백은 작업이 완료되면 태스크를 세트에서 제거합니다. 이를 통해 메모리 누수를 방지하고 가비지 컬렉터가 태스크를 종료시키는 것을 막을 수 있습니다.

Python 3.11 이상 버전에서는 TaskGroups를 사용할 수 있습니다. TaskGroup은 내부의 모든 태스크에 대해 강한 참조(strong reference)를 유지합니다. 단, TaskGroup은 블록을 빠져나가기 전에 모든 태스크가 완료될 때까지 기다린다는 점에 유의하세요. 사용자에게 응답하기 전에 작업이 반드시 완료되어야 하는 경우에 이 방식을 사용하세요.

무거운 백그라운드 작업의 경우, 슈퍼바이저 패턴(supervisor pattern)을 사용하세요:

  • 시작 시점에 수명이 긴 태스크를 하나 생성합니다.
  • 큐(queue)를 사용하여 해당 태스크로 작업을 보냅니다.
  • 요청 핸들러는 큐에 아이템을 추가하기만 합니다.

이렇게 하면 작업이 중단되지 않고 유지되며, 나중에 재시도(retry) 로직을 추가할 수도 있습니다.

Python에서는 태스크를 직접 관리해야 합니다. 참조를 유지하지 않으면 런타임이 태스크를 보호해주지 않습니다.

Source: https://dev.to/r9v/your-asyncio-task-can-be-garbage-collected-mid-flight-3kg1