# 백엔드 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__` 범위)는 자동 인덱스로 충분.