- Firestore/RTDB read/write 패턴 전수 조사 결과를 문서화 - backend-writes.md, backend-reads.md, data-lifecycle.md, optimization-plan.md 추가
16 KiB
16 KiB
백엔드 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종)
- 인메모리 (
src/lib/memCache.ts) — 인스턴스 단위Map, TTL + promise coalescing. (gameList 10s, scoreboard scope/me-rank 30s, vote summary 5s) - Firestore
kboCache(src/repositories/kboCacheRepository.ts) — 분산 락(kboLocks, 30s) 기반 공유 캐시. rank/player(1h 고정), schedule(동적 30s~7d), gameDetail(동적). - scoreboardCache (
src/repositories/scoreboardCacheRepository.ts) — RTDB/scoreboardCache, 크론이 미리 계산해 채움(precompute). - (파생) stats 캐시 — RTDB
/cache/stats/{uid}/{period},forDate로 무효화. - (파생) 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(fetchGameListgame-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:
kboCache1h 고정, 키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:
kboCache1h 고정, 키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.
- 동적 TTL
- 라이브 병합: 대상이 "오늘"이면
mergeLiveIntoSchedule가 §1-1 호출해 점수·상태 덮어씀(kboRepository.ts:206-280, 342, 417). 과거/미래는 캐시 그대로. - 효율 관찰:
- 단일일 미스 시 월 전체를 fetch(
fetchScheduleSingleDay:331-339) 후 그 날짜만 재read — 미스 1건이 30일치 write 유발(단, 만료분만). - 폴링 루프(최대 50회 ×
readDayDocs30 doc) → 락 경합 시 read 증폭. mergeLiveIntoSchedule에console.log가 게임 수만큼 다수 — read는 아니나 로그 비용.
- 단일일 미스 시 월 전체를 fetch(
3. 스코어보드 read — scoreboardService.ts
- Caller:
GET /prediction/scoreboard?type=team|overall(인증 필요, 랭킹 화면).Cache-Control: private, max-age=60. - 읽는 것:
getUser(uid)— Firestoreusers/{uid}단일 doc, 요청마다 무캐시 (scoreboardService.ts:54).- scope top10/totalCount —
readScope(date, scope)RTDB/scoreboardCache/{date}/...(scoreboardService.ts:63-67), 인메모리scopeCache30s로 감쌈. miss이고 precompute 안 됨이면 503 (즉시계산 안 함). - 본인 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).dailyArchivefinally에서 매일 호출(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는
getAll1회로 공유하므로 OK.
- ⚠️ 가장 큰 read 비용 후보: 캐시 무효화(경기 완료 시
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마다getGame1 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}/users1회(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):reserveNicknameRTDB 트랜잭션,verifyReservation/releaseReservation1~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 우선)
- [높음] dailyArchive의 유저 간
listByDate중복 —dailyArchive.ts→judgeDay/hasMissedGameDayBetween. 유저 U명 × 같은 날짜games쿼리 1+최대14회 반복. 날짜별로 한 번 로드해 in-memory 공유(또는 휴장일 판정 메모이즈)하면 U배 → 1배로 절감. 시즌·유저 증가 시 가장 빠르게 악화. - [높음]
getStats캐시 무효화 후 voteHistory 풀스캔 —invalidateStats가/cache/stats/{uid}전체를 비우므로, 경기 완료가 잦은 날getStats호출마다getAll(uid)(서브컬렉션 전체 read, 시즌 누적 시 수십~수백 doc). 누적 집계(overall/season)는 별도 저장(증분 갱신)하고 weekly/streak만 재계산하도록 분리하면 read 급감. - [중간]
dailyArchive의/userVotes전체 트리 read — 타깃 날짜만 필요한데 전 유저·전 날짜 로드(dailyArchive.ts:96). 미아카이브 날짜가 쌓이면 단일 read가 비대해짐. shallow + 유저별/userVotes/{uid}/{date}조회 또는 인덱스 경로 도입 검토. - [중간] 캐시 스탬피드 시 폴링 read 증폭 —
kboCacheRepository.waitForCache(≤50회 getCached) ·getOrFetchDynamic(≤50회) · schedule 월 폴링(≤50회 × 30 doc getAll). 락 경합·콜드스타트 동시요청에서 대기 요청마다 수십 read 발생. 폴링 간격/횟수 상향 또는 백오프로 완화. - [중간] 스코어보드
getUser무캐시 + per-uid count aggregation — 랭킹 화면 폴링마다 user doc 전체 read(.select미적용)와 유저별countUsersAboveTierPoints.me블록을 scopeCache처럼 짧게라도 묶거나, precompute 시 본인 rank 포함 검토. - [낮음] gameList MemCache
maxSize미설정 —CACHING.md가 약속한 cap 100 evict가 코드에 없음(gameListService.ts:22). 키 수가 적어 실위험 낮으나 문서-코드 드리프트. 메트릭(/metrics/gameListCache/...)도 미구현. - [낮음] 쓰기 후 재read 중복 —
createMe/updateMe의 2차getUser(userService.ts:162,269). 패치 결과를 메모리에서 합성해 1 read 절약 가능. - [낮음] rank 다년도 순차
getOrFetch—fetchRankFromKbofor 루프(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__범위)는 자동 인덱스로 충분.