본문으로 건너뛰기

비동기 우선 워커 복구

증상

워커 서비스가 의도한 비동기 우선 패턴에서 벗어나기 시작할 때 이 가이드를 사용하세요.

  • FastAPI 핸들러가 I/O 차단을 피하기 위한 임시 방편으로 def를 사용합니다.
  • 라이브 클라이언트 또는 컨테이너가 FastAPI 수명주기 내부가 아니라 import 시점에 생성됩니다.
  • 공유 httpx.AsyncClient 인스턴스는 종료 시 닫히지 않습니다.
  • best-effort 실패가 로그 추적 없이 사라집니다.

이 복구 패턴은 2026년 2월 20일부터 2026년 5월 19일 사이의 analysis, report, grading 변경에서 직접 확인됩니다.

이 실패 모드가 발생한 이유

분석 서비스는 c96370c7에서 async def 라우트로 처음 출시되었지만, services/analysis/main.py에는 동기 Anthropic 클라이언트가 있었습니다. 5c80a4d1의 즉각적인 우회책은 이벤트 루프 차단을 피하기 위해 라우트 핸들러를 일반 def로 바꾸는 것이었습니다.

이는 아키텍처가 아니라 증상을 수정했습니다. 이후 복구 순서는 다음과 같습니다.

날짜커밋복구 단계
2026-05-113710695f분석이 AsyncOpenAI로 전환되었습니다. 보고서는 자동으로 삼키는 대신 최선의 순위 실패를 기록하기 시작했습니다
2026-05-11622f1149grading과 report가 수명주기 범위의 공유 httpx.AsyncClient wiring으로 이동
2026-05-18aa770cd7analysis가 DI 컨테이너와 src/ 레이어 뒤로 이동
2026-05-190cd5feaa워커 서비스가 import 시점 컨테이너 생성을 중단하고 FastAPI 수명주기로 이동

진단 경로

Import 시점 컨테이너 또는 클라이언트 확인

현재 복구 대상은 services/analysis/main.py, services/report/main.py에서 사용되는 패턴입니다.

대표적인 형태:

_container: Container | None = None

@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
global _container
_container = build_container()
try:
yield
finally:
...
_container = None

반대로 워커가 모듈 import 시점에 컨테이너를 구성하면 시작 실패를 복구하기가 더 어려워지고 테스트 격리도도 낮아집니다.

비동기 클라이언트가 공유되고 닫혀 있는지 확인하세요.

services/grading/src/wiring.py는 공유 백엔드 HTTP 액세스에 대한 참조 패턴입니다.

def build_http_client() -> httpx.AsyncClient:
return httpx.AsyncClient(
timeout=httpx.Timeout(connect=5.0, read=30.0, write=10.0, pool=5.0),
limits=httpx.Limits(max_connections=20, max_keepalive_connections=10),
transport=_http_transport(),
)

컨테이너가 이 클라이언트를 보유하고, 진입점은 종료 중에 컨테이너를 닫습니다. services/report/main.py는 이제 수명주기 finally 블록에서 callbacker, report_renderer, http_client를 닫습니다. services/analysis/main.pyjoossameng_cache, http_client, llm_adapter를 닫습니다.

동기 라우트가 차단 어댑터를 가리고 있는지 확인하세요

차단 네트워크 I/O를 숨기기 위해서만 라우트가 def가 되었다면 현재 설계에 대한 회귀입니다. 수정된 analysis 진입점은 /api/analysis/exam-commentary/api/analysis/student-feedback 모두에서 async def로 돌아갔고, 동기 핸들러 우회책 대신 컨테이너 뒤의 비동기 어댑터를 사용합니다.

조용한 best-effort 실패 확인

3710695fservices/report/src/usecase.py의 학생 순위 대체 경로에 로그를 추가해 report 쪽 사각지대를 수정했습니다. 워커에 로깅 없이 대체값을 반환하는 best-effort 분기가 여전히 있으면, 반복되는 실패가 런타임 오류가 아니라 간헐적인 데이터 문제처럼 보일 수 있습니다.

