brandon@seoul:~/ko/posts/fastapi-nonblocking-startup-dependenciesen / ko
fastapi-nonblocking-startup-dependencies/index.md0%
글 목록으로 돌아가기

FastAPI에서 startup dependency를 non-blocking으로 만들기

FastAPI의 lifespan code는 application이 request를 받기 전에 실행돼요. startup이 Kafka 같은 optional dependency를 await하면, 그 dependency 없이도 안전하게 route를 serve할 수 있는데도 Cloud Run cold start가 health check + E2E probe를 실패시킬 수 있어요.

이 글 목차
  1. producer는 background, 요청은 foreground
  2. 연결 여부가 아니라 지연으로 test하세요
  3. 이게 맞는 상황
  4. 실용적인 정리
  5. References

FastAPI의 lifespan은 앱이 요청을 받기 전에 실행돼요. 그래서 startup이 Kafka 같은 optional dependency를 await하면, 그 dependency 없이도 라우트를 안전하게 처리할 수 있는데도 Cloud Run cold start에서 health check와 E2E probe가 실패하기도 해요. 헷갈리는 부분은 신호 자체예요. Kafka producer 실패가 마치 서비스 startup 실패처럼 보이지만, 실제 계약은 더 좁아요 — 핵심 쓰기 경로는, 거기서 발행하는 이벤트가 best-effort 관측용일 뿐이라면 Kafka를 기다리면 안 돼요.

여기서 얻을 수 있는 교훈은 정합성을 좌우하는 dependency(database, auth)와 관측성을 좌우하는 dependency(event bus, audit fan-out, cache warmer)를 구분하는 거예요. 앞쪽은 startup을 막아야 해요. 뒤쪽은 절대 막으면 안 되고요.

producer는 background, 요청은 foreground

optional producer는 lifespan 동안 background task로 띄우고, task handle은 shutdown 정리를 위해 app.state에 보관하세요. 요청 경로는 이벤트 발행을 fire-and-forget으로 처리하고, 실패는 비즈니스 작업을 실패시키지 않으면서 로그만 남겨요.

from contextlib import asynccontextmanager, suppress


@asynccontextmanager
async def lifespan(app: FastAPI):
    # startup: producer를 예약만 하고 await하지 않아요
    app.state.kafka_init_task = asyncio.create_task(start_kafka_producer())

    yield  # 이 yield 구간 동안 앱이 요청을 받아요

    # shutdown: 초기화가 아직 진행 중일 수 있으니 취소 후 정리해요
    with suppress(asyncio.CancelledError):
        app.state.kafka_init_task.cancel()
        await app.state.kafka_init_task
    await stop_kafka_producer()

이게 동작하는 이유는 두 가지예요. 첫째, asyncio.create_task는 producer 초기화를 예약만 하고 lifespan 진행을 막지 않아요 — producer가 백그라운드에서 연결되는 동안 앱은 올라오는 절차를 마저 끝내요. 둘째, cancel 후 await하는 shutdown 순서가 필요한 이유는 stop_kafka_producer()도 깔끔하게 돌아가야 할 수 있고, shutdown이 떨어질 때 초기화가 아직 진행 중인 경우도 다뤄야 하기 때문이에요.

연결 여부가 아니라 지연으로 test하세요

소켓 기반 test를 직접 거는 방식은 잘 깨져요 — Kafka 가용성이 CI 환경마다 다르고, connection-refused 에러가 원래 startup이 멈추던 증상을 안정적으로 재현하지 않거든요. 실제로 버그를 잡아주는 회귀 검사는 지연 시간 기반이에요. start_kafka_producer()를 느린 stub으로 바꿔서, lifespan 완료가 서비스 startup 예산을 넘어가지 않는지 확인하는 형태죠.

이런 모양의 test가 “실수로 다시 블로킹으로 돌아가지 않았나”를 잡는 데 일반적으로 더 단단해요. 실제 실패 양상은 “lifespan이 event loop를 너무 오래 잡고 있었다”이지 “dependency가 실패했다”가 아니에요 — 그래서 test도 event loop를 잡고 있는 동작을 겨냥해야 해요.

이게 맞는 상황

이 패턴은 dependency가 텔레메트리, audit fan-out, cache warming, 이벤트 전파에는 유용하지만 API가 핵심 트래픽을 받는 데 꼭 필요하지는 않을 때 써요. 지켜야 할 안전 불변식은 하나예요. 그 dependency가 필요 없는 요청 경로가, 그게 다운됐다는 이유로 막히면 안 돼요.

정합성, 영속성, 인가에 꼭 필요한 dependency는 백그라운드로 돌리지 마세요. 앱이 그것 없이 안전하게 응답할 수 없다면, startup을 시끄럽게 실패시키고 플랫폼이 재시작하거나 페이징하도록 두는 게 맞아요. 정합성에 결정적인 dependency를 백그라운드로 빼면 startup 시점의 실패가 진단하기 더 어려운 런타임 실패로 바뀌는 거라, 분명히 더 나쁜 선택이에요.

실용적인 정리

optional dependency는 lifespan 안에서 asyncio.create_task로 띄우고, task handle은 shutdown을 위해 app.state에 보관하세요. 느린 stub으로 test하면 돼요 — 신경 써야 할 실패 양상은 “event loop를 너무 오래 막는다”이고, 지연 시간을 기준으로 두는 단언이 단순한 연결 확인보다 이 양상을 더 안정적으로 잡아줘요. “이건 막는다” vs “이건 백그라운드로 돌린다”의 경계선은, 요청 경로가 그것 없이 안전하게 응답을 돌려줄 수 있느냐예요.

References

댓글

글 목록으로 돌아가기

0:home1:posts2:study3:b34:about