Files

20 KiB

PRD: 메인 홈 크리에이터 랭킹 개편

1. Overview

메인 홈 크리에이터 랭킹의 스냅샷 조회 fallback을 콘텐츠 랭킹 fallback과 같은 refresh 후 재조회 방식으로 변경하고, 크리에이터 랭킹 스냅샷 점수 산식을 순위 기반 인기 점수로 교체한다.


2. Problem

  • 현재 크리에이터 랭킹 fallback은 스냅샷 테이블이 완전히 비어 있는 cold-start 상황에서만 원천 데이터를 직접 집계해 응답한다.
  • 이 방식은 콘텐츠 랭킹 fallback처럼 스케줄러와 동일한 refresh 경로로 스냅샷을 생성한 뒤 재조회하지 않으므로, 운영 중 특정 주차 스냅샷이 없을 때 복구 동작이 다르다.
  • 기존 크리에이터 랭킹 산식은 캔/좋아요/댓글/후원/팬Talk/팔로우 raw value에 가중치를 곱한다.
  • 신규 요구사항은 라이브 매출, 콘텐츠 매출, 팔로워 증가, AI 채팅 개수를 각각 랭킹 점수로 변환한 뒤 가중합해야 하므로 기존 raw value 기반 산식과 다르다.
  • 점수 계산을 DB에서 하든 Kotlin에서 하든 산식과 순위 점수 경계값이 흔들리지 않도록 테스트 근거가 필요하다.

3. Goals

  • GET /api/v2/home/rankings/creators 공개 API 응답 스키마는 유지한다.
  • 최신 공개 크리에이터 랭킹 스냅샷이 없으면 콘텐츠 랭킹과 같은 방식으로 fallback refresh를 실행한다.
  • fallback refresh는 스케줄러와 동일한 크리에이터 랭킹 refresh 로직을 실행하고, 저장된 스냅샷을 다시 조회해 응답한다.
  • fallback 실행은 작업 이력에 FALLBACK trigger로 남기고, 동일 집계 기간 기준 최대 3회까지만 시도한다.
  • 집계 기간은 기존과 동일하게 KST 기준 지난 완료 주차를 UTC half-open 범위로 변환해 조회한다.
  • 후보 크리에이터는 활성 크리에이터 중 라이브 매출, 콘텐츠 매출, 팔로워 증가, AI 채팅 개수 중 하나라도 있는 크리에이터로 제한한다.
  • 최종 크리에이터 인기 점수는 라이브 매출 랭킹 점수 60% + 콘텐츠 매출 랭킹 점수 20% + 팔로워 증가 수 랭킹 점수 10% + 채팅 개수 랭킹 점수 10%로 계산한다.
  • 최종 응답 인원 수는 20명이다.
  • 이번 변경에서는 기존 크리에이터 랭킹 스냅샷 데이터를 폐기하고 신규 구조로 다시 쌓는다.
  • 이후 산식 변경 시 DB 테이블 변경을 반복하지 않도록 creator_ranking_snapshot을 고정 결과 컬럼과 JSON 상세 컬럼 중심으로 재정리한다.
  • 산식, 순위 점수 변환, 후보 제외 조건, fallback 실행 제한은 단위/통합 테스트로 촘촘히 검증한다.

4. Non-Goals

  • 메인 홈 크리에이터 랭킹 API endpoint, 응답 필드, JSON 스키마는 변경하지 않는다.
  • 콘텐츠 랭킹 산식과 콘텐츠 랭킹 fallback 동작은 변경하지 않는다.
  • 메인 홈 추천 섹션의 CHEER_CREATOR, AI_CHARACTER, POPULAR_COMMUNITY 스냅샷은 변경하지 않는다.
  • 관리자 화면 신규 개발, 수동 순위 보정, 랭킹 제외/고정 기능은 포함하지 않는다.
  • 개인화 랭킹, 실시간 랭킹, A/B 테스트, 머신러닝 기반 점수 산정은 포함하지 않는다.
  • 신규 공개 API를 만들지 않는다.

5. Target Users

  • 회원/비회원: 메인 홈에서 인기 크리에이터 랭킹을 확인하는 사용자
  • 앱 클라이언트: 기존 크리에이터 랭킹 응답 스키마로 화면을 구성하는 클라이언트
  • 운영자: 스냅샷 생성 실패 또는 누락 시 fallback 복구 여부와 신규 산식 결과를 확인해야 하는 내부 사용자
  • 개발자: 순위 기반 점수 산식과 fallback job 흐름을 테스트로 검증해야 하는 개발자

