- 백엔드 WRITE 최적화 적용 결과를 improvements-backend-writes.md에 문서화 - W1~W6 및 문서 드리프트(Doc) 적용 내역 기록
12 KiB
Panit 백엔드 WRITE 최적화 개선 보고서
대상 브랜치:
opt/backend-rw근거 문서:backend-writes.md(감사) ·optimization-plan.md(로드맵) 각 항목은 문제(Before) → 수정(Fix) → 개선(After) 순으로, 감사 문서의path:line과 실제 커밋 diff를 근거로 기술한다. 공통 원칙: 비용(read/write)을 줄이되 관찰 가능한 결과(판정/포인트/스트릭/투표 카운트)와 정확성 안전장치는 보존한다.
| 항목 | 커밋 | 핵심 효과 | 영향도 |
|---|---|---|---|
| W1 | b17ac4c |
games 월간 전량 재기록 → 변경분만 (일일 write ≈ 0 수렴) |
🔴 최대 |
| W2 | 48a8f16 |
voteHistory 이중 write → 1회 (유저×매일 write 절반↓) |
🟠 |
| W3 | 9a59755 |
아카이브 경기별 stats 무효화 fan-out 제거 (중복 제거) | 🟠 |
| W4 | 4ea1a77 |
users/{uid} cron run당 2 write → 1 write |
🟡 |
| W5+R6 | 037baab |
getMe photoUrl hot-write 억제 + 쓰기 후 2차 read 제거 |
🟡 |
| W6 | 50349e6 |
game_detail__ 캐시 일괄 무효화 중단 (미스 폭주 완화) |
🟡 |
| Doc | 82198a6 |
CACHING.md gameListCache 문서 드리프트 정정 |
🟢 |
W1. games 월간 동기화에 getAll+diff 도입 — 🔴 최대 효과
문제 (Before)
syncGamesForMonth(gameSyncService.ts:63-77)는 kboDailyRefresh cron(매일 02:00)에서 한 달치 모든 경기(수십~150+건)를 변경 여부 검사 없이 firestore.batch() + set({merge:true})로 무조건 재기록했다. 일정 데이터는 대부분 불변이므로, 사실상 매일 변경 없는 데이터의 반복 write가 발생 — 감사 문서 기준 Firestore write 비용 1위 핫스팟(backend-writes.md:58-67, 최적화 관찰 #1 :159).
수정 (Fix)
커밋 b17ac4c — src/services/gameSyncService.ts:
- 이미 존재하던
forceSyncDay의 read-then-diff 패턴을 월간 동기화에도 적용. result.games를 gameId 기준Map으로 dedupe(더블헤더 등 중복 시 last-wins, 기존 순차 set 의미 유지).- 대상 ref들을
firestore.getAll(...refs)로 한 번에 읽고, 새gameDocUnchanged(existing, doc)헬퍼로 비교.merge:true이므로 새 doc에 포함된 필드만 비교(생략된winningTeamCode등은 merge가 건드리지 않아 비교 제외).time은Timestamp이라toMillis()로 비교.
- 변경된 문서만
batch.set(merge:true),count === 0이면 commit 생략. 반환값 의미를 "upsert 개수" → "실제 write 개수"로 변경.
개선 (After)
| Before | After | |
|---|---|---|
| 일일 write | 월 전체 매일 무조건 ( |
변경분만 — 평시 거의 0 |
| read | 없음 | getAll 1회(배치 read, write보다 저렴) |
| 트리거 영향 | 없음 | 없음 — 값이 같으면 onGameCompleted는 애초에 점화되지 않음 |
신규 일정/시각 변경/상태 전이는 diff에서 변경으로 잡혀 그대로 반영된다. merge:true 의미와 수동 채움 필드(winningTeamCode) 보존도 유지.
W2. voteHistory 이중 write를 1회로 통합 — 🟠
문제 (Before)
dailyArchive는 활성 유저마다 같은 voteHistory/{date} 문서를 하루 2번 full-set했다: ① 본체에서 setDay(uid,date,{data})(dailyArchive.ts:130) → ② judgeDay가 판정 필드를 채워 setDay 재호출(judgmentService.ts:94). setDay는 merge 없는 전체 덮어쓰기이므로 동일 doc을 2회 기록 — 활성 유저 N × 매일 × 2(backend-writes.md:32-35, 관찰 #2 :161).
수정 (Fix)
커밋 48a8f16 — src/scheduled/dailyArchive.ts, src/services/judgmentService.ts:
dailyArchive에서 선기록setDay제거 → 정상 경로에서judgeDay가 유일한 writer.- 데이터 유실 방지를 위한 순서/복구 안전장치:
judgeDay가 doc을 영속화한 뒤에야/userVotes/{uid}/{date}를 remove(쓰기 전 삭제 금지).judgeDay가 throw하면catch에서setDay(uid,date,{data})로 data만 보존(기존 실패 경로 동작 유지) 후 remove.- 멱등 guard-skip 경로(
tx.skippedByGuard):getDay로 doc 존재를 확인해 없을 때만setDay(voteDoc)로 복원(앞선 run이 트랜잭션 커밋 후 voteHistory 기록 직전 크래시한 경우 대비). 정상 멱등 재실행에서는 이미 존재하므로 판정 필드를 덮어쓰지 않는다.
개선 (After)
| Before | After | |
|---|---|---|
| 정상 경로 write | 2회 (data → 판정 포함) | 1회 (판정 포함 단일 set) |
| 실패 경로 | data 1회 + 판정 실패 | data 1회 보존(동일 보장) |
| 멱등 재실행 | 2번째 write 스킵됨 | 추가 write 0 (doc 존재 시) |
활성 유저 × 매일 기준 voteHistory write 절반 감소. 판정 결과·data 유실 방지 의미는 그대로 유지.
W3. 아카이브 리컨실의 경기별 stats 무효화 fan-out 제거 — 🟠
문제 (Before)
dailyArchive의 reconcileDayVotes는 미판정 완료 경기마다 processGameEndWithGame을 호출하고, 그 안에서 Promise.all(uids.map(invalidateStats))로 해당 경기 투표자 전원의 /cache/stats/{uid}를 무효화(gameResultService.ts:42). 그런데 dailyArchive는 per-user 루프 끝에서 어차피 유저별로 1회 invalidateStats를 호출하므로, 경기별 fan-out은 중복이다. 하루 여러 경기에 투표한 유저는 경기 수만큼 중복 무효화(backend-writes.md:143-151, 관찰 #3 :163).
수정 (Fix)
커밋 9a59755 — src/services/gameResultService.ts, src/scheduled/dailyArchive.ts:
processGameEndWithGame에opts?: { skipInvalidate?: boolean }추가.skipInvalidate면 종료 후invalidateStatsfan-out을 건너뜀(투표 결과 주입·deleteGameVotes등 나머지는 그대로).reconcileDayVotes에서processGameEndWithGame(gameId, game, { skipInvalidate: true })로 호출.- 라이브
onGameCompleted트리거 경로는 기존대로 무효화 유지(opts 미전달).
개선 (After)
- 아카이브에서 stats 무효화는 유저 단위 1회로 수렴 — 경기 수만큼의 중복 RTDB subtree remove 제거.
- stale 위험 없음:
getStats는 일자forDate롤오버 시 자체 무효화하며, 아카이브 루프 끝의 유저 1회 무효화도 그대로 동작.
W4. rank 스냅샷을 일일 판정 트랜잭션에 통합 — 🟡
문제 (Before)
dailyArchive는 cron run당 같은 users/{uid} 문서를 2회 write했다: ① 판정 직전 snapshotRankForUser가 rankSnapshot을 별도 set(rankSnapshotService.ts:26-54) → ② applyDailyJudgmentTx가 같은 doc을 판정으로 갱신(userRepository.ts:182-254). 스냅샷은 "판정 이전 rank"여야 한다는 제약이 있어 단순 제거는 불가(backend-writes.md:24-25, 관찰 #4 :165).
수정 (Fix)
커밋 4ea1a77 — rankSnapshotService.ts, dailyArchive.ts, judgmentService.ts, userRepository.ts:
- read-only 계산부를
computeRankSnapshot(uid,date): RankSnapshot | null로 분리(write 없음). 기존snapshotRankForUser는 이를 호출 후set하는 얇은 래퍼로 유지(기존 호출자/테스트 호환). dailyArchive가 트랜잭션 전에computeRankSnapshot(현재=판정 전 tierPoints 기준)을 계산해judgeDay(uid, date, {data}, { gameCache, rankSnapshot })로 전달.judgeDay가 이를applyDailyJudgmentTx의input.rankSnapshot으로 넘기고, 트랜잭션이 판정 patch에rankSnapshot을 같은tx.set(merge:true)로 병합.
개선 (After)
| Before | After | |
|---|---|---|
users/{uid} write |
cron run당 2회 (snapshot + 판정) | 1회 (단일 patch) |
| guard-skip 경로 | snapshot write 발생 가능 | write 0 (stale 재스냅샷 회피) |
"판정 전 rank" 의미는 트랜잭션 진입 전 현재 tierPoints로 계산하므로 보존. tierPoints <= 0이면 null 반환으로 스냅샷 미기록도 유지.
W5 + R6. userService의 불필요한 read/write 정리 — 🟡
문제 (Before)
- W5:
getMe는 GET/user(읽기 경로)인데 토큰의picture가 저장된photoUrl과 문자열로 다르면 매 요청updateUser로 write(userService.ts:101-105). Google 등은 같은 사진에도 쿼리스트링(=s96-c크기 파라미터)을 매번 바꿔 내려주므로 읽기 경로에서 hot write churn 발생(관찰 #5:167). - R6:
createMe/updateMe는 write 직후getUser로 다시 읽어 응답을 만든다(userService.ts:120부근) — 온보딩/프로필 수정마다 불필요한 Firestore read 1회(로드맵 R6optimization-plan.md:69).
수정 (Fix)
커밋 037baab — src/services/userService.ts:
- W5:
samePhotoUrl(a,b)헬퍼 추가 —'?'앞부분만 비교해 쿼리스트링만 다르면 동일 사진으로 간주.getMe의 동기화 조건을tokenPhoto !== user.photoUrl→!samePhotoUrl(tokenPhoto, user.photoUrl)로 변경 → 실질적 변경 시에만 write. - R6: 쓰기 후 2차
getUser제거.updateMe: 시작 시 읽은user에 방금 적용한 patch(displayName/favoriteTeamCode/knowledgeLevel, null 해제 포함)만 반영해 응답 합성.createMe: 방금 쓴 입력값으로UserProfile합성.createdAt은 저장본이serverTimestamp이므로 응답엔 근사치Timestamp.now()를 싣고, 이후getMe가 저장본을 반영.
개선 (After)
| Before | After | |
|---|---|---|
getMe photoUrl write |
쿼리스트링만 바뀌어도 read마다 write | 실제 사진 변경 시에만 (평시 0) |
createMe/updateMe read |
쓰기 후 getUser 1회 |
0회 (응답 합성) |
photoUrl write 빈도(잠재적으로 user doc 2회/일 이상 → 변경 시에만)와 온보딩/수정당 read 1회를 제거. 응답 정확성은 합성으로 유지(서버 timestamp 근사치만 예외, 후속 read에서 정정).
W6. kboRefresh의 game_detail__ 캐시 일괄 무효화 중단 — 🟡
문제 (Before)
kboDailyRefresh(02:00 cron)는 rank__/schedule_day__/game_detail__ prefix를 전량 batch-delete 후 재생성했다(kboRefresh.ts:16-39). game_detail__는 응답 기반 동적 TTL(종료 경기 7d, 라이브 30s 등)을 이미 갖는데도 매일 일괄 삭제되어, cron 직후 첫 상세 조회들이 **대량 캐시 미스 → 외부 KBO 재조회 폭주(stampede)**를 유발(backend-writes.md:69-78, 관찰 #6 :169).
수정 (Fix)
커밋 50349e6 — src/scheduled/kboRefresh.ts:
await invalidateByPrefix("game_detail__")라인 제거(주석으로 사유 명시).game_detail__는 자체 TTL 자연 만료에 위임.rank__/schedule_day__의 안전망 무효화 + 재fetch warm-up은 그대로 유지.
개선 (After)
- 02:00 직후
game_detail콜드 미스 폭주 제거 — 외부 KBO 상세 재조회/재write가 TTL 만료 시점으로 자연 분산. - 종료 경기 상세(7d TTL 등)는 다음 cron까지 살아남아 불필요한 재생성 write도 감소. rank/schedule 신선도는 기존 warm-up으로 보장.
Doc. CACHING.md gameListCache 문서 드리프트 정정 — 🟢
문제 (Before)
src/kbo/CACHING.md는 게임센터 메모리 캐시에 대해 실제와 다른 내용을 기술했다: ① "사이즈 cap 100개(초과 시 oldest evict)" — 실제 gameListService는 new MemCache(10_000)로 maxSize를 주지 않음. ② "/metrics/gameListCache/{date}/{hit|miss} RTDB 카운터" — 실제 미구현. 코드↔문서 불일치(로드맵 Doc 항목 optimization-plan.md:72).
수정 (Fix)
커밋 82198a6 — src/kbo/CACHING.md:
- 사이즈 cap 항목을 "미설정,
MemCache(10_000)은maxSize미전달, 만료 항목은set()시 정리되나 동시 보관 상한 없음(키 카디널리티=날짜×시리즈×리그로 작아 사실상 무해), 필요 시 생성자 2번째 인자로maxSize전달 시 LRU-ish evict"로 정정. - 조회 흐름을 실제 API(
get/set,getOrFetch의 동일 키 coalesce)로 갱신. - 메트릭 항목을 "현재 미구현"으로 정정하고 도입 시 권장안(
ServerValue.incrementfire-and-forget)만 남김.
개선 (After)
투기적 코드 추가 대신 문서를 실제 동작에 일치시켜 드리프트 해소. 향후 cap/메트릭 도입 시의 정확한 후크 지점(MemCache 생성자 인자)도 명시.