mmday-firebase/docs/data-patterns/improvements-backend-reads.md
윤정민 e6de4d1568 Document backend READ optimizations (R1/R4/R5/R7) and deferrals
- 백엔드 READ 최적화 적용 결과를 improvements-backend-reads.md에 문서화
- R1·R4·R5·R7 적용 내역 및 R2·R3 보류 사유 기록
2026-05-28 19:12:44 +09:00

119 lines
9.5 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 백엔드 READ 최적화 개선 보고서 (Panit Firebase Functions)
> 대상 브랜치: `opt/backend-rw`. 근거 문제 정의: [`backend-reads.md`](./backend-reads.md) · [`optimization-plan.md`](./optimization-plan.md).
> 각 항목은 **문제(Before) → 수정(Fix) → 개선(After)** 순서로 정리한다. 인용은 `파일:라인` 기준, 커밋은 short hash.
> 이번 라운드에서 착수한 READ 항목은 **R1·R4·R5·R7**, 의도적으로 보류한 항목은 **R2·R3**이다.
| 항목 | 영역 | 커밋 | 핵심 효과 |
|------|------|------|-----------|
| R1 | dailyArchive 날짜별 games 공유 | `5a498c3` | `U명×(1+최대14)` listByDate → **날짜당 1회** |
| R4 | 캐시 스탬피드 폴링 백오프 | `69f31ae` | 대기 요청당 폴링 read **~50회 → ~9회** (≈5×↓) |
| R5 | 스코어보드 self-user fieldMask | `60461c4` | 전체 user doc → **5개 필드만** 전송 |
| R7 | 다년도 rank 병렬 fetch | `e9ba36c` | 동시 miss 시 직렬 N×T → **병렬 ~1×T** |
---
## R1. `dailyArchive` 날짜별 `listByDate` 공유
### 문제 (Before)
- 아카이브 1회 run에서 `judgeDay``hasMissedGameDayBetween`**유저마다** 동일 날짜의 `games` 범위쿼리(`listByDate`)를 재실행했다.
- 유저당 비용: `judgeDay`의 대상 날짜 1회 + `hasMissedGameDayBetween`의 직전 결석 판정 look-back **최대 14회** = 최대 15회.
- 따라서 U명 아카이브 시 같은 날짜군의 `games` 쿼리를 **`U × (1 + 최대 14)`회** 반복 → U배 증폭. 유저·시즌 증가 시 가장 빠르게 악화하는 READ 비용 1순위 (`backend-reads.md:141,164`).
### 수정 (Fix) — `5a498c3`
- **파일**: `src/repositories/gameRepository.ts`, `src/scheduled/dailyArchive.ts`, `src/services/judgmentService.ts`.
- **메커니즘**:
- `gameRepository`에 메모이징 캐시 `GameDayCache` + 팩토리 `createGameDayCache()` 신설. 날짜→`Promise<GameWithId[]>` `Map`을 보유해 **Promise 자체를 캐싱**하므로 동시 호출도 단일 쿼리로 coalescing된다.
- `runDailyArchive`가 run 시작 시 캐시 1개를 생성해 유저 루프 전체에서 공유하고, `judgeDay(uid, date, {data}, gameCache)`로 주입.
- `judgeDay``hasMissedGameDayBetween(lastJudgedPre, date, fetch)`로 동일 인스턴스를 다시 넘겨 look-back 루프까지 캐시 공유. 휴장일 판정은 캐시된 쿼리 결과로 자연 메모이즈된다.
- 두 함수의 `gameCache` 인자는 **선택적**(`gameCache ?? createGameDayCache()`). 단독 호출자(`statsService` 등)는 호출당 기본 캐시를 받아 기존 동작 유지.
### 개선 (After)
- run 내 **distinct 날짜당 Firestore read 1회**로 수렴. 대상 날짜 + look-back 윈도우(약 15개 날짜)를 U와 무관하게 1회씩만 읽는다.
- 비용: `U × (1 + 최대 14)` listByDate → **distinct 날짜 수 (≈ U와 무관)**. U명 기준 약 **U배 절감**.
- 판정 결과 동일성 보장(쿼리 결과만 공유, 로직 불변).
---
## R4. 캐시 스탬피드 폴링 read 완화
### 문제 (Before)
- 락 보유자가 캐시를 채울 때까지 대기하는 3개 폴링 루프가 모두 **고정 500ms 간격**으로 폴링했다.
- `kboCacheRepository.waitForCache` (≤25s → 최대 ~50회 `getCached`),
- `kboRepository.fetchScheduleMonth`의 월 폴링 (≤50회 × `readDayDocs` ~30 doc),
- `gameDetailService.getOrFetchDynamic` (≤50회 `getCached`).
- 락 경합·콜드스타트 동시요청에서 **대기 요청마다 수십 read** 발생 → READ 증폭 (`backend-reads.md:167`, `optimization-plan.md:R4`).
### 수정 (Fix) — `69f31ae`
- **파일**: `src/repositories/kboCacheRepository.ts`, `src/repositories/kboRepository.ts`, `src/services/gameDetailService.ts`.
- **메커니즘**:
- 공유 헬퍼 `backoffDelayMs(attempt, base=300, max=5000)` 신설 → `min(maxMs, baseMs × 2^attempt)` 지수 백오프.
- 세 폴링 루프의 고정 `setTimeout(r, 500)``setTimeout(r, backoffDelayMs(attempt))`로 교체. `getOrFetchDynamic`은 기존 `for i<50` 카운트 기반에서 **시간 기반(`Date.now()-start < 25_000`)** 으로 정렬해 타임아웃 의미를 통일.
- 타임아웃 윈도우(~25s)와 타임아웃 후 fallback(캐시 우회 직접 fetch) 의미는 **불변**.
### 개선 (After)
- 지연 시퀀스: 0.3 → 0.6 → 1.2 → 2.4 → 4.8 → 5.0 → 5.0 … (초). 25s 윈도우 안에서 폴링 시도가 **~50회 → ~8~9회**로 감소.
| 루프 | Before (read/대기요청) | After (read/대기요청) |
|------|------------------------|------------------------|
| `waitForCache` / `getOrFetchDynamic` | ~50 getCached | ~9 getCached |
| schedule 월 폴링 | ~50 × 30 ≈ 1,500 doc | ~9 × 30 ≈ 270 doc |
- 대기 요청당 read 증폭 약 **5배 절감**. 락 경합·콜드스타트 버스트에서 효과가 크다.
---
## R5. 스코어보드 self-user read field-mask
### 문제 (Before)
- `getScoreboard`가 본인 정보 계산에 **전체 user doc**을 `getUser(uid)`로 읽었다(`scoreboardService.ts:54`). 실제 사용 필드는 5개뿐인데 streak/티켓/notifications 등 전체 doc을 전송받아 bandwidth 낭비.
- 랭킹 화면은 폴링성 read(`Cache-Control: private, max-age=60`)라 요청마다 발생 (`backend-reads.md:86,168`).
### 수정 (Fix) — `60461c4`
- **파일**: `src/repositories/userRepository.ts`, `src/services/scoreboardService.ts`.
- **메커니즘**:
- `getUserForScoreboard(uid)` 신설 — `firestore.getAll(ref, { fieldMask: [...] })``displayName`, `photoUrl`, `tierPoints`, `favoriteTeamCode`, `rankSnapshot` **5개 필드만** 페치. 반환 타입 `ScoreboardSelfUser`로 부분집합 명시.
- `getScoreboard``getUser``getUserForScoreboard`로 전환. 본인 rank count 측면(R5의 나머지 절반)은 기존 `meRankCache`(30s)가 이미 커버.
### 개선 (After)
- read **횟수는 1회로 동일**하나, 전송 페이로드를 전체 doc → 5개 필드로 축소해 **per-request bandwidth 절감**.
- 인덱스/쿼리 형태 변화 없음(문서 ID 직접 read). 정확성 영향 없음.
---
## R7. 다년도 rank 병렬 fetch
### 문제 (Before)
- `fetchRankFromKbo(years)``for` 루프로 연도마다 `getOrFetch`를 **순차 await**했다(`kboRepository.ts:46`). 연도별 캐시 키(`rank__{year}`)가 독립적인데도 직렬 대기.
- 캐시 히트면 무해하나, **동시 miss(콜드/만료) 시 외부 KBO fetch가 직렬**이라 다년도 조회 지연 = N × 단년 지연 (`backend-reads.md:40,171`).
### 수정 (Fix) — `e9ba36c`
- **파일**: `src/repositories/kboRepository.ts`.
- **메커니즘**: `for` 루프 누적을 `Promise.all(years.map(...))`로 교체. 연도별 `getOrFetch`를 동시 실행. `Promise.all`은 **입력 순서를 보존**하므로 결과 배열 순서 불변.
### 개선 (After)
- read **횟수는 동일**(연도당 캐시 1회), **지연(latency)** 개선: 동시 miss 시 직렬 `N×T` → 병렬 `~1×T`.
- 캐시 히트 경로는 영향 없음(원래 저렴).
---
## 보류 항목 (R2, R3)
아래 두 항목은 이번 라운드에서 **의도적으로 보류**했다. 각각 정확성 리스크 또는 SDK 한계로 인해 안전한 재설계 비용이 기대 절감을 초과한다.
### R2 — `getStats` 누적 집계 분리 (보류)
- **제안 내용**: `invalidateStats``voteHistory` 전체 풀스캔을 줄이기 위해, overall/season 누적치를 판정 시 증분 갱신해 별도 저장하고 weekly/streak만 재계산 (`optimization-plan.md:R2`).
- **보류 사유**:
1. **증분 집계의 정확성 리스크**: 판정 정정(judgment-correction)이 발생하면 누적치를 재계산(repath)하는 별도 경로가 필요하다. 정정이 누락되면 누적 통계가 영구히 드리프트한다.
2. **period 단위 무효화 fallback 불가**: `invalidateStats`는 경기 종료 시 투표자 전원에 대한 **hot fan-out 경로**라 키 단위 정밀 무효화로 바꾸기 어렵고, week 키는 **클라이언트 날짜 문자열 기반**이라 서버가 안전하게 타깃팅할 수 없다.
3. **순효익 marginal**: `getStats`는 이미 `forDate` 가드로 **매일 자가 무효화**된다. 즉 캐시가 하루 단위로는 동작하므로 추가 분리의 한계 이득이 작다.
- **안전한 재설계 전제**: 판정·정정을 단일 진실원(single source)로 모으는 이벤트 소싱형 집계 경로 + week 키의 서버측 정규화(클라 날짜 의존 제거)가 선행되어야 한다.
### R3 — `dailyArchive`의 `/userVotes` 전체 트리 read 축소 (보류)
- **제안 내용**: `dailyArchive.ts:96``rtdb.ref("/userVotes").get()`(전 유저·전 날짜 트리 로드)을 타깃 날짜만 읽도록 축소 (`optimization-plan.md:R3`).
- **보류 사유**:
1. **Node Admin SDK에 shallow 쿼리 부재**: 클라 REST의 `shallow=true`에 해당하는 기능이 Admin SDK에 없어, "유저 키 목록만 얕게 조회 후 날짜별 fan-out"을 깔끔하게 구현할 수 없다.
2. **진짜 해법은 범위 밖**: 날짜 인덱스 write 경로(`/userVotesByDate/{date}/{uid}` 등) 도입이 정공법이나, 이는 write 경로 변경이며 **정확성이 중요한 아카이브 경로**에 리스크를 더한다.
3. **steady state에서 트리가 작음**: 아카이브된 날짜는 처리 후 제거되므로 `/userVotes` 트리는 정상 운영에서 작게 유지된다. 단일 read 비대화는 미아카이브 적체 시에만 문제.
- **안전한 재설계 전제**: 투표 write 시점에 날짜 인덱스를 동시 기록하는 경로를 먼저 도입하고(이중 기록·정합성 검증 포함), 아카이브 read를 인덱스 경로로 전환해야 한다.