6. User Stories

  • 사용자는 지난 완료 주차 기준으로 라이브 매출, 콘텐츠 매출, 팔로워 증가, AI 채팅 반응이 좋은 크리에이터 20명을 보고 싶다.
  • 앱 클라이언트는 기존 showRankChange, rank, rankChange, isNew, 크리에이터 표시 정보를 그대로 받아 화면 변경 없이 순위 기준만 바뀐 결과를 노출하고 싶다.
  • 운영자는 스케줄러가 크리에이터 랭킹 스냅샷 생성을 놓친 경우 첫 조회 또는 후속 조회에서 동일 refresh 로직으로 스냅샷이 생성되기를 원한다.
  • 개발자는 DB-side 계산과 Kotlin-side 계산 중 어떤 방식을 선택하더라도 순위 점수 변환 결과를 테스트로 고정하고 싶다.

7. Core Features

Feature A. 집계 기간과 공개 시각 유지

Requirements

  • 기준 시각을 KST로 변환한 뒤 이번 주 월요일 00:00:00 KST를 구한다.
  • 집계 구간은 직전 월요일 00:00:00 KST 이상, 이번 주 월요일 00:00:00 KST 미만이다.
  • DB 조회에는 이 구간을 UTC로 변환한 created_at >= startInclusiveUtc and created_at < endExclusiveUtc 조건을 사용한다.
  • 공개 시각은 집계 종료 KST + 9시간이다.
  • 공개 조회는 기존처럼 visibleFromAt <= nowUtc 조건을 만족하는 최신 공개 스냅샷만 사용한다.

Edge Cases

  • 월요일 09:00:00 KST 전에는 새 주차 스냅샷이 생성되어 있어도 공개 조회 대상이 아니다.
  • 연도/월 경계를 넘는 주차도 동일한 KST 주간 정책을 따른다.
  • fallback으로 생성된 스냅샷도 visibleFromAt <= nowUtc 조건을 만족하지 않으면 공개 조회에 노출하지 않는다.

Feature B. 후보 크리에이터 집계

Requirements

  • 후보 대상은 member.role = 'CREATOR'이고 member.is_active = true인 회원이다.
  • 아래 지표 중 하나라도 있는 크리에이터만 후보에 포함한다.
    • liveCanAmount
    • contentPurchaseCanAmount
    • followIncrease
    • aiChatCount >= 1
  • liveCanAmountuse_can_calculateDONATION, LIVE, SPIN_ROULETTE 사용 캔 합계로 계산한다.
  • contentPurchaseCanAmountORDER_CONTENT 사용 캔 합계로 계산한다.
  • 공통 캔 조건은 recipient_creator_id is not null, status = RECEIVED, use_can.is_refund = false, 집계 기간 내 생성이다.
  • followIncrease는 집계 기간 내 팔로우 증가 수로 계산한다.
  • followIncrease의 최소값은 0이다. 기간 내 언팔로우가 팔로우보다 많아 계산값이 음수가 되면 0으로 보정한다.
  • aiChatCountchat_message에서 ParticipantType.CHARACTER 메시지를 세는 방식으로 계산한다.
  • aiChatCount 집계는 기존 AI 캐릭터 스냅샷 query와 동일하게 chat_message, chat_participant, chat_character, creatorMember 관계를 기준으로 한다.
  • 원천 지표가 없으면 0으로 계산한다.

Edge Cases

  • 캔 매출이 없어도 팔로워 증가 또는 AI 채팅 개수가 있으면 후보가 될 수 있다.
  • followIncrease 원 계산값이 음수이면 0으로 보정하므로 팔로워 증가 지표만으로는 후보가 될 수 없다.
  • aiChatCount는 비활성 메시지, 비활성 participant, 비활성 캐릭터/크리에이터를 제외해야 한다.
  • 사용자 프롬프트의 SPIN_ROULETE는 기존 코드 enum SPIN_ROULETTE의 오타로 보고, 구현 전 enum명을 확인한다.

Feature C. 순위 기반 점수 산식

Requirements

  • 각 원천 지표별로 크리에이터 순위를 계산한 뒤 순위 점수로 변환한다.
  • 순위 점수 기본 산식은 기존 SQL 샘플과 동일한 형태를 따른다.
GREATEST((100 - cast((5 * (rank() over (order by metric desc) - 1)) as signed)) * weight, 0)
  • 순수 랭킹 점수는 max(100 - (5 * (rank - 1)), 0)으로 해석한다.
  • 동률 순위는 SQL rank() 의미를 따른다. 같은 metric 값은 같은 rank를 받고, 다음 순위는 건너뛴다.
  • 최종 점수는 아래 산식으로 계산한다.
