동기 FastAPI 핸들러에서 asyncio.create_task 가 조용히 죽는 이유

def 핸들러는 이벤트 루프가 아니라 스레드풀에서 돈다 — 그 안의 asyncio.create_task() 는 잡을 루프가 없어 코루틴째로 유실된다.

환경

  • Python 3.12
  • FastAPI 0.115 / Starlette 0.45

문제 상황

응답을 반환한 뒤 알림 하나를 fire-and-forget 으로 쏘는 엔드포인트였다. 핸들러는 동기(def)로 선언돼 있었고, 본문 끝에서 코루틴을 하나 띄웠다.

@router.post("/orders/{order_id}/approve")
def approve_order(order_id: int):
    result = approve(order_id)
    asyncio.create_task(notify(result))   # fire-and-forget
    return result

두 가지가 동시에 터졌다.

  • 엔드포인트가 500 을 반환한다.
  • 로그에 RuntimeError: no running event loop 가 찍히고, 곧이어 coroutine 'notify' was never awaited 경고가 뜬다. 알림은 한 번도 실행되지 않는다.

notify 자체는 멀쩡하다. create_task 를 부르는 그 줄에서 죽는다.

원인

FastAPI(Starlette)는 핸들러를 선언 방식에 따라 다른 곳에서 실행한다.

  • async def 핸들러 → 이벤트 루프 위에서 직접 실행.
  • def 핸들러 → 스레드풀(anyio worker thread) 에서 실행. 동기 블로킹 코드가 이벤트 루프를 멈추지 않게 하려는 설계다.

asyncio.create_task() 는 “지금 이 스레드에서 돌고 있는 이벤트 루프”에 태스크를 붙인다. 내부적으로 get_running_loop() 를 부르는데, 스레드풀 워커 스레드에는 도는 루프가 없다. 그래서 RuntimeError: no running event loop 로 죽는다.

한 글자 차이로 코드가 어디서 실행되는지가 갈리고, 거기서 운명이 결정된다.

graph TD
  R[요청 도착] --> S{핸들러 선언}
  S -->|async def| L[이벤트 루프 스레드]
  S -->|def| W[anyio 워커 스레드풀]
  L --> LOK[도는 루프 있음]
  W --> WNO[도는 루프 없음]
  LOK --> OK[create_task 성공<br/>태스크가 루프에 등록됨]
  WNO --> ERR[RuntimeError<br/>no running event loop]
  ERR --> DEAD[코루틴 유실<br/>never awaited]

핸들러를 def 로 내린 것 자체는 의도된 선택이었다 — 동기 DB 드라이버로 블로킹 쿼리를 하는 핸들러가 커넥션 풀 고갈 시 이벤트 루프를 통째로 얼리는 문제 때문에, 이런 핸들러들을 async defdef 로 전환하던 중이었다. 전환 과정에서 본문에 남아 있던 create_task 가 그대로 사각지대가 됐다. async def 였을 땐 멀쩡히 돌던 코드가, def 로 바뀌는 순간 조용히 깨진다.

해결

응답 반환 후 실행할 후처리는 asyncio 가 아니라 Starlette 의 BackgroundTasks 에 태운다. 등록된 작업은 응답이 나간 뒤 실행되고, 동기 함수는 스레드풀에서 돈다.

문제는 여기 태울 게 코루틴이라는 점이다. BackgroundTasks 는 sync/async 를 알아서 처리해주지만, 우리 쪽 후처리 함수엔 동기와 코루틴이 섞여 있었다. 그래서 항상 동기 러너로 감싸고, 코루틴이면 그 스레드에서 전용 루프로 완주시키는 헬퍼를 하나 뒀다.

import asyncio, inspect
from fastapi import BackgroundTasks

def fire_and_forget(background_tasks, func, *args, **kwargs):
    background_tasks.add_task(_run, func, *args, **kwargs)

def _run(func, *args, **kwargs):
    result = func(*args, **kwargs)
    if inspect.iscoroutine(result):
        asyncio.run(result)   # 이 스레드 전용 루프에서 완주
