- Published on
Python asyncio RuntimeError - Event loop is closed 해결
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버나 배치 작업에서 asyncio 를 쓰다 보면 어느 순간 RuntimeError: Event loop is closed 로 프로그램이 터지는 경우가 있습니다. 특히 테스트 러너, Jupyter/REPL, Windows, 멀티스레드, aiohttp 같은 네트워크 라이브러리 조합에서 재현이 쉽습니다.
이 에러는 “이미 닫힌 이벤트 루프에 작업을 예약하거나(콜백/태스크 등록), 닫힌 루프를 참조하는 객체(Transport/ClientSession 등)를 계속 사용”할 때 발생합니다. 즉, 진짜 문제는 에러가 난 지점이 아니라 루프의 생명주기(lifecycle)가 꼬였다는 데 있습니다.
아래에서는 가장 흔한 원인 6가지와, 현업에서 바로 적용 가능한 해결 패턴을 정리합니다.
에러가 발생하는 핵심 메커니즘
asyncio 는 이벤트 루프가 살아있는 동안에만 create_task, call_soon, 소켓 I/O, 타이머 등을 등록할 수 있습니다. 그런데 다음과 같은 상황이 생기면 루프가 닫힌 뒤에도 누군가가 계속 작업을 등록하려고 시도합니다.
asyncio.run()이 종료되며 루프를 닫았는데, 백그라운드 태스크/콜백이 뒤늦게 실행됨- 전역(singleton)으로 만든
ClientSession/ 커넥션 풀이 이전 루프에 묶여 있음 - 다른 스레드에서 메인 루프를 잘못 참조함
- 테스트 프레임워크가 루프를 닫고 새 루프를 만들었는데, 이전 루프에 매달린 객체가 남아 있음
따라서 해결의 방향은 두 가지입니다.
- 루프를 하나의 진입점에서 생성하고, 종료 시 태스크를 정상 정리한다.
- 루프에 종속되는 리소스(
aiohttp.ClientSession, DB 커넥션 등)를 루프 범위 안에서 생성/폐기한다.
1) asyncio.run() 중첩 호출(특히 Jupyter/REPL)
가장 흔한 실수는 이미 실행 중인 이벤트 루프가 있는데도 asyncio.run() 을 또 호출하는 패턴입니다. Jupyter에서는 기본적으로 루프가 돌고 있어, 억지로 돌리다 보면 루프 상태가 꼬이거나 종료 타이밍에 예외가 터집니다.
잘못된 예
import asyncio
async def main():
await asyncio.sleep(0.1)
def handler():
# 이미 루프가 도는 환경에서 호출되면 문제가 된다
asyncio.run(main())
해결
- 스크립트/CLI: 최상위에서만
asyncio.run()을 1회 사용 - Jupyter: 셀에서는
await main()형태로 호출
# notebook cell
import asyncio
async def main():
await asyncio.sleep(0.1)
await main()
만약 “동기 함수에서 비동기 함수를 호출”해야 하는 환경이라면, 설계를 바꿔 비동기 진입점에서만 await 하도록 경계를 재정의하는 것이 가장 안전합니다.
2) 루프에 묶인 전역 리소스(aiohttp.ClientSession 등)
aiohttp.ClientSession 같은 객체는 생성될 때의 루프/커넥터/트랜스포트에 강하게 결합됩니다. 전역으로 만들어두고 여러 번 재사용하면, 첫 실행에서 루프가 종료된 뒤 다음 실행에서 “닫힌 루프”를 참조하며 폭발할 수 있습니다.
잘못된 예: 전역 세션
import aiohttp
session = aiohttp.ClientSession()
async def fetch(url: str) -> str:
async with session.get(url) as resp:
return await resp.text()
해결: 루프 범위에서 생성하고 async with 로 닫기
import aiohttp
import asyncio
async def fetch(url: str) -> str:
async with aiohttp.ClientSession() as session:
async with session.get(url) as resp:
return await resp.text()
async def main():
html = await fetch("https://example.com")
print(len(html))
if __name__ == "__main__":
asyncio.run(main())
서비스에서 세션을 재사용하고 싶다면 “전역”이 아니라 애플리케이션 수명주기(startup/shutdown) 에 묶어 관리하세요. 예를 들어 FastAPI/Starlette에서는 lifespan 이벤트에서 만들고 종료에서 닫는 방식이 안전합니다.
3) 백그라운드 태스크를 남겨둔 채 루프 종료
asyncio.run() 은 내부적으로 루프를 만들고, main() 이 끝나면 루프를 닫습니다. 그런데 main() 내부에서 asyncio.create_task() 로 만든 태스크가 남아있거나, 종료 직전에 콜백이 예약되면 루프 종료 이후에 예외가 터질 수 있습니다.
문제 패턴
import asyncio
async def background():
while True:
await asyncio.sleep(1)
async def main():
asyncio.create_task(background())
await asyncio.sleep(0.2)
asyncio.run(main())
이 코드는 실행 환경에 따라 종료 시점에 경고/예외가 섞여 나올 수 있고, 실제 프로젝트에서는 네트워크 transport 정리 타이밍과 맞물리며 Event loop is closed 로 번지기도 합니다.
해결: 태스크 추적 후 취소/정리
import asyncio
async def background(stop: asyncio.Event):
while not stop.is_set():
await asyncio.sleep(0.1)
async def main():
stop = asyncio.Event()
task = asyncio.create_task(background(stop))
await asyncio.sleep(0.3)
stop.set()
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
if __name__ == "__main__":
asyncio.run(main())
핵심은 “루프 종료 전에, 내가 만든 태스크는 내가 끝까지 책임지고 정리한다”입니다.
4) 멀티스레드에서 잘못된 루프 접근
asyncio 루프는 기본적으로 스레드 로컬입니다. 다른 스레드에서 메인 루프의 API를 직접 호출하면, 닫힌 루프를 잡거나 엉뚱한 루프를 참조할 수 있습니다.
해결: 스레드에서 메인 루프로 안전하게 스케줄
- 메인 루프를 잡아두고
- 다른 스레드에서는
asyncio.run_coroutine_threadsafe()를 사용합니다.
import asyncio
import threading
async def do_async_work(x: int) -> int:
await asyncio.sleep(0.1)
return x * 2
def worker(loop: asyncio.AbstractEventLoop):
fut = asyncio.run_coroutine_threadsafe(do_async_work(21), loop)
print(fut.result())
async def main():
loop = asyncio.get_running_loop()
t = threading.Thread(target=worker, args=(loop,))
t.start()
await asyncio.sleep(0.2)
t.join()
if __name__ == "__main__":
asyncio.run(main())
이 패턴을 쓰면 “이미 닫힌 루프”에 접근할 확률이 크게 줄어듭니다. 스레드 종료/프로세스 종료 시점에도 훨씬 예측 가능해집니다.
5) Windows에서 Proactor/Selector 정책 이슈
Windows에서는 기본 이벤트 루프 정책이 버전별로 달랐고(특히 Python 3.8 전후), 서브프로세스/소켓 동작과 맞물리며 종료 시 예외가 나는 케이스가 있습니다. 일부 환경에서는 WindowsSelectorEventLoopPolicy 로 바꾸면 안정화되는 경우가 있습니다.
import asyncio
import sys
def configure_event_loop_policy():
if sys.platform.startswith("win"):
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
async def main():
await asyncio.sleep(0.1)
if __name__ == "__main__":
configure_event_loop_policy()
asyncio.run(main())
이 설정은 만능은 아니지만, “Windows에서만 종료 시점에 Event loop is closed 가 간헐적으로 난다”면 우선 확인할 가치가 있습니다.
6) 테스트(Pytest)에서 루프 스코프/픽스처 충돌
테스트에서는 다음 상황이 자주 발생합니다.
- 테스트마다 루프를 새로 만들고 닫는다
- 그런데 세션/커넥터 같은 객체가 모듈 스코프/세션 스코프로 살아남는다
- 다음 테스트에서 그 객체가 이전(닫힌) 루프를 참조한다
해결 방향
- 비동기 리소스를
function스코프로 만들거나 - 루프 스코프와 리소스 스코프를 동일하게 맞추기
예시(개념 코드):
import pytest
import aiohttp
@pytest.fixture
async def http_session():
async with aiohttp.ClientSession() as session:
yield session
@pytest.mark.asyncio
async def test_example(http_session):
async with http_session.get("https://example.com") as resp:
assert resp.status == 200
프로젝트에서 pytest-asyncio 설정을 쓰고 있다면, 루프 스코프 옵션과 fixture scope를 함께 점검하세요. 증상이 “테스트를 단독 실행하면 OK, 전체 실행하면 실패”라면 거의 이 케이스입니다.
실전 점검 체크리스트
문제가 재현될 때 아래를 순서대로 확인하면 원인에 빨리 도달합니다.
asyncio.run()을 한 프로세스에서 여러 번 호출하고 있지 않은가(특히 라이브러리 코드 내부)- 전역 객체로
ClientSession, DB pool, 메시지 큐 커넥션 등을 들고 있지 않은가 create_task()로 만든 태스크를 종료 전에 취소/await 하고 있는가- 스레드에서 루프를 직접 만지지 않는가(스레드 안전 API 사용 여부)
- Windows라면 이벤트 루프 정책을 명시했는가
- 테스트에서 루프 스코프와 리소스 스코프가 어긋나지 않는가
uvloop 사용 시 추가 팁
Linux 계열에서 uvloop 를 도입하면 성능이 좋아지지만, 루프 생명주기/리소스 정리가 부실하면 종료 시점 예외가 더 눈에 띄게 드러나는 경우가 있습니다. uvloop 환경에서 특히 Event loop is closed 로 고생 중이라면 아래 글의 케이스별 처방이 도움이 됩니다.
결론: “루프 수명주기”와 “리소스 수명주기”를 맞추기
RuntimeError: Event loop is closed 는 한 줄로 요약하면 닫힌 루프를 누군가 계속 쓰고 있다는 신호입니다. 해결의 핵심은
- 이벤트 루프는 최상위 진입점에서 만들고(
asyncio.run()1회) - 루프에 종속되는 리소스는 루프 범위에서 생성/폐기하며
- 백그라운드 태스크는 종료 전에 취소/정리하고
- 스레드/테스트 환경에서는 루프 참조를 안전하게 전달하는 것
입니다.
위 패턴대로 정리하면, 동일한 코드가 로컬/CI/프로덕션에서 일관되게 동작하고 “종료할 때만 가끔 터지는” 류의 비동기 버그를 크게 줄일 수 있습니다.