# Panit 백엔드 WRITE 최적화 개선 보고서 > 대상 브랜치: `opt/backend-rw` > 근거 문서: [`backend-writes.md`](./backend-writes.md)(감사) · [`optimization-plan.md`](./optimization-plan.md)(로드맵) > 각 항목은 **문제(Before) → 수정(Fix) → 개선(After)** 순으로, 감사 문서의 `path:line`과 실제 커밋 diff를 근거로 기술한다. > 공통 원칙: 비용(read/write)을 줄이되 **관찰 가능한 결과(판정/포인트/스트릭/투표 카운트)와 정확성 안전장치는 보존**한다. > **이 문서를 읽는 분께**: 아래 항목들은 Panit 앱의 "서버"가 데이터를 **저장(write)**·**조회(read)**하는 횟수를 줄여 비용을 아낀 개선들이다. 자주 나오는 용어를 먼저 풀어 둔다. > - **Firestore**: 구글 클라우드 데이터베이스. 데이터를 "문서(document)" 단위로 저장하며, **문서를 읽거나 쓸 때마다 횟수에 비례해 요금이 부과**된다. 그래서 "꼭 필요할 때만 쓰기"가 비용 절감의 핵심이다. > - **write / read**: write는 데이터를 저장(쓰기), read는 데이터를 조회(읽기). 보통 write가 read보다 비싸다. > - **cron(크론)**: 사람이 누르지 않아도 **정해진 시각에 서버가 자동 실행**하는 예약 작업. > - **RTDB(Realtime Database)**: Firebase의 실시간 데이터베이스. 여기서는 주로 "임시 계산 결과(캐시)"를 담는 용도로 쓴다. > - **캐시(cache) / TTL**: 매번 새로 계산하면 느리고 비싸니, 결과를 잠시 보관해 두고 재사용하는 것이 캐시. TTL(Time To Live)은 그 보관물의 "유효 기간"으로, 기간이 지나면 자동으로 버려진다. | 항목 | 커밋 | 핵심 효과 | 영향도 | |---|---|---|---| | W1 | `b17ac4c` | `games` 월간 전량 재기록 → 변경분만 (일일 write ≈ 0 수렴) | 🔴 최대 | | W2 | `48a8f16` | `voteHistory` 이중 write → 1회 (유저×매일 write 절반↓) | 🟠 | | W3 | `9a59755` | 아카이브 경기별 stats 무효화 fan-out 제거 (중복 제거) | 🟠 | | W4 | `4ea1a77` | `users/{uid}` cron run당 2 write → 1 write | 🟡 | | W5+R6 | `037baab` | `getMe` photoUrl hot-write 억제 + 쓰기 후 2차 read 제거 | 🟡 | | W6 | `50349e6` | `game_detail__` 캐시 일괄 무효화 중단 (미스 폭주 완화) | 🟡 | | Doc | `82198a6` | `CACHING.md` gameListCache 문서 드리프트 정정 | 🟢 | --- ## W1. `games` 월간 동기화에 getAll+diff 도입 — 🔴 최대 효과 > **요약**: Panit 서버는 매일 새벽, KBO 공식 홈페이지에서 이번 달 경기 일정을 받아와 데이터베이스에 저장한다(이 일정이 앱의 "오늘 경기" 화면이 된다). 그런데 **경기 일정은 한 번 정해지면 거의 바뀌지 않는데도**, 예전 방식은 변경 여부를 따지지 않고 *한 달치 전부를 매일 통째로 다시 저장*했다. 마치 책 내용이 그대로인데 매일 책 한 권을 통째로 새로 인쇄하는 셈이라, 데이터베이스 저장 요금(write 비용)에서 1위를 차지했다. 이번 수정으로 저장 직전에 기존 데이터를 한 번 읽어 **실제로 달라진 경기만 골라 저장**하도록 바꿔, 평소에는 거의 아무것도 쓰지 않게 됐다. ### 배경 `kboDailyRefresh`는 매일 새벽 2시에 자동 실행되는 서버 작업(cron — 정해진 시각에 자동 실행되는 예약 작업)이다. KBO 공식 홈페이지에서 이번 달 야구 경기 일정을 긁어와 Firestore(구글 클라우드 데이터베이스 — 데이터를 문서 단위로 저장하고 읽기·쓰기 횟수에 따라 비용이 발생한다)에 저장하며, 앱의 "오늘 경기" 화면이 바로 이 데이터를 기반으로 한다. Firestore는 문서 1건을 쓸 때마다 비용이 발생하므로, "안 바뀐 데이터를 다시 쓰는" 일을 줄이는 것이 곧 비용 절감이다. ### 문제 (Before) 경기 일정은 대부분 한 번 확정되면 그대로 유지되는 "불변 데이터"다. 그런데도 예전 방식은 매일 한 달치 경기 전부를 *바뀐 게 있는지 확인도 하지 않고* 무조건 다시 저장했다. 한 달이면 수십~150건이 넘는 경기를, 내용이 똑같아도 매일 새로 쓴 셈이다 — 그래서 서버 전체에서 가장 큰 저장 비용 항목이 됐다. `syncGamesForMonth`(`gameSyncService.ts:63-77`)는 `kboDailyRefresh` cron(매일 02:00)에서 한 달치 모든 경기(수십~150+건)를 **변경 여부 검사 없이** `firestore.batch()` + `set({merge:true})`로 무조건 재기록했다. 일정 데이터는 대부분 불변이므로, 사실상 매일 *변경 없는 데이터의 반복 write*가 발생 — 감사 문서 기준 **Firestore write 비용 1위 핫스팟**(`backend-writes.md:58-67`, 최적화 관찰 #1 `:159`). ### 수정 (Fix) 이제 저장하기 전에 데이터베이스에 있는 기존 경기들을 한 번에 읽어와, 새로 받아온 내용과 **하나씩 비교**한다. 달라진 경기만 저장하고, 바뀐 게 하나도 없으면 저장 자체를 건너뛴다. 커밋 `b17ac4c` — `src/services/gameSyncService.ts`: - 이미 존재하던 `forceSyncDay`의 read-then-diff 패턴을 월간 동기화에도 적용. - `result.games`를 gameId 기준 `Map`으로 dedupe(더블헤더 등 중복 시 last-wins, 기존 순차 set 의미 유지). - 대상 ref들을 `firestore.getAll(...refs)`로 한 번에 읽고, 새 `gameDocUnchanged(existing, doc)` 헬퍼로 비교. - `merge:true`이므로 **새 doc에 포함된 필드만** 비교(생략된 `winningTeamCode` 등은 merge가 건드리지 않아 비교 제외). - `time`은 `Timestamp`이라 `toMillis()`로 비교. - 변경된 문서만 `batch.set(merge:true)`, `count === 0`이면 commit 생략. 반환값 의미를 "upsert 개수" → "실제 write 개수"로 변경. ### 개선 (After) | | Before | After | |---|---|---| | 일일 write | 월 전체 매일 무조건 (~수십~150+건) | **변경분만** — 평시 거의 0 | | read | 없음 | `getAll` 1회(배치 read, write보다 저렴) | | 트리거 영향 | 없음 | 없음 — 값이 같으면 `onGameCompleted`는 애초에 점화되지 않음 | 신규 일정/시각 변경/상태 전이는 diff에서 변경으로 잡혀 그대로 반영된다. `merge:true` 의미와 수동 채움 필드(`winningTeamCode`) 보존도 유지. --- ## W2. `voteHistory` 이중 write를 1회로 통합 — 🟠 > **요약**: 매일 새벽, 서버는 어제 모든 사용자의 예측 결과를 채점해 "예측 결과" 기록 문서에 저장한다. 예전에는 이 문서를 **하루에 두 번** 통째로 덮어썼다 — 먼저 채점 전 데이터를 한 번 저장하고, 채점이 끝난 뒤 다시 한 번 저장했기 때문이다. 같은 종이에 초안을 적었다가 완성본으로 통째로 다시 쓰는 셈이라, 사용자 수만큼의 쓰기가 매일 2배로 들었다. 이번 수정으로 **채점이 끝난 결과만 한 번에 저장**하도록 바꾸되, 중간에 작업이 실패하더라도 데이터가 사라지지 않도록 복구 안전장치를 더했다. 결과적으로 저장 횟수는 절반으로 줄고, 사용자가 보는 채점 결과는 그대로다. ### 배경 `dailyArchive`는 매일 새벽 3시에 자동 실행되는 cron 작업이다. 어제 모든 사용자의 예측(투표) 결과를 채점해 `voteHistory/{날짜}` 문서에 저장하며, 이 문서가 앱의 "예측 결과" 화면과 각종 통계의 기반이 된다. 실제 채점 로직은 `judgeDay`라는 함수가 담당한다. ### 문제 (Before) 같은 결과 문서를 하루에 두 번 통째로 저장하는 게 문제였다. 한 번은 채점하기 전 "내가 무엇에 투표했는가" 데이터를 적고, 다시 한 번은 채점이 끝난 "맞았는가/틀렸는가"까지 합쳐 통째로 덮어썼다. 이 덮어쓰기는 활성 사용자 한 명당 매일 일어나므로, 사용자가 많을수록 불필요한 쓰기가 정확히 2배로 불어났다. `dailyArchive`는 활성 유저마다 같은 `voteHistory/{date}` 문서를 **하루 2번 full-set**했다: ① 본체에서 `setDay(uid,date,{data})`(`dailyArchive.ts:130`) → ② `judgeDay`가 판정 필드를 채워 `setDay` **재호출**(`judgmentService.ts:94`). `setDay`는 merge 없는 전체 덮어쓰기이므로 동일 doc을 2회 기록 — 활성 유저 N × 매일 × 2(`backend-writes.md:32-35`, 관찰 #2 `:161`). ### 수정 (Fix) 이제 채점 전 초안 저장을 없애고, **채점이 끝난 결과만 한 번에 저장**한다. 다만 도중에 채점이 실패하거나 작업이 중복 실행되는 경우에도 데이터가 유실되지 않도록, "결과를 저장한 다음에야 원본 투표를 정리"하고 "실패 시 원본 데이터만이라도 보존"하는 안전장치를 넣었다. 커밋 `48a8f16` — `src/scheduled/dailyArchive.ts`, `src/services/judgmentService.ts`: - `dailyArchive`에서 **선기록 `setDay` 제거** → 정상 경로에서 `judgeDay`가 유일한 writer. - 데이터 유실 방지를 위한 순서/복구 안전장치: - `judgeDay`가 doc을 영속화한 **뒤에야** `/userVotes/{uid}/{date}`를 remove(쓰기 전 삭제 금지). - `judgeDay`가 throw하면 `catch`에서 `setDay(uid,date,{data})`로 data만 보존(기존 실패 경로 동작 유지) 후 remove. - 멱등 guard-skip 경로(`tx.skippedByGuard`): `getDay`로 doc 존재를 확인해 **없을 때만** `setDay(voteDoc)`로 복원(앞선 run이 트랜잭션 커밋 후 voteHistory 기록 직전 크래시한 경우 대비). 정상 멱등 재실행에서는 이미 존재하므로 판정 필드를 덮어쓰지 않는다. ### 개선 (After) | | Before | After | |---|---|---| | 정상 경로 write | 2회 (data → 판정 포함) | **1회** (판정 포함 단일 set) | | 실패 경로 | data 1회 + 판정 실패 | data 1회 보존(동일 보장) | | 멱등 재실행 | 2번째 write 스킵됨 | 추가 write 0 (doc 존재 시) | 활성 유저 × 매일 기준 voteHistory write **절반 감소**. 판정 결과·data 유실 방지 의미는 그대로 유지. --- ## W3. 아카이브 리컨실의 경기별 stats 무효화 fan-out 제거 — 🟠 > **요약**: 경기가 끝나면 서버는 그 경기에 투표한 사람들의 "통계 임시 저장본(캐시)"을 지워, 다음에 볼 때 최신 결과로 다시 계산되게 한다. 그런데 새벽 채점 작업이 밀린 경기들을 뒤늦게 처리할 때, **경기마다** 투표자 전원의 캐시를 지우고, 작업 끝에 **사용자마다 또 한 번** 지웠다. 하루에 여러 경기에 투표한 사람은 경기 수만큼 같은 캐시를 중복으로 지우게 된 것이다. 어차피 작업 끝에 사용자별로 한 번씩 정리하므로, 경기별 중복 삭제는 불필요했다. 이번 수정으로 경기별 삭제를 건너뛰고 **사용자당 한 번**으로 합쳐, 같은 결과를 더 적은 작업으로 얻게 했다. ### 배경 경기가 끝나면 그 경기에 투표한 사람들의 통계 캐시를 삭제(무효화)해, 다음 조회 때 최신 결과로 다시 계산하도록 한다. 여기서 통계 임시 데이터는 `/cache/stats/{uid}` 경로의 Firebase Realtime Database(RTDB — 실시간 동기화용 데이터베이스, 여기서는 통계 캐시 보관용)에 저장되며, 이 캐시를 지우는 함수가 `invalidateStats`다. `dailyArchive`(새벽 채점) 안의 `reconcileDayVotes`는 라이브 처리에서 누락된 "이미 끝난 경기"를 사후에 마저 처리하는 단계다. ### 문제 (Before) 밀린 경기를 사후 처리할 때, 경기 하나를 처리할 때마다 그 경기 투표자 전원의 통계 캐시를 지웠다. 그런데 같은 작업의 마지막 단계에서 어차피 사용자별로 한 번씩 캐시를 정리한다. 그래서 하루에 세 경기에 투표한 사람이라면, 경기별로 세 번 + 마무리로 한 번, 같은 캐시를 여러 번 중복해서 지우는 낭비가 생겼다. `dailyArchive`의 `reconcileDayVotes`는 미판정 완료 경기마다 `processGameEndWithGame`을 호출하고, 그 안에서 `Promise.all(uids.map(invalidateStats))`로 **해당 경기 투표자 전원의 `/cache/stats/{uid}`를 무효화**(`gameResultService.ts:42`). 그런데 `dailyArchive`는 per-user 루프 끝에서 어차피 유저별로 1회 `invalidateStats`를 호출하므로, 경기별 fan-out은 **중복**이다. 하루 여러 경기에 투표한 유저는 경기 수만큼 중복 무효화(`backend-writes.md:143-151`, 관찰 #3 `:163`). ### 수정 (Fix) 사후 처리 단계에서는 경기별 캐시 삭제를 건너뛰는 선택지(`skipInvalidate`)를 추가하고, 캐시 정리는 작업 끝의 사용자당 한 번으로만 맡겼다. 실시간(라이브) 경기 종료 경로는 기존처럼 즉시 캐시를 지운다. 커밋 `9a59755` — `src/services/gameResultService.ts`, `src/scheduled/dailyArchive.ts`: - `processGameEndWithGame`에 `opts?: { skipInvalidate?: boolean }` 추가. `skipInvalidate`면 종료 후 `invalidateStats` fan-out을 건너뜀(투표 결과 주입·`deleteGameVotes` 등 나머지는 그대로). - `reconcileDayVotes`에서 `processGameEndWithGame(gameId, game, { skipInvalidate: true })`로 호출. - 라이브 `onGameCompleted` 트리거 경로는 기존대로 무효화 유지(opts 미전달). ### 개선 (After) - 아카이브에서 stats 무효화는 **유저 단위 1회**로 수렴 — 경기 수만큼의 중복 RTDB subtree remove 제거. - stale 위험 없음: `getStats`는 일자 `forDate` 롤오버 시 자체 무효화하며, 아카이브 루프 끝의 유저 1회 무효화도 그대로 동작. --- ## W4. rank 스냅샷을 일일 판정 트랜잭션에 통합 — 🟡 > **요약**: 채점으로 "내 랭킹 점수가 얼마나 올랐는지" 보여주려면, 채점 *직전*의 점수를 기록해 둬야 한다. 예전에는 이 기록을 사용자 프로필 문서에 따로 한 번 저장하고, 곧이어 채점 결과를 같은 문서에 또 한 번 저장해 — 매번 같은 문서에 두 번 썼다. 점수 기록은 "채점 전 값"이어야 해서 그냥 없앨 수는 없었다. 그래서 **점수를 계산만 하는 부분**과 **저장하는 부분**을 분리해, 채점 전 값을 미리 계산해 두었다가 채점 결과를 저장할 때 **한 번에 함께 저장**하도록 합쳤다. 의미는 그대로 두면서 저장 횟수만 절반으로 줄였다. ### 배경 `rankSnapshot`은 "채점 직전 내 랭킹 점수(tierPoints)가 X점이었다"는 기록으로, 나중에 "이번 채점으로 점수가 얼마나 올랐나"를 보여주는 데 필요하다. 이 기록과 채점 결과는 모두 `users/{uid}` 문서에 저장되는데, 이 문서는 Firestore에서 사용자 프로필과 게임 통계를 담는 핵심 문서다. 문제는 `dailyArchive`(새벽 채점)가 **같은 실행 안에서 이 한 문서에 두 번** 썼다는 점이다. ### 문제 (Before) 같은 사용자 문서를 한 번의 새벽 작업에서 두 번 저장했다. 먼저 "채점 전 점수" 기록을 따로 저장하고, 곧바로 채점 결과를 같은 문서에 저장했기 때문이다. 그렇다고 첫 번째 저장을 단순히 없앨 수는 없었는데, 기록해야 할 값이 반드시 "채점이 반영되기 전의 점수"여야 했기 때문이다. `dailyArchive`는 cron run당 같은 `users/{uid}` 문서를 **2회 write**했다: ① 판정 직전 `snapshotRankForUser`가 `rankSnapshot`을 별도 `set`(`rankSnapshotService.ts:26-54`) → ② `applyDailyJudgmentTx`가 같은 doc을 판정으로 갱신(`userRepository.ts:182-254`). 스냅샷은 "판정 **이전** rank"여야 한다는 제약이 있어 단순 제거는 불가(`backend-writes.md:24-25`, 관찰 #4 `:165`). ### 수정 (Fix) 점수를 "계산만 하는" 부분을 저장 동작에서 떼어냈다. 채점에 들어가기 전에 현재(=채점 전) 점수를 미리 계산해 두었다가, 채점 결과를 저장하는 바로 그 한 번의 쓰기에 함께 끼워 넣어 단일 저장으로 합쳤다. 커밋 `4ea1a77` — `rankSnapshotService.ts`, `dailyArchive.ts`, `judgmentService.ts`, `userRepository.ts`: - read-only 계산부를 `computeRankSnapshot(uid,date): RankSnapshot | null`로 분리(write 없음). 기존 `snapshotRankForUser`는 이를 호출 후 `set`하는 얇은 래퍼로 유지(기존 호출자/테스트 호환). - `dailyArchive`가 트랜잭션 **전에** `computeRankSnapshot`(현재=판정 전 tierPoints 기준)을 계산해 `judgeDay(uid, date, {data}, { gameCache, rankSnapshot })`로 전달. - `judgeDay`가 이를 `applyDailyJudgmentTx`의 `input.rankSnapshot`으로 넘기고, 트랜잭션이 판정 patch에 `rankSnapshot`을 **같은 `tx.set(merge:true)`로 병합**. ### 개선 (After) | | Before | After | |---|---|---| | `users/{uid}` write | cron run당 2회 (snapshot + 판정) | **1회** (단일 patch) | | guard-skip 경로 | snapshot write 발생 가능 | write 0 (stale 재스냅샷 회피) | "판정 전 rank" 의미는 트랜잭션 진입 전 현재 tierPoints로 계산하므로 보존. `tierPoints <= 0`이면 `null` 반환으로 스냅샷 미기록도 유지. --- ## W5 + R6. `userService`의 불필요한 read/write 정리 — 🟡 > **요약**: 사용자가 앱을 열 때마다 서버는 프로필을 내려준다. 이때 구글 로그인 토큰에 담긴 프로필 사진 주소를 저장된 주소와 비교하는데, 구글은 같은 사진에도 `?sz=96` 같은 크기 꼬리표를 매번 다르게 붙여 준다. 예전 코드는 이 꼬리표 차이까지 "사진이 바뀌었다"로 오해해, **단순히 프로필을 열기만 해도 매번 저장(write)** 이 일어났다 — 읽기만 하면 되는 화면이 매번 쓰기를 유발한 것이다. 또 회원가입·프로필 수정 때는 저장한 직후 같은 데이터를 **다시 읽어** 응답을 만들었다. 이번 수정으로 꼬리표를 무시하고 **사진이 실제로 바뀐 경우에만 저장**하고, 다시 읽는 대신 방금 저장한 값으로 응답을 바로 만들어 불필요한 읽기·쓰기를 없앴다. ### 배경 `getMe`는 사용자가 앱을 열거나 마이페이지를 볼 때 서버에서 프로필을 가져오는 API(`GET /user`)다. 구글 로그인 토큰에는 프로필 사진 URL이 들어 있는데, 구글은 같은 사진이라도 `?sz=96` 같은 크기 파라미터(쿼리스트링)를 매번 다르게 붙여서 내려준다. `createMe`/`updateMe`는 각각 회원가입·프로필 수정 API다. ### 문제 (Before) - 사진이 실제로는 그대로인데 주소 끝의 크기 꼬리표만 달라져도, 코드가 "바뀌었다"고 판단해 매 요청마다 프로필을 저장했다. 프로필을 **읽기만** 하면 되는 화면이 사실상 매번 **쓰기**를 일으킨 셈이다. 또 가입·수정 시에는 저장한 값을 굳이 데이터베이스에서 한 번 더 읽어와 응답을 만들어, 불필요한 읽기가 한 번씩 더 발생했다. - **W5**: `getMe`는 GET `/user`(읽기 경로)인데 토큰의 `picture`가 저장된 `photoUrl`과 문자열로 다르면 매 요청 `updateUser`로 write(`userService.ts:101-105`). Google 등은 같은 사진에도 쿼리스트링(`=s96-c` 크기 파라미터)을 매번 바꿔 내려주므로 **읽기 경로에서 hot write churn** 발생(관찰 #5 `:167`). - **R6**: `createMe`/`updateMe`는 write 직후 `getUser`로 **다시 읽어** 응답을 만든다(`userService.ts:120` 부근) — 온보딩/프로필 수정마다 불필요한 Firestore read 1회(로드맵 R6 `optimization-plan.md:69`). ### 수정 (Fix) 주소 비교 시 `?` 뒤의 꼬리표를 떼고 비교해, **진짜 사진이 바뀐 경우에만** 저장하도록 바꿨다. 그리고 저장 후 다시 읽는 대신, 방금 적용한 값으로 응답을 직접 조립한다. 커밋 `037baab` — `src/services/userService.ts`: - **W5**: `samePhotoUrl(a,b)` 헬퍼 추가 — `'?'` 앞부분만 비교해 쿼리스트링만 다르면 동일 사진으로 간주. `getMe`의 동기화 조건을 `tokenPhoto !== user.photoUrl` → `!samePhotoUrl(tokenPhoto, user.photoUrl)`로 변경 → **실질적 변경 시에만** write. - **R6**: 쓰기 후 2차 `getUser` 제거. - `updateMe`: 시작 시 읽은 `user`에 방금 적용한 patch(displayName/favoriteTeamCode/knowledgeLevel, null 해제 포함)만 반영해 응답 합성. - `createMe`: 방금 쓴 입력값으로 `UserProfile` 합성. `createdAt`은 저장본이 `serverTimestamp`이므로 응답엔 근사치 `Timestamp.now()`를 싣고, 이후 `getMe`가 저장본을 반영. ### 개선 (After) | | Before | After | |---|---|---| | `getMe` photoUrl write | 쿼리스트링만 바뀌어도 read마다 write | **실제 사진 변경 시에만** (평시 0) | | `createMe`/`updateMe` read | 쓰기 후 `getUser` 1회 | **0회** (응답 합성) | photoUrl write 빈도(잠재적으로 user doc 2회/일 이상 → 변경 시에만)와 온보딩/수정당 read 1회를 제거. 응답 정확성은 합성으로 유지(서버 timestamp 근사치만 예외, 후속 read에서 정정). --- ## W6. `kboRefresh`의 `game_detail__` 캐시 일괄 무효화 중단 — 🟡 > **요약**: 서버는 KBO 홈페이지에서 가져온 경기 상세(라인업, 타격 기록 등)를 임시 보관(캐시)해 둔다. 이 캐시는 종류별로 알아서 유효 기간(TTL)이 정해져 있어, 끝난 경기는 7일, 진행 중 경기는 30초처럼 자연스럽게 만료된다. 그런데 매일 새벽 정리 작업이 이 캐시를 **유효 기간과 상관없이 통째로 한꺼번에 삭제**해 버려, 새벽 작업 직후 경기 상세를 처음 보는 사용자들이 한꺼번에 "캐시 없음" 상태를 만나 외부 KBO 홈페이지로 조회가 폭주(stampede)했다. 이번 수정으로 경기 상세 캐시는 통째 삭제하지 않고 **각자의 유효 기간에 따라 자연 만료**되도록 맡겨, 새벽 직후의 조회 폭주를 없앴다. ### 배경 `kboCache`는 KBO 홈페이지에서 가져온 경기 상세(라인업, 타격 기록 등)를 Firestore에 임시 저장하는 캐시다. 각 항목은 유형에 따라 만료 시간(TTL — 캐시의 유효 기간, 지나면 자동 폐기)이 다르다(종료 경기 7일, 라이브 경기 30초 등). `kboDailyRefresh` cron이 매일 새벽 이 캐시를 정리한다. ### 문제 (Before) 경기 상세 캐시는 이미 종류별로 알맞은 유효 기간을 갖고 있어 그냥 두면 알아서 만료된다. 그런데도 새벽 정리 작업이 유효 기간을 무시하고 전부 한꺼번에 지워 버렸다. 그 결과 새벽 작업이 끝난 직후, 경기 상세를 보러 온 사용자들이 동시에 "캐시에 아무것도 없음"을 만나고, 서버가 외부 KBO 홈페이지에 한꺼번에 재조회를 날리는 폭주(stampede)가 벌어졌다. `kboDailyRefresh`(02:00 cron)는 `rank__`/`schedule_day__`/`game_detail__` prefix를 **전량 batch-delete** 후 재생성했다(`kboRefresh.ts:16-39`). `game_detail__`는 응답 기반 동적 TTL(종료 경기 7d, 라이브 30s 등)을 이미 갖는데도 매일 일괄 삭제되어, cron 직후 첫 상세 조회들이 **대량 캐시 미스 → 외부 KBO 재조회 폭주(stampede)**를 유발(`backend-writes.md:69-78`, 관찰 #6 `:169`). ### 수정 (Fix) 경기 상세 캐시를 새벽에 통째로 지우는 동작을 없애고, 각 항목이 자기 유효 기간에 따라 알아서 만료되도록 맡겼다. 랭킹·일자별 일정 캐시는 신선도가 중요하므로 기존의 안전망 삭제 + 재조회 예열은 그대로 둔다. 커밋 `50349e6` — `src/scheduled/kboRefresh.ts`: - `await invalidateByPrefix("game_detail__")` 라인 제거(주석으로 사유 명시). `game_detail__`는 자체 TTL 자연 만료에 위임. - `rank__`/`schedule_day__`의 안전망 무효화 + 재fetch warm-up은 그대로 유지. ### 개선 (After) - 02:00 직후 `game_detail` 콜드 미스 폭주 제거 — 외부 KBO 상세 재조회/재write가 TTL 만료 시점으로 자연 분산. - 종료 경기 상세(7d TTL 등)는 다음 cron까지 살아남아 불필요한 재생성 write도 감소. rank/schedule 신선도는 기존 warm-up으로 보장. --- ## Doc. `CACHING.md` gameListCache 문서 드리프트 정정 — 🟢 > **요약**: `CACHING.md`는 서버의 "경기 목록 임시 저장(메모리 캐시)"이 어떻게 동작하는지 설명하는 내부 문서다. 그런데 이 설명이 실제 코드와 어긋나 있었다 — 문서에는 "최대 100개만 보관하고 넘치면 오래된 것부터 버린다", "조회 적중/실패 횟수를 기록한다"고 적혀 있었지만, 실제 코드는 그런 개수 제한도, 횟수 기록도 구현하지 않았다. 이렇게 코드와 문서가 따로 노는 것을 "문서 드리프트"라 한다. 없던 기능을 코드에 억지로 새로 넣기보다, **문서를 실제 동작에 맞게 고쳐** 혼선을 없앴다. ### 배경 `CACHING.md`는 서버의 인메모리 게임 목록 캐시(`MemCache` — 서버 메모리에 잠깐 담아 두는 임시 저장소)의 동작을 설명하는 내부 문서다. 코드는 그대로인데 문서만 실제와 달라져 서로 어긋난 상태를 "문서 드리프트(documentation drift)"라고 부른다. ### 문제 (Before) 문서에는 실제로 코드에 없는 기능들이 마치 있는 것처럼 적혀 있었다. "최대 100개까지만 담고 넘치면 오래된 것부터 버린다"고 했지만 코드엔 그런 개수 상한이 없었고, "조회 성공/실패 횟수를 따로 기록한다"고 했지만 그 기록 기능도 구현돼 있지 않았다. 문서만 믿고 작업하면 잘못된 가정을 하게 되는 상황이었다. `src/kbo/CACHING.md`는 게임센터 메모리 캐시에 대해 **실제와 다른 내용**을 기술했다: ① "사이즈 cap 100개(초과 시 oldest evict)" — 실제 `gameListService`는 `new MemCache(10_000)`로 `maxSize`를 주지 않음. ② "`/metrics/gameListCache/{date}/{hit|miss}` RTDB 카운터" — 실제 미구현. 코드↔문서 불일치(로드맵 Doc 항목 `optimization-plan.md:72`). ### 수정 (Fix) 없는 기능을 새로 코딩해 문서에 맞추는 대신, 문서를 실제 코드 동작에 맞게 고쳤다. 더불어 나중에 개수 제한이나 횟수 기록을 도입하고 싶을 때 어디를 손대면 되는지(정확한 후크 지점)도 함께 적어 두었다. 커밋 `82198a6` — `src/kbo/CACHING.md`: - 사이즈 cap 항목을 "**미설정**, `MemCache(10_000)`은 `maxSize` 미전달, 만료 항목은 `set()` 시 정리되나 동시 보관 상한 없음(키 카디널리티=날짜×시리즈×리그로 작아 사실상 무해), 필요 시 생성자 2번째 인자로 `maxSize` 전달 시 LRU-ish evict"로 정정. - 조회 흐름을 실제 API(`get`/`set`, `getOrFetch`의 동일 키 coalesce)로 갱신. - 메트릭 항목을 "현재 미구현"으로 정정하고 도입 시 권장안(`ServerValue.increment` fire-and-forget)만 남김. ### 개선 (After) 투기적 코드 추가 대신 **문서를 실제 동작에 일치**시켜 드리프트 해소. 향후 cap/메트릭 도입 시의 정확한 후크 지점(`MemCache` 생성자 인자)도 명시.