@router.post("/orders/{order_id}/approve")
def approve_order(order_id: int, background_tasks: BackgroundTasks):
    result = approve(order_id)
    fire_and_forget(background_tasks, notify, result)   # sync/async 무관
    return result

등록만 하고 즉시 반환하기 때문에, 코루틴이 도는 시점은 응답이 나간 뒤다.

sequenceDiagram
  participant C as 클라이언트
  participant L as 루프 스레드
  participant W as 워커 스레드
  C->>L: POST 요청
  L->>W: def 핸들러 오프로드
  W->>W: approve 실행
  W->>W: add_task 등록만 (즉시 반환)
  W-->>L: 반환값
  L-->>C: 응답 전송 — 여기서 체감 응답 끝
  L->>W: 응답 후 백그라운드 실행
  W->>W: asyncio.run 으로 코루틴 완주

버린 대안 — 핸들러 안에서 직접 asyncio.run(notify(result)). 스레드풀 스레드라 새 루프를 열어 돌릴 수는 있다. 하지만 그러면 알림이 끝날 때까지 응답이 블로킹된다. 위 그림에서 마지막 두 줄이 응답 전송 앞으로 당겨지는 셈이다. fire-and-forget 의 의미가 사라진다.

한 가지 주의 — 응답을 커스텀 Response 객체로 반환하는 경우. 데코레이터 등이 핸들러 반환값을 JSONResponse 로 감싸 돌려줘도 이 방식은 동작한다. FastAPI 가 라우팅 단계에서 response.background 에 수집된 BackgroundTasks 를 채워주기 때문이다. 직접 Response(...) 를 만들어 반환하면서 background= 를 지정하지 않으면 그때는 등록한 작업이 실행되지 않으니, 그 경우엔 명시적으로 넘겨야 한다.

재발을 막으려고 AST 로 “동기 핸들러 본문의 asyncio.create_task / ensure_future 호출”을 잡는 린트 테스트를 하나 붙였다. async def 핸들러(루프 위)와, 같은 이름의 관계없는 메서드(예: 진행률 트래커의 create_task())는 오탐이라 asyncio. 로 시작하는 호출만 걸러낸다.

이 버그의 뿌리는 한 문장으로 줄어든다: “이벤트 루프는 스레드에 묶여 있고, create_task 는 그 묶임을 전제한다.” 이 문장이 왜 참인지를 아래에서 층층이 푼다.

코루틴 객체는 그 자체로는 아무것도 하지 않는다

notify(result) 라고 쓰면 함수가 실행되는 게 아니라 코루틴 객체가 하나 생긴다. 제너레이터처럼, 코루틴은 “실행할 준비가 된 계산”을 담은 값일 뿐이다. 누군가 그것을 await 하거나 이벤트 루프에 태워 send() 를 반복 호출해줘야 비로소 코드가 돈다.

async def notify(x): ...

c = notify(1)      # 아직 아무 일도 안 일어남. c 는 coroutine 객체
# 여기서 c 를 그냥 버리면 → "coroutine 'notify' was never awaited"

create_task 가 죽어버린 뒤 뜨는 never awaited 경고가 정확히 이 상황이다. 예외로 코루틴이 태워지지도, await 되지도 못한 채 가비지 컬렉션됐다는 신호다. 즉 에러 한 줄과 경고 한 줄은 별개 사건이 아니라 인과다 — create_task 가 예외로 죽었기 때문에 코루틴이 붕 뜬 것이다. 로그에서 이 둘을 세트로 읽을 줄 알아야 한다.

이벤트 루프는 스레드-로컬이다

이벤트 루프는 “지금 어떤 태스크들이 실행 대기·IO 대기 중인지”를 들고 단일 스레드에서 한 번에 하나씩 코루틴을 전진시키는 스케줄러다. asyncio 는 이 루프를 스레드마다 따로 관리한다 — 루프는 특정 OS 스레드에 바인딩되고, 다른 스레드에서는 그 루프가 보이지 않는다. 그래서 조회 API 가 두 종류다.

  • get_running_loop()지금 이 스레드에서 실제로 돌고 있는 루프. 없으면 RuntimeError.
  • get_event_loop() — (레거시) 이 스레드에 설정된 루프를 반환하거나 없으면 만들어줌. 돌고 있는지와 무관해서 “루프는 얻었는데 아무도 안 돌리는” 함정을 만든다. 그래서 파이썬 3.10+ 는 루프 밖에서의 이 호출을 deprecate 했다.

