mmday-firebase/docs/data-patterns/data-lifecycle.md
윤정민 e76b752a41 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 추가
2026-05-28 17:10:19 +09:00

7.3 KiB

Panit 데이터 생애주기 — 생성 주체 · 소비자 맵

각 데이터(Firestore 컬렉션 / RTDB 경로)에 대해 어떻게 생성·갱신되는지(writer/trigger)누가 읽는지(소비자 = 백엔드 경로 + 클라 화면) 를 정리한다. 근거: backend-writes.md, backend-reads.md, ../../../AndroidStudioProjects/Panit/docs/data-patterns/client-patterns.md 종합. 핵심 사실: 클라이언트는 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/notificationsupdateUser (프로필·알림 토글)
    • 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 /statscomputeStats가 서브컬렉션 전체 스캔(누적 성적 집계), 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로 확정 데이터 재검증 생략.