fix(B05): 분석 요청 타임아웃 분리 + 확장 회차마다 방향장 재계산 제거

"signal is aborted without reason" 오류. 프론트 공통 타임아웃이 30초인데
등고선 하강 + 확장 루프가 33초 걸려 계산 도중 abort 됐다.

- config_frontend 에 API_ANALYSIS_TIMEOUT_MS(60초) 추가. 공통 30초는 그대로 두고
  분석 엔드포인트(primary-region, basins)에만 적용한다. 일반 요청까지 늘리면
  장애 시 화면이 오래 멈춘다.
- requestJson 에 timeoutMs 인자 추가. AbortError 원문은 원인을 알 수 없으므로
  "요청이 N초 안에 끝나지 않았습니다"로 바꿔 던진다.

같이 속도도 줄였다 (33.3s -> 26.2s, 결과 동일)
- 하강 방향장은 해석 영역과 무관하다 — 등고선 기하만으로 정해진다. 확장
  회차마다 다시 만들 이유가 없어, 격자가 커졌을 때만 새로 만들고 아니면
  재사용한다. 해석 영역은 마지막에 마스크로만 씌운다.
- 1차 영역 bbox 는 영역에 딱 붙어 있어 첫 회차부터 격자를 넓혀야 했다.
  시작할 때 사방에 확장폭 2배 여유를 둬 몇 회차는 격자를 안 넓히고 돈다.

