Etc Programming
FastAPI
Background Task

FastAPI Background Tasks

배경 작업(Background Tasks)

응답을 반환한 후 실행될 배경 작업을 정의할 수 있습니다.

이는 요청 후에 발생해야 하는 작업이지만, 클라이언트가 작업이 완료될 때까지 기다릴 필요가 없는 경우에 유용합니다.

예를 들어 다음과 같은 경우에 사용됩니다:

  1. 작업 수행 후 이메일 알림 전송:

    • 이메일 서버에 연결하고 이메일을 보내는 것은 "느린" 작업(몇 초)이므로, 즉시 응답을 반환하고 배경에서 이메일 알림을 보낼 수 있습니다.
  2. 데이터 처리:

    • 예를 들어, 느린 처리가 필요한 파일을 받았을 때 "Accepted"(HTTP 202) 응답을 반환하고 배경에서 파일을 처리할 수 있습니다.

BackgroundTasks 사용법

먼저 BackgroundTasks를 import하고 경로 작업 함수에 BackgroundTasks 타입의 매개변수를 정의합니다:

from fastapi import BackgroundTasks, FastAPI
 
app = FastAPI()
 
def write_notification(email: str, message=""):
    with open("log.txt", mode="w") as email_file:
        content = f"notification for {email}: {message}"
        email_file.write(content)
 
@app.post("/send-notification/{email}")
async def send_notification(email: str, background_tasks: BackgroundTasks):
    background_tasks.add_task(write_notification, email, message="some notification")
    return {"message": "Notification sent in the background"}

FastAPI는 BackgroundTasks 타입의 객체를 생성하여 해당 매개변수로 전달합니다.

작업 함수 생성

배경 작업으로 실행될 함수를 생성합니다.

이는 매개변수를 받을 수 있는 일반 함수입니다.

async def 또는 일반 def 함수일 수 있으며, FastAPI는 이를 올바르게 처리하는 방법을 알고 있습니다.

이 경우 작업 함수는 파일에 쓰기를 수행합니다(이메일 전송을 시뮬레이션).

배경 작업 추가

경로 작업 함수 내에서 .add_task() 메서드를 사용하여 작업 함수를 배경 작업 객체에 전달합니다:

background_tasks.add_task(write_notification, email, message="some notification")

.add_task()는 다음을 인수로 받습니다:

  • 배경에서 실행될 작업 함수 (write_notification)
  • 작업 함수에 순서대로 전달될 인수 시퀀스 (email)
  • 작업 함수에 전달될 키워드 인수 (message="some notification")

의존성 주입

BackgroundTasks는 의존성 주입 시스템과도 함께 작동합니다. BackgroundTasks 타입의 매개변수를 여러 수준에서 선언할 수 있습니다: 경로 작업 함수, 의존성(의존 가능), 하위 의존성 등.

FastAPI는 각 경우에 무엇을 해야 하는지 알고 있으며 동일한 객체를 재사용하는 방법을 알고 있어, 모든 배경 작업이 병합되어 이후에 배경에서 실행됩니다.

기술적 세부 사항

BackgroundTasks 클래스는 starlette.background에서 직접 가져옵니다.

FastAPI에서 직접 import할 수 있도록 FastAPI에 포함되어 있으며, 실수로 starlette.background에서 BackgroundTask(끝에 s가 없는)를 import하는 것을 방지합니다.

BackgroundTasks만 사용하면(Request 객체를 직접 사용할 때와 마찬가지로) 경로 작업 함수 매개변수로 사용할 수 있으며 FastAPI가 나머지를 처리합니다.

주의사항

무거운 배경 계산이 필요하고 반드시 동일한 프로세스에서 실행할 필요가 없는 경우(예: 메모리, 변수 등을 공유할 필요가 없는 경우) Celery와 같은 더 큰 도구를 사용하는 것이 유용할 수 있습니다.

