
FastAPI BackgroundTasks가 조용히 죽는 지점은 크게 세 가지입니다. 워커 프로세스 재시작, 작업 안에서 발생한 처리되지 않은 예외, 그리고 이미 실행 중인 이벤트 루프 안에서 asyncio.run()을 호출하는 실수입니다. 셋 다 클라이언트에게는 200 응답이 정상적으로 돌아가기 때문에, 문제를 눈치채는 시점이 한참 늦어진다는 공통점이 있습니다.
이 글은 Starlette 기반 FastAPI(백그라운드 작업 처리 구조가 여러 버전에 걸쳐 바뀌지 않은 최신 버전대)와 Uvicorn 조합을 전제로, 어디서 조용히 실패가 나는지와 어느 시점에 Celery·RQ 같은 별도 워커로 옮겨야 하는지를 판단 기준으로 정리합니다.
FastAPI BackgroundTasks, 정확히 언제 실행되나요?
BackgroundTasks.add_task()로 등록한 함수는 요청 처리와 동시에 실행되는 게 아닙니다. 응답 바디를 클라이언트로 전송한 직후, 같은 요청의 ASGI 호출 스택 안에서 실행됩니다. 즉 별도 프로세스나 별도 스레드가 새로 생기는 구조가 아니라, 지금 요청을 처리 중인 워커가 응답을 다 보낸 다음 잠깐 더 일을 하는 방식입니다.
이 구조를 만든 쪽은 FastAPI가 아니라 Starlette입니다. FastAPI는 이를 그대로 감싸서 노출할 뿐입니다. 공식 문서에도 짧은 알림 발송이나 로그 기록처럼 가벼운 작업에 쓰라고 명시돼 있고, 무거운 작업은 별도 큐 시스템을 권장합니다. 자세한 동작 방식은 FastAPI 공식 문서의 Background Tasks 페이지에서 확인할 수 있습니다.
여기서 중요한 건, 이 작업이 ‘지금 요청을 처리하던 그 워커’를 벗어나지 못한다는 점입니다. 워커가 없어지면 작업도 함께 없어집니다.
워커가 재시작되면 백그라운드 작업은 조용히 사라집니다
배포 스크립트가 git pull 후 서버를 재시작하는 구조든, Docker 컨테이너를 롤링 배포하는 구조든 원리는 같습니다. BackgroundTasks에 등록된 작업은 디스크나 외부 큐에 저장되지 않고 오직 그 프로세스의 메모리 위에만 존재합니다.
응답을 이미 클라이언트로 보낸 뒤에 SIGTERM이 들어오면, 서버가 우아한 종료(graceful shutdown)를 얼마나 기다려주느냐에 따라 결과가 갈립니다. Uvicorn은 종료 대기 시간을 설정할 수 있지만, 이 값은 배포 환경마다 다르게 잡혀 있는 경우가 많아서 기본값만 믿고 넘어가면 재배포 타이밍에 걸린 작업들이 그대로 증발합니다.

문제는 로그에도 흔적이 잘 남지 않는다는 점입니다. 프로세스가 정상 종료된 것처럼 보이기 때문에, 나중에 “그 알림이 왜 안 왔지” 같은 문의가 들어와야 뒤늦게 원인을 추적하게 됩니다. 재시도 로직이나 영속 큐 없이 BackgroundTasks만 쓰는 서비스라면, 재배포 직전 몇 초 동안 들어온 요청의 후속 작업은 유실될 수 있다는 전제를 깔고 설계해야 합니다.
응답은 200인데 작업은 왜 조용히 죽어버릴까요?
아래 코드를 보겠습니다.
from fastapi import FastAPI, BackgroundTasks
app = FastAPI()
def send_welcome_email(to: str):
# 예: SMTP 연결 실패, 잘못된 주소 등으로 예외 발생
raise RuntimeError(f"메일 발송 실패: {to}")
@app.post("/signup")
async def signup(email: str, background_tasks: BackgroundTasks):
background_tasks.add_task(send_welcome_email, email)
return {"status": "ok"}
이 코드를 호출하면 클라이언트는 {"status": "ok"}와 함께 200을 그대로 받습니다. send_welcome_email 안에서 발생한 RuntimeError는 응답이 이미 전송된 뒤에 터지기 때문에, 클라이언트 쪽 에러 핸들링이나 4xx/5xx 응답으로 전혀 드러나지 않습니다.
대신 서버 콘솔이나 Uvicorn 로그에는 트레이스백이 찍힙니다. 다만 이 로그를 별도로 수집·알림 설정을 해두지 않았다면, 콘솔 출력은 그냥 쌓이기만 하고 아무도 보지 않는 채로 묻힙니다. 이게 “조용히 죽는다”는 표현이 정확히 들어맞는 지점입니다 — 에러가 나긴 나는데, 그걸 볼 사람이 아무도 지정돼 있지 않은 상태입니다.
또 하나 자주 나오는 실수는 이미 실행 중인 이벤트 루프 안에서 asyncio.run()을 다시 호출하는 경우입니다. FastAPI의 요청 핸들러 자체가 이벤트 루프 위에서 돌고 있기 때문에, 백그라운드 작업 안에서 새 루프를 만들려고 asyncio.run()을 쓰면 “asyncio.run() cannot be called from a running event loop”라는 RuntimeError가 즉시 발생합니다. 이 동작은 파이썬 공식 문서의 asyncio.run() 설명에도 명시돼 있습니다. 해결책은 새 루프를 만드는 대신 await로 직접 호출하거나, 필요하면 asyncio.create_task()로 같은 루프 위에 얹는 것입니다.
동기 함수와 비동기 함수, 스레드풀 처리 차이
add_task()에 넘긴 함수가 def인지 async def인지에 따라 실행 위치가 달라집니다. async def로 만든 작업은 요청을 처리하던 이벤트 루프 위에서 그대로 실행됩니다. 반면 일반 def 함수는 별도 스레드풀에서 실행되어, 동기적인 블로킹 코드(파일 I/O, 외부 라이브러리 호출 등)가 있어도 이벤트 루프 자체를 막지는 않습니다.

