서비스 학습
SOURCE · be/app/main.py · 2026-09-06

용도별 HTTP client를
서비스 수명 동안 공유한다

main.py는 설정·Router·Adapter를 조립하는 composition root다. Step 2에서는 lifespan이 생성용·상태 확인용 HTTPX2 client 두 개를 열고 닫으며, 같은 용도의 요청들은 해당 connection pool을 재사용한다. 두 client의 목적지는 같은 TurboFieldfareServer다.

lifespan에 Adapter 자원을 넣는가?

HTTP client를 route 요청마다 만들면 TCP 연결과 pool도 반복 생성된다. 반대로 module global로 만들면 어느 event loop에서 열고 언제 닫아야 하는지 불분명해진다. Lifespan은 Uvicorn startup과 shutdown 사이를 명확히 표시하므로 application 단위 공유 자원의 소유권을 표현하기 좋다.

중요: AsyncClient() 생성은 TurboFieldfare 연결이나 model load를 시작하지 않는다. 실제 network I/O는 check_ready()generate()가 호출될 때 시작된다.

현재 전체 코드

import logging
from collections.abc import AsyncGenerator
from contextlib import asynccontextmanager

import httpx2
from fastapi import FastAPI

from app.api.router import api_router
from app.core.config import Settings, get_settings
from app.engines.turbofieldfare import TurboFieldfareAdapter


def create_app(settings: Settings | None = None) -> FastAPI:
    """Build a FastAPI application with explicit, testable dependencies."""
    resolved_settings = settings or get_settings()

    logging.basicConfig(
        level=resolved_settings.log_level,
        format="%(asctime)s %(levelname)s %(name)s %(message)s",
    )
    logger = logging.getLogger(resolved_settings.service_name)

    @asynccontextmanager
    async def lifespan(application: FastAPI) -> AsyncGenerator[None, None]:
        # Each client owns a pool; generation traffic cannot occupy probe slots.
        # Creating clients does not connect to the server or load a model.
        async with (
            httpx2.AsyncClient(
                base_url=str(resolved_settings.inference_base_url),
                timeout=httpx2.Timeout(
                    resolved_settings.inference_generation_timeout,
                    connect=resolved_settings.inference_connect_timeout,
                    pool=resolved_settings.inference_connect_timeout,
                ),
                limits=httpx2.Limits(max_connections=4, max_keepalive_connections=2),
                follow_redirects=False,
                trust_env=False,
            ) as generation_client,
            httpx2.AsyncClient(
                base_url=str(resolved_settings.inference_base_url),
                timeout=httpx2.Timeout(
                    resolved_settings.inference_probe_timeout,
                    connect=resolved_settings.inference_connect_timeout,
                    pool=resolved_settings.inference_connect_timeout,
                ),
                limits=httpx2.Limits(max_connections=2, max_keepalive_connections=2),
                follow_redirects=False,
                trust_env=False,
            ) as probe_client,
        ):
            application.state.inference_engine = TurboFieldfareAdapter(
                generation_client,
                probe_client=probe_client,
                model=resolved_settings.inference_model,
                probe_timeout=resolved_settings.inference_probe_timeout,
                generation_timeout=resolved_settings.inference_generation_timeout,
            )
            logger.info(
                "service_started version=%s environment=%s",
                resolved_settings.service_version,
                resolved_settings.environment,
            )
            try:
                yield
            finally:
                application.state.inference_engine = None
        logger.info("service_stopped")

    application = FastAPI(
        title="Local MoE Service API",
        version=resolved_settings.service_version,
        description="FastAPI gateway for local LLM inference.",
        lifespan=lifespan,
    )
    application.state.settings = resolved_settings
    application.include_router(api_router)
    return application


app = create_app()
소스 동기화: 위 코드는 2026-09-06 readiness 연결 분리 후의 실제 main.py 전체다.

import부터 차근차근 읽기

AsyncGeneratorasynccontextmanager

lifespanyield 앞에서 startup을 실행하고 잠시 멈춘 뒤, server 종료 시 yield 뒤를 계속 실행하는 async generator다. AsyncGenerator[None, None]의 첫 번째 None은 yield로 내보내는 값이 없다는 뜻이고 두 번째 None은 asend()로 받아들이는 값이 없다는 뜻이다. 동기 generator의 send()와 혼동하지 않는다.

httpx2

FastAPI async 함수 안에서 blocking 없이 upstream HTTP를 기다리는 client library다. 요청을 기다리는 동안 event loop는 다른 연결을 처리할 수 있다. httpx2가 Gemma를 실행하는 것은 아니며 JSON을 Swift server로 보내는 역할만 한다.

api_router, Settings, TurboFieldfareAdapter

각각 들어오는 HTTP route 모음, 환경 설정 type, 나가는 inference 호출 구현이다. main.py가 세 부분을 import해 하나의 FastAPI object로 조립한다.

왜 client가 두 개인가?

이전에는 답변 생성과 상태 확인이 연결 4개짜리 풀 하나를 공유했다. 긴 생성 요청 4개가 응답을 기다리면 상태 확인도 빈 연결이 나기를 기다렸다. TurboFieldfare가 정상이어도 기본 3초의 연결 대기 제한을 넘겨 busy, 이어서 /readyz의 503으로 보일 수 있었다.

generation_client · 최대 4개
답변 생성 전용

generate()

POST /v1/chat/completions

probe_client · 최대 2개
상태·모델 목록 확인 전용

check_ready() / list_models()

GET /health · GET /v1/models

