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

View File

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

View File

@ -1,10 +1,23 @@
import { Timestamp } from "firebase-admin/firestore";
import { firestore } from "../firebase";
import { MemCache } from "../lib/memCache";
import type { Game } from "../types/panit";
import { startOfDayKst, type DateString } from "../types/dateString";
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 };
/**
@ -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`
* , Firestore read를 1 .
* Promise를 .
*
* (`listByDateCached`) ,
* .
*/
export interface GameDayCache {
listByDate(date: DateString): Promise<GameWithId[]>;
@ -54,7 +97,7 @@ export function createGameDayCache(): GameDayCache {
listByDate(date: DateString): Promise<GameWithId[]> {
let p = cache.get(date);
if (!p) {
p = listByDate(date);
p = listByDateCached(date);
cache.set(date, p);
}
return p;

View File

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

View File

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

View File

@ -1,12 +1,17 @@
import { beforeEach, describe, expect, it } from "vitest";
import { Timestamp } from "firebase-admin/firestore";
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";
describe("gameRepository", () => {
beforeEach(async () => {
await firestore.recursiveDelete(firestore.collection("games"));
invalidateAllGameDays();
});
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);
});
});