From e6de4d1568954fb7e7be172fc287104ab2491d01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A4=EC=A0=95=EB=AF=BC?= Date: Thu, 28 May 2026 19:12:44 +0900 Subject: [PATCH] Document backend READ optimizations (R1/R4/R5/R7) and deferrals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 백엔드 READ 최적화 적용 결과를 improvements-backend-reads.md에 문서화 - R1·R4·R5·R7 적용 내역 및 R2·R3 보류 사유 기록 --- .../improvements-backend-reads.md | 118 ++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 docs/data-patterns/improvements-backend-reads.md diff --git a/docs/data-patterns/improvements-backend-reads.md b/docs/data-patterns/improvements-backend-reads.md new file mode 100644 index 0000000..230f853 --- /dev/null +++ b/docs/data-patterns/improvements-backend-reads.md @@ -0,0 +1,118 @@ +# 백엔드 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` `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를 인덱스 경로로 전환해야 한다.