두 경로 모두 같은 TurboFieldfareServer로 향한다. 이제 생성 요청이 자기 연결 4개를 모두 사용해도 상태 확인용 연결은 차지하지 못한다. 하나의 풀을 6개로 늘리는 것과 다르다. 하나의 풀이라면 생성 요청 6개가 다시 모든 연결을 차지할 수 있기 때문이다.

async with (... as generation_client, ... as probe_client) 읽는 법

첫 번째 client를 열고, 두 번째 client를 연 다음 안쪽 코드를 실행한다. 두 객체는 같은 설정 주소를 사용하지만 서로 다른 연결 풀을 소유한다. 종료 시에는 진입의 역순으로 두 client가 닫힌다. 두 번째 객체를 준비하는 중 예외가 나더라도 이미 진입한 첫 번째 context의 종료 절차를 실행한다.

TurboFieldfareAdapter(generation_client, probe_client=probe_client, ...)는 client를 복제하지 않고 두 객체의 참조를 전달한다. Adapter 하나가 요청의 목적에 따라 사용할 client를 고른다.

AsyncClient 설정이 뜻하는 것

옵션현재 값의미
base_url두 client 모두 같은 설정값Adapter의 상대 경로 앞에 붙는 추론 서버 주소다.
timeout생성 300초 / 확인 5초, 양쪽 connect·pool 3초기본 설정값이다. 개별 네트워크 작업 제한과 별도로 Adapter의 asyncio.timeout이 client.request의 연결 풀 대기부터 응답 본문 수신까지를 감싼다. 이후 Pydantic 검증·내부 객체 변환은 그 context 밖이다. Readiness는 health와 models 두 요청을 순서대로 실행하므로 전체 readiness가 반드시 5초 안에 끝난다는 뜻은 아니다.
max_connections생성 4 / 확인 2각 client가 유지할 TCP 연결 수의 상한이다. 생성 풀의 기존 값은 유지하고 확인용 풀을 따로 확보했다. 이 값이 최적이라는 성능 실험을 한 것은 아니다.
max_keepalive_connections각 2작업 후 재사용을 위해 남겨 둘 유휴 연결 수의 상한이다. 처음부터 연결 2개를 여는 명령이 아니다.
follow_redirectsfalse다른 주소로 자동 이동하지 않는다.
trust_envfalse환경변수의 proxy 설정을 자동 적용하지 않는다.
연결 수 제한 ≠ 대기 요청 수 제한. 생성 연결 4개가 사용 중일 때 추가 요청은 pool에서 연결을 기다릴 수 있다. 기다리는 요청의 개수 자체를 4개로 제한하는 설정은 아니다. Swift의 생성 대기열과 FastAPI 측 요청 수용 정책도 별개다. 이 값은 application worker 하나당 적용되며, 모델의 동시 추론 개수를 뜻하지 않는다.

상태 확인 요청끼리는 여전히 최대 2개의 연결을 공유한다. 해당 풀이 포화되거나 실제 upstream이 응답하지 않으면 readiness는 실패할 수 있다. 이번 수정의 보장은 “생성 요청이 상태 확인용 풀을 점유하지 않는다”이지 “readiness가 무조건 성공한다”가 아니다.

Runtime 시간 순서

1. Import

Uvicorn이 app.main:app을 import하고 마지막 줄의 create_app()이 FastAPI object를 만든다.

2. Startup

Uvicorn이 lifespan에 진입한다. 생성용·확인용 Client와 Adapter 하나가 만들어지고 application.state.inference_engine에 저장된다.

3. Serve

yield에서 lifespan 함수만 멈춘다. Server 전체가 멈추는 것이 아니며 이 사이 여러 HTTP 요청이 처리된다.

4. Shutdown

finally가 state 참조를 없애고, async with가 두 client를 닫는다. 정상 경로에서는 그 다음 stop log를 남긴다. 예외가 바깥으로 전파되는 경로에서는 마지막 log를 건너뛸 수 있다.

try/finally가 필요한가?

yield 구간을 정상 종료하거나 이 구간으로 예외·취소가 전달되면 finally가 engine 참조를 제거한다. 이 설명은 Python이 정리 코드를 실행할 수 있는 종료 경로에 관한 것이다. 강제 프로세스 종료나 전원 차단까지 finally 실행을 보장하지는 않는다. 그 바깥의 async with는 두 HTTP client 각각의 aclose() 역할을 수행한다. 단, FastAPI는 자신이 시작하지 않은 TurboFieldfare process를 종료하지 않는다.

logging.basicConfig()는 process 전역 logging 설정이며 이미 handler가 구성된 경우 아무 효과가 없을 수 있다. Application factory가 logging까지 자동 격리한다는 뜻은 아니다. 현재 학습 단계에서는 단순한 형식을 사용하고, observability 단계에서 request ID와 구조화 log를 별도로 설계한다.

초기 구현에서 관찰한 실제 서버 동작

service_started version=0.1.0 environment=development GET /healthz → 200 GET /readyz → Turbo /health 200 → /v1/models 200 → 200 service_stopped

Host Python과 Docker container에서 같은 lifespan 경로가 실행됐다. Container에서는 base URL만 http://host.docker.internal:8080으로 바꿨으며 source 코드는 바꾸지 않았다.

이번 수정의 검증

모델 없이 실제 TCP 연결을 사용하는 회귀 테스트로 생성 연결 4개가 점유된 상태에서도 readiness가 올바르게 응답함을 확인했다. 정상·예외·취소 lifespan 종료에서 두 client가 모두 닫히고 engine 참조가 제거되는 것도 확인했다. 전체 테스트는 47 passed다. 이번 수정에서는 실제 Gemma 생성을 다시 실행하지 않았다.

연결 분리 회귀 테스트 해설 · 두 client의 종료 테스트 해설

← 이전Adapter 내부다음 →Inference 설정