From 742d57c56d8db8fc617732f2833c23211dc8a1e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Tue, 14 Apr 2026 17:05:43 +0900 Subject: [PATCH] Support daily schedule filtering and implement game list caching. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - KBO 일정 조회 기능에 일(day) 단위 필터링을 추가하여 API와 CLI에서 특정 날짜의 경기만 조회할 수 있도록 개선했습니다. - 일 단위 일정 요청 시 캐시가 없으면 월간 데이터를 조회하여 캐시를 보충하는 효율적인 캐싱 전략을 도입했습니다. - `gameListService`에 10초 TTL 기반의 메모리 캐시를 적용하여 외부 API 호출 빈도를 최적화했습니다. - 캐시의 적중률(Hit/Miss)을 일자별로 RTDB에 기록하는 메트릭 수집 로직을 추가하여 모니터링 기반을 마련했습니다. --- src/handlers/kboHandlers.ts | 11 +++++- src/kbo/cli.ts | 32 ++++++++++++++-- src/kbo/schedule.ts | 2 + src/repositories/kboRepository.ts | 39 +++++++++++++++++++ src/services/gameListService.ts | 64 ++++++++++++++++++++++++++++++- src/services/scheduleService.ts | 4 +- 6 files changed, 144 insertions(+), 8 deletions(-) diff --git a/src/handlers/kboHandlers.ts b/src/handlers/kboHandlers.ts index a0b0d41..390a139 100644 --- a/src/handlers/kboHandlers.ts +++ b/src/handlers/kboHandlers.ts @@ -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; } diff --git a/src/kbo/cli.ts b/src/kbo/cli.ts index 28dc853..87dbeff 100644 --- a/src/kbo/cli.ts +++ b/src/kbo/cli.ts @@ -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; } diff --git a/src/kbo/schedule.ts b/src/kbo/schedule.ts index 82da838..673140a 100644 --- a/src/kbo/schedule.ts +++ b/src/kbo/schedule.ts @@ -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[]; } diff --git a/src/repositories/kboRepository.ts b/src/repositories/kboRepository.ts index 64cf84e..9556cc6 100644 --- a/src/repositories/kboRepository.ts +++ b/src/repositories/kboRepository.ts @@ -195,6 +195,45 @@ async function readDayDocs(keys: string[]): Promise { + if (filters.day != null) { + return fetchScheduleSingleDay(filters as ScheduleFilters & { day: number }); + } + return fetchScheduleMonth(filters); +} + +async function fetchScheduleSingleDay( + filters: ScheduleFilters & { day: number } +): Promise { + 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 { const allDays = enumerateMonthDays(filters.year, filters.month); const keys = allDays.map((d) => dayKey(d, filters.team, filters.series)); diff --git a/src/services/gameListService.ts b/src/services/gameListService.ts index 8179722..b81b1da 100644 --- a/src/services/gameListService.ts +++ b/src/services/gameListService.ts @@ -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(); + +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; } diff --git a/src/services/scheduleService.ts b/src/services/scheduleService.ts index 38c7676..b9b19cb 100644 --- a/src/services/scheduleService.ts +++ b/src/services/scheduleService.ts @@ -6,7 +6,8 @@ export async function getSchedule( year: number, month: number, team?: string, - series?: string + series?: string, + day?: number ): Promise { 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, });