/개발/RuntimeError: Event loop is closed 원인과 해결 30초 판별법
개발python asyncioEvent loop is closed

RuntimeError: Event loop is closed 원인과 해결 30초 판별법

Event loop is closed, already running, attached to a different loop 세 계열을 30초에 판별하는 런북. Jupyter·pytest-asyncio·FastAPI 상황별 원인과 lifespan·fixture 스코프 정렬 해법, nest_asyncio 사용 기준까지 정리했습니다.

RuntimeError: Event loop is closed 원인과 해결 30초 판별법

로컬에선 멀쩡한데 Jupyter·pytest·운영에서만 터진다

파이썬 개발 가이드 5편까지는 PEP 668 외부 관리 환경 문제, venv/Poetry 의존성 충돌, ModuleNotFoundError 런북처럼 실행 전에 터지는 설치·환경 이슈를 다뤘습니다. 그런데 환경을 아무리 정확히 맞춰도, 코드가 돌기 시작한 뒤에 터지는 계열이 하나 남습니다. asyncio 이벤트 루프 에러입니다.

대표적으로 이 세 줄입니다.

TEXT
RuntimeError: Event loop is closed
RuntimeError: This event loop is already running
RuntimeError: asyncio.run() cannot be called from a running event loop
RuntimeError: Task <Task pending ...> got Future <Future pending> attached to a different loop

이 에러들의 공통 원인은 한 줄로 정리됩니다. 루프는 하나가 아니다. 파이썬 프로세스 안에는 여러 개의 이벤트 루프가 생겼다 사라질 수 있고, aiohttp.ClientSession·asyncio.Lock·asyncio.Queue·DB 커넥션 풀 같은 객체는 자신이 만들어진 루프에 묶여 있습니다. 만들어진 루프와 사용되는 루프가 달라지는 순간, 위 네 줄 중 하나가 나옵니다.

그래서 디버깅 순서도 딱 세 단계입니다. ① 지금 코드가 루프 인지 인지 판정한다 → ② 객체 생성 시점과 사용 시점의 id(loop)를 비교한다 → ③ 루프 소유권을 lifespan이나 fixture로 옮긴다. python asyncio 에러 해결 검색으로 여기 오셨다면, 아래 판별표부터 보시면 됩니다.

이 글의 적용 범위는 CPython 3.103.13, FastAPI 0.100+ / Starlette, aiohttp 3.9+, httpx 0.27+, pytest-asyncio 0.210.24 기준입니다.

에러 원문 → 원인 매핑 판별표 (30초 1단계)

에러 메시지 원문과 "어디서 터졌는가"만 교차하면 계열이 나옵니다.

발생 상황A계열 Event loop is closedB계열 already running / asyncio.run() cannot be called...C계열 attached to a different loop
Jupyter / IPython셀에서 asyncio.run()을 여러 번 돌려 이전 루프가 닫힌 뒤 그 루프의 객체를 재사용전형적. ipykernel이 이미 루프를 돌리는 중이라 asyncio.run() 자체가 거부됨셀 A에서 만든 세션을 다른 커널 루프에서 재사용
pytest-asyncio테스트 종료 후 닫힌 function-scope 루프의 세션을 다음 테스트가 사용동기 테스트 함수 안에서 asyncio.run() 호출 + asyncio_mode 설정 누락전형적. session-scope fixture와 function-scope 루프 불일치
FastAPI 라우트 안에서 asyncio.run()전형적. uvicorn이 이미 루프 구동 중asyncio.run()이 만든 새 루프에서 앱 전역 커넥션 풀을 건드림
모듈 전역 aiohttp/httpx 세션전형적. 임포트 시점 루프에 바인딩된 세션이, 그 루프가 닫힌 뒤 호출됨전형적. 워커·테스트마다 루프가 바뀌면서 생성 루프와 불일치
Windows ProactorEventLoop전형적. 인터프리터 종료 시 transport __del__에서 닫힌 루프 접근(스택에 _ProactorBasePipeTransport.__del__ 등장)스레드마다 다른 루프를 세팅했을 때 발생

