Skip to content

feat(map): MySQL 근거 기반 추천 엔진을 공개 v1/v2 API에 도입한다 - #35

Merged
mungmnb777 merged 14 commits into
mainfrom
feature/mysql-evidence-recommendations
Jul 18, 2026
Merged

feat(map): MySQL 근거 기반 추천 엔진을 공개 v1/v2 API에 도입한다#35
mungmnb777 merged 14 commits into
mainfrom
feature/mysql-evidence-recommendations

Conversation

@mungmnb777

@mungmnb777 mungmnb777 commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Summary

Closes #34.

Depends on #33 (merged).

  • MLS-BE 내부에 query-only MySQL evidence scorer를 구현합니다. mapleland의 HTTP test endpoint는 호출하지 않습니다.
  • mapleland의 hexagonal port/adapter 구조는 복사하지 않고 MLS-BE의 기존 application/domain/repository/presentation 계층과 DTO factory convention에 맞췄습니다.
  • v1은 exact mobile JSON shape를 유지하고 RECOMMENDATION_V1_ENGINE=AURA|MYSQL로 선택합니다. production 기본값은 AURA입니다.
  • v2는 별도 DTO로 reasons를 추가하며 RECOMMENDATION_V2_ENABLED=false가 production 기본값입니다.
  • 요청 단위 dual-read/자동 Aura fallback은 없습니다. Aura dependency와 keep-alive도 이 PR에서 제거하지 않습니다.
  • 배포는 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}

data item에는 mapId, score, iconUrl, nameKr, bookmarkId 다섯 key만 존재합니다.

{
  "mapId": 100000000,
  "score": 0.95,
  "iconUrl": "https://...",
  "nameKr": "헤네시스",
  "bookmarkId": null
}
  • reasons, facets, hasRecommendation 같은 additive field 없음
  • empty result는 data: []
  • 익명/Bearer 선택 인증과 기존 ResponseTemplate/validation/status/error 계약 유지
  • 로그인 bookmark와 canonical map은 bulk 조회하고 scorer 순서를 보존
  • score type은 같지만 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"}
  ]
}
  • 별도 v2 DTO
  • reasons는 항상 배열이며 reason이 없으면 []
  • axis: reward, play_style, operability
  • value: xp, meso, loot, solo, party, party_quest, fatigue, mobility, budget
  • 축별 최대 하나, reward -> play_style -> operability 순서
  • OpenAPI operation에 anonymous {} 또는 Bearer Authorization 대안을 명시

Scoring policy

승인된 deduplicated evidence마다:

levelMatch_i = 요청 level이 유효 범위 안이면 1, 아니면 0
freshnessWeight_i = round(max(0.1, 1.0 - 0.05 * 이후 PATCH_NOTE 수), 3)
polaritySign_i = positive +1, negative -1
contribution_i = polaritySign_i * freshnessWeight_i * levelMatch_i
mapScore = 동일 map contribution_i 합
  • confidence_score, 조회/반응/댓글, Job/조상 차등 가중치, Aura levelHits/jobHits, 0.8/0.2를 추가하지 않음
  • positive 근거가 하나 이상이고 net score > 0인 map만 반환
  • finite double만 허용
  • reason facet에도 같은 signed contribution을 합산하고 양수인 최상위 facet만 반환
  • Job 자신과 조상만 포함; 자손/무관/FAMILY runtime fan-out 제외
  • extracted_claim_id + final_map_id 중복은 한 번만 합산하고 서로 다른 FAMILY map ID는 별도 후보 유지
  • bound 한쪽만 있으면 ±10, 양쪽 없으면 제외
  • 정렬: score desc -> freshnessSum desc -> 대표 publishedAt desc -> mapId asc
  • 동일 최고 contribution이면 최신 publishedAt을 대표 근거로 선택
  • 최종 정렬 뒤 default 5, min 1, max 20 적용

고정 예시는 +1.00 +0.90 -0.95 = 0.95이며 테스트로 고정했습니다.

