From ed573a81e03b850557bd8f7114758d7cd24d6253 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Mon, 27 Jul 2026 13:25:59 +0900 Subject: [PATCH] Cache games by date and invalidate on writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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건 신설 --- src/handlers/predictionHandlers.ts | 3 + src/lib/memCache.ts | 23 ++++- src/repositories/gameRepository.ts | 47 +++++++++- src/services/gameSyncService.ts | 20 +++- src/services/predictionService.ts | 4 +- tests/repositories/gameRepository.test.ts | 7 +- tests/unit/memCache.test.ts | 106 ++++++++++++++++++++++ 7 files changed, 200 insertions(+), 10 deletions(-) create mode 100644 tests/unit/memCache.test.ts diff --git a/src/handlers/predictionHandlers.ts b/src/handlers/predictionHandlers.ts index 42fce01..7200090 100644 --- a/src/handlers/predictionHandlers.ts +++ b/src/handlers/predictionHandlers.ts @@ -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; } diff --git a/src/lib/memCache.ts b/src/lib/memCache.ts index 0c385c8..df8d034 100644 --- a/src/lib/memCache.ts +++ b/src/lib/memCache.ts @@ -14,13 +14,23 @@ export class MemCache { ) {} 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 { this.inflight.delete(key); } + /** 전체 엔트리를 버린다. 여러 key에 영향을 주는 쓰기 이후의 일괄 무효화용. */ + clear(): void { + this.cache.clear(); + this.inflight.clear(); + } + /** * 캐시 조회 → miss면 fetcher 실행 (동일 key 중복 호출 방지). */ async getOrFetch(key: string, fetcher: () => Promise): Promise { - 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; diff --git a/src/repositories/gameRepository.ts b/src/repositories/gameRepository.ts index 8626bc0..f0dddd4 100644 --- a/src/repositories/gameRepository.ts +++ b/src/repositories/gameRepository.ts @@ -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(15_000, 64); + export type GameWithId = Game & { gameId: string }; /** @@ -37,11 +50,41 @@ export async function listByDate(date: DateString): Promise { } /** - * 동일 날짜의 `listByDate` 결과를 메모이즈하는 캐시. + * `listByDate`의 공유 캐시 래퍼. 요청 경로는 이쪽을 쓴다. + * + * 동일 날짜 동시 요청은 `MemCache`의 inflight 병합으로 단일 쿼리가 된다. + */ +export async function listByDateCached(date: DateString): Promise { + 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; @@ -54,7 +97,7 @@ export function createGameDayCache(): GameDayCache { listByDate(date: DateString): Promise { let p = cache.get(date); if (!p) { - p = listByDate(date); + p = listByDateCached(date); cache.set(date, p); } return p; diff --git a/src/services/gameSyncService.ts b/src/services/gameSyncService.ts index b1b626f..d13b0e4 100644 --- a/src/services/gameSyncService.ts +++ b/src/services/gameSyncService.ts @@ -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 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 }; } diff --git a/src/services/predictionService.ts b/src/services/predictionService.ts index d992580..e0ed74a 100644 --- a/src/services/predictionService.ts +++ b/src/services/predictionService.ts @@ -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 { 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); diff --git a/tests/repositories/gameRepository.test.ts b/tests/repositories/gameRepository.test.ts index 7afbcd1..9bfee5e 100644 --- a/tests/repositories/gameRepository.test.ts +++ b/tests/repositories/gameRepository.test.ts @@ -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 () => { diff --git a/tests/unit/memCache.test.ts b/tests/unit/memCache.test.ts new file mode 100644 index 0000000..c02c185 --- /dev/null +++ b/tests/unit/memCache.test.ts @@ -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(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(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(60_000); + const empty = new MemCache(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(-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(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(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); + }); +});