이러한 도구는 RabbitMQ나 Redis와 같은 메시지/작업 큐 관리자가 필요하지만, 여러 프로세스와 특히 여러 서버에서 배경 작업을 실행할 수 있습니다.

하지만 FastAPI 앱의 변수와 객체에 액세스해야 하거나 이메일 알림 전송과 같은 작은 배경 작업을 수행해야 하는 경우 BackgroundTasks를 사용하면 됩니다.

요약

경로 작업 함수와 의존성에서 BackgroundTasks를 import하고 매개변수로 사용하여 배경 작업을 추가합니다.

Reference

Starlette Background Task

Starlette는 인-프로세스 배경 작업을 위한 BackgroundTask 클래스를 포함하고 있습니다.

배경 작업은 응답에 첨부되어야 하며, 응답이 전송된 후에만 실행됩니다.

Background Task

단일 배경 작업을 응답에 추가하는 데 사용됩니다.

시그니처: BackgroundTask(func, *args, **kwargs)

from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.routing import Route
from starlette.background import BackgroundTask
 
async def signup(request):
    data = await request.json()
    username = data['username']
    email = data['email']
    task = BackgroundTask(send_welcome_email, to_address=email)
    message = {'status': 'Signup successful'}
    return JSONResponse(message, background=task)
 
async def send_welcome_email(to_address):
    ...
 
routes = [
    ...
    Route('/user/signup', endpoint=signup, methods=['POST'])
]
 
app = Starlette(routes=routes)

BackgroundTasks

여러 배경 작업을 응답에 추가하는 데 사용됩니다.

시그니처: BackgroundTasks(tasks=[])

from starlette.applications import Starlette
from starlette.responses import JSONResponse
from starlette.background import BackgroundTasks
 
async def signup(request):
    data = await request.json()
    username = data['username']
    email = data['email']
    tasks = BackgroundTasks()
    tasks.add_task(send_welcome_email, to_address=email)
    tasks.add_task(send_admin_notification, username=username)
    message = {'status': 'Signup successful'}
    return JSONResponse(message, background=tasks)
 
async def send_welcome_email(to_address):
    ...
 
async def send_admin_notification(username):
    ...
 
routes = [
    Route('/user/signup', endpoint=signup, methods=['POST'])
]
 
app = Starlette(routes=routes)

중요 사항

작업은 순서대로 실행됩니다. 작업 중 하나가 예외를 발생시키면, 이후 작업들은 실행될 기회를 얻지 못합니다.

Reference

Background Task: Starlette vs FastAPI

TL;DR

배경 작업은 항상 메인 애플리케이션과 동일한 프로세스에서 실행됩니다. 이벤트 루프에서 비동기적으로 실행되거나 별도의 스레드에서 실행됩니다.

주로 I/O가 아닌 작업의 경우, 이를 사용하지 않고 대신 multiprocessing을 사용하는 것이 좋습니다.

상세 설명

multiprocessing 사용 (올바르게)

FastAPI 문서는 multiprocessing 사용을 금지하지 않을 뿐만 아니라, 계산 집약적인 작업에 대해 명시적으로 제안합니다.

인용: (강조는 필자)

무거운 배경 계산이 필요하고 반드시 동일한 프로세스에서 실행할 필요가 없는 경우(예: 메모리, 변수 등을 공유할 필요가 없는 경우), 다른 더 큰 도구를 사용하는 것이 유용할 수 있습니다.

따라서 multiprocessing을 사용할 수 있습니다. 그리고 CPU-bound 작업을 배경에서 수행하려면 거의 확실히 자체 multiprocessing 설정을 사용해야 합니다.

하지만 질문에서 보여준 예시에서는 파일을 어딘가에 업로드하는 작업을 배경에서 수행하려는 것으로 보입니다. 이러한 작업은 I/O-bound이므로 BackgroundTasks 기반 동시성에 잘 맞습니다. 새로운 프로세스를 생성하면 추가 오버헤드가 발생하여 BackgroundTasks보다 효율성이 떨어질 수 있습니다.

