Skip to content

Commit b6af475

Browse files
committed
refactor: SpotifyService 리팩토링
1 parent add9347 commit b6af475

13 files changed

Lines changed: 906 additions & 1230 deletions

File tree

src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistService.java

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
import com.back.web7_9_codecrete_be.domain.artists.repository.ArtistRepository;
88
import com.back.web7_9_codecrete_be.domain.artists.repository.ArtistLikeRepository;
99
import com.back.web7_9_codecrete_be.domain.artists.repository.ConcertArtistRepository;
10-
import com.back.web7_9_codecrete_be.domain.artists.service.spotifyService.SpotifyService;
1110
import com.back.web7_9_codecrete_be.domain.concerts.entity.Concert;
1211
import com.back.web7_9_codecrete_be.domain.concerts.repository.ConcertRepository;
1312
import com.back.web7_9_codecrete_be.domain.concerts.service.ConcertService;
@@ -46,7 +45,7 @@ public Artist findArtist(Long artistId) {
4645

4746
@Transactional
4847
public int setArtist() {
49-
return spotifyService.seedKoreanArtists300();
48+
return spotifyService.seedKoreanArtists();
5049
}
5150

5251
@Transactional

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

Lines changed: 69 additions & 970 deletions
Large diffs are not rendered by default.
Lines changed: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
1+
package com.back.web7_9_codecrete_be.domain.artists.service.spotify.application;
2+
3+
import com.back.web7_9_codecrete_be.domain.artists.dto.response.AlbumResponse;
4+
import com.back.web7_9_codecrete_be.domain.artists.dto.response.SpotifyArtistDetailCache;
5+
import com.back.web7_9_codecrete_be.domain.artists.dto.response.TopTrackResponse;
6+
import com.back.web7_9_codecrete_be.domain.artists.service.spotify.rate_limit.SpotifyRateLimitHandler;
7+
import com.back.web7_9_codecrete_be.global.spotify.SpotifyClient;
8+
import com.neovisionaries.i18n.CountryCode;
9+
import lombok.RequiredArgsConstructor;
10+
import lombok.extern.slf4j.Slf4j;
11+
import org.springframework.stereotype.Service;
12+
import se.michaelthelin.spotify.SpotifyApi;
13+
import se.michaelthelin.spotify.enums.AlbumType;
14+
import se.michaelthelin.spotify.model_objects.specification.AlbumSimplified;
15+
import se.michaelthelin.spotify.model_objects.specification.Image;
16+
import se.michaelthelin.spotify.model_objects.specification.Paging;
17+
import se.michaelthelin.spotify.model_objects.specification.Track;
18+
19+
import java.util.Arrays;
20+
import java.util.List;
21+
import java.util.Objects;
22+
import java.util.concurrent.Semaphore;
23+
import java.util.stream.Stream;
24+
25+
import static java.util.stream.Collectors.toList;
26+
27+
28+
@Slf4j
29+
@Service
30+
@RequiredArgsConstructor
31+
// Spotify API를 통한 아티스트 상세 정보 조회 서비스 : Spotify에서 아티스트 기본 정보, Top Tracks, 앨범 목록을 조회
32+
public class SpotifyDetailService {
33+
34+
private final SpotifyClient spotifyClient;
35+
private final SpotifyRateLimitHandler rateLimitHandler;
36+
37+
private final Semaphore spotifyRateLimiter = new Semaphore(1);
38+
39+
// Spotify API에서 아티스트 상세 정보 조회
40+
public SpotifyArtistDetailCache fetchDetailFromApi(String spotifyArtistId) {
41+
SpotifyApi api = spotifyClient.getAuthorizedApi();
42+
43+
// 아티스트 기본 정보
44+
se.michaelthelin.spotify.model_objects.specification.Artist artist = rateLimitHandler.callWithRateLimitRetry(() -> {
45+
try {
46+
spotifyRateLimiter.acquire();
47+
return api.getArtist(spotifyArtistId).build().execute();
48+
} catch (RuntimeException e) {
49+
throw e;
50+
} catch (Exception e) {
51+
throw new RuntimeException("Exception during getArtist API call", e);
52+
}
53+
}, "getArtistDetail getArtist spotifyId=" + spotifyArtistId);
54+
55+
// Top Tracks
56+
Track[] topTracks = safeGetTopTracks(api, spotifyArtistId);
57+
58+
// 앨범 목록
59+
Paging<AlbumSimplified> albums = safeGetAlbums(api, spotifyArtistId);
60+
61+
return new SpotifyArtistDetailCache(
62+
artist.getName(),
63+
pickImageUrl(artist.getImages()),
64+
artist.getPopularity(),
65+
toTopTrackResponses(topTracks),
66+
toAlbumResponses(albums != null ? albums.getItems() : null, spotifyArtistId),
67+
albums != null ? albums.getTotal() : 0
68+
);
69+
}
70+
71+
private Track[] safeGetTopTracks(SpotifyApi api, String artistId) {
72+
try {
73+
return rateLimitHandler.callWithRateLimitRetry(() -> {
74+
try {
75+
spotifyRateLimiter.acquire();
76+
return api.getArtistsTopTracks(artistId, CountryCode.KR)
77+
.build()
78+
.execute();
79+
} catch (RuntimeException e) {
80+
throw e;
81+
} catch (Exception e) {
82+
throw new RuntimeException("Exception during getArtistsTopTracks API call", e);
83+
}
84+
}, "safeGetTopTracks artistId=" + artistId);
85+
} catch (RuntimeException e) {
86+
return new Track[0];
87+
} catch (Exception e) {
88+
return new Track[0];
89+
}
90+
}
91+
92+
private Paging<AlbumSimplified> safeGetAlbums(SpotifyApi api, String artistId) {
93+
try {
94+
return rateLimitHandler.callWithRateLimitRetry(() -> {
95+
try {
96+
spotifyRateLimiter.acquire();
97+
return api.getArtistsAlbums(artistId)
98+
.market(CountryCode.KR)
99+
.limit(20)
100+
.build()
101+
.execute();
102+
} catch (RuntimeException e) {
103+
throw e;
104+
} catch (Exception e) {
105+
throw new RuntimeException("Exception during getArtistsAlbums API call", e);
106+
}
107+
}, "safeGetAlbums artistId=" + artistId);
108+
} catch (RuntimeException e) {
109+
return null;
110+
} catch (Exception e) {
111+
return null;
112+
}
113+
}
114+
115+
public String pickImageUrl(Image[] images) {
116+
if (images == null || images.length == 0) return null;
117+
return Arrays.stream(images)
118+
.filter(Objects::nonNull)
119+
.findFirst()
120+
.map(Image::getUrl)
121+
.orElse(null);
122+
}
123+
124+
private List<AlbumResponse> toAlbumResponses(AlbumSimplified[] items, String artistId) {
125+
if (items == null) return List.of();
126+
return Stream.of(items)
127+
.filter(Objects::nonNull)
128+
.filter(a -> a.getAlbumType() == AlbumType.ALBUM
129+
|| a.getAlbumType() == AlbumType.SINGLE
130+
|| a.getAlbumType() == AlbumType.COMPILATION)
131+
.filter(a -> {
132+
if (a.getArtists() == null) return false;
133+
return Arrays.stream(a.getArtists())
134+
.anyMatch(ar -> ar != null && artistId != null && artistId.equals(ar.getId()));
135+
})
136+
.map(a -> new AlbumResponse(
137+
a.getName(),
138+
a.getReleaseDate(),
139+
albumTypeToString(a.getAlbumType()),
140+
pickImageUrl(a.getImages()),
141+
a.getExternalUrls() != null ? a.getExternalUrls().get("spotify") : null
142+
))
143+
.collect(toList());
144+
}
145+
146+
private String albumTypeToString(AlbumType type) {
147+
if (type == null) return null;
148+
return type.getType();
149+
}
150+
151+
private List<TopTrackResponse> toTopTrackResponses(Track[] tracks) {
152+
if (tracks == null) return List.of();
153+
return Stream.of(tracks)
154+
.filter(Objects::nonNull)
155+
.map(t -> new TopTrackResponse(
156+
t.getName(),
157+
t.getExternalUrls() != null ? t.getExternalUrls().get("spotify") : null
158+
))
159+
.collect(toList());
160+
}
161+
}
162+
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
package com.back.web7_9_codecrete_be.domain.artists.service.spotify.cache;
2+
3+
import com.back.web7_9_codecrete_be.domain.artists.dto.response.SpotifyArtistDetailCache;
4+
import com.fasterxml.jackson.databind.ObjectMapper;
5+
import lombok.RequiredArgsConstructor;
6+
import lombok.extern.slf4j.Slf4j;
7+
import org.springframework.data.redis.core.RedisTemplate;
8+
import org.springframework.stereotype.Service;
9+
10+
import java.util.concurrent.TimeUnit;
11+
12+
13+
@Slf4j
14+
@Service
15+
@RequiredArgsConstructor
16+
// Spotify 아티스트 상세 정보 Redis 캐시 관리 서비스 : 성능 최적화를 위한 캐시 전략 담당
17+
public class SpotifyCacheService {
18+
19+
private final RedisTemplate<String, String> redisTemplate;
20+
private final RedisTemplate<String, Object> objectRedisTemplate;
21+
private final ObjectMapper objectMapper;
22+
23+
private static final String CACHE_KEY_PREFIX = "artist:detail:spotify:";
24+
private static final String LOCK_KEY_PREFIX = "artist:detail:spotify:lock:";
25+
private static final long CACHE_TTL_SECONDS = 3600; // 1시간 (기본값, 추후 3~6시간 조정 가능)
26+
private static final long LOCK_TTL_SECONDS = 30; // 락 TTL: 30초 (API 호출 완료 대기 시간)
27+
28+
// Redis 캐시에서 Spotify 상세 정보 조회
29+
public SpotifyArtistDetailCache getCached(String spotifyArtistId) {
30+
try {
31+
String cacheKey = getCacheKey(spotifyArtistId);
32+
Object cached = objectRedisTemplate.opsForValue().get(cacheKey);
33+
34+
if (cached == null) {
35+
return null;
36+
}
37+
38+
// Object를 SpotifyArtistDetailCache로 변환
39+
if (cached instanceof SpotifyArtistDetailCache) {
40+
return (SpotifyArtistDetailCache) cached;
41+
}
42+
43+
// LinkedHashMap 등으로 역직렬화된 경우 ObjectMapper로 변환
44+
return objectMapper.convertValue(cached, SpotifyArtistDetailCache.class);
45+
} catch (Exception e) {
46+
log.warn("Redis 캐시 조회 실패: spotifyArtistId={}", spotifyArtistId, e);
47+
return null;
48+
}
49+
}
50+
51+
// Redis 캐시에 Spotify 상세 정보 저장
52+
public void save(String spotifyArtistId, SpotifyArtistDetailCache data) {
53+
try {
54+
String cacheKey = getCacheKey(spotifyArtistId);
55+
objectRedisTemplate.opsForValue().set(
56+
cacheKey,
57+
data,
58+
CACHE_TTL_SECONDS,
59+
TimeUnit.SECONDS
60+
);
61+
log.debug("Spotify 상세 정보 캐시 저장: spotifyArtistId={}, ttl={}초", spotifyArtistId, CACHE_TTL_SECONDS);
62+
} catch (Exception e) {
63+
log.warn("Redis 캐시 저장 실패: spotifyArtistId={}", spotifyArtistId, e);
64+
// 캐시 저장 실패해도 API 호출은 성공했으므로 계속 진행
65+
}
66+
}
67+
68+
// 캐시 스탬피드 방지: Redis 락을 사용하여 동시 API 호출 제한
69+
public SpotifyArtistDetailCache getOrFetchWithLock(
70+
String spotifyArtistId,
71+
java.util.function.Supplier<SpotifyArtistDetailCache> apiCallSupplier
72+
) {
73+
String lockKey = getLockKey(spotifyArtistId);
74+
75+
// 락 획득 시도 (SETNX 방식)
76+
Boolean lockAcquired = redisTemplate.opsForValue().setIfAbsent(
77+
lockKey,
78+
"locked",
79+
LOCK_TTL_SECONDS,
80+
TimeUnit.SECONDS
81+
);
82+
83+
if (Boolean.TRUE.equals(lockAcquired)) {
84+
// 락 획득 성공: 이 스레드가 API 호출 담당
85+
try {
86+
log.debug("Spotify API 호출 락 획득: spotifyArtistId={}", spotifyArtistId);
87+
88+
// 다시 한 번 캐시 확인 (락 획득 대기 중 다른 스레드가 저장했을 수 있음)
89+
SpotifyArtistDetailCache doubleCheck = getCached(spotifyArtistId);
90+
if (doubleCheck != null) {
91+
log.debug("락 획득 후 캐시 재조회 HIT: spotifyArtistId={}", spotifyArtistId);
92+
return doubleCheck;
93+
}
94+
95+
// Spotify API 호출
96+
SpotifyArtistDetailCache spotifyData = apiCallSupplier.get();
97+
98+
// 캐시에 저장
99+
save(spotifyArtistId, spotifyData);
100+
101+
return spotifyData;
102+
} finally {
103+
// 락 해제
104+
redisTemplate.delete(lockKey);
105+
}
106+
} else {
107+
// 락 획득 실패: 다른 스레드가 API 호출 중
108+
log.debug("Spotify API 호출 락 획득 실패 (다른 스레드가 처리 중): spotifyArtistId={}", spotifyArtistId);
109+
110+
// 짧은 대기 후 캐시 재조회 (다른 스레드가 저장 완료했을 수 있음)
111+
try {
112+
Thread.sleep(100); // 100ms 대기
113+
} catch (InterruptedException e) {
114+
Thread.currentThread().interrupt();
115+
}
116+
117+
// 캐시 재조회
118+
SpotifyArtistDetailCache retryCache = getCached(spotifyArtistId);
119+
if (retryCache != null) {
120+
log.debug("락 대기 후 캐시 재조회 HIT: spotifyArtistId={}", spotifyArtistId);
121+
return retryCache;
122+
}
123+
124+
// 여전히 캐시가 없으면 최대 3초까지 대기하며 재시도
125+
int maxRetries = 30; // 100ms * 30 = 3초
126+
for (int i = 0; i < maxRetries; i++) {
127+
try {
128+
Thread.sleep(100);
129+
} catch (InterruptedException e) {
130+
Thread.currentThread().interrupt();
131+
break;
132+
}
133+
134+
retryCache = getCached(spotifyArtistId);
135+
if (retryCache != null) {
136+
log.debug("락 대기 중 캐시 재조회 HIT ({}ms 후): spotifyArtistId={}", (i + 1) * 100, spotifyArtistId);
137+
return retryCache;
138+
}
139+
}
140+
141+
// 최종적으로도 캐시가 없으면 직접 API 호출 (락이 만료되었을 수 있음)
142+
log.warn("락 대기 후에도 캐시 없음, 직접 API 호출: spotifyArtistId={}", spotifyArtistId);
143+
SpotifyArtistDetailCache spotifyData = apiCallSupplier.get();
144+
save(spotifyArtistId, spotifyData);
145+
return spotifyData;
146+
}
147+
}
148+
149+
private String getCacheKey(String spotifyArtistId) {
150+
return CACHE_KEY_PREFIX + spotifyArtistId;
151+
}
152+
153+
private String getLockKey(String spotifyArtistId) {
154+
return LOCK_KEY_PREFIX + spotifyArtistId;
155+
}
156+
}
157+

src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/ArtistData.java renamed to src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/dto/ArtistData.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
1-
package com.back.web7_9_codecrete_be.domain.artists.service.spotifyService;
1+
package com.back.web7_9_codecrete_be.domain.artists.service.spotify.dto;
22

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

6-
// 아티스트 데이터 임시 저장용 클래스
7-
6+
// 아티스트 데이터 임시 저장용 DTO : Spotify에서 받은 데이터를 내부 파이프라인에서 운반하기 위한 객체
87
public class ArtistData {
98
public final String spotifyId;
109
public final String name;

src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/CategoryConfig.java renamed to src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/genre/CategoryConfig.java

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,8 @@
1-
package com.back.web7_9_codecrete_be.domain.artists.service.spotifyService;
1+
package com.back.web7_9_codecrete_be.domain.artists.service.spotify.genre;
22

33
import java.util.List;
44

5-
// 카테고리별 키워드 및 상한 설정
6-
5+
// 카테고리별 키워드 및 상한 설정 : 장르/카테고리별 수집 규칙 정의
76
public class CategoryConfig {
87
public final List<String> keywords;
98
public final int targetCount;

0 commit comments

Comments
 (0)