서비스 학습
SOURCE · be/tests/test_inference_lifespan.py · 2026-09-06

서버 수명이 끝나면 두 client도 닫히는가?

연결 풀을 두 개로 나눴다면 정리 책임도 두 개다. 정상 종료, 예외 종료, 취소 종료에서 같은 정리 규칙이 지켜지는지 실제 lifespan으로 확인한다.

1. 이 파일을 만든 이유

한쪽 client만 닫거나 application.state에 오래된 Adapter 참조를 남기는 실수를 막기 위한 테스트다. 테스트 자체가 자원을 닫아 버리면 production의 정리 누락이 가려지므로, 종료 책임은 실제 main.py의 lifespan에 둔다.

2. 전체 구조의 역할

patch("app.main.httpx2.AsyncClient", ...)는 main.py가 client를 만드는 순간을 관찰한다. 원래 AsyncClient 생성자를 미리 보관하고, 대체 함수 안에서 실제 client를 만들어 목록에 넣는다. network만 MockTransport로 바꾸며 client의 is_closed와 async context 정리 동작은 실제 것을 사용한다.

3. 입력과 출력

종료 조건lifespan 내부종료 후
normal별도 예외 없이 통과두 client 모두 is_closed=True
application.state.inference_engine=None
exceptionExitForTest 발생
cancel현재 Task에 cancel 요청 후 await로 전달

공유 준비 시점에는 정확히 두 개의 서로 다른 client가 존재하고 둘 다 열려 있어야 한다.

4. 코드 구간별 해설

original_client와 build_client

원래 생성자를 저장하지 않고 patch된 이름으로 다시 만들면 대체 함수가 자신을 반복 호출할 수 있다. original_client로 실제 객체를 만들고 main.py에서 받은 timeout·limits 등의 설정은 그대로 전달한다. unexpected_request는 startup·shutdown에서 HTTP 요청이 생기면 AssertionError를 발생시킨다. client 생성이 모델 로딩을 뜻하지 않는다는 경계도 검사한다.

is와 is_closed의 차이

clients[0] is not clients[1]은 서로 다른 객체인지 확인한다. is_closed는 해당 객체가 종료됐는지 확인하는 상태 속성이다. 객체가 두 개라는 사실만으로 종료를 증명하지 못하므로 두 검사를 나눠 수행한다.

예외를 왜 테스트 바깥에서 잡는가?

ExitForTest나 CancelledError가 async with 밖으로 지나가야 lifespan의 예외 종료 경로가 실행된다. 내부에서 모두 잡으면 정상 종료만 검사하게 된다. 테스트 본문은 의도한 예외를 받은 뒤 client와 state 정리를 확인한다. cancel 조건의 sleep(0)은 시간을 오래 기다리는 코드가 아니라 다음 await에서 취소가 전달될 기회를 주는 코드다.

5. 수명과 상태 변화

진입 전: client 목록 비어 있음 → lifespan 진입: 서로 다른 client 두 개, is_closed=False → yield 구간: Adapter 사용 가능 → 정상 / 예외 / 취소 종료 → finally: engine 참조 제거 → async with 종료: 두 client is_closed=True

6. 실제 결과

세 종료 조건 모두 통과했다. 전체 47 passed에 포함된다. 여기서는 실제 TCP 연결을 열지 않고 client 객체의 수명 관리를 검사하며, socket 반환은 별도 실제 TCP 테스트가 담당한다.

7. 자주 생기는 오해

engine을 None으로 바꾸는 것만으로 client가 닫히지는 않는다. None은 application의 참조를 제거하고, 실제 정리는 async with가 수행한다. 또한 stop log는 정상 경로 마지막에 있으므로 예외가 바깥으로 전파되면 생략될 수 있다. 이 테스트의 완료 기준은 log 문구가 아니라 자원의 종료 상태다.

현재 전체 코드

import asyncio
from unittest.mock import patch

import httpx2
import pytest

from app.core.config import Settings
from app.main import create_app


@pytest.mark.parametrize("exit_mode", ["normal", "exception", "cancel"])
def test_both_http_clients_close_when_lifespan_ends(exit_mode: str) -> None:
    async def scenario() -> None:
        clients: list[httpx2.AsyncClient] = []
        original_client = httpx2.AsyncClient

        def build_client(**kwargs) -> httpx2.AsyncClient:
            def unexpected_request(request: httpx2.Request) -> httpx2.Response:
                raise AssertionError("Startup and shutdown must not call inference")

            client = original_client(
                **kwargs, transport=httpx2.MockTransport(unexpected_request),
            )
            clients.append(client)
            return client

        class ExitForTest(Exception):
            pass

        application = create_app(Settings(_env_file=None, environment="test"))
        with patch("app.main.httpx2.AsyncClient", side_effect=build_client):
            try:
                async with application.router.lifespan_context(application):
                    assert len(clients) == 2
                    assert clients[0] is not clients[1]
                    assert all(not client.is_closed for client in clients)
                    if exit_mode == "exception":
                        raise ExitForTest()
                    if exit_mode == "cancel":
                        task = asyncio.current_task()
                        assert task is not None
                        task.cancel()
                        await asyncio.sleep(0)
            except (ExitForTest, asyncio.CancelledError):
                assert exit_mode != "normal"
        assert all(client.is_closed for client in clients)
        assert application.state.inference_engine is None

    asyncio.run(scenario())

실제 테스트 소스와 동일한 코드다. 실행 명령: cd be.venv/bin/python -m pytest -q tests/test_inference_lifespan.py.

8. 다음 파일과의 연결

오류 분류 검증에서 다른 경계의 보장을 확인한다. 전체 구조는 Backend 개요, client 조립은 main.py, 오류 번역은 Adapter 구현과 연결된다.

← 개요Backend 검증 목록다음 →오류 분류 검증