Python 비동기 코드를 짜다 보면 이런 에러가 나온다.
RuntimeError: Event loop is closed
또는:
RuntimeError: This event loop is already running.
FastAPI, SQLAlchemy async, httpx, aiohttp 같은 비동기 라이브러리를 쓰다 보면 특히 자주 마주친다. 에러 메시지는 짧은데 원인은 여러 가지라 헷갈린다. 이 글에서 상황별로 원인과 해결책을 정리한다.
asyncio 이벤트 루프 기초
Python asyncio는 단일 이벤트 루프(Event Loop)를 기반으로 돌아간다. 이 루프가 열려 있어야 async/await 코드가 실행되고, 루프가 닫히면 그 위에서 뭔가를 실행하려 할 때 RuntimeError: Event loop is closed가 발생한다.
문제가 생기는 대표 상황은 루프가 이미 닫혔는데 비동기 작업을 추가 실행하려 할 때, asyncio.get_event_loop()를 잘못 쓸 때, Windows 환경에서 asyncio.run() 이후 후처리 과정에서, pytest 등 테스트 프레임워크와 충돌할 때다.
원인 1 — asyncio.run() 이후 루프에 접근
import asyncio
async def main():
await asyncio.sleep(1)
print("done")
asyncio.run(main()) # 루프가 생성되고 실행 후 닫힘
# 여기서 다시 루프에 접근하면 에러
loop = asyncio.get_event_loop()
loop.run_until_complete(some_coroutine()) # RuntimeError: Event loop is closed
asyncio.run()은 새 이벤트 루프를 만들고, 코루틴 실행 후 루프를 닫는다. 이후 get_event_loop()로 루프를 가져오면 이미 닫힌 루프다.
해결: 모든 비동기 로직을 main() 코루틴 안으로 옮긴다.
import asyncio
async def main():
await step_one()
await step_two() # 모든 비동기 작업을 하나의 main 안에서 처리
await step_three()
asyncio.run(main())
원인 2 — Windows에서 발생하늕 Event loop is closed
Windows에서 asyncio.run() 이후 ProactorEventLoop의 종료 과정에서 내부적으로 에러가 발생한다. Python 3.10 이전 버전에서 특히 자주 보인다.
Exception ignored in: <function _ProactorBasePipeTransport.__del__>
RuntimeError: Event loop is closed
해결: 스크립트 시작 부분에 이벤트 루프 정책을 변경한다.
import asyncio
import sys
if sys.platform == "win32":
asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy())
async def main():
pass
asyncio.run(main())
원인 3 — SQLAlchemy async 사용 시 엔진 미처리
SQLAlchemy의 AsyncEngine을 쓸 때 프로그램이 종료되면서 루프가 닫히기 전에 엔진을 제대로 dispose하지 않으면 에러가 난다.
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
engine = create_async_engine("postgresql+asyncpg://user:pass@localhost/db")
async def main():
async with AsyncSession(engine) as session:
result = await session.execute(...)
# 반드시 엔진 정리
await engine.dispose()
asyncio.run(main())
engine.dispose()를 빠뜨리면 프로그램 종료 시 asyncpg 내부 커넥션 정리 과정에서 이벤트 루프가 이미 닫힌 상태라 에러가 발생한다.
원인 4 — httpx/aiohttp AsyncClient를 재사용할 때
httpx나 aiohttp의 AsyncClient는 이벤트 루프에 바인딩된다. 루프가 바뀌면 기존 클라이언트를 쓸 수 없다.
import asyncio
import httpx
# 잘못된 패턴 — 전역 클라이언트
client = httpx.AsyncClient() # 이 시점에 루프에 바인딩
async def fetch(url):
return await client.get(url)
asyncio.run(fetch("https://example.com"))
asyncio.run(fetch("https://example.com")) # 두 번째 실행에서 문제
해결: async with로 컨텍스트 매니저를 활용한다.
import asyncio
import httpx
async def fetch(url):
async with httpx.AsyncClient() as client: # 루프마다 새 클라이언트
response = await client.get(url)
return response.json()
asyncio.run(fetch("https://example.com"))
asyncio.run(fetch("https://example.com")) # 정상 작동
원인 5 — pytest에서 asyncio 테스트 충돌
pytest로 비동기 테스트를 작성할 때 이벤트 루프 설정이 잘못되면 에러가 난다. pytest-asyncio를 설치하고 사용한다.
pip install pytest-asyncio
# 올바른 패턴
import pytest
@pytest.mark.asyncio
async def test_async_func():
result = await some_async_func()
assert result == expect%d
# pytest.ini
[pytest]
asyncio_mode = auto
원인별 에러 상황 체크리스트
| 에러 상황 | 원인 | 해결 |
|---|---|---|
| asyncio.run() 이후 다시 루프 사용 | 닫힌 루프에 접근 | 모든 로직을 main 코루틴 안으로 |
| Windows에서 종료 시 에러 출력 | ProactorEventLoop 종료 버그 | WindowsSelectorEventLoopPolicy 설정 |
| SQLAlchemy async 종료 시 에러 | 엔진 dispose 누락 | await engine.dispose() 명시적 호출 |
| httpx/aiohttp 두 번째 실행 시 에러 | 클라이언트 루프 바인딩 | async with 패턴으로 클라이언트 관리 |
| pytest 비동기 테스트 에러 | 이벤트 루프 충돌 | pytest-asyncio 사용 |
디버깅 팁
에러 나왔는 짧아서 어디서 왔는지 파악하기 어려울 때, asyncio 디버그 모드를 켜면 상세 정보가 나온다.
import asyncio
import logging
logging.basicConfig(level=logging.DEBUG)
async def main():
pass
asyncio.run(main(), debug=True) # debug=True 추가
또는 환경변수로:
PYTHONASYNCIODEBUG=1 python main.py
정리
RuntimeError: Event loop is closed는 닫힌 루프에 접근하거나, 루프 생명주기를 제대로 관리하지 않아서 발생한다asyncio.run()을 쓴다면 모든 비동기 로직은 그 안에서 시작하고 끝내야 한다- Windows는
WindowsSelectorEventLoopPolicy로 별도 처리 필요 - httpx/aiohttp 끴라 �언트는
async with라 관리 - 테스트는
pytest-asyncio라 통일
비동기 코드는 루프의 생릅하가 관리하면 대부분의 문제가 해결된다.
댓글 없음:
댓글 쓰기