- Published on
Python uvloop 도입 후 Event loop is closed 해결 가이드
- Authors
- Name
- 스타차일드
- https://x.com/ETFBITX
서버 성능을 올리려고 uvloop를 붙였는데, 배포 후 로그에 갑자기 이런 에러가 보이기 시작합니다.
RuntimeError: Event loop is closed
대부분 “종료 중(shutdown)인데 아직 비동기 작업이 남아있거나”, “이미 닫힌 루프에 콜백/퓨처를 붙이려는 코드가 어딘가에 남아있거나”, “스레드/테스트에서 루프를 잘못 재사용”할 때 터집니다. uvloop가 원인이라기보다, 기존에 숨어 있던 루프 생명주기 버그가 uvloop 도입으로 더 잘 드러나는 경우가 많습니다.
이 글에서는 현업에서 자주 만나는 재현 패턴을 기준으로, uvloop 도입 후 Event loop is closed를 재발 없이 고치는 방법을 정리합니다.
에러가 나는 진짜 순간을 먼저 분류하자
같은 메시지라도 발생 시점에 따라 원인이 갈립니다. 로그/스택트레이스를 보고 아래 중 어디에 해당하는지 먼저 분류하세요.
- 프로세스 종료/재시작 시(SIGTERM, 롤링 업데이트, 컨테이너 종료)
- 요청 처리 도중 간헐적으로(특정 엔드포인트, 특정 트래픽 패턴)
- 테스트에서만(pytest, anyio/asyncio 혼용)
- 스레드와 asyncio 혼용 시(ThreadPoolExecutor, background thread)
이 중 1번이 가장 흔합니다. 특히 FastAPI/Uvicorn/Gunicorn 조합에서 “graceful shutdown”이 제대로 안 되면, 종료 중에 남은 태스크가 루프에 접근하면서 터집니다.
uvloop의 올바른 적용 방식
Uvicorn/FastAPI에서는 uvicorn 옵션을 우선
FastAPI를 Uvicorn으로 띄운다면, 애플리케이션 코드에서 uvloop.install()을 직접 호출하기보다 Uvicorn이 제공하는 루프 설정을 사용하는 게 가장 안전합니다.
uvicorn app:app --loop uvloop
Gunicorn을 쓰면:
gunicorn -k uvicorn.workers.UvicornWorker app:app \
--workers 4 \
--graceful-timeout 30 \
--timeout 60
--loop uvloop또는UvicornWorker가 uvloop를 적절한 시점에 설치합니다.- 앱 코드에서 전역으로 루프를 만지는 습관(예: import 시점에 loop 생성/저장)이 있으면 shutdown 때 폭발합니다.
asyncio.get_event_loop() 전역 캐싱 금지
아래 패턴은 uvloop/기본 루프 모두에서 위험하지만, uvloop 도입 후 더 자주 터집니다.
# ❌ 나쁜 예: import 시점에 loop를 잡아두기
import asyncio
LOOP = asyncio.get_event_loop()
async def do_work():
fut = asyncio.run_coroutine_threadsafe(coro(), LOOP)
return await asyncio.wrap_future(fut)
왜 위험하냐면:
- Uvicorn은 워커/프로세스 단위로 루프를 만들고 종료 시 닫습니다.
- 전역에 저장된
LOOP는 이미 종료된 루프를 가리킬 수 있습니다.
대신 “현재 실행 중인 루프”를 사용하세요.
# ✅ 좋은 예: 실행 중인 루프를 그때그때 사용
import asyncio
async def do_work():
loop = asyncio.get_running_loop()
# loop 기반 작업 수행
스레드에서 코루틴을 실행해야 한다면, “그 스레드가 소유한 루프”를 명확히 관리해야 합니다(아래 참고).
케이스 1: 종료(shutdown) 중 백그라운드 태스크가 남아 터지는 경우
증상
- SIGTERM 이후 또는 재시작 시점에
Event loop is closed - 함께
Task was destroyed but it is pending!가 보이기도 함
이 경우는 거의 항상 취소/정리되지 않은 백그라운드 태스크가 원인입니다. 관련 경고를 더 깊게 다룬 글로는 Python asyncio Task was destroyed but it is pending 경고 원인 5가지와 완벽 해결법도 같이 참고하면 좋습니다.
흔한 원인
asyncio.create_task()로 만든 작업을 추적하지 않음- 종료 이벤트에서
await로 정리하지 않음 - http client(aiohttp/httpx), DB pool, Redis 커넥션을 닫지 않음
- “재시도 루프/폴링 루프”가 무한히 돌고 있음
해결: FastAPI lifespan에서 태스크 추적 + 취소 + await
@app.on_event("startup")/shutdown도 가능하지만, 최신 권장인 lifespan 컨텍스트를 추천합니다.
from contextlib import asynccontextmanager
import asyncio
from fastapi import FastAPI
stop_event = asyncio.Event()
background_tasks: set[asyncio.Task] = set()
async def poller():
try:
while not stop_event.is_set():
# ... 작업 ...
await asyncio.sleep(1)
except asyncio.CancelledError:
# 취소 시 정리 로직
raise
@asynccontextmanager
async def lifespan(app: FastAPI):
# startup
stop_event.clear()
task = asyncio.create_task(poller(), name="poller")
background_tasks.add(task)
try:
yield
finally:
# shutdown
stop_event.set()
for t in list(background_tasks):
t.cancel()
# 중요: 취소된 태스크를 반드시 await 해서 예외를 수거
results = await asyncio.gather(*background_tasks, return_exceptions=True)
background_tasks.clear()
# 취소 예외 외의 예외는 로깅
for r in results:
if isinstance(r, Exception) and not isinstance(r, asyncio.CancelledError):
# logger.exception("background task failed", exc_info=r)
pass
app = FastAPI(lifespan=lifespan)
포인트는 3가지입니다.
- 태스크를 set에 넣어 추적
- shutdown에서 cancel
gather(..., return_exceptions=True)로 반드시 await해서 “닫힌 루프에 뭔가가 남아있는 상태”를 없앰
Uvicorn/Gunicorn 종료 튜닝도 같이 확인
--graceful-timeout이 너무 짧으면 정리 중 강제 종료되어 루프가 닫히며 에러가 남습니다.- K8s라면 preStop + terminationGracePeriodSeconds도 함께 봐야 합니다.
스트리밍/장시간 연결이 있는 서비스라면 종료 및 타임아웃 튜닝이 더 민감합니다. 관련해서는 Kubernetes LLM 서비스 502 504 간헐 장애와 스트리밍 끊김을 끝내는 NGINX Ingress와 Gunicorn Uvicorn 실전 튜닝도 함께 보면 “종료 시점 에러”를 줄이는 데 도움이 됩니다.
케이스 2: httpx/aiohttp 클라이언트가 닫힌 루프에서 close 되며 터지는 경우
증상
- 앱 종료 시점 또는 특정 요청 이후
- 스택트레이스에
httpx,anyio,aiohttp의aclose()/__del__가 보임
원인
클라이언트를 전역으로 만들고, 종료 시점에 제대로 닫지 않으면 GC(가비지 컬렉션)나 atexit 단계에서 __del__이 돌면서 이미 닫힌 루프에 접근할 수 있습니다.
해결: lifespan에서 생성/종료를 명시
from contextlib import asynccontextmanager
from fastapi import FastAPI
import httpx
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.http = httpx.AsyncClient(timeout=10)
try:
yield
finally:
await app.state.http.aclose()
app = FastAPI(lifespan=lifespan)
@app.get("/ping")
async def ping():
r = await app.state.http.get("https://example.com")
return {"status": r.status_code}
- “전역 AsyncClient” 자체는 가능하지만, 생명주기를 프레임워크에 붙여서 닫히는 시점을 통제해야 합니다.
케이스 3: 스레드에서 asyncio를 잘못 호출하는 경우
증상
- 간헐적
run_coroutine_threadsafe,call_soon_threadsafe근처에서 터짐- uvloop 도입 후 빈도 증가
원인
스레드에서 asyncio.get_event_loop()를 호출하면, 파이썬 버전/정책에 따라 “현재 스레드에 루프가 없음” 또는 “엉뚱한 루프”를 잡을 수 있습니다. 더 나쁘게는 메인 루프를 전역 캐싱해두고 스레드에서 계속 쓰다가, 종료 후에도 스레드가 살아있어 닫힌 루프에 접근합니다.
해결 1: 스레드는 스레드대로 루프를 만들고 종료
import asyncio
import threading
class LoopThread(threading.Thread):
def __init__(self):
super().__init__(daemon=True)
self.loop = None
self._ready = threading.Event()
def run(self):
self.loop = asyncio.new_event_loop()
asyncio.set_event_loop(self.loop)
self._ready.set()
self.loop.run_forever()
self.loop.close()
def submit(self, coro):
self._ready.wait()
return asyncio.run_coroutine_threadsafe(coro, self.loop)
def stop(self):
if self.loop and self.loop.is_running():
self.loop.call_soon_threadsafe(self.loop.stop)
loop_thread = LoopThread()
loop_thread.start()
async def job():
await asyncio.sleep(0.1)
return 123
future = loop_thread.submit(job())
print(future.result())
loop_thread.stop()
핵심은 루프 생성/실행/종료를 한 스레드가 책임지게 하는 것입니다.
해결 2: 가능하면 스레드 대신 asyncio.to_thread 사용
비동기 루프 안에서 동기 함수를 돌리고 싶을 뿐이라면:
import asyncio
def blocking_io():
# ...
return "ok"
async def handler():
return await asyncio.to_thread(blocking_io)
이렇게 하면 “스레드가 루프를 건드리는 문제”를 원천 차단할 수 있습니다.
케이스 4: pytest/anyio 환경에서 루프가 닫힌 뒤 픽스처가 접근
증상
- 로컬 테스트에서만
Event loop is closed - 테스트 종료 단계(teardown)에서 발생
원인
pytest-asyncio의 event_loop 픽스처 스코프와, 앱/클라이언트 픽스처 스코프가 어긋남- anyio 기반(TestClient, httpx AsyncClient)과 asyncio 픽스처를 섞어 teardown 순서가 꼬임
해결: 루프 스코프를 테스트 스코프에 맞추기
예: 세션 스코프 루프를 쓸 거면, 그 루프를 쓰는 리소스도 세션 스코프에서 정리되게 맞춥니다.
import pytest
import asyncio
@pytest.fixture(scope="session")
def event_loop():
loop = asyncio.new_event_loop()
yield loop
loop.close()
그리고 AsyncClient/앱 리소스도 teardown에서 await aclose()가 보장되게 구성하세요.
트러블슈팅 체크리스트
1) 종료 시점 로그를 의도적으로 늘려라
- SIGTERM 수신 시점
- lifespan/shutdown 진입/종료
- 백그라운드 태스크 개수
- 커넥션 풀 close 여부
2) “전역에 저장된 loop”가 있는지 grep
get_event_loop()결과를 모듈 전역에 저장run_coroutine_threadsafe(..., LOOP)
3) atexit/__del__에서 비동기 close가 호출되는지 확인
- 전역 객체의
__del__이 네트워크 close 수행 - 종료 시점에만 재현되면 특히 의심
4) shutdown에서 cancel만 하고 await 안 하는 코드 제거
# ❌ cancel만 하고 끝내면 루프 닫힐 때 잔여 콜백이 터질 수 있음
for t in tasks:
t.cancel()
# ✅ 반드시 gather로 수거
await asyncio.gather(*tasks, return_exceptions=True)
Best Practice 요약
- uvloop는 서버 런타임(uvicorn/gunicorn) 설정으로 적용하고, 앱 코드에서 루프를 직접 만지는 일을 최소화합니다.
asyncio.get_running_loop()를 사용하고, 루프 전역 캐싱을 금지합니다.- 백그라운드 태스크는 “생성-추적-취소-대기(await)”까지 한 세트로 관리합니다.
- http client/DB/Redis 등 비동기 리소스는 lifecycle에 묶어서 명시적으로 close합니다.
- 스레드 혼용 시 루프 소유권을 분리하거나
asyncio.to_thread로 우회합니다.
결론: uvloop는 성능을 올려주지만, 루프 생명주기는 더 엄격해져야 한다
RuntimeError: Event loop is closed는 대부분 “uvloop 버그”가 아니라 종료/정리 순서가 불명확한 코드가 드러난 신호입니다. 이 글의 순서대로 (1) 발생 시점 분류 → (2) 전역 루프 제거 → (3) lifespan에서 태스크/리소스 정리 → (4) 스레드/테스트 스코프 정렬을 적용하면 재발을 크게 줄일 수 있습니다.
지금 운영 중인 서비스라면, 오늘 바로 lifespan에 종료 정리 로직을 추가하고 배포에서 SIGTERM 롤링 업데이트를 한 번 돌려 “에러가 0으로 떨어지는지” 확인해보세요.