mmday-firebase/docs/data-patterns/improvements-backend-writes.md
윤정민 05b774a713 Expand backend-write improvement report for non-expert readability
- improvements-backend-writes.md 각 항목에 배경 단락 및 평이한 설명 추가
- 비전문가도 이해할 수 있도록 비유·용어 괄호 설명 포함
- 문서 상단에 Firestore/RTDB/cron 등 핵심 용어 풀이 추가
2026-05-28 19:51:05 +09:00

27 KiB
Raw Permalink Blame History

Panit 백엔드 WRITE 최적화 개선 보고서

대상 브랜치: opt/backend-rw 근거 문서: backend-writes.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)

이제 저장하기 전에 데이터베이스에 있는 기존 경기들을 한 번에 읽어와, 새로 받아온 내용과 하나씩 비교한다. 달라진 경기만 저장하고, 바뀐 게 하나도 없으면 저장 자체를 건너뛴다.

커밋 b17ac4csrc/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가 건드리지 않아 비교 제외).
    • timeTimestamp이라 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)

이제 채점 전 초안 저장을 없애고, 채점이 끝난 결과만 한 번에 저장한다. 다만 도중에 채점이 실패하거나 작업이 중복 실행되는 경우에도 데이터가 유실되지 않도록, "결과를 저장한 다음에야 원본 투표를 정리"하고 "실패 시 원본 데이터만이라도 보존"하는 안전장치를 넣었다.

커밋 48a8f16src/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)

밀린 경기를 사후 처리할 때, 경기 하나를 처리할 때마다 그 경기 투표자 전원의 통계 캐시를 지웠다. 그런데 같은 작업의 마지막 단계에서 어차피 사용자별로 한 번씩 캐시를 정리한다. 그래서 하루에 세 경기에 투표한 사람이라면, 경기별로 세 번 + 마무리로 한 번, 같은 캐시를 여러 번 중복해서 지우는 낭비가 생겼다.

dailyArchivereconcileDayVotes는 미판정 완료 경기마다 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)를 추가하고, 캐시 정리는 작업 끝의 사용자당 한 번으로만 맡겼다. 실시간(라이브) 경기 종료 경로는 기존처럼 즉시 캐시를 지운다.

커밋 9a59755src/services/gameResultService.ts, src/scheduled/dailyArchive.ts:

  • processGameEndWithGameopts?: { 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했다: ① 판정 직전 snapshotRankForUserrankSnapshot을 별도 set(rankSnapshotService.ts:26-54) → ② applyDailyJudgmentTx가 같은 doc을 판정으로 갱신(userRepository.ts:182-254). 스냅샷은 "판정 이전 rank"여야 한다는 제약이 있어 단순 제거는 불가(backend-writes.md:24-25, 관찰 #4 :165).

수정 (Fix)

점수를 "계산만 하는" 부분을 저장 동작에서 떼어냈다. 채점에 들어가기 전에 현재(=채점 전) 점수를 미리 계산해 두었다가, 채점 결과를 저장하는 바로 그 한 번의 쓰기에 함께 끼워 넣어 단일 저장으로 합쳤다.

커밋 4ea1a77rankSnapshotService.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가 이를 applyDailyJudgmentTxinput.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)

주소 비교 시 ? 뒤의 꼬리표를 떼고 비교해, 진짜 사진이 바뀐 경우에만 저장하도록 바꿨다. 그리고 저장 후 다시 읽는 대신, 방금 적용한 값으로 응답을 직접 조립한다.

커밋 037baabsrc/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. kboRefreshgame_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)

경기 상세 캐시를 새벽에 통째로 지우는 동작을 없애고, 각 항목이 자기 유효 기간에 따라 알아서 만료되도록 맡겼다. 랭킹·일자별 일정 캐시는 신선도가 중요하므로 기존의 안전망 삭제 + 재조회 예열은 그대로 둔다.

커밋 50349e6src/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)" — 실제 gameListServicenew MemCache(10_000)maxSize를 주지 않음. ② "/metrics/gameListCache/{date}/{hit|miss} RTDB 카운터" — 실제 미구현. 코드↔문서 불일치(로드맵 Doc 항목 optimization-plan.md:72).

수정 (Fix)

없는 기능을 새로 코딩해 문서에 맞추는 대신, 문서를 실제 코드 동작에 맞게 고쳤다. 더불어 나중에 개수 제한이나 횟수 기록을 도입하고 싶을 때 어디를 손대면 되는지(정확한 후크 지점)도 함께 적어 두었다.

커밋 82198a6src/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 생성자 인자)도 명시.