diff --git a/main.py b/main.py index 84c59609..8ed3fe41 100644 --- a/main.py +++ b/main.py @@ -12,6 +12,7 @@ FastAPI 애플리케이션 진입점 import asyncio import logging import os +import signal import subprocess from contextlib import asynccontextmanager from pathlib import Path @@ -63,6 +64,12 @@ logger = logging.getLogger(__name__) # ───────────────────────────────────────────────────────────────────────── +# 개발 서버 포트. 고정해 두고, 앱이 내려갈 때 함께 정리한다. +FRONTEND_DEV_PORT = int(os.getenv("FRONTEND_DEV_PORT", "5173")) +# 띄운 개발 서버 핸들 — 종료 때 이 계통을 통째로 끝낸다. +_frontend_dev_process: subprocess.Popen | None = None + + def _frontend_process_env(root_dir: Path) -> dict[str, str]: """config/node_modules를 사용하는 프론트엔드 프로세스 환경을 만든다.""" env = os.environ.copy() @@ -104,29 +111,94 @@ def build_frontend() -> bool: return False +def _kill_process_tree(pid: int) -> None: + """프로세스와 그 자식들을 함께 끝낸다. + + `shell=True`로 띄우면 실제 계통이 cmd.exe → npm → node라 부모만 죽이면 node가 남는다. + 남은 node가 포트를 계속 물고 있으면 다음 실행 때 Vite가 옆 포트로 올라가고, 사용자는 + 옛 화면을 보게 된다(2026-08-01 실제로 13개가 쌓였다). + """ + try: + if os.name == "nt": + subprocess.run( + ["taskkill", "/T", "/F", "/PID", str(pid)], + check=False, + capture_output=True, + ) + else: + os.killpg(os.getpgid(pid), signal.SIGTERM) + except Exception as exc: # 이미 죽었으면 그것으로 됐다 + logger.debug("[Frontend] 프로세스 정리 중 무시된 예외: %s", exc) + + +def _free_dev_port(port: int) -> None: + """개발 서버 포트를 물고 있는 잔여 프로세스를 정리한다. + + 앞선 실행이 비정상 종료(강제 종료·전원 차단)했을 때를 대비한 청소다. 이걸 하지 않으면 + Vite가 `--strictPort` 때문에 기동에 실패한다. + """ + if os.name != "nt": + return + try: + result = subprocess.run( + ["netstat", "-ano", "-p", "TCP"], check=False, capture_output=True, text=True + ) + except Exception: + return + needle = f":{port} " + for line in (result.stdout or "").splitlines(): + if needle not in line or "LISTENING" not in line: + continue + pid = line.split()[-1] + if pid.isdigit() and int(pid) != os.getpid(): + logger.info("[Frontend] 포트 %d를 물고 있던 잔여 프로세스 %s 정리", port, pid) + _kill_process_tree(int(pid)) + + def serve_frontend_dev() -> None: - """프론트엔드 개발 서버 실행 (npm run dev from project root) — 선택 사항""" + """프론트엔드 개발 서버 실행 (npm run dev from project root) — 선택 사항. + + 포트를 **고정**(`--strictPort`)한다. 예전에는 포트가 비어 있지 않으면 Vite가 조용히 + 옆 포트로 올라갔고, 그렇게 뜬 옛 인스턴스가 계속 살아남아 하루치 변경이 화면에 반영되지 + 않는 일이 있었다. 고정해 두면 포트가 막혀 있을 때 그 자리에서 드러난다. + """ + global _frontend_dev_process root_dir = Path(__file__).resolve().parent if ENVIRONMENT == "production": logger.info("[Frontend] 프로덕션 환경: 개발 서버 실행 스킵") return - logger.info("[Frontend] npm run dev 시작 (백그라운드)...") + _free_dev_port(FRONTEND_DEV_PORT) + logger.info("[Frontend] npm run dev 시작 (포트 %d 고정, 백그라운드)...", FRONTEND_DEV_PORT) try: - subprocess.Popen( - "npm run dev -- --host", + _frontend_dev_process = subprocess.Popen( + f"npm run dev -- --host --port {FRONTEND_DEV_PORT} --strictPort", shell=True, cwd=str(root_dir), env=_frontend_process_env(root_dir), stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + # POSIX에서 자식까지 한 번에 끝내려면 프로세스 그룹을 따로 잡아야 한다. + start_new_session=os.name != "nt", ) - logger.info("✓ 프론트엔드 개발 서버 백그라운드 실행") + logger.info("✓ 프론트엔드 개발 서버 백그라운드 실행 (pid %s)", _frontend_dev_process.pid) except Exception as e: logger.warning(f"⚠ 프론트엔드 개발 서버 실행 실패: {e}") +def stop_frontend_dev() -> None: + """앱이 내려갈 때 개발 서버도 같이 내린다 — 유령 인스턴스를 남기지 않는다.""" + global _frontend_dev_process + if _frontend_dev_process is None: + return + logger.info("[Frontend] 개발 서버 종료 (pid %s)", _frontend_dev_process.pid) + _kill_process_tree(_frontend_dev_process.pid) + _frontend_dev_process = None + # 셸을 거쳐 띄우므로 손자 프로세스가 남을 수 있다. 포트로 한 번 더 확인해 정리한다. + _free_dev_port(FRONTEND_DEV_PORT) + + async def cleanup_expired_sessions() -> None: """매시간 만료된 세션과 사용 완료 OTP를 정리한다.""" while True: @@ -190,6 +262,7 @@ async def lifespan(app: FastAPI): logger.info("앱 종료 중...") cleanup_task.cancel() resource_task.cancel() + stop_frontend_dev() await close_db_pool() logger.info("✓ DB 풀 종료 완료")