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

173 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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)을 줄이되 **관찰 가능한 결과(판정/포인트/스트릭/투표 카운트)와 정확성 안전장치는 보존**한다.
| 항목 | 커밋 | 핵심 효과 | 영향도 |
|---|---|---|---|
| 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)
커밋 `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회로 통합 — 🟠
> **요약**: 기존에는 `dailyArchive`가 같은 `voteHistory/{date}` 문서를 본체의 `setDay`와 `judgeDay`의 `setDay`로 하루 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)
커밋 `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 제거 — 🟠
> **요약**: 기존에는 아카이브 리컨실(`reconcileDayVotes`)이 미판정 완료 경기마다 `processGameEndWithGame` 안에서 투표자 전원의 `/cache/stats/{uid}`를 무효화하는데, per-user 루프 끝에서도 유저별 1회 무효화가 일어나 여러 경기에 투표한 유저는 경기 수만큼 중복 fan-out되는 방식이었으므로, `skipInvalidate` 옵션으로 경기별 무효화를 건너뛰고 유저 단위 1회로 수렴시키는 방식으로 수정했다.
### 문제 (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)
커밋 `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 스냅샷을 일일 판정 트랜잭션에 통합 — 🟡
> **요약**: 기존에는 `dailyArchive`가 판정 직전 `snapshotRankForUser`로 `rankSnapshot`을 별도 `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**했다: ① 판정 직전 `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 정리 — 🟡
> **요약**: 기존에는 읽기 경로인 `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)
커밋 `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__` 캐시 일괄 무효화 중단 — 🟡
> **요약**: 기존에는 `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)
커밋 `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`가 gameListCache에 대해 "사이즈 cap 100개(초과 시 oldest evict)"와 "`/metrics/gameListCache` RTDB hit/miss 카운터"처럼 실제 코드(`MemCache(10_000)`로 `maxSize` 미전달, 메트릭 미구현)와 다른 내용을 기술해 코드↔문서 드리프트가 있었으므로, 투기적 코드 추가 대신 문서를 실제 동작에 일치시키는 방향으로 정정했다.
### 문제 (Before)
`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` 생성자 인자)도 명시.