Document the multi-layered caching architecture for KBO data.

- KBO 데이터 조회 성능 최적화를 위한 3계층 캐싱 전략 및 구조를 상세히 문서화했습니다.
- Firestore 기반의 일 단위 스케줄 캐시와 경기 상태에 따른 동적 TTL 적용 로직을 명시했습니다.
- 라이브 경기 정보 조회를 위한 10초 만료 메모리 캐시와 RTDB 기반의 메트릭 수집 방식을 설명했습니다.
- 분산 락을 활용한 동시성 제어 및 매일 새벽 수행되는 캐시 무효화와 프리워밍 과정을 포함했습니다.
This commit is contained in:
윤정민 2026-04-14 17:09:27 +09:00
parent 742d57c56d
commit 6145af7159

120
src/kbo/CACHING.md Normal file
View File

@ -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<string, { data, expiresAt }>`.
- 키: `${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도 혜택.