View
FastAPI 백그라운드 태스크를 코드에 추가해 두고 "비동기로 동작하니 메인 앱에는 영향이 없을 것이고, 서버를 재시작해도 끝까지 처리될 것"이라고 가정한 적이 있는가? 나 역시 그러했다. 그러나 운영 환경에서 한 번 장애가 발생한 뒤에야 진실을 깨달았다. 이것은 별도 프로세스가 아니다. 동일한 앱 내부에서 동작한다. 메인이 종료되면 함께 종료된다.
이 글에서 정리할 내용은 다음과 같다. FastAPI 공식 문서와 Starlette 소스를 분석하여 BackgroundTasks가 실제로 어디에서 실행되는지, Ctrl+C / SIGKILL / --reload / 멀티 워커 시나리오별로 진행 중인 작업이 어떻게 처리되는지, 그리고 언제 BackgroundTasks를 사용하고 언제 Celery로 이전해야 하는지까지 모두 다룰 것이다. 한국어로 이를 정리한 글이 거의 없어 직접 작성한다.
BackgroundTasks의 정체부터 파악하자

FastAPI BackgroundTasks의 한 줄 정의는 다음과 같다. 응답 전송이 완료된 직후에 실행되는 콜백 등록 도구이다. 이것이 전부이다. 별도 라이브러리도 아니며, 사실상 Starlette의 BackgroundTasks를 그대로 임포트해 사용하는 얇은 래퍼이다.
가장 흔한 사용 사례는 다음과 같다.
- 회원가입 후 환영 메일 발송
- 요청 완료 로그를 외부로 적재
- 알림 푸시 트리거
- 분석용 이벤트 단발 전송
코드 예시는 다음과 같이 작성된다.
from fastapi import BackgroundTasks, FastAPI
app = FastAPI()
def write_log(message: str):
with open("log.txt", "a") as f:
f.write(message + "\n")
@app.post("/signup/")
async def signup(email: str, background_tasks: BackgroundTasks):
background_tasks.add_task(write_log, f"signup: {email}")
return {"ok": True}
요청이 들어오면 라우트 함수가 종료되고 200 응답을 보낸 다음에야 write_log가 실행된다. 클라이언트 측에서는 응답을 빠르게 받는다. 서버 측에서는 요청 처리가 진정으로 종료된 것이 아니다.
BackgroundTasks와 일반 라우트 함수의 실행 순서
이 부분에서 혼동하는 경우가 많은데, 실행 순서는 다음과 같다.
- 라우트 함수 실행
- 응답 객체 생성
- 응답이 클라이언트에 전송 완료
- 그제야 BackgroundTasks 콜백 실행
응답 전이 아니라 응답 후이다. 따라서 백그라운드 작업이 라우트 응답 시간을 늘리지는 않는다. 다만 이것이 후술할 문제의 원인이 된다. "응답 종료 후에 시작"이라는 사실은, 결국 그 사이에 서버가 한 번이라도 흔들리면 해당 작업은 그대로 사라진다는 의미이다.
"비동기"라는 단어가 만든 오해부터 해소하자

출처: Software Development Blog & IT Tech Insights | Django Stars
한국어로 "비동기"라고 표기하다 보니 사람들이 자꾸 별도 프로세스와 혼동한다. 그렇지 않다. 결코 아니다.
FastAPI 백그라운드 태스크는 다음 환경에서 동작한다.
- 동일한 파이썬 프로세스
- 동일한 이벤트 루프 (또는 그 프로세스 내부의 스레드풀)
- 동일한 메모리 공간
- 동일한 DB 커넥션 풀
- 동일한 CPU 타임
비교하자면 Celery는 진정으로 별개의 프로세스(워커)에서 실행된다. 그것이 진정으로 분리된 백그라운드이다. FastAPI BackgroundTasks는 분리된 워커가 아니라 "응답을 보낸 뒤 한 번 더 실행해 주겠다" 수준에 불과하다.
async def 콜백은 어디에서 실행되는가
async def 콜백은 동일한 이벤트 루프에서 await으로 실행된다. 여기에 무거운 작업을 넣으면 어떻게 되는가? 이벤트 루프가 그 작업이 끝날 때까지 다른 비동기 요청 처리를 모두 미룬다. 다른 사용자들의 응답이 모두 지연된다.
async def heavy_async_task():
# CPU 무거운 연산을 async def에 넣은 경우
result = sum(i * i for i in range(10**8))
return result
@app.post("/process/")
async def process(background_tasks: BackgroundTasks):
background_tasks.add_task(heavy_async_task)
return {"queued": True}
이 코드는 위험하다. async def로 선언했지만 실제로는 await 포인트가 없는 CPU bound 작업이라 이벤트 루프를 통째로 점유한다. 후속 요청들이 줄줄이 지연된다.
def(동기) 콜백의 경우는 어떠한가
def로 선언하면 starlette이 자동으로 run_in_threadpool을 통해 스레드풀에 위임한다. 기본 스레드풀 사이즈는 anyio 기준으로 40개이다. 40개가 모두 차면 그 다음 작업은 대기 큐에 쌓인다. 라우트 핸들러도 동기 함수일 경우 동일한 스레드풀을 사용하므로, 백그라운드가 풀을 모두 점유하면 일반 요청도 차단된다.
정리하면 다음과 같다.
| 콜백 타입 | 실행 위치 | 무거운 작업 투입 시 |
| `async def` | 동일한 이벤트 루프 | 모든 비동기 요청 지연 |
| `def` | 스레드풀 (기본 40) | 풀 포화 시 동기 요청도 지연 |
어느 쪽이든 메인 앱과 자원을 공유한다는 본질은 동일하다.
그렇다면 메인 앱에 영향이 없는가? → 100% 영향이 있다

