From 44ff1dea4dd871128a389f538eb14babe0d1eeae Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 6 Sep 2026 21:52:13 +0900 Subject: [PATCH 1/2] =?UTF-8?q?perf(B05):=20=ED=99=94=EB=A9=B4=EC=97=90=20?= =?UTF-8?q?=EB=93=A4=EC=96=B4=EC=98=AC=20=EB=95=8C=EB=A7=88=EB=8B=A4=20?= =?UTF-8?q?=EC=A7=80=ED=91=9C=EB=A9=B4=EC=9D=84=20=EB=8B=A4=EC=8B=9C=20?= =?UTF-8?q?=ED=8C=8C=EC=8B=B1=ED=95=98=EB=8D=98=20=EA=B2=83=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B05 는 해시가 바뀔 때마다 renderB05Route 로 통째로 다시 조립되어 뷰어 상태가 비워짐. 그래서 8MB 지표면을 진입할 때마다 다시 읽고 다시 파싱했고, 그것이 진입을 잡는 단일 동기 블록 14.4초였음(공용 브라우저 3왕복 실측: 14,708 / 1,060 / 14,841ms). - 파싱해 둔 지형을 모듈 단위 cachedTerrain 에 한 벌 보관 — 같은 모델이면 새 장면에 그대로 붙임. 다른 모델을 부르면 옛것을 버려 GPU 버퍼가 안 쌓임. - 장면에서 뗄 때 보관본은 disposeObject 하지 않음. - window.__surfaceTiming 디버그 훅 추가 — 단계별 시간(fetch·parse·fit·contours). 자체검증(공용 브라우저 3왕복) — B05 진입 683 / 474 / 639ms (전 14,708 / 1,060 / 14,841). surfaceTiming: reuse 0ms · fit+markers 35ms · contours 183ms · total 222ms. B06 진입은 2.4~2.7초로 변화 없음. 시험 395 통과·17 건너뜀, typecheck 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_Viewer.ts | 80 +++++++++++++++++++++++----- 1 file changed, 67 insertions(+), 13 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index 09b5674e..90797feb 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -37,6 +37,29 @@ type ViewKind = "iso" | "top" | "front" | "side"; const LIGHT_VIEWER_BACKGROUND = 0xf5f7fa; const DARK_VIEWER_BACKGROUND = 0x251f38; +/** + * 파싱해 둔 지표면 한 벌 — **뷰어보다 오래 산다**. + * + * B05 는 해시가 바뀔 때마다 `renderB05Route` 로 통째로 다시 조립되므로 뷰어 안의 상태는 + * 매번 비워진다. 그러면 8MB 짜리 지표면을 화면에 들어올 때마다 다시 읽고 다시 파싱하는데, + * 실측에서 그 값이 **단일 동기 블록 14.4초**였다(2026-09-06 공용 브라우저 3왕복). + * 같은 모델이면 이 자리에 둔 것을 새 장면에 그대로 붙인다. + * + * 한 벌만 쥔다 — 다른 모델을 부르면 옛것을 버린다(GPU 버퍼가 쌓이지 않게). + * 장면에서 뗄 때도 이 객체는 `disposeObject` 하지 않는다. + */ +const cachedTerrain: { key: string | null; object: THREE.Object3D | null } = { + key: null, + object: null, +}; + +declare global { + interface Window { + /** 지표면 적재 단계별 시간(ms) — 화면 밖에서 수치로 확인하는 디버그 훅. */ + __surfaceTiming?: Array<{ step: string; ms: number }>; + } +} + /** 서피스 삼각형 수 — 클리핑이 실제로 걷어냈는지 확인하는 계측용. */ function countTriangles(root: THREE.Object3D | null): number { let total = 0; @@ -184,6 +207,9 @@ export function createRouteViewer(): RouteViewer { let terrain: THREE.Object3D | null = null; // 예상형상(코리도) 상태 — 원본/클리핑 지형 두 벌 유지·스왑(2026-08-23). + // + // 지형은 아래 모듈 단위 `cachedTerrain` 이 한 벌 쥐고 있어, B05 를 드나들어도 다시 + // 파싱하지 않는다(2026-09-06 실측: 재진입마다 14.4초짜리 단일 동기 블록이 있었다). let corridorBuild: CorridorBuildResult | null = null; let corridorGroup: THREE.Group | null = null; let clippedTerrain: THREE.Object3D | null = null; @@ -609,38 +635,66 @@ export function createRouteViewer(): RouteViewer { markers, structurePick, async loadSurface(projectId, modelId, method, smooth, interval, nextBounds) { + const step = (label: string, from: number): void => { + timing.push({ step: label, ms: Math.round(performance.now() - from) }); + }; + const timing: Array<{ step: string; ms: number }> = []; + const started = performance.now(); bounds = nextBounds; current = { projectId, modelId, smooth, interval }; if (terrain) { scene.remove(terrain); - disposeObject(terrain); + if (terrain !== cachedTerrain.object) disposeObject(terrain); } heightIndex = null; // 지형이 바뀌면 높이 색인·밴드 분할본도 새로 만든다. bandSplit?.dispose(); bandSplit = null; const url = `${API_BASE_URL}/projects/${projectId}/surface/models/${modelId}/preview?smooth=${smooth}`; - // 브라우저 보관함에 있으면 그대로 쓰고, 없을 때만 내려받는다(새로고침이 빨라진다). - const buffer = await fetchCachedBytes(projectId, url); - terrain = await new Promise((resolve, reject) => { - if (method === "meshfree") { - const geometry = new PLYLoader().parse(buffer); - resolve(new THREE.Points(geometry, new THREE.PointsMaterial({ size: 0.35 }))); - } else { - new GLTFLoader().parse(buffer, "", (gltf) => resolve(gltf.scene), reject); + const key = `${projectId}:${modelId}:${method}:${smooth}`; + if (cachedTerrain.key === key && cachedTerrain.object) { + // 같은 지표면 모델을 이미 파싱해 뒀다 — 다시 읽지도 파싱하지도 않는다(2026-09-06). + terrain = cachedTerrain.object; + step("reuse", started); + } else { + const fetched = performance.now(); + // 브라우저 보관함에 있으면 그대로 쓰고, 없을 때만 내려받는다(새로고침이 빨라진다). + const buffer = await fetchCachedBytes(projectId, url); + step("fetch", fetched); + const parsed = performance.now(); + terrain = await new Promise((resolve, reject) => { + if (method === "meshfree") { + const geometry = new PLYLoader().parse(buffer); + resolve(new THREE.Points(geometry, new THREE.PointsMaterial({ size: 0.35 }))); + } else { + new GLTFLoader().parse(buffer, "", (gltf) => resolve(gltf.scene), reject); + } + }); + terrain.traverse((child) => { + if (child instanceof THREE.Mesh) child.material.side = THREE.DoubleSide; + }); + step("parse", parsed); + // 한 벌만 쥔다 — 다른 모델로 바뀌면 옛것을 버린다. + if (cachedTerrain.object && cachedTerrain.object !== terrain) { + disposeObject(cachedTerrain.object); } - }); - terrain.traverse((child) => { - if (child instanceof THREE.Mesh) child.material.side = THREE.DoubleSide; - }); + cachedTerrain.key = key; + cachedTerrain.object = terrain; + } scene.add(terrain); // 흑백 토글이 켜진 채 모델을 다시 불러와도 상태를 유지한다. applySurfaceGrayscale(); // 지형·bounds가 준비된 시점에 코리도를 다시 조립한다 — 초기 진입은 종횡단 // 로드가 지형보다 먼저 끝나 setCorridor가 그룹 생성을 미뤄뒀을 수 있다. if (corridorBuild) setCorridor(corridorBuild); + const fitted = performance.now(); fit("top"); markers.renderMarkers(); + step("fit+markers", fitted); + const contoured = performance.now(); await reloadContours(interval); + step("contours", contoured); + step("total", started); + window.__surfaceTiming = timing; // 로딩이 끝나면 안내문을 지운다 — 조작법 설명이 화면에 계속 떠 있을 이유가 // 없다(2026-08-19 사용자 지시). 로딩·이동 중 안내는 그대로 쓴다. status.textContent = ""; From d650d705d17f971412d6d49a8079312bce60ca87 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 6 Sep 2026 22:00:28 +0900 Subject: [PATCH 2/2] =?UTF-8?q?perf(B06):=20=EC=B8=A1=EC=A0=90=20=EC=84=A4?= =?UTF-8?q?=EA=B3=84=20=EC=A0=80=EC=9E=A5=EC=9D=84=20=ED=95=9C=20=EB=AC=B8?= =?UTF-8?q?=EC=9E=A5=EC=9C=BC=EB=A1=9C=20=EB=AC=B6=EC=96=B4=20=EC=9B=90?= =?UTF-8?q?=EA=B2=A9=20DB=20=EC=99=95=EB=B3=B5=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 측점마다 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) --- B06_Section/B06_Section_Repository_Bulk.py | 124 ++++++++++++++++++ B06_Section/B06_Section_Router_Confirm.py | 28 ++-- .../B06_Section_Server_Calc_Prebuild.py | 34 +++-- 3 files changed, 154 insertions(+), 32 deletions(-) create mode 100644 B06_Section/B06_Section_Repository_Bulk.py diff --git a/B06_Section/B06_Section_Repository_Bulk.py b/B06_Section/B06_Section_Repository_Bulk.py new file mode 100644 index 00000000..c2a83e27 --- /dev/null +++ b/B06_Section/B06_Section_Repository_Bulk.py @@ -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 diff --git a/B06_Section/B06_Section_Router_Confirm.py b/B06_Section/B06_Section_Router_Confirm.py index c5f8c20f..c88978d8 100644 --- a/B06_Section/B06_Section_Router_Confirm.py +++ b/B06_Section/B06_Section_Router_Confirm.py @@ -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: diff --git a/B06_Section/B06_Section_Server_Calc_Prebuild.py b/B06_Section/B06_Section_Server_Calc_Prebuild.py index 4fd59c1c..306559e3 100644 --- a/B06_Section/B06_Section_Server_Calc_Prebuild.py +++ b/B06_Section/B06_Section_Server_Calc_Prebuild.py @@ -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}