fix(공통): 앱 종료 시 프론트엔드 개발 서버도 함께 내리고 포트를 고정
지금까지 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) <noreply@anthropic.com>
This commit is contained in:
@@ -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 풀 종료 완료")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user