출처: ManageEngine
"비동기로 동작하므로 메인 앱에 영향이 없을 것"이라는 생각은 매우 흔한 착각이다. 영향은 반드시 존재한다. 세 가지 측면 모두에서 영향이 있다.
메모리 공유: 백그라운드에서 큰 파일을 처리하거나 큰 데이터를 보유하면 그 메모리는 메인 앱 프로세스의 메모리이다. OOM이 발생하면 라우트 핸들러도 함께 종료된다.
DB 커넥션 풀 공유: 백그라운드에서 DB 작업을 실행하며 풀 사이즈를 모두 점유하면 다른 라우트가 커넥션을 받지 못한다. 라우트 응답이 모두 느려지거나 타임아웃이 발생한다.
CPU/이벤트 루프 공유: 위에서 기술한 그대로이다. async def인 경우 이벤트 루프, def인 경우 스레드풀 포화로 영향이 발생한다.
실제 장애 시나리오 사례는 다음과 같다. 회원이 사진을 업로드할 때 응답을 빠르게 주기 위해 이미지 리사이즈를 BackgroundTasks에 위임했다. 응답 시간이 200ms로 단축되어 좋아 보였다. 그러나 동시 업로드 10건이 들어오자 이미지 처리가 이벤트 루프를 모두 점유하여 다른 페이지 로딩이 5초씩 걸렸다. "응답이 빨라진 것처럼" 보였지만, 실제로는 부담을 다른 요청들에 전가한 것이다.
이러한 패턴이 너무 흔하여 운영에 들어가면 거의 반드시 한 번은 장애가 발생한다. 백그라운드라는 단어를 보고 안심해서는 안 된다.
서버가 종료되면 백그라운드 작업은 어떻게 되는가

