Published on

Python asyncio Task was destroyed but it is pending 경고 원인 5가지와 완벽 해결법

Authors

서버나 크롤러, 워커를 asyncio로 돌리다 보면 종료 시점에 아래 같은 경고를 만나기 쉽습니다.

Task was destroyed but it is pending!

이 메시지는 단순한 “경고”처럼 보이지만, 실제로는 종료 시점에 처리 중이던 작업이 강제 파기(destroy)되어 리소스 누수/데이터 유실/커넥션 미정리가 발생할 수 있다는 의미입니다. 특히 aiohttp 커넥션 풀, 파일 핸들, DB 커넥션, 백그라운드 재시도 루프가 섞이면 재현이 어렵고 운영에서만 터지기도 합니다.

이 글에서는 현업에서 가장 자주 보는 원인 5가지와, 한 번에 정리되는 “완벽한 종료(graceful shutdown)” 패턴을 소개합니다.


경고의 본질: 루프가 닫히는데 Task가 아직 pending

Task was destroyed but it is pending!은 보통 다음 상황에서 발생합니다.

  • 이벤트 루프가 종료/닫힘(loop.close() 또는 asyncio.run() 종료) 직전
  • 아직 완료되지 않은 asyncio.Task가 존재
  • 그 Task는 더 이상 await될 기회가 없어서 GC 과정에서 파기

즉, 핵심은 "종료 전에 남은 Task를 식별하고, 취소(cancellation)를 전파하고, 리소스를 정리하고, 끝까지 기다리는" 것입니다.


원인 1) create_task로 띄우고 await/추적을 안 함 (fire-and-forget)

가장 흔한 형태입니다.

import asyncio

async def worker():
    await asyncio.sleep(10)

async def main():
    asyncio.create_task(worker())  # 추적 안 함
    await asyncio.sleep(0.1)       # main이 먼저 끝나며 루프 종료

asyncio.run(main())

해결

  • Task 핸들을 저장하고 종료 시 cancel() + gather()로 회수
  • 또는 asyncio.TaskGroup(3.11+)로 구조화된 동시성 사용
import asyncio

async def worker():
    try:
        while True:
            await asyncio.sleep(1)
    except asyncio.CancelledError:
        # 정리 작업
        raise

async def main():
    task = asyncio.create_task(worker())
    await asyncio.sleep(0.2)

    task.cancel()
    await asyncio.gather(task, return_exceptions=True)

asyncio.run(main())

원인 2) 취소(cancel)를 걸었는데 CancelledError를 삼켜서 종료가 안 됨

취소는 예외(asyncio.CancelledError)로 전달됩니다. 아래처럼 broad exception으로 삼키면 Task가 계속 pending이거나, 상위로 취소가 전파되지 않습니다.

async def worker():
    try:
        while True:
            await asyncio.sleep(1)
    except Exception:
        # 여기서 CancelledError까지 먹어버리면 곤란
        pass

해결

  • CancelledError반드시 다시 raise
  • 정리 후 raise가 정석
import asyncio

async def worker():
    try:
        while True:
            await asyncio.sleep(1)
    except asyncio.CancelledError:
        # cleanup
        raise
    except Exception as e:
        # 진짜 에러 처리
        raise

Best Practice

  • except Exception:을 쓰더라도 CancelledError는 별도로 먼저 처리
  • “취소는 정상 제어 흐름”으로 취급

원인 3) aiohttp ClientSession/커넥터를 닫지 않음 (백그라운드 연결 관리 Task 잔존)

aiohttp.ClientSession은 내부적으로 커넥션 풀/커넥터 관련 작업이 얽혀 있어, 세션을 닫지 않으면 종료 시점에 pending Task가 남을 수 있습니다.

문제 코드

import aiohttp
import asyncio

async def main():
    session = aiohttp.ClientSession()
    async with session.get("https://example.com") as resp:
        await resp.text()
    # session.close() 누락

asyncio.run(main())

해결

  • 가장 안전한 방식: async with ClientSession()
import aiohttp
import asyncio

async def main():
    async with aiohttp.ClientSession() as session:
        async with session.get("https://example.com") as resp:
            await resp.text()

asyncio.run(main())

종료 훅에서 세션 정리하기

앱 전역에서 세션을 공유한다면, shutdown 단계에서 확실히 닫아야 합니다.

