diff --git a/backend/src/modules/sa-match/dto/match-item.dto.ts b/backend/src/modules/sa-match/dto/match-item.dto.ts index f993ab4..1e485a2 100644 --- a/backend/src/modules/sa-match/dto/match-item.dto.ts +++ b/backend/src/modules/sa-match/dto/match-item.dto.ts @@ -25,4 +25,10 @@ export class MatchItemDto { @ApiProperty({ example: 3 }) assist!: number; + + @ApiProperty({ + description: '매치 맵 — 상세 캐시 보유 시에만 값 존재, 미보유면 빈 문자열(백그라운드 백필)', + example: '제3보급창고', + }) + matchMap!: string; } diff --git a/backend/src/modules/sa-match/entities/sa-match-detail-failure.entity.ts b/backend/src/modules/sa-match/entities/sa-match-detail-failure.entity.ts new file mode 100644 index 0000000..c6deff9 --- /dev/null +++ b/backend/src/modules/sa-match/entities/sa-match-detail-failure.entity.ts @@ -0,0 +1,22 @@ +import { Column, Entity, PrimaryColumn, UpdateDateColumn } from 'typeorm'; + +// 매치 상세 적재 실패 기록 — 넥슨이 상세를 끝내 제공하지 않는 매치(삭제/만료 등)의 +// 무한 재시도를 막기 위해 실패 횟수를 누적한다. 한도 초과 시 백필 대상에서 제외. +// 적재에 성공하면 행을 삭제해 기록을 정리한다. +@Entity('sa_match_detail_failures') +export class SaMatchDetailFailureEntity { + @PrimaryColumn({ type: 'varchar', length: 40 }) + matchId!: string; + + // 누적 실패 횟수 + @Column({ type: 'int', default: 0 }) + failCount!: number; + + // 마지막 실패 사유 (로그 추적용) + @Column({ type: 'varchar', length: 200, default: '' }) + lastError!: string; + + // 마지막 시도 시각 + @UpdateDateColumn() + lastTriedAt!: Date; +} diff --git a/backend/src/modules/sa-match/sa-match.module.ts b/backend/src/modules/sa-match/sa-match.module.ts index 856c048..459bfad 100644 --- a/backend/src/modules/sa-match/sa-match.module.ts +++ b/backend/src/modules/sa-match/sa-match.module.ts @@ -6,6 +6,7 @@ import { SaMatchService } from './sa-match.service'; import { SaMatchEntity } from './entities/sa-match.entity'; import { SaMatchDetailEntity } from './entities/sa-match-detail.entity'; import { SaMatchParticipantEntity } from './entities/sa-match-participant.entity'; +import { SaMatchDetailFailureEntity } from './entities/sa-match-detail-failure.entity'; import { SaUserEntity } from '../sa-user/entities/sa-user.entity'; // 매치 모듈 — 목록(기본 누적 + 일일 크론) + 상세(더보기 lazy). 넥슨 호출은 전역 NexonService 사용. @@ -16,6 +17,7 @@ import { SaUserEntity } from '../sa-user/entities/sa-user.entity'; SaMatchEntity, SaMatchDetailEntity, SaMatchParticipantEntity, + SaMatchDetailFailureEntity, SaUserEntity, ]), ], diff --git a/backend/src/modules/sa-match/sa-match.service.ts b/backend/src/modules/sa-match/sa-match.service.ts index 83defc5..ea0fd7c 100644 --- a/backend/src/modules/sa-match/sa-match.service.ts +++ b/backend/src/modules/sa-match/sa-match.service.ts @@ -1,7 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { Cron } from '@nestjs/schedule'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository } from 'typeorm'; +import { In, MoreThanOrEqual, Repository } from 'typeorm'; import { NexonService } from '../../shared/nexon/nexon.service'; import { SaUserEntity } from '../sa-user/entities/sa-user.entity'; @@ -11,6 +11,7 @@ import { ParticipantDto } from './dto/participant.dto'; import { SaMatchEntity } from './entities/sa-match.entity'; import { SaMatchDetailEntity } from './entities/sa-match-detail.entity'; import { SaMatchParticipantEntity } from './entities/sa-match-participant.entity'; +import { SaMatchDetailFailureEntity } from './entities/sa-match-detail-failure.entity'; // 넥슨 API 응답 타입 interface MatchApiItem { @@ -67,6 +68,17 @@ export class SaMatchService { '랭크전 파티', '토너먼트', ]; + // 목록 조회 시 상세(맵) 백필 한도 — 프론트 더보기 노출 개수와 동일 (개발 4 / 운영 20) + private static readonly BACKFILL_LIMIT = + process.env.NODE_ENV === 'production' ? 20 : 4; + // 크론 백필 유저당 한도 — 개발은 넥슨 일일 쿼터(1,000건/일) 보호를 위해 20건, 운영은 무제한(null) + private static readonly CRON_PER_USER_BACKFILL_LIMIT: number | null = + process.env.NODE_ENV === 'production' ? null : 20; + // 상세 적재 실패 허용 횟수 — 초과한 매치(넥슨 미제공 추정)는 백필 대상에서 영구 제외 + private static readonly MAX_DETAIL_FAILURES = 3; + + // 백필 중복 실행 방지 — 진행 중인 matchId 집합 + private readonly backfillInFlight = new Set(); constructor( @InjectRepository(SaMatchEntity) @@ -75,12 +87,14 @@ export class SaMatchService { private readonly detailRepo: Repository, @InjectRepository(SaMatchParticipantEntity) private readonly participantRepo: Repository, + @InjectRepository(SaMatchDetailFailureEntity) + private readonly failureRepo: Repository, @InjectRepository(SaUserEntity) private readonly userRepo: Repository, private readonly nexon: NexonService, ) {} - /** 매치 목록 조회 — 최신분 누적 후 DB에서 반환. */ + /** 매치 목록 조회 — 최신분 누적 후 DB에서 반환. 맵은 상세 캐시에서 조인해 채운다. */ async getMatches( ouid: string, matchType: string, @@ -92,6 +106,38 @@ export class SaMatchService { where: { ouid, matchType }, order: { dateMatch: 'DESC' }, }); + + // 상세 캐시(sa_match_details)에 있는 맵 이름 매핑 — 없으면 빈 문자열 + const ids = saved.map((m) => m.matchId); + const details = ids.length + ? await this.detailRepo.find({ + where: { matchId: In(ids) }, + select: ['matchId', 'matchMap'], + }) + : []; + const mapById = new Map(details.map((d) => [d.matchId, d.matchMap])); + + // 맵 미보유 최근 매치는 백그라운드 백필 (응답은 기다리지 않음) + const missingIds = saved + .filter((m) => !mapById.has(m.matchId)) + .map((m) => m.matchId); + if (missingIds.length) { + // 실패 한도를 초과한 매치(넥슨 미제공 추정)는 제외해 쿼터 낭비를 막는다 + const dead = await this.failureRepo.find({ + where: { + matchId: In(missingIds), + failCount: MoreThanOrEqual(SaMatchService.MAX_DETAIL_FAILURES), + }, + select: ['matchId'], + }); + const deadSet = new Set(dead.map((f) => f.matchId)); + this.backfillDetails( + missingIds + .filter((id) => !deadSet.has(id)) + .slice(0, SaMatchService.BACKFILL_LIMIT), + ); + } + return saved.map((m) => ({ matchId: m.matchId, matchType: m.matchType, @@ -101,9 +147,33 @@ export class SaMatchService { kill: m.kill, death: m.death, assist: m.assist, + matchMap: mapById.get(m.matchId) ?? '', })); } + /** 상세 미보유 매치를 비동기로 적재 (fire-and-forget). 중복/실패는 격리. */ + private backfillDetails(matchIds: string[]): void { + const targets = matchIds.filter((id) => !this.backfillInFlight.has(id)); + if (!targets.length) return; + targets.forEach((id) => this.backfillInFlight.add(id)); + + void (async () => { + for (const id of targets) { + try { + // 호출 간격은 NexonService 전역 rate limiter 가 조절한다 + await this.getMatchDetail(id); + } catch (error) { + this.logger.warn( + `매치 상세 백필 실패(matchId=${id}): ${(error as Error).message}`, + ); + await this.recordDetailFailure(id, (error as Error).message); + } finally { + this.backfillInFlight.delete(id); + } + } + })(); + } + /** 넥슨 매치 목록 API → 신규분 누적(upsert). 실패는 격리(기존 데이터 유지). */ private async syncMatches( ouid: string, @@ -153,9 +223,93 @@ export class SaMatchService { await this.syncMatches(user.ouid, type, SaMatchService.DEFAULT_MODE); } } + await this.backfillMissingDetails(); this.logger.log('일일 매치 동기화 완료'); } + /** + * 크론 백필 대상 matchId 목록 — 상세 미보유 매치를 최신순으로 수집. + * 실패 한도 초과 매치는 제외하고, 개발 환경은 넥슨 일일 쿼터 보호를 위해 + * 유저당 한도(CRON_PER_USER_BACKFILL_LIMIT)를 적용한다. + */ + private async getBackfillTargets(): Promise { + const rows = await this.matchRepo + .createQueryBuilder('m') + .leftJoin(SaMatchDetailEntity, 'd', 'd.matchId = m.matchId') + .leftJoin(SaMatchDetailFailureEntity, 'f', 'f.matchId = m.matchId') + .where('d.matchId IS NULL') + .andWhere('(f.matchId IS NULL OR f.failCount < :maxFail)', { + maxFail: SaMatchService.MAX_DETAIL_FAILURES, + }) + .select('m.matchId', 'matchId') + .addSelect('m.ouid', 'ouid') + .orderBy('m.dateMatch', 'DESC') + .getRawMany<{ matchId: string; ouid: string }>(); + + const perUserLimit = SaMatchService.CRON_PER_USER_BACKFILL_LIMIT; + const seen = new Set(); + const countByUser = new Map(); + const targets: string[] = []; + for (const { matchId, ouid } of rows) { + // 같은 매치를 여러 유저가 공유할 수 있으므로 matchId 기준 중복 제거 + if (seen.has(matchId)) continue; + if (perUserLimit !== null) { + const used = countByUser.get(ouid) ?? 0; + if (used >= perUserLimit) continue; + countByUser.set(ouid, used + 1); + } + seen.add(matchId); + targets.push(matchId); + } + return targets; + } + + /** 상세(맵) 미보유 매치를 최신순으로 백필 — 크론 전용. (개발: 유저당 한도, 운영: 전량) */ + private async backfillMissingDetails(): Promise { + const targets = await this.getBackfillTargets(); + if (!targets.length) return; + + const perUserLimit = SaMatchService.CRON_PER_USER_BACKFILL_LIMIT; + this.logger.log( + `매치 상세 백필 시작 (${targets.length}건${perUserLimit !== null ? `, 유저당 ${perUserLimit}건 한도` : ''})`, + ); + for (const matchId of targets) { + // 온디맨드 백필과 같은 매치를 동시에 적재하지 않도록 가드 + if (this.backfillInFlight.has(matchId)) continue; + this.backfillInFlight.add(matchId); + try { + await this.getMatchDetail(matchId); + } catch (error) { + this.logger.warn( + `매치 상세 백필 실패(matchId=${matchId}): ${(error as Error).message}`, + ); + await this.recordDetailFailure(matchId, (error as Error).message); + } finally { + this.backfillInFlight.delete(matchId); + } + } + } + + /** 상세 적재 실패 기록 — 횟수를 누적해 한도 초과 시 백필 대상에서 제외한다. */ + private async recordDetailFailure( + matchId: string, + message: string, + ): Promise { + try { + const prev = await this.failureRepo.findOne({ where: { matchId } }); + await this.failureRepo.save({ + matchId, + failCount: (prev?.failCount ?? 0) + 1, + lastError: message.slice(0, 200), + }); + } catch (error) { + // 기록 실패는 백필 흐름을 끊지 않는다 (다음 시도에서 재기록) + this.logger.warn( + `상세 실패 기록 실패(matchId=${matchId}): ${(error as Error).message}`, + ); + } + } + /** 매치 상세 조회 — DB에 적재돼 있으면 반환, 없으면 더보기 시점에 1회 API 적재 후 반환. */ async getMatchDetail(matchId: string): Promise { const cached = await this.detailRepo.findOne({ where: { matchId } }); @@ -199,6 +353,9 @@ export class SaMatchService { await m.save(parts); }); + // 적재 성공 — 과거 실패 기록이 있었다면 정리 (일시 장애 후 복구된 매치) + await this.failureRepo.delete({ matchId }); + const header = await this.detailRepo.findOne({ where: { matchId } }); const parts = await this.participantRepo.find({ where: { matchId } }); return this.toDetailDto(header!, parts); diff --git a/frontend/src/composables/useApi.ts b/frontend/src/composables/useApi.ts index 41ce196..7a234fb 100644 --- a/frontend/src/composables/useApi.ts +++ b/frontend/src/composables/useApi.ts @@ -55,15 +55,22 @@ api.interceptors.response.use( // 성공 시 data 만 언래핑하여 반환 return payload.data as never } - // HTTP 200 이지만 비즈니스 로직상 실패인 경우 + // HTTP 200 이지만 비즈니스 로직상 실패인 경우 — 서버 메시지 우선, 없으면 코드 사전 매핑 const errCode = payload.error?.code || 'BIZ_001' - showErrorUI(ErrorCodeLexicon[errCode] ?? ErrorCodeLexicon.SYS_001 ?? '일시적인 시스템 오류가 발생했습니다.') + showErrorUI( + payload.error?.message + || ErrorCodeLexicon[errCode] + || ErrorCodeLexicon.SYS_001 + || '일시적인 시스템 오류가 발생했습니다.', + ) return Promise.reject(payload.error) } return payload as never }, (error) => { let errCode = 'SYS_001' + // 백엔드 Exception Filter 가 내려준 사용자용 메시지 (점검 안내 등) — 있으면 우선 노출 + let serverMessage: string | undefined if (error?.response) { const status: number = error.response.status @@ -71,13 +78,19 @@ api.interceptors.response.use( if (payload?.error?.code) { errCode = payload.error.code + serverMessage = payload.error.message } else if (status === 401) errCode = 'AUTH_001' else if (status === 403) errCode = 'AUTH_003' else if (status === 400 || status === 422) errCode = 'VAL_001' else if (status === 404) errCode = 'RES_001' } - showErrorUI(ErrorCodeLexicon[errCode] ?? ErrorCodeLexicon.SYS_001 ?? '일시적인 시스템 오류가 발생했습니다.') + showErrorUI( + serverMessage + || ErrorCodeLexicon[errCode] + || ErrorCodeLexicon.SYS_001 + || '일시적인 시스템 오류가 발생했습니다.', + ) return Promise.reject(error) }, ) diff --git a/frontend/src/composables/useSaMatch.ts b/frontend/src/composables/useSaMatch.ts index 7c9447c..cceceb2 100644 --- a/frontend/src/composables/useSaMatch.ts +++ b/frontend/src/composables/useSaMatch.ts @@ -10,6 +10,8 @@ export interface SaMatchItem { kill: number death: number assist: number + // 매치 맵 — 상세 캐시 보유 시에만 값 존재, 미보유면 빈 문자열 + matchMap: string } // 매치 상세 참가자 diff --git a/frontend/src/pages/ProfilePage.vue b/frontend/src/pages/ProfilePage.vue index 9ba8faa..b1f680d 100644 --- a/frontend/src/pages/ProfilePage.vue +++ b/frontend/src/pages/ProfilePage.vue @@ -123,7 +123,7 @@ function adaptMatches(items: SaMatchItem[]): MatchRecord[] { matchId: m.matchId, mode: TYPE_LABEL[m.matchType] ?? m.matchType, win: m.matchResult === '1', - map: '', + map: m.matchMap ?? '', k: m.kill, d: m.death, a: m.assist,