fix(dev): 리로드마다 프론트를 다시 빌드하던 고리를 끊음

- 개발 중에는 앱이 내려갈 때 Vite 를 죽이지 않음. Vite 는 한 앱 프로세스가 아니라
  개발 세션의 것이고, 다음 기동이 그대로 물려씀
- Vite 가 이미 그 포트에 살아 있으면 npm run build 를 건너뜀(첫 기동에서만 빌드)

까닭 — uvicorn 리로더가 *.py 마다 앱 프로세스를 갈아 끼우는데, 옛 프로세스가 종료하며
Vite 를 죽이면 새 프로세스가 「Vite 없음」으로 보고 npm run build(최대 300초 + B07 CAD)를
처음부터 다시 돌렸음. 그 사이 /api/health 가 stale: true 인 채 옛 코드를 계속 내주어
옛 화면을 보고 헛검증하게 됨. 2026-09-12 에 두 번 멈췄고 한 번은 두 시간 반 동안 안 끝남.

배포 환경은 그대로 — 빌드도 종료 정리도 종전과 같음.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GrXDD23Dvt2sR7q3X6oekp
This commit is contained in:
2026-09-12 16:41:20 +09:00
co-authored by Claude Opus 5
parent 882d7ebaf3
commit 9f827bf647
+53 -7
View File
@@ -169,6 +169,27 @@ def _kill_process_tree(pid: int) -> None:
logger.debug("[Frontend] 프로세스 정리 중 무시된 예외: %s", exc)
def _dev_port_listening(port: int) -> bool:
"""개발 서버(Vite)가 이미 그 포트를 물고 있나.
**리로드인지 첫 기동인지를 가르는 하나뿐인 표식**이다. uvicorn 리로더는 코드가 바뀌면
앱 프로세스만 다시 띄우고 Vite 자식은 그대로 두므로, 포트가 살아 있으면 리로드다.
환경변수 같은 별도 깃발을 두지 않는 까닭은 그것이 **실제와 어긋날 수 있기** 때문이다 —
포트는 어긋나지 않는다.
"""
import socket
probe = socket.socket()
probe.settimeout(0.5)
try:
probe.connect(("127.0.0.1", port))
return True
except OSError:
return False
finally:
probe.close()
def _free_dev_port(port: int) -> None:
"""개발 서버 포트를 물고 있는 잔여 프로세스를 정리한다.
@@ -235,8 +256,22 @@ def serve_frontend_dev() -> None:
def stop_frontend_dev() -> None:
"""앱이 내려갈 때 개발 서버도 같이 내린다 — 유령 인스턴스를 남기지 않는다."""
"""앱이 내려갈 때 개발 서버도 같이 내린다 — 유령 인스턴스를 남기지 않는다.
⚠⚠ **개발 중에는 내리지 않는다** (2026-09-12 실측으로 드러난 고리).
uvicorn 리로더는 `*.py` 가 바뀔 때마다 앱 프로세스를 갈아 끼운다. 그때 여기서
Vite 를 죽이면 **새 프로세스는 「Vite 가 없다」고 보고 `npm run build` 를 처음부터
다시 돌린다**(최대 300초 + B07 CAD). 그 사이 화면은 뜨지 않고 `/api/health` 는
`stale: true` 인 채로 옛 코드를 계속 내준다 — 그날 리로드가 두 번 멈췄고 한 번은
두 시간 반 동안 안 끝났다. **옛 화면을 보고 헛검증하게 만드는 자리다.**
⇒ Vite 는 **한 앱 프로세스가 아니라 개발 세션의 것**이다. 살려 두고 다음 기동이
그대로 물려쓴다(`_dev_port_listening`). 남은 유령은 다음 기동의
`_free_dev_port` 가 포트로 잡아 정리하므로 쌓이지 않는다.
"""
global _frontend_dev_process
if ENVIRONMENT == "development":
logger.info("[Frontend] 개발 서버는 살려 둔다 — 다음 기동이 그대로 물려쓴다")
return
if _frontend_dev_process is None:
return
logger.info("[Frontend] 개발 서버 종료 (pid %s)", _frontend_dev_process.pid)
@@ -294,12 +329,23 @@ async def lifespan(app: FastAPI):
datetime.fromtimestamp(_CODE_MTIME_AT_START).isoformat(timespec="seconds"),
)
# 프론트엔드 빌드 (필수)
build_frontend()
# 개발 환경에서 개발 서버 실행 (선택사항)
if ENVIRONMENT == "development":
serve_frontend_dev()
# ⚠⚠ **리로드마다 프론트를 다시 빌드하지 않는다** (2026-09-12 실측으로 드러난 자리).
# uvicorn 리로더는 `*.py` 가 바뀔 때마다 이 lifespan 을 다시 탄다. 그때마다
# `npm run build`(최대 300초, B07 CAD 빌드까지 딸림)가 돌고 Vite 를 죽였다 살리느라
# **리로드가 몇 분씩 걸리거나 아예 멈춘다** — 그날 두 번 멈췄고 한 번은 두 시간 반 동안
# 안 끝나, `/api/health` 가 `stale: true` 인 채로 옛 코드를 계속 내주었다.
# ⇒ **옛 화면을 보고 헛검증하게 만드는 자리라 그냥 느린 것이 아니다.**
# 개발 중에는 Vite 가 HMR 로 이미 최신을 내주므로 다시 빌드할 까닭이 없다.
if ENVIRONMENT == "development" and _dev_port_listening(FRONTEND_DEV_PORT):
logger.info(
"[Frontend] 개발 서버가 %d 포트에 이미 살아 있음 — 빌드·재기동 건너뜀(리로드)",
FRONTEND_DEV_PORT,
)
else:
# 첫 기동(또는 프로덕션) — 여기서만 빌드한다.
build_frontend()
if ENVIRONMENT == "development":
serve_frontend_dev()
# DB 풀 초기화
await init_db_pool()