class App:
    def __init__(self):
        self.session = None

    async def start(self):
        import aiohttp
        self.session = aiohttp.ClientSession()

    async def stop(self):
        if self.session:
            await self.session.close()

원인 4) signal 처리 없이 프로세스가 급종료되거나, 종료 이벤트가 Task에 전달되지 않음

운영에서는 Ctrl+C(SIGINT)나 SIGTERM(컨테이너 종료)로 내려가는 경우가 많습니다. 이때 종료 신호를 받아 "종료 플래그"를 세팅하고, 백그라운드 Task를 취소하고, 리소스를 닫고, join하는 구조가 없으면 pending Task가 남습니다.

해결: signal + shutdown 이벤트 + Task 취소 전파

아래는 Linux/macOS에서 잘 동작하는 전형적인 패턴입니다.

import asyncio
import signal

class Service:
    def __init__(self):
        self._tasks: set[asyncio.Task] = set()
        self._stop_event = asyncio.Event()

    async def run_forever(self):
        self._tasks.add(asyncio.create_task(self._background_loop(), name="bg"))
        await self._stop_event.wait()  # 종료 신호 대기

    async def _background_loop(self):
        try:
            while True:
                await asyncio.sleep(1)
        except asyncio.CancelledError:
            # cleanup
            raise

    async def shutdown(self):
        self._stop_event.set()

        # 1) 모든 task cancel
        for t in list(self._tasks):
            t.cancel()

        # 2) 취소 완료까지 대기
        await asyncio.gather(*self._tasks, return_exceptions=True)

async def main():
    svc = Service()
    loop = asyncio.get_running_loop()

    def _on_signal():
        # signal handler는 sync 함수여야 하므로 task로 연결
        asyncio.create_task(svc.shutdown())

    for sig in (signal.SIGINT, signal.SIGTERM):
        loop.add_signal_handler(sig, _on_signal)

    await svc.run_forever()

asyncio.run(main())

Windows 주의

Windows는 loop.add_signal_handler 지원이 제한적입니다. 이 경우:

  • KeyboardInterrupt를 잡아 shutdown() 호출
  • 또는 ProactorEventLoop 환경에 맞는 별도 처리 필요

원인 5) 종료 순서가 잘못됨 (리소스를 닫기 전에 Task를 기다리거나, 반대로 Task가 리소스를 붙잡음)

자주 나오는 실수는 다음 두 가지입니다.

  1. Task가 네트워크/큐를 사용 중인데 리소스를 먼저 close → Task가 예외/대기 상태로 꼬임
  2. 리소스 close를 Task가 담당하는데, Task를 먼저 cancel → close 로직이 실행되지 않음

해결: “정리 순서”를 명시적으로 설계

추천 순서(일반적인 서버/워커 기준):

  1. 새로운 작업 유입 차단(accept 중단, 큐 소비 중단)
  2. 백그라운드 Task에 종료 신호 전달(이벤트 set)
  3. Task cancel 전파(필요 시)
  4. Task join(gather)
  5. 세션/커넥션/파일 close

예: 큐 소비 워커

import asyncio

class QueueWorker:
    def __init__(self):
        self.q = asyncio.Queue()
        self.stop = asyncio.Event()
        self.task: asyncio.Task | None = None

    async def start(self):
        self.task = asyncio.create_task(self._loop())

    async def _loop(self):
        try:
            while not self.stop.is_set():
                try:
                    item = await asyncio.wait_for(self.q.get(), timeout=0.5)
                except asyncio.TimeoutError:
                    continue
                # 처리
                await asyncio.sleep(0.1)
        except asyncio.CancelledError:
            raise

    async def shutdown(self):
        # 1) 유입 차단
        self.stop.set()

        # 2) join (필요 시 cancel)
        if self.task:
            self.task.cancel()
            await asyncio.gather(self.task, return_exceptions=True)

완벽 해결 템플릿: TaskGroup + 종료 훅 + aiohttp 세션 + signal

Python 3.11+라면 TaskGroup을 중심으로 “구조화된 동시성”을 구성하면 경고가 크게 줄어듭니다. 아래 예시는:

  • aiohttp 세션을 수명 범위로 묶고
  • 백그라운드 작업을 TaskGroup에 넣고
  • SIGINT/SIGTERM에 반응해 graceful shutdown
  • cancel 전파가 자동으로 정리되도록 구성
