Skip to content
Closed
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
@@ -0,0 +1,21 @@
package com.back.web7_9_codecrete_be.domain.artists.dto.response;

import java.util.List;

// Spotify 아티스트 상세 정보 캐시용 DTO - Redis에 저장하기 위한 데이터 구조

public record SpotifyArtistDetailCache(
// 아티스트 기본 정보
String artistName,
String profileImageUrl,
double popularity,

// Top Tracks (상위 10개)
List<TopTrackResponse> topTracks,

// 앨범 목록 (최대 20개)
List<AlbumResponse> albums,
int totalAlbums
) {
}

Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
import com.back.web7_9_codecrete_be.domain.artists.repository.ArtistRepository;
import com.back.web7_9_codecrete_be.domain.artists.repository.ArtistLikeRepository;
import com.back.web7_9_codecrete_be.domain.artists.repository.ConcertArtistRepository;
import com.back.web7_9_codecrete_be.domain.artists.service.spotifyService.SpotifyService;
import com.back.web7_9_codecrete_be.domain.concerts.entity.Concert;
import com.back.web7_9_codecrete_be.domain.concerts.repository.ConcertRepository;
import com.back.web7_9_codecrete_be.domain.concerts.service.ConcertService;
Expand Down Expand Up @@ -46,7 +45,7 @@ public Artist findArtist(Long artistId) {

@Transactional
public int setArtist() {
return spotifyService.seedKoreanArtists300();
return spotifyService.seedKoreanArtists();
}

@Transactional
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
package com.back.web7_9_codecrete_be.domain.artists.service.spotify.application;

import com.back.web7_9_codecrete_be.domain.artists.dto.response.AlbumResponse;
import com.back.web7_9_codecrete_be.domain.artists.dto.response.SpotifyArtistDetailCache;
import com.back.web7_9_codecrete_be.domain.artists.dto.response.TopTrackResponse;
import com.back.web7_9_codecrete_be.domain.artists.service.spotify.rate_limit.SpotifyRateLimitHandler;
import com.back.web7_9_codecrete_be.global.spotify.SpotifyClient;
import com.neovisionaries.i18n.CountryCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import se.michaelthelin.spotify.SpotifyApi;
import se.michaelthelin.spotify.enums.AlbumType;
import se.michaelthelin.spotify.model_objects.specification.AlbumSimplified;
import se.michaelthelin.spotify.model_objects.specification.Image;
import se.michaelthelin.spotify.model_objects.specification.Paging;
import se.michaelthelin.spotify.model_objects.specification.Track;

import java.util.Arrays;
import java.util.List;
import java.util.Objects;
import java.util.concurrent.Semaphore;
import java.util.stream.Stream;

import static java.util.stream.Collectors.toList;


@Slf4j
@Service
@RequiredArgsConstructor
// Spotify API를 통한 아티스트 상세 정보 조회 서비스 : Spotify에서 아티스트 기본 정보, Top Tracks, 앨범 목록을 조회
public class SpotifyDetailService {

private final SpotifyClient spotifyClient;
private final SpotifyRateLimitHandler rateLimitHandler;

private final Semaphore spotifyRateLimiter = new Semaphore(1);

// Spotify API에서 아티스트 상세 정보 조회
public SpotifyArtistDetailCache fetchDetailFromApi(String spotifyArtistId) {
SpotifyApi api = spotifyClient.getAuthorizedApi();

// 아티스트 기본 정보
se.michaelthelin.spotify.model_objects.specification.Artist artist = rateLimitHandler.callWithRateLimitRetry(() -> {
try {
spotifyRateLimiter.acquire();
return api.getArtist(spotifyArtistId).build().execute();
} catch (RuntimeException e) {
throw e;
} catch (Exception e) {
throw new RuntimeException("Exception during getArtist API call", e);
}
}, "getArtistDetail getArtist spotifyId=" + spotifyArtistId);

// Top Tracks
Track[] topTracks = safeGetTopTracks(api, spotifyArtistId);

// 앨범 목록
Paging<AlbumSimplified> albums = safeGetAlbums(api, spotifyArtistId);

return new SpotifyArtistDetailCache(
artist.getName(),
pickImageUrl(artist.getImages()),
artist.getPopularity(),
toTopTrackResponses(topTracks),
toAlbumResponses(albums != null ? albums.getItems() : null, spotifyArtistId),
albums != null ? albums.getTotal() : 0
);
}

private Track[] safeGetTopTracks(SpotifyApi api, String artistId) {
try {
return rateLimitHandler.callWithRateLimitRetry(() -> {
try {
spotifyRateLimiter.acquire();
return api.getArtistsTopTracks(artistId, CountryCode.KR)
.build()
.execute();
} catch (RuntimeException e) {
throw e;
} catch (Exception e) {
throw new RuntimeException("Exception during getArtistsTopTracks API call", e);
}
}, "safeGetTopTracks artistId=" + artistId);
} catch (RuntimeException e) {
return new Track[0];
} catch (Exception e) {
return new Track[0];
}
}

private Paging<AlbumSimplified> safeGetAlbums(SpotifyApi api, String artistId) {
try {
return rateLimitHandler.callWithRateLimitRetry(() -> {
try {
spotifyRateLimiter.acquire();
return api.getArtistsAlbums(artistId)
.market(CountryCode.KR)
.limit(20)
.build()
.execute();
} catch (RuntimeException e) {
throw e;
} catch (Exception e) {
throw new RuntimeException("Exception during getArtistsAlbums API call", e);
}
}, "safeGetAlbums artistId=" + artistId);
} catch (RuntimeException e) {
return null;
} catch (Exception e) {
return null;
}
}

public String pickImageUrl(Image[] images) {
if (images == null || images.length == 0) return null;
return Arrays.stream(images)
.filter(Objects::nonNull)
.findFirst()
.map(Image::getUrl)
.orElse(null);
}

private List<AlbumResponse> toAlbumResponses(AlbumSimplified[] items, String artistId) {
if (items == null) return List.of();
return Stream.of(items)
.filter(Objects::nonNull)
.filter(a -> a.getAlbumType() == AlbumType.ALBUM
|| a.getAlbumType() == AlbumType.SINGLE
|| a.getAlbumType() == AlbumType.COMPILATION)
.filter(a -> {
if (a.getArtists() == null) return false;
return Arrays.stream(a.getArtists())
.anyMatch(ar -> ar != null && artistId != null && artistId.equals(ar.getId()));
})
.map(a -> new AlbumResponse(
a.getName(),
a.getReleaseDate(),
albumTypeToString(a.getAlbumType()),
pickImageUrl(a.getImages()),
a.getExternalUrls() != null ? a.getExternalUrls().get("spotify") : null
))
.collect(toList());
}

private String albumTypeToString(AlbumType type) {
if (type == null) return null;
return type.getType();
}

private List<TopTrackResponse> toTopTrackResponses(Track[] tracks) {
if (tracks == null) return List.of();
return Stream.of(tracks)
.filter(Objects::nonNull)
.map(t -> new TopTrackResponse(
t.getName(),
t.getExternalUrls() != null ? t.getExternalUrls().get("spotify") : null
))
.collect(toList());
}
}

Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
package com.back.web7_9_codecrete_be.domain.artists.service.spotify.cache;

import com.back.web7_9_codecrete_be.domain.artists.dto.response.SpotifyArtistDetailCache;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;

import java.util.concurrent.TimeUnit;


@Slf4j
@Service
@RequiredArgsConstructor
// Spotify 아티스트 상세 정보 Redis 캐시 관리 서비스 : 성능 최적화를 위한 캐시 전략 담당
public class SpotifyCacheService {

private final RedisTemplate<String, String> redisTemplate;
private final RedisTemplate<String, Object> objectRedisTemplate;
private final ObjectMapper objectMapper;

private static final String CACHE_KEY_PREFIX = "artist:detail:spotify:";
private static final String LOCK_KEY_PREFIX = "artist:detail:spotify:lock:";
private static final long CACHE_TTL_SECONDS = 3600; // 1시간 (기본값, 추후 3~6시간 조정 가능)
private static final long LOCK_TTL_SECONDS = 30; // 락 TTL: 30초 (API 호출 완료 대기 시간)

// Redis 캐시에서 Spotify 상세 정보 조회
public SpotifyArtistDetailCache getCached(String spotifyArtistId) {
try {
String cacheKey = getCacheKey(spotifyArtistId);
Object cached = objectRedisTemplate.opsForValue().get(cacheKey);

if (cached == null) {
return null;
}

// Object를 SpotifyArtistDetailCache로 변환
if (cached instanceof SpotifyArtistDetailCache) {
return (SpotifyArtistDetailCache) cached;
}

// LinkedHashMap 등으로 역직렬화된 경우 ObjectMapper로 변환
return objectMapper.convertValue(cached, SpotifyArtistDetailCache.class);
} catch (Exception e) {
log.warn("Redis 캐시 조회 실패: spotifyArtistId={}", spotifyArtistId, e);
return null;
}
}

// Redis 캐시에 Spotify 상세 정보 저장
public void save(String spotifyArtistId, SpotifyArtistDetailCache data) {
try {
String cacheKey = getCacheKey(spotifyArtistId);
objectRedisTemplate.opsForValue().set(
cacheKey,
data,
CACHE_TTL_SECONDS,
TimeUnit.SECONDS
);
log.debug("Spotify 상세 정보 캐시 저장: spotifyArtistId={}, ttl={}초", spotifyArtistId, CACHE_TTL_SECONDS);
} catch (Exception e) {
log.warn("Redis 캐시 저장 실패: spotifyArtistId={}", spotifyArtistId, e);
// 캐시 저장 실패해도 API 호출은 성공했으므로 계속 진행
}
}

// 캐시 스탬피드 방지: Redis 락을 사용하여 동시 API 호출 제한
public SpotifyArtistDetailCache getOrFetchWithLock(
String spotifyArtistId,
java.util.function.Supplier<SpotifyArtistDetailCache> apiCallSupplier
) {
String lockKey = getLockKey(spotifyArtistId);

// 락 획득 시도 (SETNX 방식)
Boolean lockAcquired = redisTemplate.opsForValue().setIfAbsent(
lockKey,
"locked",
LOCK_TTL_SECONDS,
TimeUnit.SECONDS
);

if (Boolean.TRUE.equals(lockAcquired)) {
// 락 획득 성공: 이 스레드가 API 호출 담당
try {
log.debug("Spotify API 호출 락 획득: spotifyArtistId={}", spotifyArtistId);

// 다시 한 번 캐시 확인 (락 획득 대기 중 다른 스레드가 저장했을 수 있음)
SpotifyArtistDetailCache doubleCheck = getCached(spotifyArtistId);
if (doubleCheck != null) {
log.debug("락 획득 후 캐시 재조회 HIT: spotifyArtistId={}", spotifyArtistId);
return doubleCheck;
}

// Spotify API 호출
SpotifyArtistDetailCache spotifyData = apiCallSupplier.get();

// 캐시에 저장
save(spotifyArtistId, spotifyData);

return spotifyData;
} finally {
// 락 해제
redisTemplate.delete(lockKey);
}
} else {
// 락 획득 실패: 다른 스레드가 API 호출 중
log.debug("Spotify API 호출 락 획득 실패 (다른 스레드가 처리 중): spotifyArtistId={}", spotifyArtistId);

// 짧은 대기 후 캐시 재조회 (다른 스레드가 저장 완료했을 수 있음)
try {
Thread.sleep(100); // 100ms 대기
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}

// 캐시 재조회
SpotifyArtistDetailCache retryCache = getCached(spotifyArtistId);
if (retryCache != null) {
log.debug("락 대기 후 캐시 재조회 HIT: spotifyArtistId={}", spotifyArtistId);
return retryCache;
}

// 여전히 캐시가 없으면 최대 3초까지 대기하며 재시도
int maxRetries = 30; // 100ms * 30 = 3초
for (int i = 0; i < maxRetries; i++) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}

retryCache = getCached(spotifyArtistId);
if (retryCache != null) {
log.debug("락 대기 중 캐시 재조회 HIT ({}ms 후): spotifyArtistId={}", (i + 1) * 100, spotifyArtistId);
return retryCache;
}
}

// 최종적으로도 캐시가 없으면 직접 API 호출 (락이 만료되었을 수 있음)
log.warn("락 대기 후에도 캐시 없음, 직접 API 호출: spotifyArtistId={}", spotifyArtistId);
SpotifyArtistDetailCache spotifyData = apiCallSupplier.get();
save(spotifyArtistId, spotifyData);
return spotifyData;
}
}

private String getCacheKey(String spotifyArtistId) {
return CACHE_KEY_PREFIX + spotifyArtistId;
}

private String getLockKey(String spotifyArtistId) {
return LOCK_KEY_PREFIX + spotifyArtistId;
}
}

Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
package com.back.web7_9_codecrete_be.domain.artists.service.spotifyService;
package com.back.web7_9_codecrete_be.domain.artists.service.spotify.dto;

import com.back.web7_9_codecrete_be.domain.artists.entity.ArtistType;
import java.util.List;

// 아티스트 데이터 임시 저장용 클래스

// 아티스트 데이터 임시 저장용 DTO : Spotify에서 받은 데이터를 내부 파이프라인에서 운반하기 위한 객체
public class ArtistData {
public final String spotifyId;
public final String name;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
package com.back.web7_9_codecrete_be.domain.artists.service.spotifyService;
package com.back.web7_9_codecrete_be.domain.artists.service.spotify.genre;

import java.util.List;

// 카테고리별 키워드 및 상한 설정

// 카테고리별 키워드 및 상한 설정 : 장르/카테고리별 수집 규칙 정의
public class CategoryConfig {
public final List<String> keywords;
public final int targetCount;
Expand Down
Loading