creatorPopularityScore =
  liveRevenueRankScore * 0.6
  + contentRevenueRankScore * 0.2
  + followIncreaseRankScore * 0.1
  + aiChatRankScore * 0.1
  • 위 가중치 합은 1.00이다.
  • liveRevenueRankScoreliveCanAmount 기준 내림차순 rank를 순위 점수로 변환한 값이다.
  • contentRevenueRankScorecontentPurchaseCanAmount 기준 내림차순 rank를 순위 점수로 변환한 값이다.
  • followIncreaseRankScorefollowIncrease 기준 내림차순 rank를 순위 점수로 변환한 값이다.
  • aiChatRankScoreaiChatCount 기준 내림차순 rank를 순위 점수로 변환한 값이다.
  • 특정 지표 값이 0인 후보는 해당 지표 점수를 0점으로 강제한다.
  • 최종 점수 정렬은 creatorPopularityScore desc를 1차 기준으로 한다.
  • 최종 점수가 같으면 크리에이터 데뷔일 내림차순, creatorId 내림차순으로 정렬한다.

Edge Cases

  • rank 1의 순수 랭킹 점수는 100점이다.
  • rank 2는 95점, rank 20은 5점, rank 21부터는 0점이다.
  • 동일 metric 값의 동률 후보는 같은 지표 점수를 받아야 한다.
  • 특정 지표가 없는 후보는 해당 지표 점수 0점이어야 한다.
  • 최종 점수가 같은 후보는 데뷔일이 더 늦은 크리에이터가 먼저 노출된다.
  • 최종 점수와 데뷔일이 같으면 creatorId가 더 큰 크리에이터가 먼저 노출된다.

Feature D. 스냅샷 저장과 최종 인원 수

Requirements

  • 최종 공개 랭킹 인원 수는 20명이다.
  • 스냅샷 저장 대상도 최종 점수 기준 상위 20명으로 제한한다.
  • 최종 동점 tie-breaker가 명확하므로 기존처럼 20위 점수 동점자를 추가 저장하지 않고 정확히 20명만 저장한다.
  • 스냅샷 테이블은 산식별 metric 컬럼을 늘리지 않고 최종 랭킹 결과를 저장하는 구조로 재정리한다.
  • 기존 스냅샷 데이터는 보존하지 않고 삭제한 뒤 신규 스냅샷을 다시 생성한다.
  • creator_ranking_snapshot 테이블 자체는 유지한다.
  • 유지하는 고정 컬럼은 id, ranking_type, aggregation_start_at_utc, aggregation_end_at_utc, visible_from_at, creator_id, nickname, profile_image_url, final_score, created_at, updated_at이다.
  • 신규 고정 컬럼은 rank_no, score_policy_version, score_detail_json이다.
  • rank_no는 최종 tie-breaker까지 반영된 저장 순위이며 공개 조회는 rank_no asc를 기준으로 한다.
  • score_policy_version은 스냅샷 생성에 사용한 산식 버전이다. 예: CREATOR_WEEKLY_POPULARITY_V2.
  • score_detail_json은 산식별 원천 지표, rank, rank score, weight, weighted score, tie-breaker 값을 저장한다.
  • score_detail_json에는 최소한 이번 산식의 liveRevenue, contentRevenue, followIncrease, aiChat 상세가 포함되어야 한다.
  • 기존 산식 전용 컬럼은 삭제한다. 삭제 대상은 content_live_score, engagement_score, support_score, fan_loyalty_score, live_can_amount, content_purchase_can_amount, content_like_count, content_comment_count, channel_donation_can_amount, channel_donation_count, fan_talk_count, final_follower_count, follow_increase다.
  • 이후 산식 변경 시에는 score_policy_versionscore_detail_json의 애플리케이션 schema만 변경하고, 공개 조회에 필요한 고정 컬럼이 늘어나지 않는 한 테이블 DDL은 변경하지 않는다.
  • 운영 추적은 DB의 산식별 개별 컬럼이 아니라 score_detail_json, 로그, 테스트 fixture를 기준으로 한다.

Edge Cases

  • 후보가 20명 미만이면 가능한 후보만 저장/응답한다.
  • 최종 점수가 0인 후보는 스냅샷 저장 대상에서 제외한다.
  • 스냅샷 생성 결과가 0건이면 콘텐츠 랭킹 fallback과 동일하게 fallback job 3회 제한으로 반복 실행을 제한한다. 별도 empty marker는 이번 범위에 추가하지 않는다.
  • 기존 스냅샷 row를 삭제하므로 legacy row 조회 호환, nullable backfill, old/new score version 혼재 처리는 이번 범위에서 고려하지 않는다.
  • 기존 job 이력이 fallback 3회 제한에 영향을 주지 않도록 creator_ranking_snapshot_job 기존 데이터도 함께 삭제한다.