복구 단계

  1. 컨테이너 구성을 FastAPI 수명주기로 옮기고 null 허용 모듈 수준 참조만 유지합니다.
  2. 핸들러를 def로 바꾸는 대신 동기 네트워크 클라이언트를 비동기 어댑터로 교체합니다.
  3. 공유 httpx.AsyncClient 구성을 서비스 wiring 레이어에 두고 수명주기 종료에서 닫습니다.
  4. 동기 라우트 핸들러 대신 asyncio.to_thread(...)를 사용해 CPU 바운드 작업을 이벤트 루프 밖에서 실행합니다.
  5. 반복되는 소프트 오류가 로그에 표시되도록 best-effort 대체 경로에 logger.exception(...)를 추가합니다.

예방

  • services/analysis/main.pyservices/report/main.py를 수명주기 범위 컨테이너의 진입점 참조로 처리합니다.
  • services/grading/src/wiring.py를 공유 httpx.AsyncClient 소유권에 대한 참조로 처리합니다.
  • 서비스에 임시 동기 우회책이 필요한 경우 부채로 문서화하세요. 최종 상태는 여전히 비동기 어댑터가 지원하는 비동기 핸들러여야 합니다.

종속성 또는 런타임 리팩터링 후에 나타나는 CI 오류는 Generated Requirements Drift를 참조하세요.

운영 세부사항

async-first pattern의 핵심은 resource ownership입니다. worker service는 runtime dependency를 FastAPI lifespan startup에서 명시적으로 만들고 shutdown에서 해제해야 합니다.

resource기대 owner잘못 배치될 때의 실패
DI containerFastAPI lifespan.import-time side effect와 격리하기 어려운 test가 생깁니다.
httpx.AsyncClientservice wiring과 lifespan cleanup.connection leak과 noisy shutdown이 생깁니다.
LLM 또는 backend adaptercontainer 뒤의 async adapter.event loop blocking 또는 sync handler workaround가 생깁니다.
best-effort fallbacklogging을 갖춘 use case layer.조용한 degraded behavior가 생깁니다.

목표는 모든 작업을 무조건 non-blocking으로 만드는 것이 아닙니다. CPU-bound work도 ownership과 logging boundary가 분명하다면 asyncio.to_thread(...)로 안전하게 실행할 수 있습니다.

복구 절차

  1. affected service에서 module-level client 또는 container construction을 검색합니다.
  2. construction을 lifespan()으로 옮기고 request access용 nullable module-level handle만 유지합니다.
  3. route handler를 다시 async def로 바꾸기 전에 sync network adapter를 교체합니다.
  4. 모든 shared async resource에 shutdown cleanup을 추가합니다.
  5. production fallback behavior에 의존하기 전에 best-effort fallback 주변에 logger.exception(...)을 추가합니다.
  6. startup, shutdown, container를 사용하는 최소 하나의 request path를 다루는 service test를 다시 실행합니다.

검증 증거

다음을 기록합니다.

  • import가 더 이상 live network client를 열지 않습니다.
  • startup이 app lifespan마다 container를 정확히 한 번 생성합니다.
  • shutdown이 unclosed-resource warning 없이 shared client를 닫습니다.
  • I/O를 수행하는 route handler는 async def이고 async adapter를 호출합니다.
  • best-effort failure path는 반복 진단에 충분한 context가 있는 log를 남깁니다.

피해야 할 패턴

  • blocking I/O의 영구 수정으로 route를 def로 바꾸는 것.
  • handler 편의를 위해 global client를 module import 시점에 만드는 것.
  • ranking, callback, report-render fallback 오류를 log 없이 삼키는 것.
  • lifespan-scoped shared client 대신 request마다 httpx.AsyncClient를 만드는 것.