- 백엔드 READ 최적화 적용 결과를 improvements-backend-reads.md에 문서화 - R1·R4·R5·R7 적용 내역 및 R2·R3 보류 사유 기록
9.5 KiB
9.5 KiB
백엔드 READ 최적화 개선 보고서 (Panit Firebase Functions)
대상 브랜치:
opt/backend-rw. 근거 문제 정의:backend-reads.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회 →
89회로 감소.
| 루프 | 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,rankSnapshot5개 필드만 페치. 반환 타입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). - 보류 사유:
- 증분 집계의 정확성 리스크: 판정 정정(judgment-correction)이 발생하면 누적치를 재계산(repath)하는 별도 경로가 필요하다. 정정이 누락되면 누적 통계가 영구히 드리프트한다.
- period 단위 무효화 fallback 불가:
invalidateStats는 경기 종료 시 투표자 전원에 대한 hot fan-out 경로라 키 단위 정밀 무효화로 바꾸기 어렵고, week 키는 클라이언트 날짜 문자열 기반이라 서버가 안전하게 타깃팅할 수 없다. - 순효익 marginal:
getStats는 이미forDate가드로 매일 자가 무효화된다. 즉 캐시가 하루 단위로는 동작하므로 추가 분리의 한계 이득이 작다.
- 안전한 재설계 전제: 판정·정정을 단일 진실원(single source)로 모으는 이벤트 소싱형 집계 경로 + week 키의 서버측 정규화(클라 날짜 의존 제거)가 선행되어야 한다.
R3 — dailyArchive의 /userVotes 전체 트리 read 축소 (보류)
- 제안 내용:
dailyArchive.ts:96의rtdb.ref("/userVotes").get()(전 유저·전 날짜 트리 로드)을 타깃 날짜만 읽도록 축소 (optimization-plan.md:R3). - 보류 사유:
- Node Admin SDK에 shallow 쿼리 부재: 클라 REST의
shallow=true에 해당하는 기능이 Admin SDK에 없어, "유저 키 목록만 얕게 조회 후 날짜별 fan-out"을 깔끔하게 구현할 수 없다. - 진짜 해법은 범위 밖: 날짜 인덱스 write 경로(
/userVotesByDate/{date}/{uid}등) 도입이 정공법이나, 이는 write 경로 변경이며 정확성이 중요한 아카이브 경로에 리스크를 더한다. - steady state에서 트리가 작음: 아카이브된 날짜는 처리 후 제거되므로
/userVotes트리는 정상 운영에서 작게 유지된다. 단일 read 비대화는 미아카이브 적체 시에만 문제.
- Node Admin SDK에 shallow 쿼리 부재: 클라 REST의
- 안전한 재설계 전제: 투표 write 시점에 날짜 인덱스를 동시 기록하는 경로를 먼저 도입하고(이중 기록·정합성 검증 포함), 아카이브 read를 인덱스 경로로 전환해야 한다.