@@ -1623,7 +1623,7 @@ private List<RelatedArtistResponse> getRelatedArtists(
16231623 }
16241624
16251625 // 2단계: 점수 매기기 (Score)
1626- List <ScoredArtist > scoredArtists = scoreCandidates (candidates , artistGroup , artistType , genreId );
1626+ List <ScoredArtist > scoredArtists = scoreCandidates (candidates , artistGroup , artistType , genreId , artistId );
16271627
16281628 // 3단계: 4~5명 뽑기 + 도배 방지 (Diversity)
16291629 List <Artist > selectedArtists = selectWithDiversity (scoredArtists , artistGroup , genreId );
@@ -1647,9 +1647,9 @@ private List<RelatedArtistResponse> getRelatedArtists(
16471647
16481648 /**
16491649 * 1단계: 후보 뽑기 (Recall)
1650- * - 같은 genre인 아티스트들
1651- * - 같은 artistGroup인 아티스트들 (artistGroup이 있을 때만)
1652- * - 같은 artistType인 아티스트들
1650+ * - 같은 genre인 아티스트들 (최대 200명, 결정론적 다양성을 위해 넓은 후보 풀 확보)
1651+ * - 같은 artistGroup인 아티스트들 (최대 5명, artistGroup이 있을 때만)
1652+ * - 같은 artistType인 아티스트들 (최대 50명, fallback: 장르/그룹 후보가 부족할 때만)
16531653 */
16541654 private Set <Artist > collectRelatedCandidates (
16551655 long artistId ,
@@ -1658,27 +1658,34 @@ private Set<Artist> collectRelatedCandidates(
16581658 Long genreId
16591659 ) {
16601660 Set <Artist > candidates = new HashSet <>();
1661+ final int MAX_GENRE_CANDIDATES = 200 ; // 결정론적 다양성을 위해 후보 풀 확장
1662+ final int MAX_GROUP_CANDIDATES = 5 ;
1663+ final int MAX_TYPE_CANDIDATES = 50 ;
1664+ final int MIN_CANDIDATES_FOR_FALLBACK = 10 ; // 이보다 적으면 타입 후보 추가
16611665
16621666 // 같은 genre인 아티스트들
1663- if (genreId != null ) {
1664- List <Artist > sameGenre = artistRepository .findTop5ByGenreIdAndIdNot (
1667+ if (genreId != null ) {
1668+ List <Artist > sameGenre = artistRepository .findByGenreIdAndIdNot (
16651669 genreId , artistId ,
1666- org .springframework .data .domain .PageRequest .of (0 , 50 ) // 충분히 많이 가져오기
1670+ org .springframework .data .domain .PageRequest .of (0 , MAX_GENRE_CANDIDATES )
16671671 );
16681672 candidates .addAll (sameGenre );
16691673 }
16701674
16711675 // 같은 artistGroup인 아티스트들 (artistGroup이 있을 때만)
16721676 if (artistGroup != null && !artistGroup .isBlank ()) {
1673- List <Artist > sameGroup = artistRepository .findTop5ByArtistGroupAndIdNot (artistGroup , artistId );
1677+ List <Artist > sameGroup = artistRepository .findByArtistGroupAndIdNot (
1678+ artistGroup , artistId ,
1679+ org .springframework .data .domain .PageRequest .of (0 , MAX_GROUP_CANDIDATES )
1680+ );
16741681 candidates .addAll (sameGroup );
16751682 }
16761683
1677- // 같은 artistType인 아티스트들
1678- if (artistType != null ) {
1684+ // 같은 artistType인 아티스트들 (fallback: 후보가 부족할 때만)
1685+ if (artistType != null && candidates . size () < MIN_CANDIDATES_FOR_FALLBACK ) {
16791686 List <Artist > sameType = artistRepository .findByArtistTypeAndIdNot (
16801687 artistType , artistId ,
1681- org .springframework .data .domain .PageRequest .of (0 , 50 ) // 충분히 많이 가져오기
1688+ org .springframework .data .domain .PageRequest .of (0 , MAX_TYPE_CANDIDATES )
16821689 );
16831690 candidates .addAll (sameType );
16841691 }
@@ -1689,34 +1696,44 @@ private Set<Artist> collectRelatedCandidates(
16891696 /**
16901697 * 2단계: 점수 매기기 (Score)
16911698 * - 같은 그룹: +80
1692- * - 같은 장르: +60
1699+ * - 같은 장르: +60 (그룹 점수가 있을 때는 +30으로 완화)
16931700 * - 같은 타입: +15
1694- * - likeCount 보정: + 5 * log(likeCount+1)
1701+ * - likeCount 보정: + 5 * log(likeCount+1), 최대 15점 (기본 연관 점수가 30 이상일 때만 적용)
1702+ * - hash 기반 미세 조정: 점수에 직접 반영하여 기준 아티스트별로 다른 결과 보장
1703+ *
1704+ * 정렬: 점수(내부에 hash 반영) → likeCount → 이름 → Spotify ID → id
1705+ * hash를 점수에 직접 반영하여 같은 점수/likeCount를 가진 아티스트들도 기준 아티스트별로 다른 순서를 보장
16951706 */
16961707 private List <ScoredArtist > scoreCandidates (
16971708 Set <Artist > candidates ,
16981709 String artistGroup ,
16991710 ArtistType artistType ,
1700- Long genreId
1711+ Long genreId ,
1712+ long baseArtistId // 기준 아티스트 ID (hash 계산용)
17011713 ) {
17021714 List <ScoredArtist > scored = new ArrayList <>();
1715+ final double MAX_LIKECOUNT_BONUS = 15.0 ;
1716+ final double MIN_BASE_SCORE_FOR_LIKECOUNT = 30.0 ; // 기본 연관 점수가 이 이상일 때만 likeCount 보정 적용
17031717
17041718 for (Artist candidate : candidates ) {
17051719 double score = 0.0 ;
1720+ boolean hasGroupScore = false ;
17061721
17071722 // 같은 그룹이면 +80
17081723 if (artistGroup != null && !artistGroup .isBlank () &&
17091724 candidate .getArtistGroup () != null &&
17101725 candidate .getArtistGroup ().equals (artistGroup )) {
17111726 score += 80 ;
1727+ hasGroupScore = true ;
17121728 }
17131729
1714- // 같은 장르면 +60
1730+ // 같은 장르면 +60 (그룹 점수가 있을 때는 +30으로 완화하여 중복 가산 완화)
17151731 if (genreId != null ) {
17161732 boolean hasSameGenre = candidate .getArtistGenres ().stream ()
17171733 .anyMatch (ag -> ag .getGenre ().getId () == genreId );
17181734 if (hasSameGenre ) {
1719- score += 60 ;
1735+ // 그룹 점수가 있으면 장르 점수를 절반으로 완화
1736+ score += hasGroupScore ? 30 : 60 ;
17201737 }
17211738 }
17221739
@@ -1725,64 +1742,165 @@ private List<ScoredArtist> scoreCandidates(
17251742 score += 15 ;
17261743 }
17271744
1728- // likeCount 보정: + 5 * log(likeCount+1)
1729- if (candidate .getLikeCount () > 0 ) {
1730- score += 5.0 * Math .log (candidate .getLikeCount () + 1 );
1745+ // likeCount 보정: 기본 연관 점수가 일정 수준 이상일 때만 적용, 최대 15점
1746+ double baseScore = score ; // likeCount 보정 전 점수
1747+ if (baseScore >= MIN_BASE_SCORE_FOR_LIKECOUNT && candidate .getLikeCount () > 0 ) {
1748+ double likeCountBonus = 5.0 * Math .log (candidate .getLikeCount () + 1 );
1749+ score += Math .min (likeCountBonus , MAX_LIKECOUNT_BONUS );
17311750 }
17321751
1733- scored .add (new ScoredArtist (candidate , score ));
1752+ // hash 기반 tie-breaker 값 계산 (기준 아티스트 ID와 후보 아티스트 ID 조합)
1753+ int hashValue = calculateHashForTieBreaker (baseArtistId , candidate .getId ());
1754+
1755+ // hash를 점수에 반영하여 기준 아티스트별로 다른 순서 보장
1756+ // hashValue를 0~1 범위로 정규화하여 점수에 더함 (최대 약 1점 차이)
1757+ // 음수 hash도 처리하기 위해 절댓값 사용 후 modulo 연산
1758+ double normalizedHash = (Math .abs (hashValue ) % 10000 ) / 10000.0 ; // 0.0 ~ 0.9999
1759+ score += normalizedHash ; // 최대 약 1점 차이로 같은 점수/likeCount를 가진 아티스트들도 순서가 달라짐
1760+
1761+ scored .add (new ScoredArtist (candidate , score , hashValue ));
17341762 }
17351763
1736- // 점수 내림차순 정렬, 같은 점수면 id 오름차순으로 일관성 보장
1764+ // 점수 내림차순 정렬, 동점일 때는 의미 있는 기준으로 정렬
17371765 scored .sort ((a , b ) -> {
1766+ // 1순위: 점수 내림차순 (이미 hash가 반영되어 있음)
17381767 int scoreCompare = Double .compare (b .score , a .score );
17391768 if (scoreCompare != 0 ) {
17401769 return scoreCompare ;
17411770 }
1742- // 같은 점수면 id 오름차순으로 일관성 보장
1771+
1772+ // 2순위: likeCount 내림차순
1773+ int likeCountCompare = Integer .compare (b .artist .getLikeCount (), a .artist .getLikeCount ());
1774+ if (likeCountCompare != 0 ) {
1775+ return likeCountCompare ;
1776+ }
1777+
1778+ // 3순위: 이름 오름차순 (nameKo 우선, 없으면 artistName)
1779+ String nameA = a .artist .getNameKo () != null && !a .artist .getNameKo ().isBlank ()
1780+ ? a .artist .getNameKo ()
1781+ : a .artist .getArtistName ();
1782+ String nameB = b .artist .getNameKo () != null && !b .artist .getNameKo ().isBlank ()
1783+ ? b .artist .getNameKo ()
1784+ : b .artist .getArtistName ();
1785+ int nameCompare = nameA .compareTo (nameB );
1786+ if (nameCompare != 0 ) {
1787+ return nameCompare ;
1788+ }
1789+
1790+ // 4순위: Spotify ID 오름차순
1791+ if (a .artist .getSpotifyArtistId () != null && b .artist .getSpotifyArtistId () != null ) {
1792+ int spotifyIdCompare = a .artist .getSpotifyArtistId ().compareTo (b .artist .getSpotifyArtistId ());
1793+ if (spotifyIdCompare != 0 ) {
1794+ return spotifyIdCompare ;
1795+ }
1796+ }
1797+
1798+ // 최종 tie-breaker: id 오름차순
17431799 return Long .compare (a .artist .getId (), b .artist .getId ());
17441800 });
17451801
17461802 return scored ;
17471803 }
17481804
17491805 /**
1750- * 3단계: 4~5명 뽑기 + 도배 방지 (Diversity)
1751- * - 같은 artistGroup 최대 2명
1752- * - 나머지는 같은 genre에서 채우기
1806+ * 기준 아티스트 ID와 후보 아티스트 ID를 조합하여 hash 값 계산
1807+ *
1808+ * 같은 기준 아티스트에 대해서는 항상 동일한 hash 값을 반환하지만,
1809+ * 기준 아티스트가 다르면 같은 후보라도 다른 hash 값을 가져 결정론적 다양성을 보장합니다.
1810+ *
1811+ * @param baseArtistId 기준 아티스트 ID
1812+ * @param candidateArtistId 후보 아티스트 ID
1813+ * @return hash 값 (정렬용)
1814+ */
1815+ private int calculateHashForTieBreaker (long baseArtistId , long candidateArtistId ) {
1816+ // 두 ID를 조합하여 hash 계산
1817+ String combined = baseArtistId + "-" + candidateArtistId ;
1818+ return combined .hashCode ();
1819+ }
1820+
1821+ /**
1822+ * 3단계: 슬롯 기반 최종 선택 (Diversity)
1823+ *
1824+ * 슬롯 구조로 구성 비율을 강제하여 장르 편향을 완화합니다.
1825+ * 랜덤 요소 없이 점수 순으로 고정적으로 선별하므로, 동일한 아티스트 조회 시 항상 동일한 결과를 보장합니다.
1826+ *
1827+ * 슬롯 구성:
1828+ * - 그룹 슬롯: 같은 그룹 최대 2명
1829+ * - 장르 슬롯: 같은 장르(그룹 아님) 최대 3명
1830+ * - 그 외 슬롯: 나머지
1831+ *
1832+ * 목표: 최대 5명
17531833 */
17541834 private List <Artist > selectWithDiversity (
17551835 List <ScoredArtist > scoredArtists ,
17561836 String artistGroup ,
17571837 Long genreId
17581838 ) {
1759- List <Artist > selected = new ArrayList <>();
1760- int sameGroupCount = 0 ;
17611839 final int MAX_SAME_GROUP = 2 ;
1840+ final int MAX_SAME_GENRE = 3 ;
17621841 final int TARGET_COUNT = 5 ;
17631842
1843+ // 슬롯별로 후보 분류
1844+ List <ScoredArtist > groupSlot = new ArrayList <>();
1845+ List <ScoredArtist > genreSlot = new ArrayList <>();
1846+ List <ScoredArtist > otherSlot = new ArrayList <>();
1847+
17641848 for (ScoredArtist scored : scoredArtists ) {
1765- if (selected .size () >= TARGET_COUNT ) {
1766- break ;
1767- }
1768-
17691849 Artist candidate = scored .artist ;
17701850
17711851 // 같은 그룹 체크
17721852 boolean isSameGroup = artistGroup != null && !artistGroup .isBlank () &&
17731853 candidate .getArtistGroup () != null &&
17741854 candidate .getArtistGroup ().equals (artistGroup );
17751855
1856+ // 같은 장르 체크
1857+ boolean isSameGenre = genreId != null && candidate .getArtistGenres ().stream ()
1858+ .anyMatch (ag -> ag .getGenre ().getId () == genreId );
1859+
17761860 if (isSameGroup ) {
1777- if (sameGroupCount < MAX_SAME_GROUP ) {
1778- selected .add (candidate );
1779- sameGroupCount ++;
1780- }
1781- // 같은 그룹이 이미 2명이면 스킵
1861+ groupSlot .add (scored );
1862+ } else if (isSameGenre ) {
1863+ genreSlot .add (scored );
17821864 } else {
1783- // 같은 그룹이 아니면 추가
1784- selected .add (candidate );
1865+ otherSlot .add (scored );
1866+ }
1867+ }
1868+
1869+ // 슬롯별로 최종 선택 (각 슬롯 내에서는 이미 점수 순으로 정렬되어 있음)
1870+ List <Artist > selected = new ArrayList <>();
1871+
1872+ // 1. 그룹 슬롯에서 최대 2명 선택
1873+ for (int i = 0 ; i < Math .min (MAX_SAME_GROUP , groupSlot .size ()) && selected .size () < TARGET_COUNT ; i ++) {
1874+ selected .add (groupSlot .get (i ).artist );
17851875 }
1876+
1877+ // 2. 장르 슬롯에서 선택 (그룹 슬롯 선택 후 남은 자리만큼, 최대 3명)
1878+ int remainingSlots = TARGET_COUNT - selected .size ();
1879+ int genreCount = Math .min (MAX_SAME_GENRE , Math .min (genreSlot .size (), remainingSlots ));
1880+ for (int i = 0 ; i < genreCount && selected .size () < TARGET_COUNT ; i ++) {
1881+ selected .add (genreSlot .get (i ).artist );
1882+ }
1883+
1884+ // 3. 그 외 슬롯에서 나머지 채우기 (5명이 될 때까지)
1885+ for (ScoredArtist scored : otherSlot ) {
1886+ if (selected .size () >= TARGET_COUNT ) {
1887+ break ;
1888+ }
1889+ selected .add (scored .artist );
1890+ }
1891+
1892+ // 4. 장르 슬롯에서 추가로 채우기 (5명이 안 되면 장르 슬롯에서 더 선택)
1893+ if (selected .size () < TARGET_COUNT && genreSlot .size () > genreCount ) {
1894+ for (int i = genreCount ; i < genreSlot .size () && selected .size () < TARGET_COUNT ; i ++) {
1895+ selected .add (genreSlot .get (i ).artist );
1896+ }
1897+ }
1898+
1899+ // 5. 그룹 슬롯에서 추가로 채우기 (5명이 안 되면 그룹 슬롯에서 더 선택)
1900+ if (selected .size () < TARGET_COUNT && groupSlot .size () > MAX_SAME_GROUP ) {
1901+ for (int i = MAX_SAME_GROUP ; i < groupSlot .size () && selected .size () < TARGET_COUNT ; i ++) {
1902+ selected .add (groupSlot .get (i ).artist );
1903+ }
17861904 }
17871905
17881906 return selected ;
@@ -1794,10 +1912,18 @@ private List<Artist> selectWithDiversity(
17941912 private static class ScoredArtist {
17951913 final Artist artist ;
17961914 final double score ;
1915+ final int hashValue ; // hash 기반 tie-breaker 값
17971916
17981917 ScoredArtist (Artist artist , double score ) {
17991918 this .artist = artist ;
18001919 this .score = score ;
1920+ this .hashValue = 0 ; // 하위 호환성
1921+ }
1922+
1923+ ScoredArtist (Artist artist , double score , int hashValue ) {
1924+ this .artist = artist ;
1925+ this .score = score ;
1926+ this .hashValue = hashValue ;
18011927 }
18021928 }
18031929
0 commit comments