asyncio.create_task() 는 내부적으로 get_running_loop() 를 부른다. 이름과 달리 루프를 만들지 않는다 — 이미 도는 루프에 태스크를 얹어 “이 코루틴도 같이 굴려달라”고 등록할 뿐이다. 그러니 도는 루프가 없는 스레드에서 부르면 등록할 대상이 없어 즉사한다.

대비되는 게 asyncio.run(coro) 다. 이쪽은 새 루프를 만들어, 코루틴이 끝날 때까지 돌리고, 루프를 닫는다. 스레드풀 워커 스레드처럼 도는 루프가 없는 곳에서 코루틴을 완주시켜야 할 때 쓰는 도구가 이거다(해결에서 헬퍼가 쓴 게 정확히 이것). 대신 run 은 코루틴이 끝날 때까지 블로킹한다 — fire-and-forget 이 아니다.

정리하면 이름이 헷갈리게 지어졌을 뿐, 역할은 명확히 갈린다.

함수 루프를 만드나 블로킹하나 도는 루프가 없으면
create_task(c) 아니오 아니오(등록만) RuntimeError
ensure_future(c) 아니오 아니오 RuntimeError
run(c) 예(새로) 예(완주까지) 정상 동작
await c 아니오 예(완주까지) 문법상 async 함수 안에서만

async 핸들러와 sync 핸들러는 실행되는 장소가 다르다

이벤트 루프는 단일 스레드에서 돈다. 그 스레드 위에서 블로킹 호출(동기 DB 드라이버, 파일 IO, time.sleep, CPU 바운드 루프)이 실행되면 루프가 그 시간 동안 다른 어떤 요청도 전진시키지 못한다. 한 요청의 200ms 블로킹이 전체 서버의 200ms 정지가 된다.

Starlette/ASGI 는 이 위험을 핸들러 선언 방식으로 가른다.

  • async def → 개발자가 “이건 블로킹 안 한다”고 약속한 것으로 보고 루프 스레드에서 직접 실행. 여기서는 create_task 가 당연히 동작한다 — 도는 루프 위니까.
  • def → 블로킹일 수 있다고 보고 anyio 워커 스레드풀로 오프로드. 루프 스레드는 자유로워져 다른 요청을 계속 처리한다. 대신 그 워커 스레드엔 도는 루프가 없다.

바로 이 지점이 함정이다. 같은 코드가 핸들러 선언 한 글자(async)에 따라 살고 죽는다. async def 였던 핸들러를 블로킹 이슈 때문에 def 로 내리는 순간, 본문에 얌전히 있던 create_task 는 아무 경고 없이 실행 위치가 루프 밖으로 바뀌어 깨진다. 리팩터링이 유발하는 이런 “위치 의존 버그”는 타입 체커도 못 잡는다 — 그래서 해결에서 AST 린트로 못을 박은 것이다.

이 차이는 그림 한 장으로 줄어든다. 아래는 두 스레드를 나란히 잘라 놓은 단면도다. 스레드마다 루프 자리가 하나씩 있고, 후처리 호출 셋이 그 자리를 각각 어떻게 대하는지 — 읽기만 하는지, 임시로 채우는지, 아예 안 보는지 — 를 표시했다.

