Skip to content

Commit 7c6cd83

Browse files
committed
Merge branch 'feat/#30' into release
2 parents 53f63cc + a68f8dd commit 7c6cd83

5 files changed

Lines changed: 213 additions & 48 deletions

File tree

src/main/java/com/back/web7_9_codecrete_be/domain/artists/repository/ArtistRepository.java

Lines changed: 34 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,19 +23,35 @@ public interface ArtistRepository extends JpaRepository<Artist, Long> {
2323
boolean existsByArtistName(String artistName);
2424
boolean existsByNameKo(String nameKo);
2525

26-
@Query("SELECT a FROM Artist a WHERE a.artistGroup = :artistGroup AND a.id != :excludeId ORDER BY a.likeCount DESC, a.id ASC")
27-
List<Artist> findTop5ByArtistGroupAndIdNot(@org.springframework.data.repository.query.Param("artistGroup") String artistGroup,
28-
@org.springframework.data.repository.query.Param("excludeId") long excludeId);
26+
/**
27+
* 같은 artistGroup인 아티스트들 조회 (관련 아티스트 추천용)
28+
* artistGenres를 fetch join하여 N+1 문제 방지
29+
*/
30+
@Query("""
31+
SELECT DISTINCT a FROM Artist a
32+
LEFT JOIN FETCH a.artistGenres ag
33+
LEFT JOIN FETCH ag.genre
34+
WHERE a.artistGroup = :artistGroup AND a.id != :excludeId
35+
ORDER BY a.likeCount DESC, a.id ASC
36+
""")
37+
List<Artist> findByArtistGroupAndIdNot(@org.springframework.data.repository.query.Param("artistGroup") String artistGroup,
38+
@org.springframework.data.repository.query.Param("excludeId") long excludeId,
39+
Pageable pageable);
2940

41+
/**
42+
* 같은 genre인 아티스트들 조회 (관련 아티스트 추천용)
43+
* artistGenres와 genre를 fetch join하여 N+1 문제 방지
44+
*/
3045
@Query("""
3146
SELECT DISTINCT a FROM Artist a
32-
JOIN a.artistGenres ag
47+
JOIN FETCH a.artistGenres ag
48+
JOIN FETCH ag.genre
3349
WHERE ag.genre.id = :genreId AND a.id != :excludeId
3450
ORDER BY a.likeCount DESC, a.id ASC
3551
""")
36-
List<Artist> findTop5ByGenreIdAndIdNot(@org.springframework.data.repository.query.Param("genreId") Long genreId,
37-
@org.springframework.data.repository.query.Param("excludeId") long excludeId,
38-
Pageable pageable);
52+
List<Artist> findByGenreIdAndIdNot(@org.springframework.data.repository.query.Param("genreId") Long genreId,
53+
@org.springframework.data.repository.query.Param("excludeId") long excludeId,
54+
Pageable pageable);
3955

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

@@ -64,8 +80,17 @@ List<Artist> findTop5ByGenreIdAndIdNot(@org.springframework.data.repository.quer
6480
@Query("SELECT a FROM Artist a WHERE a.spotifyArtistId IN :spotifyIds")
6581
List<Artist> findBySpotifyArtistIdIn(@org.springframework.data.repository.query.Param("spotifyIds") List<String> spotifyIds);
6682

67-
// 같은 artistType인 아티스트들 조회 (관련 아티스트 추천용)
68-
@Query("SELECT a FROM Artist a WHERE a.artistType = :artistType AND a.id != :excludeId ORDER BY a.likeCount DESC, a.id ASC")
83+
/**
84+
* 같은 artistType인 아티스트들 조회 (관련 아티스트 추천용, fallback)
85+
* artistGenres를 fetch join하여 N+1 문제 방지
86+
*/
87+
@Query("""
88+
SELECT DISTINCT a FROM Artist a
89+
LEFT JOIN FETCH a.artistGenres ag
90+
LEFT JOIN FETCH ag.genre
91+
WHERE a.artistType = :artistType AND a.id != :excludeId
92+
ORDER BY a.likeCount DESC, a.id ASC
93+
""")
6994
List<Artist> findByArtistTypeAndIdNot(@org.springframework.data.repository.query.Param("artistType") com.back.web7_9_codecrete_be.domain.artists.entity.ArtistType artistType,
7095
@org.springframework.data.repository.query.Param("excludeId") long excludeId,
7196
Pageable pageable);

src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyService.java

Lines changed: 164 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -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

src/main/java/com/back/web7_9_codecrete_be/domain/community/post/controller/PostLikeController.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,4 +35,14 @@ public RsData<?> getLikeCount(@PathVariable Long postId) {
3535
long count = postLikeService.count(postId);
3636
return RsData.success("게시글 좋아요 수 조회 성공", count);
3737
}
38+
39+
@Operation(summary = "게시글 좋아요 여부 조회", description = "현재 로그인한 사용자가 해당 게시글을 좋아요 했는지 여부를 반환합니다.")
40+
@GetMapping("/me")
41+
public RsData<?> isLikedByMe(@PathVariable Long postId) {
42+
User user = rq.getUser();
43+
boolean liked = postLikeService.isLiked(postId, user.getId());
44+
return RsData.success("게시글 좋아요 여부 조회 성공", liked);
45+
}
46+
47+
3848
}

0 commit comments

Comments
 (0)