feat(dev): 지금 도는 서버가 옛 코드인지 헬스체크가 알림

자동 리로드가 조용히 죽어 **옛 코드로 도는 서버**에 오늘 두 창이 세 번 속았음
(속도 488초 오측 · 응답에 새 필드가 없는데 화면은 정상으로 보임 · 표준단면 응답이 옛 스키마).
매번 틀린 결론을 한 번씩 냈다가 되물렀음. 규칙("재기 전에 재시작")으로는 세 번 다 안 지켜져
**한 번 보면 아는 표시**로 바꿈.

- `/api/health` 가 개발 모드에서 `started_at` · `code_mtime`(기동 때 본 소스 최신 수정) ·
  `source_mtime`(지금 소스 최신 수정) · `stale` 을 함께 냄. `stale: true` 면 재시작 전
  어떤 측정도 믿으면 안 됨.
- 기동 로그에 감시 폴더 수와 소스 최신 수정 시각을 한 줄 남김.
- 실측 — 감시 폴더의 `.py` 를 고치니 리로드는 **안 뜨고** `stale` 이 즉시 true 로 바뀜.

원인 추적은 계속함(PLAN 7-2) — 한글 경로·정션은 **아님**(`watchfiles` 를 그 폴더에
직접 걸어 보니 변경을 정상 감지). uvicorn 이 감시 폴더 12개를 선언하고도 리로드를
안 내는 자리라 더 파야 함.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-07 11:57:33 +09:00
co-authored by Claude Opus 5
parent 72e50b3edd
commit df02793caf
+49 -2
View File
@@ -15,7 +15,9 @@ import logging
import os
import signal
import subprocess
import time
from contextlib import asynccontextmanager
from datetime import datetime
from pathlib import Path
from typing import Any
@@ -271,6 +273,17 @@ async def lifespan(app: FastAPI):
# 시작
logger.info(f"[{ENVIRONMENT}] 앱 시작 중...")
# 지금 도는 코드가 언제 것인지 못박아 둔다 — `/api/health` 가 `stale` 로 알린다(7-2).
global _STARTED_AT, _CODE_MTIME_AT_START
_STARTED_AT = time.time()
if DEBUG:
_CODE_MTIME_AT_START = _newest_source_mtime()
logger.info(
"[reload] 감시 폴더 %d개, 소스 최신 수정 %s",
len(_reload_dirs()),
datetime.fromtimestamp(_CODE_MTIME_AT_START).isoformat(timespec="seconds"),
)
# 프론트엔드 빌드 (필수)
build_frontend()
@@ -440,10 +453,44 @@ async def root():
}
#: 이 프로세스가 뜬 시각과, 뜰 때 본 **가장 최근 소스 수정 시각**. 기동 때 채운다
#: (`_reload_dirs` 가 아래에 정의돼 있어 모듈 로드 시점에는 못 부른다).
#: 자동 리로드가 조용히 죽는 일이 잦아(오늘만 세 번, 매번 틀린 결론을 냄) 「지금 도는 서버가
#: 옛 코드인가」를 한 번에 가릴 수 있게 남긴다(2026-09-07, PLAN 7-2).
_STARTED_AT = 0.0
_CODE_MTIME_AT_START = 0.0
def _newest_source_mtime() -> float:
"""감시 대상 폴더의 `.py` 중 가장 최근 수정 시각. 못 읽으면 0."""
newest = 0.0
for folder in _reload_dirs():
for path in Path(folder).rglob("*.py"):
try:
newest = max(newest, path.stat().st_mtime)
except OSError:
continue
return newest
@app.get("/api/health")
async def health():
"""헬스 체크"""
return {"status": "ok", "environment": ENVIRONMENT}
"""헬스 체크.
개발 모드에서는 **코드가 최신인지**도 함께 알린다 — `stale: true` 면 소스가 더 새로우니
(자동 리로드가 안 붙은 것) 재시작 전에는 어떤 측정도 믿으면 안 된다.
"""
body: dict[str, Any] = {"status": "ok", "environment": ENVIRONMENT}
if DEBUG:
newest = _newest_source_mtime()
body["started_at"] = datetime.fromtimestamp(_STARTED_AT).isoformat(timespec="seconds")
body["code_mtime"] = datetime.fromtimestamp(_CODE_MTIME_AT_START).isoformat(
timespec="seconds"
)
body["source_mtime"] = datetime.fromtimestamp(newest).isoformat(timespec="seconds")
# 1초 여유 — 저장 직후의 미세한 시각차로 헛경고가 뜨지 않게.
body["stale"] = newest > _CODE_MTIME_AT_START + 1.0
return body
# ─────────────────────────────────────────────────────────────────────────