mmday-firebase/docs/data-patterns/backend-writes.md
윤정민 ffafca1338 Unify prediction ranking on baseball tier codes
- 티어 코드를 bronze~diamond에서 야구 테마 BW/PR/ST/AS/MVP로 교체 (임계값 0/100/300/700/1500 유지), 클라이언트 동기화 주석 추가
- 누적 예측 수 기반 레벨 시스템 제거 — levels.ts 삭제, /stats 응답의 currentLevel·progress 필드 제거
- 스코어보드 top/me 엔트리에 tierPoints 파생 tier 코드 포함, 배포 이전 생성 캐시는 응답 시점에 tier 보강
- 주간 마스터 티켓 잔재 주석과 문서 표의 tickets 항목 정리, 폐기된 레벨·티켓 언급 주석 정돈
- statsService 테스트를 새 티어 코드·필드 구성으로 갱신
2026-07-23 14:13:19 +09:00

181 lines
16 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 패턴 감사 (Firestore + RTDB)
> 범위: `src/` TypeScript 소스만 분석 (`lib/`, `dist/` 컴파일 산출물 제외).
> 백엔드는 모두 Firebase Admin SDK로 쓰기 때문에 보안 규칙을 우회한다.
> 함수 wiring: `src/index.ts` — region `asia-northeast3`, `maxInstances: 10`.
>
> **함수 종류 / 스케줄 (index.ts:6-15):**
> - HTTP `onRequest`: `kbo`, `user`, `prediction`, `stats`, `admin`, `debug`, `attendance`
> - 스케줄 `onSchedule`: `kboDailyRefresh` (`0 2 * * *` KST, 매일 02:00), `dailyArchive` (`0 3 * * *` KST, 매일 03:00)
> - Firestore 트리거: `onGameCompleted` — `onDocumentUpdated("games/{gameId}")`
---
## 1. Firestore 쓰기 대상
### 1.1 `users/{uid}` (유저 루트 문서)
| 항목 | 내용 |
|---|---|
| 주요 필드 | `displayName, email, provider, knowledgeLevel, photoUrl, favoriteTeamCode, createdAt` (생성), `currentStreak, highestStreak, tierPoints, lastJudgedDate, rankSnapshot, notifications` (운영 중 갱신) |
| Writer / 위치 | `createUser` `userRepository.ts:128-139` · `updateUser` `:145-150` · `applyDailyJudgmentTx` `:182-254` · `deleteUser` `:155-157` · `snapshotRankForUser` `rankSnapshotService.ts:26-54` |
| Trigger | 생성=POST `/user`(`createMe`, userService.ts:120) · 갱신=PATCH `/user`(`updateMe`), PATCH `/user/notifications`, **GET `/user`(`getMe`) 의 photoUrl 자동 동기화**(userService.ts:101-105) · 판정=`dailyArchive` cron→`judgeDay``applyDailyJudgmentTx` · rankSnapshot=`dailyArchive` cron(judge **이전**) · 삭제=DELETE `/user` |
| Mechanism | 생성 `set({merge:false})`; 일반 갱신 `set({merge:true})`; 판정 `runTransaction`+`set(merge:true)`+`FieldValue.serverTimestamp()`(createdAt); 삭제 `recursiveDelete`(서브컬렉션 voteHistory/attendance/pointLedger 포함) |
| 빈도/볼륨 | 가입 1회/유저 · 프로필 수정 드묾 · **rankSnapshot + 판정 = 유저당 매일 2회 write** (전체 활성 유저 N명 × 매일) |
| 비용 관찰 | ⚠️ **`getMe`(읽기 경로)에서 토큰 사진이 다르면 매 요청 write 발생** — 사진 변경이 잦은 토큰이면 read마다 hot write. ⚠️ `rankSnapshot` write(archive 중)와 `applyDailyJudgmentTx` write가 **같은 doc을 같은 cron run에서 2번** 건드림 → 1 write로 합칠 여지. |
### 1.2 `users/{uid}/voteHistory/{date}` (날짜별 예측 이력)
| 항목 | 내용 |
|---|---|
| 데이터 | `{ data: [{gameId, team, result}], judgment, correctCount, completedCount, streakAfter }` (문서 ID = `YYYY-MM-DD`) |
| Writer | `setDay` `voteHistoryRepository.ts:65-67``set()` **전체 덮어쓰기(merge 없음)** |
| Trigger | `dailyArchive` cron: ① 본체에서 `setDay(uid,date,{data})` (dailyArchive.ts:130) → ② `judgeDay`가 판정 필드 채워 `setDay` **재호출** (judgmentService.ts:94-100) |
| 빈도/볼륨 | **유저당 하루 1문서이지만 write는 2회** (data 기록 → 판정 후 전체 재기록). 활성 유저 N × 매일 × 2 |
| 비용 관찰 | ⚠️ **같은 문서를 같은 archive run에서 2번 full-set**`judgeDay`에서 한 번만 쓰도록 통합 가능(아래 최적화 #2). 멱등 가드(`skippedByGuard`) 경로에선 2번째 write 생략됨. |
### 1.3 `users/{uid}/attendance/{month}` (월별 출석)
| 항목 | 내용 |
|---|---|
| 데이터 | `{ days:[number], lastCheckedInAt, lastIdempotencyKey, lastResult }` (문서 ID = `YYYY-MM`) |
| Writer | `attendanceService.checkIn` `:162`(이미 출석/멱등 경로) · `:243`(신규 출석) — `tx.set(merge:true)` |
| Trigger | POST `attendance` (checkIn), `runTransaction` 내부 |
| 빈도/볼륨 | 유저당 하루 1회(+중복 호출 시 멱등 write). 활성 유저 × 매일 |
| 비용 관찰 | `days` 배열을 매번 통째로 재기록(append-only 패턴이라 배열 누적). `lastResult`에 전체 결과 객체를 doc 안에 중복 저장 — 문서 크기 점증. 멱등/중복 호출도 `lastIdempotencyKey`만 갱신하려 write 1회 발생. |
### 1.4 `users/{uid}/pointLedger/{autoId}` (포인트 원장)
| 항목 | 내용 |
|---|---|
| 데이터 | `{ type, amount, balanceAfter, seq, createdAt, refMonth?, refDay? }` |
| Writer | `createLedgerEntryTx` `pointLedgerRepository.ts:61-78``tx.create()` (autoId 사전할당) |
| Trigger | attendance `checkIn` 트랜잭션 (attendanceService.ts:182/201/218) |
| Mechanism | 트랜잭션 내 `tx.create` + `FieldValue.serverTimestamp()` |
| 빈도/볼륨 | 출석당 **1~3행** (daily 항상, 주간보너스/월간보너스 조건부). append-only 무한 증가 |
| 비용 관찰 | 잔액 조회가 `orderBy(createdAt desc, seq desc).limit(1)` 쿼리(`getLatestBalanceTx`) — 행이 쌓일수록 인덱스 부담. balance를 별도 doc에 캐싱하면 매 read의 쿼리 비용 절감. |
### 1.5 `games/{gameId}` (경기 문서) — ⚠️ 가장 큰 쓰기 증폭원
| 항목 | 내용 |
|---|---|
| 데이터 | `{ time(Timestamp), stadium, status, homeTeamCode, awayTeamCode, winningTeamCode?, cancelReason?, endedAt? }` |
| Writer | `syncGamesForMonth` `gameSyncService.ts:63-77` · `forceSyncDay` `:126-164` · `markGameEnded` `gameResultService.ts:48-64` |
| Trigger | `syncGamesForMonth`=`kboDailyRefresh` cron(매일 02:00, 이번달+월말엔 다음달) · `forceSyncDay`=cron(어제분) + `debug/forceSync`(무인증!) · `markGameEnded`=POST `/admin/game/end` |
| Mechanism | sync = `firestore.batch()` + `set(merge:true)` · markGameEnded = `update()`+`serverTimestamp()` |
| 빈도/볼륨 | ⚠️ **`syncGamesForMonth`는 한 달치 모든 경기(~수십~150+건)를 매일 `merge:true`로 무조건 재기록** — 변경 여부 검사 없음. `forceSyncDay`는 변경 감지(read-then-write) 있어 변경분만 씀. |
| 비용 관찰 | 🔴 **최대 비용 핫스팟**: `syncGamesForMonth`가 매일 월 전체를 덮어써 대부분이 *변경 없는 데이터의 반복 write*. 같은 month 동기화에 forceSyncDay처럼 `getAll` 후 diff 비교를 도입하면 write 대부분 제거 가능. status 전이가 `onGameCompleted` 트리거를 깨우므로, 불필요한 재write가 트리거를 잘못 깨울 가능성도(merge로 같은 값이면 트리거 fire 안 함이라 실害은 적음). `debug/forceSync` 무인증 노출은 운영 리스크. |
### 1.6 `kboCache/{key}` (외부 KBO 데이터 캐시)
| 항목 | 내용 |
|---|---|
| 데이터 | `{ data:<T>, updatedAt(serverTimestamp), ttlMs? }` — key 예: `rank__2026`, `schedule_day__20260401__HT`, `player__...`, `game_detail__<gameId>` |
| Writer | `setCached` `kboCacheRepository.ts:69-80` (getOrFetch 미스 시 `:161`) · 월간 일정은 일자별 `setCached` 루프 `kboRepository.ts:384-390` · 게임상세 `getOrFetchDynamic` `gameDetailService.ts:129` · 무효화 삭제 `kboRefresh.ts:16-25` |
| Trigger | `kbo` HTTP 핸들러 read 미스(rank/schedule/player/game-detail) · `kboDailyRefresh` cron(prefix별 batch delete 후 rank/schedule 재생성) |
| Mechanism | `set()` 전체 덮어쓰기 · 무효화는 `firestore.batch()` delete (prefix range 쿼리 `>= prefix, < prefix+`) |
| 빈도/볼륨 | read 미스당 1 write · 월간 일정 미스 시 **한 달 일수(~30건) 개별 write**(kboRepository.ts:384) · cron이 매일 `rank__`/`schedule_day__`/`game_detail__` 전체 삭제 후 재생성 |
| 비용 관찰 | 락(`kboLocks`)으로 thundering-herd는 방지됨. 월간 미스 1회가 30 write 유발 → 일자별 분리 캐시의 트레이드오프. cron의 prefix batch-delete + 재fetch는 매일 캐시를 전량 무효화 → 02:00 직후 첫 사용자 요청들이 대량 미스/재생성 유발. |
### 1.7 `kboLocks/{key}` (분산 락)
| 항목 | 내용 |
|---|---|
| 데이터 | `{ acquiredAt(serverTimestamp) }`, TTL 30s |
| Writer | `acquireLock` `kboCacheRepository.ts:88-104` (`runTransaction`+`tx.set`) · `releaseLock` `:109-111` (`delete`) |
| Trigger | 캐시 미스 fetch 직렬화 (schedule month, rank, player, game-detail) |
| 빈도/볼륨 | 캐시 미스 fetch당 set 1 + delete 1 (트랜잭션 read 포함) |
| 비용 관찰 | 미스마다 트랜잭션(read+write) + delete = 3 작업. 캐시 hit가 많으면 무시 가능. |
---
## 2. Realtime Database 쓰기 대상
### 2.1 `/votes/{gameId}/counts/{homeCount|awayCount}` — 🔴 핫 카운터
| 항목 | 내용 |
|---|---|
| Writer | `submitVote` `voteRepository.ts:66` · `changeVote` `:92-93``ServerValue.increment(±1)` |
| Trigger | POST `/prediction`(createPrediction), PUT `/prediction`(updatePrediction) |
| Mechanism | multi-path `ref().update()` 원자적 increment |
| 빈도/볼륨 | **유저 투표 1건당 1~2 카운터 increment**. 인기 경기 투표 마감 직전 동시성 高 → 단일 노드 hot-write |
| 비용 관찰 | ⚠️ `ServerValue.increment`는 RTDB에서 단일 경로 경합. 한 경기 카운터에 트래픽 집중(hot path)이나 RTDB increment는 서버측 병합이라 비교적 안전. summary read는 `MemCache`(5s, 프로세스 내)로 완화됨(predictionService.ts:16). |
### 2.2 `/votes/{gameId}/users/{uid}` & 2.3 `/userVotes/{uid}/{date}/{gameId}`
| 항목 | 내용 |
|---|---|
| 데이터 | `{ team }` → 경기 종료 후 `{ team, result:boolean }` |
| Writer | `submitVote`/`changeVote`(`voteRepository.ts:67-68, 94-95`) 동일 update에서 두 경로 동시 기록 · 결과주입 `gameResultService.ts:33-34` · 취소정리 `onGameCompleted.ts:52-56` & `voteRepository.ts:129-135`(deleteUserVoteGame) · archive 후 `dailyArchive.ts:131` 일자 전체 remove |
| Trigger | 투표=HTTP · 결과/취소=`onGameCompleted` 트리거 + `processGameEnd` + `dailyArchive` reconcile |
| Mechanism | multi-path `ref().update()`; 취소는 `update({path:null})` / `remove()` |
| 빈도/볼륨 | 투표당 2경로 write · 경기 종료당 투표자 수만큼 result write(fan-out) · 매일 archive가 유저별 일자 노드 remove |
| 비용 관찰 | ⚠️ **fan-out**: `processGameEndWithGame`이 투표한 모든 uid에 대해 `/userVotes/.../result`를 한 update 객체로 기록(gameResultService.ts:30-37) — 인기 경기면 큰 multi-path update. + 직후 모든 uid `invalidateStats` 호출(아래 2.8)로 추가 RTDB delete fan-out. |
### 2.4 `/votes/{gameId}` (경기 투표 전체)
| Writer | `deleteGameVotes` `voteRepository.ts:121-123``remove()` |
|---|---|
| Trigger | `onGameCompleted`(완료/취소) + `processGameEnd`(gameResultService.ts:40) |
| 비용 관찰 | 경기 종료당 1 subtree remove. 단, `/userVotes` 인덱스는 별도 정리 필요(주석에 명시됨). |
### 2.5 `/nicknames/{name}` & 2.6 `/userNicknames/{uid}` (닉네임 예약)
| 항목 | 내용 |
|---|---|
| Writer | `reserveNickname` `nicknameRepository.ts:19-57`(`transaction` + `update`) · `deleteReservation` `:82-90` · `releaseReservation` `:92-105` |
| Trigger | GET `/user/check-nickname`(checkNickname) · POST `/user`(createMe→deleteReservation) · DELETE `/user`(releaseReservation) |
| Mechanism | `ref(name).transaction()`(TTL 10분 점유) + multi-path `update()`(이전 예약 null 처리) |
| 빈도/볼륨 | 온보딩 닉네임 확인당 transaction 1 + update 1. 가입 플로우에 한정 |
| 비용 관찰 | 적정. transaction 경합은 동일 닉네임 동시 요청 시에만. |
### 2.7 `/scoreboardCache/{date}/overall` 및 `/{date}/team/{teamCode}`
| 항목 | 내용 |
|---|---|
| 데이터 | `{ top:[ScoreboardEntry×10], totalCount, generatedAt }` |
| Writer | `writeScope` `scoreboardCacheRepository.ts:30-36` (`set()`), `precomputeScoreboardCache` `rankSnapshotService.ts:128-148` |
| Trigger | `dailyArchive` cron `finally` 블록(dailyArchive.ts:163) — 매 run 1회 |
| Mechanism | scope별 `set()` 전체 덮어쓰기. overall + 10개 팀 = **루프 11회 set** + 각 scope당 `listTopByTierPoints`(쿼리) + `countRankedUsers`(count agg) read |
| 빈도/볼륨 | 매일 11 write (+ enrichTop 내부 read). 고정 비용 |
| 비용 관찰 | 적정(하루 11건). 단 각 scope 계산이 Firestore orderBy+limit+count aggregation read를 동반 → write보다 read 비용이 큼. |
### 2.8 `/cache/stats/{uid}/{key}` (유저 통계 캐시)
| 항목 | 내용 |
|---|---|
| 데이터 | 계산된 `StatsResponse` (forDate 포함) |
| Writer | 저장 `statsService.ts:262` `set()` (캐시 미스 시) · 무효화 `statsService.ts:280-282` `rtdb.ref('/cache/stats/{uid}').remove()` |
| Trigger | 저장=GET `/stats` 미스 · 무효화=`invalidateStats``processGameEnd`/`onGameCompleted`/`dailyArchive`에서 호출 |
| 빈도/볼륨 | stats read 미스당 1 write(키=period별로 분기 → 유저당 여러 키 누적) · **경기 종료마다 투표자 전원 무효화 fan-out**(gameResultService.ts:42, onGameCompleted.ts:59-61) |
| 비용 관찰 | ⚠️ 경기 종료 시 `Promise.all(uids.map(invalidateStats))` — 투표자 수만큼 RTDB subtree remove. 같은 유저가 하루 여러 경기에 투표하면 경기마다 중복 무효화(매 경기 동일 유저 stats 삭제). 경기 묶음 단위로 1회만 무효화하면 절감. |
---
## 비용·최적화 관찰 (writes)
영향도 순 (높음→낮음):
1. 🔴 **`games` 매일 월간 전량 재기록 (`syncGamesForMonth`, gameSyncService.ts:63-77)** — 가장 큰 쓰기 증폭. 한 달치 경기(수십~150+건)를 매일 02:00 cron이 변경 여부 검사 없이 `merge:true`로 무조건 덮어씀. 대부분이 *불변 데이터의 반복 write*. → **`forceSyncDay`처럼 `getAll` 후 diff 비교하여 변경분만 batch write**. 즉시 일일 write를 거의 0에 수렴시킬 수 있음.
2. 🟠 **`voteHistory/{date}` 이중 write (dailyArchive.ts:130 + judgmentService.ts:94)** — 같은 문서를 같은 archive run에서 ① data만, ② 판정 포함 전체로 두 번 full-set. → judgeDay가 voteDoc을 인자로 받으므로 **첫 setDay 생략하고 judgeDay에서 1회만 기록**(또는 실패 복구용으로만 첫 write 유지). 활성 유저 N × 매일 write 절반 감소.
3. 🟠 **경기 종료 시 stats 캐시 무효화 fan-out (gameResultService.ts:42, onGameCompleted.ts:59-61)** — 경기마다 투표자 전원 `/cache/stats/{uid}` remove. 한 유저가 당일 여러 경기 투표 시 경기 수만큼 중복 무효화. → **archive/일괄 처리 시 유저 단위로 1회만 무효화**하거나, 무효화 대신 `forDate` 기반 lazy 만료(이미 존재)에 의존.
4. 🟡 **`users/{uid}` 동일 cron run 내 2회 write (rankSnapshot + applyDailyJudgmentTx)** — `snapshotRankForUser`가 judge 직전 rankSnapshot을 별도 set, 이후 판정 트랜잭션이 같은 doc 갱신. 스냅샷이 "판정 전 rank"여야 하는 제약은 있으나, 판정 트랜잭션 내부에서 스냅샷까지 함께 기록하도록 재설계하면 유저당 매일 1 write 절감.
5. 🟡 **`getMe`(읽기 경로)의 photoUrl 자동 write (userService.ts:101-105)** — GET `/user`인데 토큰 사진이 다르면 write 발생. 토큰 picture가 자주 바뀌면 read마다 hot write. → 변경 빈도 낮으면 무해하나, write 조건을 더 보수적으로(예: 일정 주기) 두거나 클라 명시 갱신으로 이전.
6. 🟡 **`kboCache` cron 전량 무효화 후 재생성 (kboRefresh.ts:16-39)** — 매일 `rank__`/`schedule_day__`/`game_detail__` prefix 전체 batch-delete. 직후 첫 사용자 요청들이 대량 캐시 미스→재fetch→재write 유발. rank/schedule은 cron이 미리 warm-up하지만 game_detail은 안 함. → game_detail은 TTL 자연 만료에 맡기고 prefix 전량 삭제 제외 고려.
7. 🟢 **`pointLedger` append-only 무한 증가** — 잔액 조회가 매번 `orderBy+limit(1)` 쿼리. 행 누적 시 비용 증가. → 잔액을 attendance month doc 또는 user doc에 캐싱하면 read 쿼리 비용 절감(write 자체는 정상).
8. 🟢 **`debug/forceSync`·`debug/dailyArchive` 무인증 노출 (debugHandlers.ts)** — `games` 일괄 write 및 archive 전체 실행을 인증 없이 트리거 가능. 비용보다 운영/보안 리스크. 주석에도 "운영 안정화 후 제거" 명시됨.
### 참고: 비용 영향 적은 정상 패턴
- 투표 카운터 `ServerValue.increment` (RTDB 서버측 병합, 핫하지만 안전)
- 닉네임 예약 transaction (온보딩 한정)
- `scoreboardCache` 매일 11 write (고정·소량)
- `kboLocks` thundering-herd 방지 (캐시 hit 시 무비용)
- in-process `MemCache` (summary 5s, scope/meRank, gameList 10s) — Firestore/RTDB write 아님