Add narrative summaries to backend-write improvement report

- improvements-backend-writes.md 각 항목 제목 아래에 서사 요약 blockquote 추가
- W3·W4·W5+R6·W6·Doc 항목의 Before→Fix 흐름을 한 문장으로 정리
This commit is contained in:
윤정민 2026-05-28 19:33:29 +09:00
parent d23b0814d9
commit 11d9fa9b5f

View File

@ -19,6 +19,8 @@
## W1. `games` 월간 동기화에 getAll+diff 도입 — 🔴 최대 효과 ## W1. `games` 월간 동기화에 getAll+diff 도입 — 🔴 최대 효과
> **요약**: 기존에는 월간 동기화(`syncGamesForMonth`)가 한 달치 경기를 변경검사 없이 매일 `batch.set(merge:true)`로 무조건 재기록하는 방식이라 불변 데이터의 반복 write가 write 비용 1위였으므로, `getAll`+diff로 변경된 경기만 기록하는 방식으로 수정했다.
### 문제 (Before) ### 문제 (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`). `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`).
@ -44,6 +46,8 @@
## W2. `voteHistory` 이중 write를 1회로 통합 — 🟠 ## W2. `voteHistory` 이중 write를 1회로 통합 — 🟠
> **요약**: 기존에는 `dailyArchive`가 같은 `voteHistory/{date}` 문서를 본체의 `setDay``judgeDay``setDay`로 하루 2번 전체 덮어쓰는 방식이라 유저×매일 write가 2배였으므로, 선기록 `setDay`를 제거해 `judgeDay`를 유일한 writer로 두고 실패·멱등 경로에 복구 안전장치를 추가하는 방식으로 수정했다.
### 문제 (Before) ### 문제 (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`). `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`).
@ -68,6 +72,8 @@
## W3. 아카이브 리컨실의 경기별 stats 무효화 fan-out 제거 — 🟠 ## W3. 아카이브 리컨실의 경기별 stats 무효화 fan-out 제거 — 🟠
> **요약**: 기존에는 아카이브 리컨실(`reconcileDayVotes`)이 미판정 완료 경기마다 `processGameEndWithGame` 안에서 투표자 전원의 `/cache/stats/{uid}`를 무효화하는데, per-user 루프 끝에서도 유저별 1회 무효화가 일어나 여러 경기에 투표한 유저는 경기 수만큼 중복 fan-out되는 방식이었으므로, `skipInvalidate` 옵션으로 경기별 무효화를 건너뛰고 유저 단위 1회로 수렴시키는 방식으로 수정했다.
### 문제 (Before) ### 문제 (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`). `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`).
@ -85,6 +91,8 @@
## W4. rank 스냅샷을 일일 판정 트랜잭션에 통합 — 🟡 ## 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) ### 문제 (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`). `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`).
@ -106,6 +114,8 @@
## W5 + R6. `userService`의 불필요한 read/write 정리 — 🟡 ## W5 + R6. `userService`의 불필요한 read/write 정리 — 🟡
> **요약**: 기존에는 읽기 경로인 `getMe`가 토큰의 `picture`와 저장된 `photoUrl`을 문자열로 비교해 다르면 매 요청 `updateUser`로 write하고(쿼리스트링만 바뀌어도 hot write churn), `createMe`/`updateMe`가 write 직후 `getUser`로 다시 읽어 응답을 만드는 방식이라 불필요한 read/write가 있었으므로, `samePhotoUrl`로 쿼리스트링을 무시해 실제 사진 변경 시에만 write하고 2차 read 대신 응답을 합성하는 방식으로 수정했다.
### 문제 (Before) ### 문제 (Before)
- **W5**: `getMe`는 GET `/user`(읽기 경로)인데 토큰의 `picture`가 저장된 `photoUrl`과 문자열로 다르면 매 요청 `updateUser`로 write(`userService.ts:101-105`). Google 등은 같은 사진에도 쿼리스트링(`=s96-c` 크기 파라미터)을 매번 바꿔 내려주므로 **읽기 경로에서 hot write churn** 발생(관찰 #5 `:167`). - **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`). - **R6**: `createMe`/`updateMe`는 write 직후 `getUser`**다시 읽어** 응답을 만든다(`userService.ts:120` 부근) — 온보딩/프로필 수정마다 불필요한 Firestore read 1회(로드맵 R6 `optimization-plan.md:69`).
@ -129,6 +139,8 @@ photoUrl write 빈도(잠재적으로 user doc 2회/일 이상 → 변경 시에
## W6. `kboRefresh``game_detail__` 캐시 일괄 무효화 중단 — 🟡 ## W6. `kboRefresh``game_detail__` 캐시 일괄 무효화 중단 — 🟡
> **요약**: 기존에는 `kboDailyRefresh`(02:00 cron)가 응답 기반 동적 TTL을 가진 `game_detail__` 캐시까지 매일 prefix 전량 batch-delete 후 재생성해 cron 직후 첫 상세 조회들이 대량 캐시 미스 → 외부 KBO 재조회 폭주(stampede)를 유발했으므로, `game_detail__` 일괄 무효화 라인을 제거해 자체 TTL 자연 만료에 위임하는 방식으로 수정했다.
### 문제 (Before) ### 문제 (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`). `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`).
@ -145,6 +157,8 @@ photoUrl write 빈도(잠재적으로 user doc 2회/일 이상 → 변경 시에
## Doc. `CACHING.md` gameListCache 문서 드리프트 정정 — 🟢 ## Doc. `CACHING.md` gameListCache 문서 드리프트 정정 — 🟢
> **요약**: 기존에는 `CACHING.md`가 gameListCache에 대해 "사이즈 cap 100개(초과 시 oldest evict)"와 "`/metrics/gameListCache` RTDB hit/miss 카운터"처럼 실제 코드(`MemCache(10_000)``maxSize` 미전달, 메트릭 미구현)와 다른 내용을 기술해 코드↔문서 드리프트가 있었으므로, 투기적 코드 추가 대신 문서를 실제 동작에 일치시키는 방향으로 정정했다.
### 문제 (Before) ### 문제 (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`). `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`).