윤정민 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

16 KiB
Raw Permalink Blame History

백엔드 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.getGameListMemCache(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.getRankkboRepository.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.getPlayerStatsGET /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.getGameDetailGET /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: readDayDocsfirestore.getAll(...refs) 배치 read (kboRepository.ts:282-300).
  • Caller: scheduleService.getScheduleGET /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 증폭.
    • mergeLiveIntoScheduleconsole.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: listByDatetime 범위+정렬 → 단일필드 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.tsjudgeDay/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 다년도 순차 getOrFetchfetchRankFromKbo 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__ 범위)는 자동 인덱스로 충분.