또한 코드에서 새 프로세스를 언제, 어떻게 join하는지 보여주지 않았습니다. 이는 중요하며 multiprocessing 가이드라인에서 언급됩니다:

프로세스가 완료되었지만 join되지 않으면 좀비가 됩니다. [...] 시작한 모든 프로세스를 명시적으로 join하는 것이 좋은 관행입니다.

그냥 생성하고 잊어버리는 것은 아마도 끔찍한 아이디어일 것입니다. 특히 해당 경로가 요청될 때마다 그렇게 되는 경우에는 더욱 그렇습니다.

그리고 자식 프로세스는 스스로 join할 수 없습니다. 그렇게 하면 데드락이 발생할 수 있기 때문입니다.

기술적 차이점

아시다시피, FastAPI의 배경 작업은 Starlette의 BackgroundTasks 클래스를 재사용한 것입니다. FastAPI는 이를 경로 처리 설정에 통합하여 사용자가 명시적으로 반환할 필요가 없게 했습니다.

하지만 Starlette 문서는 이 클래스가 "인-프로세스 배경 작업"을 위한 것이라고 명확히 밝히고 있습니다.

소스를 살펴보면, 내부적으로 __call__ 구현은 두 가지 중 하나만 수행합니다:

  1. 전달된 함수가 비동기인 경우, 단순히 await합니다.
  2. 전달된 함수가 "일반" 함수(비동기가 아님)인 경우, 스레드 풀에서 실행합니다. (더 깊이 들어가면 anyio.to_thread.run_sync 코루틴을 활용하는 것을 볼 수 있습니다.)

이는 어떤 경우에도 다른 프로세스가 관여하지 않는다는 것을 의미합니다. 1)의 경우에는 애플리케이션의 나머지 부분과 동일한 이벤트 루프에서 스케줄링되므로, 모든 것이 하나의 스레드에서 발생합니다. 2)의 경우에는 추가 스레드가 작업을 수행합니다.

Python에서 동시성을 다뤄본 경험이 있다면 그 의미는 매우 명확합니다: CPU-bound 작업을 수행하려는 경우 BackgroundTasks를 사용하지 마세요. 이러한 작업은 1) 유일한 사용 가능한 스레드에서 이벤트 루프를 차단하거나 2) GIL이 메인 스레드를 잠그게 만들기 때문에 애플리케이션을 완전히 차단할 것입니다.

적절한 사용 사례

반면에 작업이 I/O-bound 작업을 수행하는 경우(문서에서 주어진 예는 요청이 처리된 후 이메일 서버에 연결하여 무언가를 보내는 것), BackgroundTasks 메커니즘은 매우 편리합니다.

BackgroundTasks의 주요 이점은 코루틴이 언제, 어떻게 await될지 또는 스레드가 언제 join될지 걱정할 필요가 없다는 것입니다. 이는 모두 경로 핸들러 뒤에서 추상화되어 있습니다. 응답 후에 실행하고 싶은 함수만 지정하면 됩니다.

경로 핸들러 함수 끝에서 asyncio.create_task를 호출할 수도 있습니다. 이는 요청이 처리된 직후에 작업을 스케줄링하고 효과적으로 배경에서 실행되게 할 것입니다. 하지만 이에는 세 가지 문제가 있습니다:

  1. 즉시 스케줄링된다는 보장이 없습니다. 처리 중인 요청이 많으면 시간이 걸릴 수 있습니다.
  2. 경로 핸들러 외부에서 작업을 추적하는 메커니즘을 직접 개발하지 않는 한, 해당 작업이 실제로 완료되는지(예상대로 또는 오류와 함께) 확인할 수 있는 기회가 없습니다.
  3. 이벤트 루프는 작업에 대한 약한 참조만 유지하므로, 작업이 완료되기 전에 가비지 수집될 수 있습니다. (이는 그냥 사라진다는 것을 의미합니다.)

Reference