확인 명령 한 줄과 다음 행동은 이렇게 잡습니다.

계열확인 한 줄정답 패턴
Apython -c "import sys; print(sys.platform, sys.version)" + 트레이스백에 __del__ 유무 확인계열별 해결 — 세션 수명 관리, 종료 시 await session.close()
B아래 where_am_i() 스니펫 실행 → running loop: <...> 출력asyncio.run() 제거하고 await 또는 nest_asyncio 판정표 확인
C생성·사용 지점 id(loop) 대조 스니펫fixture loop_scope 정렬 / lifespan 주입

30초 진단 절차: 지금 코드가 어느 루프에서 도는지

진단 1 — 루프 안인가 밖인가

이 함수를 문제 지점 바로 위에 붙여 호출하세요.

Python
import asyncio, sys

def where_am_i(tag: str = "") -> None:
    print(f"--- where_am_i {tag} ---")
    print("python :", sys.version.split()[0], "|", sys.platform)
    try:
        loop = asyncio.get_running_loop()
        print("state  : INSIDE running loop")
        print("loop   :", type(loop).__name__, "id=", id(loop))
    except RuntimeError:
        print("state  : OUTSIDE (no running loop)")
    print("policy :", type(asyncio.get_event_loop_policy()).__name__)

예상 출력과 분기입니다.

TEXT
# (1) 일반 스크립트에서 asyncio.run() 호출 전
state  : OUTSIDE (no running loop)
# → asyncio.run(main()) 이 정답. B계열 아님.

# (2) Jupyter 셀 / FastAPI 라우트 핸들러 안
state  : INSIDE running loop
loop   : _UnixSelectorEventLoop id=140234...
# → 여기서 asyncio.run()을 부르면 100% B계열. await 로 바꾼다.

# (3) Windows에서
python : 3.12.4 | win32
policy : WindowsProactorEventLoopPolicy
# → A계열 __del__ 잡음 가능성 체크

INSIDE가 찍히는데 코드에 asyncio.run(...)이 있다면 그 자리에서 판정 끝입니다. B계열이고, 해법은 nest_asyncio가 아니라 await입니다.

진단 2 — 생성 루프와 사용 루프의 id 대조

C계열과 A계열은 이 대조로 갈립니다. 세션을 만든 곳과 쓰는 곳에 각각 심으세요.

Python
import asyncio, httpx

class TracedClient(httpx.AsyncClient):
    def __init__(self, *a, **kw):
        super().__init__(*a, **kw)
        try:
            self.born_loop = id(asyncio.get_running_loop())
        except RuntimeError:
            self.born_loop = None  # 루프 밖에서 생성됨 = 위험 신호
        print("[create] born_loop =", self.born_loop)

    async def request(self, *a, **kw):
        now = id(asyncio.get_running_loop())
        if now != self.born_loop:
            print(f"[MISMATCH] born={self.born_loop} now={now}")
        return await super().request(*a, **kw)

aiohttp를 쓴다면 내부 속성으로 직접 비교할 수 있습니다(비공개 속성이므로 진단용으로만).

Python
print("session loop:", id(session._loop))
print("running loop:", id(asyncio.get_running_loop()))

판정 기준은 다음과 같습니다.

  • born_loop is None → 모듈 전역/임포트 시점 생성. A계열 예비군. 첫 요청은 성공해도 루프가 닫히면 Event loop is closed가 납니다.
  • [MISMATCH] 출력 → C계열 확정. 소유권 위치가 잘못됐습니다.
  • id가 같은데도 Event loop is closed → 루프가 이미 닫힌 뒤 __del__/백그라운드 태스크가 접근하는 A계열 종료 순서 문제입니다.

계열별 해결과 버전별 함정

asyncio.run vs run_until_complete 선택 기준

