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>
91 lines
4.2 KiB
Python
91 lines
4.2 KiB
Python
"""JSON 파일을 안전하게 저장하는 공통 유틸리티."""
|
|
|
|
import json
|
|
import os
|
|
import tempfile
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
# 윈도우에서 `os.replace`는 대상 파일을 **누가 열고만 있어도** 거부당한다(WinError 5).
|
|
# 진행률 파일처럼 서버가 쓰는 동안 화면이 계속 읽는 파일에서 실제로 부딪혔고, 전처리
|
|
# 산출물(structured.npz) 교체에서는 분석이 통째로 죽기도 했다(2026-08-08).
|
|
# 상대가 파일을 놓는 데 걸리는 시간은 밀리초 수준이라 짧게 여러 번 다시 시도한다.
|
|
_REPLACE_RETRY_COUNT = 12
|
|
_REPLACE_RETRY_DELAY_SECONDS = 0.05
|
|
|
|
|
|
def replace_with_retry(source: str | Path, target: str | Path) -> None:
|
|
"""`os.replace` — 다른 쪽이 잠깐 잡고 있으면 잠시 기다렸다 다시 시도한다."""
|
|
last_error: OSError | None = None
|
|
for attempt in range(_REPLACE_RETRY_COUNT):
|
|
try:
|
|
os.replace(source, target)
|
|
return
|
|
except PermissionError as error: # 윈도우 공유 위반
|
|
last_error = error
|
|
time.sleep(_REPLACE_RETRY_DELAY_SECONDS * (attempt + 1))
|
|
raise last_error if last_error else OSError("파일 교체에 실패했습니다.")
|
|
|
|
|
|
def atomic_write_json(path: str | Path, value: Any) -> None:
|
|
"""같은 디렉터리의 임시 파일을 교체하여 JSON을 원자적으로 저장한다."""
|
|
target = Path(path)
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
temporary_path: Path | None = None
|
|
|
|
try:
|
|
with tempfile.NamedTemporaryFile(
|
|
mode="w",
|
|
encoding="utf-8",
|
|
dir=target.parent,
|
|
prefix=f".{target.name}.",
|
|
suffix=".tmp",
|
|
delete=False,
|
|
) as temporary:
|
|
json.dump(value, temporary, ensure_ascii=False, indent=2)
|
|
temporary.write("\n")
|
|
temporary.flush()
|
|
os.fsync(temporary.fileno())
|
|
temporary_path = Path(temporary.name)
|
|
|
|
replace_with_retry(temporary_path, target)
|
|
temporary_path = 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
|