fix(506-1 · 506-4 개발 서버): 다시 읽기 범위 절대 경로 제외 · main.py 개발 전용 덩이를 config_dev_server 로 나눔

- 시험 · 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>
This commit is contained in:
2026-10-21 12:36:34 +09:00
co-authored by Claude Sonnet 5.5
parent 5e5f9fc437
commit 5fe3003d85
3 changed files with 361 additions and 284 deletions
+301
View File
@@ -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() 방식으로 갈아 끼움")
+18 -284
View File
@@ -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,
)
@@ -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