mmday-firebase/docs/data-patterns/backend-reads.md
윤정민 e76b752a41 docs: add Firebase read/write pattern audit & optimization plan
- Firestore/RTDB read/write 패턴 전수 조사 결과를 문서화
- backend-writes.md, backend-reads.md, data-lifecycle.md, optimization-plan.md 추가
2026-05-28 17:10:19 +09:00

175 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 백엔드 READ 패턴 · 캐싱 (Panit Firebase Functions)
> 조사 범위: `src/` only (lib/·dist/ 제외). 모든 read 경로(Firestore / RTDB / 외부 KBO fetch / 캐시)를 추적한다.
> 작성일 2026-05-28. 인용은 `파일:라인` 기준.
## 데이터 저장소 개요
| 저장소 | 용도 |
|--------|------|
| **Firestore** | `users`, `users/{uid}/voteHistory`, `users/{uid}/attendance`, `users/{uid}/pointLedger`, `games`, `kboCache`, `kboLocks` |
| **RTDB** | `/votes/{gameId}`, `/userVotes/{uid}/{date}`, `/cache/stats/{uid}`, `/scoreboardCache/{date}`, `/nicknames`, `/userNicknames` |
| **외부 KBO** | `koreabaseball.com` (게임센터 JSON API + ASP.NET 스크레이핑) |
## 캐시 계층 3종 (+ RTDB 파생 캐시 2종)
1. **인메모리 (`src/lib/memCache.ts`)** — 인스턴스 단위 `Map`, TTL + promise coalescing. (gameList 10s, scoreboard scope/me-rank 30s, vote summary 5s)
2. **Firestore `kboCache` (`src/repositories/kboCacheRepository.ts`)** — 분산 락(`kboLocks`, 30s) 기반 공유 캐시. rank/player(1h 고정), schedule(동적 30s~7d), gameDetail(동적).
3. **scoreboardCache (`src/repositories/scoreboardCacheRepository.ts`)** — RTDB `/scoreboardCache`, 크론이 미리 계산해 채움(precompute).
4. (파생) **stats 캐시** — RTDB `/cache/stats/{uid}/{period}`, `forDate`로 무효화.
5. (파생) **vote counts** — RTDB `/votes/{gameId}/counts`.
---
## 1. KBO 외부 fetch (네트워크 read — 캐시가 보호하는 대상)
### 1-1. 게임센터 라이브 (`src/kbo/game-list.ts`)
- **Source**: `POST koreabaseball.com/ws/Main.asmx/GetKboGameList` (`fetchGameList` `game-list.ts:240`). 응답에 HTML 에러페이지가 붙어 와서 JSON 부분만 추출(`game-list.ts:263-266`).
- **Reader/Caller**: `services/gameListService.getGameList``MemCache(10_000)` (`gameListService.ts:22,39`).
- **트리거**: ① 클라 `GET /kbo/games` (게임센터 화면), ② `kboRepository.mergeLiveIntoSchedule`가 "오늘" 일정 조회 시 매번 호출(`kboRepository.ts:224`), ③ `gameSyncService.forceSyncDay`(크론/디버그, `gameSyncService.ts:129`).
- **Caching**: 인메모리 10s, 키 `${date}|${series}|${league}` (`gameListService.ts:37`). 히트 시 외부 호출 0. coalescing으로 동일 키 동시요청 1회 fetch.
- **효율 관찰**:
- ⚠️ `new MemCache(10_000)`**maxSize 미지정**`CACHING.md`가 명시한 "cap 100 evict"가 **실제 코드에 없다**(문서 드리프트). 키가 date·series·league 조합이라 사실상 소수지만 무제한 증가 가능.
- ⚠️ `CACHING.md``/metrics/gameListCache/{date}/{hit|miss}` RTDB 메트릭은 **코드에 구현돼 있지 않음**(문서만 존재).
- 인스턴스마다 캐시가 흩어짐(cold start 시 초기화) — 인스턴스 수 × 분당 최대 6회 외부 호출.
### 1-2. 팀 순위 (`src/kbo/team-rank.ts`)
- **Source**: ASP.NET 초기 GET + postback (`aspnet-client.ts:100,122`). 현재 연도는 초기 페이지 그대로, 과거 연도는 postback (`kboRepository.fetchSingleYearRank:60`).
- **Caller**: `rankService.getRank``kboRepository.fetchRankFromKbo` (`kboRepository.ts:42`) ← `GET /kbo/rank` (순위 화면).
- **Caching**: `kboCache` 1h 고정, 키 `rank__{year}` (`kboRepository.ts:47,32`). **연도별 개별 캐싱**.
- **효율**: 여러 연도 요청 시 `for` 루프로 연도마다 `getOrFetch` 순차 호출(`kboRepository.ts:46-52`) — 캐시 히트면 저렴하나, 동시 miss 시 직렬.
### 1-3. 선수 기록 (`src/kbo/player/*`)
- **Source**: ASP.NET 초기+postback, `allPages`면 페이지 끝까지 순회(`kboRepository.fetchPlayerFromKbo:457-462`).
- **Caller**: `playerService.getPlayerStats``GET /kbo/player` (선수 기록 화면).
- **Caching**: `kboCache` 1h 고정, 키 `player__{type}__{year}__{team}__{series}__{pos}__{situation}__{situationDetail}__{all|one}` (`kboRepository.ts:446`). 필터 조합 폭발 가능(키 다양).
### 1-4. 일정 (`src/kbo/schedule.ts`)
- **Source**: KBO 월 단위 응답 (`fetchSchedule`). → 아래 §2에서 일자 캐시로 분해.
### 1-5. 게임 상세 (`src/kbo/game-detail.ts`)
- **Source**: `fetchGameDetail` (scoreBoard/lineup/play-by-play).
- **Caller**: `gameDetailService.getGameDetail``GET /kbo/gameDetail` (경기 상세 화면).
- **Caching**: `kboCache` **동적 TTL**, 키 `game_detail__{gameId}` (`gameDetailService.ts:87`). `getOrFetchDynamic`로 응답 보고 TTL 결정 — 종료 7d / 라이브 30s / 시작전 발표전 라인업 10s, 그 외 시작시각까지 ≤1h (`gameDetailService.ts:29-49`).
---
## 2. 일정 캐시 (Firestore 일자 단위 + 동적 TTL) — `kboRepository.ts`
- **Source**: `kboCache` 문서, 키 `schedule_day__{YYYYMMDD}__{team}__{series}` (`kboRepository.ts:86-92`).
- **Reader**: `readDayDocs``firestore.getAll(...refs)` 배치 read (`kboRepository.ts:282-300`).
- **Caller**: `scheduleService.getSchedule``GET /kbo/schedule` (일정 화면). 클라가 `day`를 주면 단일일, 아니면 월 전체.
- **Query shape**: 문서 ID 직접 지정 → 인덱스 불필요. `getAll`은 1 round-trip이지만 **월 조회 시 ~30 doc read** (`fetchScheduleMonth:356-358`).
- **Caching/stale**:
- 동적 TTL `dayTtlMs` (`kboRepository.ts:124-168`): 어제 이전 7d(미종료 잔존 시 30s) / 오늘 진행중 30s / 오늘 시작전 min(1h, 시작까지) / 내일 6h / D+2+ 7d.
- 미스 시 **월 단위 락**(`schedule_month__...`) 획득 후 KBO 월 1회 fetch → 만료 일자 doc만 `setCached`(`kboRepository.ts:384-390`).
- 락 실패 요청은 500ms 간격 **최대 25s 폴링**(`kboRepository.ts:398-404`), 타임아웃 시 캐시 우회 직접 fetch.
- **라이브 병합**: 대상이 "오늘"이면 `mergeLiveIntoSchedule`가 §1-1 호출해 점수·상태 덮어씀(`kboRepository.ts:206-280`, 342, 417). 과거/미래는 캐시 그대로.
- **효율 관찰**:
- 단일일 미스 시 **월 전체를 fetch**(`fetchScheduleSingleDay:331-339`) 후 그 날짜만 재read — 미스 1건이 30일치 write 유발(단, 만료분만).
- 폴링 루프(최대 50회 × `readDayDocs` 30 doc) → 락 경합 시 **read 증폭**.
- `mergeLiveIntoSchedule``console.log`가 게임 수만큼 다수 — read는 아니나 로그 비용.
---
## 3. 스코어보드 read — `scoreboardService.ts`
- **Caller**: `GET /prediction/scoreboard?type=team|overall` (인증 필요, 랭킹 화면). `Cache-Control: private, max-age=60`.
- **읽는 것**:
1. `getUser(uid)` — Firestore `users/{uid}` 단일 doc, **요청마다 무캐시** (`scoreboardService.ts:54`).
2. scope top10/totalCount — `readScope(date, scope)` RTDB `/scoreboardCache/{date}/...` (`scoreboardService.ts:63-67`), 인메모리 `scopeCache` 30s로 감쌈. **miss이고 precompute 안 됨이면 503** (즉시계산 안 함).
3. 본인 rank — `meRankCache`(30s) miss 시 `countUsersAboveTierPoints(myPoints, teamCode)` count aggregation (`scoreboardService.ts:72-78`).
- **Query shape / 인덱스**:
- `listTopByTierPoints`(precompute에서만 사용): `where(favoriteTeamCode==)` + `orderBy(tierPoints desc)` + `limit` + `.select(...)``users[favoriteTeamCode ASC, tierPoints DESC]` 인덱스 사용(✓ `firestore.indexes.json:51`). overall(팀 없음)은 단일필드 자동.
- `countUsersAboveTierPoints`: `where(favoriteTeamCode==)` + `where(tierPoints>)``users[favoriteTeamCode ASC, tierPoints ASC]` 인덱스 사용(✓ `firestore.indexes.json:59`). count aggregation은 읽은 인덱스 엔트리당 과금(1000개당 1 read 근사).
- **효율 관찰**:
- `getUser`가 캐시 밖이라 랭킹 화면 폴링 시 매번 user doc read(전체 doc 페치). `.select`로 줄일 여지.
- `meRankCache` 키가 `{date}:{scope}:{uid}`**유저마다 별 키** → 활성 유저 수만큼 count aggregation. 30s 버스트 보호는 동일 유저 재요청에만 효과.
### precompute (크론) — `rankSnapshotService.precomputeScoreboardCache`
- overall + 10팀 = **11 scope** 각각 `listTopByTierPoints(10)` + `countRankedUsers` 병렬(`rankSnapshotService.ts:111-122,138`). 즉 11×(top10 쿼리 + count agg). `dailyArchive` finally에서 매일 호출(`dailyArchive.ts:163`).
- `snapshotRankForUser`: judged 유저마다 `getUser` + overall count agg + (응원팀 있으면) team count agg (`rankSnapshotService.ts:26-54`).
---
## 4. 통계 read — `statsService.getStats`
- **Caller**: `GET /stats` / `GET /stats/history` (통계·홈 화면, 인증).
- **캐시**: RTDB `/cache/stats/{uid}/{period}` (`statsService.ts:255`). `forDate===today`면 캐시 반환, 아니면 재계산 후 set(`statsService.ts:256-263`). `invalidateStats``/cache/stats/{uid}` **전체** 삭제(`statsService.ts:281`).
- **miss 시 `computeStats`**:
- `getAll(uid)``voteHistory` 서브컬렉션 **전체 스캔**(`orderBy(__name__)`, `voteHistoryRepository.ts:53`). 시즌 누적 시 doc 수 = 예측한 날 수(최대 ~180+).
- `getUser(uid)` 병렬 (`statsService.ts:166`).
- streak 보정: `lastJudged<어제`면 RTDB `/userVotes/{uid}/{yesterday}` read(`statsService.ts:200`), 거기서 또 `hasMissedGameDayBetween` → §6.
- **효율 관찰**:
- ⚠️ **가장 큰 read 비용 후보**: 캐시 무효화(경기 완료 시 `invalidateStats`) 후 다음 `/stats` 호출이 전체 `voteHistory` 풀스캔. 한 유저가 하루 여러 게임 완료를 겪으면 그 사이 stats 호출마다 풀스캔 재발(forDate 동일이라 set은 되지만 invalidate가 다시 비움).
- getStats가 모든 기간(overall/season/month/week/period)을 in-memory로 재집계 — read는 `getAll` 1회로 공유하므로 OK.
### history (`getHistory`)
- `voteHistoryRepository.getDay(uid, date)` 단일 doc(`statsService.ts:298`). 저렴.
---
## 5. 예측(투표) read — `predictionService.ts` / `voteRepository.ts`
| 경로 | Source | 캐시 |
|------|--------|------|
| `GET /prediction/games?date=` | Firestore `games` `where(time>=,<) orderBy(time)` (`gameRepository.listByDate:27-37`) | 없음 |
| `GET /prediction?date=` (내 투표) | RTDB `/userVotes/{uid}/{date}` (`voteRepository.getUserDateVotes:106`) | 없음 |
| `GET /prediction/summary?gameId=` | RTDB `/votes/{gameId}/counts` (`voteRepository.getCounts:23`) | 인메모리 `summaryCache` 5s (`predictionService.ts:16,112`) |
| `POST/PUT /prediction` | `getGame`(단일 doc) + `getUserVote`(RTDB) (`predictionService.ts:44,47,71`) | 없음, 쓰기 후 `summaryCache.delete` |
- **Query shape**: `listByDate``time` 범위+정렬 → 단일필드 `time` 자동 인덱스(범위+동일필드 orderBy라 복합 불필요).
- **효율**: `summary`만 캐시(5s). `games` 리스트는 무캐시지만 하루치(≤5~6경기)라 작음. `loadWaitingGame`이 POST/PUT마다 `getGame` 1 read — 정상.
---
## 6. 판정·아카이브 경로 (크론/트리거) — read 집약 구간
### `judgmentService.hasMissedGameDayBetween` (`judgmentService.ts:27-41`)
- `(lastJudged, upTo)` 구간을 **최대 14일 역순 루프**, 매일 `listByDate(cursor)` = `games` 범위쿼리 1회 → 최대 14 쿼리.
- 호출처: `getStats`(유저 요청 경로!) + `judgeDay`(아카이브).
### `judgmentService.judgeDay` (`judgmentService.ts:53-101`)
- `listByDate(date)` + `getUser` + `hasMissedGameDayBetween`(≤14 listByDate) + 트랜잭션 내 `tx.get(user)`.
### `dailyArchive.runDailyArchive` (`dailyArchive.ts:87-168`)
- ⚠️ `rtdb.ref("/userVotes").get()`**전체 userVotes 트리 1회 read**(모든 유저·모든 날짜, `dailyArchive.ts:96`). 타깃 날짜만 필요하나 전부 로드.
- 유저별 루프에서:
- `reconcileDayVotes`: 미판정 게임마다 `getGame`(N reads, `dailyArchive.ts:46`).
- `snapshotRankForUser`: getUser + 1~2 count agg.
- `judgeDay`: 위 참조.
- ⚠️ **유저 간 중복 read**: `judgeDay`/`hasMissedGameDayBetween`**유저마다 동일 날짜의 `listByDate`를 재실행**. U명 아카이브 시 같은 날 games 쿼리를 U×(1+최대14)회 반복 — 날짜별 1회 로드 후 공유하면 제거 가능.
### `gameResultService.processGameEndWithGame` (트리거 `onGameCompleted`)
- `getAllUserVotes(gameId)` RTDB `/votes/{gameId}/users` 1회(`gameResultService.ts:25`). 정상.
### `gameSyncService.forceSyncDay`
- `getGameList`(memcache) + `firestore.getAll(...refs)` 변경 게임만 비교 후 write(`gameSyncService.ts:145-160`). read 효율 양호(전 doc 배치 read 1회).
---
## 7. 유저/닉네임/출석 read
- **getMe** (`userService.ts:95`): `getUser` → 토큰 사진 다르면 `updateUser` 후 메모리값 갱신.
- ⚠️ **createMe** (`userService.ts:120-167`): `getUser`(존재체크) + `verifyReservation`(RTDB 2 read 병렬) + createUser + **다시 `getUser`**(생성물 재read). updateMe도 `getUser` + `findUidByDisplayName`(쿼리) + update + **다시 `getUser`**. 쓰기 후 재read 중복 — 반환값 합성으로 생략 가능.
- **닉네임**(`nicknameRepository.ts`): `reserveNickname` RTDB 트랜잭션, `verifyReservation`/`releaseReservation` 1~2 read. 정상.
- **출석**(`attendanceService.ts`): `checkIn`은 트랜잭션 내 `getMonthDocTx` + `getLatestBalanceTx`(orderBy createdAt desc, seq desc, limit1) — `pointLedger[createdAt DESC, seq DESC]` 인덱스 사용(✓ `firestore.indexes.json:67`). `getMonth`는 doc + balance 병렬. 모두 점 read, 효율 양호.
---
## 비용·최적화 관찰 (reads)
영향도 순. (대량 Firestore doc read / 반복 외부 fetch 우선)
1. **[높음] dailyArchive의 유저 간 `listByDate` 중복** — `dailyArchive.ts``judgeDay`/`hasMissedGameDayBetween`. 유저 U명 × 같은 날짜 `games` 쿼리 1+최대14회 반복. 날짜별로 한 번 로드해 in-memory 공유(또는 휴장일 판정 메모이즈)하면 **U배 → 1배**로 절감. 시즌·유저 증가 시 가장 빠르게 악화.
2. **[높음] `getStats` 캐시 무효화 후 voteHistory 풀스캔** — `invalidateStats``/cache/stats/{uid}` 전체를 비우므로, 경기 완료가 잦은 날 `getStats` 호출마다 `getAll(uid)`(서브컬렉션 전체 read, 시즌 누적 시 수십~수백 doc). 누적 집계(overall/season)는 별도 저장(증분 갱신)하고 weekly/streak만 재계산하도록 분리하면 read 급감.
3. **[중간] `dailyArchive``/userVotes` 전체 트리 read** — 타깃 날짜만 필요한데 전 유저·전 날짜 로드(`dailyArchive.ts:96`). 미아카이브 날짜가 쌓이면 단일 read가 비대해짐. shallow + 유저별 `/userVotes/{uid}/{date}` 조회 또는 인덱스 경로 도입 검토.
4. **[중간] 캐시 스탬피드 시 폴링 read 증폭** — `kboCacheRepository.waitForCache`(≤50회 getCached) · `getOrFetchDynamic`(≤50회) · schedule 월 폴링(≤50회 × 30 doc getAll). 락 경합·콜드스타트 동시요청에서 대기 요청마다 수십 read 발생. 폴링 간격/횟수 상향 또는 백오프로 완화.
5. **[중간] 스코어보드 `getUser` 무캐시 + per-uid count aggregation** — 랭킹 화면 폴링마다 user doc 전체 read(`.select` 미적용)와 유저별 `countUsersAboveTierPoints`. `me` 블록을 scopeCache처럼 짧게라도 묶거나, precompute 시 본인 rank 포함 검토.
6. **[낮음] gameList MemCache `maxSize` 미설정** — `CACHING.md`가 약속한 cap 100 evict가 코드에 없음(`gameListService.ts:22`). 키 수가 적어 실위험 낮으나 문서-코드 드리프트. 메트릭(`/metrics/gameListCache/...`)도 미구현.
7. **[낮음] 쓰기 후 재read 중복** — `createMe`/`updateMe`의 2차 `getUser`(`userService.ts:162,269`). 패치 결과를 메모리에서 합성해 1 read 절약 가능.
8. **[낮음] rank 다년도 순차 `getOrFetch`** — `fetchRankFromKbo` for 루프(`kboRepository.ts:46`). 캐시 히트면 무해, 동시 miss 시 직렬. 병렬화 여지.
### 인덱스 점검 결과
- 필요한 복합 인덱스 **모두 존재**: `users[favoriteTeamCode+tierPoints DESC]`, `users[favoriteTeamCode+tierPoints ASC+__name__]`, `pointLedger[createdAt DESC+seq DESC+__name__]` (`firestore.indexes.json`). 누락된 복합쿼리 없음. 단일필드(`games.time`, `users.displayName`, `voteHistory.__name__` 범위)는 자동 인덱스로 충분.