""" main.py FastAPI 애플리케이션 진입점 - 공통 설정 로드 (config_system, config_db) - 프론트엔드 자동 빌드 및 서빙 (npm run build) - 정적 파일 서빙 (프론트엔드 빌드 결과) - DB 풀 초기화/종료 - 라우터 등록 (페이지별 API) """ import asyncio import locale import logging import os import signal import subprocess import time from contextlib import asynccontextmanager from datetime import datetime from pathlib import Path from typing import Any from fastapi import Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.gzip import GZipMiddleware from fastapi.staticfiles import StaticFiles from A06_Login.A06_Login_Router import router as a06_login_router from A07_Register.A07_Register_Router import router as a07_register_router from A08_Support.A08_Support_Router import router as a08_support_router from A09_Security.A09_Security_Router import router as a09_security_router from B01_Dashboard.B01_Dashboard_Router import router as b01_dashboard_router from B02_ProjRegister.B02_ProjRegister_Router import router as b02_proj_register_router from B03_FileInput.B03_FileInput_Router import router as b03_file_input_router from B03_FileInput.B03_FileInput_Router_Temp import ( attach_router as b03_temp_attach_router, ) from B03_FileInput.B03_FileInput_Router_Temp import router as b03_temp_upload_router from B04_PreProcess.B04_PreProcess_Router import router as b04_surface_router from B04_PreProcess.B04_PreProcess_Router_Basins import router as b04_basins_router from B04_PreProcess.B04_PreProcess_Router_Contour import router as b04_surface_contour_router from B04_PreProcess.B04_PreProcess_Router_GIS import router as b04_surface_gis_router from B04_PreProcess.B04_PreProcess_Router_GIS import tiles_router from B04_PreProcess.B04_PreProcess_Router_Inflow import router as b04_inflow_router from B04_PreProcess.B04_PreProcess_Router_Watershed import router as b04_watershed_router from B05_Profile.B05_Profile_Router import router as b05_route_router from B05_Profile.B05_Profile_Router_Corridor import router as b05_corridor_router from B05_Profile.B05_Profile_Router_Lifecycle import router as b05_route_lifecycle_router from B05_Profile.B05_Profile_Router_Replan import router as b05_route_replan_router from B05_Profile.B05_Profile_Router_Terrain import router as b05_route_terrain_router from B05_Profile.B05_Profile_Structures_Router import router as b05_structures_router from B06_Section.B06_Section_Router import router as b06_section_router from B06_Section.B06_Section_Router_Stations import router as b06_section_stations_router from B06_Section.B06_Section_Router_Confirm import ( router as b06_section_confirm_router, ) from B06_Section.B06_Section_Router_HaulPlan import ( router as b06_section_haul_plan_router, ) from B07_DesignDetail.B07_DesignDetail_Router import router as b07_design_router from B07_DesignDetail.B07_DesignDetail_Router_Frame import router as b07_frame_router from B07_DesignDetail.B07_DesignDetail_Router_Standard import router as b07_standard_router from B08_Quantity.B08_Quantity_Router import router as b08_quantity_router from B08_Quantity.B08_Quantity_Router_Earthwork import router as b08_earthwork_router from B08_Quantity.B08_Quantity_Router_Material import router as b08_material_router from B09_Estimation.B09_Estimation_Router import router as b09_estimation_router from common_util.common_util_audit import note_api_call, record_call_burst from common_util.common_util_auth import ( require_company, require_project_access, verify_session, ) # 개발환경 전용 — 「확정 없이 다음으로」. **문은 서버가 정본이다** — `ENVIRONMENT` 가 # 개발이 아니면 세 입구 모두 403 으로 거절한다(화면 단추 숨김은 보조). from common_util.common_util_dev_unlock_router import router as dev_unlock_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 # 설정 import from config.config_system import ( ADMIN_EMAIL, CORS_ORIGINS, DEBUG, ENVIRONMENT, LOG_LEVEL, SERVER_HOST, SERVER_PORT, SESSION_COOKIE_NAME, STATIC_DIR, STATIC_URL, ) # 로깅 설정 logging.basicConfig(level=getattr(logging, LOG_LEVEL)) logger = logging.getLogger(__name__) # ⚠ **리로드가 멈출 때 어디까지 갔는지 알려 주는 표식**(2026-09-12). # uvicorn 리로더는 옛 워커를 내리고 **새 워커 프로세스를 다시 띄운다.** 그 워커는 이 # 파일을 처음부터 다시 읽으므로, 이 줄이 로그에 찍히면 **새 워커가 뜨기는 한 것**이고 # 안 찍히면 리로더가 옛 워커를 못 내려 **새 워커가 아예 시작도 못 한 것**이다. # 둘은 고칠 자리가 전혀 다른데, 이 줄이 없으면 로그만 보고는 못 가린다. 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를 정리한다.""" while True: await asyncio.sleep(3600) pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: await cursor.execute("DELETE FROM sessions WHERE expires_at < CURRENT_TIMESTAMP") await cursor.execute( """DELETE FROM email_otps WHERE expires_at < CURRENT_TIMESTAMP OR consumed_at < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 1 DAY)""" ) await cursor.execute( """DELETE FROM login_logs WHERE (status = 'SUCCESS' AND login_at < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 1 YEAR)) OR (status = 'FAILURE' AND login_at < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 6 MONTH))""" ) await cursor.execute( """DELETE FROM activity_logs WHERE action_at < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 2 YEAR)""" ) await connection.commit() # ───────────────────────────────────────────────────────────────────────── # 라이프사이클: 앱 시작/종료 # ───────────────────────────────────────────────────────────────────────── #: 종료 한 걸음에 줄 시한(초). 짧게 둔다 — 종료는 **기다리는 곳이 아니라 끝내는 곳**이다. _SHUTDOWN_STEP_TIMEOUT_S = 5.0 async def _cancel_tasks(*tasks: asyncio.Task) -> None: """취소를 걸고 **실제로 끝났는지 확인한다.** `cancel()` 은 「끝내 달라」는 요청일 뿐이라 부르고 지나가면 그 태스크가 아직 도는 중일 수 있다. 여기서 한 번 거둬야 종료가 깔끔하다. """ for task in tasks: task.cancel() await asyncio.gather(*tasks, return_exceptions=True) async def _shutdown_step(what: str, coro: Any) -> None: """종료 한 걸음 — **시한을 두고** 기다리고, 못 끝내면 넘어간다. ⚠ **종료는 어떤 경우에도 멈추면 안 된다** (2026-09-12 실측). 여기서 멈추면 uvicorn 리로더가 옛 프로세스를 못 내리고, 새 프로세스는 포트를 못 잡아 서버가 `stale` 인 채 **옛 코드를 계속 내준다** — 옛 화면을 보고 헛검증하게 되는 자리다. 못 끝낸 걸음은 **경고만 남기고 넘어간다.** 어차피 프로세스가 곧 사라지므로 남은 정리는 OS 가 한다. 무엇이 못 끝났는지는 로그에 이름으로 남아 다음에 바로 짚인다. """ try: await asyncio.wait_for(coro, timeout=_SHUTDOWN_STEP_TIMEOUT_S) logger.info("✓ 종료: %s", what) except TimeoutError: logger.warning("⚠ 종료: %s 가 %.0f초 안에 안 끝나 건너뜀", what, _SHUTDOWN_STEP_TIMEOUT_S) except Exception as exc: logger.warning("⚠ 종료: %s 중 예외 — %s", what, exc) @asynccontextmanager async def lifespan(app: FastAPI): """FastAPI 라이프사이클 관리""" # 시작 logger.info(f"[{ENVIRONMENT}] 앱 시작 중...") # 지금 도는 코드가 언제 것인지 못박아 둔다 — `/api/health` 가 `stale` 로 알린다(7-2). global _STARTED_AT, _CODE_MTIME_AT_START _STARTED_AT = time.time() if DEBUG: _CODE_MTIME_AT_START = _newest_source_mtime() logger.info( "[reload] 감시 폴더 %d개, 소스 최신 수정 %s", len(_reload_dirs()), datetime.fromtimestamp(_CODE_MTIME_AT_START).isoformat(timespec="seconds"), ) # ⚠⚠ **리로드마다 프론트를 다시 빌드하지 않는다** (2026-09-12 실측으로 드러난 자리). # uvicorn 리로더는 `*.py` 가 바뀔 때마다 이 lifespan 을 다시 탄다. 그때마다 # `npm run build`(최대 300초, B07 CAD 빌드까지 딸림)가 돌고 Vite 를 죽였다 살리느라 # **리로드가 몇 분씩 걸리거나 아예 멈춘다** — 그날 두 번 멈췄고 한 번은 두 시간 반 동안 # 안 끝나, `/api/health` 가 `stale: true` 인 채로 옛 코드를 계속 내주었다. # ⇒ **옛 화면을 보고 헛검증하게 만드는 자리라 그냥 느린 것이 아니다.** # 개발 중에는 Vite 가 HMR 로 이미 최신을 내주므로 다시 빌드할 까닭이 없다. if ENVIRONMENT == "development" and _dev_port_listening(FRONTEND_DEV_PORT): logger.info( "[Frontend] 개발 서버가 %d 포트에 이미 살아 있음 — 빌드·재기동 건너뜀(리로드)", FRONTEND_DEV_PORT, ) else: # 첫 기동(또는 프로덕션) — 여기서만 빌드한다. build_frontend() if ENVIRONMENT == "development": serve_frontend_dev() # DB 풀 초기화 await init_db_pool() logger.info("✓ DB 풀 초기화 완료") if ADMIN_EMAIL: pool = get_db_pool() async with pool.acquire() as connection, connection.cursor() as cursor: await cursor.execute( "UPDATE users SET role = 'SYSTEM_ADMIN' WHERE email = %s", (ADMIN_EMAIL.lower(),) ) # 시스템 관리 회사(관리자 계정이 속한 회사) 소속은 전원 시스템관리자다 # (2026-09-06 사용자 확정) — 역할을 따로 고를 일이 없다. await cursor.execute( """UPDATE users u JOIN users a ON a.email = %s AND a.deleted_at IS NULL AND a.company_id IS NOT NULL SET u.role = 'SYSTEM_ADMIN' WHERE u.company_id = a.company_id AND u.deleted_at IS NULL""", (ADMIN_EMAIL.lower(),), ) await connection.commit() cleanup_task = asyncio.create_task(cleanup_expired_sessions()) resource_task = asyncio.create_task(sample_resources_loop()) temp_cleanup_task = asyncio.create_task(cleanup_expired_temp_uploads_loop()) yield # 종료 logger.info("앱 종료 중...") await _shutdown_step( "백그라운드 작업 정리", _cancel_tasks(cleanup_task, resource_task, temp_cleanup_task) ) stop_frontend_dev() await _shutdown_step("DB 풀 종료", close_db_pool()) logger.info("앱 종료 끝") # ───────────────────────────────────────────────────────────────────────── # FastAPI 앱 생성 # ───────────────────────────────────────────────────────────────────────── app = FastAPI( title="임도 설계 및 견적 자동화", description="Forest Road Design & Estimation API", version="0.1.0", debug=DEBUG, lifespan=lifespan, ) # ───────────────────────────────────────────────────────────────────────── # CORS 미들웨어 # ───────────────────────────────────────────────────────────────────────── app.add_middleware( CORSMiddleware, allow_origins=CORS_ORIGINS, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) # ───────────────────────────────────────────────────────────────────────── # 응답 압축 (2026-09-06 실측으로 도입) # ───────────────────────────────────────────────────────────────────────── # 화면 한 번 여는 데 수십 MB 가 오간다 — 종횡단 상세 1.56MB → 0.34MB(압축 13ms), # 배수유역 151KB → 37KB, 지형 도엽 GeoJSON 은 훨씬 크다. 사무실 랜에서는 티가 덜 나지만 # 현장에서 인터넷으로 열면 이 차이가 곧 대기 시간이다. # # 딱 하나 예외 — 3D 예상형상(코리도)은 숫자 배열이라 절반밖에 안 줄면서 압축에만 493ms 가 # 든다(18.6MB → 9.4MB). 그 자리는 「덜 보내기」가 아니라 「안 보내기」로 푼다. # # 압축 강도는 **1**(가장 약하게). 기본값 9 로 두었더니 등고선 도엽 36.8MB 를 누르느라 # 요청 하나가 9.4초 걸렸다(2026-09-06 실측). 1 로 낮춰도 줄어드는 양은 거의 같고 # (66MB 기준 level 1 은 24.0MB, level 6 은 23.9MB) 시간만 1/3 이 된다. _COMPRESS_MIN_BYTES = 1024 _COMPRESS_LEVEL = 1 def _skips_compression(path: str) -> bool: """압축에서 뺄 경로인가 — 지금은 코리도 하나뿐.""" return path.endswith("/corridor") class _SelectiveGZipMiddleware(GZipMiddleware): """경로 몇 개만 빼고 압축한다. 나머지 동작은 표준 미들웨어 그대로.""" async def __call__(self, scope: Any, receive: Any, send: Any) -> None: if scope["type"] == "http" and _skips_compression(scope.get("path", "")): await self.app(scope, receive, send) return await super().__call__(scope, receive, send) app.add_middleware( _SelectiveGZipMiddleware, minimum_size=_COMPRESS_MIN_BYTES, compresslevel=_COMPRESS_LEVEL, ) # ───────────────────────────────────────────────────────────────────────── # 호출량 감시 (2026-09-06 사용자 지시 — 보안) # ───────────────────────────────────────────────────────────────────────── # 계산 결과는 화면에 나가도 된다는 방침이라, 남는 위험은 입력을 바꿔가며 출력을 긁어 모으는 # 것이다. **막지는 않고** 상한을 넘은 세션만 시스템 로그에 한 줄 남긴다 — 자세한 이유는 # `common_util_audit.note_api_call` 머리 참조. 세는 값은 메모리에 있어 요청당 비용이 없다. @app.middleware("http") async def watch_call_volume(request, call_next): # type: ignore[no-untyped-def] if request.url.path.startswith("/api/"): session_id = request.cookies.get(SESSION_COOKIE_NAME) if note_api_call(session_id) and session_id: asyncio.create_task(record_call_burst(session_id, request)) return await call_next(request) # ───────────────────────────────────────────────────────────────────────── # 정적 파일 서빙 (프론트엔드) # ───────────────────────────────────────────────────────────────────────── app.mount( STATIC_URL, StaticFiles(directory=STATIC_DIR, check_dir=False), name="static", ) logger.info(f"✓ 정적 파일 서빙 경로 등록: {STATIC_URL} → {STATIC_DIR}") # B07 독립형 2D CAD 앱 — 내부 JSON 연동용 iframe B07_CAD_DIST_DIR = str(Path(__file__).parent / "B07_DesignDetail" / "openwebcad" / "dist") class _NoCacheHtmlStatic(StaticFiles): """`index.html` 만 캐시하지 않는다 (2026-09-06). 캐드를 새로 빌드해도 브라우저가 옛 `index.html` 을 들고 있어 **옛 화면이 그대로** 남았다(파일 이름에 해시가 붙는 자바스크립트는 새 이름이라 문제가 없다). """ def file_response(self, *args, **kwargs): # type: ignore[override] response = super().file_response(*args, **kwargs) if str(getattr(response, "path", "")).endswith(".html"): response.headers["Cache-Control"] = "no-store" return response app.mount( "/b07-cad", _NoCacheHtmlStatic(directory=B07_CAD_DIST_DIR, html=True, check_dir=False), name="b07-cad", ) logger.info(f"✓ B07 CAD 정적 서빙 경로 등록: /b07-cad → {B07_CAD_DIST_DIR}") # ───────────────────────────────────────────────────────────────────────── # 기본 엔드포인트 # ───────────────────────────────────────────────────────────────────────── @app.get("/") async def root(): """루트 엔드포인트 — 프론트엔드 진입점 리다이렉트""" return { "message": "임도 설계 및 견적 자동화 API", "version": "0.1.0", "frontend": f"http://{SERVER_HOST}:{SERVER_PORT}{STATIC_URL}/", } #: 이 프로세스가 뜬 시각과, 뜰 때 본 **가장 최근 소스 수정 시각**. 기동 때 채운다 #: (`_reload_dirs` 가 아래에 정의돼 있어 모듈 로드 시점에는 못 부른다). #: 자동 리로드가 조용히 죽는 일이 잦아(오늘만 세 번, 매번 틀린 결론을 냄) 「지금 도는 서버가 #: 옛 코드인가」를 한 번에 가릴 수 있게 남긴다(2026-09-07, PLAN 7-2). _STARTED_AT = 0.0 _CODE_MTIME_AT_START = 0.0 def _newest_source_mtime() -> float: """감시 대상 폴더의 `.py` 중 가장 최근 수정 시각. 못 읽으면 0.""" newest = 0.0 for folder in _reload_dirs(): for path in Path(folder).rglob("*.py"): try: newest = max(newest, path.stat().st_mtime) except OSError: continue return newest @app.get("/api/health") async def health(): """헬스 체크. 개발 모드에서는 **코드가 최신인지**도 함께 알린다 — `stale: true` 면 소스가 더 새로우니 (자동 리로드가 안 붙은 것) 재시작 전에는 어떤 측정도 믿으면 안 된다. """ body: dict[str, Any] = {"status": "ok", "environment": ENVIRONMENT} if DEBUG: newest = _newest_source_mtime() body["started_at"] = datetime.fromtimestamp(_STARTED_AT).isoformat(timespec="seconds") body["code_mtime"] = datetime.fromtimestamp(_CODE_MTIME_AT_START).isoformat( timespec="seconds" ) body["source_mtime"] = datetime.fromtimestamp(newest).isoformat(timespec="seconds") # 1초 여유 — 저장 직후의 미세한 시각차로 헛경고가 뜨지 않게. body["stale"] = newest > _CODE_MTIME_AT_START + 1.0 return body # ───────────────────────────────────────────────────────────────────────── # 라우터 등록 (pages → API) # ───────────────────────────────────────────────────────────────────────── # 예: from A01_Home.A01_Home_Api_Fetch import router as a01_router # app.include_router(a01_router, prefix="/api/a01", tags=["A01_Home"]) # # 나중에 각 페이지별 라우터가 구현되면 여기에 등록. app.include_router(a06_login_router) app.include_router(a07_register_router) app.include_router(a08_support_router) app.include_router(a09_security_router) app.include_router(b01_dashboard_router) app.include_router(b02_proj_register_router) protected = [Depends(verify_session)] # 프로젝트 경로가 붙는 라우터는 그 프로젝트가 내 회사 것인지도 함께 본다 # (2026-09-01 — 지금까지 id만 알면 남의 회사 자료에 닿았다). protected_with_company = [ Depends(verify_session), Depends(require_company), Depends(require_project_access), ] app.include_router(b03_file_input_router, dependencies=protected_with_company) app.include_router(b03_temp_upload_router, dependencies=protected) app.include_router(b03_temp_attach_router, dependencies=protected_with_company) app.include_router(b04_surface_router, dependencies=protected_with_company) app.include_router(b04_surface_contour_router, dependencies=protected_with_company) app.include_router(b04_surface_gis_router, dependencies=protected_with_company) app.include_router(b04_watershed_router, dependencies=protected_with_company) app.include_router(b04_inflow_router, dependencies=protected_with_company) app.include_router(b04_basins_router, dependencies=protected_with_company) app.include_router(tiles_router, dependencies=protected_with_company) app.include_router(b05_route_router, dependencies=protected_with_company) app.include_router(b05_route_lifecycle_router, dependencies=protected_with_company) app.include_router(b05_corridor_router, dependencies=protected_with_company) app.include_router(b05_route_replan_router, dependencies=protected_with_company) app.include_router(b05_route_terrain_router, dependencies=protected_with_company) app.include_router(b05_structures_router, dependencies=protected_with_company) app.include_router(b06_section_router, dependencies=protected_with_company) app.include_router(b06_section_stations_router, dependencies=protected_with_company) app.include_router(b06_section_confirm_router, dependencies=protected_with_company) app.include_router(b06_section_haul_plan_router, dependencies=protected_with_company) app.include_router(b07_design_router, dependencies=protected_with_company) app.include_router(b07_frame_router, dependencies=protected_with_company) app.include_router(b07_standard_router, dependencies=protected_with_company) app.include_router(b08_quantity_router, dependencies=protected_with_company) app.include_router(b08_earthwork_router, dependencies=protected_with_company) app.include_router(b08_material_router, dependencies=protected_with_company) app.include_router(b09_estimation_router, dependencies=protected_with_company) # 개발 전용 잠금 해제 — 다른 라우터와 **같은 보호**를 받는다(로그인·회사·프로젝트 접근). # 그 위에 서버가 환경까지 한 번 더 본다. app.include_router(dev_unlock_router, dependencies=protected_with_company) # ───────────────────────────────────────────────────────────────────────── # 앱 실행 # ───────────────────────────────────────────────────────────────────────── 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", "graphify-out", "0_old", "db_management", "migrations", "ui_template", } if __name__ == "__main__": import uvicorn logger.info(f"Starting server on {SERVER_HOST}:{SERVER_PORT}") uvicorn.run( "main:app", host=SERVER_HOST, port=SERVER_PORT, reload=DEBUG, # 폴더를 좁히지 않으면 리로더가 storage(27GB)까지 0.25초마다 훑는다 — 위 주석 참조. reload_dirs=_reload_dirs() if DEBUG else None, access_log=False, )