From da2130c988fba3edfa5a9cc5656caa258a672b50 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 6 Sep 2026 21:13:05 +0900 Subject: [PATCH] =?UTF-8?q?perf(B04):=20=EC=9D=91=EB=8B=B5=20=EC=95=95?= =?UTF-8?q?=EC=B6=95=20+=20=EC=A2=8C=ED=91=9C=20=EC=9E=90=EB=A6=BF?= =?UTF-8?q?=EC=88=98=20=EC=A0=95=EB=A6=AC=EB=A1=9C=20=EB=82=B4=EB=A0=A4?= =?UTF-8?q?=EB=B3=B4=EB=82=BC=20=EC=96=91=20=EC=B6=95=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DB·파일에는 뜻 없는 자릿수가 붙어 있고(1.4000000000000001 꼴) 서버는 응답을 전혀 압축하지 않고 있었음. B05 한 번 진입에 118MB 수신(2026-09-06 실측). - main.py: 압축 미들웨어(1KB 이상). 3D 예상형상(/corridor)은 예외 — 숫자 배열이라 절반만 줄면서 압축에 493ms 듦(18.6->9.4MB). - common_util_json: round_floats(value, digits) + LONLAT_DIGITS 7(1.1cm) · METRE_DIGITS 6. 저장 파일은 그대로 두고 내려보낼 때만 줄임. 브라우저가 다시 계산에 넣는 값(종횡단 지반선)은 줄이면 안 된다고 머리에 경고 적음. - 도엽 GeoJSON: 자릿수만 줄인 표시용 사본(.display.geojson)을 한 번 만들어 서빙. 원본이 새로 깔리면 자동 재생성, 실패하면 원본 그대로. 등고선 도엽 65.9->36.8MB, 압축까지 11.3MB. - 배수유역 응답: 위경도 7자리. 151->100KB, 압축 22KB. ruff 통과, 테스트 390 통과·17 건너뜀. Co-Authored-By: Claude Opus 5 (1M context) --- .../B04_PreProcess_Router_Basins.py | 19 ++++++++- B04_PreProcess/B04_PreProcess_Router_GIS.py | 42 ++++++++++++++++++- common_util/common_util_json.py | 34 +++++++++++++++ main.py | 31 ++++++++++++++ 4 files changed, 124 insertions(+), 2 deletions(-) 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) + # ───────────────────────────────────────────────────────────────────────── # 정적 파일 서빙 (프론트엔드) # ─────────────────────────────────────────────────────────────────────────