Source and adaptations

  • approved source: bfa2af8e9135b53b51a0891a9d5187a21b74d2af
  • inspected mapleland HEAD: 79f7a036d879e7ddd6254dcedab86d89f73a438e; 관련 scorer/ADR/schema 파일은 source commit과 차이 없음
  • MLS-BE implementation head: 8722c6fcf438c3377b7cd3576388b1f3814852e5

Adaptations:

  • source의 hexagonal package 경계 대신 MLS-BE convention에 맞춘 repository/application/domain/presentation 구성
  • lineage/dedup/patch count/canonical map/reason을 MySQL 8 recursive CTE/window 단일 query로 통합
  • upstream claim별 patch, ancestor 반복, claim별 reason query를 포팅하지 않음
  • comparator의 두 번째 reversed() 전체 반전 문제를 명시 comparator와 high-score-first test로 수정
  • upstream limit(3) 대신 v1/v2 contract limit 사용
  • reason text/excerpt/author는 DTO/log에 포함하지 않음
  • recommendation.v1-engine은 enum configuration binding을 사용해 임의 문자열 parsing을 제거

DB preflight and ownership

접근 가능한 mapleland DB를 read-only transaction으로 확인했습니다.

  • MySQL 8.0.42 Community, schema mapledb
  • APPROVED claims 3,010
  • reasons 11,955; APPROVED-linked 4,817
  • PATCH_NOTE 95
  • canonical map/job orphan 0/0
  • Job cycle 0, max lineage depth 3
  • duplicate evidence group/surplus 0/0
  • both level bounds null인 APPROVED claim 0

현재 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)
  • reason schema/index, polarity/facet allowlist, consolidated query EXPLAIN FORMAT=TREE

Production 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:

  • Job existence 1
  • MySQL scorer 1
  • canonical maps bulk 최대 1
  • authenticated bookmarks bulk 최대 1
  • total anonymous 최대 3, authenticated 최대 4; empty candidate는 enrichment 생략

Testcontainers mysql:8.4, 3,000 evidence / 100 candidate maps / 20 results, 40 warm runs의 최신 결과:

  • scorer queries/request: 1
  • cold: 90.896 ms
  • warm p50/p95/p99: 33.618 / 60.479 / 70.781 ms
  • 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:

  1. main-only production-build job이 arm64 image를 build/publish합니다.
  2. unique tag를 정확히 한 번 digest로 해소합니다.
  3. FCM permission, OCI revision, Paketo run image를 같은 digest reference로 검증합니다.
  4. 별도 최소권한 deploy job이 production owner 승인을 한 번 받습니다.
  5. Tailscale 뒤 native OpenSSH가 deploy <immutable digest> forced command만 전달합니다.
  6. host runner는 lock, pull/recreate, 공개/management readiness, 자동 image rollback과 root-only 진단만 담당합니다.

Supply-chain 경계:

  • deploy runner ubuntu-24.04(x64)와 Tailscale 1.94.2 amd64 공식 SHA-256 고정
  • build runner arm64와 pack 0.40.0 arm64 공식 SHA-256 고정
  • Gradle 8.13 distribution SHA-256 고정
  • GitHub Actions commit SHA 고정
  • appleboy/drone-ssh downloader 제거, native OpenSSH + reviewed host fingerprint 사용
  • tag를 다시 조회하지 않고 검증된 단일 digest를 deploy output으로 사용

삭제/변경:

  • preflight-host.sh와 host-preflight CI/workflow path 제거
  • checksum 세 개를 SSH command로 전달하는 계약 제거
  • 2026-04-07 이후 실행 이력이 없는 legacy mutable 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 없음
  • custom labels는 engine, api_version, outcome만 사용
  • Job/level/map/member/raw URI/query/claim text를 label/log에 기록하지 않음
  • ECS fields는 action/outcome/engine/api version/duration/result count만 추가
  • Alloy allowlist에는 필요한 recommendation metric만 추가

