Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,19 +23,35 @@ public interface ArtistRepository extends JpaRepository<Artist, Long> {
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<Artist> 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<Artist> 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<Artist> findTop5ByGenreIdAndIdNot(@org.springframework.data.repository.query.Param("genreId") Long genreId,
@org.springframework.data.repository.query.Param("excludeId") long excludeId,
Pageable pageable);
List<Artist> findByGenreIdAndIdNot(@org.springframework.data.repository.query.Param("genreId") Long genreId,
@org.springframework.data.repository.query.Param("excludeId") long excludeId,
Pageable pageable);

List<Artist> findAllByArtistNameContainingIgnoreCaseOrNameKoContainingIgnoreCase(String artistName1, String artistName2);

Expand Down Expand Up @@ -64,8 +80,17 @@ List<Artist> findTop5ByGenreIdAndIdNot(@org.springframework.data.repository.quer
@Query("SELECT a FROM Artist a WHERE a.spotifyArtistId IN :spotifyIds")
List<Artist> findBySpotifyArtistIdIn(@org.springframework.data.repository.query.Param("spotifyIds") List<String> 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<Artist> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1623,7 +1623,7 @@ private List<RelatedArtistResponse> getRelatedArtists(
}

// 2단계: 점수 매기기 (Score)
List<ScoredArtist> scoredArtists = scoreCandidates(candidates, artistGroup, artistType, genreId);
List<ScoredArtist> scoredArtists = scoreCandidates(candidates, artistGroup, artistType, genreId, artistId);

// 3단계: 4~5명 뽑기 + 도배 방지 (Diversity)
List<Artist> selectedArtists = selectWithDiversity(scoredArtists, artistGroup, genreId);
Expand All @@ -1647,9 +1647,9 @@ private List<RelatedArtistResponse> getRelatedArtists(

/**
* 1단계: 후보 뽑기 (Recall)
* - 같은 genre인 아티스트들
* - 같은 artistGroup인 아티스트들 (artistGroup이 있을 때만)
* - 같은 artistType인 아티스트들
* - 같은 genre인 아티스트들 (최대 200명, 결정론적 다양성을 위해 넓은 후보 풀 확보)
* - 같은 artistGroup인 아티스트들 (최대 5명, artistGroup이 있을 때만)
* - 같은 artistType인 아티스트들 (최대 50명, fallback: 장르/그룹 후보가 부족할 때만)
*/
private Set<Artist> collectRelatedCandidates(
long artistId,
Expand All @@ -1658,27 +1658,34 @@ private Set<Artist> collectRelatedCandidates(
Long genreId
) {
Set<Artist> 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<Artist> sameGenre = artistRepository.findTop5ByGenreIdAndIdNot(
if (genreId != null) {
List<Artist> 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<Artist> sameGroup = artistRepository.findTop5ByArtistGroupAndIdNot(artistGroup, artistId);
List<Artist> 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<Artist> 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);
}
Expand All @@ -1689,34 +1696,44 @@ private Set<Artist> 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<ScoredArtist> scoreCandidates(
Set<Artist> candidates,
String artistGroup,
ArtistType artistType,
Long genreId
Long genreId,
long baseArtistId // 기준 아티스트 ID (hash 계산용)
) {
List<ScoredArtist> 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;
}
}

Expand All @@ -1725,64 +1742,165 @@ private List<ScoredArtist> 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());
});

return scored;
}

/**
* 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<Artist> selectWithDiversity(
List<ScoredArtist> scoredArtists,
String artistGroup,
Long genreId
) {
List<Artist> selected = new ArrayList<>();
int sameGroupCount = 0;
final int MAX_SAME_GROUP = 2;
final int MAX_SAME_GENRE = 3;
final int TARGET_COUNT = 5;

// 슬롯별로 후보 분류
List<ScoredArtist> groupSlot = new ArrayList<>();
List<ScoredArtist> genreSlot = new ArrayList<>();
List<ScoredArtist> otherSlot = new ArrayList<>();

for (ScoredArtist scored : scoredArtists) {
if (selected.size() >= TARGET_COUNT) {
break;
}

Artist candidate = scored.artist;

// 같은 그룹 체크
boolean isSameGroup = artistGroup != null && !artistGroup.isBlank() &&
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<Artist> 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;
Expand All @@ -1794,10 +1912,18 @@ private List<Artist> 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;
}
}

Expand Down