로컬에선 멀쩡한데 Jupyter·pytest·운영에서만 터진다
파이썬 개발 가이드 5편까지는 PEP 668 외부 관리 환경 문제, venv/Poetry 의존성 충돌, ModuleNotFoundError 런북처럼 실행 전에 터지는 설치·환경 이슈를 다뤘습니다. 그런데 환경을 아무리 정확히 맞춰도, 코드가 돌기 시작한 뒤에 터지는 계열이 하나 남습니다. asyncio 이벤트 루프 에러입니다.
대표적으로 이 세 줄입니다.
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 closed | B계열 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__ 등장) | — | 스레드마다 다른 루프를 세팅했을 때 발생 |
확인 명령 한 줄과 다음 행동은 이렇게 잡습니다.
| 계열 | 확인 한 줄 | 정답 패턴 |
|---|---|---|
| A | python -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 — 루프 안인가 밖인가
이 함수를 문제 지점 바로 위에 붙여 호출하세요.
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__)예상 출력과 분기입니다.
# (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계열은 이 대조로 갈립니다. 세션을 만든 곳과 쓰는 곳에 각각 심으세요.
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를 쓴다면 내부 속성으로 직접 비교할 수 있습니다(비공개 속성이므로 진단용으로만).
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계열은 거의 사라집니다.
- 애플리케이션 진입점에서 딱 한 번
asyncio.run(main()). 프로세스 전체에서 1회입니다. - 이미 루프가 도는 호스트 환경(Jupyter, uvicorn, Celery의 일부 워커, GUI 프레임워크)에서는 새 루프를 만들지 않습니다.
await로 흡수하거나, 굳이 동기 함수에서 호출해야 하면 별도 스레드에서asyncio.run_coroutine_threadsafe(coro, loop)를 씁니다. - 라이브러리 코드는 절대 루프를 만들지 않습니다. 라이브러리는 코루틴만 노출하고, 루프 생성·종료는 호출자에게 맡깁니다.
loop.run_until_complete는 이미 루프 객체를 명시적으로 소유·관리하는 레거시 코드에서만 남겨 두세요.
Python 3.10 / 3.11 / 3.12 동작 차이
| 항목 | 3.10 | 3.11 | 3.12 |
|---|---|---|---|
asyncio.get_event_loop() (루프 밖 호출) | 루프가 없으면 생성하고 경고 없음/약함 | Deprecation 흐름 진행 | DeprecationWarning 발생, 현재 루프 없을 때 자동 생성 의존 금지 |
| 러닝 루프 없을 때 자동 루프 생성 | 대체로 동작 | 축소 방향 | 제거 방향으로 이동 — 의존 코드는 깨질 수 있음 |
asyncio.Runner | 없음 | 도입 | 사용 가능 |
TaskGroup / asyncio.timeout() | 없음 | 도입 | 사용 가능 |
| 권장 진입점 | asyncio.run() | asyncio.run() 또는 Runner | asyncio.run() / Runner |
정확한 버전별 문구는 CPython의 asyncio 공식 문서와 각 릴리스 "What's New" 문서를 확인하세요. 실무 결론은 단순합니다. asyncio.get_event_loop()를 코드에서 없애는 것이 3.12+ 마이그레이션의 90%입니다. 루프 안이면 asyncio.get_running_loop(), 루프 밖이면 asyncio.run()으로 대체하면 됩니다.
3.11+에서 루프 정책까지 제어해야 한다면 Runner가 깔끔합니다.
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" → 모드 설정 누락입니다.
# pyproject.toml
[tool.pytest.ini_options]
asyncio_mode = "auto"; pytest.ini 를 쓴다면
[pytest]
asyncio_mode = auto-
증상:
event_loopfixture를 재정의했더니 DeprecationWarning → 0.23+ 계열에서event_loopfixture 재정의는 권장되지 않습니다. 루프 수명은 fixture를 덮어쓰는 대신 스코프 옵션으로 맞춥니다. -
증상:
Task ... attached to a different loop(C계열) → session-scope fixture가 만든 객체를 function-scope 루프가 쓰고 있습니다. 스코프를 정렬하세요.
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/문서를 기준으로 옵션명을 확인하세요.
pip show pytest-asyncio | head -3nest_asyncio 판정 체크리스트
nest_asyncio는 이미 도는 루프 안에서 asyncio.run()을 억지로 허용하도록 루프를 패치합니다. 편하지만 부작용이 있습니다.
써도 되는 경우
- Jupyter/IPython에서 일회성 탐색·데모 코드를 돌릴 때
- 되돌릴 수 있는 로컬 스크립트, 수명이 짧은 배치
쓰면 안 되는 경우
- 운영 서버(FastAPI/uvicorn 등) — 루프 재진입은 태스크 취소·타임아웃·예외 전파 의미를 흐립니다
uvloop사용 환경 — 표준 루프 구현을 전제로 한 패치라 호환되지 않는 것으로 알려져 있습니다- 배포용 라이브러리 — 사용자의 루프를 몰래 패치하는 것은 명백한 민폐입니다
- 커넥션 풀·백그라운드 태스크 등 라이프사이클을 관리하는 코드
판정은 한 문장입니다. "이 코드가 다른 사람의 프로세스에서 돌 가능성이 있는가?" 있으면 쓰지 마세요.
재발 방지 패턴과 잘못된 해결책 3가지
Before — 전역 싱글턴 세션 안티패턴
# 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에서 소유권 관리
# 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)핸들러에서는 요청 객체를 통해 주입받습니다.
@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줄 런북
정리하면 이렇습니다.
- 루프 밖인가 안인가 —
where_am_i()로 판정.INSIDE인데asyncio.run()이 있으면 B계열, 그 자리에서await로 교체. id(loop)비교 — 생성 시점과 사용 시점이 다르면 C계열, 같은데 닫혀 있으면 A계열.- 소유권을 옮긴다 — 세션·풀·락은
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로 걷어내는 방법을 다루겠습니다.
AI 도구는 자료 조사와 초안 작성의 보조 수단으로 사용될 수 있습니다. Nodelog는 공개 전 내용과 출처를 검토하고, 환경(OS·버전)에 따라 결과가 달라질 수 있는 기술 정보는 공식 문서를 함께 확인하도록 안내합니다. 오류를 발견하시면 이메일로 제보해 주세요 — 확인 후 신속히 정정합니다.
댓글
첫 번째 댓글을 남겨보세요.