Cache games by date and invalidate on writes

- gameRepository에 날짜 키 공유 캐시(MemCache, 15초) 추가 — 날짜별 경기 목록은 유저 독립 전역 데이터라 요청 간 공유할 수 있다. GET /prediction/games가 요청마다 range 쿼리를 재발행하던 것을 인스턴스당 분당 수 회로 수렴시킨다
- games 문서 쓰기 경로에 무효화 연결: syncGamesForMonth는 여러 날짜에 걸치므로 전량 폐기, forceSyncDay는 해당 날짜만
- createGameDayCache가 공유 캐시를 경유하도록 변경 — 요청마다 새로 만드는 짧은 수명 인스턴스도 실제 쿼리를 유발하지 않는다
- predictionHandlers의 games 분기에 Cache-Control public max-age=15 추가(무인증·전 유저 공통 응답이라 CDN·브라우저 중복 제거가 가능한데 그동안 헤더가 없었다)
- MemCache.getOrFetch가 truthy 대신 히트 여부로 판정하도록 수정 — 캐시된 null이 미스로 취급돼 fetcher가 매번 재실행되던 문제(존재하지 않는 상품 조회가 요청마다 Firestore read 유발)
- MemCache에 peek·clear 추가, 단위 테스트 6건 신설
This commit is contained in:
윤정민 2026-07-27 13:25:59 +09:00
parent aa53a5b070
commit ed573a81e0
7 changed files with 200 additions and 10 deletions

View File

@ -36,6 +36,9 @@ export const prediction = onRequest(async (req, res) => {
const date = resolveDateParam(req.query.date); const date = resolveDateParam(req.query.date);
const games = await listGamesByDate(date); const games = await listGamesByDate(date);
const dto: GamesResponseDto = { date, games }; const dto: GamesResponseDto = { date, games };
// 무인증·전 유저 공통 응답 — CDN/브라우저에서 중복 요청을 합칠 수 있게 한다.
// 경기 중 스코어 변동을 고려해 서버 캐시(15초)와 같은 수준으로 짧게 잡는다.
res.set("Cache-Control", "public, max-age=15");
res.status(200).json(dto); res.status(200).json(dto);
return; return;
} }

View File

