- 시험 · tmp · docs · venv · .claude 의 .py 로 서버가 다시 읽던 것 차단(모듈 폴더 15 개만) - main.py 807 → 541 - test_506_1_reload_scope 신설 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
302 lines
14 KiB
Python
302 lines
14 KiB
Python
"""
|
|
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() 방식으로 갈아 끼움")
|