diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/repository/ArtistRepository.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/repository/ArtistRepository.java index a4903b67..9472fcb3 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/repository/ArtistRepository.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/repository/ArtistRepository.java @@ -23,19 +23,35 @@ public interface ArtistRepository extends JpaRepository { boolean existsByArtistName(String artistName); boolean existsByNameKo(String nameKo); - @Query("SELECT a FROM Artist a WHERE a.artistGroup = :artistGroup AND a.id != :excludeId ORDER BY a.likeCount DESC, a.id ASC") - List findTop5ByArtistGroupAndIdNot(@org.springframework.data.repository.query.Param("artistGroup") String artistGroup, - @org.springframework.data.repository.query.Param("excludeId") long excludeId); + /** + * 같은 artistGroup인 아티스트들 조회 (관련 아티스트 추천용) + * artistGenres를 fetch join하여 N+1 문제 방지 + */ + @Query(""" + SELECT DISTINCT a FROM Artist a + LEFT JOIN FETCH a.artistGenres ag + LEFT JOIN FETCH ag.genre + WHERE a.artistGroup = :artistGroup AND a.id != :excludeId + ORDER BY a.likeCount DESC, a.id ASC + """) + List findByArtistGroupAndIdNot(@org.springframework.data.repository.query.Param("artistGroup") String artistGroup, + @org.springframework.data.repository.query.Param("excludeId") long excludeId, + Pageable pageable); + /** + * 같은 genre인 아티스트들 조회 (관련 아티스트 추천용) + * artistGenres와 genre를 fetch join하여 N+1 문제 방지 + */ @Query(""" SELECT DISTINCT a FROM Artist a - JOIN a.artistGenres ag + JOIN FETCH a.artistGenres ag + JOIN FETCH ag.genre WHERE ag.genre.id = :genreId AND a.id != :excludeId ORDER BY a.likeCount DESC, a.id ASC """) - List findTop5ByGenreIdAndIdNot(@org.springframework.data.repository.query.Param("genreId") Long genreId, - @org.springframework.data.repository.query.Param("excludeId") long excludeId, - Pageable pageable); + List findByGenreIdAndIdNot(@org.springframework.data.repository.query.Param("genreId") Long genreId, + @org.springframework.data.repository.query.Param("excludeId") long excludeId, + Pageable pageable); List findAllByArtistNameContainingIgnoreCaseOrNameKoContainingIgnoreCase(String artistName1, String artistName2); @@ -64,8 +80,17 @@ List findTop5ByGenreIdAndIdNot(@org.springframework.data.repository.quer @Query("SELECT a FROM Artist a WHERE a.spotifyArtistId IN :spotifyIds") List findBySpotifyArtistIdIn(@org.springframework.data.repository.query.Param("spotifyIds") List spotifyIds); - // 같은 artistType인 아티스트들 조회 (관련 아티스트 추천용) - @Query("SELECT a FROM Artist a WHERE a.artistType = :artistType AND a.id != :excludeId ORDER BY a.likeCount DESC, a.id ASC") + /** + * 같은 artistType인 아티스트들 조회 (관련 아티스트 추천용, fallback) + * artistGenres를 fetch join하여 N+1 문제 방지 + */ + @Query(""" + SELECT DISTINCT a FROM Artist a + LEFT JOIN FETCH a.artistGenres ag + LEFT JOIN FETCH ag.genre + WHERE a.artistType = :artistType AND a.id != :excludeId + ORDER BY a.likeCount DESC, a.id ASC + """) List findByArtistTypeAndIdNot(@org.springframework.data.repository.query.Param("artistType") com.back.web7_9_codecrete_be.domain.artists.entity.ArtistType artistType, @org.springframework.data.repository.query.Param("excludeId") long excludeId, Pageable pageable); diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyService.java index ccfdf3d2..9c561595 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyService.java @@ -1623,7 +1623,7 @@ private List getRelatedArtists( } // 2단계: 점수 매기기 (Score) - List scoredArtists = scoreCandidates(candidates, artistGroup, artistType, genreId); + List scoredArtists = scoreCandidates(candidates, artistGroup, artistType, genreId, artistId); // 3단계: 4~5명 뽑기 + 도배 방지 (Diversity) List selectedArtists = selectWithDiversity(scoredArtists, artistGroup, genreId); @@ -1647,9 +1647,9 @@ private List getRelatedArtists( /** * 1단계: 후보 뽑기 (Recall) - * - 같은 genre인 아티스트들 - * - 같은 artistGroup인 아티스트들 (artistGroup이 있을 때만) - * - 같은 artistType인 아티스트들 + * - 같은 genre인 아티스트들 (최대 200명, 결정론적 다양성을 위해 넓은 후보 풀 확보) + * - 같은 artistGroup인 아티스트들 (최대 5명, artistGroup이 있을 때만) + * - 같은 artistType인 아티스트들 (최대 50명, fallback: 장르/그룹 후보가 부족할 때만) */ private Set collectRelatedCandidates( long artistId, @@ -1658,27 +1658,34 @@ private Set collectRelatedCandidates( Long genreId ) { Set candidates = new HashSet<>(); + final int MAX_GENRE_CANDIDATES = 200; // 결정론적 다양성을 위해 후보 풀 확장 + final int MAX_GROUP_CANDIDATES = 5; + final int MAX_TYPE_CANDIDATES = 50; + final int MIN_CANDIDATES_FOR_FALLBACK = 10; // 이보다 적으면 타입 후보 추가 // 같은 genre인 아티스트들 - if (genreId != null) { - List sameGenre = artistRepository.findTop5ByGenreIdAndIdNot( + if (genreId != null) { + List sameGenre = artistRepository.findByGenreIdAndIdNot( genreId, artistId, - org.springframework.data.domain.PageRequest.of(0, 50) // 충분히 많이 가져오기 + org.springframework.data.domain.PageRequest.of(0, MAX_GENRE_CANDIDATES) ); candidates.addAll(sameGenre); } // 같은 artistGroup인 아티스트들 (artistGroup이 있을 때만) if (artistGroup != null && !artistGroup.isBlank()) { - List sameGroup = artistRepository.findTop5ByArtistGroupAndIdNot(artistGroup, artistId); + List sameGroup = artistRepository.findByArtistGroupAndIdNot( + artistGroup, artistId, + org.springframework.data.domain.PageRequest.of(0, MAX_GROUP_CANDIDATES) + ); candidates.addAll(sameGroup); } - // 같은 artistType인 아티스트들 - if (artistType != null) { + // 같은 artistType인 아티스트들 (fallback: 후보가 부족할 때만) + if (artistType != null && candidates.size() < MIN_CANDIDATES_FOR_FALLBACK) { List sameType = artistRepository.findByArtistTypeAndIdNot( artistType, artistId, - org.springframework.data.domain.PageRequest.of(0, 50) // 충분히 많이 가져오기 + org.springframework.data.domain.PageRequest.of(0, MAX_TYPE_CANDIDATES) ); candidates.addAll(sameType); } @@ -1689,34 +1696,44 @@ private Set collectRelatedCandidates( /** * 2단계: 점수 매기기 (Score) * - 같은 그룹: +80 - * - 같은 장르: +60 + * - 같은 장르: +60 (그룹 점수가 있을 때는 +30으로 완화) * - 같은 타입: +15 - * - likeCount 보정: + 5 * log(likeCount+1) + * - likeCount 보정: + 5 * log(likeCount+1), 최대 15점 (기본 연관 점수가 30 이상일 때만 적용) + * - hash 기반 미세 조정: 점수에 직접 반영하여 기준 아티스트별로 다른 결과 보장 + * + * 정렬: 점수(내부에 hash 반영) → likeCount → 이름 → Spotify ID → id + * hash를 점수에 직접 반영하여 같은 점수/likeCount를 가진 아티스트들도 기준 아티스트별로 다른 순서를 보장 */ private List scoreCandidates( Set candidates, String artistGroup, ArtistType artistType, - Long genreId + Long genreId, + long baseArtistId // 기준 아티스트 ID (hash 계산용) ) { List scored = new ArrayList<>(); + final double MAX_LIKECOUNT_BONUS = 15.0; + final double MIN_BASE_SCORE_FOR_LIKECOUNT = 30.0; // 기본 연관 점수가 이 이상일 때만 likeCount 보정 적용 for (Artist candidate : candidates) { double score = 0.0; + boolean hasGroupScore = false; // 같은 그룹이면 +80 if (artistGroup != null && !artistGroup.isBlank() && candidate.getArtistGroup() != null && candidate.getArtistGroup().equals(artistGroup)) { score += 80; + hasGroupScore = true; } - // 같은 장르면 +60 + // 같은 장르면 +60 (그룹 점수가 있을 때는 +30으로 완화하여 중복 가산 완화) if (genreId != null) { boolean hasSameGenre = candidate.getArtistGenres().stream() .anyMatch(ag -> ag.getGenre().getId() == genreId); if (hasSameGenre) { - score += 60; + // 그룹 점수가 있으면 장르 점수를 절반으로 완화 + score += hasGroupScore ? 30 : 60; } } @@ -1725,21 +1742,60 @@ private List scoreCandidates( score += 15; } - // likeCount 보정: + 5 * log(likeCount+1) - if (candidate.getLikeCount() > 0) { - score += 5.0 * Math.log(candidate.getLikeCount() + 1); + // likeCount 보정: 기본 연관 점수가 일정 수준 이상일 때만 적용, 최대 15점 + double baseScore = score; // likeCount 보정 전 점수 + if (baseScore >= MIN_BASE_SCORE_FOR_LIKECOUNT && candidate.getLikeCount() > 0) { + double likeCountBonus = 5.0 * Math.log(candidate.getLikeCount() + 1); + score += Math.min(likeCountBonus, MAX_LIKECOUNT_BONUS); } - scored.add(new ScoredArtist(candidate, score)); + // hash 기반 tie-breaker 값 계산 (기준 아티스트 ID와 후보 아티스트 ID 조합) + int hashValue = calculateHashForTieBreaker(baseArtistId, candidate.getId()); + + // hash를 점수에 반영하여 기준 아티스트별로 다른 순서 보장 + // hashValue를 0~1 범위로 정규화하여 점수에 더함 (최대 약 1점 차이) + // 음수 hash도 처리하기 위해 절댓값 사용 후 modulo 연산 + double normalizedHash = (Math.abs(hashValue) % 10000) / 10000.0; // 0.0 ~ 0.9999 + score += normalizedHash; // 최대 약 1점 차이로 같은 점수/likeCount를 가진 아티스트들도 순서가 달라짐 + + scored.add(new ScoredArtist(candidate, score, hashValue)); } - // 점수 내림차순 정렬, 같은 점수면 id 오름차순으로 일관성 보장 + // 점수 내림차순 정렬, 동점일 때는 의미 있는 기준으로 정렬 scored.sort((a, b) -> { + // 1순위: 점수 내림차순 (이미 hash가 반영되어 있음) int scoreCompare = Double.compare(b.score, a.score); if (scoreCompare != 0) { return scoreCompare; } - // 같은 점수면 id 오름차순으로 일관성 보장 + + // 2순위: likeCount 내림차순 + int likeCountCompare = Integer.compare(b.artist.getLikeCount(), a.artist.getLikeCount()); + if (likeCountCompare != 0) { + return likeCountCompare; + } + + // 3순위: 이름 오름차순 (nameKo 우선, 없으면 artistName) + String nameA = a.artist.getNameKo() != null && !a.artist.getNameKo().isBlank() + ? a.artist.getNameKo() + : a.artist.getArtistName(); + String nameB = b.artist.getNameKo() != null && !b.artist.getNameKo().isBlank() + ? b.artist.getNameKo() + : b.artist.getArtistName(); + int nameCompare = nameA.compareTo(nameB); + if (nameCompare != 0) { + return nameCompare; + } + + // 4순위: Spotify ID 오름차순 + if (a.artist.getSpotifyArtistId() != null && b.artist.getSpotifyArtistId() != null) { + int spotifyIdCompare = a.artist.getSpotifyArtistId().compareTo(b.artist.getSpotifyArtistId()); + if (spotifyIdCompare != 0) { + return spotifyIdCompare; + } + } + + // 최종 tie-breaker: id 오름차순 return Long.compare(a.artist.getId(), b.artist.getId()); }); @@ -1747,25 +1803,49 @@ private List scoreCandidates( } /** - * 3단계: 4~5명 뽑기 + 도배 방지 (Diversity) - * - 같은 artistGroup 최대 2명 - * - 나머지는 같은 genre에서 채우기 + * 기준 아티스트 ID와 후보 아티스트 ID를 조합하여 hash 값 계산 + * + * 같은 기준 아티스트에 대해서는 항상 동일한 hash 값을 반환하지만, + * 기준 아티스트가 다르면 같은 후보라도 다른 hash 값을 가져 결정론적 다양성을 보장합니다. + * + * @param baseArtistId 기준 아티스트 ID + * @param candidateArtistId 후보 아티스트 ID + * @return hash 값 (정렬용) + */ + private int calculateHashForTieBreaker(long baseArtistId, long candidateArtistId) { + // 두 ID를 조합하여 hash 계산 + String combined = baseArtistId + "-" + candidateArtistId; + return combined.hashCode(); + } + + /** + * 3단계: 슬롯 기반 최종 선택 (Diversity) + * + * 슬롯 구조로 구성 비율을 강제하여 장르 편향을 완화합니다. + * 랜덤 요소 없이 점수 순으로 고정적으로 선별하므로, 동일한 아티스트 조회 시 항상 동일한 결과를 보장합니다. + * + * 슬롯 구성: + * - 그룹 슬롯: 같은 그룹 최대 2명 + * - 장르 슬롯: 같은 장르(그룹 아님) 최대 3명 + * - 그 외 슬롯: 나머지 + * + * 목표: 최대 5명 */ private List selectWithDiversity( List scoredArtists, String artistGroup, Long genreId ) { - List selected = new ArrayList<>(); - int sameGroupCount = 0; final int MAX_SAME_GROUP = 2; + final int MAX_SAME_GENRE = 3; final int TARGET_COUNT = 5; + // 슬롯별로 후보 분류 + List groupSlot = new ArrayList<>(); + List genreSlot = new ArrayList<>(); + List otherSlot = new ArrayList<>(); + for (ScoredArtist scored : scoredArtists) { - if (selected.size() >= TARGET_COUNT) { - break; - } - Artist candidate = scored.artist; // 같은 그룹 체크 @@ -1773,16 +1853,54 @@ private List selectWithDiversity( candidate.getArtistGroup() != null && candidate.getArtistGroup().equals(artistGroup); + // 같은 장르 체크 + boolean isSameGenre = genreId != null && candidate.getArtistGenres().stream() + .anyMatch(ag -> ag.getGenre().getId() == genreId); + if (isSameGroup) { - if (sameGroupCount < MAX_SAME_GROUP) { - selected.add(candidate); - sameGroupCount++; - } - // 같은 그룹이 이미 2명이면 스킵 + groupSlot.add(scored); + } else if (isSameGenre) { + genreSlot.add(scored); } else { - // 같은 그룹이 아니면 추가 - selected.add(candidate); + otherSlot.add(scored); + } + } + + // 슬롯별로 최종 선택 (각 슬롯 내에서는 이미 점수 순으로 정렬되어 있음) + List selected = new ArrayList<>(); + + // 1. 그룹 슬롯에서 최대 2명 선택 + for (int i = 0; i < Math.min(MAX_SAME_GROUP, groupSlot.size()) && selected.size() < TARGET_COUNT; i++) { + selected.add(groupSlot.get(i).artist); } + + // 2. 장르 슬롯에서 선택 (그룹 슬롯 선택 후 남은 자리만큼, 최대 3명) + int remainingSlots = TARGET_COUNT - selected.size(); + int genreCount = Math.min(MAX_SAME_GENRE, Math.min(genreSlot.size(), remainingSlots)); + for (int i = 0; i < genreCount && selected.size() < TARGET_COUNT; i++) { + selected.add(genreSlot.get(i).artist); + } + + // 3. 그 외 슬롯에서 나머지 채우기 (5명이 될 때까지) + for (ScoredArtist scored : otherSlot) { + if (selected.size() >= TARGET_COUNT) { + break; + } + selected.add(scored.artist); + } + + // 4. 장르 슬롯에서 추가로 채우기 (5명이 안 되면 장르 슬롯에서 더 선택) + if (selected.size() < TARGET_COUNT && genreSlot.size() > genreCount) { + for (int i = genreCount; i < genreSlot.size() && selected.size() < TARGET_COUNT; i++) { + selected.add(genreSlot.get(i).artist); + } + } + + // 5. 그룹 슬롯에서 추가로 채우기 (5명이 안 되면 그룹 슬롯에서 더 선택) + if (selected.size() < TARGET_COUNT && groupSlot.size() > MAX_SAME_GROUP) { + for (int i = MAX_SAME_GROUP; i < groupSlot.size() && selected.size() < TARGET_COUNT; i++) { + selected.add(groupSlot.get(i).artist); + } } return selected; @@ -1794,10 +1912,18 @@ private List selectWithDiversity( private static class ScoredArtist { final Artist artist; final double score; + final int hashValue; // hash 기반 tie-breaker 값 ScoredArtist(Artist artist, double score) { this.artist = artist; this.score = score; + this.hashValue = 0; // 하위 호환성 + } + + ScoredArtist(Artist artist, double score, int hashValue) { + this.artist = artist; + this.score = score; + this.hashValue = hashValue; } }