perf(B06): 측점 설계 저장을 한 문장으로 묶어 원격 DB 왕복 제거

측점마다 SELECT+UPDATE 두 왕복을 냈고 DB 가 원격이라 왕복 하나가 약 12ms.
22행이면 670ms 이고 측점 수에 선형으로 늘었음(보조 창 서버 내부 측정).

- B06_Section_Repository_Bulk.merge_cross_section_designs 신설 — 노선 측점을 한 번에
  읽고, 파이썬에서 chainage 를 맞춰 JSON 을 합친 뒤
  `UPDATE ... SET data = CASE id ...` 한 문장으로 되돌려 씀(없는 행은 다중 INSERT).
  행 수와 무관하게 왕복 두 번. Repository 가 685줄이라 파일을 나눔(700줄 한계).
- 부르는 자리 셋을 묶음으로 바꿈 — _apply_section_edits 의 기본설계·측점 patch 두 루프,
  _recompute 의 보정·면적 두 루프.

자체검증(공용 브라우저 [저장] 3회) — sections/save 3,593ms -> 2,205 / 1,870 / 2,547ms.
버튼 전체 대기 4,137ms -> 2,624~3,415ms. 진행 표시는 2~3ms 만에 뜸.
시험 tmp/tests/test_b06_bulk_designs.py 5건(같은 JSON 결과·왕복 두 번·1cm 허용오차·
행 없을 때 INSERT·빈 목록은 왕복 0). 전체 400 통과·17 건너뜀.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-06 22:00:28 +09:00
co-authored by Claude Opus 5
parent 44ff1dea4d
commit d650d705d1
3 changed files with 154 additions and 32 deletions
+124
View File
@@ -0,0 +1,124 @@
"""측점 설계를 **여러 행 한 번에** 쓰는 자리.
왜 (2026-09-06 실측) — [저장]이 측점마다 `update_cross_section_design` ·
`merge_cross_section_design_patch` 를 불렀고, 그 하나가 `SELECT` + `UPDATE` 두 왕복이다.
DB 가 원격(`dsm.chemifactory.com`)이라 왕복 하나가 **약 12ms** 다. 22행이면 왕복 44번,
곧 **670ms**. 측점이 많은 프로젝트일수록 선형으로 늘어난다.
여기서는 세 문장으로 끝낸다 —
① 노선의 측점 행을 **한 번에** 읽고
② 파이썬에서 chainage 를 맞춰 JSON 을 합치고
③ `UPDATE … SET data = CASE id …` 한 문장으로 되돌려 쓴다(없는 행은 다중 INSERT).
`B06_Section_Repository` 가 685줄이라 700줄 한계에 걸려 파일을 나눴다. 한 행짜리 함수는
그쪽에 그대로 두고, 여러 행을 쓸 때만 이쪽을 쓴다.
"""
from __future__ import annotations
import json
from typing import Any
from uuid import UUID
import aiomysql
# 측점을 같은 자리로 볼 허용 오차(m) — 한 행짜리 함수와 같은 값을 쓴다.
_CHAINAGE_TOLERANCE_M = 0.01
async def merge_cross_section_designs(
connection: aiomysql.Connection,
*,
route_id: int,
entries: list[tuple[float, dict[str, Any]]],
replace: bool,
project_id: UUID | None = None,
) -> int:
"""측점 여러 곳의 `data.design` 을 한 번에 쓴다. 실제로 바뀐 행 수를 돌려준다.
`replace=True` 면 design 을 통째로 갈아 끼우고(`update_cross_section_design` 과 같은 뜻),
`False` 면 키만 얹는다(`merge_cross_section_design_patch` 와 같은 뜻).
행이 없는 측점은 `project_id` 가 오면 새로 만든다 — 구조물(비정규) 측점은 B05 확정이
파일만 쓰고 DB 행을 안 만들기 때문이다(한 행짜리 함수와 같은 규칙).
"""
if not entries:
return 0
async with connection.cursor() as cursor:
await cursor.execute(
"SELECT id, chainage_m, data FROM cross_sections WHERE route_id = %s ORDER BY id",
(route_id,),
)
rows = await cursor.fetchall()
existing: dict[int, dict[str, Any]] = {}
for row_id, _chainage, raw in rows:
data = json.loads(raw) if isinstance(raw, str) else raw
existing[int(row_id)] = data if isinstance(data, dict) else {}
# 같은 측점이 여러 행이면 뒤에 온 것(=큰 id)을 쓴다 — 한 행짜리 함수의 `ORDER BY id DESC`.
ordered = [(float(chainage), int(row_id)) for row_id, chainage, _ in rows]
def find(chainage_m: float) -> int | None:
best: int | None = None
for value, row_id in ordered:
if abs(value - chainage_m) < _CHAINAGE_TOLERANCE_M and (best is None or row_id > best):
best = row_id
return best
updates: list[tuple[int, str]] = []
inserts: list[tuple[str, int, float, str]] = []
for chainage_m, payload in entries:
if not payload and not replace:
continue
row_id = find(chainage_m)
if row_id is None:
if project_id is None:
continue
inserts.append(
(
str(project_id),
route_id,
chainage_m,
json.dumps({"design": payload}, ensure_ascii=False),
)
)
continue
data = dict(existing[row_id])
if replace:
data["design"] = payload
else:
design = data.get("design")
design = dict(design) if isinstance(design, dict) else {}
design.update(payload)
data["design"] = design
updates.append((row_id, json.dumps(data, ensure_ascii=False)))
written = 0
async with connection.cursor() as cursor:
if updates:
# 한 문장 — `CASE id WHEN … THEN …` 이라 왕복이 한 번이다.
cases = " ".join("WHEN %s THEN %s" for _ in updates)
params: list[Any] = []
for row_id, blob in updates:
params.extend((row_id, blob))
params.extend(row_id for row_id, _ in updates)
placeholders = ", ".join("%s" for _ in updates)
await cursor.execute(
f"UPDATE cross_sections SET data = CASE id {cases} END "
f"WHERE id IN ({placeholders})",
params,
)
written += len(updates)
if inserts:
values = ", ".join("(%s, %s, %s, %s, 'DRAFT')" for _ in inserts)
flat: list[Any] = []
for item in inserts:
flat.extend(item)
await cursor.execute(
"INSERT INTO cross_sections (project_id, route_id, chainage_m, data, status) "
f"VALUES {values}",
flat,
)
written += len(inserts)
return written
+15 -13
View File
@@ -28,11 +28,10 @@ from B06_Section.B06_Section_Repository import (
get_cross_section_designs,
get_cross_sections_missing_design_chainages,
get_longitudinal_section,
merge_cross_section_design_patch,
merge_longitudinal_section_data,
merge_longitudinal_section_options,
update_cross_section_design,
)
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
# 원본은 `B06_Section_Router_Design` 이다 — `B06_Section_Router` 를 거쳐 들여오던 것을
# 곧바로 잇는다(2026-09-06). 그 재수출이 없어지면서 서버가 뜨지 못했다.
@@ -95,14 +94,14 @@ async def _apply_section_edits(
project_id: UUID | None = None,
) -> None:
"""임시 저장과 확정이 **함께 쓰는** 저장 본체. 트랜잭션은 호출한 쪽이 연다."""
for chainage_m, design in default_designs:
await update_cross_section_design(
connection,
route_id=route_id,
chainage_m=chainage_m,
design=design,
project_id=project_id,
)
# 행마다 쓰면 원격 DB 왕복이 행 수만큼 난다(측정: 22행 670ms) — 한 문장으로 묶는다.
await merge_cross_section_designs(
connection,
route_id=route_id,
entries=list(default_designs),
replace=True,
project_id=project_id,
)
if request and request.standard_cross_section:
await merge_longitudinal_section_options(
connection,
@@ -116,6 +115,7 @@ async def _apply_section_edits(
)
# 프론트 세션 보관값(암 경계선 오프셋 등)을 측점별 design에 병합.
if request and request.cross_patches:
patches: list[tuple[float, dict[str, Any]]] = []
for patch_item in request.cross_patches:
patch: dict[str, Any] = {}
if patch_item.rock_boundary_offset_m is not None:
@@ -163,9 +163,11 @@ async def _apply_section_edits(
if value is not None:
patch[area_key] = value
if patch:
await merge_cross_section_design_patch(
connection, route_id=route_id, chainage_m=patch_item.chainage_m, patch=patch
)
patches.append((patch_item.chainage_m, patch))
# 측점 patch 도 한 문장으로 — 전 측점을 보내는 저장에서 왕복이 측점 수만큼 났다.
await merge_cross_section_designs(
connection, route_id=route_id, entries=patches, replace=False
)
async def _recompute_stored_designs(project_id: UUID, route_id: int) -> None:
+15 -19
View File
@@ -32,10 +32,9 @@ from uuid import UUID
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B06_Section.B06_Section_Repository import (
get_longitudinal_section,
merge_cross_section_design_patch,
merge_longitudinal_section_data,
update_cross_section_design,
)
from B06_Section.B06_Section_Repository_Bulk import merge_cross_section_designs
from common_util.common_util_node_bundle import run_bundle_json
from common_util.common_util_storage import resolve_stored_project_path
from config.config_db import get_db_pool
@@ -153,29 +152,26 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
await connection.begin()
try:
for item in fixed:
await update_cross_section_design(
connection,
route_id=route_id,
chainage_m=float(item.get("chainage_m") or 0.0),
design=item["design"],
project_id=project_uuid,
)
# 행마다 쓰면 원격 DB 왕복이 행 수만큼 난다(측정: 22행 670ms) — 한 문장으로 묶는다.
await merge_cross_section_designs(
connection,
route_id=route_id,
entries=[(float(item.get("chainage_m") or 0.0), item["design"]) for item in fixed],
replace=True,
project_id=project_uuid,
)
area_entries: list[tuple[float, dict[str, Any]]] = []
for row in rows if isinstance(rows, list) else []:
patch: dict[str, Any] = {
key: float(row[key])
for key in _AREA_KEYS
if isinstance(row.get(key), (int, float))
}
if not patch:
continue
if await merge_cross_section_design_patch(
connection,
route_id=route_id,
chainage_m=float(row["chainage_m"]),
patch=patch,
):
updated += 1
if patch:
area_entries.append((float(row["chainage_m"]), patch))
updated = await merge_cross_section_designs(
connection, route_id=route_id, entries=area_entries, replace=False
)
if isinstance(mass_haul, dict):
await merge_longitudinal_section_data(
connection, route_id=route_id, data_patch={"mass_haul": mass_haul}