870615219b
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5.4 KiB
5.4 KiB
매치 상세 동기화 구조
전적검색의 매치 기록(맵/참가자 상세) 데이터를 넥슨 오픈 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회만) - ②/③ 공통:
backfillInFlightSet으로 동일 매치 동시 적재 방지
환경별 한도
| 항목 | 개발 | 운영 | 코드 위치 |
|---|---|---|---|
| 프론트 더보기 노출 | 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 크론 수동 트리거 | 개발 편의 | 개발 환경 전용 트리거 엔드포인트 |