Feature E. 콘텐츠 랭킹과 동일한 fallback refresh

Requirements

  • 크리에이터 랭킹 조회 시 최신 공개 스냅샷이 없으면 fallback refresh를 시도한다.
  • fallback은 조회 서비스가 원천 집계 결과를 직접 응답하지 않는다.
  • fallback은 스케줄러와 동일한 크리에이터 랭킹 refresh service를 실행해 creator_ranking_snapshot에 저장한다.
  • fallback 실행 후 저장된 최신 공개 스냅샷을 다시 조회해 응답한다.
  • fallback 후에도 스냅샷이 없으면 showRankChange=false, items=[]로 성공 응답한다.
  • fallback 여부는 공개 API response schema에 포함하지 않는다.
  • fallback 실행은 creator_ranking_snapshot_jobFALLBACK trigger로 기록한다.
  • 동일 집계 기간 기준 fallback job 수가 3회 이상이면 추가 fallback을 실행하지 않는다.
  • fallback refresh는 기간 기반 Redisson lock을 사용해 같은 기간 중복 refresh를 막는다.
  • lock 획득 실패는 다른 요청 또는 스케줄러가 처리 중인 정상 skip으로 보고, 현재 요청은 재조회 후 없으면 빈 목록을 반환한다.
  • fallback 실패는 로그와 job 상태로 남기되 공개 API 전체 실패로 전파하지 않는다.

Edge Cases

  • 스냅샷 테이블에 과거 row가 있더라도 최신 공개 스냅샷이 없으면 fallback을 시도한다. 이는 기존 cold-start 전용 fallback과 달라지는 핵심 동작이다.
  • fallback으로 생성된 스냅샷이 아직 공개 시각 전이면 재조회 결과가 없을 수 있으며, 이 경우 빈 목록을 반환한다.
  • fallback job 저장은 성공했지만 refresh가 실패하면 job은 FAILED가 되어야 한다.
  • fallback 3회 제한에는 실패한 fallback job도 포함한다.

Feature F. 순위 변화 계산 유지

Requirements

  • 최신 공개 스냅샷과 직전 공개 스냅샷을 비교해 기존과 같은 방식으로 rankChange, isNew, showRankChange를 계산한다.
  • 공개 API 응답 DTO는 변경하지 않는다.
  • 차단 관계 마스킹은 기존 크리에이터 랭킹 조회 정책을 유지한다.
  • 프로필 이미지 CDN URL 변환 정책도 기존 정책을 유지한다.

Edge Cases

  • 직전 공개 스냅샷이 없으면 showRankChange=false, rankChange=null, isNew=false를 유지한다.
  • 차단된 크리에이터는 row를 제거하지 않고 기존처럼 id 0, 빈 닉네임, 기본 프로필 이미지로 마스킹한다.

Feature G. 테스트 기준

Requirements

  • 기간 정책 테스트는 KST 주간 경계와 UTC 변환을 검증한다.
  • 순위 점수 정책 테스트는 rank 1, 2, 20, 21과 동률 rank를 검증한다.
  • 최종 점수 정책 테스트는 네 개 지표의 가중합을 검증한다.
  • 지표별 0값 처리와 followIncrease 음수값 0 보정은 테스트로 명시한다.
  • 집계 repository 테스트는 캔 조건, 팔로우 증가, AI 채팅 개수, 활성 크리에이터 조건, 후보 제외 조건을 검증한다.
  • refresh service 테스트는 신규 산식으로 상위 20명만 저장하는지 검증한다.
  • query service 테스트는 최신 공개 스냅샷 없음 시 fallback 실행 후 재조회하는지 검증한다.
  • job service 테스트는 FALLBACK trigger 생성, 최대 3회 제한, lock 획득 실패 skip, 실패 상태 기록을 검증한다.
  • controller/facade 테스트는 공개 응답 스키마가 바뀌지 않았음을 검증한다.

Edge Cases

  • fallback refresh 실패 시 빈 목록 성공 응답을 검증한다.
  • fallback refresh 성공 후 재조회 결과가 있으면 해당 스냅샷으로 응답함을 검증한다.
  • 최신 공개 스냅샷이 있으면 fallback을 실행하지 않음을 검증한다.

