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:
parent
a153206391
commit
742d57c56d
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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[];
|
||||
}
|
||||
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user