Optimize prediction summaries and game lists with new MemCache utility.
- `MemCache` 유틸리티 클래스를 도입하여 인메모리 캐싱 로직을 구조화하고 Promise Coalescing을 통해 동일 키에 대한 중복 fetch 요청을 방지했습니다. - 투표 요약(`getSummary`) 정보에 5초 TTL 캐시를 적용하고, 투표 데이터 변경 시 해당 캐시를 즉시 무효화하여 데이터 정합성을 유지했습니다. - 기존 `gameListService`에 개별적으로 구현되어 있던 수동 캐시 관리 로직을 `MemCache`로 교체하여 코드 중복을 제거했습니다. - 투표 요약 API 응답에 `Cache-Control` 헤더를 추가하여 클라이언트 및 인프라 계층에서의 효율적인 캐싱을 지원합니다.
This commit is contained in:
parent
a59ccf3314
commit
cbf2bdd18d
@ -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;
|
||||
}
|
||||
|
||||
72
src/lib/memCache.ts
Normal file
72
src/lib/memCache.ts
Normal file
@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 인스턴스 단위 인메모리 캐시 + promise coalescing.
|
||||
*
|
||||
* 동일 key에 대해 TTL 내에는 캐싱된 값을 반환하고,
|
||||
* TTL 만료 후 동시에 여러 요청이 들어와도 fetcher를 한 번만 실행한다.
|
||||
*/
|
||||
export class MemCache<T> {
|
||||
private cache = new Map<string, { data: T; expiresAt: number }>();
|
||||
private inflight = new Map<string, Promise<T>>();
|
||||
|
||||
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<T>): Promise<T> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@ -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<string, MemEntry>();
|
||||
|
||||
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<GameListResult>(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 }),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<SummaryCounts>(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));
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user