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

9.5 KiB
Raw Blame History

백엔드 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에서 judgeDayhasMissedGameDayBetween유저마다 동일 날짜의 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)로 주입.
    • judgeDayhasMissedGameDayBetween(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 docgetUser(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로 부분집합 명시.
    • getScoreboardgetUsergetUserForScoreboard로 전환. 본인 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 누적 집계 분리 (보류)

  • 제안 내용: invalidateStatsvoteHistory 전체 풀스캔을 줄이기 위해, 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:96rtdb.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를 인덱스 경로로 전환해야 한다.