
이 글에서는 파이썬 asyncio.gather에서 코루틴 하나만 실패해도 나머지 작업까지 통째로 멈춰버리는 상황을 코드로 재현하고, return_exceptions 옵션으로 이 문제를 피하는 방법을 정리합니다. 정답을 먼저 말씀드리면, asyncio.gather(*aws)를 기본 옵션 그대로 쓰면 그중 하나가 예외를 던지는 순간 gather 자체가 그 예외를 그대로 위로 전달해서 await 지점을 감싼 코드가 멈춰 버립니다. return_exceptions=True를 넣으면 실패한 작업의 예외 객체를 결과 리스트 안에 값으로 그대로 담아 돌려주기 때문에, 성공한 결과와 실패한 예외를 한 번에 받아서 직접 나눠 처리할 수 있습니다.
파이썬 asyncio.gather에서 하나 실패하면 전체가 죽는 원리
asyncio.gather는 인자로 받은 코루틴들을 각각 Task로 감싸서 이벤트 루프에 동시에 올려놓습니다. 이 자체는 순차 실행보다 훨씬 빠르게 결과를 모으기 위한 구조인데요, 문제는 기본값인 return_exceptions=False 상태에서 그중 하나가 예외를 던지면 gather가 그 순간 첫 번째로 발생한 예외를 그대로 호출부로 전달해 버린다는 점입니다.
아래 코드로 확인해 보겠습니다.
import asyncio
async def ok_task(n):
await asyncio.sleep(n)
return f"완료: {n}초"
async def fail_task():
await asyncio.sleep(0.5)
raise ValueError("의도적으로 발생시킨 예외")
async def main():
results = await asyncio.gather(
ok_task(1),
fail_task(),
ok_task(2),
)
print(results)
asyncio.run(main())
이 코드를 실행하면 print(results) 줄까지 가지도 못하고 ValueError(“의도적으로 발생시킨 예외”)가 그대로 터져 나옵니다. 정작 ok_task(1)과 ok_task(2)는 실패하지도 않았는데 그 결과값을 확인할 방법이 없어지는 셈입니다.
return_exceptions=True로 개별 실패를 값으로 받는 방법

같은 코드에서 gather 호출부만 아래처럼 바꿔 보겠습니다.
async def main():
results = await asyncio.gather(
ok_task(1),
fail_task(),
ok_task(2),
return_exceptions=True,
)
print(results)
asyncio.run(main())
이번에는 프로그램이 죽지 않고 results 리스트가 그대로 채워집니다. 순서대로 [“완료: 1초”, ValueError(“의도적으로 발생시킨 예외”), “완료: 2초”] 형태가 되는데, 두 번째 자리에 예외 객체가 값으로 그대로 들어가 있다는 점이 핵심입니다. 이 리스트를 순회하면서 isinstance(item, Exception)으로 성공/실패를 구분해 로깅하거나 재시도 큐에 넣는 식으로 후처리하면 됩니다. gather는 하나가 실패해도 나머지 코루틴을 취소하지 않고 끝까지 실행한다는 동작이 공식 문서의 gather 설명에도 명시돼 있습니다.
실패한 코루틴만 죽고 나머지는 계속 도는 숨은 함정
return_exceptions=False 상태에서 예외가 발생해 gather가 즉시 예외를 던지더라도, 아직 끝나지 않은 다른 코루틴들은 백그라운드에서 계속 실행됩니다. 위 예제에서 fail_task가 0.5초 만에 실패해도 ok_task(2)는 이미 스케줄된 Task라서 2초짜리 sleep을 끝까지 채우고 종료됩니다.
호출부는 이미 예외 처리로 넘어가서 이 사실을 모르고 지나칠 수 있는데, 만약 코루틴 안에서 DB에 쓰기 작업을 하거나 외부 API를 호출하고 있었다면 결과를 못 받았을 뿐 부수효과는 그대로 실행된다는 뜻입니다. 이 상황이 문제가 된다면 try/except로 gather를 감싼 뒤, 예외 발생 시 남은 Task를 직접 cancel() 해주는 절차가 필요합니다. asyncio.gather에서 하나 실패했다고 나머지가 자동으로 정리되지 않는다는 점을 놓치면, 실패하면 원인 파악이 늦어지고 전체가 죽는 순간과 실제 부수효과가 끝나는 시점이 어긋나 디버깅이 꼬입니다.
TaskGroup을 쓰면 이 문제가 자동으로 해결될까요?
파이썬 3.11부터 추가된 asyncio.TaskGroup은 gather와 반대되는 철학으로 설계돼 있습니다. 하나의 작업이 실패하면 나머지 형제 Task를 자동으로 취소하고, 여러 예외가 동시에 발생했을 수도 있다는 전제 아래 이를 하나의 ExceptionGroup으로 묶어서 던집니다.

