- Firestore/RTDB read/write 패턴 전수 조사 결과를 문서화 - backend-writes.md, backend-reads.md, data-lifecycle.md, optimization-plan.md 추가
101 lines
7.3 KiB
Markdown
101 lines
7.3 KiB
Markdown
# Panit 데이터 생애주기 — 생성 주체 · 소비자 맵
|
|
|
|
> 각 데이터(Firestore 컬렉션 / RTDB 경로)에 대해 **어떻게 생성·갱신되는지(writer/trigger)** 와 **누가 읽는지(소비자 = 백엔드 경로 + 클라 화면)** 를 정리한다.
|
|
> 근거: [`backend-writes.md`](./backend-writes.md), [`backend-reads.md`](./backend-reads.md), [`../../../AndroidStudioProjects/Panit/docs/data-patterns/client-patterns.md`](client) 종합.
|
|
> 핵심 사실: **클라이언트는 Firestore/RTDB를 직접 읽지 않는다.** `users/{uid}.fcmTokens` 직접 write 1곳을 제외한 모든 접근은 Cloud Functions HTTP 경유. (region `asia-northeast3`)
|
|
|
|
---
|
|
|
|
## 데이터 흐름 한눈에
|
|
|
|
```
|
|
외부 KBO (koreabaseball.com)
|
|
│ scrape/fetch (락+캐시 보호)
|
|
▼
|
|
kboCache(Firestore) / MemCache(인메모리) ──GET /kbo/*──▶ 클라 화면(일정·순위·선수·경기상세)
|
|
│ kboDailyRefresh cron(02:00) + 미스 시 fetch
|
|
▼
|
|
games(Firestore) ◀─syncGamesForMonth cron─ KBO 일정
|
|
│ onDocumentUpdated(games) = onGameCompleted 트리거
|
|
▼
|
|
votes / userVotes(RTDB) ◀─POST/PUT /prediction─ 유저 투표
|
|
│ 경기 종료 → result 주입 → invalidateStats
|
|
▼
|
|
voteHistory · attendance · pointLedger(Firestore 서브컬렉션) ◀─dailyArchive cron(03:00)+checkIn─
|
|
│ 판정/스냅샷
|
|
▼
|
|
users(Firestore) · scoreboardCache · cache/stats(RTDB) ──GET /stats·/scoreboard─▶ 클라(성적·랭킹)
|
|
```
|
|
|
|
---
|
|
|
|
## Firestore
|
|
|
|
### `users/{uid}` — 유저 루트
|
|
- **생성**: `POST /user`(온보딩 가입) → `createUser` (`userRepository.ts:128`), `set(merge:false)`.
|
|
- **갱신**:
|
|
- `PATCH /user`·`/user/notifications` → `updateUser` (프로필·알림 토글)
|
|
- `GET /user`(`getMe`)에서 토큰 사진 변경 시 photoUrl 자동 write (`userService.ts:101`)
|
|
- `dailyArchive` cron(03:00): `snapshotRankForUser`(rank 스냅샷) → `applyDailyJudgmentTx`(streak/tierPoints/tickets) → 일요일 `applyWeeklyMasterTx` — **유저당 매일 2 write**
|
|
- 직접 write: 클라 `users/{uid}.fcmTokens` arrayUnion/Remove (`fcm_service.dart:40,58`) — 유일한 클라 직접 쓰기
|
|
- **소비자(읽기)**: `GET /user`→클라 마이페이지/홈/캘린더 `currentUser`, `getStats`·스코어보드·판정·rank 스냅샷이 내부적으로 `getUser`. 랭킹 정렬은 `users[favoriteTeamCode+tierPoints]` 인덱스로 조회.
|
|
|
|
### `users/{uid}/voteHistory/{YYYY-MM-DD}` — 날짜별 예측 채점 이력
|
|
- **생성/갱신**: `dailyArchive` cron이 ① data 기록(`dailyArchive.ts:130`) → ② `judgeDay` 판정 채워 재기록(`judgmentService.ts:94`) — **동일 run 2회 full-set**.
|
|
- **소비자**: `GET /stats`의 `computeStats`가 서브컬렉션 **전체 스캔**(누적 성적 집계), `GET /stats/history`가 단일 일자 doc. 클라 `userStats`·`voteHistory(date)`·`DayPredictionRecord`.
|
|
|
|
### `users/{uid}/attendance/{YYYY-MM}` — 월별 출석
|
|
- **생성/갱신**: `POST /attendance/check-in` 트랜잭션, `days[]` 누적 + `lastResult` (`attendanceService.ts:162,243`).
|
|
- **소비자**: `GET /attendance/month`→클라 `currentMonthAttendance`(출석현황·포인트잔액·오늘출석여부 파생).
|
|
|
|
### `users/{uid}/pointLedger/{autoId}` — 포인트 원장(append-only)
|
|
- **생성**: `checkIn` 트랜잭션 내 `tx.create` (daily 1 + 주간/월간 보너스 조건부, 출석당 1~3행) (`pointLedgerRepository.ts:61`).
|
|
- **소비자**: 잔액 조회 `getLatestBalanceTx`(`orderBy createdAt desc,seq desc limit 1`, `pointLedger[createdAt+seq]` 인덱스). `GET /attendance/month` 응답에 잔액 포함.
|
|
|
|
### `games/{gameId}` — 경기 (쓰기 증폭 1위)
|
|
- **생성/갱신**:
|
|
- `syncGamesForMonth` = `kboDailyRefresh` cron(02:00): **한 달치 전 경기를 변경검사 없이 `merge:true` batch로 매일 덮어씀** (`gameSyncService.ts:63`)
|
|
- `forceSyncDay` = cron(어제분)+`debug/forceSync`: getAll+diff 후 변경분만 write
|
|
- `markGameEnded` = `POST /admin/game/end`: status/승팀 update → **`onGameCompleted` 트리거 점화**
|
|
- **소비자**: `GET /prediction/games`(`listByDate`, `time` 범위쿼리)→클라 예측 화면. 판정/아카이브가 `listByDate` 반복 호출. status 변경이 `onGameCompleted` 트리거 구동.
|
|
|
|
### `kboCache/{key}` — 외부 KBO 데이터 캐시
|
|
- **생성/갱신**: `GET /kbo/*` 미스 시 `setCached`(rank/player 1h, schedule/gameDetail 동적 TTL), 월간 일정은 일자별 ~30 write. `kboDailyRefresh` cron이 prefix 전량 삭제 후 rank/schedule 재생성 (`kboRefresh.ts:16`).
|
|
- **소비자**: `GET /kbo/schedule·rank·player·gameDetail`→클라 일정/순위/선수/경기상세. `kboLocks`로 thundering-herd 차단.
|
|
|
|
### `kboLocks/{key}` — 분산 락(TTL 30s)
|
|
- **생성/삭제**: 캐시 미스 fetch 직렬화용 `acquireLock`/`releaseLock` (`kboCacheRepository.ts:88`). 소비자=캐시 미스 경로 내부 전용.
|
|
|
|
---
|
|
|
|
## Realtime Database
|
|
|
|
### `/votes/{gameId}/counts/{home|away}` — 투표 카운터(핫)
|
|
- **생성/갱신**: `submitVote`/`changeVote` `ServerValue.increment(±1)` (`voteRepository.ts:66,92`). POST/PUT `/prediction`.
|
|
- **소비자**: `GET /prediction/summary`(`getCounts`, MemCache 5s)→클라 `voteSummary` **10초 폴링**. counts는 RTDB public read 허용.
|
|
|
|
### `/votes/{gameId}/users/{uid}` & `/userVotes/{uid}/{date}/{gameId}` — 투표 내역
|
|
- **생성/갱신**: 투표 시 두 경로 동시 기록 → 경기 종료 시 `result` 주입(fan-out, `gameResultService.ts:33`) → `onGameCompleted`/`dailyArchive`가 취소·정리·remove.
|
|
- **소비자**: `GET /prediction`(`getUserDateVotes`)→클라 그날 예측 목록. 판정(`judgeDay`)·streak 보정·`dailyArchive`가 읽음.
|
|
|
|
### `/cache/stats/{uid}/{period}` — 유저 통계 캐시(파생)
|
|
- **생성**: `GET /stats` 미스 시 `set` (`statsService.ts:262`). `forDate`로 staleness 판단.
|
|
- **무효화**: `invalidateStats`가 `/cache/stats/{uid}` **전체 삭제** — 경기 종료마다 투표자 전원 fan-out (`gameResultService.ts:42`, `onGameCompleted.ts:59`).
|
|
- **소비자**: `GET /stats`→클라 `userStats`/홈 성적.
|
|
|
|
### `/scoreboardCache/{date}/{overall|team/{code}}` — 랭킹 precompute
|
|
- **생성**: `dailyArchive` cron finally(`dailyArchive.ts:163`)→`precomputeScoreboardCache`: overall+10팀 = 11 scope `set`, 각 scope `listTopByTierPoints`+`countRankedUsers`.
|
|
- **소비자**: `GET /prediction/scoreboard`(MemCache scope 30s)→클라 `scoreboard(type)`. **miss이고 미precompute면 503**(즉시계산 안 함).
|
|
|
|
### `/nicknames/{name}` & `/userNicknames/{uid}` — 닉네임 예약
|
|
- **생성/갱신**: `GET /user/check-nickname`(`reserveNickname` 트랜잭션 TTL 10분), 가입 시 `deleteReservation`, 탈퇴 시 `releaseReservation`.
|
|
- **소비자**: 온보딩 닉네임 중복확인 한정.
|
|
|
|
---
|
|
|
|
## 클라이언트 직접 Firebase 사용처(HTTP 외)
|
|
- **firebase_auth**: Google/Apple 로그인 + `authStateChanges()` 스트림(라우터 redirect·스플래시·`currentUser` 게이트). read 비용 ~0.
|
|
- **Remote Config**: `team_order_by_standing` 1키, 앱 시작 시 1회 fetch.
|
|
- **FCM**: `users/{uid}.fcmTokens` 직접 write(로그인/토큰갱신 union, 로그아웃 remove).
|
|
- **SWR 캐시**(`swr_cache.dart`): 일정/경기상세/내기록/기록날짜 4개 도메인. TTL 없음, hit 즉시반환+bg 재검증, `skipRevalidateIf`로 확정 데이터 재검증 생략.
|