From 6fc00b2952e22576a55c28f55f4fbf65b9cc98f5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Thu, 28 May 2026 17:25:45 +0900 Subject: [PATCH] Use exponential backoff for cache-stampede polling (R4) waitForCache, the schedule month-poll loop, and getOrFetchDynamic all polled Firestore every fixed 500ms (up to ~50 reads) while waiting for a lock holder to populate the cache. Replace the fixed interval with a shared backoffDelayMs helper (300ms base, doubling, 5s cap) bounded by the same ~25s timeout, cutting per-waiter read amplification by roughly 5x. Timeout/fallback semantics are unchanged. --- src/repositories/kboCacheRepository.ts | 15 +++++++++++++-- src/repositories/kboRepository.ts | 7 ++++--- src/services/gameDetailService.ts | 6 ++++-- 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/repositories/kboCacheRepository.ts b/src/repositories/kboCacheRepository.ts index c395059..d7eaa8f 100644 --- a/src/repositories/kboCacheRepository.ts +++ b/src/repositories/kboCacheRepository.ts @@ -110,6 +110,17 @@ async function releaseLock(key: string): Promise { await firestore.collection(LOCK_COLLECTION).doc(key).delete().catch(() => undefined); } +/** + * 캐시 스탬피드 폴링용 지수 백오프 지연(ms)을 계산한다. + * + * `attempt`(0부터)에 대해 `baseMs * 2^attempt`를 `maxMs`로 클램프한다. + * 고정 간격(500ms) 대신 점증 간격을 쓰면 락 보유자가 채울 때까지 대기하는 + * 동안의 캐시 read 횟수를 크게 줄인다(예: 25초 윈도우에서 ~50회 → ~9회). + */ +export function backoffDelayMs(attempt: number, baseMs = 300, maxMs = 5_000): number { + return Math.min(maxMs, baseMs * 2 ** attempt); +} + /** * 락이 걸려있는 동안 주기적으로 캐시를 폴링한다. * @@ -122,8 +133,8 @@ async function releaseLock(key: string): Promise { */ async function waitForCache(key: string, ttlMs: number, timeoutMs = 25_000): Promise { const start = Date.now(); - while (Date.now() - start < timeoutMs) { - await new Promise((r) => setTimeout(r, 500)); + for (let attempt = 0; Date.now() - start < timeoutMs; attempt++) { + await new Promise((r) => setTimeout(r, backoffDelayMs(attempt))); const cached = await getCached(key, ttlMs); if (cached !== null) return cached; } diff --git a/src/repositories/kboRepository.ts b/src/repositories/kboRepository.ts index 8e07177..534842f 100644 --- a/src/repositories/kboRepository.ts +++ b/src/repositories/kboRepository.ts @@ -24,6 +24,7 @@ import { setCached, acquireLock, releaseLock, + backoffDelayMs, } from "./kboCacheRepository"; import { firestore } from "../firebase"; import { getGameList } from "../services/gameListService"; @@ -394,10 +395,10 @@ async function fetchScheduleMonth( await releaseLock(lockKey); } } else { - // 다른 요청이 fetch 중. 짧게 polling. + // 다른 요청이 fetch 중. 지수 백오프로 polling(대기 중 read 폭증 완화). const start = Date.now(); - while (Date.now() - start < 25_000) { - await new Promise((r) => setTimeout(r, 500)); + for (let attempt = 0; Date.now() - start < 25_000; attempt++) { + await new Promise((r) => setTimeout(r, backoffDelayMs(attempt))); cached = await readDayDocs(keys); missing = allDays.filter((_, i) => cached.get(keys[i]) === null); if (missing.length === 0) break; diff --git a/src/services/gameDetailService.ts b/src/services/gameDetailService.ts index 1853d23..760ee28 100644 --- a/src/services/gameDetailService.ts +++ b/src/services/gameDetailService.ts @@ -9,6 +9,7 @@ import { setCached, acquireLock, releaseLock, + backoffDelayMs, } from "../repositories/kboCacheRepository"; const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000; @@ -115,8 +116,9 @@ async function getOrFetchDynamic( const locked = await acquireLock(key); if (!locked) { - for (let i = 0; i < 50; i++) { - await new Promise((r) => setTimeout(r, 500)); + const start = Date.now(); + for (let attempt = 0; Date.now() - start < 25_000; attempt++) { + await new Promise((r) => setTimeout(r, backoffDelayMs(attempt))); const c = await getCached(key, Number.MAX_SAFE_INTEGER); if (c !== null) return c; }