결론부터 말한다. 함께 종료된다. "끝까지 처리된다"는 거짓이다. 시나리오별로 살펴보자.
시나리오 1: Ctrl+C / SIGTERM (정상 종료)
uvicorn은 SIGTERM을 받으면 graceful shutdown을 시도한다. 진행 중인 HTTP 요청은 종료될 때까지 대기한다. 여기까지는 양호하다.
다만 BackgroundTasks는 응답 전송이 끝난 다음에 시작되는 것이라 다음과 같이 처리된다.
- 응답을 모두 보냈고 백그라운드 시작 직전에 SIGTERM이 들어옴 → 시작도 하지 못하고 사라진다
- 백그라운드 진행 중에 SIGTERM이 들어옴 → graceful timeout 안에 종료되지 않으면 그대로 종료된다
uvicorn 자체의 --timeout-graceful-shutdown은 사실 기본값이 None(무제한 대기)이다. 다만 운영 환경에서는 거의 외부에서 타임아웃이 걸린다. 쿠버네티스는 terminationGracePeriodSeconds 기본 30초가 지나면 SIGKILL을 발사하고, systemd TimeoutStopSec, 두 번째 SIGTERM/SIGINT도 즉시 종료 트리거이다. 따라서 진행 중이던 백그라운드가 길어지면 그대로 잘린다. 클라이언트는 200을 받았으나 실제 후처리는 중간에 끊긴 상태로 남는다.
시나리오 2: SIGKILL / OOM Kill (비정상 종료)
kill -9나 OOM Killer가 발동하면 프로세스가 통째로 즉사한다. graceful 같은 것은 없다. 진행 중인 백그라운드 작업도 그대로 모두 사라진다.
진짜 위험한 사례를 들어 본다. 결제 후 영수증 메일 발송을 BackgroundTasks에 위임했는데 SIGKILL을 맞았다. 클라이언트는 결제 성공 200을 받았다. 메일은 발송되지 않았다. DB에는 결제 완료로 기록되었다. 사용자 입장에서는 "결제는 됐다는데 영수증이 오지 않는다"가 된다. 데이터 불일치이다. CS 대란이 발생한다.
시나리오 3: uvicorn --reload (개발 환경의 함정)
uvicorn main:app --reload로 띄워 두고 코드를 한 줄 수정하면 워커가 재시작된다. 진행 중이던 백그라운드 작업은 어떻게 되는가? 그대로 함께 종료된다. 개발 중에는 이것이 별 문제 없어 보인다. 그러나 이를 모르고 "잘 동작하는군"이라고 판단하며 운영에 진입하면 동일한 패턴이 SIGTERM/SIGKILL에서 다시 터진다.
시나리오 4: Gunicorn 멀티 워커
운영에서는 보통 gunicorn -w 4 -k uvicorn.workers.UvicornWorker 같은 형태로 워커를 4개 띄운다. 이때 백그라운드 태스크는 그 요청을 받은 워커 프로세스 내부에서만 실행된다.
- 워커 A가 요청을 받음 → 백그라운드도 워커 A에 등록된다
- 워커 A가 종료됨 → A의 백그라운드는 모두 사라진다
- 워커 B, C, D는 아무것도 알지 못한다
모니터링 측면에서 "어느 워커에서 어떤 백그라운드가 살아 있는지" 추적하기는 거의 불가능하다. 워커 간 분산 처리도 불가하다. 분산 처리를 하려면 외부 큐 시스템이 필요하다.
직접 검증한 결과 — 30초면 재현 가능하다
여기까지 읽고도 "정말 그러한가?"라는 의문이 남을 수 있다. 그래서 직접 돌려 보았다. FastAPI 0.135.3 / uvicorn 0.43.0 / Python 3.13 환경에서 다음 한 파일이면 핵심 주장 3개를 모두 재현할 수 있다.
import os, time, asyncio
from fastapi import FastAPI, BackgroundTasks
app = FastAPI()
LOG = "bg.log"
def log(msg: str):
line = f"{time.time():.3f} pid={os.getpid()} {msg}"
with open(LOG, "a", encoding="utf-8") as f:
f.write(line + "\n")
async def slow_async(tag: str, secs: int = 6):
log(f"BG-START tag={tag}")
for i in range(secs):
await asyncio.sleep(1)
log(f"BG-TICK tag={tag} i={i}")
log(f"BG-END tag={tag}")
@app.post("/bg/{tag}")
async def bg(tag: str, bt: BackgroundTasks):
log(f"ROUTE tag={tag}")
bt.add_task(slow_async, tag)
return {"pid": os.getpid(), "tag": tag}
uvicorn bg_app:app --port 8765로 띄우고 curl -X POST http://127.0.0.1:8765/bg/E1을 한 번 호출하면 된다.
검증 1 — 정말 같은 프로세스에서 동작하는가
서버 PID는 31516이었다. 로그 결과는 다음과 같다.
| 이벤트 | 시각 (epoch) | PID |
| 라우트 진입 (`ROUTE tag=E1`) | 259.504 | **31516** |
| 백그라운드 시작 (`BG-START`) | 259.505 | **31516** |
| 백그라운드 종료 (`BG-END`) | 265.563 | **31516** |
| 별도 호출한 `/ping` | — | **31516** |
라우트 핸들러, 백그라운드 콜백, 다른 요청 핸들러 모두 PID 31516 동일. "별도 프로세스가 아니다"가 실측으로 확정된다.
검증 2 — 응답은 즉시 반환되지만 서버는 계속 일하고 있다
curl 기준 라우트 응답 수신까지 0.36초가 걸렸다. 클라이언트 입장에서는 0.36초 만에 끝난 요청이다. 그러나 서버 측에서는 그 후로 6초간 BG-TICK 로그가 1초 간격으로 6번 더 찍혔다. "응답이 빨라진 것"이 아니라 "응답을 보낸 뒤에도 같은 프로세스에서 계속 일하고 있는 것"이다. 동시 요청이 몰리면 이 6초가 다른 요청들에 부담으로 전가된다.
검증 3 — 프로세스가 종료되면 백그라운드는 즉사한다
/bg/KILL을 호출하고 2초 기다린 뒤 서버 프로세스를 강제 종료(kill -9 상당)했다. 결과는 다음과 같다.
298.811 BG-START tag=KILL
299.820 BG-TICK i=0
300.839 BG-TICK i=1
301.841 BG-TICK i=2 ← 여기서 프로세스 강제 종료
✗ (i=3, 4, 5 없음)
✗ BG-END 없음
6틱 중 3틱만 기록되고 BG-END는 끝내 찍히지 않았다. 클라이언트는 이미 200 응답을 받은 상태이다. 즉, 결제는 성공했지만 영수증 메일은 발송되지 않은 상태가 그대로 재현된 것이다.
검증 결과 요약
| 항목 | 결과 |
| 별도 프로세스가 아님, 같은 프로세스 | ✅ PID 동일 |
| 응답 후에 실행됨 | ✅ 라우트 0.36s 종료, BG는 6s간 별도 진행 |
| 메인 종료 시 BG도 같이 종료 | ✅ 3틱 만에 잘림, BG-END 미도달 |
| 라우트 응답시간에 영향 없음 | ✅ 0.36s |
핵심 동작 3가지 모두 30초 만에 재현된다. 의심스럽다면 직접 돌려 보면 된다. 운영 진입 전에 한 번은 이 실험을 거쳐 보아야 한다.
그렇다면 BackgroundTasks는 언제 사용해도 되는가
지금까지 비판적으로 다루었으나 그래도 BackgroundTasks가 무용한 것은 아니다. 적절한 자리에서는 진정으로 편리하다. 체크리스트로 정리한다.
사용해도 되는 경우:
- 짧고 빠른 작업 (수 ms ~ 길어야 1~2초)
- 실패해도 큰 문제가 발생하지 않는 작업 (분석 핑, 비크리티컬 로그)
- 응답 속도를 약간 단축하고 싶으나 외부 워커를 띄우기는 부담스러운 경우
- 작업 자체가 idempotent하여 재실행에 부담이 없는 경우
사용해서는 안 되는 경우:
- 5초가 넘는 작업
- 실패해서는 안 되는 결제 후처리, 결제 영수증, 중요 알림
- 재시도 / 결과 추적 / 스케줄링이 필요한 작업
- CPU 무거운 작업 (이미지/영상 처리, ML 추론)
- 분산 워커로 분산 처리해야 하는 작업
- 트랜잭션 보장이 필요한 작업
기준은 단순하다. "이것이 중간에 끊겨도 정말 괜찮은가?" 한 번 자문해 보고, "다소 불편하지만 큰 문제는 아니다" 수준이라면 BackgroundTasks를 사용해도 된다. "이것이 끊기면 큰일이 난다" 수준이라면 무조건 외부 워커이다.
그렇다면 진정한 백그라운드는 어떻게 구현하는가