@ -14,13 +14,23 @@ export class MemCache<T> {
) {} ) {}
get(key: string): T | null { get(key: string): T | null {
return this.peek(key)?.data ?? null;
}
/**
* .
*
* `get` `null`·`0`·`""` . negative
* falsy fetcher가 .
*/
peek(key: string): { data: T } | null {
const e = this.cache.get(key); const e = this.cache.get(key);
if (!e) return null; if (!e) return null;
if (Date.now() > e.expiresAt) { if (Date.now() > e.expiresAt) {
this.cache.delete(key); this.cache.delete(key);
return null; return null;
} }
return e.data; return { data: e.data };
} }
set(key: string, data: T): void { set(key: string, data: T): void {
@ -44,12 +54,19 @@ export class MemCache<T> {
this.inflight.delete(key); this.inflight.delete(key);
} }
/** 전체 엔트리를 버린다. 여러 key에 영향을 주는 쓰기 이후의 일괄 무효화용. */
clear(): void {
this.cache.clear();
this.inflight.clear();
}
/** /**
* miss면 fetcher ( key ). * miss면 fetcher ( key ).
*/ */
async getOrFetch(key: string, fetcher: () => Promise<T>): Promise<T> { async getOrFetch(key: string, fetcher: () => Promise<T>): Promise<T> {
const cached = this.get(key); // truthy 검사가 아니라 히트 여부로 판정한다 — 캐시된 null/0/""도 서빙된다.
if (cached) return cached; const cached = this.peek(key);
if (cached) return cached.data;
const existing = this.inflight.get(key); const existing = this.inflight.get(key);
if (existing) return existing; if (existing) return existing;

View File

@ -1,10 +1,23 @@
import { Timestamp } from "firebase-admin/firestore"; import { Timestamp } from "firebase-admin/firestore";
import { firestore } from "../firebase"; import { firestore } from "../firebase";
import { MemCache } from "../lib/memCache";
import type { Game } from "../types/panit"; import type { Game } from "../types/panit";
import { startOfDayKst, type DateString } from "../types/dateString"; import { startOfDayKst, type DateString } from "../types/dateString";
const COLLECTION = "games"; const COLLECTION = "games";
/**
* games .
*
* .
* `GET /prediction/games` ()
* range .
*
* TTL이 이유: 경기 · .
* `invalidateGameDay` TTL은 .
*/
const dayCache = new MemCache<GameWithId[]>(15_000, 64);
export type GameWithId = Game & { gameId: string }; export type GameWithId = Game & { gameId: string };
/** /**
@ -37,11 +50,41 @@ export async function listByDate(date: DateString): Promise<GameWithId[]> {
} }
/** /**
* `listByDate` . * `listByDate` . .
*
* `MemCache` inflight .
*/
export async function listByDateCached(date: DateString): Promise<GameWithId[]> {
return dayCache.getOrFetch(date, () => listByDate(date));
}
/**
* games . `games` .
*
* TTL(15) stale ,
* .
*/
export function invalidateGameDay(date: DateString): void {
dayCache.delete(date);
}
/**
* games를 ( ) .
* 64 .
*/
export function invalidateAllGameDays(): void {
dayCache.clear();
}
/**
* `listByDate` run .
* *
* `dailyArchive` run에서 `games` * `dailyArchive` run에서 `games`
* , Firestore read를 1 . * , Firestore read를 1 .
* Promise를 . * Promise를 .
*
* (`listByDateCached`) ,
* .
*/ */
export interface GameDayCache { export interface GameDayCache {
listByDate(date: DateString): Promise<GameWithId[]>; listByDate(date: DateString): Promise<GameWithId[]>;
@ -54,7 +97,7 @@ export function createGameDayCache(): GameDayCache {
listByDate(date: DateString): Promise<GameWithId[]> { listByDate(date: DateString): Promise<GameWithId[]> {
let p = cache.get(date); let p = cache.get(date);
if (!p) { if (!p) {
p = listByDate(date); p = listByDateCached(date);
cache.set(date, p); cache.set(date, p);
} }
return p; return p;

View File

@ -4,9 +4,14 @@ import {
fetchScheduleFromKbo, fetchScheduleFromKbo,
statusFromRecord, statusFromRecord,
} from "../repositories/kboRepository"; } from "../repositories/kboRepository";
import {
invalidateAllGameDays,
invalidateGameDay,
} from "../repositories/gameRepository";
import { getGameList } from "./gameListService"; import { getGameList } from "./gameListService";
import type { GameListRecord } from "../kbo/game-list"; import type { GameListRecord } from "../kbo/game-list";
import type { ScheduleGame, GameStatus } from "../kbo/schedule"; import type { ScheduleGame, GameStatus } from "../kbo/schedule";
import { parseDateString } from "../types/dateString";
import type { Game } from "../types/panit"; import type { Game } from "../types/panit";
const COLLECTION = "games"; const COLLECTION = "games";
@ -109,7 +114,11 @@ export async function syncGamesForMonth(year: number, month: number): Promise<nu
count++; count++;
}); });
if (count > 0) await batch.commit(); if (count > 0) {
await batch.commit();
// 월 전체에 걸쳐 쓰므로 날짜별 무효화 대신 일괄 폐기한다.
invalidateAllGameDays();
}
return count; return count;
} }
@ -196,6 +205,13 @@ export async function forceSyncDay(
updated++; updated++;
}); });
if (updated > 0) await batch.commit(); if (updated > 0) {
await batch.commit();
invalidateGameDay(
parseDateString(
`${yyyymmdd.slice(0, 4)}-${yyyymmdd.slice(4, 6)}-${yyyymmdd.slice(6, 8)}`
)
);
}
return { updated }; return { updated };
} }

View File

