diff --git a/config/config_dev_server.py b/config/config_dev_server.py new file mode 100644 index 000000000..487a5a3fc --- /dev/null +++ b/config/config_dev_server.py @@ -0,0 +1,301 @@ +""" +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() 방식으로 갈아 끼움") diff --git a/main.py b/main.py index b8135cf87..3740dd697 100644 --- a/main.py +++ b/main.py @@ -10,11 +10,8 @@ FastAPI 애플리케이션 진입점 """ import asyncio -import locale import logging import os -import signal -import subprocess import time from contextlib import asynccontextmanager from datetime import datetime @@ -99,6 +96,16 @@ from common_util.common_util_help_router import router as help_router from common_util.common_util_resource_monitor import sample_resources_loop from common_util.common_util_temp_cleanup import cleanup_expired_temp_uploads_loop from config.config_db import close_db_pool, get_db_pool, init_db_pool +from config.config_dev_server import ( + FRONTEND_DEV_PORT, + build_frontend, + dev_port_listening, + patch_windows_reload, + reload_dirs, + reload_excludes, + serve_frontend_dev, + stop_frontend_dev, +) # 설정 import from config.config_system import ( @@ -125,189 +132,6 @@ logger = logging.getLogger(__name__) # 둘은 고칠 자리가 전혀 다른데, 이 줄이 없으면 로그만 보고는 못 가린다. logger.info("[boot] 워커 프로세스 시작 pid=%s", os.getpid()) -# ───────────────────────────────────────────────────────────────────────── -# 프론트엔드 빌드 및 서빙 함수 -# ───────────────────────────────────────────────────────────────────────── - - -# 개발 서버 포트. 고정해 두고, 앱이 내려갈 때 함께 정리한다. -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 = Path(__file__).resolve().parent - - 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 = Path(__file__).resolve().parent - - 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) - async def cleanup_expired_sessions() -> None: """매시간 만료된 세션과 사용 완료 OTP를 정리한다.""" @@ -385,7 +209,7 @@ async def lifespan(app: FastAPI): _CODE_MTIME_AT_START = _newest_source_mtime() logger.info( "[reload] 감시 폴더 %d개, 소스 최신 수정 %s", - len(_reload_dirs()), + len(reload_dirs()), datetime.fromtimestamp(_CODE_MTIME_AT_START).isoformat(timespec="seconds"), ) @@ -396,7 +220,7 @@ async def lifespan(app: FastAPI): # 안 끝나, `/api/health` 가 `stale: true` 인 채로 옛 코드를 계속 내주었다. # ⇒ **옛 화면을 보고 헛검증하게 만드는 자리라 그냥 느린 것이 아니다.** # 개발 중에는 Vite 가 HMR 로 이미 최신을 내주므로 다시 빌드할 까닭이 없다. - if ENVIRONMENT == "development" and _dev_port_listening(FRONTEND_DEV_PORT): + if ENVIRONMENT == "development" and dev_port_listening(FRONTEND_DEV_PORT): logger.info( "[Frontend] 개발 서버가 %d 포트에 이미 살아 있음 — 빌드·재기동 건너뜀(리로드)", FRONTEND_DEV_PORT, @@ -571,7 +395,7 @@ async def root(): #: 이 프로세스가 뜬 시각과, 뜰 때 본 **가장 최근 소스 수정 시각**. 기동 때 채운다 -#: (`_reload_dirs` 가 아래에 정의돼 있어 모듈 로드 시점에는 못 부른다). +#: (`reload_dirs` 가 아래에 정의돼 있어 모듈 로드 시점에는 못 부른다). #: 자동 리로드가 조용히 죽는 일이 잦아(오늘만 세 번, 매번 틀린 결론을 냄) 「지금 도는 서버가 #: 옛 코드인가」를 한 번에 가릴 수 있게 남긴다(2026-09-07, PLAN 7-2). _STARTED_AT = 0.0 @@ -581,7 +405,7 @@ _CODE_MTIME_AT_START = 0.0 def _newest_source_mtime() -> float: """감시 대상 폴더의 `.py` 중 가장 최근 수정 시각. 못 읽으면 0.""" newest = 0.0 - for folder in _reload_dirs(): + for folder in reload_dirs(): for path in Path(folder).rglob("*.py"): try: newest = max(newest, path.stat().st_mtime) @@ -696,104 +520,13 @@ app.include_router(help_router, dependencies=protected) # ───────────────────────────────────────────────────────────────────────── # 앱 실행 # ───────────────────────────────────────────────────────────────────────── -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 = Path(__file__).resolve().parent - # ⚠ 루트와 `config` 는 넣지 않는다 — uvicorn 은 준 폴더를 `rglob("*.py")` 로 **재귀**로 - # 훑는다. 루트를 주면 storage·venv(파이썬 파일만 7,530개)를 도로 다 훑고, `config` 안에는 - # `node_modules` 가 들어 있어 역시 무겁다. 그 대신 **`main.py` 와 `config/` 를 고칠 때는 - # 서버를 손으로 다시 띄울 것** (둘 다 자주 고치는 자리가 아니다). - 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", -} - - -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() 방식으로 갈아 끼움") - - if __name__ == "__main__": import sys import uvicorn if DEBUG and sys.platform == "win32": - _patch_windows_reload() + patch_windows_reload() logger.info(f"Starting server on {SERVER_HOST}:{SERVER_PORT}") uvicorn.run( @@ -801,7 +534,8 @@ if __name__ == "__main__": host=SERVER_HOST, port=SERVER_PORT, reload=DEBUG, - # 폴더를 좁히지 않으면 리로더가 storage(27GB)까지 0.25초마다 훑는다 — 위 주석 참조. - reload_dirs=_reload_dirs() if DEBUG else None, + # 폴더를 좁히지 않으면 storage(27GB)까지 훑는다 — config_dev_server 참조. + reload_dirs=reload_dirs() if DEBUG else None, + reload_excludes=reload_excludes() if DEBUG else None, access_log=False, ) diff --git a/resources/tester/test_506_1_reload_scope.py b/resources/tester/test_506_1_reload_scope.py new file mode 100644 index 000000000..7e6ee5c70 --- /dev/null +++ b/resources/tester/test_506_1_reload_scope.py @@ -0,0 +1,42 @@ +"""506-1 — 개발 서버 다시 읽기 범위: 시험·tmp .py 는 제외, 모듈 폴더 .py 는 읽음.""" + +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(ROOT)) + +from uvicorn.config import Config # noqa: E402 +from uvicorn.supervisors.watchfilesreload import FileFilter # noqa: E402 + +from config.config_dev_server import RELOAD_SKIP, reload_dirs, reload_excludes # noqa: E402 + + +def _filter() -> FileFilter: + cfg = Config( + "main:app", reload=True, reload_dirs=reload_dirs(), reload_excludes=reload_excludes() + ) + return FileFilter(cfg) + + +def test_제외_목록은_절대_경로_실존_폴더(): + ex = reload_excludes() + assert ex and all(Path(e).is_absolute() and Path(e).is_dir() for e in ex) + names = {Path(e).name for e in ex} + assert {"resources", "tmp", "venv", "docs", ".claude"} <= names + assert names - {n for n in names if n.startswith(".")} <= RELOAD_SKIP + + +def test_감시_폴더는_제외_폴더와_안_겹치고_모듈_폴더_15개(): + dirs = reload_dirs() + assert len(dirs) == 15 + skip = {Path(e) for e in reload_excludes()} + assert not skip & {Path(d) for d in dirs} + + +def test_필터_시험_tmp는_안_읽고_모듈_main_config는_읽음(): + f = _filter() + for rel in ("resources/tester/x.py", "tmp/x.py", "docs/x.py", ".claude/x.py", "venv/Lib/x.py"): + assert not f(ROOT / rel), rel + for rel in ("B05_Profile/x.py", "B07_DesignDetail/x.py", "main.py", "config/config_db.py"): + assert f(ROOT / rel), rel