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:
윤정민 2026-04-21 14:02:36 +09:00
parent a59ccf3314
commit cbf2bdd18d
4 changed files with 86 additions and 37 deletions

View File

@ -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
View 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;
}
}

View File

@ -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 }),
);
}

View File

@ -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));
}