Expand backend-read improvement report for non-expert readability

- improvements-backend-reads.md 각 항목에 배경 단락 및 평이한 설명 추가
- 비전문가도 이해할 수 있도록 비유·용어 괄호 설명 포함
- 보류 항목 R2·R3의 사유를 쉬운 말로 풀어 설명
This commit is contained in:
윤정민 2026-05-28 19:50:32 +09:00
parent dafaacc446
commit 7fbc4f9501

View File

@ -15,14 +15,21 @@
## R1. `dailyArchive` 날짜별 `listByDate` 공유 ## R1. `dailyArchive` 날짜별 `listByDate` 공유
> **요약**: 기존에는 아카이브 run에서 `judgeDay``hasMissedGameDayBetween`**유저마다** 동일 날짜의 `games` 범위쿼리(`listByDate`)를 재실행하는 방식이라 `U × (1 + 최대 14)`회로 read가 U배 증폭됐으므로, run당 1개의 메모이징 캐시(`GameDayCache`)를 생성해 유저 루프 전체와 look-back 루프에 공유 주입함으로써 distinct 날짜당 1회로 수렴시키는 방식으로 수정했다. > **요약**: 매일 새벽 도는 자동 정산 작업(dailyArchive)은 사용자마다 "어제 경기 결과"와 "최근 연속 참여 여부"를 따지려고 같은 날짜의 경기 목록을 Firestore(구글이 제공하는 클라우드 데이터베이스)에서 거듭 꺼내 읽었다. 사용자가 1,000명이면 똑같은 날짜의 경기 목록을 1,000번 중복으로 조회하는 셈이다. 이번 수정에서는 한 번 읽은 날짜별 경기 목록을 정산 작업 한 회분 동안 임시 저장(캐싱)해 모든 사용자가 함께 쓰도록 바꿔, 날짜당 단 1번만 읽도록 줄였다. 결과 값은 그대로이고 읽는 횟수만 줄어든다.
### 배경
야구 앱은 매일 새벽 정해진 시각에 "어제 경기가 어떻게 끝났는지" 확인하고 사용자별 적중·연속 참여 기록을 정리하는 자동 작업(`dailyArchive`, 사람이 누르지 않아도 정해진 시각에 스스로 도는 예약 작업 = cron)을 돌린다. 이 작업은 ① 어제 경기 목록을 날짜로 조회해 정산하고(`judgeDay`), ② "이 사용자가 경기 없는 날(휴장일)을 빼고 연속으로 참여했는지"를 판단하려고 과거 최대 14일치 경기 목록까지 거슬러 올라가 확인(look-back)한다. 문제는 이 똑같은 날짜 조회를 사용자 한 명을 처리할 때마다 처음부터 다시 실행했다는 점이다.
### 문제 (Before) ### 문제 (Before)
사용자 한 명을 정산할 때마다 같은 날짜의 경기 목록을 매번 새로 읽다 보니, 사용자가 많아질수록 똑같은 데이터를 읽는 횟수가 사용자 수만큼 곱절로 불어났다. 마치 같은 책을 보러 온 사람마다 도서관 사서가 매번 서고에 새로 다녀오는 것과 같다 — 한 번 꺼내 책상에 두고 모두가 보면 될 일이다.
- 아카이브 1회 run에서 `judgeDay``hasMissedGameDayBetween`**유저마다** 동일 날짜의 `games` 범위쿼리(`listByDate`)를 재실행했다. - 아카이브 1회 run에서 `judgeDay``hasMissedGameDayBetween`**유저마다** 동일 날짜의 `games` 범위쿼리(`listByDate`)를 재실행했다.
- 유저당 비용: `judgeDay`의 대상 날짜 1회 + `hasMissedGameDayBetween`의 직전 결석 판정 look-back **최대 14회** = 최대 15회. - 유저당 비용: `judgeDay`의 대상 날짜 1회 + `hasMissedGameDayBetween`의 직전 결석 판정 look-back **최대 14회** = 최대 15회.
- 따라서 U명 아카이브 시 같은 날짜군의 `games` 쿼리를 **`U × (1 + 최대 14)`회** 반복 → U배 증폭. 유저·시즌 증가 시 가장 빠르게 악화하는 READ 비용 1순위 (`backend-reads.md:141,164`). - 따라서 U명 아카이브 시 같은 날짜군의 `games` 쿼리를 **`U × (1 + 최대 14)`회** 반복 → U배 증폭. 유저·시즌 증가 시 가장 빠르게 악화하는 READ 비용 1순위 (`backend-reads.md:141,164`).
### 수정 (Fix) — `5a498c3` ### 수정 (Fix) — `5a498c3`
정산 작업이 시작될 때 "날짜별 경기 목록을 담아 두는 메모장(캐시)" 하나를 만들어, 그 작업 안에서 모든 사용자와 모든 look-back 조회가 이 메모장을 공유하도록 바꿨다. 한 번 읽은 날짜는 메모장에 기록돼 있어 두 번째부터는 데이터베이스 대신 메모장에서 바로 꺼낸다.
- **파일**: `src/repositories/gameRepository.ts`, `src/scheduled/dailyArchive.ts`, `src/services/judgmentService.ts`. - **파일**: `src/repositories/gameRepository.ts`, `src/scheduled/dailyArchive.ts`, `src/services/judgmentService.ts`.
- **메커니즘**: - **메커니즘**:
- `gameRepository`에 메모이징 캐시 `GameDayCache` + 팩토리 `createGameDayCache()` 신설. 날짜→`Promise<GameWithId[]>` `Map`을 보유해 **Promise 자체를 캐싱**하므로 동시 호출도 단일 쿼리로 coalescing된다. - `gameRepository`에 메모이징 캐시 `GameDayCache` + 팩토리 `createGameDayCache()` 신설. 날짜→`Promise<GameWithId[]>` `Map`을 보유해 **Promise 자체를 캐싱**하므로 동시 호출도 단일 쿼리로 coalescing된다.
@ -39,9 +46,14 @@
## R4. 캐시 스탬피드 폴링 read 완화 ## R4. 캐시 스탬피드 폴링 read 완화
> **요약**: 기존에는 락 보유자가 캐시를 채울 때까지 대기하는 3개 폴링 루프가 모두 **고정 500ms 간격**으로 폴링하는 방식이라 25s 윈도우 안에서 대기 요청마다 ~50회씩 read가 발생해 락 경합·콜드스타트 버스트에서 read가 증폭됐으므로, 공유 헬퍼 `backoffDelayMs`로 지수 백오프를 적용해 폴링 시도를 ~8~9회로 줄이는 방식으로 수정했다. > **요약**: 앱이 KBO 데이터(순위·일정 등)를 요청하면 서버는 미리 저장해 둔 사본(캐시)부터 확인하고, 사본이 없으면 KBO 사이트에서 새로 가져온다. 여러 요청이 한꺼번에 몰려 모두 "사본 없음"으로 판단할 때는, 딱 한 요청만 실제로 데이터를 가져오게 하고(분산 잠금 = 락) 나머지 요청은 사본이 채워질 때까지 기다리며 "다 됐나?"를 반복해서 확인(폴링)한다. 기존에는 이 확인을 0.5초마다 최대 50번씩 했는데, 이는 대부분 헛걸음이라 불필요한 데이터베이스 읽기를 잔뜩 만들었다. 이번 수정에서는 확인 간격을 점점 길게 늘리는 방식(지수 백오프)으로 바꿔, 같은 시간 동안 확인 횟수를 약 50회에서 8~9회로 줄였다.
### 배경
앱에서 KBO 순위표나 경기 일정을 열면 서버는 매번 KBO 사이트를 긁어오는 대신, 한 번 가져온 결과를 데이터베이스(Firestore의 `kboCache`)에 사본으로 저장해 두고 재사용한다. 그런데 사본이 아직 없거나 만료된 순간에 사용자 요청이 동시에 여러 개 몰리면, 모두가 동시에 "사본이 없네"라고 판단해 다 같이 KBO 사이트를 긁으려는 상황(캐시 스탬피드, 댐이 터지듯 몰리는 현상)이 벌어진다. 이를 막으려고 "한 요청만 데이터를 가져오고(락을 잡고) 나머지는 기다리게" 하는데, 기다리는 요청들은 사본이 채워졌는지 주기적으로 들여다본다(폴링). 이 들여다보는 행위 자체도 데이터베이스 읽기라서, 너무 자주 하면 그만큼 비용이 된다.
### 문제 (Before) ### 문제 (Before)
기다리는 요청들이 0.5초라는 짧고 일정한 간격으로 계속 "다 됐나?"를 물었다. 마치 주문한 음식이 나올 때까지 30초마다가 아니라 0.5초마다 주방을 들여다보는 것과 같아서, 한 요청이 끝까지 기다리는 25초 동안 최대 50번이나 데이터베이스를 읽었다. 요청이 몰릴수록(락 경합·콜드스타트) 이 헛읽기가 요청 수만큼 더해져 비용이 크게 불어났다.
- 락 보유자가 캐시를 채울 때까지 대기하는 3개 폴링 루프가 모두 **고정 500ms 간격**으로 폴링했다. - 락 보유자가 캐시를 채울 때까지 대기하는 3개 폴링 루프가 모두 **고정 500ms 간격**으로 폴링했다.
- `kboCacheRepository.waitForCache` (≤25s → 최대 ~50회 `getCached`), - `kboCacheRepository.waitForCache` (≤25s → 최대 ~50회 `getCached`),
- `kboRepository.fetchScheduleMonth`의 월 폴링 (≤50회 × `readDayDocs` ~30 doc), - `kboRepository.fetchScheduleMonth`의 월 폴링 (≤50회 × `readDayDocs` ~30 doc),
@ -49,6 +61,8 @@
- 락 경합·콜드스타트 동시요청에서 **대기 요청마다 수십 read** 발생 → READ 증폭 (`backend-reads.md:167`, `optimization-plan.md:R4`). - 락 경합·콜드스타트 동시요청에서 **대기 요청마다 수십 read** 발생 → READ 증폭 (`backend-reads.md:167`, `optimization-plan.md:R4`).
### 수정 (Fix) — `69f31ae` ### 수정 (Fix) — `69f31ae`
확인 간격을 0.5초로 고정하던 것을 "처음엔 짧게, 갈수록 길게(0.3초 → 0.6초 → 1.2초 …)" 늘어나는 방식으로 바꿨다. 사본이 곧 채워질 가능성이 높은 초반에는 자주 확인하고, 오래 기다리는 상황에서는 띄엄띄엄 확인해 헛읽기를 줄이는 것이다.
- **파일**: `src/repositories/kboCacheRepository.ts`, `src/repositories/kboRepository.ts`, `src/services/gameDetailService.ts`. - **파일**: `src/repositories/kboCacheRepository.ts`, `src/repositories/kboRepository.ts`, `src/services/gameDetailService.ts`.
- **메커니즘**: - **메커니즘**:
- 공유 헬퍼 `backoffDelayMs(attempt, base=300, max=5000)` 신설 → `min(maxMs, baseMs × 2^attempt)` 지수 백오프. - 공유 헬퍼 `backoffDelayMs(attempt, base=300, max=5000)` 신설 → `min(maxMs, baseMs × 2^attempt)` 지수 백오프.
@ -69,13 +83,20 @@
## R5. 스코어보드 self-user read field-mask ## R5. 스코어보드 self-user read field-mask
> **요약**: 기존에는 `getScoreboard`가 본인 정보 계산에 `getUser(uid)`로 **전체 user doc**을 읽어 실제 사용하는 5개 필드 외 streak/티켓/notifications까지 폴링성 요청마다 전송하는 방식이라 bandwidth 낭비였으므로, `fieldMask`로 5개 필드만 페치하는 `getUserForScoreboard`를 도입하는 방식으로 수정했다. > **요약**: 사용자가 랭킹 화면을 열면 서버는 "내 현재 순위"를 보여주려고 본인 프로필을 데이터베이스에서 읽어 온다. 그런데 이 프로필 문서에는 이름·사진 외에도 알림 설정, 연속 참여 기록(스트릭), 티켓 등 화면에 필요 없는 정보까지 잔뜩 들어 있어, 매번 통째로 받아오는 건 낭비였다. 이번 수정에서는 랭킹 화면에 실제로 쓰는 5개 항목만 골라 받도록(`fieldMask`) 바꿨다. 읽는 횟수는 그대로 1번이지만, 한 번에 주고받는 데이터 양(대역폭)이 줄었다.
### 배경
랭킹(스코어보드) 화면은 "지금 내 순위가 몇 등인지"를 보여주기 위해 서버가 사용자 본인의 프로필을 데이터베이스(`users/{uid}` 문서, 사용자 한 명당 하나씩 있는 정보 묶음)에서 읽어 온다. 이 문서 하나에는 표시 이름·프로필 사진 같은 기본 정보뿐 아니라 알림 설정, 스트릭, 티켓 등 다른 기능에서 쓰는 필드까지 한데 모여 있다. 랭킹 화면이 실제로 필요로 하는 건 그중 5개(이름, 사진, 점수, 응원팀, 순위 스냅샷)뿐이다.
### 문제 (Before) ### 문제 (Before)
필요한 건 5개 항목인데도, 본인 프로필 문서를 통째로 받아왔다. 우편으로 주소 한 줄만 확인하면 되는데 서류철 전체를 부쳐 받는 격이다. 게다가 랭킹 화면은 자주 새로 고쳐지는(폴링성) 화면이라 요청이 잦아, 매 요청마다 쓰지도 않는 데이터를 실어 나르는 낭비가 반복됐다.
- `getScoreboard`가 본인 정보 계산에 **전체 user doc**을 `getUser(uid)`로 읽었다(`scoreboardService.ts:54`). 실제 사용 필드는 5개뿐인데 streak/티켓/notifications 등 전체 doc을 전송받아 bandwidth 낭비. - `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`). - 랭킹 화면은 폴링성 read(`Cache-Control: private, max-age=60`)라 요청마다 발생 (`backend-reads.md:86,168`).
### 수정 (Fix) — `60461c4` ### 수정 (Fix) — `60461c4`
랭킹 화면 전용으로 "필요한 5개 항목만 콕 집어 받아오는" 읽기 함수를 새로 만들어, 기존의 "통째로 받기"를 이걸로 교체했다. 데이터베이스에 "이 문서에서 이 5개 필드만 주세요"라고 요청하는 방식(필드 마스크)이다.
- **파일**: `src/repositories/userRepository.ts`, `src/services/scoreboardService.ts`. - **파일**: `src/repositories/userRepository.ts`, `src/services/scoreboardService.ts`.
- **메커니즘**: - **메커니즘**:
- `getUserForScoreboard(uid)` 신설 — `firestore.getAll(ref, { fieldMask: [...] })``displayName`, `photoUrl`, `tierPoints`, `favoriteTeamCode`, `rankSnapshot` **5개 필드만** 페치. 반환 타입 `ScoreboardSelfUser`로 부분집합 명시. - `getUserForScoreboard(uid)` 신설 — `firestore.getAll(ref, { fieldMask: [...] })``displayName`, `photoUrl`, `tierPoints`, `favoriteTeamCode`, `rankSnapshot` **5개 필드만** 페치. 반환 타입 `ScoreboardSelfUser`로 부분집합 명시.
@ -89,13 +110,20 @@
## R7. 다년도 rank 병렬 fetch ## R7. 다년도 rank 병렬 fetch
> **요약**: 기존에는 `fetchRankFromKbo(years)``for` 루프로 연도마다 `getOrFetch`를 **순차 await**하는 방식이라 연도별 캐시 키가 독립적인데도 동시 miss 시 외부 KBO fetch가 직렬화되어 다년도 조회 지연이 `N×T`로 누적됐으므로, `Promise.all`로 연도별 fetch를 병렬 실행해 `~1×T`로 단축하는 방식으로 수정했다. > **요약**: 상대전적 기능은 두 팀이 여러 해에 걸쳐 어떻게 맞붙었는지 보여주는데, 이를 위해 여러 연도의 순위 데이터를 KBO 캐시에서 가져온다. 기존에는 연도를 한 해씩 차례로 처리해서, 사본이 없어 새로 가져와야 할 때 각 연도의 대기 시간이 줄줄이 더해졌다. 이번 수정에서는 여러 연도를 동시에 가져오도록(병렬 처리) 바꿔, 전체 대기 시간을 "연도 수만큼 곱한 시간"에서 "한 해를 가져오는 시간 정도"로 단축했다. 읽는 횟수는 그대로다.
### 배경
상대전적 화면은 두 팀이 특정 연도에 어떻게 붙었는지를 여러 해에 걸쳐 보여 준다. 이때 서버는 연도별로 순위 데이터를 KBO 캐시에서 가져오는데, 사본이 있으면 즉시 꺼내 오지만 사본이 없으면(콜드/만료) KBO 사이트에서 새로 가져와야 해서 시간이 걸린다.
### 문제 (Before) ### 문제 (Before)
여러 연도가 필요할 때 한 해를 다 가져온 뒤에야 다음 해를 가져오는 식으로 한 줄로 세워 처리했다. 다섯 곳에 심부름을 보낼 때 한 사람을 보내 돌아오면 다음 사람을 보내는 격이라, 사본이 없는 연도가 많을수록 대기 시간이 연도 수만큼 차곡차곡 쌓였다. 같은 일을 다섯 명에게 동시에 시키면 한 번 다녀올 시간이면 끝난다.
- `fetchRankFromKbo(years)``for` 루프로 연도마다 `getOrFetch`를 **순차 await**했다(`kboRepository.ts:46`). 연도별 캐시 키(`rank__{year}`)가 독립적인데도 직렬 대기. - `fetchRankFromKbo(years)``for` 루프로 연도마다 `getOrFetch`를 **순차 await**했다(`kboRepository.ts:46`). 연도별 캐시 키(`rank__{year}`)가 독립적인데도 직렬 대기.
- 캐시 히트면 무해하나, **동시 miss(콜드/만료) 시 외부 KBO fetch가 직렬**이라 다년도 조회 지연 = N × 단년 지연 (`backend-reads.md:40,171`). - 캐시 히트면 무해하나, **동시 miss(콜드/만료) 시 외부 KBO fetch가 직렬**이라 다년도 조회 지연 = N × 단년 지연 (`backend-reads.md:40,171`).
### 수정 (Fix) — `e9ba36c` ### 수정 (Fix) — `e9ba36c`
연도를 한 해씩 차례로 기다리던 반복문을, 모든 연도를 한꺼번에 띄워 놓고 다 끝나기를 기다리는 방식(`Promise.all`, 여러 작업을 동시에 시작해 전부 완료될 때까지 기다리는 도구)으로 바꿨다. 연도별 데이터는 서로 독립적이라 동시에 가져와도 안전하다.
- **파일**: `src/repositories/kboRepository.ts`. - **파일**: `src/repositories/kboRepository.ts`.
- **메커니즘**: `for` 루프 누적을 `Promise.all(years.map(...))`로 교체. 연도별 `getOrFetch`를 동시 실행. `Promise.all`은 **입력 순서를 보존**하므로 결과 배열 순서 불변. - **메커니즘**: `for` 루프 누적을 `Promise.all(years.map(...))`로 교체. 연도별 `getOrFetch`를 동시 실행. `Promise.all`은 **입력 순서를 보존**하므로 결과 배열 순서 불변.
@ -110,6 +138,9 @@
아래 두 항목은 이번 라운드에서 **의도적으로 보류**했다. 각각 정확성 리스크 또는 SDK 한계로 인해 안전한 재설계 비용이 기대 절감을 초과한다. 아래 두 항목은 이번 라운드에서 **의도적으로 보류**했다. 각각 정확성 리스크 또는 SDK 한계로 인해 안전한 재설계 비용이 기대 절감을 초과한다.
### R2 — `getStats` 누적 집계 분리 (보류) ### R2 — `getStats` 누적 집계 분리 (보류)
> **쉬운 설명**: 투표 통계를 보여줄 때 지금은 "지난 모든 기록을 처음부터 다시 계산"한다. 이걸 "결과가 나올 때마다 누적값을 조금씩 더해 두고 그 값을 쓰는 방식"으로 바꾸자는 제안이었다. 하지만 나중에 판정이 정정되면(예: 무효 처리됐던 경기가 다시 인정되면) 미리 더해 둔 누적값을 정확히 되돌리는 별도 장치가 필요한데, 이 장치가 빠지거나 어긋나면 통계가 영구히 틀어질 위험이 있어 이번에는 보류했다.
- **제안 내용**: `invalidateStats``voteHistory` 전체 풀스캔을 줄이기 위해, overall/season 누적치를 판정 시 증분 갱신해 별도 저장하고 weekly/streak만 재계산 (`optimization-plan.md:R2`). - **제안 내용**: `invalidateStats``voteHistory` 전체 풀스캔을 줄이기 위해, overall/season 누적치를 판정 시 증분 갱신해 별도 저장하고 weekly/streak만 재계산 (`optimization-plan.md:R2`).
- **보류 사유**: - **보류 사유**:
1. **증분 집계의 정확성 리스크**: 판정 정정(judgment-correction)이 발생하면 누적치를 재계산(repath)하는 별도 경로가 필요하다. 정정이 누락되면 누적 통계가 영구히 드리프트한다. 1. **증분 집계의 정확성 리스크**: 판정 정정(judgment-correction)이 발생하면 누적치를 재계산(repath)하는 별도 경로가 필요하다. 정정이 누락되면 누적 통계가 영구히 드리프트한다.
@ -118,6 +149,9 @@
- **안전한 재설계 전제**: 판정·정정을 단일 진실원(single source)로 모으는 이벤트 소싱형 집계 경로 + week 키의 서버측 정규화(클라 날짜 의존 제거)가 선행되어야 한다. - **안전한 재설계 전제**: 판정·정정을 단일 진실원(single source)로 모으는 이벤트 소싱형 집계 경로 + week 키의 서버측 정규화(클라 날짜 의존 제거)가 선행되어야 한다.
### R3 — `dailyArchive``/userVotes` 전체 트리 read 축소 (보류) ### R3 — `dailyArchive``/userVotes` 전체 트리 read 축소 (보류)
> **쉬운 설명**: 새벽 정산 작업은 모든 사용자의 투표가 모인 저장소(RTDB의 `/userVotes`, RTDB는 실시간 데이터베이스)를 통째로 읽는데, 실제로는 "어제 날짜" 부분만 읽으면 충분하다. 그런데 Firebase의 서버용 도구(Admin SDK)에는 "원하는 깊이까지만 얕게 읽기" 기능이 없어, 서버 코드 수준에서 깔끔하게 어제치만 골라 읽기가 어렵다. 근본 해결책은 투표를 저장할 때 날짜별 위치에도 같이 기록해 두는 구조 변경인데, 이는 정확성이 가장 중요한 정산 경로를 건드리는 일이라 이번 범위 밖으로 미뤘다.
- **제안 내용**: `dailyArchive.ts:96``rtdb.ref("/userVotes").get()`(전 유저·전 날짜 트리 로드)을 타깃 날짜만 읽도록 축소 (`optimization-plan.md:R3`). - **제안 내용**: `dailyArchive.ts:96``rtdb.ref("/userVotes").get()`(전 유저·전 날짜 트리 로드)을 타깃 날짜만 읽도록 축소 (`optimization-plan.md:R3`).
- **보류 사유**: - **보류 사유**:
1. **Node Admin SDK에 shallow 쿼리 부재**: 클라 REST의 `shallow=true`에 해당하는 기능이 Admin SDK에 없어, "유저 키 목록만 얕게 조회 후 날짜별 fan-out"을 깔끔하게 구현할 수 없다. 1. **Node Admin SDK에 shallow 쿼리 부재**: 클라 REST의 `shallow=true`에 해당하는 기능이 Admin SDK에 없어, "유저 키 목록만 얕게 조회 후 날짜별 fan-out"을 깔끔하게 구현할 수 없다.