파이썬에서 RuntimeError: Event loop is closed가 발생하면 먼저 asyncio.run()을 호출한 위치와 루프에 묶인 객체의 생명주기를 확인해야 합니다. asyncio.run()은 새 이벤트 루프를 만들고 코루틴이 끝나면 그 루프를 닫으므로, 일반 스크립트에서는 최상위 진입점으로 한 번 사용하고 이미 실행 중인 루프에서는 await를 사용해야 합니다. 이 구분을 하면 두 번째 실행의 실패, 종료 시점의 실패, 닫힌 루프에 연결된 Task·Future 재사용을 서로 다른 문제로 진단할 수 있습니다.
먼저 오류를 세 가지 상황으로 나누기
같은 오류 문구라도 수정 위치는 다를 수 있습니다.
- 실행 중인 루프 안에서
asyncio.run()을 다시 호출했다면 중첩 실행 문제입니다. Jupyter 같은 환경이나 이미 비동기 함수 안에 들어온 경우에는 새 루프를 만들지 말고await 작업()으로 이어갑니다. asyncio.run()이 끝난 뒤 같은 루프나 그 루프에 연결된 객체를 다시 사용했다면 종료된 루프 재사용 문제입니다. 루프를 되살리려 하지 말고 실행 경계 안에서 객체를 만들고 정리하도록 구조를 바꿉니다.loop.close()이후에 루프 메서드나 연결된 Future·Task를 사용했다면 수명 순서 문제입니다. 작업의 완료 또는 취소를 기다린 뒤 자원을 정리하고 마지막에 루프를 닫아야 합니다.
Event loop is closed라는 문자열만 보고 라이브러리 문제로 단정하기보다, 오류가 난 시점이 “실행 중”, “첫 실행 종료 직후”, “두 번째 호출” 중 어디인지 로그로 확인하는 편이 빠릅니다.
asyncio.run()은 왜 루프를 닫나
공식 문서의 asyncio.run()은 awaitable을 실행하기 위한 높은 수준의 진입점입니다. 새 이벤트 루프를 만들고 비동기 제너레이터와 기본 executor를 정리한 뒤 루프를 닫습니다. 따라서 다음처럼 일반 스크립트의 경계를 한 곳에 두는 구조가 이해하기 쉽습니다.
import asyncio
async def main() -> None:
result = await fetch_data()
print(result)
async def fetch_data() -> str:
return "done"
if __name__ == "__main__":
asyncio.run(main())
이 예제의 핵심은 asyncio.run()을 함수 안의 작은 작업마다 호출하지 않는 것입니다. 여러 작업이 필요하면 main() 안에서 await하거나 Task를 만들고 완료를 기다립니다. 여러 최상위 비동기 호출을 하나의 실행 경계에서 이어야 한다면 asyncio.Runner를 검토할 수 있습니다.
자세한 동작은 Python 공식 Runners 문서에서 확인할 수 있습니다.
이미 실행 중인 루프에서는 await를 사용하기
현재 스레드에서 루프가 실행 중이면 그 안에 또 asyncio.run()을 넣을 수 없습니다. 호출하는 함수 자체를 비동기 함수로 만들고 호출부에서 기다리는 방식으로 경계를 맞춥니다.
import asyncio
async def load_items() -> list[str]:
await asyncio.sleep(0)
return ["one", "two"]
async def main() -> None:
items = await load_items()
print(items)
# 일반 .py 스크립트의 최상위에서만 실행 경계를 연다.
if __name__ == "__main__":
asyncio.run(main())
이미 main()과 비슷한 비동기 함수 안이라면 asyncio.run(load_items())로 바꾸는 것이 아니라 await load_items()를 사용해야 합니다. Jupyter처럼 실행기가 루프를 이미 관리하는 환경에서도 같은 원칙을 적용하되, 해당 실행기의 호출 방식은 별도 문서와 실제 실행 구조를 함께 확인해야 합니다.
닫힌 루프와 객체의 수명을 함께 점검하기
Task는 이벤트 루프에서 코루틴을 실행하는 Future 유사 객체이고, loop.create_future()로 만든 Future는 특정 루프에 연결됩니다. 그래서 루프만 새로 만들고 이전 Task나 Future를 옮겨 쓰는 방식은 안전한 초기화가 아닙니다.
작업과 자원의 순서는 다음처럼 잡는 것이 기본입니다.
- 실행 중인 루프 안에서 비동기 자원과 Task를 만든다.
- 필요한 작업이 끝나거나 Task의 취소가 완료될 때까지 기다린다.
- 자원이 제공하는 비동기 정리 절차를 같은 실행 경계 안에서 수행한다.
- 최상위 실행이 반환된 뒤
asyncio.run()이 루프 종료 절차를 맡게 한다.
외부 라이브러리의 Session이나 연결 객체는 라이브러리마다 정리 방법이 다르므로, 객체가 어느 루프에 만들어졌는지와 해당 라이브러리의 close 규칙을 함께 확인해야 합니다. 공식 asyncio 문서만으로 특정 Session의 내부 동작까지 확정할 수는 없습니다.
백그라운드 Task를 만들었다면 참조를 잃지 말고 완료를 기다리거나 취소하는 흐름을 명시합니다.
async def main() -> None:
task = asyncio.create_task(work())
try:
await task
finally:
if not task.done():
task.cancel()
await task
async def work() -> None:
await asyncio.sleep(0)
실제 코드에서는 취소된 Task를 기다릴 때 애플리케이션의 취소 정책에 맞게 CancelledError 처리도 검토해야 합니다. 중요한 점은 루프를 먼저 닫고 남은 Task가 뒤늦게 콜백을 예약하게 만들지 않는 것입니다.
get_running_loop()과 get_event_loop() 구분
코루틴이나 콜백 안에서 “지금 실제로 실행 중인 루프”가 필요하면 asyncio.get_running_loop()을 사용합니다. 실행 중인 루프가 없으면 RuntimeError가 발생하므로 호출 위치의 실행 경계를 바로 드러냅니다.
async def show_loop() -> None:
loop = asyncio.get_running_loop()
print(loop.is_running(), loop.is_closed())
반면 get_event_loop()는 호출 문맥과 파이썬 버전에 따라 동작과 경고·예외 시점이 달라질 수 있습니다. 코루틴과 콜백에서는 공식 문서가 권장하는 get_running_loop()을 우선 선택하고, 루프를 직접 생성·관리해야 하는 특별한 코드에서만 명시적인 생명주기를 함께 설계합니다.
루프 상태를 진단할 때는 is_running()과 is_closed()를 구분해 기록합니다. 실행 중이 아닌 것과 이미 닫힌 것은 같은 상태가 아니며, loop.close()는 실행 중이 아닐 때 호출해야 하고 이후 해당 루프의 메서드를 다시 사용할 수 없습니다.
실행 환경별로 고칠 위치 선택하기
일반 스크립트라면 main() 아래의 asyncio.run(main()) 하나로 실행 경계를 모읍니다. 함수마다 asyncio.run()을 흩어 놓지 말고, 공유해야 하는 비동기 자원과 작업을 main()의 수명 안에 둡니다.
이미 실행 중인 환경이라면 새 루프를 만들려는 호출을 찾아 await로 바꿀 수 있는지 먼저 봅니다. 호출하는 코드가 동기 API여야 한다면 그 API의 경계에서 비동기 호출을 한 번만 감싸고, 안쪽 함수들이 다시 실행 경계를 열지 않게 합니다.
예약 작업이라면 한 번의 실행이 끝날 때 Task와 자원이 남아 있지 않은지 확인하고, 다음 실행이 이전 루프의 객체를 전역 변수로 재사용하지 않도록 합니다. 실행기마다 루프를 재사용하는지 새로 만드는지는 해당 실행기의 문서를 확인해야 하므로, 그 사실을 모른 채 new_event_loop()를 추가하는 것은 첫 번째 해결책이 아닙니다.
점검 순서
다음 순서로 보면 수정 범위를 좁힐 수 있습니다.
- 예외가 난 호출부가 이미 실행 중인 비동기 함수 안인지 확인합니다.
asyncio.run()과loop.close()가 몇 곳에 있는지 확인하고 최상위 경계를 하나로 정리합니다.- 실패한 Task·Future가 어느 루프에서 만들어졌는지 확인합니다.
- 반복 실행 때 전역 Session, Future, Task 또는 연결 객체를 재사용하는지 확인합니다.
- Task 완료·취소와 자원 정리가 끝난 뒤 실행 경계가 반환되는지 확인합니다.
핵심은 닫힌 루프를 다시 여는 것이 아니라, 루프와 그 안의 비동기 객체가 같은 수명을 갖도록 만드는 것입니다. 이 기준으로도 원인을 좁히기 어렵다면 다음으로는 오류를 발생시킨 외부 라이브러리의 자원 정리 규칙과 실행기의 루프 관리 방식을 각각 확인하면 됩니다.