perf(B05,B06): 지형 표고를 레이캐스팅 대신 격자 색인으로 · 카드 버튼줄 레이아웃 스래싱 제거

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) <noreply@anthropic.com>
This commit is contained in:
2026-09-06 22:22:08 +09:00
co-authored by Claude Opus 5
parent 2476af0f4a
commit 0b30f5b035
3 changed files with 90 additions and 35 deletions
+25 -19
View File
@@ -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,