From 870615219b66abdb7df2213e45a923be5a5f2173 Mon Sep 17 00:00:00 2001 From: TtiPo Date: Fri, 12 Jun 2026 10:11:47 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20=EB=A7=A4=EC=B9=98=20=EC=83=81=EC=84=B8?= =?UTF-8?q?=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EA=B5=AC=EC=A1=B0=20=EB=AC=B8?= =?UTF-8?q?=EC=84=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 4.8 (1M context) --- MATCH_SYNC.md | 106 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 MATCH_SYNC.md diff --git a/MATCH_SYNC.md b/MATCH_SYNC.md new file mode 100644 index 0000000..7e4e2ca --- /dev/null +++ b/MATCH_SYNC.md @@ -0,0 +1,106 @@ +# 매치 상세 동기화 구조 + +전적검색의 매치 기록(맵/참가자 상세) 데이터를 넥슨 오픈 API에서 수집·캐시하는 구조 문서. + +> 관련 코드: `backend/src/modules/sa-match/`, `frontend/src/pages/profile/` + +--- + +## 1. 설계 원칙 + +| 원칙 | 내용 | +| ---- | ---- | +| **불변 데이터** | 끝난 매치의 상세(맵/참가자)는 절대 변하지 않는다 → **매치당 평생 1회만** 넥슨 API 호출, 이후 영원히 DB 재사용 | +| **수집이 병목, 연산은 아님** | 평균·집계는 DB에서 밀리초면 끝난다. 문제는 상세 API가 매치당 1건씩만 제공된다는 것 → "실시간 전수 조회" 대신 **점진 수집** | +| **0초 대기** | 상세 적재는 응답을 블로킹하지 않는다(fire-and-forget). 사용자는 항상 즉시 목록을 보고, 맵은 점진적으로 채워진다 | +| **쿼터 보호** | 개발 키는 일일 1,000건 한도 → 환경별 백필 상한으로 보호 | + +--- + +## 2. 데이터 구조 + +``` +sa_matches 매치 목록 — (matchId, ouid) 단위. 본인 k/d/a·결과·일시 (맵 없음 = 목록 API가 미제공) +sa_match_details 상세 헤더 — matchId 단위. 맵 이름. 행 존재 = 적재 완료 (영구 캐시) +sa_match_participants 상세 참가자 — matchId 단위. 전원 k/d/a·헤드샷·데미지 +sa_match_detail_failures 실패 기록 — matchId 단위. failCount 누적, 한도 초과 시 백필 제외 +``` + +목록 응답의 맵 이름은 `sa_matches ⟕ sa_match_details` 조인으로 채운다. 캐시 미보유 매치는 빈 문자열(`''`)로 내려가고 프론트는 `—`로 표시한다. + +--- + +## 3. 상세 적재 경로 (3가지) + +| 경로 | 트리거 | 범위 | 블로킹 | +| ---- | ------ | ---- | ------ | +| ① 행 펼치기 | 매치 행 클릭 (`GET /sa-matches/:matchId`) | 해당 1건 | 동기 (사용자가 상세를 보는 중) | +| ② 온디맨드 백필 | 매치 목록 조회 (`getMatches`) | 맵 미보유 최신 N건 | **비동기** (fire-and-forget) | +| ③ 야간 크론 | 매일 03:00 KST (`handleDailyMatchSync`) | 미보유 전량 (환경별 한도) | 백그라운드 | + +- ②: 검색 1회 = 5개 매치 타입 병렬 호출이므로 실제 최대 적재는 `한도 × 5` +- ③: 목록 동기화(전 유저 × 5타입) 후 백필. 최신순, `matchId` 중복 제거(공유 매치는 1회만) +- ②/③ 공통: `backfillInFlight` Set으로 동일 매치 동시 적재 방지 + +### 환경별 한도 + +| 항목 | 개발 | 운영 | 코드 위치 | +| ---- | ---- | ---- | --------- | +| 프론트 더보기 노출 | 4개 | 20개 | `MatchHistory.vue` (`import.meta.env.DEV`) | +| 온디맨드 백필 (타입당) | 4건 | 20건 | `BACKFILL_LIMIT` | +| 크론 백필 (유저당) | 20건 | **무제한** | `CRON_PER_USER_BACKFILL_LIMIT` | +| 넥슨 rate limit | 5건/초 | 500건/초 | env `NEXON_API_RATE_LIMIT_PER_SEC` | + +환경 분기는 백엔드 `process.env.NODE_ENV === 'production'`, 프론트 `import.meta.env.DEV` 기준. + +--- + +## 4. 실패 마킹 (무한 재시도 방지) + +넥슨이 상세를 끝내 제공하지 않는 매치(삭제/만료 등)가 매일 재시도되며 쿼터를 낭비하는 것을 막는다. + +- 백필 실패 시 `sa_match_detail_failures.failCount` 누적 + 마지막 에러 기록 +- **`MAX_DETAIL_FAILURES`(3회) 초과 매치는 백필 대상에서 제외** — 크론은 쿼리(leftJoin), 온디맨드는 후보 선정 시 제외 +- 적재 **성공 시 실패 기록 삭제** — 일시 장애로 쌓인 카운트는 복구 후 자동 정리 +- 실패 "기록" 자체의 실패는 백필 흐름을 끊지 않음 (warn 로그만) + +--- + +## 5. 성능 특성 + +### rate limiter ≠ 처리량 + +크론 백필의 실제 처리량은 rate limiter가 아니라 **순차 `await` 루프 구조**가 결정한다. + +``` +순차 루프: 동시 요청이 항상 1개 → 처리량 = 1 ÷ 호출당 왕복시간(~250ms) ≈ 초당 4건 +→ 운영 한도 500/s는 한 번도 닿지 않음 (점유율 ~0.8%) +``` + +| 규모 | 크론 소요 시간 (순차 기준) | +| ---- | ------------------------ | +| 1,000건 | ~5분 | +| 10,000건 | **~50분** (최초 캐치업 1회성. steady state는 일 신규분만 → 수 분) | + +### 검색과의 경합 — 운영 기준 없음 + +| 자원 | 크론 점유 | 한도 | 판정 | +| ---- | -------- | ---- | ---- | +| 넥슨 rate limit | ~4건/초 | 500건/초 | 0.8% — 경합 없음 | +| Node 이벤트 루프 | I/O 대기 위주 | — | `await`마다 양보, 서버 응답성 유지 | +| DB 커넥션 풀 | 1개 (순차) | 100개 | 영향 없음 | + +개발(5/s)에서는 크론의 ~4/s가 한도의 80%라 동시 검색이 몇 초 지연될 수 있으나, 유저당 20건 캡 덕분에 크론 자체가 ~수십 초면 끝난다. + +> 과거 "동기화 중 검색 차단(SyncState)" 로직이 있었으나, 위 분석(경합 사실상 없음)에 따라 제거됨. + +--- + +## 6. 향후 개선 후보 + +| 항목 | 시점 | 방법 | +| ---- | ---- | ---- | +| 크론 동시성 | 일 신규분만으로 크론이 1시간 초과 시 | 순차 루프 → 동시 8건 풀 (10,000건 ≈ 5분, API 점유 6%) | +| Redis / BullMQ | 서버 다중 인스턴스 도입 시 | 크론 분산 락 + `backfillInFlight` 공유 + 백필 큐 영속화 | +| 헤드샷/데미지 평균 | 기능 요구 시 | 상세 적재 시 본인 참가자 행을 `sa_matches`에 역정규화 → SQL AVG + 커버리지 표기("적재된 N전 / 전체 M전 기준") | +| dev 크론 수동 트리거 | 개발 편의 | 개발 환경 전용 트리거 엔드포인트 |