perf(B05): 3D 코리도를 브라우저 보관함(IndexedDB)에 담아 재수신 없앰
손 안 댄 프로젝트를 열면 저장본 열쇠가 맞아 매번 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) <noreply@anthropic.com>
This commit is contained in:
@@ -111,6 +111,67 @@ export async function purgeOtherProjects(projectId: string): Promise<void> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 보관함에서 바이트만 꺼낸다 — 네트워크를 타지 않는다. 없으면 null.
|
||||||
|
* 주소에 열쇠가 박히는 자료(3D 코리도)는 이걸로 **먼저 보고** 없을 때만 받는다. */
|
||||||
|
export async function readCachedBytes(projectId: string, url: string): Promise<ArrayBuffer | null> {
|
||||||
|
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<void> {
|
||||||
|
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<void> {
|
||||||
|
const db = await openDatabase();
|
||||||
|
if (!db) return;
|
||||||
|
await new Promise<void>((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 {
|
export interface CachedFetchOptions {
|
||||||
/** 내려받는 동안 진행률(0~1, 모르면 null)을 알려준다. 저장본을 쓰면 호출되지 않는다. */
|
/** 내려받는 동안 진행률(0~1, 모르면 null)을 알려준다. 저장본을 쓰면 호출되지 않는다. */
|
||||||
onProgress?: (ratio: number | null) => void;
|
onProgress?: (ratio: number | null) => void;
|
||||||
|
|||||||
@@ -14,6 +14,11 @@
|
|||||||
* ========================================================================== */
|
* ========================================================================== */
|
||||||
|
|
||||||
import { API_BASE_URL, API_TIMEOUT_MS } from "@config/config_frontend";
|
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 { SectionDetailResponse } from "../B06_Section/B06_Section_Api_Fetch";
|
||||||
import type { RoutePoint } from "./B05_Profile_Api_Fetch";
|
import type { RoutePoint } from "./B05_Profile_Api_Fetch";
|
||||||
import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build";
|
import { buildCorridor, type CorridorBuildResult } from "./B05_Profile_UI_Corridor_Build";
|
||||||
@@ -60,31 +65,51 @@ async function requestCorridor(path: string, init: RequestInit): Promise<Respons
|
|||||||
|
|
||||||
/** 저장본 조회 결과 — 파일을 받았거나, 열쇠가 어긋나 안 받았거나, 아예 없거나. */
|
/** 저장본 조회 결과 — 파일을 받았거나, 열쇠가 어긋나 안 받았거나, 아예 없거나. */
|
||||||
type StoredLookup =
|
type StoredLookup =
|
||||||
| { kind: "envelope"; envelope: CorridorEnvelope }
|
{ kind: "envelope"; envelope: CorridorEnvelope } | { kind: "stale" } | { kind: "missing" };
|
||||||
| { kind: "stale" }
|
|
||||||
| { kind: "missing" };
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 저장본을 가져온다. **열쇠(hash)를 함께 보내면 서버가 먼저 맞춰 본다** — 어긋나면
|
* 저장본을 가져온다. **열쇠(hash)를 함께 보내면 서버가 먼저 맞춰 본다** — 어긋나면
|
||||||
* 파일 대신 수십 바이트짜리 `stale` 만 온다(2026-09-06). 그 전에는 18.6MB 를 다 받은 뒤
|
* 파일 대신 수십 바이트짜리 `stale` 만 온다(2026-09-06). 그 전에는 18.6MB 를 다 받은 뒤
|
||||||
* 해시가 다르다고 버렸다.
|
* 해시가 다르다고 버렸다.
|
||||||
*/
|
*/
|
||||||
|
/** 이 노선 코리도의 보관 주소 앞머리 — 열쇠만 다른 옛 보관본을 치울 때 쓴다. */
|
||||||
|
function corridorUrlPrefix(projectId: string, routeId: number): string {
|
||||||
|
return `${API_BASE_URL}/projects/${projectId}/routes/${routeId}/corridor`;
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchStored(
|
async function fetchStored(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
routeId: number,
|
routeId: number,
|
||||||
hash: string,
|
hash: string,
|
||||||
): Promise<StoredLookup> {
|
): Promise<StoredLookup> {
|
||||||
|
const url = `${corridorUrlPrefix(projectId, routeId)}?hash=${encodeURIComponent(hash)}`;
|
||||||
try {
|
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(
|
const response = await requestCorridor(
|
||||||
`/projects/${projectId}/routes/${routeId}/corridor?hash=${encodeURIComponent(hash)}`,
|
`/projects/${projectId}/routes/${routeId}/corridor?hash=${encodeURIComponent(hash)}`,
|
||||||
{ method: "GET" },
|
{ method: "GET" },
|
||||||
);
|
);
|
||||||
if (!response.ok) return { kind: "missing" };
|
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" };
|
if (payload?.status === "stale") return { kind: "stale" };
|
||||||
return payload && payload.version === ENVELOPE_VERSION && Array.isArray(payload.ribbons)
|
if (payload && payload.version === ENVELOPE_VERSION && Array.isArray(payload.ribbons)) {
|
||||||
? { kind: "envelope", envelope: payload }
|
// 담기 전에 열쇠만 다른 옛 보관본을 치운다 — 17MB 짜리가 쌓이지 않게.
|
||||||
: { kind: "missing" };
|
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 {
|
} catch {
|
||||||
return { kind: "missing" }; // 저장본 조회 실패는 빌드로 폴백 — 표시를 막지 않는다.
|
return { kind: "missing" }; // 저장본 조회 실패는 빌드로 폴백 — 표시를 막지 않는다.
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user