From b3d9282869cc53c1d1137a845f5ba1e5a208cab9 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sat, 1 Aug 2026 16:57:50 +0900 Subject: [PATCH] =?UTF-8?q?fix(=EA=B3=B5=ED=86=B5):=20=EC=95=B1=20?= =?UTF-8?q?=EC=A2=85=EB=A3=8C=20=EC=8B=9C=20=ED=94=84=EB=A1=A0=ED=8A=B8?= =?UTF-8?q?=EC=97=94=EB=93=9C=20=EA=B0=9C=EB=B0=9C=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=EB=8F=84=20=ED=95=A8=EA=BB=98=20=EB=82=B4=EB=A6=AC=EA=B3=A0=20?= =?UTF-8?q?=ED=8F=AC=ED=8A=B8=EB=A5=BC=20=EA=B3=A0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 지금까지 main.py는 Vite를 띄우기만 하고 종료를 시키지 않았다. 서버를 다시 띄울 때마다 이전 Vite가 살아남고, 포트가 차 있으면 Vite가 조용히 옆 포트로 올라갔다. 그래서 오늘 5173~5190에 13개가 쌓였고, 사용자가 오전에 뜬 인스턴스를 보는 바람에 하루치 변경이 화면에 반영되지 않은 것처럼 보였다. - 종료 시 stop_frontend_dev()로 개발 서버 계통을 통째로 끝낸다. shell=True로 띄우면 실제 계통이 cmd.exe -> npm -> node라 부모만 죽이면 node가 남는다. Windows는 taskkill /T /F, POSIX는 프로세스 그룹으로 처리한다. - 개발 서버 포트를 FRONTEND_DEV_PORT(기본 5173)로 고정한다(--strictPort). 포트가 막혀 있으면 조용히 옮겨가지 않고 그 자리에서 드러난다. - 기동 전에 그 포트를 물고 있는 잔여 프로세스를 정리한다. 앞선 실행이 강제 종료됐을 때를 대비한 청소이며, 이게 없으면 --strictPort 때문에 기동이 실패한다. 실기동 검증: 기동 시 5173/8000만 뜸 -> API만 강제 종료해 고아 Vite를 만든 뒤 재기동하니 "포트 5173를 물고 있던 잔여 프로세스 30736 정리" 로그와 함께 5173을 회수했다. Co-Authored-By: Claude Opus 5 (1M context) --- main.py | 83 +++++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 78 insertions(+), 5 deletions(-) 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 풀 종료 완료")