8. Technical Constraints

  • Kotlin, Spring Boot 2.7.14, Java 17, Gradle Wrapper 구조를 유지한다.
  • 기존 kr.co.vividnext.sodalive.v2.ranking 패키지 경계와 v2.api.home facade/controller 경계를 유지한다.
  • 기존 API endpoint GET /api/v2/home/rankings/creators를 유지한다.
  • 콘텐츠 랭킹 fallback 구현인 AudioRankingQueryServiceAudioRankingSnapshotJobService의 흐름을 우선 참고한다.
  • 크리에이터 랭킹 job trigger enum에 FALLBACK을 추가해야 한다.
  • 신규 산식의 원천 지표와 하위 점수는 산식 전용 컬럼으로 추가하지 않고 score_detail_json에 저장한다.
  • 기존 creator_ranking_snapshot 테이블은 유지하되 formula-specific 컬럼은 삭제한다.
  • 운영 DB 반영용 DDL은 MySQL 기준으로 작성하며, 기존 스냅샷/job 데이터 삭제를 전제로 한다.
  • 공개 조회와 직전 스냅샷 비교는 rank_no와 고정 컬럼만 사용한다.
  • score_detail_json은 MySQL JSON 타입을 기본으로 사용한다.
  • score_detail_json의 key schema는 코드 상수와 테스트 fixture로 고정해 산식 변경 시에도 의도치 않은 운영 추적 포맷 변경을 막는다.
  • DB-side scoring을 선택하면 Kotlin 정책 테스트와 DB expression이 같은 상수를 참조하거나, 최소한 동일 기대값 테스트로 parity를 보장해야 한다.
  • Kotlin-side scoring을 선택하면 DB는 원천 metric만 집계하고 Kotlin이 rank/score/sort/limit을 적용한다. 후보 수가 많을 때 메모리 사용량을 구현 계획에서 검토한다.
  • 공개 API 스키마는 변경하지 않는다.

9. Metrics

  • 크리에이터 랭킹 스냅샷 생성 성공/실패 횟수
  • fallback 실행/성공/실패 횟수
  • fallback 최대 3회 제한으로 skip된 횟수
  • fallback lock 획득 성공/실패 횟수
  • 크리에이터 랭킹 조회 API latency
  • 신규 산식 기준 후보 크리에이터 수
  • 최종 저장/응답 item 수
  • metric별 rank score 분포
  • 스냅샷 생성 소요 시간

10. Decisions

  • 라이브 매출 랭킹 점수 가중치는 60%로 한다.
  • 최종 산식 가중치 합은 60% + 20% + 10% + 10% = 100%다.
  • AI 채팅 개수chat_message에서 ParticipantType.CHARACTER 메시지를 세는 방식으로 본다.
  • followIncrease의 최소값은 0이다.
  • 특정 지표 값이 0인 후보는 해당 지표 점수를 0점으로 강제한다.
  • 최종 동점 tie-breaker는 크리에이터 데뷔일 내림차순, creatorId 내림차순이다.
  • 최종 동점 tie-breaker가 명확하므로 스냅샷 저장은 20명으로 제한한다.
  • 기존 크리에이터 랭킹 스냅샷 데이터와 job 이력은 삭제하고 신규 구조 기준으로 다시 생성한다.
  • creator_ranking_snapshot 테이블은 유지하되 산식별 컬럼을 삭제하고 rank_no, score_policy_version, score_detail_json 중심으로 재정리한다.
  • 기존 old semantic 컬럼을 재사용해 의미를 바꾸는 방식은 운영 추적과 관리자 조회에서 혼동을 만들 수 있으므로 사용하지 않는다.
  • 미래 산식 변경은 고정 컬럼 추가가 꼭 필요한 경우가 아니라면 score_policy_versionscore_detail_json schema 변경만으로 처리한다.
  • 최종 점수가 0인 후보는 저장하지 않는다.
  • 스냅샷 생성 결과가 0건이어도 empty marker는 추가하지 않고, 콘텐츠 랭킹 fallback과 같은 job 3회 제한으로 반복 fallback을 제한한다.

11. Open Questions

  • 현재 PRD 기준 미결정 항목은 없다. DDL은 기존 데이터 삭제와 산식별 컬럼 삭제를 전제로 작성한다.

  • docs/prd/sample-prd.md
  • docs/agent-guides/작업절차.md
  • docs/agent-guides/문서유지보수.md
  • docs/20260608_크리에이터_랭킹/prd.md
  • docs/20260623_메인_콘텐츠_랭킹_탭_API/prd.md
  • docs/20260710_메인_홈_크리에이터_랭킹_개편/alter-creator-ranking-snapshot-score-detail.sql