@ -1,5 +1,5 @@
import { HttpError } from "../middleware/errors"; import { HttpError } from "../middleware/errors";
import { getGame, listByDate } from "../repositories/gameRepository"; import { getGame, listByDateCached } from "../repositories/gameRepository";
import { import {
getUserVote, getUserVote,
submitVote, submitVote,
@ -107,7 +107,7 @@ export async function getMyVotes(
export async function listGamesByDate(date: string): Promise<GameDto[]> { export async function listGamesByDate(date: string): Promise<GameDto[]> {
try { try {
const games = await listByDate(parseDateString(date)); const games = await listByDateCached(parseDateString(date));
return games.map(toGameDto); return games.map(toGameDto);
} catch (err) { } catch (err) {
throw new HttpError(400, (err as Error).message); throw new HttpError(400, (err as Error).message);

View File

@ -1,12 +1,17 @@
import { beforeEach, describe, expect, it } from "vitest"; import { beforeEach, describe, expect, it } from "vitest";
import { Timestamp } from "firebase-admin/firestore"; import { Timestamp } from "firebase-admin/firestore";
import { firestore } from "../../src/firebase"; import { firestore } from "../../src/firebase";
import { getGame, listByDate } from "../../src/repositories/gameRepository"; import {
getGame,
invalidateAllGameDays,
listByDate,
} from "../../src/repositories/gameRepository";
import type { DateString } from "../../src/types/dateString"; import type { DateString } from "../../src/types/dateString";
describe("gameRepository", () => { describe("gameRepository", () => {
beforeEach(async () => { beforeEach(async () => {
await firestore.recursiveDelete(firestore.collection("games")); await firestore.recursiveDelete(firestore.collection("games"));
invalidateAllGameDays();
}); });
it("존재하지 않는 경기는 null을 반환한다", async () => { it("존재하지 않는 경기는 null을 반환한다", async () => {

106
tests/unit/memCache.test.ts Normal file
View File

@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import { MemCache } from "../../src/lib/memCache";
describe("MemCache", () => {
it("TTL 내에는 캐시된 값을 반환하고 fetcher를 다시 돌리지 않는다", async () => {
const cache = new MemCache<number>(60_000);
let calls = 0;
const fetcher = async () => {
calls += 1;
return 42;
};
expect(await cache.getOrFetch("k", fetcher)).toBe(42);
expect(await cache.getOrFetch("k", fetcher)).toBe(42);
expect(calls).toBe(1);
});
/**
* negative truthy null이
* fetcher가 ( Firestore read ).
*/
it("캐시된 null도 히트로 취급해 fetcher를 다시 돌리지 않는다", async () => {
const cache = new MemCache<string | null>(60_000);
let calls = 0;
const fetcher = async () => {
calls += 1;
return null;
};
expect(await cache.getOrFetch("missing", fetcher)).toBeNull();
expect(await cache.getOrFetch("missing", fetcher)).toBeNull();
expect(calls).toBe(1);
});
it("0과 빈 문자열도 히트로 취급한다", async () => {
const zero = new MemCache<number>(60_000);
const empty = new MemCache<string>(60_000);
let zeroCalls = 0;
let emptyCalls = 0;
await zero.getOrFetch("z", async () => {
zeroCalls += 1;
return 0;
});
await zero.getOrFetch("z", async () => {
zeroCalls += 1;
return 0;
});
await empty.getOrFetch("e", async () => {
emptyCalls += 1;
return "";
});
await empty.getOrFetch("e", async () => {
emptyCalls += 1;
return "";
});
expect(zeroCalls).toBe(1);
expect(emptyCalls).toBe(1);
});
it("TTL이 지나면 다시 조회한다", async () => {
const cache = new MemCache<number>(-1); // 즉시 만료
let calls = 0;
const fetcher = async () => {
calls += 1;
return 1;
};
await cache.getOrFetch("k", fetcher);
await cache.getOrFetch("k", fetcher);
expect(calls).toBe(2);
});
it("delete는 해당 키만, clear는 전부 버린다", async () => {
const cache = new MemCache<number>(60_000);
await cache.getOrFetch("a", async () => 1);
await cache.getOrFetch("b", async () => 2);
cache.delete("a");
expect(cache.get("a")).toBeNull();
expect(cache.get("b")).toBe(2);
cache.clear();
expect(cache.get("b")).toBeNull();
});
it("동일 키 동시 호출은 fetcher를 한 번만 실행한다", async () => {
const cache = new MemCache<number>(60_000);
let calls = 0;
const fetcher = async () => {
calls += 1;
await new Promise((r) => setTimeout(r, 10));
return 7;
};
const [a, b, c] = await Promise.all([
cache.getOrFetch("k", fetcher),
cache.getOrFetch("k", fetcher),
cache.getOrFetch("k", fetcher),
]);
expect([a, b, c]).toEqual([7, 7, 7]);
expect(calls).toBe(1);
});
});