다만 이 스레드풀도 동시에 처리할 수 있는 개수에 상한이 있습니다. 짧은 작업이 간헐적으로 들어오는 정도라면 문제가 안 되지만, 초당 수십 건씩 동기 백그라운드 작업이 몰리면 스레드풀 대기열이 쌓이면서 응답 자체는 멀쩡한데 후속 작업 처리만 지연되는 상황이 생깁니다.
| 구분 | def 백그라운드 함수 |
async def 백그라운드 함수 |
|---|---|---|
| 실행 위치 | 별도 스레드풀 | 요청과 같은 이벤트 루프 |
| 블로킹 I/O 영향 | 다른 요청에 영향 적음 | 다른 요청 처리 지연 가능 |
| 동시 처리 한도 | 스레드풀 크기에 제한됨 | 루프 하나를 여러 작업이 공유 |
| 적합한 작업 | 파일 저장, 외부 CLI 호출 등 | 짧은 DB 쓰기, 외부 API 논블로킹 호출 |
async def 작업 안에 실수로 블로킹 코드(예: requests.get(), 동기 DB 드라이버 호출)를 넣으면 이벤트 루프 전체가 멈춰서, 같은 워커가 처리하던 다른 요청들의 응답까지 함께 늦어집니다. 이 경우는 백그라운드 작업 하나의 실패로 끝나지 않고 서비스 전체 지연으로 번지기 때문에 가장 먼저 점검해야 할 지점입니다.
이 세 가지 신호가 보이면 워커를 분리해 보세요
BackgroundTasks를 계속 써도 되는지, 아니면 Celery·RQ·ARQ 같은 별도 워커 프로세스로 옮겨야 하는지는 작업의 성격으로 판단하는 게 정확합니다. 아래 표로 비교해 보겠습니다.
| 판단 기준 | BackgroundTasks로 충분 | 별도 워커로 분리 권장 |
|---|---|---|
| 유실돼도 되는가 | 예 (로그 기록, 캐시 예열 등) | 아니오 (결제 후속 처리, 알림 발송) |
| 재시도가 필요한가 | 불필요 | 필요 (실패 시 자동 재시도) |
| 실행 시간 | 수 초 이내 | 수십 초~수 분 이상 |
| 재배포 빈도 | 낮음 | 잦은 배포로 유실 위험 높음 |
| 실패 모니터링 | 콘솔 로그로 충분 | 별도 알림/대시보드 필요 |
표에서 오른쪽 칸에 두 개 이상 해당한다면 그 시점이 워커를 분리할 타이밍입니다. 다만 분리한다고 모든 문제가 사라지는 건 아닙니다. Redis나 RabbitMQ 같은 브로커가 새로운 단일 장애 지점이 될 수 있고, 워커 프로세스를 별도로 배포·모니터링해야 하는 운영 비용이 추가로 붙습니다. 트래픽이 적고 실패해도 큰 문제가 없는 내부 관리자 도구 수준이라면, 오히려 Celery 도입이 과한 선택이 되기도 합니다.
BackgroundTasks로 버틸지 워커를 분리할지 정하는 체크리스트
지금까지 다룬 내용을 실행 단계로 옮기면 이렇습니다. 먼저 백그라운드 함수 안에 try/except를 걸어 예외를 명시적으로 로깅하고 있는지 확인합니다. 없다면 지금 당장 추가하는 게 가장 빠른 개선입니다.
다음으로 재배포 스크립트가 우아한 종료 시간을 얼마로 설정했는지 확인합니다. 값이 너무 짧으면 배포 직후 유실되는 작업이 늘어납니다. 마지막으로 위 표의 판단 기준에 두 개 이상 해당하는 작업이 있다면, 그 작업만이라도 RQ나 Celery의 큐로 옮기는 것부터 시작해 보는 게 좋습니다. 전체를 한 번에 옮기지 않고 유실 위험이 큰 작업 하나부터 분리하면, 인프라 비용 대비 효과를 먼저 확인한 뒤 나머지를 판단할 수 있습니다.
