# 매치 상세 동기화 구조 전적검색의 매치 기록(맵/참가자 상세) 데이터를 넥슨 오픈 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 크론 수동 트리거 | 개발 편의 | 개발 환경 전용 트리거 엔드포인트 |