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:
parent
aa53a5b070
commit
ed573a81e0
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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 };
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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
106
tests/unit/memCache.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user