Published on

Python uvloop 도입 후 Event loop is closed 해결 가이드

Authors

서버 성능을 올리려고 uvloop를 붙였는데, 배포 후 로그에 갑자기 이런 에러가 보이기 시작합니다.

RuntimeError: Event loop is closed

대부분 “종료 중(shutdown)인데 아직 비동기 작업이 남아있거나”, “이미 닫힌 루프에 콜백/퓨처를 붙이려는 코드가 어딘가에 남아있거나”, “스레드/테스트에서 루프를 잘못 재사용”할 때 터집니다. uvloop가 원인이라기보다, 기존에 숨어 있던 루프 생명주기 버그가 uvloop 도입으로 더 잘 드러나는 경우가 많습니다.

이 글에서는 현업에서 자주 만나는 재현 패턴을 기준으로, uvloop 도입 후 Event loop is closed재발 없이 고치는 방법을 정리합니다.

에러가 나는 진짜 순간을 먼저 분류하자

같은 메시지라도 발생 시점에 따라 원인이 갈립니다. 로그/스택트레이스를 보고 아래 중 어디에 해당하는지 먼저 분류하세요.

  1. 프로세스 종료/재시작 시(SIGTERM, 롤링 업데이트, 컨테이너 종료)
  2. 요청 처리 도중 간헐적으로(특정 엔드포인트, 특정 트래픽 패턴)
  3. 테스트에서만(pytest, anyio/asyncio 혼용)
  4. 스레드와 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, aiohttpaclose()/__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으로 떨어지는지” 확인해보세요.