- 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건 신설
107 lines
3.0 KiB
TypeScript
107 lines
3.0 KiB
TypeScript
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);
|
|
});
|
|
});
|