Skip to content

Commit add9347

Browse files
committed
feat: Redis에 아티스트 상세 정보 저장
1 parent 6909477 commit add9347

2 files changed

Lines changed: 229 additions & 21 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
package com.back.web7_9_codecrete_be.domain.artists.dto.response;
2+
3+
import java.util.List;
4+
5+
// Spotify 아티스트 상세 정보 캐시용 DTO - Redis에 저장하기 위한 데이터 구조
6+
7+
public record SpotifyArtistDetailCache(
8+
// 아티스트 기본 정보
9+
String artistName,
10+
String profileImageUrl,
11+
double popularity,
12+
13+
// Top Tracks (상위 10개)
14+
List<TopTrackResponse> topTracks,
15+
16+
// 앨범 목록 (최대 20개)
17+
List<AlbumResponse> albums,
18+
int totalAlbums
19+
) {
20+
}
21+

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

Lines changed: 208 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.back.web7_9_codecrete_be.domain.artists.dto.response.AlbumResponse;
44
import com.back.web7_9_codecrete_be.domain.artists.dto.response.ArtistDetailResponse;
55
import com.back.web7_9_codecrete_be.domain.artists.dto.response.RelatedArtistResponse;
6+
import com.back.web7_9_codecrete_be.domain.artists.dto.response.SpotifyArtistDetailCache;
67
import com.back.web7_9_codecrete_be.domain.artists.dto.response.TopTrackResponse;
78
import com.back.web7_9_codecrete_be.domain.artists.entity.Artist;
89
import com.back.web7_9_codecrete_be.domain.artists.entity.ArtistGenre;
@@ -22,6 +23,7 @@
2223
import org.springframework.data.redis.core.RedisTemplate;
2324
import org.springframework.stereotype.Service;
2425
import org.springframework.transaction.annotation.Transactional;
26+
import com.fasterxml.jackson.databind.ObjectMapper;
2527
import se.michaelthelin.spotify.SpotifyApi;
2628
import se.michaelthelin.spotify.enums.AlbumType;
2729
import se.michaelthelin.spotify.model_objects.specification.AlbumSimplified;
@@ -30,6 +32,7 @@
3032
import se.michaelthelin.spotify.model_objects.specification.Track;
3133

3234
import java.util.*;
35+
import java.util.concurrent.TimeUnit;
3336
import java.util.stream.Collectors;
3437
import 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

Comments
 (0)