mmday-firebase/docs/data-patterns/improvements-backend-writes.md
윤정민 11d9fa9b5f Add narrative summaries to backend-write improvement report
- improvements-backend-writes.md 각 항목 제목 아래에 서사 요약 blockquote 추가
- W3·W4·W5+R6·W6·Doc 항목의 Before→Fix 흐름을 한 문장으로 정리
2026-05-28 19:33:29 +09:00

15 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 도입 — 🔴 최대 효과

요약: 기존에는 월간 동기화(syncGamesForMonth)가 한 달치 경기를 변경검사 없이 매일 batch.set(merge:true)로 무조건 재기록하는 방식이라 불변 데이터의 반복 write가 write 비용 1위였으므로, 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회로 통합 — 🟠

요약: 기존에는 dailyArchive가 같은 voteHistory/{date} 문서를 본체의 setDayjudgeDaysetDay로 하루 2번 전체 덮어쓰는 방식이라 유저×매일 write가 2배였으므로, 선기록 setDay를 제거해 judgeDay를 유일한 writer로 두고 실패·멱등 경로에 복구 안전장치를 추가하는 방식으로 수정했다.

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

요약: 기존에는 아카이브 리컨실(reconcileDayVotes)이 미판정 완료 경기마다 processGameEndWithGame 안에서 투표자 전원의 /cache/stats/{uid}를 무효화하는데, per-user 루프 끝에서도 유저별 1회 무효화가 일어나 여러 경기에 투표한 유저는 경기 수만큼 중복 fan-out되는 방식이었으므로, skipInvalidate 옵션으로 경기별 무효화를 건너뛰고 유저 단위 1회로 수렴시키는 방식으로 수정했다.

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

요약: 기존에는 dailyArchive가 판정 직전 snapshotRankForUserrankSnapshot을 별도 set한 뒤 applyDailyJudgmentTx가 같은 users/{uid} 문서를 다시 갱신해 cron run당 2 write였으나 스냅샷이 "판정 이전 rank"여야 해 단순 제거가 불가했으므로, read-only 계산부(computeRankSnapshot)를 분리해 트랜잭션 전에 판정 전 값을 계산·전달하고 판정 patch와 같은 tx.set(merge:true)로 병합해 1 write로 합치는 방식으로 수정했다.

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

요약: 기존에는 읽기 경로인 getMe가 토큰의 picture와 저장된 photoUrl을 문자열로 비교해 다르면 매 요청 updateUser로 write하고(쿼리스트링만 바뀌어도 hot write churn), createMe/updateMe가 write 직후 getUser로 다시 읽어 응답을 만드는 방식이라 불필요한 read/write가 있었으므로, samePhotoUrl로 쿼리스트링을 무시해 실제 사진 변경 시에만 write하고 2차 read 대신 응답을 합성하는 방식으로 수정했다.

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

요약: 기존에는 kboDailyRefresh(02:00 cron)가 응답 기반 동적 TTL을 가진 game_detail__ 캐시까지 매일 prefix 전량 batch-delete 후 재생성해 cron 직후 첫 상세 조회들이 대량 캐시 미스 → 외부 KBO 재조회 폭주(stampede)를 유발했으므로, game_detail__ 일괄 무효화 라인을 제거해 자체 TTL 자연 만료에 위임하는 방식으로 수정했다.

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

요약: 기존에는 CACHING.md가 gameListCache에 대해 "사이즈 cap 100개(초과 시 oldest evict)"와 "/metrics/gameListCache RTDB hit/miss 카운터"처럼 실제 코드(MemCache(10_000)maxSize 미전달, 메트릭 미구현)와 다른 내용을 기술해 코드↔문서 드리프트가 있었으므로, 투기적 코드 추가 대신 문서를 실제 동작에 일치시키는 방향으로 정정했다.

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