""" config_dev_server.py 개발 서버 전용 — 프런트(Vite) 띄움·끔 + 파이썬 자동 리로드 범위 main.py 에서 쪼갠 것(동작 그대로). 운영에서는 `serve_frontend_dev` 가 아무것도 안 한다. """ import locale import logging import os import signal import subprocess from pathlib import Path from config.config_system import ENVIRONMENT logger = logging.getLogger(__name__) _ROOT = Path(__file__).resolve().parent.parent # 개발 서버 포트. 고정해 두고, 앱이 내려갈 때 함께 정리한다. 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() node_modules = root_dir / "config" / "node_modules" bin_dir = node_modules / ".bin" env["PATH"] = f"{bin_dir}{os.pathsep}{env.get('PATH', '')}" env["NODE_PATH"] = str(node_modules) return env def build_frontend() -> bool: """프론트엔드 빌드 (npm run build from project root)""" root_dir = _ROOT logger.info("[Frontend] npm run build 시작...") try: result = subprocess.run( "npm run build", shell=True, cwd=str(root_dir), env=_frontend_process_env(root_dir), capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=300, ) if result.returncode == 0: logger.info("✓ 프론트엔드 빌드 완료") return True else: logger.error(f"✗ 프론트엔드 빌드 실패:\n{result.stderr}") return False except subprocess.TimeoutExpired: logger.error("✗ 프론트엔드 빌드 타임아웃 (300초)") return False except Exception as e: logger.error(f"✗ 프론트엔드 빌드 중 예외: {e}") 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 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: """개발 서버 포트를 물고 있는 잔여 프로세스를 정리한다. 앞선 실행이 비정상 종료(강제 종료·전원 차단)했을 때를 대비한 청소다. 이걸 하지 않으면 Vite가 `--strictPort` 때문에 기동에 실패한다. """ if os.name != "nt": return try: # 한글 Windows의 netstat는 콘솔 코드페이지(cp949)로 찍는다 — 기본 utf-8로 읽으면 # 한글 헤더에서 UnicodeDecodeError가 나고, 그 예외가 읽기 스레드에서 터져 # 포트 정리가 통째로 죽는다(2026-08-16 실제 발생). 시스템 인코딩으로 읽고, # 깨지는 바이트는 넘긴다 — 우리가 볼 것은 숫자 포트·PID뿐이다. result = subprocess.run( ["netstat", "-ano", "-p", "TCP"], check=False, capture_output=True, text=True, encoding=locale.getpreferredencoding(False), errors="replace", ) 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) — 선택 사항. 포트를 **고정**(`--strictPort`)한다. 예전에는 포트가 비어 있지 않으면 Vite가 조용히 옆 포트로 올라갔고, 그렇게 뜬 옛 인스턴스가 계속 살아남아 하루치 변경이 화면에 반영되지 않는 일이 있었다. 고정해 두면 포트가 막혀 있을 때 그 자리에서 드러난다. """ global _frontend_dev_process root_dir = _ROOT if ENVIRONMENT == "production": logger.info("[Frontend] 프로덕션 환경: 개발 서버 실행 스킵") return _free_dev_port(FRONTEND_DEV_PORT) logger.info("[Frontend] npm run dev 시작 (포트 %d 고정, 백그라운드)...", FRONTEND_DEV_PORT) try: _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("✓ 프론트엔드 개발 서버 백그라운드 실행 (pid %s)", _frontend_dev_process.pid) except Exception as e: logger.warning(f"⚠ 프론트엔드 개발 서버 실행 실패: {e}") 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) _kill_process_tree(_frontend_dev_process.pid) _frontend_dev_process = None # 셸을 거쳐 띄우므로 손자 프로세스가 남을 수 있다. 포트로 한 번 더 확인해 정리한다. _free_dev_port(FRONTEND_DEV_PORT) def reload_dirs() -> list[str]: """자동 리로드가 **훑을 폴더만** 골라 준다 — 폴더째 주면 서버가 쉬는 중에도 CPU를 먹는다. uvicorn 의 StatReload 는 0.25초마다 지정 폴더를 통째로 훑는다. 아무것도 안 주면 현재 폴더가 대상이 되는데, 여기에는 `storage`(27GB · 프로젝트 산출물)와 `tmp`·`venv`· `node_modules` 가 함께 들어 있다. 그래서 **요청이 하나도 없는 서버가 CPU 70%** 를 물고 있었고, 창 둘이 각각 그러니 기기가 늘 140% 눌린 채였다(2026-09-07 실측). 속도 계측이 회차마다 3배씩 흔들린 원인도 이것이다. 파이썬이 실제로 사는 폴더만 준다 — 화면 코드(TS)는 vite 가 따로 본다. """ root = _ROOT # ⚠ 루트와 `config` 는 넣지 않는다 — uvicorn 은 준 폴더를 `rglob("*.py")` 로 **재귀**로 # 훑는다. 루트를 주면 storage·venv(파이썬 파일만 7,530개)를 도로 다 훑고, `config` 안에는 # `node_modules` 가 들어 있어 역시 무겁다. 그 대신 `main.py`·`config/` 는 # uvicorn 이 더하는 현재 폴더 감시가 맡는다(`reload_excludes` 참조). watched: list[str] = [] for entry in sorted(root.iterdir()): if not entry.is_dir() or entry.name.startswith(".") or entry.name in RELOAD_SKIP: continue # 링크(정션)는 통째로 건너뛴다. `node_modules` 안에 **저장소 루트를 되가리키는 정션**이 # 있어(`B07_DesignDetail/openwebcad/node_modules/forest-road-webapp` → 저장소 루트, # npm 이 만든 것) 리로더가 그리로 되돌아가 `venv`·`storage` 를 다시 훑고 또 되감겼다. # 실측(2026-09-07) — 감시 폴더를 훑으면 파일 **486,809개**, 한 바퀴 **1,045초**. # 파이썬 `rglob` 은 정션을 따라 들어가고 PowerShell 은 안 따라가 눈으로는 안 보였다. if entry.is_symlink(): continue # 의존성 트리가 **한 겹 아래에 숨어 있는** 경우도 뺀다 — 위 정션이 그 안에 있었다. if (entry / "node_modules").is_dir() or any(entry.glob("*/node_modules")): continue if any(entry.rglob("*.py")): watched.append(str(entry)) return watched # 파이썬이 있어도 훑지 않을 폴더 — 산출물·의존성·백업. RELOAD_SKIP = { "storage", "tmp", "venv", "node_modules", "__pycache__", "log", "docs", "resources", "scratch", "0_old", "db_management", "migrations", "ui_template", "old_code", } def reload_excludes() -> list[str]: """uvicorn 0.24 는 `reload_dirs` 와 무관하게 현재 폴더 전체를 감시에 더한다 (`watchfilesreload.py` 의 `Path.cwd()` 추가) — 시험·tmp·docs 의 `.py` 하나로도 서버가 다시 읽힌다. 그 폴더들을 **절대 경로**로 제외한다(상대 이름은 uvicorn 이 안 맞춘다). """ return [ str(e) for e in sorted(_ROOT.iterdir()) if e.is_dir() and (e.name in RELOAD_SKIP or e.name.startswith(".")) ] def patch_windows_reload() -> None: """윈도우에서 uvicorn 리로더가 **영원히 멈추는 것**을 고친다 (2026-09-12 실측). 무엇이 잘못됐나 uvicorn 0.24 의 `BaseReload.restart()` 는 윈도우에서만 옛 워커를 `os.kill(worker_pid, CTRL_C_EVENT)` 로 내리려 한다. 그런데 CTRL_C_EVENT 는 **프로세스 그룹을 지정해 보낼 수 없다** — MSDN 이 「그룹 id 가 0 이 아니면 호출은 성공하지만 신호는 전달되지 않는다」고 못박아 두었다. 워커는 리로더의 평범한 자식이라 그룹 우두머리가 아니므로 그 신호는 **아무 데도 가지 않는다.** 바로 다음 줄 `join()` 이 영원히 안 풀려서 — · 옛 워커는 살아서 **옛 코드로 요청을 계속 처리**하고 · 새 워커는 태어나지 않으며 · 그 뒤의 파일 변경은 **감지조차 안 된다**(리로더가 그 자리에 갇혀 있다) 「고쳤는데 화면이 안 바뀐다」로 보이는 자리라 그냥 느린 것이 아니다. 실측 (그 자리를 그대로 흉내 낸 부모·자식 한 쌍) CTRL_C_EVENT → **8초 안에 안 풀림** (DETACHED · CREATE_NO_WINDOW · CREATE_NO_WINDOW+NEWGROUP 셋 모두) · `terminate()` → **0.00초** · CTRL_BREAK_EVENT(자식을 그룹 우두머리로) → **0.00초** ⇒ uvicorn 이 **리눅스에서 이미 쓰는** `terminate()` 가 윈도우에서도 즉시 먹는다. 그래서 여기서는 그 리눅스 경로를 그대로 쓴다(새 방식을 지어내지 않는다). ⚠ 개발 기동(`DEBUG`)에서만 갈아 끼운다. uvicorn 이 위쪽에서 고치면 **이 함수를 지우면 된다** — 다른 자리는 아무것도 안 건드린다. """ from uvicorn._subprocess import get_subprocess from uvicorn.supervisors.basereload import BaseReload def restart(self: BaseReload) -> None: self.process.terminate() self.process.join() self.process = get_subprocess(config=self.config, target=self.target, sockets=self.sockets) self.process.start() BaseReload.restart = restart logger.info("[reload] 윈도우 리로더를 terminate() 방식으로 갈아 끼움")