feat(map): MySQL 근거 기반 추천 엔진을 공개 v1/v2 API에 도입한다 - #35
Conversation
There was a problem hiding this comment.
Code Review
This pull request introduces a new MySQL-based recommendation engine alongside the existing AuraDB engine, adding a new v2 recommendation API endpoint that includes recommendation reasons while preserving the v1 contract. It implements a pure scoring policy based on approved evidence, updates deployment scripts and preflight checks to validate recommendation settings, and expands observability assets (Alloy and Grafana) to track recommendation metrics. Feedback on the changes highlights a potential NullPointerException in the EvidenceAccumulator result set mapping if database values for polarity or publication timestamp are null, suggesting defensive null checks.
Important
The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.
| private static EvidenceAccumulator from(ResultSet resultSet) { | ||
| try { | ||
| return new EvidenceAccumulator( | ||
| resultSet.getLong("extracted_claim_id"), | ||
| resultSet.getLong("reviewed_claim_id"), | ||
| resultSet.getLong("final_map_id"), | ||
| resultSet.getLong("final_job_id"), | ||
| nullableInteger(resultSet, "final_level_min"), | ||
| nullableInteger(resultSet, "final_level_max"), | ||
| Polarity.from(resultSet.getString("final_polarity")), | ||
| resultSet.getTimestamp("source_published_at").toLocalDateTime(), | ||
| Math.toIntExact(resultSet.getLong("patch_count")) | ||
| ); | ||
| } catch (SQLException exception) { | ||
| throw new EvidenceMappingException(exception); | ||
| } | ||
| } |
There was a problem hiding this comment.
resultSet.getTimestamp("source_published_at") 및 resultSet.getString("final_polarity") 호출 결과가 null일 경우, 각각 .toLocalDateTime() 호출 시 NullPointerException이 발생하거나 Polarity.from()에서 예외가 발생할 수 있습니다. 데이터베이스 스키마 상 NOT NULL로 정의되어 있더라도, 예외적인 상황에 대비하여 안전하게 null 체크를 수행하고 명시적인 예외를 던지도록 방어적 프로그래밍(Defensive Programming)을 적용하는 것이 안전합니다.
private static EvidenceAccumulator from(ResultSet resultSet) {
try {
String polarityStr = resultSet.getString("final_polarity");
if (polarityStr == null) {
throw new SQLException("final_polarity is null");
}
Timestamp publishedAtTimestamp = resultSet.getTimestamp("source_published_at");
if (publishedAtTimestamp == null) {
throw new SQLException("source_published_at is null");
}
return new EvidenceAccumulator(
resultSet.getLong("extracted_claim_id"),
resultSet.getLong("reviewed_claim_id"),
resultSet.getLong("final_map_id"),
resultSet.getLong("final_job_id"),
nullableInteger(resultSet, "final_level_min"),
nullableInteger(resultSet, "final_level_max"),
Polarity.from(polarityStr),
publishedAtTimestamp.toLocalDateTime(),
Math.toIntExact(resultSet.getLong("patch_count"))
);
} catch (SQLException exception) {
throw new EvidenceMappingException(exception);
}
}
Summary
Closes #34.
Depends on #33 (merged).
port/adapter구조는 복사하지 않고 MLS-BE의 기존application/domain/repository/presentation계층과 DTO factory convention에 맞췄습니다.RECOMMENDATION_V1_ENGINE=AURA|MYSQL로 선택합니다. production 기본값은AURA입니다.reasons를 추가하며RECOMMENDATION_V2_ENABLED=false가 production 기본값입니다.build-and-publish -> production owner approval -> deploy immutable digest로 단순화합니다. 순환 checksum bootstrap이던 host preflight는 제거합니다.Linked work:
Public API contracts
v1 exact contract retained
GET /api/v1/maps/recommendations?level={1..200}&jobId={id}&limit={1..20}dataitem에는mapId,score,iconUrl,nameKr,bookmarkId다섯 key만 존재합니다.{ "mapId": 100000000, "score": 0.95, "iconUrl": "https://...", "nameKr": "헤네시스", "bookmarkId": null }reasons,facets,hasRecommendation같은 additive field 없음data: []ResponseTemplate/validation/status/error 계약 유지scoretype은 같지만 MySQL 선택 시 의미가 Aura hit score에서 evidence net score로 변경v2 reasons
GET /api/v2/maps/recommendations{ "mapId": 100000000, "score": 0.95, "iconUrl": "https://...", "nameKr": "헤네시스", "bookmarkId": null, "reasons": [ {"axis": "reward", "value": "xp"} ] }reasons는 항상 배열이며 reason이 없으면[]reward,play_style,operabilityxp,meso,loot,solo,party,party_quest,fatigue,mobility,budgetreward -> play_style -> operability순서{}또는 Bearer Authorization 대안을 명시Scoring policy
승인된 deduplicated evidence마다:
confidence_score, 조회/반응/댓글, Job/조상 차등 가중치, AuralevelHits/jobHits,0.8/0.2를 추가하지 않음double만 허용extracted_claim_id + final_map_id중복은 한 번만 합산하고 서로 다른 FAMILY map ID는 별도 후보 유지고정 예시는
+1.00 +0.90 -0.95 = 0.95이며 테스트로 고정했습니다.Source and adaptations
bfa2af8e9135b53b51a0891a9d5187a21b74d2af79f7a036d879e7ddd6254dcedab86d89f73a438e; 관련 scorer/ADR/schema 파일은 source commit과 차이 없음8722c6fcf438c3377b7cd3576388b1f3814852e5Adaptations:
reversed()전체 반전 문제를 명시 comparator와 high-score-first test로 수정limit(3)대신 v1/v2 contract limit 사용recommendation.v1-engine은 enum configuration binding을 사용해 임의 문자열 parsing을 제거DB preflight and ownership
접근 가능한 mapleland DB를 read-only transaction으로 확인했습니다.
mapledb현재 index 없는 plan은 reviewed claims 약 7,649 rows와
alrim약 464 rows를 full scan합니다. schema owner PR #132는 다음을 owner-gated forward/rollback/preflight SQL로 관리합니다.idx_recommendation_reviewed_claims_scoring (review_status, final_job_id)idx_alrim_type_date (type, date)EXPLAIN FORMAT=TREEProduction DDL은 실행하지 않았습니다.
현재 권한으로 MLS-BE production
DB_URL이 mapleland와 동일 endpoint/schema인지, 운영 credential의 SELECT 권한과 실제 분포 plan/index 선택을 증명하지 못했습니다. 이 gate와 reverse-proxy recommendation rate-limit이 확인되기 전에는 v2를 켜거나 v1을 MYSQL로 전환하지 않습니다.Query budget and local performance
고정 round-trip budget:
Testcontainers
mysql:8.4, 3,000 evidence / 100 candidate maps / 20 results, 40 warm runs의 최신 결과:idx_alrim_type_date선택; reviewed-claim index는 100%-matched fixture에서 optimizer가 full scan 선택SELECT SLEEP(3)을 1초 statement timeout으로 취소하는 계약 검증 포함이는 local characterization이며 production SLO/alert threshold가 아닙니다.
Deployment simplification
실패한 run https://github.com/Team-Maple/MLS-BE/actions/runs/29531165131 은 host의 기존 preflight script checksum과 branch checksum이 달라 build/publish/recreate 전에 실패했습니다. 파일 checksum을 검증하는 host script 자체를 먼저 갱신해야 하는 순환 bootstrap이 원인이며 추천 코드나 애플리케이션 장애가 아닙니다.
새 routine path:
production-buildjob이 arm64 image를 build/publish합니다.productionowner 승인을 한 번 받습니다.deploy <immutable digest>forced command만 전달합니다.Supply-chain 경계:
ubuntu-24.04(x64)와 Tailscale 1.94.2 amd64 공식 SHA-256 고정삭제/변경:
preflight-host.sh와 host-preflight CI/workflow path 제거deploy-jar.yml제거Legacy EC2 경로가 DNS/LB/failover/DR에서 쓰이지 않는지는 public DNS만으로 입증하지 못했습니다. owner inventory 확인 전에는 workflow 삭제/credential 폐기를 승인 완료로 보지 않습니다.
Observability / Grafana
http.server.requests를 rate/p95/HTTP error에 재사용; duplicate latency timer 없음engine,api_version,outcome만 사용Live create-or-update:
mapleland-production-overview, version 3, panel 24개Validation
Latest local evidence:
RecommendationScoringServiceTest: 14/14 pass, 고정 0.95와 high-score-first comparator/lineage dedup/tie-break 포함MySqlMapRecommendationRepositoryIntegrationTest: 3/3 pass, MySQL 8.4 CTE/window/query counter/performance 포함./gradlew bootJar: pass./gradlew clean test: 91 tests 중 88 pass; 기존 외부 실사이트NoticeApiTest3건만maple.landSocketTimeoutException으로 실패Initial rollout gates
이 PR에서 merge, host 변경 또는 운영 deploy를 수행하지 않습니다. 첫 workflow dispatch 전 owner가 다음을 완료해야 합니다.
production-buildEnvironment 생성: main-only, reviewer 없음,FIREBASE_KEYEnvironment secretproductionEnvironment: main-only, owner reviewer 1명, admin bypass off, Tailscale/SSH Environment secretsORACLE_SSH_FINGERPRINTEnvironment variable 등록HOST/USERNAME/KEY/PORT/GHCR_*credential 폐기초기 image는
AURA / v2=false / timeout=10으로만 배포합니다. 이 단계에서는 MySQL traffic을 만들지 않습니다.MySQL activation and rollback
Activation:
Rollback:
v1=AURA,v2=false로 MySQL traffic drain 후 승인된 immutable image를 재생성Not tested / residual risk