33import com .back .web7_9_codecrete_be .domain .artists .dto .response .AlbumResponse ;
44import com .back .web7_9_codecrete_be .domain .artists .dto .response .ArtistDetailResponse ;
55import com .back .web7_9_codecrete_be .domain .artists .dto .response .RelatedArtistResponse ;
6+ import com .back .web7_9_codecrete_be .domain .artists .dto .response .SpotifyArtistDetailCache ;
67import com .back .web7_9_codecrete_be .domain .artists .dto .response .TopTrackResponse ;
78import com .back .web7_9_codecrete_be .domain .artists .entity .Artist ;
89import com .back .web7_9_codecrete_be .domain .artists .entity .ArtistGenre ;
2223import org .springframework .data .redis .core .RedisTemplate ;
2324import org .springframework .stereotype .Service ;
2425import org .springframework .transaction .annotation .Transactional ;
26+ import com .fasterxml .jackson .databind .ObjectMapper ;
2527import se .michaelthelin .spotify .SpotifyApi ;
2628import se .michaelthelin .spotify .enums .AlbumType ;
2729import se .michaelthelin .spotify .model_objects .specification .AlbumSimplified ;
3032import se .michaelthelin .spotify .model_objects .specification .Track ;
3133
3234import java .util .*;
35+ import java .util .concurrent .TimeUnit ;
3336import java .util .stream .Collectors ;
3437import java .util .stream .Stream ;
3538
@@ -45,9 +48,17 @@ public class SpotifyService {
4548 private final SpotifyClient spotifyClient ;
4649 private final MusicBrainzClient musicBrainzClient ;
4750 private final RedisTemplate <String , String > redisTemplate ;
51+ private final RedisTemplate <String , Object > objectRedisTemplate ;
52+ private final ObjectMapper objectMapper ;
4853 private final WikidataClient wikidataClient ;
4954 private final SpotifyRateLimitHandler rateLimitHandler ;
5055
56+ // Redis 캐시 설정
57+ private static final String CACHE_KEY_PREFIX = "artist:detail:spotify:" ;
58+ private static final String LOCK_KEY_PREFIX = "artist:detail:spotify:lock:" ;
59+ private static final long CACHE_TTL_SECONDS = 3600 ; // 1시간 (기본값, 추후 3~6시간 조정 가능)
60+ private static final long LOCK_TTL_SECONDS = 30 ; // 락 TTL: 30초 (API 호출 완료 대기 시간)
61+
5162 // Rate Limiter 설정
5263 private static final long SPOTIFY_RATE_LIMIT_INTERVAL_MS = 500 ; // 초당 2회
5364 private static final long MUSICBRAINZ_RATE_LIMIT_INTERVAL_MS = 1000 ; // 초당 1회
@@ -1507,46 +1518,46 @@ public ArtistDetailResponse getArtistDetail(
15071518 Long genreId
15081519 ) {
15091520 try {
1510- SpotifyApi api = spotifyClient . getAuthorizedApi ();
1511-
1512- se . michaelthelin . spotify . model_objects . specification . Artist artist = rateLimitHandler . callWithRateLimitRetry (() -> {
1513- try {
1514- spotifyRateLimiter . acquire ();
1515- return api . getArtist ( spotifyArtistId ). build (). execute ( );
1516- } catch ( RuntimeException e ) {
1517- throw e ;
1518- } catch ( Exception e ) {
1519- throw new RuntimeException ( "Exception during getArtist API call" , e );
1520- }
1521- }, "getArtistDetail getArtist spotifyId=" + spotifyArtistId );
1521+ // 1. Redis 캐시에서 조회 시도
1522+ SpotifyArtistDetailCache cached = getCachedSpotifyDetail ( spotifyArtistId );
1523+
1524+ SpotifyArtistDetailCache spotifyData ;
1525+ if ( cached != null ) {
1526+ log . debug ( "Spotify 상세 정보 캐시 HIT: spotifyArtistId={}" , spotifyArtistId );
1527+ spotifyData = cached ;
1528+ } else {
1529+ log . debug ( "Spotify 상세 정보 캐시 MISS: spotifyArtistId={}" , spotifyArtistId );
1530+ // 2. 캐시 스탬피드 방지: Redis 락으로 동시 API 호출 제한
1531+ spotifyData = fetchSpotifyDetailWithLock ( spotifyArtistId );
1532+ }
15221533
1534+ // 4. DB에서 추가 정보 조회 (캐시하지 않는 데이터)
15231535 Artist dbArtist = artistRepository .findById (artistId )
15241536 .orElse (null );
15251537 String nameKo = dbArtist != null ? dbArtist .getNameKo () : null ;
15261538
1527- Track [] topTracks = safeGetTopTracks (api , spotifyArtistId );
1528- Paging <AlbumSimplified > albums = safeGetAlbums (api , spotifyArtistId );
1529-
1539+ // 5. Related Artists 조회 (DB 기반 로직, 캐시하지 않음)
15301540 List <RelatedArtistResponse > relatedResponses = getRelatedArtists (
15311541 artistId ,
15321542 artistGroup ,
15331543 artistType ,
15341544 genreId
15351545 );
15361546
1547+ // 6. 최종 Response 구성
15371548 return new ArtistDetailResponse (
15381549 (long ) artistId ,
1539- artist . getName (),
1550+ spotifyData . artistName (),
15401551 nameKo ,
15411552 artistGroup ,
15421553 artistType ,
1543- pickImageUrl ( artist . getImages () ),
1554+ spotifyData . profileImageUrl ( ),
15441555 likeCount ,
1545- albums != null ? albums . getTotal () : 0 ,
1546- artist . getPopularity (),
1556+ spotifyData . totalAlbums () ,
1557+ spotifyData . popularity (),
15471558 "" ,
1548- toAlbumResponses ( albums != null ? albums . getItems () : null , spotifyArtistId ),
1549- toTopTrackResponses ( topTracks ),
1559+ spotifyData . albums ( ),
1560+ spotifyData . topTracks ( ),
15501561 relatedResponses
15511562 );
15521563 } catch (RuntimeException e ) {
@@ -1560,6 +1571,182 @@ public ArtistDetailResponse getArtistDetail(
15601571 throw new BusinessException (ArtistErrorCode .SPOTIFY_API_ERROR );
15611572 }
15621573 }
1574+
1575+ /**
1576+ * Redis 캐시에서 Spotify 상세 정보 조회
1577+ */
1578+ private SpotifyArtistDetailCache getCachedSpotifyDetail (String spotifyArtistId ) {
1579+ try {
1580+ String cacheKey = getCacheKey (spotifyArtistId );
1581+ Object cached = objectRedisTemplate .opsForValue ().get (cacheKey );
1582+
1583+ if (cached == null ) {
1584+ return null ;
1585+ }
1586+
1587+ // Object를 SpotifyArtistDetailCache로 변환
1588+ if (cached instanceof SpotifyArtistDetailCache ) {
1589+ return (SpotifyArtistDetailCache ) cached ;
1590+ }
1591+
1592+ // LinkedHashMap 등으로 역직렬화된 경우 ObjectMapper로 변환
1593+ return objectMapper .convertValue (cached , SpotifyArtistDetailCache .class );
1594+ } catch (Exception e ) {
1595+ log .warn ("Redis 캐시 조회 실패: spotifyArtistId={}" , spotifyArtistId , e );
1596+ return null ;
1597+ }
1598+ }
1599+
1600+ /**
1601+ * Spotify API에서 상세 정보 조회
1602+ */
1603+ private SpotifyArtistDetailCache fetchSpotifyDetailFromApi (String spotifyArtistId ) {
1604+ SpotifyApi api = spotifyClient .getAuthorizedApi ();
1605+
1606+ // 아티스트 기본 정보
1607+ se .michaelthelin .spotify .model_objects .specification .Artist artist = rateLimitHandler .callWithRateLimitRetry (() -> {
1608+ try {
1609+ spotifyRateLimiter .acquire ();
1610+ return api .getArtist (spotifyArtistId ).build ().execute ();
1611+ } catch (RuntimeException e ) {
1612+ throw e ;
1613+ } catch (Exception e ) {
1614+ throw new RuntimeException ("Exception during getArtist API call" , e );
1615+ }
1616+ }, "getArtistDetail getArtist spotifyId=" + spotifyArtistId );
1617+
1618+ // Top Tracks
1619+ Track [] topTracks = safeGetTopTracks (api , spotifyArtistId );
1620+
1621+ // 앨범 목록
1622+ Paging <AlbumSimplified > albums = safeGetAlbums (api , spotifyArtistId );
1623+
1624+ return new SpotifyArtistDetailCache (
1625+ artist .getName (),
1626+ pickImageUrl (artist .getImages ()),
1627+ artist .getPopularity (),
1628+ toTopTrackResponses (topTracks ),
1629+ toAlbumResponses (albums != null ? albums .getItems () : null , spotifyArtistId ),
1630+ albums != null ? albums .getTotal () : 0
1631+ );
1632+ }
1633+
1634+ /**
1635+ * Redis 캐시에 Spotify 상세 정보 저장
1636+ */
1637+ private void saveSpotifyDetailToCache (String spotifyArtistId , SpotifyArtistDetailCache data ) {
1638+ try {
1639+ String cacheKey = getCacheKey (spotifyArtistId );
1640+ objectRedisTemplate .opsForValue ().set (
1641+ cacheKey ,
1642+ data ,
1643+ CACHE_TTL_SECONDS ,
1644+ TimeUnit .SECONDS
1645+ );
1646+ log .debug ("Spotify 상세 정보 캐시 저장: spotifyArtistId={}, ttl={}초" , spotifyArtistId , CACHE_TTL_SECONDS );
1647+ } catch (Exception e ) {
1648+ log .warn ("Redis 캐시 저장 실패: spotifyArtistId={}" , spotifyArtistId , e );
1649+ // 캐시 저장 실패해도 API 호출은 성공했으므로 계속 진행
1650+ }
1651+ }
1652+
1653+ /**
1654+ * 캐시 스탬피드 방지: Redis 락을 사용하여 동시 API 호출 제한
1655+ *
1656+ * 1. 락 획득 시도
1657+ * 2. 락 획득 성공 → Spotify API 호출 → 캐시 저장 → 락 해제
1658+ * 3. 락 획득 실패 → 짧은 대기 후 캐시 재조회 (다른 스레드가 저장했을 수 있음)
1659+ */
1660+ private SpotifyArtistDetailCache fetchSpotifyDetailWithLock (String spotifyArtistId ) {
1661+ String lockKey = getLockKey (spotifyArtistId );
1662+
1663+ // 락 획득 시도 (SETNX 방식)
1664+ Boolean lockAcquired = redisTemplate .opsForValue ().setIfAbsent (
1665+ lockKey ,
1666+ "locked" ,
1667+ LOCK_TTL_SECONDS ,
1668+ TimeUnit .SECONDS
1669+ );
1670+
1671+ if (Boolean .TRUE .equals (lockAcquired )) {
1672+ // 락 획득 성공: 이 스레드가 API 호출 담당
1673+ try {
1674+ log .debug ("Spotify API 호출 락 획득: spotifyArtistId={}" , spotifyArtistId );
1675+
1676+ // 다시 한 번 캐시 확인 (락 획득 대기 중 다른 스레드가 저장했을 수 있음)
1677+ SpotifyArtistDetailCache doubleCheck = getCachedSpotifyDetail (spotifyArtistId );
1678+ if (doubleCheck != null ) {
1679+ log .debug ("락 획득 후 캐시 재조회 HIT: spotifyArtistId={}" , spotifyArtistId );
1680+ return doubleCheck ;
1681+ }
1682+
1683+ // Spotify API 호출
1684+ SpotifyArtistDetailCache spotifyData = fetchSpotifyDetailFromApi (spotifyArtistId );
1685+
1686+ // 캐시에 저장
1687+ saveSpotifyDetailToCache (spotifyArtistId , spotifyData );
1688+
1689+ return spotifyData ;
1690+ } finally {
1691+ // 락 해제
1692+ redisTemplate .delete (lockKey );
1693+ }
1694+ } else {
1695+ // 락 획득 실패: 다른 스레드가 API 호출 중
1696+ log .debug ("Spotify API 호출 락 획득 실패 (다른 스레드가 처리 중): spotifyArtistId={}" , spotifyArtistId );
1697+
1698+ // 짧은 대기 후 캐시 재조회 (다른 스레드가 저장 완료했을 수 있음)
1699+ try {
1700+ Thread .sleep (100 ); // 100ms 대기
1701+ } catch (InterruptedException e ) {
1702+ Thread .currentThread ().interrupt ();
1703+ }
1704+
1705+ // 캐시 재조회
1706+ SpotifyArtistDetailCache retryCache = getCachedSpotifyDetail (spotifyArtistId );
1707+ if (retryCache != null ) {
1708+ log .debug ("락 대기 후 캐시 재조회 HIT: spotifyArtistId={}" , spotifyArtistId );
1709+ return retryCache ;
1710+ }
1711+
1712+ // 여전히 캐시가 없으면 최대 3초까지 대기하며 재시도
1713+ int maxRetries = 30 ; // 100ms * 30 = 3초
1714+ for (int i = 0 ; i < maxRetries ; i ++) {
1715+ try {
1716+ Thread .sleep (100 );
1717+ } catch (InterruptedException e ) {
1718+ Thread .currentThread ().interrupt ();
1719+ break ;
1720+ }
1721+
1722+ retryCache = getCachedSpotifyDetail (spotifyArtistId );
1723+ if (retryCache != null ) {
1724+ log .debug ("락 대기 중 캐시 재조회 HIT ({}ms 후): spotifyArtistId={}" , (i + 1 ) * 100 , spotifyArtistId );
1725+ return retryCache ;
1726+ }
1727+ }
1728+
1729+ // 최종적으로도 캐시가 없으면 직접 API 호출 (락이 만료되었을 수 있음)
1730+ log .warn ("락 대기 후에도 캐시 없음, 직접 API 호출: spotifyArtistId={}" , spotifyArtistId );
1731+ SpotifyArtistDetailCache spotifyData = fetchSpotifyDetailFromApi (spotifyArtistId );
1732+ saveSpotifyDetailToCache (spotifyArtistId , spotifyData );
1733+ return spotifyData ;
1734+ }
1735+ }
1736+
1737+ /**
1738+ * 캐시 키 생성
1739+ */
1740+ private String getCacheKey (String spotifyArtistId ) {
1741+ return CACHE_KEY_PREFIX + spotifyArtistId ;
1742+ }
1743+
1744+ /**
1745+ * 락 키 생성
1746+ */
1747+ private String getLockKey (String spotifyArtistId ) {
1748+ return LOCK_KEY_PREFIX + spotifyArtistId ;
1749+ }
15631750
15641751 private Track [] safeGetTopTracks (SpotifyApi api , String artistId ) {
15651752 try {
0 commit comments