출처: Level Up Coding - GitConnected (92KB)
진정으로 메인 앱과 분리된 백그라운드가 필요하다면 외부 워커 + 메시지 브로커 조합이 정답이다. 구조는 대략 다음과 같다.
FastAPI는 작업을 큐(Redis, RabbitMQ 등)에 적재하기만 하고, 별도로 띄운 워커 프로세스가 큐에서 꺼내 실행한다. FastAPI가 종료되어도 워커는 살아 있고, 워커가 종료되어도 큐에 쌓인 작업은 살아 있다.
| 솔루션 | 특징 | FastAPI 궁합 |
| Celery | 대장. 무겁다. 모든 기능이 가능하다. Redis/RabbitMQ | 양호 (단 sync 기반) |
| RQ | 가볍다. Redis 전용. 코드가 단순하다 | 양호 |
| ARQ | async 네이티브. Redis. | 매우 양호 |
| Dramatiq | Celery 대체용. 빠르고 간결하다 | 양호 |
| Taskiq | 신상. async 네이티브. FastAPI 풍 API | 매우 양호 |
각 솔루션에 대한 한 줄 평이다.
- Celery: 매우 유명하다. 기능이 풍부하다. 다만 무겁고 설정이 복잡하며 sync 기반이라 FastAPI async와 다소 어색하다. 그럼에도 큰 워크플로우에는 이것이다.
- RQ: Redis 하나로 가볍게 시작하고 싶을 때 적합하다. 코드가 진정으로 단순하다. 대신 기능이 많지는 않다.
- ARQ: FastAPI와 async 호환성이 양호하다. Redis를 사용하여 운영 부담이 적다. 처음 도입하기에 적합하다.
- Dramatiq: Celery를 사용하지 않으면서 그 정도 기능이 필요할 때 적합하다.
- Taskiq: 가장 최신이다. async 네이티브 + FastAPI 스타일 API이다. 신생이라 레퍼런스가 적다는 것이 단점이다.
내 추천은 다음과 같다. FastAPI 프로젝트라면 ARQ나 Taskiq부터 검토하면 된다. 워크플로우가 진정으로 복잡해지면 그때 Celery로 전환하는 것이 맞다. 처음부터 Celery를 도입하면 셋업 부담만 커진다.
FastAPI BackgroundTasks 실전 권장 패턴
각설하고, 작업 성격에 따라 분기를 두는 것이 정답이다. 정리하면 다음과 같다.
@app.post("/event/")
async def track_event(payload: dict, background_tasks: BackgroundTasks):
# 빠른 후처리: BackgroundTasks 적합
background_tasks.add_task(send_analytics_ping, payload)
return {"ok": True}
@app.post("/order/")
async def create_order(order: dict):
save_order(order)
# 무거운 작업: 외부 워커로
arq_pool.enqueue_job("process_order", order["id"])
return {"order_id": order["id"]}
추가로 고려할 사항은 다음과 같다.
- Idempotency key: 외부 워커를 사용할 때 동일한 작업이 두 번 실행되어도 결과가 동일하도록 만든다. 재시도 안전성의 핵심이다.
- DB 큐 패턴: 진정으로 절대 잃어서는 안 되는 작업은 DB에 작업 row를 적재하고 워커가 폴링하는 패턴이 가장 안전하다. 메시지 브로커조차 신뢰할 수 없을 때 사용하는 패턴이다.
- 모니터링: Celery는 Flower, arq는 자체 CLI, 공통적으로는 Prometheus exporter로 큐 길이/처리 시간을 확인하면 된다.
- 로컬 vs 운영: --reload로 잘 돌던 것이 운영에서 SIGTERM을 만나면 어떻게 되는지 한 번은 직접 시뮬레이션을 돌려 보아야 한다.
FastAPI 백그라운드 태스크, 운영 진입 전에 점검할 3가지
핵심 3줄 요약이다.
- FastAPI 백그라운드 태스크는 별도 프로세스가 아니다. 동일한 앱의 이벤트 루프 또는 스레드풀에서 동작한다. "비동기"라는 단어를 보고 분리되었다고 판단해서는 안 된다.
- 메인 앱이 종료되면 백그라운드도 함께 종료된다. SIGTERM, SIGKILL, --reload, 워커 재시작 모두 마찬가지이다. "끝까지 처리됨" 같은 보장은 존재하지 않는다.
- 진정한 보장이 필요하면 ARQ / Taskiq / Celery 같은 외부 워커 + 브로커 조합을 사용해야 한다. BackgroundTasks는 짧고 잃어도 무방한 작업 전용이다.
지금 운영 중인 프로젝트를 한 번 점검해 보고, 5초가 넘는 작업이나 실패해서는 안 되는 작업이 BackgroundTasks에 들어가 있다면 ARQ나 Celery로 이전하는 것을 권장한다. FastAPI 공식 문서의 Background Tasks 페이지에도 "큰 작업은 Celery 같은 도구를 사용하라"고 명시되어 있다. 공식이 그렇게 말한다는 것은 진정으로 그렇게 해야 한다는 뜻이다. 한 번 데인 후에 옮기면 늦으니, 미리 분리하는 것이 맞다.
'Backend' 카테고리의 다른 글
| SSE 스트림은 어떤 언어가 안정적인가? Go·Python·Node·Rust를 User 1000명까지 굴려본 결과 (0) | 2026.05.03 |
|---|---|
| gRPC vs REST, 내부 통신에는 왜 모두 gRPC를 쓰는가 (0) | 2026.04.28 |
| 라라벨(Laravel)이 PHP 프레임워크 중 점유율 1위인 이유를 수치로 정리한다 (0) | 2026.04.26 |
| FastAPI는 누가 만들었고, 왜 빠른가. 비동기면 무조건 빠르다고 알고 있는가? (0) | 2026.04.23 |
| LLM API는 왜 REST와 다른가? POST만 사용하는 이유는 무엇일까? (1) | 2026.04.19 |
