diff --git a/B04_PreProcess/B04_PreProcess_Router_Basins.py b/B04_PreProcess/B04_PreProcess_Router_Basins.py index 86edeea1..453e3de1 100644 --- a/B04_PreProcess/B04_PreProcess_Router_Basins.py +++ b/B04_PreProcess/B04_PreProcess_Router_Basins.py @@ -37,6 +37,7 @@ from common_util.common_util_drainage_pipes import ( save_detail_basins, save_pipe_points, ) +from common_util.common_util_json import LONLAT_DIGITS, round_floats from common_util.common_util_route_geometry import StructureCandidate from config.config_system import DRAINAGE_PIPE_MAX_SPACING_M, DRAINAGE_PIPE_MIN_SPACING_M @@ -122,7 +123,23 @@ def _payload( points: list[PipePoint], saved: bool, ) -> dict[str, Any]: - """관 목록과 세부유역을 화면 좌표(WGS84)로 정리한다.""" + """관 목록과 세부유역을 화면 좌표(WGS84)로 정리한다. + + 좌표는 위경도 7자리(약 1.1cm)로 맞춰 내보낸다 — 저장된 값에는 뜻 없는 자리가 붙어 있어 + 응답이 151KB → 100KB(압축까지 얹으면 22KB)가 된다(2026-09-06 실측). 화면이 그리는 값이고 + 다시 계산에 넣지 않으므로 줄여도 결과가 안 갈린다. + """ + return round_floats(_raw_payload(project_id, context, detail, points, saved), LONLAT_DIGITS) + + +def _raw_payload( + project_id: UUID, + context: DrainageContext, + detail: DrainageDetail, + points: list[PipePoint], + saved: bool, +) -> dict[str, Any]: + """자릿수를 줄이기 전의 응답 본문.""" to_lonlat = context.to_lonlat return { "status": "success", diff --git a/B04_PreProcess/B04_PreProcess_Router_GIS.py b/B04_PreProcess/B04_PreProcess_Router_GIS.py index 4dd54146..652b70d0 100644 --- a/B04_PreProcess/B04_PreProcess_Router_GIS.py +++ b/B04_PreProcess/B04_PreProcess_Router_GIS.py @@ -10,6 +10,7 @@ from fastapi.responses import JSONResponse from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path from common_util.common_util_http_cache import cached_file_response +from common_util.common_util_json import LONLAT_DIGITS, round_floats from common_util.common_util_storage import resolve_stored_project_path from config.config_db import get_db_pool @@ -150,7 +151,13 @@ async def get_project_geojson( if layer in _SHEET_GEOJSON_FILES: # 도엽 산출물은 자르거나 줄이지 않고 파일 그대로 보낸다(2026-08-01 사용자 지시). # 재직렬화만 건너뛰어도 요청당 2초가 사라지고, ETag로 두 번째부터는 304가 된다. - return cached_file_response(request, filepath, "application/geo+json") + # 다만 **자릿수만 줄인 사본**을 한 번 만들어 그것을 보낸다(2026-09-06) — 형상은 + # 그대로고 뜻 없는 자리만 사라진다. + return cached_file_response( + request, + await asyncio.to_thread(_display_copy, filepath), + "application/geo+json", + ) if layer == "등고선": simplified_filepath = target_dir / "등고선_bounds_simplified.geojson" @@ -349,3 +356,36 @@ async def build_sheet_surface(project_id: UUID, request: Request) -> dict[str, A await connection.rollback() raise return {"status": "success", "method": method, "surface_model_ids": model_ids} + + +def _display_copy(source: Path) -> Path: + """도엽 GeoJSON 의 **표시용 사본**(좌표 자릿수만 줄인 것) 경로를 돌려준다. + + 저장된 파일에는 `1.4000000000000001` 꼴로 뜻 없는 자리가 붙어 있다 — 위경도 7자리 + (1.1cm)로 맞추면 형상은 그대로면서 등고선 도엽이 65.9MB → 36.8MB 가 된다(2026-09-06 실측). + 압축까지 얹으면 11.3MB. + + 사본은 **한 번만** 만든다(66MB 기준 약 3초). 원본이 새로 깔리면 수정시각이 앞서므로 + 저절로 다시 만든다. 만들다 실패하면 원본을 그대로 보낸다 — 화면을 막지 않는다. + """ + target = source.with_suffix(".display.geojson") + try: + if target.exists() and target.stat().st_mtime >= source.stat().st_mtime: + return target + payload = json.loads(source.read_text(encoding="utf-8")) + target.write_text( + json.dumps( + round_floats(payload, LONLAT_DIGITS), ensure_ascii=False, separators=(",", ":") + ), + encoding="utf-8", + ) + logger.info( + "도엽 표시용 사본 생성: %s (%.1fMB → %.1fMB)", + source.name, + source.stat().st_size / 1e6, + target.stat().st_size / 1e6, + ) + return target + except Exception as exc: # 사본 실패가 화면을 막으면 안 된다. + logger.warning("도엽 표시용 사본을 만들지 못해 원본을 보냅니다 (%s): %s", source.name, exc) + return source diff --git a/common_util/common_util_json.py b/common_util/common_util_json.py index 912e59dc..23033eed 100644 --- a/common_util/common_util_json.py +++ b/common_util/common_util_json.py @@ -54,3 +54,37 @@ def atomic_write_json(path: str | Path, value: Any) -> None: finally: if temporary_path is not None: temporary_path.unlink(missing_ok=True) + + +# ───────────────────────────────────────────────────────────────────────── +# 내려보낼 때 자릿수 줄이기 (2026-09-06 사용자 지시) +# ───────────────────────────────────────────────────────────────────────── +# 저장된 값에는 뜻 없는 자릿수가 잔뜩 붙어 있다 — `1.4000000000000001` 꼴로 적혀 있어 +# 자릿수만 정리해도 등고선 도엽이 65.9MB → 36.8MB 로 줄었다(실측). 압축까지 얹으면 11.3MB. +# +# **저장 파일은 그대로 두고 내려보낼 때만** 줄인다 — 납품 수량·도면이 쓰는 정본은 손대지 않는다. +# +# 자릿수의 뜻이 파일마다 다르다: +# · 위경도(도 단위) 7자리 = 1.1cm — 등고선 도엽·배수유역 +# · 미터 6자리 = 0.001mm — 종횡단 상세 +# ⚠ **브라우저가 받아 다시 계산에 넣는 값은 줄이면 안 된다.** 종횡단 상세의 지반선이 그런 +# 자리다 — 반올림한 값으로 브라우저가 계산하면 파이썬 짝과 면적이 갈린다(거울 테스트 대상). +# 6자리는 무시할 만하지만 3자리(1mm)는 안 된다. +LONLAT_DIGITS = 7 +METRE_DIGITS = 6 + + +def round_floats(value: Any, digits: int) -> Any: + """중첩된 목록·사전 안의 실수를 모두 `digits` 자리로 반올림한 **새 값**을 돌려준다. + + 원본은 건드리지 않는다. 정수·문자열·None 은 그대로 지난다. + """ + if isinstance(value, float): + return round(value, digits) + if isinstance(value, list): + return [round_floats(item, digits) for item in value] + if isinstance(value, tuple): + return tuple(round_floats(item, digits) for item in value) + if isinstance(value, dict): + return {key: round_floats(item, digits) for key, item in value.items()} + return value diff --git a/main.py b/main.py index 0d6673c4..1ed147bc 100644 --- a/main.py +++ b/main.py @@ -17,9 +17,11 @@ import signal import subprocess from contextlib import asynccontextmanager 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 @@ -329,6 +331,35 @@ app.add_middleware( allow_headers=["*"], ) +# ───────────────────────────────────────────────────────────────────────── +# 응답 압축 (2026-09-06 실측으로 도입) +# ───────────────────────────────────────────────────────────────────────── +# 화면 한 번 여는 데 수십 MB 가 오간다 — 종횡단 상세 1.56MB → 0.34MB(압축 13ms), +# 배수유역 151KB → 37KB, 지형 도엽 GeoJSON 은 훨씬 크다. 사무실 랜에서는 티가 덜 나지만 +# 현장에서 인터넷으로 열면 이 차이가 곧 대기 시간이다. +# +# 딱 하나 예외 — 3D 예상형상(코리도)은 숫자 배열이라 절반밖에 안 줄면서 압축에만 493ms 가 +# 든다(18.6MB → 9.4MB). 그 자리는 「덜 보내기」가 아니라 「안 보내기」로 푼다. +_COMPRESS_MIN_BYTES = 1024 + + +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) + # ───────────────────────────────────────────────────────────────────────── # 정적 파일 서빙 (프론트엔드) # ─────────────────────────────────────────────────────────────────────────