세 가지 규칙만 지키면 B계열은 거의 사라집니다.

  1. 애플리케이션 진입점에서 딱 한 번 asyncio.run(main()). 프로세스 전체에서 1회입니다.
  2. 이미 루프가 도는 호스트 환경(Jupyter, uvicorn, Celery의 일부 워커, GUI 프레임워크)에서는 새 루프를 만들지 않습니다. await로 흡수하거나, 굳이 동기 함수에서 호출해야 하면 별도 스레드에서 asyncio.run_coroutine_threadsafe(coro, loop)를 씁니다.
  3. 라이브러리 코드는 절대 루프를 만들지 않습니다. 라이브러리는 코루틴만 노출하고, 루프 생성·종료는 호출자에게 맡깁니다. loop.run_until_complete는 이미 루프 객체를 명시적으로 소유·관리하는 레거시 코드에서만 남겨 두세요.

Python 3.10 / 3.11 / 3.12 동작 차이

항목3.103.113.12
asyncio.get_event_loop() (루프 밖 호출)루프가 없으면 생성하고 경고 없음/약함Deprecation 흐름 진행DeprecationWarning 발생, 현재 루프 없을 때 자동 생성 의존 금지
러닝 루프 없을 때 자동 루프 생성대체로 동작축소 방향제거 방향으로 이동 — 의존 코드는 깨질 수 있음
asyncio.Runner없음도입사용 가능
TaskGroup / asyncio.timeout()없음도입사용 가능
권장 진입점asyncio.run()asyncio.run() 또는 Runnerasyncio.run() / Runner

정확한 버전별 문구는 CPython의 asyncio 공식 문서와 각 릴리스 "What's New" 문서를 확인하세요. 실무 결론은 단순합니다. asyncio.get_event_loop()를 코드에서 없애는 것이 3.12+ 마이그레이션의 90%입니다. 루프 안이면 asyncio.get_running_loop(), 루프 밖이면 asyncio.run()으로 대체하면 됩니다.

3.11+에서 루프 정책까지 제어해야 한다면 Runner가 깔끔합니다.

Python
import asyncio

async def main():
    ...

with asyncio.Runner() as runner:   # Python 3.11+
    runner.run(main())
    runner.run(main())             # 같은 루프를 재사용

asyncio.run()을 두 번 부르면 루프가 두 번 만들어지고 첫 루프는 닫힙니다. 첫 루프에 묶인 세션을 두 번째 호출에서 쓰면 그게 바로 A계열입니다. Runner는 이 문제를 구조적으로 막아 줍니다.

pytest-asyncio 실패 분기

pytest-asyncio는 버전에 따라 설정 위치와 fixture 규칙이 달라 혼란이 큽니다. 증상별로 나눕니다.

  • 증상: 코루틴 테스트가 skipped 또는 "async def functions are not natively supported" → 모드 설정 누락입니다.
TOML
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"
INI
; pytest.ini 를 쓴다면
[pytest]
asyncio_mode = auto
  • 증상: event_loop fixture를 재정의했더니 DeprecationWarning → 0.23+ 계열에서 event_loop fixture 재정의는 권장되지 않습니다. 루프 수명은 fixture를 덮어쓰는 대신 스코프 옵션으로 맞춥니다.

  • 증상: Task ... attached to a different loop (C계열) → session-scope fixture가 만든 객체를 function-scope 루프가 쓰고 있습니다. 스코프를 정렬하세요.

Python
import pytest, pytest_asyncio, httpx

@pytest_asyncio.fixture(loop_scope="session", scope="session")
async def client():
    async with httpx.AsyncClient(base_url="http://test") as c:
        yield c

@pytest.mark.asyncio(loop_scope="session")
async def test_ping(client):
    assert client is not None

핵심은 fixture의 scope와 루프의 loop_scope를 같은 값으로 맞추는 것입니다. 세션 스코프 fixture인데 루프가 함수마다 새로 생기면 두 번째 테스트부터 무조건 깨집니다. 반대로 모두 function 스코프로 통일해도 정상 동작합니다(느릴 뿐입니다). 그리고 pytest-asyncio는 마이너 버전 간 옵션 이름이 바뀐 이력이 있으므로 버전을 핀하고, 설치된 버전의 README/문서를 기준으로 옵션명을 확인하세요.

