perf: 화면 진입 중복 호출 정리 — 세션·워크플로 상태·구조물 이관
실측(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) <noreply@anthropic.com>
This commit is contained in:
@@ -30,15 +30,39 @@ export const WORKFLOW_STEP_ROUTES: readonly RoutePath[] = [
|
|||||||
ROUTES.B09_ESTIMATION,
|
ROUTES.B09_ESTIMATION,
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/* 워크플로 상태는 한 화면을 여는 동안 준비 화면 가드와 페이지 본체가 각각 부른다 —
|
||||||
|
실측 B05 진입에서 두 번 나갔다(2026-09-06). 짧은 시간 동안 한 번만 부르고 나눠 쓴다.
|
||||||
|
단계가 바뀌는 자리(`goToWorkflowStage`)에서는 즉시 버린다. */
|
||||||
|
const WORKFLOW_CACHE_MS = 5000;
|
||||||
|
let workflowCache: { at: number; projectId: string; value: Promise<WorkflowState> } | null = null;
|
||||||
|
|
||||||
|
/** 워크플로 상태 캐시를 버린다 — 단계가 바뀌는 자리에서 부른다. */
|
||||||
|
export function clearWorkflowStateCache(): void {
|
||||||
|
workflowCache = null;
|
||||||
|
}
|
||||||
|
|
||||||
export async function fetchWorkflowState(projectId: string): Promise<WorkflowState> {
|
export async function fetchWorkflowState(projectId: string): Promise<WorkflowState> {
|
||||||
const response = await fetch(`${API_BASE_URL}/projects/${projectId}/workflow-state`, {
|
const now = Date.now();
|
||||||
credentials: "include",
|
if (
|
||||||
});
|
workflowCache &&
|
||||||
if (!response.ok) {
|
workflowCache.projectId === projectId &&
|
||||||
throw new Error(`Workflow state request failed: ${response.status}`);
|
now - workflowCache.at < WORKFLOW_CACHE_MS
|
||||||
|
) {
|
||||||
|
return workflowCache.value;
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const value = (async (): Promise<WorkflowState> => {
|
||||||
return data.workflow_state ?? data;
|
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 {
|
export function goToWorkflowStage(projectId: string, route: RoutePath): void {
|
||||||
localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId);
|
localStorage.setItem(CURRENT_PROJECT_ID_KEY, projectId);
|
||||||
|
// 단계가 바뀌어 이동하는 자리다 — 캐시를 버려 다음 화면이 새 상태를 본다.
|
||||||
|
clearWorkflowStateCache();
|
||||||
if (!PRELOAD_REQUIRED_ROUTES.includes(route)) {
|
if (!PRELOAD_REQUIRED_ROUTES.includes(route)) {
|
||||||
navigateTo(route);
|
navigateTo(route);
|
||||||
return;
|
return;
|
||||||
|
|||||||
@@ -32,25 +32,48 @@ export function requestLogin(email: string, password: string): Promise<ApiResult
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function verifyLogin(email: string, otpCode: string): Promise<ApiResult> {
|
export function verifyLogin(email: string, otpCode: string): Promise<ApiResult> {
|
||||||
|
clearSessionCache();
|
||||||
return post("/auth/login/verify", { email, otp_code: otpCode });
|
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<SessionUser | null> } | null = null;
|
||||||
|
|
||||||
|
/** 세션 캐시를 버린다 — 로그인·로그아웃처럼 상태가 바뀌는 자리에서 부른다. */
|
||||||
|
export function clearSessionCache(): void {
|
||||||
|
sessionCache = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionOnce(): Promise<SessionUser | null> {
|
||||||
|
const now = Date.now();
|
||||||
|
if (sessionCache && now - sessionCache.at < SESSION_CACHE_MS) return sessionCache.value;
|
||||||
|
const value = (async (): Promise<SessionUser | null> => {
|
||||||
|
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<boolean> {
|
export async function fetchSession(): Promise<boolean> {
|
||||||
const response = await fetch(`${API_BASE_URL}/auth/session`, { credentials: "include" });
|
return (await sessionOnce()) !== null;
|
||||||
return response.ok;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSessionUser(): Promise<SessionUser | null> {
|
export async function fetchSessionUser(): Promise<SessionUser | null> {
|
||||||
try {
|
return sessionOnce();
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function logout(): Promise<ApiResult> {
|
export function logout(): Promise<ApiResult> {
|
||||||
|
clearSessionCache();
|
||||||
return post("/auth/logout");
|
return post("/auth/logout");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,6 +72,9 @@ function L(key: keyof typeof ui_locales): string {
|
|||||||
return ui_locales[key][currentLanguageIndex];
|
return ui_locales[key][currentLanguageIndex];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 구 구조물 측점 이관을 이미 시도한 노선 — 탭 수명 동안 유지한다. */
|
||||||
|
const migratedRoutes = new Set<string>();
|
||||||
|
|
||||||
export async function renderB05Route(root: HTMLElement): Promise<void> {
|
export async function renderB05Route(root: HTMLElement): Promise<void> {
|
||||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||||
if (!projectId) {
|
if (!projectId) {
|
||||||
@@ -538,7 +541,11 @@ export async function renderB05Route(root: HTMLElement): Promise<void> {
|
|||||||
chainage_m: station.chainage_m,
|
chainage_m: station.chainage_m,
|
||||||
structure: station.structure ?? "",
|
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)
|
void migrateLegacyStations(activeProjectId, legacy)
|
||||||
.then((result) => {
|
.then((result) => {
|
||||||
if (result.migrated > 0) {
|
if (result.migrated > 0) {
|
||||||
|
|||||||
@@ -2,7 +2,9 @@ import "./ui_template_overlay.css";
|
|||||||
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend";
|
||||||
import { t } from "./ui_template_locale";
|
import { t } from "./ui_template_locale";
|
||||||
import { makePanelDraggable } from "./ui_template_overlay_drag";
|
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 TITLE_OVERLAY_STATE_KEY = "frd_workflow_title_overlay_open";
|
||||||
const PROGRESS_OVERLAY_STATE_KEY = "frd_workflow_progress_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";
|
tag.className = "ui-workflow-overlay__project";
|
||||||
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY);
|
||||||
if (!projectId) return tag;
|
if (!projectId) return tag;
|
||||||
void fetchProjectWorkflowState(projectId)
|
void fetchWorkflowState(projectId)
|
||||||
.then((state) => {
|
.then((state) => {
|
||||||
const name = state.project_name ?? "";
|
const name = state.project_name ?? "";
|
||||||
tag.textContent = name;
|
tag.textContent = name;
|
||||||
|
|||||||
Reference in New Issue
Block a user