docs: add Firebase read/write pattern audit & optimization plan
- Firestore/RTDB read/write 패턴 전수 조사 결과를 문서화 - backend-writes.md, backend-reads.md, data-lifecycle.md, optimization-plan.md 추가
This commit is contained in:
parent
86b0da7c2d
commit
e76b752a41
17
docs/data-patterns/README.md
Normal file
17
docs/data-patterns/README.md
Normal file
@ -0,0 +1,17 @@
|
||||
# Panit 데이터 read/write 패턴 문서
|
||||
|
||||
Panit 야구앱의 Firebase(Firestore + RTDB) 데이터 접근 패턴 전수 조사 결과. 백엔드(`mmday-firebase`)와 Flutter 클라이언트(`Panit`)를 함께 추적했다.
|
||||
|
||||
## 문서 구성
|
||||
| 문서 | 내용 |
|
||||
|---|---|
|
||||
| [data-lifecycle.md](./data-lifecycle.md) | **각 데이터가 어떻게 생성되고 누가 읽는지** — 생성 주체·트리거·소비자 맵 (종합) |
|
||||
| [backend-writes.md](./backend-writes.md) | 백엔드 모든 write 경로 (repository/trigger/scheduled/handler), 빈도·배치·핫문서·비용 |
|
||||
| [backend-reads.md](./backend-reads.md) | 백엔드 모든 read + 캐싱 3계층(MemCache/kboCache/scoreboardCache), N+1·인덱스 |
|
||||
| [optimization-plan.md](./optimization-plan.md) | **read/write 최적화 로드맵** — 영향도×리스크 우선순위, 권장 변경·검증 포인트 |
|
||||
| client-patterns.md | 클라 접근 패턴(직접 Firestore/RTDB vs CF, 실시간 vs 단발, SWR). 위치: `Panit/docs/data-patterns/client-patterns.md` |
|
||||
|
||||
## 핵심 결론 (3줄)
|
||||
1. **클라는 Firestore/RTDB를 직접 읽지 않는다** — `fcmTokens` write 1곳 외 전부 Cloud Functions HTTP 경유.
|
||||
2. **최대 비용**: write=`games` 매일 월 전량 재기록 / read=`dailyArchive` 유저별 게임쿼리 중복 + `getStats` 무효화 후 voteHistory 풀스캔 / 클라=`voteSummary` 10초 폴링.
|
||||
3. **인덱스 누락 없음** — 복합 인덱스 3종 모두 쿼리와 일치.
|
||||
174
docs/data-patterns/backend-reads.md
Normal file
174
docs/data-patterns/backend-reads.md
Normal file
@ -0,0 +1,174 @@
|
||||
# 백엔드 READ 패턴 · 캐싱 (Panit Firebase Functions)
|
||||
|
||||
> 조사 범위: `src/` only (lib/·dist/ 제외). 모든 read 경로(Firestore / RTDB / 외부 KBO fetch / 캐시)를 추적한다.
|
||||
> 작성일 2026-05-28. 인용은 `파일:라인` 기준.
|
||||
|
||||
## 데이터 저장소 개요
|
||||
|
||||
| 저장소 | 용도 |
|
||||
|--------|------|
|
||||
| **Firestore** | `users`, `users/{uid}/voteHistory`, `users/{uid}/attendance`, `users/{uid}/pointLedger`, `games`, `kboCache`, `kboLocks` |
|
||||
| **RTDB** | `/votes/{gameId}`, `/userVotes/{uid}/{date}`, `/cache/stats/{uid}`, `/scoreboardCache/{date}`, `/nicknames`, `/userNicknames` |
|
||||
| **외부 KBO** | `koreabaseball.com` (게임센터 JSON API + ASP.NET 스크레이핑) |
|
||||
|
||||
## 캐시 계층 3종 (+ RTDB 파생 캐시 2종)
|
||||
|
||||
1. **인메모리 (`src/lib/memCache.ts`)** — 인스턴스 단위 `Map`, TTL + promise coalescing. (gameList 10s, scoreboard scope/me-rank 30s, vote summary 5s)
|
||||
2. **Firestore `kboCache` (`src/repositories/kboCacheRepository.ts`)** — 분산 락(`kboLocks`, 30s) 기반 공유 캐시. rank/player(1h 고정), schedule(동적 30s~7d), gameDetail(동적).
|
||||
3. **scoreboardCache (`src/repositories/scoreboardCacheRepository.ts`)** — RTDB `/scoreboardCache`, 크론이 미리 계산해 채움(precompute).
|
||||
4. (파생) **stats 캐시** — RTDB `/cache/stats/{uid}/{period}`, `forDate`로 무효화.
|
||||
5. (파생) **vote counts** — RTDB `/votes/{gameId}/counts`.
|
||||
|
||||
---
|
||||
|
||||
## 1. KBO 외부 fetch (네트워크 read — 캐시가 보호하는 대상)
|
||||
|
||||
### 1-1. 게임센터 라이브 (`src/kbo/game-list.ts`)
|
||||
- **Source**: `POST koreabaseball.com/ws/Main.asmx/GetKboGameList` (`fetchGameList` `game-list.ts:240`). 응답에 HTML 에러페이지가 붙어 와서 JSON 부분만 추출(`game-list.ts:263-266`).
|
||||
- **Reader/Caller**: `services/gameListService.getGameList` → `MemCache(10_000)` (`gameListService.ts:22,39`).
|
||||
- **트리거**: ① 클라 `GET /kbo/games` (게임센터 화면), ② `kboRepository.mergeLiveIntoSchedule`가 "오늘" 일정 조회 시 매번 호출(`kboRepository.ts:224`), ③ `gameSyncService.forceSyncDay`(크론/디버그, `gameSyncService.ts:129`).
|
||||
- **Caching**: 인메모리 10s, 키 `${date}|${series}|${league}` (`gameListService.ts:37`). 히트 시 외부 호출 0. coalescing으로 동일 키 동시요청 1회 fetch.
|
||||
- **효율 관찰**:
|
||||
- ⚠️ `new MemCache(10_000)`에 **maxSize 미지정** → `CACHING.md`가 명시한 "cap 100 evict"가 **실제 코드에 없다**(문서 드리프트). 키가 date·series·league 조합이라 사실상 소수지만 무제한 증가 가능.
|
||||
- ⚠️ `CACHING.md`의 `/metrics/gameListCache/{date}/{hit|miss}` RTDB 메트릭은 **코드에 구현돼 있지 않음**(문서만 존재).
|
||||
- 인스턴스마다 캐시가 흩어짐(cold start 시 초기화) — 인스턴스 수 × 분당 최대 6회 외부 호출.
|
||||
|
||||
### 1-2. 팀 순위 (`src/kbo/team-rank.ts`)
|
||||
- **Source**: ASP.NET 초기 GET + postback (`aspnet-client.ts:100,122`). 현재 연도는 초기 페이지 그대로, 과거 연도는 postback (`kboRepository.fetchSingleYearRank:60`).
|
||||
- **Caller**: `rankService.getRank` → `kboRepository.fetchRankFromKbo` (`kboRepository.ts:42`) ← `GET /kbo/rank` (순위 화면).
|
||||
- **Caching**: `kboCache` 1h 고정, 키 `rank__{year}` (`kboRepository.ts:47,32`). **연도별 개별 캐싱**.
|
||||
- **효율**: 여러 연도 요청 시 `for` 루프로 연도마다 `getOrFetch` 순차 호출(`kboRepository.ts:46-52`) — 캐시 히트면 저렴하나, 동시 miss 시 직렬.
|
||||
|
||||
### 1-3. 선수 기록 (`src/kbo/player/*`)
|
||||
- **Source**: ASP.NET 초기+postback, `allPages`면 페이지 끝까지 순회(`kboRepository.fetchPlayerFromKbo:457-462`).
|
||||
- **Caller**: `playerService.getPlayerStats` ← `GET /kbo/player` (선수 기록 화면).
|
||||
- **Caching**: `kboCache` 1h 고정, 키 `player__{type}__{year}__{team}__{series}__{pos}__{situation}__{situationDetail}__{all|one}` (`kboRepository.ts:446`). 필터 조합 폭발 가능(키 다양).
|
||||
|
||||
### 1-4. 일정 (`src/kbo/schedule.ts`)
|
||||
- **Source**: KBO 월 단위 응답 (`fetchSchedule`). → 아래 §2에서 일자 캐시로 분해.
|
||||
|
||||
### 1-5. 게임 상세 (`src/kbo/game-detail.ts`)
|
||||
- **Source**: `fetchGameDetail` (scoreBoard/lineup/play-by-play).
|
||||
- **Caller**: `gameDetailService.getGameDetail` ← `GET /kbo/gameDetail` (경기 상세 화면).
|
||||
- **Caching**: `kboCache` **동적 TTL**, 키 `game_detail__{gameId}` (`gameDetailService.ts:87`). `getOrFetchDynamic`로 응답 보고 TTL 결정 — 종료 7d / 라이브 30s / 시작전 발표전 라인업 10s, 그 외 시작시각까지 ≤1h (`gameDetailService.ts:29-49`).
|
||||
|
||||
---
|
||||
|
||||
## 2. 일정 캐시 (Firestore 일자 단위 + 동적 TTL) — `kboRepository.ts`
|
||||
|
||||
- **Source**: `kboCache` 문서, 키 `schedule_day__{YYYYMMDD}__{team}__{series}` (`kboRepository.ts:86-92`).
|
||||
- **Reader**: `readDayDocs`가 `firestore.getAll(...refs)` 배치 read (`kboRepository.ts:282-300`).
|
||||
- **Caller**: `scheduleService.getSchedule` ← `GET /kbo/schedule` (일정 화면). 클라가 `day`를 주면 단일일, 아니면 월 전체.
|
||||
- **Query shape**: 문서 ID 직접 지정 → 인덱스 불필요. `getAll`은 1 round-trip이지만 **월 조회 시 ~30 doc read** (`fetchScheduleMonth:356-358`).
|
||||
- **Caching/stale**:
|
||||
- 동적 TTL `dayTtlMs` (`kboRepository.ts:124-168`): 어제 이전 7d(미종료 잔존 시 30s) / 오늘 진행중 30s / 오늘 시작전 min(1h, 시작까지) / 내일 6h / D+2+ 7d.
|
||||
- 미스 시 **월 단위 락**(`schedule_month__...`) 획득 후 KBO 월 1회 fetch → 만료 일자 doc만 `setCached`(`kboRepository.ts:384-390`).
|
||||
- 락 실패 요청은 500ms 간격 **최대 25s 폴링**(`kboRepository.ts:398-404`), 타임아웃 시 캐시 우회 직접 fetch.
|
||||
- **라이브 병합**: 대상이 "오늘"이면 `mergeLiveIntoSchedule`가 §1-1 호출해 점수·상태 덮어씀(`kboRepository.ts:206-280`, 342, 417). 과거/미래는 캐시 그대로.
|
||||
- **효율 관찰**:
|
||||
- 단일일 미스 시 **월 전체를 fetch**(`fetchScheduleSingleDay:331-339`) 후 그 날짜만 재read — 미스 1건이 30일치 write 유발(단, 만료분만).
|
||||
- 폴링 루프(최대 50회 × `readDayDocs` 30 doc) → 락 경합 시 **read 증폭**.
|
||||
- `mergeLiveIntoSchedule`에 `console.log`가 게임 수만큼 다수 — read는 아니나 로그 비용.
|
||||
|
||||
---
|
||||
|
||||
## 3. 스코어보드 read — `scoreboardService.ts`
|
||||
|
||||
- **Caller**: `GET /prediction/scoreboard?type=team|overall` (인증 필요, 랭킹 화면). `Cache-Control: private, max-age=60`.
|
||||
- **읽는 것**:
|
||||
1. `getUser(uid)` — Firestore `users/{uid}` 단일 doc, **요청마다 무캐시** (`scoreboardService.ts:54`).
|
||||
2. scope top10/totalCount — `readScope(date, scope)` RTDB `/scoreboardCache/{date}/...` (`scoreboardService.ts:63-67`), 인메모리 `scopeCache` 30s로 감쌈. **miss이고 precompute 안 됨이면 503** (즉시계산 안 함).
|
||||
3. 본인 rank — `meRankCache`(30s) miss 시 `countUsersAboveTierPoints(myPoints, teamCode)` count aggregation (`scoreboardService.ts:72-78`).
|
||||
- **Query shape / 인덱스**:
|
||||
- `listTopByTierPoints`(precompute에서만 사용): `where(favoriteTeamCode==)` + `orderBy(tierPoints desc)` + `limit` + `.select(...)` → `users[favoriteTeamCode ASC, tierPoints DESC]` 인덱스 사용(✓ `firestore.indexes.json:51`). overall(팀 없음)은 단일필드 자동.
|
||||
- `countUsersAboveTierPoints`: `where(favoriteTeamCode==)` + `where(tierPoints>)` → `users[favoriteTeamCode ASC, tierPoints ASC]` 인덱스 사용(✓ `firestore.indexes.json:59`). count aggregation은 읽은 인덱스 엔트리당 과금(1000개당 1 read 근사).
|
||||
- **효율 관찰**:
|
||||
- `getUser`가 캐시 밖이라 랭킹 화면 폴링 시 매번 user doc read(전체 doc 페치). `.select`로 줄일 여지.
|
||||
- `meRankCache` 키가 `{date}:{scope}:{uid}` → **유저마다 별 키** → 활성 유저 수만큼 count aggregation. 30s 버스트 보호는 동일 유저 재요청에만 효과.
|
||||
|
||||
### precompute (크론) — `rankSnapshotService.precomputeScoreboardCache`
|
||||
- overall + 10팀 = **11 scope** 각각 `listTopByTierPoints(10)` + `countRankedUsers` 병렬(`rankSnapshotService.ts:111-122,138`). 즉 11×(top10 쿼리 + count agg). `dailyArchive` finally에서 매일 호출(`dailyArchive.ts:163`).
|
||||
- `snapshotRankForUser`: judged 유저마다 `getUser` + overall count agg + (응원팀 있으면) team count agg (`rankSnapshotService.ts:26-54`).
|
||||
|
||||
---
|
||||
|
||||
## 4. 통계 read — `statsService.getStats`
|
||||
|
||||
- **Caller**: `GET /stats` / `GET /stats/history` (통계·홈 화면, 인증).
|
||||
- **캐시**: RTDB `/cache/stats/{uid}/{period}` (`statsService.ts:255`). `forDate===today`면 캐시 반환, 아니면 재계산 후 set(`statsService.ts:256-263`). `invalidateStats`가 `/cache/stats/{uid}` **전체** 삭제(`statsService.ts:281`).
|
||||
- **miss 시 `computeStats`**:
|
||||
- `getAll(uid)` — `voteHistory` 서브컬렉션 **전체 스캔**(`orderBy(__name__)`, `voteHistoryRepository.ts:53`). 시즌 누적 시 doc 수 = 예측한 날 수(최대 ~180+).
|
||||
- `getUser(uid)` 병렬 (`statsService.ts:166`).
|
||||
- streak 보정: `lastJudged<어제`면 RTDB `/userVotes/{uid}/{yesterday}` read(`statsService.ts:200`), 거기서 또 `hasMissedGameDayBetween` → §6.
|
||||
- **효율 관찰**:
|
||||
- ⚠️ **가장 큰 read 비용 후보**: 캐시 무효화(경기 완료 시 `invalidateStats`) 후 다음 `/stats` 호출이 전체 `voteHistory` 풀스캔. 한 유저가 하루 여러 게임 완료를 겪으면 그 사이 stats 호출마다 풀스캔 재발(forDate 동일이라 set은 되지만 invalidate가 다시 비움).
|
||||
- getStats가 모든 기간(overall/season/month/week/period)을 in-memory로 재집계 — read는 `getAll` 1회로 공유하므로 OK.
|
||||
|
||||
### history (`getHistory`)
|
||||
- `voteHistoryRepository.getDay(uid, date)` 단일 doc(`statsService.ts:298`). 저렴.
|
||||
|
||||
---
|
||||
|
||||
## 5. 예측(투표) read — `predictionService.ts` / `voteRepository.ts`
|
||||
|
||||
| 경로 | Source | 캐시 |
|
||||
|------|--------|------|
|
||||
| `GET /prediction/games?date=` | Firestore `games` `where(time>=,<) orderBy(time)` (`gameRepository.listByDate:27-37`) | 없음 |
|
||||
| `GET /prediction?date=` (내 투표) | RTDB `/userVotes/{uid}/{date}` (`voteRepository.getUserDateVotes:106`) | 없음 |
|
||||
| `GET /prediction/summary?gameId=` | RTDB `/votes/{gameId}/counts` (`voteRepository.getCounts:23`) | 인메모리 `summaryCache` 5s (`predictionService.ts:16,112`) |
|
||||
| `POST/PUT /prediction` | `getGame`(단일 doc) + `getUserVote`(RTDB) (`predictionService.ts:44,47,71`) | 없음, 쓰기 후 `summaryCache.delete` |
|
||||
|
||||
- **Query shape**: `listByDate`는 `time` 범위+정렬 → 단일필드 `time` 자동 인덱스(범위+동일필드 orderBy라 복합 불필요).
|
||||
- **효율**: `summary`만 캐시(5s). `games` 리스트는 무캐시지만 하루치(≤5~6경기)라 작음. `loadWaitingGame`이 POST/PUT마다 `getGame` 1 read — 정상.
|
||||
|
||||
---
|
||||
|
||||
## 6. 판정·아카이브 경로 (크론/트리거) — read 집약 구간
|
||||
|
||||
### `judgmentService.hasMissedGameDayBetween` (`judgmentService.ts:27-41`)
|
||||
- `(lastJudged, upTo)` 구간을 **최대 14일 역순 루프**, 매일 `listByDate(cursor)` = `games` 범위쿼리 1회 → 최대 14 쿼리.
|
||||
- 호출처: `getStats`(유저 요청 경로!) + `judgeDay`(아카이브).
|
||||
|
||||
### `judgmentService.judgeDay` (`judgmentService.ts:53-101`)
|
||||
- `listByDate(date)` + `getUser` + `hasMissedGameDayBetween`(≤14 listByDate) + 트랜잭션 내 `tx.get(user)`.
|
||||
|
||||
### `dailyArchive.runDailyArchive` (`dailyArchive.ts:87-168`)
|
||||
- ⚠️ `rtdb.ref("/userVotes").get()` — **전체 userVotes 트리 1회 read**(모든 유저·모든 날짜, `dailyArchive.ts:96`). 타깃 날짜만 필요하나 전부 로드.
|
||||
- 유저별 루프에서:
|
||||
- `reconcileDayVotes`: 미판정 게임마다 `getGame`(N reads, `dailyArchive.ts:46`).
|
||||
- `snapshotRankForUser`: getUser + 1~2 count agg.
|
||||
- `judgeDay`: 위 참조.
|
||||
- ⚠️ **유저 간 중복 read**: `judgeDay`/`hasMissedGameDayBetween`가 **유저마다 동일 날짜의 `listByDate`를 재실행**. U명 아카이브 시 같은 날 games 쿼리를 U×(1+최대14)회 반복 — 날짜별 1회 로드 후 공유하면 제거 가능.
|
||||
|
||||
### `gameResultService.processGameEndWithGame` (트리거 `onGameCompleted`)
|
||||
- `getAllUserVotes(gameId)` RTDB `/votes/{gameId}/users` 1회(`gameResultService.ts:25`). 정상.
|
||||
|
||||
### `gameSyncService.forceSyncDay`
|
||||
- `getGameList`(memcache) + `firestore.getAll(...refs)` 변경 게임만 비교 후 write(`gameSyncService.ts:145-160`). read 효율 양호(전 doc 배치 read 1회).
|
||||
|
||||
---
|
||||
|
||||
## 7. 유저/닉네임/출석 read
|
||||
|
||||
- **getMe** (`userService.ts:95`): `getUser` → 토큰 사진 다르면 `updateUser` 후 메모리값 갱신.
|
||||
- ⚠️ **createMe** (`userService.ts:120-167`): `getUser`(존재체크) + `verifyReservation`(RTDB 2 read 병렬) + createUser + **다시 `getUser`**(생성물 재read). updateMe도 `getUser` + `findUidByDisplayName`(쿼리) + update + **다시 `getUser`**. 쓰기 후 재read 중복 — 반환값 합성으로 생략 가능.
|
||||
- **닉네임**(`nicknameRepository.ts`): `reserveNickname` RTDB 트랜잭션, `verifyReservation`/`releaseReservation` 1~2 read. 정상.
|
||||
- **출석**(`attendanceService.ts`): `checkIn`은 트랜잭션 내 `getMonthDocTx` + `getLatestBalanceTx`(orderBy createdAt desc, seq desc, limit1) — `pointLedger[createdAt DESC, seq DESC]` 인덱스 사용(✓ `firestore.indexes.json:67`). `getMonth`는 doc + balance 병렬. 모두 점 read, 효율 양호.
|
||||
|
||||
---
|
||||
|
||||
## 비용·최적화 관찰 (reads)
|
||||
|
||||
영향도 순. (대량 Firestore doc read / 반복 외부 fetch 우선)
|
||||
|
||||
1. **[높음] dailyArchive의 유저 간 `listByDate` 중복** — `dailyArchive.ts` → `judgeDay`/`hasMissedGameDayBetween`. 유저 U명 × 같은 날짜 `games` 쿼리 1+최대14회 반복. 날짜별로 한 번 로드해 in-memory 공유(또는 휴장일 판정 메모이즈)하면 **U배 → 1배**로 절감. 시즌·유저 증가 시 가장 빠르게 악화.
|
||||
2. **[높음] `getStats` 캐시 무효화 후 voteHistory 풀스캔** — `invalidateStats`가 `/cache/stats/{uid}` 전체를 비우므로, 경기 완료가 잦은 날 `getStats` 호출마다 `getAll(uid)`(서브컬렉션 전체 read, 시즌 누적 시 수십~수백 doc). 누적 집계(overall/season)는 별도 저장(증분 갱신)하고 weekly/streak만 재계산하도록 분리하면 read 급감.
|
||||
3. **[중간] `dailyArchive`의 `/userVotes` 전체 트리 read** — 타깃 날짜만 필요한데 전 유저·전 날짜 로드(`dailyArchive.ts:96`). 미아카이브 날짜가 쌓이면 단일 read가 비대해짐. shallow + 유저별 `/userVotes/{uid}/{date}` 조회 또는 인덱스 경로 도입 검토.
|
||||
4. **[중간] 캐시 스탬피드 시 폴링 read 증폭** — `kboCacheRepository.waitForCache`(≤50회 getCached) · `getOrFetchDynamic`(≤50회) · schedule 월 폴링(≤50회 × 30 doc getAll). 락 경합·콜드스타트 동시요청에서 대기 요청마다 수십 read 발생. 폴링 간격/횟수 상향 또는 백오프로 완화.
|
||||
5. **[중간] 스코어보드 `getUser` 무캐시 + per-uid count aggregation** — 랭킹 화면 폴링마다 user doc 전체 read(`.select` 미적용)와 유저별 `countUsersAboveTierPoints`. `me` 블록을 scopeCache처럼 짧게라도 묶거나, precompute 시 본인 rank 포함 검토.
|
||||
6. **[낮음] gameList MemCache `maxSize` 미설정** — `CACHING.md`가 약속한 cap 100 evict가 코드에 없음(`gameListService.ts:22`). 키 수가 적어 실위험 낮으나 문서-코드 드리프트. 메트릭(`/metrics/gameListCache/...`)도 미구현.
|
||||
7. **[낮음] 쓰기 후 재read 중복** — `createMe`/`updateMe`의 2차 `getUser`(`userService.ts:162,269`). 패치 결과를 메모리에서 합성해 1 read 절약 가능.
|
||||
8. **[낮음] rank 다년도 순차 `getOrFetch`** — `fetchRankFromKbo` for 루프(`kboRepository.ts:46`). 캐시 히트면 무해, 동시 miss 시 직렬. 병렬화 여지.
|
||||
|
||||
### 인덱스 점검 결과
|
||||
- 필요한 복합 인덱스 **모두 존재**: `users[favoriteTeamCode+tierPoints DESC]`, `users[favoriteTeamCode+tierPoints ASC+__name__]`, `pointLedger[createdAt DESC+seq DESC+__name__]` (`firestore.indexes.json`). 누락된 복합쿼리 없음. 단일필드(`games.time`, `users.displayName`, `voteHistory.__name__` 범위)는 자동 인덱스로 충분.
|
||||
180
docs/data-patterns/backend-writes.md
Normal file
180
docs/data-patterns/backend-writes.md
Normal file
@ -0,0 +1,180 @@
|
||||
# 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, tickets{dailyAllKill,weeklyMaster}, lastJudgedDate, lastWeeklyMasterTuesday, rankSnapshot, notifications` (운영 중 갱신) |
|
||||
| Writer / 위치 | `createUser` `userRepository.ts:128-139` · `updateUser` `:145-150` · `applyDailyJudgmentTx` `:182-254` · `applyWeeklyMasterTx` `:261-288` · `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` · 주간티켓=일요일 archive→`applyWeeklyMasterTx` · 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로 합칠 여지. `tickets`는 매 판정마다 dailyAllKill/weeklyMaster 전체 객체를 다시 써서 변경 없는 필드도 재기록. |
|
||||
|
||||
### 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 아님
|
||||
100
docs/data-patterns/data-lifecycle.md
Normal file
100
docs/data-patterns/data-lifecycle.md
Normal file
@ -0,0 +1,100 @@
|
||||
# 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`로 확정 데이터 재검증 생략.
|
||||
78
docs/data-patterns/optimization-plan.md
Normal file
78
docs/data-patterns/optimization-plan.md
Normal file
@ -0,0 +1,78 @@
|
||||
# Panit read/write 최적화 로드맵
|
||||
|
||||
> 근거: [`backend-writes.md`](./backend-writes.md) · [`backend-reads.md`](./backend-reads.md) · [`data-lifecycle.md`](./data-lifecycle.md) · client-patterns.md.
|
||||
> 영향도(비용 절감) × 리스크(정확성/운영) 기준 우선순위. 각 항목에 권장 변경과 검증 포인트 명시.
|
||||
|
||||
## 요약 — 비용 구조
|
||||
- **Firestore write 비용 1위**: `games` 매일 월 전량 재기록(대부분 불변 데이터).
|
||||
- **Firestore read 비용 1위**: ① `dailyArchive`의 유저별 `listByDate` 중복(U배), ② `getStats` 무효화 후 `voteHistory` 전체 풀스캔.
|
||||
- **RTDB fan-out**: 경기 종료마다 투표자 전원 stats 무효화 + result 주입.
|
||||
- **클라發 비용 1위**: `voteSummary` 10초 HTTP 폴링(시트 수만큼 병렬) — 유일한 상시 read 경로.
|
||||
|
||||
---
|
||||
|
||||
## Tier 1 — 큰 절감 · 낮은 리스크 (우선 착수 권장)
|
||||
|
||||
### W1. `games` 동기화에 diff 도입 🔴 최대 효과
|
||||
- **현재**: `syncGamesForMonth`(`gameSyncService.ts:63`)가 매일 02:00 한 달치(수십~150+건)를 변경검사 없이 `merge:true` batch write.
|
||||
- **변경**: 이미 존재하는 `forceSyncDay`의 `getAll`+diff 패턴을 월 동기화에도 적용 → 변경된 경기만 batch write. 일일 write 거의 0 수렴.
|
||||
- **검증**: 신규 일정/시각 변경/상태 전이가 여전히 반영되는지. `onGameCompleted`는 값이 같으면 점화 안 되므로 영향 없음.
|
||||
|
||||
### W2. `voteHistory` 이중 write 통합 🟠
|
||||
- **현재**: `dailyArchive.ts:130`이 data만 set → `judgeDay`(`judgmentService.ts:94`)가 판정 포함 재set. 동일 doc 2회.
|
||||
- **변경**: 첫 `setDay` 생략하고 `judgeDay`에서 1회만 기록(`judgeDay`가 voteDoc 인자 보유). 활성유저×매일 write 절반↓.
|
||||
- **검증**: 판정 실패/멱등 스킵 경로에서 data 유실 없는지(필요 시 첫 write는 복구용으로만 유지).
|
||||
|
||||
### R1. `dailyArchive` 날짜별 `listByDate` 공유 🔴
|
||||
- **현재**: 유저마다 `judgeDay`/`hasMissedGameDayBetween`가 동일 날짜 `games` 쿼리 1+최대14회 재실행 → U배.
|
||||
- **변경**: 아카이브 run 시작 시 대상 날짜 범위의 games를 1회 로드해 in-memory map으로 공유, 휴장일 판정 메모이즈.
|
||||
- **검증**: 유저별 판정 결과 동일성. 유저·시즌 증가 시 가장 빠르게 악화하므로 ROI 최고.
|
||||
|
||||
### C1. `voteSummary` 폴링 → RTDB 직접 구독 🔴 (클라)
|
||||
- **현재**: `prediction_providers.dart:31`이 `/prediction/summary`를 10초 무한 폴링, 시트 수만큼 병렬 CF 호출 → 매번 RTDB read.
|
||||
- **변경**: `votes/{gameId}/counts`는 RTDB public read이므로 클라가 `onValue` 단일 리스너로 직접 구독 → CF 호출 0, 변경분만 push. (차선: 폴링 주기 상향)
|
||||
- **검증**: 인증 불요(public read), 시트 닫힐 때 리스너 해제, 카운트 표시 일치.
|
||||
|
||||
---
|
||||
|
||||
## Tier 2 — 중간 절감 (정확성 설계 필요)
|
||||
|
||||
### R2. `getStats` 누적 집계 분리 🟠
|
||||
- **현재**: `invalidateStats`가 `/cache/stats/{uid}` 전체 삭제 → 다음 `/stats`가 `voteHistory` 전체 풀스캔(시즌 누적 수십~수백 doc).
|
||||
- **변경**: overall/season 누적치는 판정 시 증분 갱신해 별도 저장, weekly/streak만 재계산. 무효화 범위도 period 단위로 축소.
|
||||
- **리스크**: 집계 정합성(판정 정정 시 재계산 경로 필요). 설계 검토 후 착수.
|
||||
|
||||
### W3. stats 무효화 fan-out 유저 단위 1회로 🟠
|
||||
- **현재**: 경기 종료마다 투표자 전원 무효화 → 하루 다경기 투표 유저는 경기수만큼 중복(`gameResultService.ts:42`, `onGameCompleted.ts:59`).
|
||||
- **변경**: 아카이브/배치 처리 시 유저 단위로 1회만 무효화, 또는 `forDate` lazy 만료에 의존(무효화 제거).
|
||||
|
||||
### R3. `dailyArchive`의 `/userVotes` 전체 트리 read 축소 🟠
|
||||
- **현재**: `dailyArchive.ts:96`이 전 유저·전 날짜 `/userVotes` 1회 로드(타깃 날짜만 필요).
|
||||
- **변경**: shallow 조회 후 유저별 `/userVotes/{uid}/{date}`만, 또는 날짜 인덱스 경로 도입.
|
||||
|
||||
### R4. 캐시 스탬피드 폴링 read 완화 🟠
|
||||
- **현재**: `waitForCache`/`getOrFetchDynamic`(≤50회), schedule 월 폴링(≤50×30 doc). 락 경합·콜드스타트에서 대기요청당 수십 read.
|
||||
- **변경**: 폴링 간격/횟수 백오프(지수), 락 보유자 결과를 pub/sub 알림으로 전달 검토.
|
||||
|
||||
### C2/C3. 클라 중복 호출 제거 🟠
|
||||
- **C2**: `rank`↔`h2h` 동일 `/kbo/rank` 중복 → `getRanks` 연도 캐시 또는 단일 keepAlive provider 통합.
|
||||
- **C3**: `pitchers`↔`acePitcher` 동일 `/kbo/player` 2회 → `acePitcher`를 `pitchers` 파생으로.
|
||||
|
||||
---
|
||||
|
||||
## Tier 3 — 소규모 · 정리성
|
||||
|
||||
- **W4**: `users` 동일 cron run 2회 write(rankSnapshot+판정) → 판정 트랜잭션 내 스냅샷 통합(판정 전 rank 제약 유지).
|
||||
- **W5**: `getMe` photoUrl 자동 write 보수화(주기적/클라 명시 갱신).
|
||||
- **W6**: `kboCache` cron 전량 무효화 중 `game_detail__`는 TTL 자연만료에 위임(02:00 직후 미스 폭주 완화).
|
||||
- **R5**: 스코어보드 `getUser` `.select` 적용 + `me` rank 짧은 캐시/precompute 포함.
|
||||
- **R6**: `createMe`/`updateMe` 쓰기 후 2차 `getUser` 제거(반환값 합성).
|
||||
- **R7**: rank 다년도 `getOrFetch` 병렬화.
|
||||
- **C4**: 무캐시 autoDispose provider(`currentUser`/`scoreboard`/`userStats`)에 SWR/short-TTL, `currentUser` keepAlive 승격(스플래시 중복 제거).
|
||||
- **Doc**: gameList MemCache `maxSize` 미설정 + `/metrics/gameListCache` 미구현 → 코드 구현 또는 `CACHING.md` 정정(문서 드리프트).
|
||||
|
||||
## 운영/보안 (비용 외, 별도 처리 권장)
|
||||
- `debug/forceSync`·`debug/dailyArchive` 무인증 노출(`debugHandlers.ts`) — `games` 일괄 write·아카이브 전체 실행 가능. 운영 안정화 후 제거 또는 인증 게이트.
|
||||
|
||||
## 인덱스
|
||||
- 필요한 복합 인덱스 3종 모두 존재·쿼리 일치, 누락 없음(`firestore.indexes.json`).
|
||||
Loading…
x
Reference in New Issue
Block a user