diff --git a/src/kbo/CACHING.md b/src/kbo/CACHING.md new file mode 100644 index 0000000..47c08c4 --- /dev/null +++ b/src/kbo/CACHING.md @@ -0,0 +1,120 @@ +# KBO 캐싱 로직 + +KBO 외부 API 호출을 줄이기 위한 3계층 캐시 구조를 정리한다. + +## 계층 요약 + +| 대상 | 저장소 | TTL | 관련 파일 | +|------|--------|-----|-----------| +| 스케줄 (경기 일정) | Firestore `kboCache` | 동적 (30s ~ 7d) | `repositories/kboRepository.ts` | +| 게임센터 (라이브) | Functions 메모리 (Map) | 10s | `services/gameListService.ts` | +| 팀 순위 / 선수 기록 | Firestore `kboCache` | 1h 고정 | `repositories/kboRepository.ts` | + +공용 저수준 유틸: `repositories/kboCacheRepository.ts` (락·read·write·getOrFetch). + +--- + +## 1. 스케줄 — Firestore 일 단위 캐시 + 동적 TTL + +### 키 구조 +- `schedule_day__{YYYYMMDD}__{team}__{series}` — 일자별 1개 doc +- 락: `schedule_month__{YYYY}__{M}__{team}__{series}` — 월 단위 fetch 직렬화 + +### doc 스키마 +```ts +{ data: ScheduleGame[], updatedAt: Timestamp, ttlMs: number } +``` + +### 동적 TTL 규칙 (`dayTtlMs`) +| 일자 | TTL | +|------|-----| +| 어제 이전 | 7d (단, 미종료 경기 남으면 30s — 자정 넘긴 연장 대응) | +| 오늘, 가장 이른 경기 시작 전 | `min(1h, earliestStart − now)` | +| 오늘, 경기 진행 중 | 30s | +| 오늘, 모든 경기 종료/취소 | 7d | +| 내일 (D+1) | 6h | +| D+2 이후 | 7d | + +- `earliestStart` = 그날 경기들의 `time` 중 최솟값 (주말·더블헤더 등 18:30 외 시작 대응) +- "모두 종료" 판정: `status === "completed" || "cancelled"` + +### 조회 흐름 + +**월 조회** (`?year=&month=`): +1. 해당 월의 모든 일자 키를 병렬 read +2. 모두 유효 → 합쳐 반환 +3. 하나라도 만료/없음 → 월 단위 lock 획득 후 KBO 월 API 1회 호출 +4. 응답을 일자별로 그룹화, **만료된 일자 doc만** set (유효한 doc은 쓰지 않음) +5. 합쳐 반환 + +**일 조회** (`?year=&month=&day=`): +1. 해당 일자 doc 1개 read (Firestore read **1회**) +2. 유효 → 반환 +3. 만료/없음 → 월 흐름 재사용 (외부 1회 + 일자별 set) → 해당 일자만 재read하여 반환 + +### 비용 효과 +- 일 조회: 30 reads → **1 read** (월 대비 1/30). +- 경기 중에도 오늘 doc만 30s 주기로 갱신. Firestore write는 10s~30s당 1건. + +--- + +## 2. 게임센터 — 메모리 캐시 (10초) + +### 저장 +Functions 인스턴스의 `Map`. +- 키: `${date}|${series}|${league}` +- TTL: 10초 +- 사이즈 cap: 100개 (초과 시 가장 오래된 항목 evict) + +### 조회 흐름 +1. `memGet(key)` — TTL 이내면 즉시 반환 (Firestore/외부 API 호출 0) +2. 미스 → `fetchGameList()` 호출 → `memSet()` + +### 특성 +- **라이브 데이터 특성에 맞춘 짧은 TTL**. 10초 이내 재요청은 외부 호출 안 나감. +- 인스턴스별로 캐시가 흩어짐. 인스턴스가 cold start되면 캐시 초기화. +- CLI는 서비스 레이어를 우회하므로 캐시 적용 안 됨 (HTTP `/kbo/games`만 적용). + +### 메트릭 +`/metrics/gameListCache/{YYYYMMDD}/{hit|miss}` (RTDB). +`ServerValue.increment(1)`로 원자적 증가, fire-and-forget (오류 무시). + +--- + +## 3. 팀 순위 / 선수 기록 — Firestore 1시간 캐시 + +`getOrFetch(key, ttlMs, fetcher)`로 처리. 기존 단순 TTL 캐시. + +- 키: `rank__{year}`, `player__{type}__{year}__...` +- TTL: **1시간 고정** +- 파일: `repositories/kboRepository.ts`의 `fetchRankFromKbo`, `fetchPlayerFromKbo` + +동적 TTL 대상이 아님 — 두 데이터는 하루 단위로 갱신되므로 1시간이면 충분. + +--- + +## 공용 인프라 (`kboCacheRepository.ts`) + +### `getOrFetch(key, ttlMs, fetcher)` +캐시 우선 → 없으면 락 획득 후 `fetcher` 실행 → 결과 저장. 락 실패 시 다른 요청이 채운 캐시를 최대 25초 폴링 후 폴백 fetch. + +### 분산 락 +- 컬렉션: `kboLocks` +- TTL: 30초 (만료된 락은 트랜잭션 내에서 자동 갱신) +- `acquireLock` / `releaseLock` export됨 + +### 일일 무효화 크론 (`scheduled/kboRefresh.ts`) +매일 **02:00 KST** 실행. +- `invalidateByPrefix("rank__")` — 순위 전체 무효화 +- `invalidateByPrefix("schedule_day__")` — 스케줄 전체 무효화 +- 이후 현재 연도/월 prewarm (rank, schedule) + `syncGamesForMonth` 실행 + +이는 동적 TTL로 놓친 엣지 케이스(예: 장기간 trafficless인 키)를 위한 안전망. + +--- + +## 의도적 비캐싱 + +- **팀 순위/선수 기록의 연도별 분기** — 별 캐시 doc이라 문제 없음. +- **게임센터의 Firestore 캐시** — 라이브 데이터라 도입 안 함. 메모리 10s로 충분. +- **CLI 경로** — 프로세스가 매번 새로 뜨므로 메모리 캐시 의미 없음. Firestore 캐시는 CLI도 혜택. \ No newline at end of file