perf(B04): 응답 압축 + 좌표 자릿수 정리로 내려보낼 양 축소

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) <noreply@anthropic.com>
This commit is contained in:
2026-09-06 21:13:05 +09:00
co-authored by Claude Opus 5
parent c97e949792
commit da2130c988
4 changed files with 124 additions and 2 deletions
+18 -1
View File
@@ -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",
+41 -1
View File
@@ -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
+34
View File
@@ -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
+31
View File
@@ -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)
# ─────────────────────────────────────────────────────────────────────────
# 정적 파일 서빙 (프론트엔드)
# ─────────────────────────────────────────────────────────────────────────