perf(계측): 재확정 체인 병목 추적용 단계 마크 추가

PLAN 0-10. 8000 에서 1회 실측 결과 재확정 체인 292.9s 중 배수유역 분석이 285.0s(97%)로
나와, 그 안쪽을 가르기 위한 마크만 추가. 로직 불변.

- B06 확정: 조회 / 기본설계 계산 / 확정 저장 / 서버 재계산 / 측구 역반영
- 서버 재계산 내부: 상세+DB 병렬 조회 / 포장·세월교 보정 / Node 번들 / 정본 저장
- 배수유역 분석 내부: _prepare(도엽 읽기·좌표변환) / preview_stages / 응답 만들기·저장
- preview_stages: 1차 영역 / 격자 확장·흐름 판정 / 흐름 강도 / 외곽선·기본 관·화살표

`_log_steps` 는 체인 모듈 것을 그대로 씀(지연 임포트로 순환 회피).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-07 03:20:39 +09:00
co-authored by Claude Opus 5
parent 41852da3b3
commit 4da04929c4
4 changed files with 36 additions and 0 deletions
@@ -166,8 +166,12 @@ def preview_stages(
"""
if len(vertices) < 2:
return None
from B03_FileInput.B03_FileInput_Service_Chain import _log_steps
marks = [("시작", time.perf_counter())]
route_line = LineString([(vertex.x, vertex.y) for vertex in vertices])
region = resolve_primary_region(vertices, route_line, contour_features, stream_features)
marks.append(("1차 영역(resolve_primary_region)", time.perf_counter()))
if region is None:
return None
@@ -185,6 +189,7 @@ def preview_stages(
logger.warning("배수유역: 등고선 하강 방향을 세우지 못해 흐름 판정을 건너뜁니다.")
return StagePreview(region=region)
marks.append(("격자 확장·흐름 판정(expand_by_red_boundary)", time.perf_counter()))
analysis = expansion.analysis
spec = analysis.spec
red = analysis.flow.reaches_road & analysis.flow.analyzed
@@ -194,6 +199,7 @@ def preview_stages(
# 최종적으로 어느 도로 셀로 들어가는지를 봐야 하므로 도로만 흡수점으로 두고 다시 따라간다.
routing = trace_flow(analysis.terrain, analysis.road) if analysis.road.count else None
strength_curve = _preview_strength(analysis, routing, red, route_line.length)
marks.append(("흐름 강도(trace_flow)", time.perf_counter()))
# ⑦ 2차 전체 배수유역 외곽선 = 적색 셀 전체의 외곽.
boundary = outer_boundary(spec, red.reshape(spec.n_rows, spec.n_cols))
@@ -209,6 +215,8 @@ def preview_stages(
# B05에 얹을 평균 흐름 화살표 — 셀 화살표는 도면 배율에서 안 보인다.
flow_arrows = build_flow_arrows(analysis, analysis.flow)
marks.append(("외곽선·기본 관·화살표", time.perf_counter()))
_log_steps("배수유역 preview_stages", marks)
logger.info(
"배수유역: 단계 분석 %.1fs (확장 %d회, 최종 셀 %d개) — "
@@ -11,6 +11,7 @@ import asyncio
import json
import logging
import math
import time
from pathlib import Path
from typing import Any
from uuid import UUID
@@ -389,9 +390,13 @@ async def get_primary_region(
logger.info("배수유역: 저장된 분석 결과를 그대로 돌려줍니다 (%s).", stored_path)
return {**saved, "from_cache": True}
from B03_FileInput.B03_FileInput_Service_Chain import _log_steps
marks = [("시작", time.perf_counter())]
prepared = await _prepare(project_id)
if isinstance(prepared, JSONResponse):
return prepared
marks.append(("_prepare(도엽 읽기·좌표변환)", time.perf_counter()))
preview = await asyncio.to_thread(
preview_stages,
prepared["vertices"],
@@ -403,6 +408,7 @@ async def get_primary_region(
status_code=400,
content={"status": "error", "message": "1차 배수유역을 정할 등고선이 없습니다."},
)
marks.append(("preview_stages(유역 산정)", time.perf_counter()))
region = preview.region
to_lonlat = prepared["to_lonlat"]
# 확장을 거치면 격자·해석 영역이 1차 영역보다 커진다. 최종본을 써야 화면과 어긋나지 않는다.
@@ -516,5 +522,7 @@ async def get_primary_region(
_write_stage_arrays(prepared["stored_path"], preview, domain, spec)
_write_road_routing(prepared["stored_path"], preview, spec, prepared["route_line"], to_lonlat)
# 응답 자체를 캐시로 남긴다 — 다음 조회는 배열을 재조립하지 않고 이 파일을 그대로 준다.
marks.append(("응답 만들기·저장", time.perf_counter()))
_log_steps("배수유역 분석 내부", marks)
_save_response(prepared["stored_path"], payload)
return payload
+11
View File
@@ -11,6 +11,7 @@
import asyncio
import json
import logging
import time
from pathlib import Path
from typing import Any
from uuid import UUID
@@ -20,6 +21,7 @@ from fastapi import APIRouter, Body
from fastapi.responses import JSONResponse
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B03_FileInput.B03_FileInput_Service_Chain import _log_steps
from B05_Profile.B05_Profile_Repository import confirm_route as confirm_route_status
from B05_Profile.B05_Profile_Router_Confirm import _merge_uphill_overrides_into_longitudinal
from B06_Section.B06_Section_Repository import (
@@ -212,6 +214,7 @@ async def save_sections(
통째로 버려져 조작값이 사라진다. 그래서 여기서 정본 행을 만든다.
"""
pool = get_db_pool()
marks = [("시작", time.perf_counter())]
try:
async with pool.acquire() as connection:
existing = await get_longitudinal_section(connection, project_id, route_id)
@@ -240,6 +243,8 @@ async def save_sections(
request.standard_cross_section if request else None,
)
marks.append(("기본설계 계산(미지정 측점)", time.perf_counter()))
async with pool.acquire() as connection:
await connection.begin()
try:
@@ -294,6 +299,7 @@ async def confirm_sections(
missing = await get_cross_sections_missing_design_chainages(connection, route_id)
known = await get_cross_section_chainages(connection, route_id)
marks.append(("조회(종단·경로·미지정 측점)", time.perf_counter()))
project_root = Path(resolve_stored_project_path(stored_path))
# 행 자체가 없는 측점(구조물 등 비정규)도 확정 대상에 넣는다 — 정본이 없으면
# 조회 때마다 프리뷰가 다시 계산돼 3D·수량이 확정 결과가 아니게 된다(2026-08-24).
@@ -336,8 +342,11 @@ async def confirm_sections(
await connection.rollback()
raise
marks.append(("확정 저장(트랜잭션)", time.perf_counter()))
# 편집이 들어간 **뒤** 서버가 정본을 다시 낸다(임시저장과 같은 자리).
await _recompute_stored_designs(project_id, route_id)
marks.append(("서버 재계산", time.perf_counter()))
# 측구 방향(design.ditch_side) 변경을 B05 종단 정본 stations.uphill_side에 역반영한다(E-7).
# 파일 기반·비치명적: 실패해도 확정은 유지한다.
@@ -363,6 +372,8 @@ async def confirm_sections(
project_id,
route_id,
)
marks.append(("측구 방향 B05 역반영", time.perf_counter()))
_log_steps("B06 확정", marks)
return SectionConfirmResponse(project_id=str(project_id), route_id=route_id)
except Exception:
logger.exception("B06 종횡단 확정 실패: project_id=%s", project_id)
@@ -25,11 +25,13 @@ from __future__ import annotations
import asyncio
import json
import logging
import time
from pathlib import Path
from typing import Any
from uuid import UUID
from B03_FileInput.B03_FileInput_Repository import get_project_storage_relative_path
from B03_FileInput.B03_FileInput_Service_Chain import _log_steps
from B06_Section.B06_Section_Repository import (
get_longitudinal_section,
merge_longitudinal_section_data,
@@ -99,6 +101,7 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
from B06_Section.B06_Section_Router import get_section_detail
project_uuid = UUID(str(project_id))
marks = [("시작", time.perf_counter())]
# 상세 만들기(파일 읽기 위주)와 DB 두 건은 서로 기다릴 이유가 없다 — 같이 보낸다.
# 원격 DB 라 순차로 내면 왕복이 그대로 더해진다(질의 하나 약 12ms, 2026-09-06 실측).
pool = get_db_pool()
@@ -107,6 +110,7 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
run_with_connection(get_project_storage_relative_path, project_uuid),
run_with_connection(get_longitudinal_section, project_uuid, route_id),
)
marks.append(("종횡단 상세+DB 조회(병렬)", time.perf_counter()))
payload = getattr(response, "model_dump", None)
if payload is None: # JSONResponse = 실패
logger.warning("서버 재계산: 종횡단 상세를 못 받음 (route_id=%s)", route_id)
@@ -128,12 +132,15 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
if json.dumps(item.get("design"), sort_keys=True, default=str) != before[index]
]
marks.append(("포장·세월교 보정", time.perf_counter()))
output = await asyncio.to_thread(
run_bundle_json,
BUNDLE,
_NPM_SCRIPT,
{"detail": detail, "context": _mass_haul_context()},
)
marks.append(("Node 번들(면적·유토곡선)", time.perf_counter()))
if not isinstance(output, dict):
output = {}
rows = output.get("areas")
@@ -182,6 +189,8 @@ async def _recompute(project_id: UUID | str, route_id: int) -> int:
except Exception:
await connection.rollback()
raise
marks.append(("정본 저장", time.perf_counter()))
_log_steps("서버 재계산 내부", marks)
logger.info(
"서버 재계산: route_id=%s 설계 보정 %s곳, 구조물 면적 %s곳, 유토곡선 %s",
route_id,