검증: 적색 457,404 로 이전과 동일. 확장 3회 닫힘, 방향장 계산 4회 -> 2회.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-31 19:47:13 +09:00
co-authored by Claude Fable 5
parent 44eb4ad2af
commit 2dcafcd286
3 changed files with 75 additions and 19 deletions
+29 -11
View File
@@ -11,7 +11,7 @@
* - 오류 응답 형식 {status:"error", message:"..."}을 Error로 변환.
* ========================================================================== */
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
import { API_ANALYSIS_TIMEOUT_MS, API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
/** 경로 제어점 (BP/EP/CP) */
export interface RoutePoint {
@@ -141,10 +141,17 @@ export interface RouteLatestResponse {
} | null;
}
/** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환. */
async function requestJson<T>(path: string, init: RequestInit): Promise<T> {
/** 공통 fetch 헬퍼: 타임아웃 + 인증 헤더 + 오류 응답 변환.
*
* `timeoutMs`를 주면 그 값으로 끊는다. 격자 해석처럼 오래 걸리는 요청은
* `API_ANALYSIS_TIMEOUT_MS`를 넘긴다 — 기본값으로 두면 계산 도중 abort 된다. */
async function requestJson<T>(
path: string,
init: RequestInit,
timeoutMs: number = API_TIMEOUT_MS,
): Promise<T> {
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), API_TIMEOUT_MS);
const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs);
try {
const response = await fetch(`${API_BASE_URL}${path}`, {
...init,
@@ -160,6 +167,12 @@ async function requestJson<T>(path: string, init: RequestInit): Promise<T> {
throw new Error(payload.message ?? `HTTP ${response.status}`);
}
return payload;
} catch (error) {
// AbortError 원문("signal is aborted without reason")은 원인을 알 수 없으니 바꿔 준다.
if (error instanceof DOMException && error.name === "AbortError") {
throw new Error(`요청이 ${Math.round(timeoutMs / 1000)}초 안에 끝나지 않았습니다.`);
}
throw error;
} finally {
window.clearTimeout(timeoutId);
}
@@ -365,9 +378,12 @@ export interface DrainagePrimaryRegion {
export async function fetchDrainagePrimaryRegion(
projectId: string,
): Promise<DrainagePrimaryRegion> {
return requestJson<DrainagePrimaryRegion>(`/projects/${projectId}/drainage/primary-region`, {
method: "GET",
});
// 등고선 하강 방향 + 적색 확장 루프까지 도는 요청이라 수십 초가 걸린다.
return requestJson<DrainagePrimaryRegion>(
`/projects/${projectId}/drainage/primary-region`,
{ method: "GET" },
API_ANALYSIS_TIMEOUT_MS,
);
}
export async function fetchDrainageCandidates(
@@ -383,8 +399,10 @@ export async function fetchDrainageBasins(
projectId: string,
chainages?: number[],
): Promise<DrainageBasinResponse> {
return requestJson<DrainageBasinResponse>(`/projects/${projectId}/drainage/basins`, {
method: "POST",
body: JSON.stringify({ chainages: chainages ?? [] }),
});
// 격자 해석이 포함된 요청이라 캐시가 없으면 수십 초가 걸린다.
return requestJson<DrainageBasinResponse>(
`/projects/${projectId}/drainage/basins`,
{ method: "POST", body: JSON.stringify({ chainages: chainages ?? [] }) },
API_ANALYSIS_TIMEOUT_MS,
);
}
@@ -72,15 +72,23 @@ def analyze_domain(
route_line: LineString,
upstream_streams: list[LineString],
elevation_floor_m: float | None = None,
descent: ContourDescent | None = None,
) -> GridAnalysis | None:
"""주어진 격자·해석 영역에 대해 등고선 하강 방향과 도로 도달 색을 한 번 계산한다."""
descent = build_contour_descent(spec, contour_features, domain, elevation_floor_m)
if not descent.valid.any():
"""주어진 격자·해석 영역에 대해 등고선 하강 방향과 도로 도달 색을 한 번 계산한다.
**하강 방향장은 해석 영역과 무관하다** — 등고선 기하만으로 정해진다. 그래서 확장
회차마다 다시 계산하지 않고, 격자가 커졌을 때만 새로 만들어 넘겨받는다(`descent`).
해석 영역은 마지막에 마스크로만 씌운다.
"""
if descent is None or descent.spec != spec:
descent = build_contour_descent(spec, contour_features, None, elevation_floor_m)
valid = descent.valid & domain
if not valid.any():
return None
terrain = TerrainGrid(
spec=spec,
elevation=descent.band_elevation,
valid=descent.valid,
elevation=np.where(valid, descent.band_elevation, np.nan).astype(np.float32),
valid=valid,
receiver=descent.receiver,
step_length=descent.step_length,
)
@@ -103,14 +111,17 @@ def expand_by_red_boundary(
max_rounds: int = DRAINAGE_RED_EXPAND_MAX_ROUNDS,
) -> RedExpansion | None:
"""최외곽 적색 셀 주변으로 넓히며, 새로 추가한 셀에 적색이 없을 때까지 반복한다."""
band_cells = max(1, int(round(band_m / spec.cell_m)))
# 1차 영역의 bbox는 영역에 딱 붙어 있어 첫 회차부터 격자를 넓혀야 한다. 미리 여유를
# 두면 방향장을 다시 만들지 않고 해석 영역만 넓히며 몇 회차를 돌 수 있다.
spec, domain = _pad_spec(spec, domain, band_cells * 2)
started_cells = int(domain.sum())
analysis = analyze_domain(
spec, domain, contour_features, route_line, upstream_streams, elevation_floor_m
)
if analysis is None:
return None
band_cells = max(1, int(round(band_m / spec.cell_m)))
started_cells = int(domain.sum())
rounds = 0
closed = False
for attempt in range(max_rounds):
@@ -138,7 +149,14 @@ def expand_by_red_boundary(
int(added_mask.sum()),
)
widened_analysis = analyze_domain(
grown_spec, widened, contour_features, route_line, upstream_streams, elevation_floor_m
grown_spec,
widened,
contour_features,
route_line,
upstream_streams,
elevation_floor_m,
# 격자가 그대로면 방향장을 재사용한다 — 등고선 기하가 안 바뀌었으므로 결과는 같다.
descent=analysis.descent if grown_spec == current else None,
)
if widened_analysis is None:
break
@@ -182,6 +200,22 @@ def _dilate_by(mask: np.ndarray, steps: int) -> np.ndarray:
return result
def _pad_spec(spec: GridSpec, domain: np.ndarray, cells: int) -> tuple[GridSpec, np.ndarray]:
"""격자에 사방 여유를 두고 해석 영역 마스크를 그 안으로 옮겨 담는다."""
if cells <= 0:
return spec, domain
padded_spec = GridSpec(
x_min=spec.x_min - cells * spec.cell_m,
y_max=spec.y_max + cells * spec.cell_m,
cell_m=spec.cell_m,
n_rows=spec.n_rows + 2 * cells,
n_cols=spec.n_cols + 2 * cells,
)
padded = np.zeros((padded_spec.n_rows, padded_spec.n_cols), dtype=bool)
padded[cells : cells + spec.n_rows, cells : cells + spec.n_cols] = domain
return padded_spec, padded
def _grow_for_rim(
spec: GridSpec, domain: np.ndarray, rim: np.ndarray, band_cells: int
) -> tuple[GridSpec, np.ndarray, np.ndarray]:
+4
View File
@@ -16,6 +16,10 @@ export const API_BASE_URL = "/api";
/** API 요청 타임아웃 (ms) */
export const API_TIMEOUT_MS = 30_000;
/** 격자 해석처럼 수십 초가 걸리는 분석 요청용 타임아웃 (ms).
* 일반 요청에 이 값을 쓰면 장애 시 화면이 오래 멈추므로 분석 엔드포인트에만 쓴다. */
export const API_ANALYSIS_TIMEOUT_MS = 60_000;
/** B03~B09 워크플로우에서 사용할 현재 프로젝트 UUID 저장 키 */
export const CURRENT_PROJECT_ID_KEY = "frd_current_project_id";