Support daily schedule filtering and implement game list caching.

- KBO 일정 조회 기능에 일(day) 단위 필터링을 추가하여 API와 CLI에서 특정 날짜의 경기만 조회할 수 있도록 개선했습니다.
- 일 단위 일정 요청 시 캐시가 없으면 월간 데이터를 조회하여 캐시를 보충하는 효율적인 캐싱 전략을 도입했습니다.
- `gameListService`에 10초 TTL 기반의 메모리 캐시를 적용하여 외부 API 호출 빈도를 최적화했습니다.
- 캐시의 적중률(Hit/Miss)을 일자별로 RTDB에 기록하는 메트릭 수집 로직을 추가하여 모니터링 기반을 마련했습니다.
This commit is contained in:
윤정민 2026-04-14 17:05:43 +09:00
parent a153206391
commit 742d57c56d
6 changed files with 144 additions and 8 deletions

View File

@ -27,7 +27,7 @@ function parseIntParam(val: unknown, fallback: number): number {
}
// GET /kbo/rank?year=2026
// GET /kbo/schedule?year=2026&month=4&team=LG&series=정규
// GET /kbo/schedule?year=2026&month=4&day=14&team=LG&series=정규
// GET /kbo/player?type=hitter&year=2025&team=LG&series=7&all=true
// GET /kbo/games?date=20260414&series=regular&league=kbo
@ -58,6 +58,9 @@ export const kbo = onRequest(async (req, res) => {
const month = parseIntParam(req.query.month, now.getMonth() + 1);
const team = req.query.team ? String(req.query.team) : undefined;
const series = req.query.series ? String(req.query.series) : undefined;
const day = req.query.day !== undefined
? parseIntParam(req.query.day, NaN)
: undefined;
if (year < 1982 || year > 2100) {
res.status(400).json({ error: `Invalid year: ${year} (1982~2100)` });
@ -67,8 +70,12 @@ export const kbo = onRequest(async (req, res) => {
res.status(400).json({ error: `Invalid month: ${month} (1~12)` });
return;
}
if (day !== undefined && (isNaN(day) || day < 1 || day > 31)) {
res.status(400).json({ error: `Invalid day: ${req.query.day} (1~31)` });
return;
}
const result = await getSchedule(year, month, team, series);
const result = await getSchedule(year, month, team, series, day);
res.status(200).json(result);
return;
}

View File

@ -320,21 +320,36 @@ async function scheduleCommand(
month: number,
jsonMode: boolean,
team?: string,
series?: string
series?: string,
day?: number
) {
const monthStr = String(month).padStart(2, "0");
console.log(`Fetching KBO schedule for ${year}-${monthStr}...\n`);
const label = day != null
? `${year}-${monthStr}-${String(day).padStart(2, "0")}`
: `${year}-${monthStr}`;
console.log(`Fetching KBO schedule for ${label}...\n`);
const resolvedTeam = team ? (TEAM_CODES[team] ?? team) : undefined;
const resolvedSeries = series ? (SERIES_CODES[series] ?? series) : undefined;
const result = await fetchSchedule({
const monthResult = await fetchSchedule({
year,
month,
series: resolvedSeries,
team: resolvedTeam,
});
const result = day != null
? {
...monthResult,
day,
games: monthResult.games.filter((g) => {
const m = g.date.match(/^(\d{2})\.(\d{2})$/);
return m ? parseInt(m[2], 10) === day : false;
}),
}
: monthResult;
console.log(` ${result.games.length} games found`);
if (jsonMode) {
@ -441,6 +456,7 @@ function printHelp() {
console.log(" --situationDetail=값");
console.log("");
console.log("Schedule filters:");
console.log(" --day=일 특정 일자만 (1~31)");
console.log(" --team=팀명 팀 (LG, 삼성, KT, ...)");
console.log(" --series=값 시리즈 (정규, 시범, 포스트)");
console.log("");
@ -557,6 +573,7 @@ async function main() {
const now = new Date();
let year = now.getFullYear();
let month = now.getMonth() + 1;
let day: number | undefined;
let schedTeam: string | undefined;
let schedSeries: string | undefined;
@ -567,6 +584,13 @@ async function main() {
schedTeam = arg.slice("--team=".length);
} else if (arg.startsWith("--series=")) {
schedSeries = arg.slice("--series=".length);
} else if (arg.startsWith("--day=")) {
const d = parseInt(arg.slice("--day=".length), 10);
if (isNaN(d) || d < 1 || d > 31) {
console.error(`Invalid --day: ${arg} (1~31)`);
process.exit(1);
}
day = d;
} else {
const n = parseInt(arg, 10);
if (n >= 1 && n <= 12) {
@ -580,7 +604,7 @@ async function main() {
}
}
await scheduleCommand(year, month, jsonMode, schedTeam, schedSeries);
await scheduleCommand(year, month, jsonMode, schedTeam, schedSeries, day);
return;
}

View File

@ -36,6 +36,7 @@ export interface ScheduleGame {
export interface ScheduleFilters {
year: number;
month: number;
day?: number;
series?: string;
team?: string;
}
@ -43,6 +44,7 @@ export interface ScheduleFilters {
export interface ScheduleResult {
year: number;
month: number;
day?: number;
games: ScheduleGame[];
}

View File

@ -195,6 +195,45 @@ async function readDayDocs(keys: string[]): Promise<Map<string, ScheduleGame[] |
*/
export async function fetchScheduleFromKbo(
filters: ScheduleFilters
): Promise<ScheduleResult> {
if (filters.day != null) {
return fetchScheduleSingleDay(filters as ScheduleFilters & { day: number });
}
return fetchScheduleMonth(filters);
}
async function fetchScheduleSingleDay(
filters: ScheduleFilters & { day: number }
): Promise<ScheduleResult> {
const ymd =
`${filters.year}` +
`${String(filters.month).padStart(2, "0")}` +
`${String(filters.day).padStart(2, "0")}`;
const key = dayKey(ymd, filters.team, filters.series);
let cached = (await readDayDocs([key])).get(key) ?? null;
if (cached === null) {
// 미스 → 월 단위 외부 fetch 흐름이 캐시를 채우도록 호출. 결과는 사용하지 않음.
await fetchScheduleMonth({
year: filters.year,
month: filters.month,
team: filters.team,
series: filters.series,
});
cached = (await readDayDocs([key])).get(key) ?? [];
}
return {
year: filters.year,
month: filters.month,
day: filters.day,
games: cached,
};
}
async function fetchScheduleMonth(
filters: ScheduleFilters
): Promise<ScheduleResult> {
const allDays = enumerateMonthDays(filters.year, filters.month);
const keys = allDays.map((d) => dayKey(d, filters.team, filters.series));

View File

@ -1,3 +1,5 @@
import { ServerValue } from "firebase-admin/database";
import { rtdb } from "../firebase.js";
import {
fetchGameList,
LEAGUE_CODES,
@ -6,6 +8,52 @@ import {
type GameListResult,
} from "../kbo/game-list.js";
const MEM_TTL_MS = 10_000;
const MEM_MAX = 100;
interface MemEntry {
data: GameListResult;
expiresAt: number;
}
const memCache = new Map<string, MemEntry>();
function memGet(key: string): GameListResult | null {
const e = memCache.get(key);
if (!e) return null;
if (Date.now() > e.expiresAt) {
memCache.delete(key);
return null;
}
return e.data;
}
function todayYmd(): string {
const n = new Date();
return `${n.getFullYear()}${String(n.getMonth() + 1).padStart(2, "0")}${String(n.getDate()).padStart(2, "0")}`;
}
/**
* hit/miss RTDB에 .
*
* : `/metrics/gameListCache/{YYYYMMDD}/{hit|miss}` .
* await fire-and-forget으로 ,
* ( ).
*/
function recordCacheMetric(kind: "hit" | "miss"): void {
rtdb
.ref(`metrics/gameListCache/${todayYmd()}/${kind}`)
.set(ServerValue.increment(1))
.catch(() => undefined);
}
function memSet(key: string, data: GameListResult): void {
if (memCache.size >= MEM_MAX) {
const oldest = memCache.keys().next().value;
if (oldest !== undefined) memCache.delete(oldest);
}
memCache.set(key, { data, expiresAt: Date.now() + MEM_TTL_MS });
}
export async function getGameList(
date: string | Date,
series?: string,
@ -16,9 +64,23 @@ export async function getGameList(
? (LEAGUE_CODES[league] ?? (league as LeagueCode))
: undefined;
return fetchGameList({
const dateStr = typeof date === "string"
? date
: `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, "0")}${String(date.getDate()).padStart(2, "0")}`;
const key = `${dateStr}|${resolvedSeries ?? ""}|${resolvedLeague ?? ""}`;
const hit = memGet(key);
if (hit) {
recordCacheMetric("hit");
return hit;
}
recordCacheMetric("miss");
const result = await fetchGameList({
date,
series: resolvedSeries,
league: resolvedLeague,
});
memSet(key, result);
return result;
}

View File

@ -6,7 +6,8 @@ export async function getSchedule(
year: number,
month: number,
team?: string,
series?: string
series?: string,
day?: number
): Promise<ScheduleResult> {
const resolvedTeam = team ? (TEAM_CODES[team] ?? team) : undefined;
const resolvedSeries = series ? (SERIES_CODES[series] ?? series) : undefined;
@ -14,6 +15,7 @@ export async function getSchedule(
return fetchScheduleFromKbo({
year,
month,
day,
series: resolvedSeries,
team: resolvedTeam,
});