From 0b30f5b03599bbf9ca48dcd8ce3b32290754a348 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 6 Sep 2026 22:22:08 +0900 Subject: [PATCH] =?UTF-8?q?perf(B05,B06):=20=EC=A7=80=ED=98=95=20=ED=91=9C?= =?UTF-8?q?=EA=B3=A0=EB=A5=BC=20=EB=A0=88=EC=9D=B4=EC=BA=90=EC=8A=A4?= =?UTF-8?q?=ED=8C=85=20=EB=8C=80=EC=8B=A0=20=EA=B2=A9=EC=9E=90=20=EC=83=89?= =?UTF-8?q?=EC=9D=B8=EC=9C=BC=EB=A1=9C=20=C2=B7=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=EB=B2=84=ED=8A=BC=EC=A4=84=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=EC=8A=A4=EB=9E=98=EC=8B=B1=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit B05 진입이 간헐적으로 13~14초 멎던 원인을 CDP 프로파일로 잡음. 13,537ms 중 getVertexPosition 3,267 + intersectTriangle 3,178 + checkGeometryIntersection 3,012 + _computeIntersections 2,198 = 11.6초가 three.js 레이캐스팅이었음. - terrainElevation 이 점마다 Raycaster 로 지형 높이를 찾고 있었음(한 번 쏠 때마다 삼각형 전수 훑기). 마커·측점선이 점마다 부르는 자리라 곱해짐. 이미 있던 TerrainHeightIndex(격자 색인)로 돌리고, 색인도 지형과 한 벌로 보관(빌드 O(삼각형)). 같은 교훈을 2026-08-23 비탈 투영에서 겪어 색인을 만들어 뒀는데 이 함수만 옛 길이었음. - B06 카드 버튼줄 reflow 의 레이아웃 스래싱 제거 — 한 칸 옮길 때마다 scrollWidth 를 다시 읽어 매번 강제 레이아웃을 냈음(카드 67장마다). 읽기/쓰기를 갈라 폭을 한 번만 재고 옮길 개수를 계산. ResizeObserver 첫 호출도 건너뜀(rAF 와 겹쳐 두 번 돌았음). - get_section_context·get_section_detail 의 순차 읽기를 asyncio.gather 로 묶음 (원격 DB 왕복 약 12ms/질의). 자체검증(공용 브라우저 4왕복) — B05 진입 14,708/1,060/14,841 -> 1,269/806/726/746ms. 급등 사라짐. B06 진입 2,884 -> 2,330~2,864ms(긴 작업 합 2,313 -> 1,853). 시험 400 통과·17 건너뜀, typecheck 통과. Co-Authored-By: Claude Opus 5 (1M context) --- B05_Profile/B05_Profile_UI_Viewer.ts | 29 +++++++++--- B06_Section/B06_Section_Router.py | 44 ++++++++++-------- B06_Section/B06_Section_UI_Cross_Design.ts | 52 +++++++++++++++++----- 3 files changed, 90 insertions(+), 35 deletions(-) diff --git a/B05_Profile/B05_Profile_UI_Viewer.ts b/B05_Profile/B05_Profile_UI_Viewer.ts index 90797feb..65375e32 100644 --- a/B05_Profile/B05_Profile_UI_Viewer.ts +++ b/B05_Profile/B05_Profile_UI_Viewer.ts @@ -48,10 +48,12 @@ const DARK_VIEWER_BACKGROUND = 0x251f38; * 한 벌만 쥔다 — 다른 모델을 부르면 옛것을 버린다(GPU 버퍼가 쌓이지 않게). * 장면에서 뗄 때도 이 객체는 `disposeObject` 하지 않는다. */ -const cachedTerrain: { key: string | null; object: THREE.Object3D | null } = { - key: null, - object: null, -}; +const cachedTerrain: { + key: string | null; + object: THREE.Object3D | null; + /** 높이 격자 색인 — 만드는 값이 O(삼각형)이라 지형과 한 벌로 쥔다. */ + heightIndex: TerrainHeightIndex | null; +} = { key: null, object: null, heightIndex: null }; declare global { interface Window { @@ -231,6 +233,17 @@ export function createRouteViewer(): RouteViewer { function terrainElevation(x: number, y: number): number | null { if (!terrain || !bounds) return null; const origin = modelToScene({ x, y, z: bounds.z[1] + 100 }, bounds); + // 격자 색인으로 찾는다 — Raycaster 는 한 번 쏠 때마다 삼각형을 전부 훑는다. + // 마커·측점선이 점마다 부르는 자리라, B05 진입에서 **13.5초 중 11.6초**가 여기였다 + // (2026-09-06 CPU 프로파일: getVertexPosition·intersectTriangle·checkGeometryIntersection). + // 같은 교훈을 2026-08-23 비탈 투영에서 이미 한 번 겪어 색인을 만들어 뒀다. + const index = ensureHeightIndex(); + if (index) { + const height = index.heightAt(origin.x, origin.z); + return height === null + ? null + : sceneToModel(new THREE.Vector3(origin.x, height, origin.z), bounds).z; + } const raycaster = new THREE.Raycaster(origin, new THREE.Vector3(0, -1, 0)); const hit = raycaster.intersectObject(terrain, true)[0]; return hit ? sceneToModel(hit.point, bounds).z : null; @@ -502,7 +515,12 @@ export function createRouteViewer(): RouteViewer { function ensureHeightIndex(): TerrainHeightIndex | null { if (heightIndex) return heightIndex; if (!terrain) return null; - heightIndex = new TerrainHeightIndex(terrain); + // 보관해 둔 지형이면 색인도 같이 쓴다 — 화면을 드나들 때마다 다시 만들지 않는다. + heightIndex = + cachedTerrain.object === terrain && cachedTerrain.heightIndex + ? cachedTerrain.heightIndex + : new TerrainHeightIndex(terrain); + if (cachedTerrain.object === terrain) cachedTerrain.heightIndex = heightIndex; return heightIndex.triangleCount > 0 ? heightIndex : null; } @@ -679,6 +697,7 @@ export function createRouteViewer(): RouteViewer { } cachedTerrain.key = key; cachedTerrain.object = terrain; + cachedTerrain.heightIndex = null; // 새 지형이면 색인도 새로. } scene.add(terrain); // 흑백 토글이 켜진 채 모델을 다시 불러와도 상태를 유지한다. diff --git a/B06_Section/B06_Section_Router.py b/B06_Section/B06_Section_Router.py index a35cecb4..59a40fde 100644 --- a/B06_Section/B06_Section_Router.py +++ b/B06_Section/B06_Section_Router.py @@ -74,7 +74,7 @@ from common_util.common_util_auth import verify_session from common_util.common_util_storage import resolve_stored_project_path from common_util.common_util_surface_confirmation import get_surface_confirmation_params from common_util.common_util_workflow_state import get_workflow_state -from config.config_db import get_db_pool +from config.config_db import get_db_pool, run_with_connection from config.config_system import ( EARTHWORK_CONVERSION_FACTORS, EARTHWORK_HAUL_EQUIPMENT_LIMITS_M, @@ -93,20 +93,24 @@ router = APIRouter(prefix="/api/projects", tags=["B06 Profile Cross"]) @router.get("/{project_id}/sections/context", response_model=SectionContextResponse) async def get_section_context(project_id: UUID) -> SectionContextResponse | JSONResponse: """최신 확정 경로와 B04 확정값, config 기반 생성 기본값을 반환한다.""" - pool = get_db_pool() try: - async with pool.acquire() as connection: - # 화면이 보는 경로 = 최신 경로(확정 여부 무관) — B05와 같은 규칙이어야 - # 두 화면이 같은 노선의 같은 값을 본다(2026-09-03 일원화). - route_context = await get_workflow_route_context(connection, project_id) - surface_params = await get_surface_confirmation_params(connection, str(project_id)) - # 임도 종류(projects.road_type) — B05가 계획선 법정 기준을 정하는 데 쓴다. + # 임도 종류(projects.road_type) — B05가 계획선 법정 기준을 정하는 데 쓴다. + async def _road_type(connection: aiomysql.Connection) -> str | None: async with connection.cursor() as cursor: await cursor.execute( "SELECT road_type FROM projects WHERE id = %s", (str(project_id),) ) row = await cursor.fetchone() - road_type = row[0] if row else None + return row[0] if row else None + + # 화면이 보는 경로 = 최신 경로(확정 여부 무관) — B05와 같은 규칙이어야 + # 두 화면이 같은 노선의 같은 값을 본다(2026-09-03 일원화). + # 셋은 서로 기다릴 이유가 없다 — 원격 DB 왕복(약 12ms)이 더해지지 않게 같이 보낸다. + route_context, surface_params, road_type = await asyncio.gather( + run_with_connection(get_workflow_route_context, project_id), + run_with_connection(get_surface_confirmation_params, str(project_id)), + run_with_connection(_road_type), + ) defaults = SectionGenerationOptions() return SectionContextResponse( @@ -285,17 +289,19 @@ async def get_section_detail( project_id: UUID, route_id: int ) -> SectionDetailResponse | JSONResponse: """경로의 SVG 렌더링용 종단·횡단 원시 샘플을 반환한다.""" - pool = get_db_pool() try: - async with pool.acquire() as connection: - longitudinal = await get_longitudinal_section(connection, project_id, route_id) - if not longitudinal: - return JSONResponse( - status_code=404, - content={"status": "error", "message": "종횡단 상세 결과가 없습니다."}, - ) - stored_path = await get_project_storage_relative_path(connection, project_id) - designs = await get_cross_section_designs(connection, route_id) + # 서로 기다릴 이유가 없는 읽기 셋 — 원격 DB 라 순차로 내면 왕복이 그대로 더해진다 + # (질의 하나 약 12ms, 2026-09-06 실측). 같이 보내 가장 느린 하나의 시간만 쓴다. + longitudinal, stored_path, designs = await asyncio.gather( + run_with_connection(get_longitudinal_section, project_id, route_id), + run_with_connection(get_project_storage_relative_path, project_id), + run_with_connection(get_cross_section_designs, route_id), + ) + if not longitudinal: + return JSONResponse( + status_code=404, + content={"status": "error", "message": "종횡단 상세 결과가 없습니다."}, + ) project_root = Path(resolve_stored_project_path(stored_path)) detail = await asyncio.to_thread( _read_section_detail, diff --git a/B06_Section/B06_Section_UI_Cross_Design.ts b/B06_Section/B06_Section_UI_Cross_Design.ts index a04a81bd..a7945ca0 100644 --- a/B06_Section/B06_Section_UI_Cross_Design.ts +++ b/B06_Section/B06_Section_UI_Cross_Design.ts @@ -31,6 +31,10 @@ export { const SVG_NS = "http://www.w3.org/2000/svg"; +/** 카드 버튼줄의 flex 간격(px) — 모든 카드가 같은 CSS 를 쓰므로 한 번만 잰다. + * `getComputedStyle` 도 강제 레이아웃을 부르므로 카드 67장마다 부르지 않는다. */ +let barGapPx: number | null = null; + function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } @@ -413,21 +417,47 @@ export function buildDesignControls( bar.append(slopeDirSeg, ...moveable, more); const reflow = (): void => { - // 후보 전부 인라인 복귀 → more 숨김 → 넘치면 뒤에서부터 패널로 이동. + // 후보 전부 인라인 복귀 → 폭을 **한 번만 재고** → 넘치는 만큼 뒤에서부터 패널로 이동. + // + // 예전에는 한 칸 옮길 때마다 `bar.scrollWidth` 를 다시 읽어(쓰기→읽기→쓰기) 브라우저가 + // 매번 레이아웃을 강제로 다시 계산했다. 카드 67장마다 도는 자리라 B06 진입에서 일한 + // 시간의 19.4% 를 이 함수가 썼다(2026-09-06 CPU 프로파일). 읽기와 쓰기를 갈랐다. for (const element of moveable) bar.insertBefore(element, more); morePanel.replaceChildren(); - more.hidden = true; - if (bar.clientWidth <= 0) return; - for ( - let index = moveable.length - 1; - index >= 0 && bar.scrollWidth > bar.clientWidth + 1; - index -= 1 - ) { - more.hidden = false; - morePanel.insertBefore(moveable[index], morePanel.firstChild); + more.hidden = false; // 폭을 재려면 자리에 있어야 한다. + + const clientWidth = bar.clientWidth; + if (clientWidth <= 0) { + more.hidden = true; + return; + } + if (barGapPx === null) barGapPx = Number.parseFloat(getComputedStyle(bar).gap) || 0; + const widths = moveable.map((element) => element.offsetWidth); + let overflow = bar.scrollWidth - clientWidth; + + let moveCount = 0; + while (overflow > 1 && moveCount < moveable.length) { + overflow -= widths[moveable.length - 1 - moveCount] + barGapPx; + moveCount += 1; + } + if (moveCount === 0) { + more.hidden = true; + return; + } + for (let index = 0; index < moveCount; index += 1) { + morePanel.insertBefore(moveable[moveable.length - 1 - index], morePanel.firstChild); } }; - const overflowObserver = new ResizeObserver(() => reflow()); + // 첫 호출은 아래 `requestAnimationFrame` 이 맡는다 — 관찰을 걸면 초기 크기로 곧바로 한 번 + // 더 불려 카드마다 reflow 가 두 번 돌았다. + let firstObservation = true; + const overflowObserver = new ResizeObserver(() => { + if (firstObservation) { + firstObservation = false; + return; + } + reflow(); + }); overflowObserver.observe(bar); requestAnimationFrame(reflow);