import asyncio
import signal
import aiohttp

async def poller(stop: asyncio.Event, session: aiohttp.ClientSession):
    try:
        while not stop.is_set():
            async with session.get("https://example.com") as r:
                await r.read()
            await asyncio.sleep(1)
    except asyncio.CancelledError:
        # 필요한 cleanup 후 전파
        raise

async def main():
    stop = asyncio.Event()
    loop = asyncio.get_running_loop()

    def on_signal():
        stop.set()

    for sig in (signal.SIGINT, signal.SIGTERM):
        try:
            loop.add_signal_handler(sig, on_signal)
        except NotImplementedError:
            # Windows 등
            pass

    async with aiohttp.ClientSession() as session:
        async with asyncio.TaskGroup() as tg:
            tg.create_task(poller(stop, session))

            # stop이 set될 때까지 대기
            await stop.wait()
            # TaskGroup 블록을 빠져나가면 남은 task는 cancel되고 join됨

if __name__ == "__main__":
    try:
        asyncio.run(main())
    except KeyboardInterrupt:
        pass

이 패턴이 강한 이유

  • TaskGroup 스코프를 벗어나면 미완료 Task를 자동 cancel + join
  • aiohttp 세션은 async with반드시 close
  • stop 이벤트로 “정상 종료 경로”를 만들고, 필요 시 cancel로 마무리

트러블슈팅: 남은 pending Task를 찾아내는 방법

경고가 계속 뜬다면 “어떤 Task가 남았는지”부터 잡아야 합니다.

1) 종료 직전에 all_tasks 덤프

import asyncio

def dump_pending_tasks():
    tasks = [t for t in asyncio.all_tasks() if not t.done()]
    for t in tasks:
        print("PENDING:", t.get_name(), t)
        # t.print_stack()  # 3.11+

asyncio.run()을 쓰는 경우 종료 직전에 훅을 넣기 어렵기 때문에, 서비스 코드 내 shutdown 루틴에서 호출하는 방식이 현실적입니다.

2) aiohttp 경고도 같이 확인

Unclosed client session, Unclosed connector가 함께 보이면 세션 수명 관리가 1순위입니다.

3) 타임아웃을 둔 shutdown

외부 I/O가 hang이면 영원히 종료가 안 됩니다. shutdown 단계에 타임아웃을 두고, 타임아웃 시 강제 cancel로 넘어가세요.

await asyncio.wait_for(graceful_shutdown(), timeout=10)

Best Practice 체크리스트

  • create_task()로 만든 Task는 반드시 추적(set/list/TaskGroup)
  • CancelledError는 삼키지 말고 정리 후 재발생(raise)
  • aiohttp ClientSessionasync with 또는 shutdown에서 await close()
  • SIGTERM/SIGINT를 받아 stop 이벤트 → cancel → gather 순으로 종료
  • shutdown에 타임아웃을 둬서 무한 대기를 방지
  • 재시도/백오프 루프는 종료 가능하도록 설계(이벤트 체크)

재시도/레이트리밋 대응처럼 백그라운드 큐와 워커를 운영한다면, 종료 설계가 더 중요해집니다. 예를 들어 API 호출을 큐잉하고 지수 백오프를 돌리는 구조에서는 “종료 시 큐 소비 중단 + in-flight 요청 취소 + 세션 정리”가 필수입니다. 관련 설계는 OpenAI API 429 폭탄 대응 실전 가이드 지수 백오프 큐잉 토큰 버짓으로 비용과 지연을 함께 줄이기에서도 비슷한 관점으로 확장해볼 수 있습니다.


결론: 경고를 없애는 게 아니라 종료를 설계하라

Task was destroyed but it is pending!는 “운 좋게 지금까진 괜찮았던 종료”가 더 이상 안전하지 않다는 신호입니다. 해결의 핵심은 단 하나입니다.

  • Task 수명 추적
  • 취소 전파
  • 리소스(특히 aiohttp 세션) 정리
  • signal 기반 graceful shutdown

지금 운영 중인 asyncio 서비스에 위 템플릿을 그대로 적용해 보세요. 경고가 사라지는 것뿐 아니라, 배포/재시작/스케일 인/아웃 상황에서 장애가 눈에 띄게 줄어듭니다.