mmday-firebase/docs/data-patterns/improvements-backend-reads.md
윤정민 7fbc4f9501 Expand backend-read improvement report for non-expert readability
- improvements-backend-reads.md 각 항목에 배경 단락 및 평이한 설명 추가
- 비전문가도 이해할 수 있도록 비유·용어 괄호 설명 포함
- 보류 항목 R2·R3의 사유를 쉬운 말로 풀어 설명
2026-05-28 19:50:32 +09:00

161 lines
19 KiB
Markdown
Raw Permalink 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` 공유
> **요약**: 매일 새벽 도는 자동 정산 작업(dailyArchive)은 사용자마다 "어제 경기 결과"와 "최근 연속 참여 여부"를 따지려고 같은 날짜의 경기 목록을 Firestore(구글이 제공하는 클라우드 데이터베이스)에서 거듭 꺼내 읽었다. 사용자가 1,000명이면 똑같은 날짜의 경기 목록을 1,000번 중복으로 조회하는 셈이다. 이번 수정에서는 한 번 읽은 날짜별 경기 목록을 정산 작업 한 회분 동안 임시 저장(캐싱)해 모든 사용자가 함께 쓰도록 바꿔, 날짜당 단 1번만 읽도록 줄였다. 결과 값은 그대로이고 읽는 횟수만 줄어든다.
### 배경
야구 앱은 매일 새벽 정해진 시각에 "어제 경기가 어떻게 끝났는지" 확인하고 사용자별 적중·연속 참여 기록을 정리하는 자동 작업(`dailyArchive`, 사람이 누르지 않아도 정해진 시각에 스스로 도는 예약 작업 = cron)을 돌린다. 이 작업은 ① 어제 경기 목록을 날짜로 조회해 정산하고(`judgeDay`), ② "이 사용자가 경기 없는 날(휴장일)을 빼고 연속으로 참여했는지"를 판단하려고 과거 최대 14일치 경기 목록까지 거슬러 올라가 확인(look-back)한다. 문제는 이 똑같은 날짜 조회를 사용자 한 명을 처리할 때마다 처음부터 다시 실행했다는 점이다.
### 문제 (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`
정산 작업이 시작될 때 "날짜별 경기 목록을 담아 두는 메모장(캐시)" 하나를 만들어, 그 작업 안에서 모든 사용자와 모든 look-back 조회가 이 메모장을 공유하도록 바꿨다. 한 번 읽은 날짜는 메모장에 기록돼 있어 두 번째부터는 데이터베이스 대신 메모장에서 바로 꺼낸다.
- **파일**: `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 완화
> **요약**: 앱이 KBO 데이터(순위·일정 등)를 요청하면 서버는 미리 저장해 둔 사본(캐시)부터 확인하고, 사본이 없으면 KBO 사이트에서 새로 가져온다. 여러 요청이 한꺼번에 몰려 모두 "사본 없음"으로 판단할 때는, 딱 한 요청만 실제로 데이터를 가져오게 하고(분산 잠금 = 락) 나머지 요청은 사본이 채워질 때까지 기다리며 "다 됐나?"를 반복해서 확인(폴링)한다. 기존에는 이 확인을 0.5초마다 최대 50번씩 했는데, 이는 대부분 헛걸음이라 불필요한 데이터베이스 읽기를 잔뜩 만들었다. 이번 수정에서는 확인 간격을 점점 길게 늘리는 방식(지수 백오프)으로 바꿔, 같은 시간 동안 확인 횟수를 약 50회에서 8~9회로 줄였다.
### 배경
앱에서 KBO 순위표나 경기 일정을 열면 서버는 매번 KBO 사이트를 긁어오는 대신, 한 번 가져온 결과를 데이터베이스(Firestore의 `kboCache`)에 사본으로 저장해 두고 재사용한다. 그런데 사본이 아직 없거나 만료된 순간에 사용자 요청이 동시에 여러 개 몰리면, 모두가 동시에 "사본이 없네"라고 판단해 다 같이 KBO 사이트를 긁으려는 상황(캐시 스탬피드, 댐이 터지듯 몰리는 현상)이 벌어진다. 이를 막으려고 "한 요청만 데이터를 가져오고(락을 잡고) 나머지는 기다리게" 하는데, 기다리는 요청들은 사본이 채워졌는지 주기적으로 들여다본다(폴링). 이 들여다보는 행위 자체도 데이터베이스 읽기라서, 너무 자주 하면 그만큼 비용이 된다.
### 문제 (Before)
기다리는 요청들이 0.5초라는 짧고 일정한 간격으로 계속 "다 됐나?"를 물었다. 마치 주문한 음식이 나올 때까지 30초마다가 아니라 0.5초마다 주방을 들여다보는 것과 같아서, 한 요청이 끝까지 기다리는 25초 동안 최대 50번이나 데이터베이스를 읽었다. 요청이 몰릴수록(락 경합·콜드스타트) 이 헛읽기가 요청 수만큼 더해져 비용이 크게 불어났다.
- 락 보유자가 캐시를 채울 때까지 대기하는 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`
확인 간격을 0.5초로 고정하던 것을 "처음엔 짧게, 갈수록 길게(0.3초 → 0.6초 → 1.2초 …)" 늘어나는 방식으로 바꿨다. 사본이 곧 채워질 가능성이 높은 초반에는 자주 확인하고, 오래 기다리는 상황에서는 띄엄띄엄 확인해 헛읽기를 줄이는 것이다.
- **파일**: `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
> **요약**: 사용자가 랭킹 화면을 열면 서버는 "내 현재 순위"를 보여주려고 본인 프로필을 데이터베이스에서 읽어 온다. 그런데 이 프로필 문서에는 이름·사진 외에도 알림 설정, 연속 참여 기록(스트릭), 티켓 등 화면에 필요 없는 정보까지 잔뜩 들어 있어, 매번 통째로 받아오는 건 낭비였다. 이번 수정에서는 랭킹 화면에 실제로 쓰는 5개 항목만 골라 받도록(`fieldMask`) 바꿨다. 읽는 횟수는 그대로 1번이지만, 한 번에 주고받는 데이터 양(대역폭)이 줄었다.
### 배경
랭킹(스코어보드) 화면은 "지금 내 순위가 몇 등인지"를 보여주기 위해 서버가 사용자 본인의 프로필을 데이터베이스(`users/{uid}` 문서, 사용자 한 명당 하나씩 있는 정보 묶음)에서 읽어 온다. 이 문서 하나에는 표시 이름·프로필 사진 같은 기본 정보뿐 아니라 알림 설정, 스트릭, 티켓 등 다른 기능에서 쓰는 필드까지 한데 모여 있다. 랭킹 화면이 실제로 필요로 하는 건 그중 5개(이름, 사진, 점수, 응원팀, 순위 스냅샷)뿐이다.
### 문제 (Before)
필요한 건 5개 항목인데도, 본인 프로필 문서를 통째로 받아왔다. 우편으로 주소 한 줄만 확인하면 되는데 서류철 전체를 부쳐 받는 격이다. 게다가 랭킹 화면은 자주 새로 고쳐지는(폴링성) 화면이라 요청이 잦아, 매 요청마다 쓰지도 않는 데이터를 실어 나르는 낭비가 반복됐다.
- `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`
랭킹 화면 전용으로 "필요한 5개 항목만 콕 집어 받아오는" 읽기 함수를 새로 만들어, 기존의 "통째로 받기"를 이걸로 교체했다. 데이터베이스에 "이 문서에서 이 5개 필드만 주세요"라고 요청하는 방식(필드 마스크)이다.
- **파일**: `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
> **요약**: 상대전적 기능은 두 팀이 여러 해에 걸쳐 어떻게 맞붙었는지 보여주는데, 이를 위해 여러 연도의 순위 데이터를 KBO 캐시에서 가져온다. 기존에는 연도를 한 해씩 차례로 처리해서, 사본이 없어 새로 가져와야 할 때 각 연도의 대기 시간이 줄줄이 더해졌다. 이번 수정에서는 여러 연도를 동시에 가져오도록(병렬 처리) 바꿔, 전체 대기 시간을 "연도 수만큼 곱한 시간"에서 "한 해를 가져오는 시간 정도"로 단축했다. 읽는 횟수는 그대로다.
### 배경
상대전적 화면은 두 팀이 특정 연도에 어떻게 붙었는지를 여러 해에 걸쳐 보여 준다. 이때 서버는 연도별로 순위 데이터를 KBO 캐시에서 가져오는데, 사본이 있으면 즉시 꺼내 오지만 사본이 없으면(콜드/만료) KBO 사이트에서 새로 가져와야 해서 시간이 걸린다.
### 문제 (Before)
여러 연도가 필요할 때 한 해를 다 가져온 뒤에야 다음 해를 가져오는 식으로 한 줄로 세워 처리했다. 다섯 곳에 심부름을 보낼 때 한 사람을 보내 돌아오면 다음 사람을 보내는 격이라, 사본이 없는 연도가 많을수록 대기 시간이 연도 수만큼 차곡차곡 쌓였다. 같은 일을 다섯 명에게 동시에 시키면 한 번 다녀올 시간이면 끝난다.
- `fetchRankFromKbo(years)``for` 루프로 연도마다 `getOrFetch`를 **순차 await**했다(`kboRepository.ts:46`). 연도별 캐시 키(`rank__{year}`)가 독립적인데도 직렬 대기.
- 캐시 히트면 무해하나, **동시 miss(콜드/만료) 시 외부 KBO fetch가 직렬**이라 다년도 조회 지연 = N × 단년 지연 (`backend-reads.md:40,171`).
### 수정 (Fix) — `e9ba36c`
연도를 한 해씩 차례로 기다리던 반복문을, 모든 연도를 한꺼번에 띄워 놓고 다 끝나기를 기다리는 방식(`Promise.all`, 여러 작업을 동시에 시작해 전부 완료될 때까지 기다리는 도구)으로 바꿨다. 연도별 데이터는 서로 독립적이라 동시에 가져와도 안전하다.
- **파일**: `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 축소 (보류)
> **쉬운 설명**: 새벽 정산 작업은 모든 사용자의 투표가 모인 저장소(RTDB의 `/userVotes`, RTDB는 실시간 데이터베이스)를 통째로 읽는데, 실제로는 "어제 날짜" 부분만 읽으면 충분하다. 그런데 Firebase의 서버용 도구(Admin SDK)에는 "원하는 깊이까지만 얕게 읽기" 기능이 없어, 서버 코드 수준에서 깔끔하게 어제치만 골라 읽기가 어렵다. 근본 해결책은 투표를 저장할 때 날짜별 위치에도 같이 기록해 두는 구조 변경인데, 이는 정확성이 가장 중요한 정산 경로를 건드리는 일이라 이번 범위 밖으로 미뤘다.
- **제안 내용**: `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를 인덱스 경로로 전환해야 한다.