diff --git a/src/handlers/predictionHandlers.ts b/src/handlers/predictionHandlers.ts index c687437..033fed4 100644 --- a/src/handlers/predictionHandlers.ts +++ b/src/handlers/predictionHandlers.ts @@ -24,6 +24,7 @@ export const prediction = onRequest(async (req, res) => { if (tail === "summary" && req.method === "GET") { const gameId = String(req.query.gameId ?? ""); const result = await getSummary(gameId); + res.set("Cache-Control", "public, max-age=5"); res.status(200).json(result); return; } diff --git a/src/lib/memCache.ts b/src/lib/memCache.ts new file mode 100644 index 0000000..0c385c8 --- /dev/null +++ b/src/lib/memCache.ts @@ -0,0 +1,72 @@ +/** + * 인스턴스 단위 인메모리 캐시 + promise coalescing. + * + * 동일 key에 대해 TTL 내에는 캐싱된 값을 반환하고, + * TTL 만료 후 동시에 여러 요청이 들어와도 fetcher를 한 번만 실행한다. + */ +export class MemCache { + private cache = new Map(); + private inflight = new Map>(); + + constructor( + private readonly ttlMs: number, + private readonly maxSize?: number, + ) {} + + get(key: string): T | null { + const e = this.cache.get(key); + if (!e) return null; + if (Date.now() > e.expiresAt) { + this.cache.delete(key); + return null; + } + return e.data; + } + + set(key: string, data: T): void { + this.evict(); + if (this.maxSize !== undefined && this.cache.size >= this.maxSize) { + const oldest = this.cache.keys().next().value; + if (oldest !== undefined) this.cache.delete(oldest); + } + this.cache.set(key, { data, expiresAt: Date.now() + this.ttlMs }); + } + + private evict(): void { + const now = Date.now(); + for (const [k, e] of this.cache) { + if (now > e.expiresAt) this.cache.delete(k); + } + } + + delete(key: string): void { + this.cache.delete(key); + this.inflight.delete(key); + } + + /** + * 캐시 조회 → miss면 fetcher 실행 (동일 key 중복 호출 방지). + */ + async getOrFetch(key: string, fetcher: () => Promise): Promise { + const cached = this.get(key); + if (cached) return cached; + + const existing = this.inflight.get(key); + if (existing) return existing; + + const promise = fetcher().then( + (data) => { + this.set(key, data); + this.inflight.delete(key); + return data; + }, + (err) => { + this.inflight.delete(key); + throw err; + }, + ); + + this.inflight.set(key, promise); + return promise; + } +} \ No newline at end of file diff --git a/src/services/gameListService.ts b/src/services/gameListService.ts index a5a5220..33f88f4 100644 --- a/src/services/gameListService.ts +++ b/src/services/gameListService.ts @@ -5,6 +5,7 @@ import { SERIES_CODES, type GameListResult, } from "../kbo/game-list.js"; +import { MemCache } from "../lib/memCache.js"; // 게임센터(GetKboGameList)는 다른 KBO 데이터와 달리 Firestore 공유 캐시 // (kboCacheRepository) 대신 인스턴스 단위 인메모리 캐시를 쓴다. @@ -18,32 +19,7 @@ import { // // 트래픽이 커져 인스턴스 수 × 호출량이 문제가 되면 Firestore 공유 캐시로 // 격상을 재검토 (TODO.md 참조). -const MEM_TTL_MS = 10_000; -const MEM_MAX = 100; - -interface MemEntry { - data: GameListResult; - expiresAt: number; -} -const memCache = new Map(); - -function memGet(key: string): GameListResult | null { - const e = memCache.get(key); - if (!e) return null; - if (Date.now() > e.expiresAt) { - memCache.delete(key); - return null; - } - return e.data; -} - -function memSet(key: string, data: GameListResult): void { - if (memCache.size >= MEM_MAX) { - const oldest = memCache.keys().next().value; - if (oldest !== undefined) memCache.delete(oldest); - } - memCache.set(key, { data, expiresAt: Date.now() + MEM_TTL_MS }); -} +const gameListCache = new MemCache(10_000); export async function getGameList( date: string | Date, @@ -60,14 +36,7 @@ export async function getGameList( : `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, "0")}${String(date.getDate()).padStart(2, "0")}`; const key = `${dateStr}|${resolvedSeries ?? ""}|${resolvedLeague ?? ""}`; - const hit = memGet(key); - if (hit) return hit; - - const result = await fetchGameList({ - date, - series: resolvedSeries, - league: resolvedLeague, - }); - memSet(key, result); - return result; + return gameListCache.getOrFetch(key, () => + fetchGameList({ date, series: resolvedSeries, league: resolvedLeague }), + ); } diff --git a/src/services/predictionService.ts b/src/services/predictionService.ts index a2062cd..b75a9d0 100644 --- a/src/services/predictionService.ts +++ b/src/services/predictionService.ts @@ -9,6 +9,11 @@ import { } from "../repositories/voteRepository.js"; import type { Game, VoteEntry } from "../types/panit.js"; import { fromTimestamp, parseDateString, type DateString } from "../types/dateString.js"; +import { MemCache } from "../lib/memCache.js"; + +type SummaryCounts = { homeCount: number; awayCount: number }; + +const summaryCache = new MemCache(5_000); function gameDate(game: Game): DateString { return fromTimestamp(game.time); @@ -49,6 +54,7 @@ export async function createPrediction( side, team: body.selectedTeamCode, }); + summaryCache.delete(body.gameId); return { ok: true }; } @@ -76,6 +82,7 @@ export async function updatePrediction( newSide, newTeam: body.selectedTeamCode, }); + summaryCache.delete(body.gameId); return { ok: true, changed: true }; } @@ -102,5 +109,5 @@ export async function getSummary( gameId: string ): Promise<{ homeCount: number; awayCount: number }> { if (!gameId) throw new HttpError(400, "gameId required"); - return getCounts(gameId); + return summaryCache.getOrFetch(gameId, () => getCounts(gameId)); }