애플리케이션 시작/종료 시점 처리하기

애플리케이션 시작/종료 시점 처리하기

애플리케이션이 요청을 받기 시작하기 전에 딱 한 번 실행돼야 하는 코드가 있어요. 예를 들어 디스크에서 데이터를 많이 읽어야 하는 머신러닝 모델을 로드하거나, DB 커넥션 풀을 준비하는 일이죠. 반대로 애플리케이션이 종료될 때 한 번 실행해서 리소스를 정리하는 코드도 필요하고요. FastAPI의 lifespan 파라미터로 이런 전체 애플리케이션 수명 주기 코드를 깔끔하게 관리할 수 있어요.

출처: FastAPI 공식 문서 - Lifespan Events

왜 필요한가요

요청마다 같은 모델을 다시 로드하는 건 불필요한데요, 그렇다고 모듈 최상단에 로드해 두면 단순한 테스트를 실행할 때조차 그 비용을 치러야 해요. 이 문제를 풀면서 자연스럽게 lifespan을 이해해 볼게요.

머신러닝 모델을 여러 요청이 공유해서 쓰는 상황을 가정해 봐요. 요청마다 모델을 만들 필요 없이, 애플리케이션이 요청을 받기 직전에 한 번만 로드하고, 종료 직전에 리소스를 정리하면 돼요.

lifespan으로 시작/종료 로직 정의하기

lifespanFastAPI 앱의 파라미터로 넘기고, 함수는 비동기 컨텍스트 매니저로 만들어요. yield를 기준으로 앞부분은 시작 시, 뒷부분은 종료 시에 실행돼요.

from contextlib import asynccontextmanager

from fastapi import FastAPI


def fake_answer_to_everything_ml_model(x: float):
    return x * 42


ml_models = {}


@asynccontextmanager
async def lifespan(app: FastAPI):
    # Load the ML model
    ml_models["answer_to_everything"] = fake_answer_to_everything_ml_model
    yield
    # Clean up the ML models and release the resources
    ml_models.clear()


app = FastAPI(lifespan=lifespan)


@app.get("/predict")
async def predict(x: float):
    result = ml_models["answer_to_everything"](x)
    return {"result": result}

여기서는 모델 로드를 startup 시점(첫 요청 전)에 시뮬레이션해요. yield 앞에 있는 코드가 애플리케이션이 요청을 받기 시작하기 전에 실행되지요. 그리고 yield 바로 뒤의 코드는 요청 처리가 끝난 뒤, 종료 직전에 실행돼요. 이 부분에서 메모리나 GPU 같은 리소스를 해제할 수 있어요.

컨텍스트 매니저가 뭔가요

함수가 @asynccontextmanager로 데코레이트 되면 비동기 컨텍스트 매니저가 돼요. 파이썬에서 컨텍스트 매니저는 with 문에서 쓰는 객체예요. open()이 대표적인 예시로, with open(...) as f:처럼 쓰죠.

with open("file.txt") as file:
    file.read()

최신 파이썬에는 비동기 컨텍스트 매니저도 있어서 async with로 써요.

async with lifespan(app):
    await do_stuff()

컨텍스트 매니저는 with 블록에 들어가기 전에 yield 앞 코드를 실행하고, 블록에서 나올 때 yield 뒤 코드를 실행해요. 예시에서는 직접 호출하지 않고 lifespan 파라미터로 FastAPI에 넘겨서 FastAPI가 쓰게 해요. FastAPI의 lifespan 파라미터는 비동기 컨텍스트 매니저를 받아요.

대안: startup/shutdown 이벤트 (권장 안 함)

경고 — 시작/종료 로직을 다루는 권장 방법은 위의 lifespan 파라미터예요. lifespan 파라미터를 제공하면 startup/shutdown 이벤트 핸들러는 더 이상 호출되지 않아요. lifespan이든 이벤트든 둘 중 하나만 써야 해요. 이 부분은 건너뛰어도 돼요.

과거에는 이벤트 핸들러로 시작/종료 로직을 정의했어요. 함수를 @app.on_event("startup") 또는 @app.on_event("shutdown") 데코레이터로 등록하는 방식이에요. 함수는 async def 또는 일반 def로 선언할 수 있어요.

from fastapi import FastAPI

app = FastAPI()

items = {}


@app.on_event("startup")
async def startup_event():
    items["foo"] = {"name": "Fighters"}
    items["bar"] = {"name": "Tenders"}


@app.get("/items/{item_id}")
async def read_items(item_id: str):
    return items[item_id]

startup 이벤트 핸들러가 전부 완료될 때까지 애플리케이션은 요청을 받기 시작하지 않아요. shutdown은 앱이 종료될 때 실행돼요.

from fastapi import FastAPI

app = FastAPI()


@app.on_event("shutdown")
def shutdown_event():
    with open("log.txt", mode="a") as log:
        log.write("Application shutdown")


@app.get("/items/")
async def read_items():
    return [{"name": "Foo"}]

이 예시에서 open()mode="a"는 "append"를 뜻해요. 파일의 기존 내용을 덮어쓰지 않고 뒤에 줄을 추가해요. 일반 open()은 파일과 상호작용하는 I/O 작업이라 async/await를 쓰지 않으므로, 이벤트 핸들러도 일반 def로 선언했어요.

시작과 종료 로직이 서로 연결된 경우(뭔가를 시작하고 반드시 끝내야 한다거나, 리소스를 취득하고 해제해야 한다거나)가 많은데요, 이걸 서로 변수를 공유하지 않는 별도 함수로 나누면 전역 변수 같은 꼼수를 써야 해서 어려워져요. 그래서 지금은 위에서 설명한 lifespan을 쓰는 게 권장돼요.

기술적인 배경

내부적으로 ASGI 기술 스펙의 Lifespan Protocol에 속하고, 거기서 startup/shutdown 이벤트가 정의돼요. Starlette의 lifespan 핸들러에 대한 더 자세한 내용은 Starlette의 Lifespan 문서에서 볼 수 있어요.

경고 — 이 lifespan 이벤트(시작/종료)는 메인 애플리케이션에서만 실행되고, Sub Applications - Mounts로 연결된 하위 앱에는 실행되지 않아요. 이 점만 기억해 두면 돼요.

더 알아보기 (Learn more)