Bash
pip show pytest-asyncio | head -3

nest_asyncio 판정 체크리스트

nest_asyncio는 이미 도는 루프 안에서 asyncio.run()을 억지로 허용하도록 루프를 패치합니다. 편하지만 부작용이 있습니다.

써도 되는 경우

  • Jupyter/IPython에서 일회성 탐색·데모 코드를 돌릴 때
  • 되돌릴 수 있는 로컬 스크립트, 수명이 짧은 배치

쓰면 안 되는 경우

  • 운영 서버(FastAPI/uvicorn 등) — 루프 재진입은 태스크 취소·타임아웃·예외 전파 의미를 흐립니다
  • uvloop 사용 환경 — 표준 루프 구현을 전제로 한 패치라 호환되지 않는 것으로 알려져 있습니다
  • 배포용 라이브러리 — 사용자의 루프를 몰래 패치하는 것은 명백한 민폐입니다
  • 커넥션 풀·백그라운드 태스크 등 라이프사이클을 관리하는 코드

판정은 한 문장입니다. "이 코드가 다른 사람의 프로세스에서 돌 가능성이 있는가?" 있으면 쓰지 마세요.

재발 방지 패턴과 잘못된 해결책 3가지

Before — 전역 싱글턴 세션 안티패턴

Python
# app/clients.py  ❌
import httpx

client = httpx.AsyncClient(timeout=10.0)  # 임포트 시점 = 루프 밖

async def fetch(url: str):
    return await client.get(url)

임포트 시점에는 러닝 루프가 없습니다. 이 클라이언트는 첫 사용 루프에 묶이고, 테스트나 다중 워커·재시작 상황에서 루프가 바뀌면 Event loop is closed 또는 attached to a different loop로 무너집니다.

After — FastAPI lifespan에서 소유권 관리

Python
# app/main.py  ✅
from contextlib import asynccontextmanager
from fastapi import FastAPI, Request
import httpx

@asynccontextmanager
async def lifespan(app: FastAPI):
    # startup: 러닝 루프 안에서 생성
    app.state.http = httpx.AsyncClient(timeout=10.0)
    try:
        yield
    finally:
        # shutdown: 루프가 닫히기 전에 정리
        await app.state.http.aclose()

app = FastAPI(lifespan=lifespan)

핸들러에서는 요청 객체를 통해 주입받습니다.

Python
@app.get("/proxy")
async def proxy(request: Request):
    client: httpx.AsyncClient = request.app.state.http
    r = await client.get("https://example.com")
    return {"status": r.status_code}

이 구조의 이점은 세 가지입니다. ① 생성이 러닝 루프 에서 일어나므로 born_loop is None 위험이 사라집니다. ② 종료가 루프 종료 전에 보장되어 A계열 __del__ 잡음이 줄어듭니다. ③ 테스트에서 lifespan을 통째로 갈아끼울 수 있어 C계열이 구조적으로 막힙니다. DB 커넥션 풀, Redis 클라이언트, 백그라운드 태스크도 동일한 자리에서 만들고 닫으세요. 레거시 @app.on_event("startup")lifespan으로 전환하는 것이 현재 권장 방향입니다.

잘못된 해결책 3가지

우회책왜 통하는 것처럼 보이나실제 부작용
① 무지성 nest_asyncio.apply()에러 메시지가 즉시 사라짐루프 재진입으로 취소·타임아웃 의미가 깨지고, uvloop/anyio 스택에서 비호환. 운영에서 원인 추적 불가능한 데드락으로 이어질 수 있음
asyncio.set_event_loop(asyncio.new_event_loop()) 덮어쓰기새 루프에서는 일단 돈다기존 루프에 묶인 세션·락·큐가 전부 고아가 됨. C계열을 양산하고, 닫히지 않은 루프가 누적되면 fd 누수
try/except RuntimeError: pass 또는 요청마다 새 루프 생성로그가 조용해짐커넥션이 정리되지 않은 채 쌓임. 요청당 루프 생성은 커넥션 풀링·keep-alive 이점을 전부 버려 지연과 소켓 소진을 유발

