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:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
# 정적 파일 서빙 (프론트엔드)
|
||||
# ─────────────────────────────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user