- NotificationKey에 predictionRemind·jjaekTalk 추가(마케팅은 opt-in 맵에서 제외) - GET/POST/PATCH /user 응답에 notifications 맵과 marketingOptIn 포함 — 클라가 서버 설정을 읽을 수 있게 함 - updateMe에 marketingOptIn 처리 추가: 동의 시각은 서버 serverTimestamp로 기록, 철회 시 동의 이력 보존 (기존에는 미파싱으로 단독 PATCH가 400) - updateNotifications가 전체 맵 재기록 대신 patch 키만 merge 기록하도록 변경(동시 토글 유실 방지) - userService 테스트 8건 추가(허용/차단 키, boolean 검증, 동의 시각 기록·보존, 기본값) - backend-writes.md §1.1에 새 쓰기 필드·규칙 반영
192 lines
18 KiB
Markdown
192 lines
18 KiB
Markdown
# 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, marketingOptIn, marketingOptInAt` (운영 중 갱신) |
|
||
| Writer / 위치 | `createUser` `userRepository.ts:128-139` · `updateUser` `:145-150` · `applyDailyJudgmentTx` `:182-254` · `deleteUser` `:155-157` · `snapshotRankForUser` `rankSnapshotService.ts:26-54` · `settleUsers` `seasonRepository.ts`(tierPoints=0 리셋 + rankSnapshot 삭제) |
|
||
| Trigger | 생성=POST `/user`(`createMe`, userService.ts:120) · 갱신=PATCH `/user`(`updateMe` — `marketingOptIn` 포함, 동의 시각은 서버 serverTimestamp 로만 기록·철회 시 보존), PATCH `/user/notifications`(키 whitelist=`NotificationKey`: attendance/predictionRemind/jjaekTalk, **patch 키만 merge 기록**), **GET `/user`(`getMe`) 의 photoUrl 자동 동기화**(userService.ts:101-105) · 판정=`dailyArchive` cron→`judgeDay`→`applyDailyJudgmentTx` · rankSnapshot=`dailyArchive` cron(judge **이전**) · 시즌 리셋=`dailyArchive` cron→`maybeSettleSeason`(시즌 endDate 다음 날 1회, §1.8) · 삭제=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가 많으면 무시 가능. |
|
||
|
||
---
|
||
|
||
### 1.8 `users/{uid}/seasonHistory/{seasonId}` (시즌 최종 성적 아카이브)
|
||
|
||
| 항목 | 내용 |
|
||
|---|---|
|
||
| 데이터 | `{ seasonId, tierPoints, tier, rank, totalRanked, settledAt }` (문서 ID = seasonId) |
|
||
| Writer | `settleUsers` `seasonRepository.ts` — 유저별 아카이브 `set` + 루트 doc `tierPoints=0`/`rankSnapshot` 삭제를 **같은 배치**로 커밋(200유저/배치) |
|
||
| Trigger | `dailyArchive` cron → `maybeSettleSeason` (seasonService.ts) — `config/season.endDate` 다음 날, `settledAt` 마커가 없을 때 1회 |
|
||
| Mechanism | 부분 실패 재실행 시 collectionGroup(`seasonId ==`) 조회로 기정산 유저 점수를 순위 산정에 합류시켜 rank 보존(멱등). `settledAt` 마커는 전원 완료 후 `config/season`에 기록 |
|
||
| 빈도/볼륨 | **시즌당 1회** × 랭킹 대상 유저 수(유저당 2 write). append-only, 시즌 수만큼만 증가 |
|
||
| 읽기 | GET `/stats/seasons` (`listUserSeasonHistory`, seasonId desc) — 클라이언트 마이페이지 "지난 시즌 기록" |
|
||
|
||
## 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 아님
|