import asyncio
async def main():
async with asyncio.TaskGroup() as tg:
tg.create_task(ok_task(1))
tg.create_task(fail_task())
tg.create_task(ok_task(2))
asyncio.run(main())
이 코드는 fail_task가 실패하는 순간 아직 끝나지 않은 ok_task(2)를 취소하고, except ValueError로 잡아야 하는 ExceptionGroup을 던집니다. “실패하면 전부 정리하고 종료”가 목표라면 TaskGroup이 더 적합하고, “일부만 실패해도 나머지 결과는 살려서 후처리”가 목표라면 gather(return_exceptions=True)가 더 적합합니다. 다만 TaskGroup은 3.11 미만 환경에서는 쓸 수 없고, except 문법 자체도 3.11부터 도입된 것이라 기존 코드베이스와 섞어 쓰려면 예외 처리 부분을 함께 바꿔야 합니다.
return_exceptions=True인데도 프로그램이 죽는 경우
return_exceptions=True를 넣었는데도 CancelledError가 그대로 튀어나와 당황하는 경우가 있습니다. 원인은 gather 안의 코루틴이 스스로 실패한 것이 아니라, gather를 감싼 바깥 Task 자체가 취소됐을 때입니다. 예를 들어 asyncio.wait_for(gather(…), timeout=1)처럼 타임아웃을 걸었는데 시간 안에 끝나지 않으면, 아직 완료되지 않은 자식 코루틴들이 전부 취소되면서 CancelledError가 발생합니다.
이때는 return_exceptions 설정과 무관하게 취소가 그대로 전파되는데, CancelledError가 Exception이 아니라 BaseException을 상속하기 때문에 except Exception으로도 걸러지지 않는다는 점까지 겹칩니다. 타임아웃과 함께 gather를 쓸 계획이라면 except asyncio.CancelledError를 별도로 처리하거나, wait_for 자체를 try/except로 감싸는 구조를 미리 준비해 두는 편이 안전합니다.
| 방식 | 하나 실패 시 동작 | 나머지 작업 | 지원 버전 |
|---|---|---|---|
| gather(return_exceptions=False) | 첫 예외를 즉시 상위로 전달 | 취소되지 않고 백그라운드에서 계속 실행 | 3.x 전반 |
| gather(return_exceptions=True) | 예외를 결과 리스트의 값으로 반환 | 끝까지 실행되고 결과도 함께 수집 | 3.x 전반 |
| asyncio.TaskGroup | 실패 즉시 나머지를 취소, ExceptionGroup으로 묶어 전달 | 자동 취소 | 3.11 이상 |
실패 허용 범위에 따라 gather 옵션을 다르게 고르세요
파이썬 asyncio.gather에서 하나만 실패해도 전체가 죽는 문제는 return_exceptions 옵션 하나로 대부분 해결됩니다. 다만 return_exceptions=True는 실패를 감춰줄 뿐 자동으로 정리해 주지는 않기 때문에, 결과 리스트를 순회하며 예외 객체를 직접 걸러내는 코드가 항상 뒤따라야 합니다. “일부 실패는 넘어가고 성공한 결과만 쓰겠다”는 요구사항이면 return_exceptions=True를, “하나라도 실패하면 나머지 작업도 즉시 취소하고 깔끔하게 종료하겠다”는 요구사항이면 3.11 이상 환경에서 TaskGroup을 선택하는 편이 코드를 더 단순하게 만들어 줍니다. 지금 프로젝트에서 gather를 쓰고 있다면, 실패한 작업의 부수효과(DB 쓰기, 외부 호출)가 그대로 남아 있지는 않은지부터 점검해 보시길 권합니다.
