- 백엔드 쓰기 패턴 문서에 시즌 정산(seasonHistory) 섹션 추가 - tierPoints 초기화·rankSnapshot 삭제·아카이브가 동일 배치로 묶이는 규칙과 멱등 재실행 동작 명시
17 KiB
Panit 백엔드 WRITE 패턴 감사 (Firestore + RTDB)
범위:
src/TypeScript 소스만 분석 (lib/,dist/컴파일 산출물 제외). 백엔드는 모두 Firebase Admin SDK로 쓰기 때문에 보안 규칙을 우회한다. 함수 wiring:src/index.ts— regionasia-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 · settleUsers seasonRepository.ts(tierPoints=0 리셋 + rankSnapshot 삭제) |
| 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 이전) · 시즌 리셋=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는 한 달치 모든 경기(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)
영향도 순 (높음→낮음):
-
🔴
games매일 월간 전량 재기록 (syncGamesForMonth, gameSyncService.ts:63-77) — 가장 큰 쓰기 증폭. 한 달치 경기(수십~150+건)를 매일 02:00 cron이 변경 여부 검사 없이merge:true로 무조건 덮어씀. 대부분이 불변 데이터의 반복 write. →forceSyncDay처럼getAll후 diff 비교하여 변경분만 batch write. 즉시 일일 write를 거의 0에 수렴시킬 수 있음. -
🟠
voteHistory/{date}이중 write (dailyArchive.ts:130 + judgmentService.ts:94) — 같은 문서를 같은 archive run에서 ① data만, ② 판정 포함 전체로 두 번 full-set. → judgeDay가 voteDoc을 인자로 받으므로 첫 setDay 생략하고 judgeDay에서 1회만 기록(또는 실패 복구용으로만 첫 write 유지). 활성 유저 N × 매일 write 절반 감소. -
🟠 경기 종료 시 stats 캐시 무효화 fan-out (gameResultService.ts:42, onGameCompleted.ts:59-61) — 경기마다 투표자 전원
/cache/stats/{uid}remove. 한 유저가 당일 여러 경기 투표 시 경기 수만큼 중복 무효화. → archive/일괄 처리 시 유저 단위로 1회만 무효화하거나, 무효화 대신forDate기반 lazy 만료(이미 존재)에 의존. -
🟡
users/{uid}동일 cron run 내 2회 write (rankSnapshot + applyDailyJudgmentTx) —snapshotRankForUser가 judge 직전 rankSnapshot을 별도 set, 이후 판정 트랜잭션이 같은 doc 갱신. 스냅샷이 "판정 전 rank"여야 하는 제약은 있으나, 판정 트랜잭션 내부에서 스냅샷까지 함께 기록하도록 재설계하면 유저당 매일 1 write 절감. -
🟡
getMe(읽기 경로)의 photoUrl 자동 write (userService.ts:101-105) — GET/user인데 토큰 사진이 다르면 write 발생. 토큰 picture가 자주 바뀌면 read마다 hot write. → 변경 빈도 낮으면 무해하나, write 조건을 더 보수적으로(예: 일정 주기) 두거나 클라 명시 갱신으로 이전. -
🟡
kboCachecron 전량 무효화 후 재생성 (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 전량 삭제 제외 고려. -
🟢
pointLedgerappend-only 무한 증가 — 잔액 조회가 매번orderBy+limit(1)쿼리. 행 누적 시 비용 증가. → 잔액을 attendance month doc 또는 user doc에 캐싱하면 read 쿼리 비용 절감(write 자체는 정상). -
🟢
debug/forceSync·debug/dailyArchive무인증 노출 (debugHandlers.ts) —games일괄 write 및 archive 전체 실행을 인증 없이 트리거 가능. 비용보다 운영/보안 리스크. 주석에도 "운영 안정화 후 제거" 명시됨.
참고: 비용 영향 적은 정상 패턴
- 투표 카운터
ServerValue.increment(RTDB 서버측 병합, 핫하지만 안전) - 닉네임 예약 transaction (온보딩 한정)
scoreboardCache매일 11 write (고정·소량)kboLocksthundering-herd 방지 (캐시 hit 시 무비용)- in-process
MemCache(summary 5s, scope/meRank, gameList 10s) — Firestore/RTDB write 아님