mmday-firebase/docs/data-patterns/improvements-backend-writes.md
윤정민 42826eafcc Document backend WRITE optimizations (W1-W6, doc-drift)
- 백엔드 WRITE 최적화 적용 결과를 improvements-backend-writes.md에 문서화
- W1~W6 및 문서 드리프트(Doc) 적용 내역 기록
2026-05-28 19:12:52 +09:00

12 KiB
Raw Blame History

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

대상 브랜치: opt/backend-rw 근거 문서: backend-writes.md(감사) · optimization-plan.md(로드맵) 각 항목은 문제(Before) → 수정(Fix) → 개선(After) 순으로, 감사 문서의 path:line과 실제 커밋 diff를 근거로 기술한다. 공통 원칙: 비용(read/write)을 줄이되 관찰 가능한 결과(판정/포인트/스트릭/투표 카운트)와 정확성 안전장치는 보존한다.

항목 커밋 핵심 효과 영향도
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 도입 — 🔴 최대 효과

문제 (Before)

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회로 통합 — 🟠

문제 (Before)

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 제거 — 🟠

문제 (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)

커밋 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 스냅샷을 일일 판정 트랜잭션에 통합 — 🟡

문제 (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 정리 — 🟡

문제 (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__ 캐시 일괄 무효화 중단 — 🟡

문제 (Before)

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 문서 드리프트 정정 — 🟢

문제 (Before)

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 생성자 인자)도 명시.