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

19 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 공유

요약: 매일 새벽 도는 자동 정산 작업(dailyArchive)은 사용자마다 "어제 경기 결과"와 "최근 연속 참여 여부"를 따지려고 같은 날짜의 경기 목록을 Firestore(구글이 제공하는 클라우드 데이터베이스)에서 거듭 꺼내 읽었다. 사용자가 1,000명이면 똑같은 날짜의 경기 목록을 1,000번 중복으로 조회하는 셈이다. 이번 수정에서는 한 번 읽은 날짜별 경기 목록을 정산 작업 한 회분 동안 임시 저장(캐싱)해 모든 사용자가 함께 쓰도록 바꿔, 날짜당 단 1번만 읽도록 줄였다. 결과 값은 그대로이고 읽는 횟수만 줄어든다.

배경

야구 앱은 매일 새벽 정해진 시각에 "어제 경기가 어떻게 끝났는지" 확인하고 사용자별 적중·연속 참여 기록을 정리하는 자동 작업(dailyArchive, 사람이 누르지 않아도 정해진 시각에 스스로 도는 예약 작업 = cron)을 돌린다. 이 작업은 ① 어제 경기 목록을 날짜로 조회해 정산하고(judgeDay), ② "이 사용자가 경기 없는 날(휴장일)을 빼고 연속으로 참여했는지"를 판단하려고 과거 최대 14일치 경기 목록까지 거슬러 올라가 확인(look-back)한다. 문제는 이 똑같은 날짜 조회를 사용자 한 명을 처리할 때마다 처음부터 다시 실행했다는 점이다.

문제 (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

정산 작업이 시작될 때 "날짜별 경기 목록을 담아 두는 메모장(캐시)" 하나를 만들어, 그 작업 안에서 모든 사용자와 모든 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)로 주입.
    • 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 완화

요약: 앱이 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회 → 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

요약: 사용자가 랭킹 화면을 열면 서버는 "내 현재 순위"를 보여주려고 본인 프로필을 데이터베이스에서 읽어 온다. 그런데 이 프로필 문서에는 이름·사진 외에도 알림 설정, 연속 참여 기록(스트릭), 티켓 등 화면에 필요 없는 정보까지 잔뜩 들어 있어, 매번 통째로 받아오는 건 낭비였다. 이번 수정에서는 랭킹 화면에 실제로 쓰는 5개 항목만 골라 받도록(fieldMask) 바꿨다. 읽는 횟수는 그대로 1번이지만, 한 번에 주고받는 데이터 양(대역폭)이 줄었다.

배경

랭킹(스코어보드) 화면은 "지금 내 순위가 몇 등인지"를 보여주기 위해 서버가 사용자 본인의 프로필을 데이터베이스(users/{uid} 문서, 사용자 한 명당 하나씩 있는 정보 묶음)에서 읽어 온다. 이 문서 하나에는 표시 이름·프로필 사진 같은 기본 정보뿐 아니라 알림 설정, 스트릭, 티켓 등 다른 기능에서 쓰는 필드까지 한데 모여 있다. 랭킹 화면이 실제로 필요로 하는 건 그중 5개(이름, 사진, 점수, 응원팀, 순위 스냅샷)뿐이다.

문제 (Before)

필요한 건 5개 항목인데도, 본인 프로필 문서를 통째로 받아왔다. 우편으로 주소 한 줄만 확인하면 되는데 서류철 전체를 부쳐 받는 격이다. 게다가 랭킹 화면은 자주 새로 고쳐지는(폴링성) 화면이라 요청이 잦아, 매 요청마다 쓰지도 않는 데이터를 실어 나르는 낭비가 반복됐다.

  • 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

랭킹 화면 전용으로 "필요한 5개 항목만 콕 집어 받아오는" 읽기 함수를 새로 만들어, 기존의 "통째로 받기"를 이걸로 교체했다. 데이터베이스에 "이 문서에서 이 5개 필드만 주세요"라고 요청하는 방식(필드 마스크)이다.

  • 파일: 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

요약: 상대전적 기능은 두 팀이 여러 해에 걸쳐 어떻게 맞붙었는지 보여주는데, 이를 위해 여러 연도의 순위 데이터를 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 누적 집계 분리 (보류)

쉬운 설명: 투표 통계를 보여줄 때 지금은 "지난 모든 기록을 처음부터 다시 계산"한다. 이걸 "결과가 나올 때마다 누적값을 조금씩 더해 두고 그 값을 쓰는 방식"으로 바꾸자는 제안이었다. 하지만 나중에 판정이 정정되면(예: 무효 처리됐던 경기가 다시 인정되면) 미리 더해 둔 누적값을 정확히 되돌리는 별도 장치가 필요한데, 이 장치가 빠지거나 어긋나면 통계가 영구히 틀어질 위험이 있어 이번에는 보류했다.

  • 제안 내용: 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 축소 (보류)

쉬운 설명: 새벽 정산 작업은 모든 사용자의 투표가 모인 저장소(RTDB의 /userVotes, RTDB는 실시간 데이터베이스)를 통째로 읽는데, 실제로는 "어제 날짜" 부분만 읽으면 충분하다. 그런데 Firebase의 서버용 도구(Admin SDK)에는 "원하는 깊이까지만 얕게 읽기" 기능이 없어, 서버 코드 수준에서 깔끔하게 어제치만 골라 읽기가 어렵다. 근본 해결책은 투표를 저장할 때 날짜별 위치에도 같이 기록해 두는 구조 변경인데, 이는 정확성이 가장 중요한 정산 경로를 건드리는 일이라 이번 범위 밖으로 미뤘다.

  • 제안 내용: 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를 인덱스 경로로 전환해야 한다.