Live create-or-update:

Validation

Latest local evidence:

  • Java 21 focused recommendation/config/Testcontainers suite: 42/42 pass
  • 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
  • full ./gradlew clean test: 91 tests 중 88 pass; 기존 외부 실사이트 NoticeApiTest 3건만 maple.land SocketTimeoutException으로 실패
  • deploy YAML parse, shell syntax, immutable deploy/rollback contract: pass; Linux command-substitution의 inherited SIGPIPE disposition 차이는 regular-file capture로 고정
  • dashboard/alert JSON, recommendation asset contract: pass
  • official Alloy validation: pass
  • 독립 코드 리뷰: latest actionable P0-P3 0
  • 위험 리뷰 finding: Tailscale archive architecture mismatch 1건을 발견해 x64 runner + amd64 checksum으로 수정; latest independent re-review actionable P0-P3 0
  • current branch CI: in progress — https://github.com/Team-Maple/MLS-BE/actions/runs/29539204890

Initial rollout gates

이 PR에서 merge, host 변경 또는 운영 deploy를 수행하지 않습니다. 첫 workflow dispatch 전 owner가 다음을 완료해야 합니다.

  • latest CI/required review green
  • production-build Environment 생성: main-only, reviewer 없음, FIREBASE_KEY Environment secret
  • production Environment: main-only, owner reviewer 1명, admin bypass off, Tailscale/SSH Environment secrets
  • ORACLE_SSH_FINGERPRINT Environment variable 등록
  • 같은 이름의 repository/organization secret 사본 rotation/removal
  • legacy EC2 inventory 확인 및 HOST/USERNAME/KEY/PORT/GHCR_* credential 폐기
  • owner-approved change window에서 merged main의 runner와 Compose override를 한 번 원자적으로 전환
  • Firebase key가 image layer에 포함되는 기존 잔여 위험을 명시적으로 수용하거나 runtime secret mount 전환 + key rotation
  • 별도 merge/운영 배포 승인

초기 image는 AURA / v2=false / timeout=10으로만 배포합니다. 이 단계에서는 MySQL traffic을 만들지 않습니다.

MySQL activation and rollback

Activation:

  1. production topology/SELECT/schema/full EXPLAIN/index와 reverse-proxy rate-limit 확인
  2. Hikari acquisition timeout/pool saturation 경계 결정
  3. owner 승인으로 v2만 활성화
  4. v2 익명/로그인 low-volume smoke와 Grafana live series 확인
  5. owner가 DB/v2/latency/error를 다시 승인한 뒤 별도 config change로 v1 MYSQL 전환
  6. 안정화 후 Aura 제거는 별도 Issue/PR

Rollback:

  • recommendation config rollback은 v1=AURA, v2=false로 MySQL traffic drain 후 승인된 immutable image를 재생성
  • image 회귀는 host runner가 exact 이전 image와 service version으로 자동 rollback
  • index rollback은 traffic drain과 별도 owner 승인 뒤 schema PR SQL만 사용
  • topology 차이를 credential/cross-schema grant/임시 복제 table로 우회하지 않음

Not tested / residual risk

  • production DB endpoint/schema equality와 SELECT 권한
  • production exact EXPLAIN/index 선택/latency
  • reverse-proxy recommendation-specific rate-limit
  • 충분한 Aura route baseline
  • 배포 후 live recommendation metric series
  • Firebase runtime secret mount; 현재 기존 계약대로 key가 image layer에 포함됨
  • legacy EC2 origin/DR dependency inventory
  • production DDL, merge, v1 MYSQL flip, host runner cutover, 운영 deploy

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +199 to +215
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);
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

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);
            }
        }

@mungmnb777
mungmnb777 marked this pull request as ready for review July 18, 2026 17:34
@mungmnb777
mungmnb777 merged commit 043c137 into main Jul 18, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(map): MySQL 근거 기반 추천 엔진을 공개 v1/v2 API에 도입한다

1 participant