From e2cc5e759ba4c1166bacf2bbf11005ee8d8356e2 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 7 Sep 2026 00:18:37 +0900 Subject: [PATCH] =?UTF-8?q?perf(B05):=203D=20=EC=BD=94=EB=A6=AC=EB=8F=84?= =?UTF-8?q?=EB=A5=BC=20=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=20=EB=B3=B4?= =?UTF-8?q?=EA=B4=80=ED=95=A8(IndexedDB)=EC=97=90=20=EB=8B=B4=EC=95=84=20?= =?UTF-8?q?=EC=9E=AC=EC=88=98=EC=8B=A0=20=EC=97=86=EC=95=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 손 안 댄 프로젝트를 열면 저장본 열쇠가 맞아 매번 16.9MB 를 받았고, 브라우저 보관이 모듈 Map 이라 새로고침 한 번에 날아가 또 받았음(보조 창 실측: 첫 로그인~B05 도달 29MB 중 16.9MB 가 이것). 해시 질의(0b30f5b0)는 낡았을 때만 막았지 맞을 때는 그대로였음. - b_asset_cache 에 readCachedBytes / writeCachedBytes / purgeAssetsWithPrefix 추가. 앞 둘은 네트워크를 안 타는 순수 보관함 접근이고, 마지막은 주소 앞머리가 같은 옛 보관본을 지움 — 코리도는 주소에 열쇠가 박혀 정본이 바뀌면 새 주소가 되므로 17MB 짜리가 쌓이지 않게 담기 전에 치움. - 코리도 조회가 보관함을 먼저 봄. 주소에 열쇠가 있어 정본이 바뀌면 저절로 다른 주소가 되므로 옛 보관본을 잘못 쓸 일이 없음. 자체검증(공용 브라우저) — 담기·읽기 왕복 정상(44바이트 왕복, 해시 보존), 열쇠만 다른 두 벌을 담은 뒤 앞머리 지우기로 둘 다 제거 확인. typecheck 통과. ※ 실제 16.9MB 절감은 저장본이 최신인 프로젝트에서 확인해야 함 — 이 프로젝트는 검증 중 계획고를 만져 저장본이 낡은 상태라 코리도 자체가 안 실림(__corridorSource null). Co-Authored-By: Claude Opus 5 (1M context) --- A00_Common/b_asset_cache.ts | 61 ++++++++++++++++++++++++++ B05_Profile/B05_Profile_UI_Corridor.ts | 39 +++++++++++++--- 2 files changed, 93 insertions(+), 7 deletions(-) diff --git a/A00_Common/b_asset_cache.ts b/A00_Common/b_asset_cache.ts index a89ac6dc..036ddb65 100644 --- a/A00_Common/b_asset_cache.ts +++ b/A00_Common/b_asset_cache.ts @@ -111,6 +111,67 @@ export async function purgeOtherProjects(projectId: string): Promise { }); } +/** 보관함에서 바이트만 꺼낸다 — 네트워크를 타지 않는다. 없으면 null. + * 주소에 열쇠가 박히는 자료(3D 코리도)는 이걸로 **먼저 보고** 없을 때만 받는다. */ +export async function readCachedBytes(projectId: string, url: string): Promise { + try { + const cached = await readAsset(projectId, url); + return cached?.body ?? null; + } catch { + return null; + } +} + +/** 받아 둔 바이트를 보관함에 담는다. 실패해도 화면은 그대로 간다(용량 초과 등). */ +export async function writeCachedBytes( + projectId: string, + url: string, + body: ArrayBuffer, +): Promise { + try { + await writeAsset({ + key: cacheKey(projectId, url), + projectId, + url, + etag: null, + savedAt: Date.now(), + body, + }); + } catch { + // 담지 못해도 다음에 다시 받으면 된다 — 막지 않는다. + } +} + +/** + * 한 프로젝트 안에서 **주소 앞머리가 같은 옛 보관본**을 지운다. + * + * 3D 코리도처럼 주소에 열쇠(해시)가 박히는 자료는 정본이 바뀔 때마다 새 주소가 되어 + * 옛 보관본이 그대로 쌓인다. 하나가 17MB 대라 두어 벌만 남아도 브라우저 보관함을 크게 + * 먹는다. 새것을 담기 **전에** 같은 앞머리를 치운다(2026-09-06). + */ +export async function purgeAssetsWithPrefix(projectId: string, prefix: string): Promise { + const db = await openDatabase(); + if (!db) return; + await new Promise((resolve) => { + try { + const transaction = db.transaction(STORE, "readwrite"); + const store = transaction.objectStore(STORE); + const cursorRequest = store.openCursor(); + cursorRequest.onsuccess = () => { + const cursor = cursorRequest.result; + if (!cursor) return; + const value = cursor.value as CachedAsset; + if (value.projectId === projectId && value.url.startsWith(prefix)) cursor.delete(); + cursor.continue(); + }; + transaction.oncomplete = () => resolve(); + transaction.onerror = () => resolve(); + } catch { + resolve(); + } + }); +} + export interface CachedFetchOptions { /** 내려받는 동안 진행률(0~1, 모르면 null)을 알려준다. 저장본을 쓰면 호출되지 않는다. */ onProgress?: (ratio: number | null) => void; diff --git a/B05_Profile/B05_Profile_UI_Corridor.ts b/B05_Profile/B05_Profile_UI_Corridor.ts index 11740b21..58864d84 100644 --- a/B05_Profile/B05_Profile_UI_Corridor.ts +++ b/B05_Profile/B05_Profile_UI_Corridor.ts @@ -14,6 +14,11 @@ * ========================================================================== */ import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend"; +import { + purgeAssetsWithPrefix, + readCachedBytes, + writeCachedBytes, +} from "../A00_Common/b_asset_cache"; import type { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch"; import type { RoutePoint } from "./B05_Profile_Api_Fetch"; import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build"; @@ -60,31 +65,51 @@ async function requestCorridor(path: string, init: RequestInit): Promise { + const url = `${corridorUrlPrefix(projectId, routeId)}?hash=${encodeURIComponent(hash)}`; try { + // **브라우저 보관함을 먼저 본다**(2026-09-06) — 저장본이 최신이면 열쇠가 맞아 매번 + // 17MB 를 받았고, 새로고침 한 번이면 메모리 캐시가 날아가 또 받았다. 주소에 열쇠가 + // 박혀 있으므로 정본이 바뀌면 저절로 다른 주소가 되어 옛 보관본을 안 쓴다. + const cached = await readCachedBytes(projectId, url); + if (cached) { + const payload = JSON.parse(new TextDecoder().decode(cached)) as CorridorEnvelope; + if (payload?.version === ENVELOPE_VERSION && Array.isArray(payload.ribbons)) { + return { kind: "envelope", envelope: payload }; + } + } const response = await requestCorridor( `/projects/${projectId}/routes/${routeId}/corridor?hash=${encodeURIComponent(hash)}`, { method: "GET" }, ); if (!response.ok) return { kind: "missing" }; - const payload = (await response.json()) as CorridorEnvelope & { status?: string }; + const text = await response.text(); + const payload = JSON.parse(text) as CorridorEnvelope & { status?: string }; if (payload?.status === "stale") return { kind: "stale" }; - return payload && payload.version === ENVELOPE_VERSION && Array.isArray(payload.ribbons) - ? { kind: "envelope", envelope: payload } - : { kind: "missing" }; + if (payload && payload.version === ENVELOPE_VERSION && Array.isArray(payload.ribbons)) { + // 담기 전에 열쇠만 다른 옛 보관본을 치운다 — 17MB 짜리가 쌓이지 않게. + void purgeAssetsWithPrefix(projectId, corridorUrlPrefix(projectId, routeId)).then(() => + writeCachedBytes(projectId, url, new TextEncoder().encode(text).buffer as ArrayBuffer), + ); + return { kind: "envelope", envelope: payload }; + } + return { kind: "missing" }; } catch { return { kind: "missing" }; // 저장본 조회 실패는 빌드로 폴백 — 표시를 막지 않는다. }