From df2dedcbc4e626134bc376a3ab13f3b32bce67e8 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Sun, 6 Sep 2026 15:28:20 +0900 Subject: [PATCH] =?UTF-8?q?perf:=20=ED=99=94=EB=A9=B4=20=EC=A7=84=EC=9E=85?= =?UTF-8?q?=20=EC=A4=91=EB=B3=B5=20=ED=98=B8=EC=B6=9C=20=EC=A0=95=EB=A6=AC?= =?UTF-8?q?=20=E2=80=94=20=EC=84=B8=EC=85=98=C2=B7=EC=9B=8C=ED=81=AC?= =?UTF-8?q?=ED=94=8C=EB=A1=9C=20=EC=83=81=ED=83=9C=C2=B7=EA=B5=AC=EC=A1=B0?= =?UTF-8?q?=EB=AC=BC=20=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 실측(B05 진입): auth/session 4회, workflow-state 2회, 구조물 이관 POST 가 진입할 때마다 나갔다. - auth/session: 라우터 가드·상단 바·워크플로 확인이 각각 부르던 것을 5초 캐시로 묶음. 로그인·로그아웃에서 즉시 버린다. - workflow-state: 공용 창구 하나로 모으고 같은 방식으로 캐시. 상단 오버레이가 따로 부르던 경로를 그 창구로 돌림. 단계 이동에서 캐시를 버린다. - 구 구조물 측점 이관 POST: 탭 수명 동안 노선마다 한 번만. 실측 결과: B05 첫 진입 20 → 18건(핵심 API 10건), B06→B05 이동 7건. Co-Authored-By: Claude Opus 5 (1M context) --- A00_Common/b_workflow_nav.ts | 40 ++++++++++++++++++++++----- A06_Login/A06_Login_Api_Fetch.ts | 43 +++++++++++++++++++++++------- B05_Profile/B05_Profile_UI_Page.ts | 9 ++++++- ui_template/ui_template_overlay.ts | 6 +++-- 4 files changed, 78 insertions(+), 20 deletions(-) diff --git a/A00_Common/b_workflow_nav.ts b/A00_Common/b_workflow_nav.ts index bbd26970..52a2b613 100644 --- a/A00_Common/b_workflow_nav.ts +++ b/A00_Common/b_workflow_nav.ts @@ -30,15 +30,39 @@ export const WORKFLOW_STEP_ROUTES: readonly RoutePath[] = [ ROUTES.B09_ESTIMATION, ]; +/* 워크플로 상태는 한 화면을 여는 동안 준비 화면 가드와 페이지 본체가 각각 부른다 — + 실측 B05 진입에서 두 번 나갔다(2026-09-06). 짧은 시간 동안 한 번만 부르고 나눠 쓴다. + 단계가 바뀌는 자리(`goToWorkflowStage`)에서는 즉시 버린다. */ +const WORKFLOW_CACHE_MS = 5000; +let workflowCache: { at: number; projectId: string; value: Promise } | null = null; + +/** 워크플로 상태 캐시를 버린다 — 단계가 바뀌는 자리에서 부른다. */ +export function clearWorkflowStateCache(): void { + workflowCache = null; +} + export async function fetchWorkflowState(projectId: string): Promise { - const response = await fetch(`${API_BASE_URL}/projects/${projectId}/workflow-state`, { - credentials: "include", - }); - if (!response.ok) { - throw new Error(`Workflow state request failed: ${response.status}`); + const now = Date.now(); + if ( + workflowCache && + workflowCache.projectId === projectId && + now - workflowCache.at < WORKFLOW_CACHE_MS + ) { + return workflowCache.value; } - const data = await response.json(); - return data.workflow_state ?? data; + const value = (async (): Promise => { + const response = await fetch(`${API_BASE_URL}/projects/${projectId}/workflow-state`, { + credentials: "include", + }); + if (!response.ok) { + throw new Error(`Workflow state request failed: ${response.status}`); + } + const data = await response.json(); + return data.workflow_state ?? data; + })(); + workflowCache = { at: now, projectId, value }; + value.catch(() => clearWorkflowStateCache()); + return value; } /** @@ -52,6 +76,8 @@ const PRELOAD_REQUIRED_ROUTES: readonly RoutePath[] = [ROUTES.B04_PREPROCESS, RO export function goToWorkflowStage(projectId: string, route: RoutePath): void { localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId); + // 단계가 바뀌어 이동하는 자리다 — 캐시를 버려 다음 화면이 새 상태를 본다. + clearWorkflowStateCache(); if (!PRELOAD_REQUIRED_ROUTES.includes(route)) { navigateTo(route); return; diff --git a/A06_Login/A06_Login_Api_Fetch.ts b/A06_Login/A06_Login_Api_Fetch.ts index ac768d3b..c2565d99 100644 --- a/A06_Login/A06_Login_Api_Fetch.ts +++ b/A06_Login/A06_Login_Api_Fetch.ts @@ -32,25 +32,48 @@ export function requestLogin(email: string, password: string): Promise { + clearSessionCache(); return post("/auth/login/verify", { email, otp_code: otpCode }); } +/* 세션 조회는 한 화면을 여는 동안 라우터 가드·상단 바·워크플로 확인이 각각 부른다 — + 실측 B05 진입에서 `GET /auth/session` 이 네 번 나갔다(2026-09-06). 같은 답을 네 번 + 받을 이유가 없으므로 **짧은 시간 동안 한 번만** 부르고 나눠 쓴다. 로그인·로그아웃 + 에서는 즉시 버려 다음 조회가 서버를 다시 본다. */ +const SESSION_CACHE_MS = 5000; +let sessionCache: { at: number; value: Promise } | null = null; + +/** 세션 캐시를 버린다 — 로그인·로그아웃처럼 상태가 바뀌는 자리에서 부른다. */ +export function clearSessionCache(): void { + sessionCache = null; +} + +function sessionOnce(): Promise { + const now = Date.now(); + if (sessionCache && now - sessionCache.at < SESSION_CACHE_MS) return sessionCache.value; + const value = (async (): Promise => { + try { + const response = await fetch(`${API_BASE_URL}/auth/session`, { credentials: "include" }); + if (!response.ok) return null; + const data = (await response.json()) as { status: string; user?: SessionUser }; + return data.user ?? null; + } catch { + return null; + } + })(); + sessionCache = { at: now, value }; + return value; +} + export async function fetchSession(): Promise { - const response = await fetch(`${API_BASE_URL}/auth/session`, { credentials: "include" }); - return response.ok; + return (await sessionOnce()) !== null; } export async function fetchSessionUser(): Promise { - try { - const response = await fetch(`${API_BASE_URL}/auth/session`, { credentials: "include" }); - if (!response.ok) return null; - const data = (await response.json()) as { status: string; user?: SessionUser }; - return data.user ?? null; - } catch { - return null; - } + return sessionOnce(); } export function logout(): Promise { + clearSessionCache(); return post("/auth/logout"); } diff --git a/B05_Profile/B05_Profile_UI_Page.ts b/B05_Profile/B05_Profile_UI_Page.ts index aeced756..e21df61d 100644 --- a/B05_Profile/B05_Profile_UI_Page.ts +++ b/B05_Profile/B05_Profile_UI_Page.ts @@ -72,6 +72,9 @@ function L(key: keyof typeof ui_locales): string { return ui_locales[key][currentLanguageIndex]; } +/** 구 구조물 측점 이관을 이미 시도한 노선 — 탭 수명 동안 유지한다. */ +const migratedRoutes = new Set(); + export async function renderB05Route(root: HTMLElement): Promise { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); if (!projectId) { @@ -538,7 +541,11 @@ export async function renderB05Route(root: HTMLElement): Promise { chainage_m: station.chainage_m, structure: station.structure ?? "", })); - if (legacy.length) { + // 이관은 멱등이지만 **진입할 때마다** POST 가 나갔다(2026-09-06 실측). 탭 수명 동안 + // 노선마다 한 번만 시도한다 — 옮길 것이 남아 있으면 다음 새로고침에 다시 본다. + const migrateKey = `${activeProjectId}:${routeId}`; + if (legacy.length && !migratedRoutes.has(migrateKey)) { + migratedRoutes.add(migrateKey); void migrateLegacyStations(activeProjectId, legacy) .then((result) => { if (result.migrated > 0) { diff --git a/ui_template/ui_template_overlay.ts b/ui_template/ui_template_overlay.ts index 607c6093..70c79471 100644 --- a/ui_template/ui_template_overlay.ts +++ b/ui_template/ui_template_overlay.ts @@ -2,7 +2,9 @@ import "./ui_template_overlay.css"; import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { t } from "./ui_template_locale"; import { makePanelDraggable } from "./ui_template_overlay_drag"; -import { fetchProjectWorkflowState } from "../B01_Dashboard/B01_Dashboard_Api_Fetch"; +// 워크플로 상태는 공용 창구 하나로 받는다 — 화면마다 따로 부르면 진입에서 같은 답을 +// 두 번 받는다(2026-09-06 실측). 그 창구가 짧은 시간 동안 캐시한다. +import { fetchWorkflowState } from "../A00_Common/b_workflow_nav"; const TITLE_OVERLAY_STATE_KEY = "frd_workflow_title_overlay_open"; const PROGRESS_OVERLAY_STATE_KEY = "frd_workflow_progress_overlay_open"; @@ -124,7 +126,7 @@ function createProjectNameTag(): HTMLElement { tag.className = "ui-workflow-overlay__project"; const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); if (!projectId) return tag; - void fetchProjectWorkflowState(projectId) + void fetchWorkflowState(projectId) .then((state) => { const name = state.project_name ?? ""; tag.textContent = name;