에러를 숨기는 게 아니라 소유권을 옮기는 것이 정답입니다.

자주 묻는 질문 (FAQ)

Q1. Jupyter에서 asyncio.run() cannot be called from a running event loop가 납니다. 어떻게 하나요? ipykernel이 이미 루프를 돌리고 있기 때문입니다. 최신 IPython/Jupyter 환경에서는 셀에서 await coro()를 그대로 쓸 수 있으니 asyncio.run()을 지우고 await만 남기는 것이 1순위입니다. 그래도 동기 함수 안에서 호출해야 하는 탐색용 코드라면 그때 한해 nest_asyncio를 고려하되, 같은 코드를 운영에 옮길 때는 반드시 제거하세요.

Q2. Windows에서 프로그램이 정상 종료했는데도 RuntimeError: Event loop is closed가 찍힙니다. 트레이스백에 _ProactorBasePipeTransport.__del__ 같은 소멸자 프레임이 보이면, 루프가 닫힌 뒤 transport가 정리되면서 나는 종료 시점 잡음입니다. 실제 로직에는 영향이 없는 경우가 많지만, 근본 대응은 종료 전에 await session.close()(aiohttp) 또는 await client.aclose()(httpx)를 명시하고 잔여 태스크를 취소·대기하는 것입니다.

Q3. asyncio.get_event_loop()는 이제 쓰면 안 되나요? 루프 안에서 현재 루프가 필요하면 asyncio.get_running_loop(), 루프 밖에서 코루틴을 실행하려면 asyncio.run()(또는 3.11+ asyncio.Runner)을 쓰는 것이 안전합니다. get_event_loop()는 3.12 계열에서 경고와 함께 동작이 좁아지는 흐름이므로 신규 코드에서는 피하세요. 정확한 버전별 문구는 CPython 공식 asyncio 문서에서 확인하는 것을 권합니다.

결론: 3줄 런북

정리하면 이렇습니다.

  1. 루프 밖인가 안인가where_am_i()로 판정. INSIDE인데 asyncio.run()이 있으면 B계열, 그 자리에서 await로 교체.
  2. id(loop) 비교 — 생성 시점과 사용 시점이 다르면 C계열, 같은데 닫혀 있으면 A계열.
  3. 소유권을 옮긴다 — 세션·풀·락은 lifespan(운영)이나 스코프를 정렬한 fixture(테스트)에서 만들고 닫는다. 전역 임포트 시점 생성 금지.

이 세 줄만 지켜도 RuntimeError: Event loop is closed, This event loop is already running, Task attached to a different loop의 대부분은 재발하지 않습니다. 설치 단계 문제로 되돌아가야 한다면 시리즈의 PEP 668 편, venv~Poetry 의존성 충돌 편, ModuleNotFoundError 런북 편을 함께 참고하세요. 환경 문제와 런타임 루프 문제를 분리해서 보는 것만으로도 디버깅 시간이 크게 줄어듭니다.

다음 7편에서는 한 단계 더 들어가, 비동기 코드가 "에러 없이 느린" 상황 — 이벤트 루프를 막는 블로킹 호출을 탐지하고 run_in_executor·anyio.to_thread로 걷어내는 방법을 다루겠습니다.

✦ ✦ ✦
편집 검토 · Editorial Review

AI 도구는 자료 조사와 초안 작성의 보조 수단으로 사용될 수 있습니다. Nodelog는 공개 전 내용과 출처를 검토하고, 환경(OS·버전)에 따라 결과가 달라질 수 있는 기술 정보는 공식 문서를 함께 확인하도록 안내합니다. 오류를 발견하시면 이메일로 제보해 주세요 — 확인 후 신속히 정정합니다.

편집 책임 · Nodelog 기술 편집팀·발행 ·

댓글

첫 번째 댓글을 남겨보세요.