스레드마다 하나씩 있는 “도는 루프 자리” 단면도 왼쪽은 이벤트 루프 스레드로, 루프 자리가 도는 이벤트 루프로 채워져 있어 asyncio.create_task 가 코루틴을 태스크로 등록한다. 오른쪽은 def 핸들러가 오프로드되는 anyio 워커 스레드로, 루프 자리가 비어 있다. 그 자리를 읽기만 하는 create_task 는 RuntimeError 로 막히고 코루틴은 never awaited 로 사라진다. asyncio.run 은 임시 루프를 만들어 그 자리에 꽂아 완주시키지만 그동안 응답이 나가지 못한다. BackgroundTasks 는 그 자리를 아예 보지 않고, 응답이 나간 뒤에 실행하도록 시점만 옮긴다. 이벤트 루프 스레드 async def 핸들러가 여기서 실행 asyncio.create_task(coro) 이 스레드의 루프 자리 도는 이벤트 루프 get_running_loop() → OK 자리를 읽는다 → 태스크로 등록 루프가 이 코루틴도 함께 굴린다 단, 루프는 태스크를 약한 참조로 붙든다. 반환값을 안 들고 있으면 GC 될 수 있다. anyio 워커 스레드 def 핸들러가 여기서 실행 — 블로킹 대비 오프로드 이 스레드의 루프 자리 비어 있음 get_running_loop() ✕ 임시 루프 — run 이 만듦 자리에 꽂는다 응답이 나간 뒤 루프가 다시 워커로 보냄 그때 완주 asyncio.create_task(coro) 자리를 읽기만 한다 → RuntimeError 코루틴은 태워지지도 못한 채 GC 된다 never awaited 경고의 정체 asyncio.run(coro) 빈 자리를 임시로 채워 완주시킨다 대신 이 줄이 끝날 때까지 응답이 못 나간다 background_tasks.add_task(...) 자리를 아예 보지 않는다 실행 시점만 응답 뒤로 옮길 뿐, 굴리는 도구는 결국 위의 임시 루프다
create_task 는 넘겨받은 코루틴을 보지 않는다 — 자기가 선 스레드의 루프 자리를 볼 뿐이다. 그 자리는 스레드마다 따로 파여 있고, def 핸들러가 오프로드된 워커 스레드에서는 비어 있다. 빈 자리 앞에서 남는 선택지는 둘뿐이다: 임시 루프로 지금 채우거나(응답을 붙잡는 대가), 채우는 일을 응답 뒤로 미루거나.

정리하면 이렇다. create_task루프 위에서만 살고, asyncio.run루프 밖에서만 살되 블로킹을 대가로 치른다. 응답 블로킹 없이 코루틴을 실제로 완주시키는 길은 BackgroundTasks 하나뿐이다. 한 가지 더 — async def + create_task 는 동작하지만, 루프가 태스크를 약한 참조로만 붙들기 때문에 반환된 태스크 객체를 아무도 들고 있지 않으면 완료 전에 GC 될 수 있다. 루프 위라고 fire-and-forget 이 공짜인 건 아니다.

(참고로 스레드풀 오프로드에는 anyio 의 CapacityLimiter 로 동시 실행 상한이 걸린다. 즉 def 로 내린다고 무한 병렬이 되는 게 아니라, 스레드 자원 안에서 관리된다.)

응답 후 후처리의 자리 — 그리고 그 한계

BackgroundTasks 는 ASGI 응답 전송이 끝난 뒤 실행된다. 그래서 후처리 시간이 클라이언트 응답 지연에 얹히지 않는다. sync/async 작업을 둘 다 받고, sync 는 스레드풀에서 돌린다 — 이 글의 헬퍼는 여기에 “코루틴이면 전용 루프로 완주”만 얹은 얇은 어댑터다.

한계도 분명히 알아야 한다. BackgroundTasks같은 프로세스, 응답 직후, 짧게 하는 후처리의 자리다. 프로세스가 죽으면 태스크도 같이 죽고, 재시도도 영속성도 없다. “반드시 완료돼야 하는” 작업(결제 후속, 정산, 장시간 배치)을 여기 태우면 배포·크래시 한 번에 유실된다. 그 층위는 외부 큐/워커(별도 프로세스 + 원장 + 재시도)의 몫이다.

그리고 이 모든 이야기의 밑바닥에는 “코루틴을 루프 밖에서 fire-and-forget 하려는 시도 자체가 항상 어댑터를 요구한다” 는 원칙이 있다. create_task 는 루프 위에서만, run 은 블로킹으로만, await 는 async 함수 안에서만 코루틴을 굴린다. “루프도 없는 동기 문맥에서, 블로킹도 없이, 나중에” 굴리고 싶다면 — 그 셋 중 어느 것도 그대로는 맞지 않고, 실행 시점을 옮겨줄 무언가(여기서는 BackgroundTasks)가 반드시 끼어야 한다.

참고