Document backend WRITE optimizations (W1-W6, doc-drift)

- 백엔드 WRITE 최적화 적용 결과를 improvements-backend-writes.md에 문서화
- W1~W6 및 문서 드리프트(Doc) 적용 내역 기록
This commit is contained in:
윤정민 2026-05-28 19:12:52 +09:00
parent e6de4d1568
commit 42826eafcc

View File

@ -0,0 +1,158 @@
# 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 도입 — 🔴 최대 효과
### 문제 (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회로 통합 — 🟠
### 문제 (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 제거 — 🟠
### 문제 (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 스냅샷을 일일 판정 트랜잭션에 통합 — 🟡
### 문제 (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 정리 — 🟡
### 문제 (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__` 캐시 일괄 무효화 중단 — 🟡
### 문제 (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 문서 드리프트 정정 — 🟢
### 문제 (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` 생성자 인자)도 명시.