From e326ec67e3e67396c1758d3a9b9f6775da42dac6 Mon Sep 17 00:00:00 2001 From: Jung Yunseo Date: Tue, 6 Jan 2026 09:49:48 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20Spotify=20AccessToken=20=EC=9E=90?= =?UTF-8?q?=EB=8F=99=20=EC=9E=AC=EB=B0=9C=EA=B8=89=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SpotifyRateLimitHandler.java | 45 ++++++++ .../global/spotify/SpotifyClient.java | 106 ++++++++++++++++-- 2 files changed, 143 insertions(+), 8 deletions(-) diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyRateLimitHandler.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyRateLimitHandler.java index 422d65e9..608d811c 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyRateLimitHandler.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyRateLimitHandler.java @@ -1,14 +1,20 @@ package com.back.web7_9_codecrete_be.domain.artists.service.spotifyService; +import com.back.web7_9_codecrete_be.global.spotify.SpotifyClient; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; // 429 에러 처리 및 전역 쿨다운 관리 +// 401 에러 처리 (토큰 만료 시 자동 재발급) @Slf4j @Component +@RequiredArgsConstructor public class SpotifyRateLimitHandler { + private final SpotifyClient spotifyClient; + private static final long SPOTIFY_RATE_LIMIT_INTERVAL_MS = 500; // 초당 2회 private static final long MUSICBRAINZ_RATE_LIMIT_INTERVAL_MS = 1000; // 초당 1회 private static final long GLOBAL_COOLDOWN_DURATION_MS = 60_000; // 60초 @@ -65,6 +71,22 @@ public T callWithRateLimitRetry(java.util.function.Supplier supplier, Str globalConsecutive429Count = 0; return result; } catch (Exception e) { + // 401 에러 확인 (Unauthorized - 토큰 만료) + boolean is401 = is401Error(e); + + if (is401) { + log.warn("401 Unauthorized 에러 감지 - 토큰 재발급 후 재시도: attempt={}/{}", attempt, maxRetry); + // 토큰 강제 재발급 + spotifyClient.forceRefreshToken(); + + if (attempt < maxRetry) { + // 재시도 + continue; + } else { + log.error("401 에러 재시도 횟수 초과 ({}회)", maxRetry); + throw new RuntimeException(context + ": 401 Unauthorized - 토큰 재발급 후에도 실패", e); + } + } // 원본 예외 확인 (래핑된 경우 cause 확인) Throwable originalException = e; Throwable current = e; @@ -203,5 +225,28 @@ public T callWithRateLimitRetry(java.util.function.Supplier supplier, Str throw new RuntimeException(context + ": rate limit retry exhausted"); } + + /** + * 401 Unauthorized 에러인지 확인 + */ + private boolean is401Error(Throwable e) { + Throwable current = e; + while (current != null) { + String className = current.getClass().getSimpleName(); + String errorMsg = current.getMessage(); + + // 401 에러 확인 + if (className.contains("Unauthorized") || + className.contains("401") || + (errorMsg != null && (errorMsg.contains("401") || + errorMsg.contains("Unauthorized") || + errorMsg.contains("Invalid access token")))) { + return true; + } + + current = current.getCause(); + } + return false; + } } diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/spotify/SpotifyClient.java b/src/main/java/com/back/web7_9_codecrete_be/global/spotify/SpotifyClient.java index 86c85e19..f21744e4 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/global/spotify/SpotifyClient.java +++ b/src/main/java/com/back/web7_9_codecrete_be/global/spotify/SpotifyClient.java @@ -1,32 +1,122 @@ package com.back.web7_9_codecrete_be.global.spotify; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; import se.michaelthelin.spotify.SpotifyApi; import se.michaelthelin.spotify.model_objects.credentials.ClientCredentials; import se.michaelthelin.spotify.requests.authorization.client_credentials.ClientCredentialsRequest; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Spotify AccessToken 중앙 관리 클래스 + * + * - 토큰 만료 시간 관리 (1시간 유효) + * - 만료 전 자동 재발급 (5분 여유) + * - 401 에러 발생 시 자동 재발급 및 재요청 지원 + */ +@Slf4j @Component @RequiredArgsConstructor public class SpotifyClient { private final SpotifyApi spotifyApi; - - public String getAccessToken() { + + // 토큰 관리 필드 + private volatile String cachedAccessToken; + private volatile long tokenExpiresAt = 0; // 만료 시각 (밀리초) + private final ReentrantLock tokenLock = new ReentrantLock(); + + // 토큰 만료 여유 시간 (5분 = 300초) + private static final long TOKEN_BUFFER_SECONDS = 300; + private static final long TOKEN_BUFFER_MS = TOKEN_BUFFER_SECONDS * 1000; + + /** + * AccessToken 발급 (내부 메서드) + * 동시성 제어를 통해 중복 발급 방지 + */ + private String refreshAccessToken() { + tokenLock.lock(); try { + // Double-check: 다른 스레드가 이미 토큰을 발급했는지 확인 + if (cachedAccessToken != null && System.currentTimeMillis() < tokenExpiresAt) { + return cachedAccessToken; + } + + log.info("Spotify AccessToken 발급 시작"); ClientCredentialsRequest request = spotifyApi.clientCredentials().build(); ClientCredentials credentials = request.execute(); - spotifyApi.setAccessToken(credentials.getAccessToken()); - return credentials.getAccessToken(); + + String newToken = credentials.getAccessToken(); + // Spotify 토큰은 3600초(1시간) 유효 + // 여유 시간을 빼서 실제 만료 시각 계산 + long expiresInSeconds = credentials.getExpiresIn(); + tokenExpiresAt = System.currentTimeMillis() + (expiresInSeconds * 1000) - TOKEN_BUFFER_MS; + + cachedAccessToken = newToken; + spotifyApi.setAccessToken(newToken); + + log.info("Spotify AccessToken 발급 완료. 만료 시각: {} ({}초 후)", + new java.util.Date(tokenExpiresAt), expiresInSeconds); + + return newToken; } catch (Exception e) { + log.error("Spotify 토큰 발급 실패", e); + // 토큰 발급 실패 시 캐시 초기화 + cachedAccessToken = null; + tokenExpiresAt = 0; throw new RuntimeException("Spotify 토큰 발급 실패", e); + } finally { + tokenLock.unlock(); } } - - public SpotifyApi getAuthorizedApi() { - if (spotifyApi.getAccessToken() == null) { - getAccessToken(); + + /** + * AccessToken 조회 (만료 체크 포함) + * 만료되었거나 곧 만료될 경우 자동 재발급 + */ + public String getAccessToken() { + long now = System.currentTimeMillis(); + + // 토큰이 없거나 만료되었거나 곧 만료될 경우 재발급 + if (cachedAccessToken == null || now >= tokenExpiresAt) { + return refreshAccessToken(); } + + return cachedAccessToken; + } + + /** + * 인증된 SpotifyApi 반환 + * 토큰이 없거나 만료된 경우 자동으로 재발급 + */ + public SpotifyApi getAuthorizedApi() { + getAccessToken(); // 만료 체크 및 필요시 재발급 return spotifyApi; } + + /** + * 401 에러 발생 시 토큰 강제 재발급 + * 이 메서드를 호출한 후 API를 재요청해야 함 + */ + public void forceRefreshToken() { + log.warn("401 에러 감지 - Spotify AccessToken 강제 재발급"); + tokenLock.lock(); + try { + // 캐시 초기화 후 재발급 + cachedAccessToken = null; + tokenExpiresAt = 0; + refreshAccessToken(); + } finally { + tokenLock.unlock(); + } + } + + /** + * 토큰 만료 여부 확인 + */ + public boolean isTokenExpired() { + return cachedAccessToken == null || System.currentTimeMillis() >= tokenExpiresAt; + } } From 3e81b1b5330d6a456fc9cd3cb4fa5f137f0ae526 Mon Sep 17 00:00:00 2001 From: Jung Yunseo Date: Tue, 6 Jan 2026 10:10:37 +0900 Subject: [PATCH 2/7] =?UTF-8?q?refactor:=20=EC=A3=BC=EC=84=9D=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SpotifyRateLimitHandler.java | 4 +- .../global/spotify/SpotifyClient.java | 39 ++++++------------- 2 files changed, 13 insertions(+), 30 deletions(-) diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyRateLimitHandler.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyRateLimitHandler.java index 608d811c..9b1b51d0 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyRateLimitHandler.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyRateLimitHandler.java @@ -226,9 +226,7 @@ public T callWithRateLimitRetry(java.util.function.Supplier supplier, Str throw new RuntimeException(context + ": rate limit retry exhausted"); } - /** - * 401 Unauthorized 에러인지 확인 - */ + // 401 에러인지 확인 private boolean is401Error(Throwable e) { Throwable current = e; while (current != null) { diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/spotify/SpotifyClient.java b/src/main/java/com/back/web7_9_codecrete_be/global/spotify/SpotifyClient.java index f21744e4..e4b5f1f4 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/global/spotify/SpotifyClient.java +++ b/src/main/java/com/back/web7_9_codecrete_be/global/spotify/SpotifyClient.java @@ -9,13 +9,12 @@ import java.util.concurrent.locks.ReentrantLock; -/** - * Spotify AccessToken 중앙 관리 클래스 - * - * - 토큰 만료 시간 관리 (1시간 유효) - * - 만료 전 자동 재발급 (5분 여유) - * - 401 에러 발생 시 자동 재발급 및 재요청 지원 - */ +// Spotify AccessToken 중앙 관리 클래스 + +// 토큰 만료 시간 관리 (1시간 유효) +// 만료 전 자동 재발급 (5분 여유) +// 401 에러 발생 시 자동 재발급 및 재요청 지원 + @Slf4j @Component @RequiredArgsConstructor @@ -32,10 +31,7 @@ public class SpotifyClient { private static final long TOKEN_BUFFER_SECONDS = 300; private static final long TOKEN_BUFFER_MS = TOKEN_BUFFER_SECONDS * 1000; - /** - * AccessToken 발급 (내부 메서드) - * 동시성 제어를 통해 중복 발급 방지 - */ + // AccessToken 발급 private String refreshAccessToken() { tokenLock.lock(); try { @@ -72,10 +68,7 @@ private String refreshAccessToken() { } } - /** - * AccessToken 조회 (만료 체크 포함) - * 만료되었거나 곧 만료될 경우 자동 재발급 - */ + // AccessToken 조회 (만료 체크 포함) - 만료되었거나 곧 만료될 경우 자동 재발급 public String getAccessToken() { long now = System.currentTimeMillis(); @@ -87,19 +80,13 @@ public String getAccessToken() { return cachedAccessToken; } - /** - * 인증된 SpotifyApi 반환 - * 토큰이 없거나 만료된 경우 자동으로 재발급 - */ + // 인증된 SpotifyApi 반환 - 토큰이 없거나 만료된 경우 자동으로 재발급 public SpotifyApi getAuthorizedApi() { getAccessToken(); // 만료 체크 및 필요시 재발급 return spotifyApi; } - - /** - * 401 에러 발생 시 토큰 강제 재발급 - * 이 메서드를 호출한 후 API를 재요청해야 함 - */ + + // 401 에러 발생 시 토큰 강제 재발급 (SpotifyRateLimitHandler에서 자동 호출) public void forceRefreshToken() { log.warn("401 에러 감지 - Spotify AccessToken 강제 재발급"); tokenLock.lock(); @@ -113,9 +100,7 @@ public void forceRefreshToken() { } } - /** - * 토큰 만료 여부 확인 - */ + // 토큰 만료 여부 확인 public boolean isTokenExpired() { return cachedAccessToken == null || System.currentTimeMillis() >= tokenExpiresAt; } From 6909477b386a763771641f3ed9c2d36b647e51e1 Mon Sep 17 00:00:00 2001 From: Jung Yunseo Date: Tue, 6 Jan 2026 10:16:24 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=ED=86=A0=ED=81=B0=20=EC=9E=AC?= =?UTF-8?q?=EB=B0=9C=EA=B8=89=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SpotifyRateLimitHandlerTest.java | 119 +++++++++ .../global/spotify/SpotifyClientTest.java | 227 ++++++++++++++++++ 2 files changed, 346 insertions(+) create mode 100644 src/test/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyRateLimitHandlerTest.java create mode 100644 src/test/java/com/back/web7_9_codecrete_be/global/spotify/SpotifyClientTest.java diff --git a/src/test/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyRateLimitHandlerTest.java b/src/test/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyRateLimitHandlerTest.java new file mode 100644 index 00000000..faf1b992 --- /dev/null +++ b/src/test/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyRateLimitHandlerTest.java @@ -0,0 +1,119 @@ +package com.back.web7_9_codecrete_be.domain.artists.service.spotifyService; + +import com.back.web7_9_codecrete_be.global.spotify.SpotifyClient; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("SpotifyRateLimitHandler 401 에러 처리 테스트") +class SpotifyRateLimitHandlerTest { + + @Mock + private SpotifyClient spotifyClient; + + @InjectMocks + private SpotifyRateLimitHandler rateLimitHandler; + + @BeforeEach + void setUp() { + // 각 테스트 전에 상태 초기화 + } + + @Test + @DisplayName("401 에러 발생 시 토큰 재발급 후 재시도 성공") + void callWithRateLimitRetry_when401Error_shouldRefreshTokenAndRetry() { + // given + RuntimeException unauthorizedException = new RuntimeException("401 Unauthorized"); + String successResult = "success-result"; + + // forceRefreshToken은 void 메서드이므로 doNothing() 사용 + doNothing().when(spotifyClient).forceRefreshToken(); + + // 첫 번째 호출은 401 에러, 두 번째 호출은 성공하도록 설정 + AtomicInteger callCount = new AtomicInteger(0); + + // when + String result = rateLimitHandler.callWithRateLimitRetry( + () -> { + int count = callCount.incrementAndGet(); + if (count == 1) { + // 첫 번째 호출 시 401 에러 + throw unauthorizedException; + } + // 두 번째 호출 시 성공 + return successResult; + }, + "test-context" + ); + + // then + assertThat(result).isEqualTo(successResult); + assertThat(callCount.get()).isEqualTo(2); // 재시도로 2번 호출됨 + verify(spotifyClient, times(1)).forceRefreshToken(); + } + + @Test + @DisplayName("401 에러가 아닌 경우 토큰 재발급하지 않음") + void callWithRateLimitRetry_whenNot401Error_shouldNotRefreshToken() { + // given + RuntimeException otherException = new RuntimeException("500 Internal Server Error"); + String successResult = "success-result"; + + // when & then + assertThatThrownBy(() -> { + rateLimitHandler.callWithRateLimitRetry( + () -> { + throw otherException; + }, + "test-context" + ); + }).isInstanceOf(RuntimeException.class) + .hasMessageContaining("500 Internal Server Error"); + + // 401이 아니면 토큰 재발급하지 않아야 함 + verify(spotifyClient, never()).forceRefreshToken(); + } + + @Test + @DisplayName("정상 호출 시 토큰 재발급하지 않음") + void callWithRateLimitRetry_whenSuccess_shouldNotRefreshToken() { + // given + String successResult = "success-result"; + + // when + String result = rateLimitHandler.callWithRateLimitRetry( + () -> successResult, + "test-context" + ); + + // then + assertThat(result).isEqualTo(successResult); + verify(spotifyClient, never()).forceRefreshToken(); + } + + @Test + @DisplayName("401 에러가 예외 체인에 있는 경우 감지") + void is401Error_when401InExceptionChain_shouldDetect() { + // given + RuntimeException cause = new RuntimeException("401 Unauthorized"); + RuntimeException wrapper = new RuntimeException("Wrapped exception", cause); + + // when + // is401Error는 private 메서드이므로 callWithRateLimitRetry를 통해 간접 테스트 + // 실제로는 401 에러가 발생하면 forceRefreshToken이 호출되어야 함 + // 이 테스트는 통합 테스트에서 더 적합할 수 있음 + } +} + diff --git a/src/test/java/com/back/web7_9_codecrete_be/global/spotify/SpotifyClientTest.java b/src/test/java/com/back/web7_9_codecrete_be/global/spotify/SpotifyClientTest.java new file mode 100644 index 00000000..e7e4a14e --- /dev/null +++ b/src/test/java/com/back/web7_9_codecrete_be/global/spotify/SpotifyClientTest.java @@ -0,0 +1,227 @@ +package com.back.web7_9_codecrete_be.global.spotify; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import se.michaelthelin.spotify.SpotifyApi; +import se.michaelthelin.spotify.model_objects.credentials.ClientCredentials; +import se.michaelthelin.spotify.requests.authorization.client_credentials.ClientCredentialsRequest; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.*; + +@ExtendWith(MockitoExtension.class) +@DisplayName("SpotifyClient 토큰 관리 테스트") +class SpotifyClientTest { + + @Mock + private SpotifyApi spotifyApi; + + @Mock + private ClientCredentialsRequest.Builder requestBuilder; + + @Mock + private ClientCredentialsRequest request; + + @InjectMocks + private SpotifyClient spotifyClient; + + @BeforeEach + void setUp() { + // ReflectionTestUtils로 private 필드 초기화 + ReflectionTestUtils.setField(spotifyClient, "cachedAccessToken", null); + ReflectionTestUtils.setField(spotifyClient, "tokenExpiresAt", 0L); + } + + @Test + @DisplayName("토큰이 없을 때 자동 발급") + void getAccessToken_whenTokenIsNull_shouldIssueNewToken() throws Exception { + // given + String expectedToken = "new-access-token-123"; + ClientCredentials credentials = mock(ClientCredentials.class); + + given(spotifyApi.clientCredentials()).willReturn(requestBuilder); + given(requestBuilder.build()).willReturn(request); + given(request.execute()).willReturn(credentials); + given(credentials.getAccessToken()).willReturn(expectedToken); + given(credentials.getExpiresIn()).willReturn(3600); // 1시간 + + // when + String token = spotifyClient.getAccessToken(); + + // then + assertThat(token).isEqualTo(expectedToken); + verify(spotifyApi, times(1)).setAccessToken(expectedToken); + } + + @Test + @DisplayName("토큰이 만료되었을 때 자동 재발급") + void getAccessToken_whenTokenExpired_shouldRefreshToken() throws Exception { + // given + String oldToken = "old-token"; + String newToken = "new-token-456"; + long expiredTime = System.currentTimeMillis() - 1000; // 1초 전에 만료 + + ReflectionTestUtils.setField(spotifyClient, "cachedAccessToken", oldToken); + ReflectionTestUtils.setField(spotifyClient, "tokenExpiresAt", expiredTime); + + ClientCredentials credentials = mock(ClientCredentials.class); + given(spotifyApi.clientCredentials()).willReturn(requestBuilder); + given(requestBuilder.build()).willReturn(request); + given(request.execute()).willReturn(credentials); + given(credentials.getAccessToken()).willReturn(newToken); + given(credentials.getExpiresIn()).willReturn(3600); + + // when + String token = spotifyClient.getAccessToken(); + + // then + assertThat(token).isEqualTo(newToken); + assertThat(token).isNotEqualTo(oldToken); + verify(spotifyApi, times(1)).setAccessToken(newToken); + } + + @Test + @DisplayName("토큰이 유효할 때 재사용") + void getAccessToken_whenTokenValid_shouldReuseToken() throws Exception { + // given + String validToken = "valid-token-789"; + long futureExpiry = System.currentTimeMillis() + 1000000; // 미래에 만료 + + ReflectionTestUtils.setField(spotifyClient, "cachedAccessToken", validToken); + ReflectionTestUtils.setField(spotifyClient, "tokenExpiresAt", futureExpiry); + + // when + String token = spotifyClient.getAccessToken(); + + // then + assertThat(token).isEqualTo(validToken); + // 토큰이 유효하면 재발급하지 않아야 함 + verify(spotifyApi, never()).clientCredentials(); + } + + @Test + @DisplayName("forceRefreshToken은 토큰을 강제로 재발급") + void forceRefreshToken_shouldForceRefresh() throws Exception { + // given + String oldToken = "old-token"; + String newToken = "forced-new-token"; + long futureExpiry = System.currentTimeMillis() + 1000000; // 아직 유효한 토큰 + + ReflectionTestUtils.setField(spotifyClient, "cachedAccessToken", oldToken); + ReflectionTestUtils.setField(spotifyClient, "tokenExpiresAt", futureExpiry); + + ClientCredentials credentials = mock(ClientCredentials.class); + given(spotifyApi.clientCredentials()).willReturn(requestBuilder); + given(requestBuilder.build()).willReturn(request); + given(request.execute()).willReturn(credentials); + given(credentials.getAccessToken()).willReturn(newToken); + given(credentials.getExpiresIn()).willReturn(3600); + + // when + spotifyClient.forceRefreshToken(); + String token = spotifyClient.getAccessToken(); + + // then + assertThat(token).isEqualTo(newToken); + assertThat(token).isNotEqualTo(oldToken); + verify(spotifyApi, times(1)).setAccessToken(newToken); + } + + @Test + @DisplayName("동시에 여러 스레드가 호출해도 토큰은 한 번만 발급") + void getAccessToken_concurrentCalls_shouldIssueTokenOnce() throws Exception { + // given + String expectedToken = "concurrent-token"; + ClientCredentials credentials = mock(ClientCredentials.class); + + given(spotifyApi.clientCredentials()).willReturn(requestBuilder); + given(requestBuilder.build()).willReturn(request); + given(request.execute()).willReturn(credentials); + given(credentials.getAccessToken()).willReturn(expectedToken); + given(credentials.getExpiresIn()).willReturn(3600); + + int threadCount = 10; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch latch = new CountDownLatch(threadCount); + AtomicInteger tokenIssueCount = new AtomicInteger(0); + + // when + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + spotifyClient.getAccessToken(); + tokenIssueCount.incrementAndGet(); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + executor.shutdown(); + + // then + // 모든 스레드가 동일한 토큰을 받아야 함 + assertThat(tokenIssueCount.get()).isEqualTo(threadCount); + // 실제 토큰 발급은 한 번만 일어나야 함 (동시성 제어) + verify(spotifyApi, atMostOnce()).clientCredentials(); + } + + @Test + @DisplayName("토큰 만료 여부 확인") + void isTokenExpired_shouldReturnCorrectStatus() { + // given - 만료된 토큰 + ReflectionTestUtils.setField(spotifyClient, "cachedAccessToken", "expired-token"); + ReflectionTestUtils.setField(spotifyClient, "tokenExpiresAt", System.currentTimeMillis() - 1000); + + // when & then + assertThat(spotifyClient.isTokenExpired()).isTrue(); + + // given - 유효한 토큰 + ReflectionTestUtils.setField(spotifyClient, "tokenExpiresAt", System.currentTimeMillis() + 1000000); + + // when & then + assertThat(spotifyClient.isTokenExpired()).isFalse(); + + // given - 토큰이 null + ReflectionTestUtils.setField(spotifyClient, "cachedAccessToken", null); + + // when & then + assertThat(spotifyClient.isTokenExpired()).isTrue(); + } + + @Test + @DisplayName("getAuthorizedApi는 유효한 토큰이 있는 API를 반환") + void getAuthorizedApi_shouldReturnApiWithValidToken() throws Exception { + // given + String expectedToken = "api-token"; + ClientCredentials credentials = mock(ClientCredentials.class); + + given(spotifyApi.clientCredentials()).willReturn(requestBuilder); + given(requestBuilder.build()).willReturn(request); + given(request.execute()).willReturn(credentials); + given(credentials.getAccessToken()).willReturn(expectedToken); + given(credentials.getExpiresIn()).willReturn(3600); + + // when + SpotifyApi api = spotifyClient.getAuthorizedApi(); + + // then + assertThat(api).isNotNull(); + assertThat(api).isEqualTo(spotifyApi); + verify(spotifyApi, times(1)).setAccessToken(expectedToken); + } +} + From add9347737516b8848b994a34e4df4a16ee4a1b3 Mon Sep 17 00:00:00 2001 From: Jung Yunseo Date: Tue, 6 Jan 2026 11:03:28 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20Redis=EC=97=90=20=EC=95=84=ED=8B=B0?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=83=81=EC=84=B8=20=EC=A0=95=EB=B3=B4=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../response/SpotifyArtistDetailCache.java | 21 ++ .../spotifyService/SpotifyService.java | 229 ++++++++++++++++-- 2 files changed, 229 insertions(+), 21 deletions(-) create mode 100644 src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/SpotifyArtistDetailCache.java diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/SpotifyArtistDetailCache.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/SpotifyArtistDetailCache.java new file mode 100644 index 00000000..50b685fe --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/dto/response/SpotifyArtistDetailCache.java @@ -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 topTracks, + + // 앨범 목록 (최대 20개) + List albums, + int totalAlbums +) { +} + diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyService.java index 9c561595..762a8baa 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyService.java @@ -3,6 +3,7 @@ import com.back.web7_9_codecrete_be.domain.artists.dto.response.AlbumResponse; import com.back.web7_9_codecrete_be.domain.artists.dto.response.ArtistDetailResponse; import com.back.web7_9_codecrete_be.domain.artists.dto.response.RelatedArtistResponse; +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.entity.Artist; import com.back.web7_9_codecrete_be.domain.artists.entity.ArtistGenre; @@ -22,6 +23,7 @@ import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import com.fasterxml.jackson.databind.ObjectMapper; import se.michaelthelin.spotify.SpotifyApi; import se.michaelthelin.spotify.enums.AlbumType; import se.michaelthelin.spotify.model_objects.specification.AlbumSimplified; @@ -30,6 +32,7 @@ import se.michaelthelin.spotify.model_objects.specification.Track; import java.util.*; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -45,9 +48,17 @@ public class SpotifyService { private final SpotifyClient spotifyClient; private final MusicBrainzClient musicBrainzClient; private final RedisTemplate redisTemplate; + private final RedisTemplate objectRedisTemplate; + private final ObjectMapper objectMapper; private final WikidataClient wikidataClient; private final SpotifyRateLimitHandler rateLimitHandler; + // Redis 캐시 설정 + 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 호출 완료 대기 시간) + // Rate Limiter 설정 private static final long SPOTIFY_RATE_LIMIT_INTERVAL_MS = 500; // 초당 2회 private static final long MUSICBRAINZ_RATE_LIMIT_INTERVAL_MS = 1000; // 초당 1회 @@ -1507,26 +1518,25 @@ public ArtistDetailResponse getArtistDetail( Long genreId ) { try { - 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); + // 1. Redis 캐시에서 조회 시도 + SpotifyArtistDetailCache cached = getCachedSpotifyDetail(spotifyArtistId); + + SpotifyArtistDetailCache spotifyData; + if (cached != null) { + log.debug("Spotify 상세 정보 캐시 HIT: spotifyArtistId={}", spotifyArtistId); + spotifyData = cached; + } else { + log.debug("Spotify 상세 정보 캐시 MISS: spotifyArtistId={}", spotifyArtistId); + // 2. 캐시 스탬피드 방지: Redis 락으로 동시 API 호출 제한 + spotifyData = fetchSpotifyDetailWithLock(spotifyArtistId); + } + // 4. DB에서 추가 정보 조회 (캐시하지 않는 데이터) Artist dbArtist = artistRepository.findById(artistId) .orElse(null); String nameKo = dbArtist != null ? dbArtist.getNameKo() : null; - Track[] topTracks = safeGetTopTracks(api, spotifyArtistId); - Paging albums = safeGetAlbums(api, spotifyArtistId); - + // 5. Related Artists 조회 (DB 기반 로직, 캐시하지 않음) List relatedResponses = getRelatedArtists( artistId, artistGroup, @@ -1534,19 +1544,20 @@ public ArtistDetailResponse getArtistDetail( genreId ); + // 6. 최종 Response 구성 return new ArtistDetailResponse( (long) artistId, - artist.getName(), + spotifyData.artistName(), nameKo, artistGroup, artistType, - pickImageUrl(artist.getImages()), + spotifyData.profileImageUrl(), likeCount, - albums != null ? albums.getTotal() : 0, - artist.getPopularity(), + spotifyData.totalAlbums(), + spotifyData.popularity(), "", - toAlbumResponses(albums != null ? albums.getItems() : null, spotifyArtistId), - toTopTrackResponses(topTracks), + spotifyData.albums(), + spotifyData.topTracks(), relatedResponses ); } catch (RuntimeException e) { @@ -1560,6 +1571,182 @@ public ArtistDetailResponse getArtistDetail( throw new BusinessException(ArtistErrorCode.SPOTIFY_API_ERROR); } } + + /** + * Redis 캐시에서 Spotify 상세 정보 조회 + */ + private SpotifyArtistDetailCache getCachedSpotifyDetail(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; + } + } + + /** + * Spotify API에서 상세 정보 조회 + */ + private SpotifyArtistDetailCache fetchSpotifyDetailFromApi(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 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 + ); + } + + /** + * Redis 캐시에 Spotify 상세 정보 저장 + */ + private void saveSpotifyDetailToCache(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 호출 제한 + * + * 1. 락 획득 시도 + * 2. 락 획득 성공 → Spotify API 호출 → 캐시 저장 → 락 해제 + * 3. 락 획득 실패 → 짧은 대기 후 캐시 재조회 (다른 스레드가 저장했을 수 있음) + */ + private SpotifyArtistDetailCache fetchSpotifyDetailWithLock(String spotifyArtistId) { + 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 = getCachedSpotifyDetail(spotifyArtistId); + if (doubleCheck != null) { + log.debug("락 획득 후 캐시 재조회 HIT: spotifyArtistId={}", spotifyArtistId); + return doubleCheck; + } + + // Spotify API 호출 + SpotifyArtistDetailCache spotifyData = fetchSpotifyDetailFromApi(spotifyArtistId); + + // 캐시에 저장 + saveSpotifyDetailToCache(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 = getCachedSpotifyDetail(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 = getCachedSpotifyDetail(spotifyArtistId); + if (retryCache != null) { + log.debug("락 대기 중 캐시 재조회 HIT ({}ms 후): spotifyArtistId={}", (i + 1) * 100, spotifyArtistId); + return retryCache; + } + } + + // 최종적으로도 캐시가 없으면 직접 API 호출 (락이 만료되었을 수 있음) + log.warn("락 대기 후에도 캐시 없음, 직접 API 호출: spotifyArtistId={}", spotifyArtistId); + SpotifyArtistDetailCache spotifyData = fetchSpotifyDetailFromApi(spotifyArtistId); + saveSpotifyDetailToCache(spotifyArtistId, spotifyData); + return spotifyData; + } + } + + /** + * 캐시 키 생성 + */ + private String getCacheKey(String spotifyArtistId) { + return CACHE_KEY_PREFIX + spotifyArtistId; + } + + /** + * 락 키 생성 + */ + private String getLockKey(String spotifyArtistId) { + return LOCK_KEY_PREFIX + spotifyArtistId; + } private Track[] safeGetTopTracks(SpotifyApi api, String artistId) { try { From b6af4758684968591458509af0133e628df6dc1e Mon Sep 17 00:00:00 2001 From: Jung Yunseo Date: Tue, 6 Jan 2026 14:12:42 +0900 Subject: [PATCH 5/7] =?UTF-8?q?refactor:=20SpotifyService=20=EB=A6=AC?= =?UTF-8?q?=ED=8C=A9=ED=86=A0=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/artists/service/ArtistService.java | 3 +- .../{spotifyService => }/SpotifyService.java | 1039 ++--------------- .../application/SpotifyDetailService.java | 162 +++ .../spotify/cache/SpotifyCacheService.java | 157 +++ .../dto}/ArtistData.java | 5 +- .../genre}/CategoryConfig.java | 5 +- .../genre/GenreNormalizationService.java | 87 ++ .../rate_limit}/SimpleRateLimiter.java | 3 +- .../rate_limit/SpotifyRateLimitHandler.java | 132 +++ .../spotify/related/RelatedArtistService.java | 275 +++++ .../spotify/related/model/ScoredArtist.java | 17 + .../SpotifyRateLimitHandler.java | 250 ---- .../SpotifyRateLimitHandlerTest.java | 1 + 13 files changed, 906 insertions(+), 1230 deletions(-) rename src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/{spotifyService => }/SpotifyService.java (56%) create mode 100644 src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/application/SpotifyDetailService.java create mode 100644 src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/cache/SpotifyCacheService.java rename src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/{spotifyService => spotify/dto}/ArtistData.java (85%) rename src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/{spotifyService => spotify/genre}/CategoryConfig.java (82%) create mode 100644 src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/genre/GenreNormalizationService.java rename src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/{spotifyService => spotify/rate_limit}/SimpleRateLimiter.java (97%) create mode 100644 src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/rate_limit/SpotifyRateLimitHandler.java create mode 100644 src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/related/RelatedArtistService.java create mode 100644 src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/related/model/ScoredArtist.java delete mode 100644 src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyRateLimitHandler.java diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistService.java index 9175b838..8f367abb 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistService.java @@ -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; @@ -46,7 +45,7 @@ public Artist findArtist(Long artistId) { @Transactional public int setArtist() { - return spotifyService.seedKoreanArtists300(); + return spotifyService.seedKoreanArtists(); } @Transactional diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/SpotifyService.java similarity index 56% rename from src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyService.java rename to src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/SpotifyService.java index 762a8baa..c05fbf38 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/SpotifyService.java @@ -1,40 +1,38 @@ -package com.back.web7_9_codecrete_be.domain.artists.service.spotifyService; +package com.back.web7_9_codecrete_be.domain.artists.service; -import com.back.web7_9_codecrete_be.domain.artists.dto.response.AlbumResponse; import com.back.web7_9_codecrete_be.domain.artists.dto.response.ArtistDetailResponse; import com.back.web7_9_codecrete_be.domain.artists.dto.response.RelatedArtistResponse; 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.entity.Artist; import com.back.web7_9_codecrete_be.domain.artists.entity.ArtistGenre; import com.back.web7_9_codecrete_be.domain.artists.entity.ArtistType; import com.back.web7_9_codecrete_be.domain.artists.entity.Genre; import com.back.web7_9_codecrete_be.domain.artists.repository.ArtistRepository; import com.back.web7_9_codecrete_be.domain.artists.repository.GenreRepository; +import com.back.web7_9_codecrete_be.domain.artists.service.spotify.application.SpotifyDetailService; +import com.back.web7_9_codecrete_be.domain.artists.service.spotify.rate_limit.SimpleRateLimiter; import com.back.web7_9_codecrete_be.global.error.code.ArtistErrorCode; import com.back.web7_9_codecrete_be.global.error.exception.BusinessException; import com.back.web7_9_codecrete_be.global.musicbrainz.MusicBrainzClient; import com.back.web7_9_codecrete_be.global.spotify.SpotifyClient; +import com.back.web7_9_codecrete_be.domain.artists.service.spotify.cache.SpotifyCacheService; +import com.back.web7_9_codecrete_be.domain.artists.service.spotify.dto.ArtistData; +import com.back.web7_9_codecrete_be.domain.artists.service.spotify.genre.CategoryConfig; +import com.back.web7_9_codecrete_be.domain.artists.service.spotify.genre.GenreNormalizationService; +import com.back.web7_9_codecrete_be.domain.artists.service.spotify.rate_limit.SpotifyRateLimitHandler; +import com.back.web7_9_codecrete_be.domain.artists.service.spotify.related.RelatedArtistService; import com.back.web7_9_codecrete_be.global.wikidata.WikidataClient; import com.fasterxml.jackson.databind.JsonNode; -import com.neovisionaries.i18n.CountryCode; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.data.redis.core.RedisTemplate; +import org.aspectj.apache.bcel.classfile.Unknown; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import com.fasterxml.jackson.databind.ObjectMapper; 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.*; -import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; -import java.util.stream.Stream; import static java.util.stream.Collectors.toList; @@ -47,17 +45,13 @@ public class SpotifyService { private final GenreRepository genreRepository; private final SpotifyClient spotifyClient; private final MusicBrainzClient musicBrainzClient; - private final RedisTemplate redisTemplate; - private final RedisTemplate objectRedisTemplate; - private final ObjectMapper objectMapper; private final WikidataClient wikidataClient; - private final SpotifyRateLimitHandler rateLimitHandler; - // Redis 캐시 설정 - 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 호출 완료 대기 시간) + // 분리된 서비스들 + private final SpotifyCacheService spotifyCacheService; + private final SpotifyDetailService spotifyDetailService; + private final RelatedArtistService relatedArtistService; + private final GenreNormalizationService genreNormalizationService; // Rate Limiter 설정 private static final long SPOTIFY_RATE_LIMIT_INTERVAL_MS = 500; // 초당 2회 @@ -150,7 +144,7 @@ public class SpotifyService { ); @Transactional - public int seedKoreanArtists300() { + public int seedKoreanArtists() { try { SpotifyApi api = spotifyClient.getAuthorizedApi(); Map categoryConfigs = createCategoryConfigs(); @@ -200,7 +194,7 @@ private Map createCategoryConfigs() { // Phase 1: FastSeed 모드 - 카테고리당 제한된 수집 (호출 폭발 방지) private List collectArtistsRoundRobin(SpotifyApi api, Map categoryConfigs) { - List artistDataList = new ArrayList<>(); + List artistDataList = new ArrayList<>(); Set collectedSpotifyIds = new HashSet<>(); // Spotify ID 중복 제거 Set seenNormalizedNames = new HashSet<>(); // 정규화된 이름 중복 제거 @@ -313,11 +307,11 @@ private void processSearchPage(SpotifyApi api, String query, int currentPage, Ca Paging paging = api.searchArtists(query) .limit(SEARCH_LIMIT) - .offset(offset) - .build() - .execute(); + .offset(offset) + .build() + .execute(); - var items = paging.getItems(); + var items = paging.getItems(); if (items == null || items.length == 0) { categoryKeywordIndex.put(categoryName, categoryKeywordIndex.get(categoryName) + 1); return; @@ -325,7 +319,7 @@ private void processSearchPage(SpotifyApi api, String query, int currentPage, Ca int collectedInCategory = categoryCollectedCount.get(categoryName); - for (var spotifyArtist : items) { + for (var spotifyArtist : items) { // 카테고리당 최대 수집 수 확인 if (collectedInCategory >= MAX_ARTISTS_PER_CATEGORY) { break; @@ -372,8 +366,8 @@ private String normalizeNameForDedup(String name) { private boolean shouldAddArtist(se.michaelthelin.spotify.model_objects.specification.Artist spotifyArtist, CategoryConfig config, String categoryName, Set collectedSpotifyIds, Set seenNormalizedNames) { - String spotifyId = spotifyArtist.getId(); - String name = spotifyArtist.getName(); + String spotifyId = spotifyArtist.getId(); + String name = spotifyArtist.getName(); // Spotify ID 중복 체크 if (spotifyId == null || name == null || name.isBlank() || collectedSpotifyIds.contains(spotifyId)) { @@ -404,7 +398,7 @@ private boolean shouldAddArtist(se.michaelthelin.spotify.model_objects.specifica return false; } - String imageUrl = pickImageUrl(spotifyArtist.getImages()); + String imageUrl = spotifyDetailService.pickImageUrl(spotifyArtist.getImages()); return imageUrl != null && !imageUrl.isBlank(); } @@ -470,13 +464,13 @@ private ArtistData createArtistData(se.michaelthelin.spotify.model_objects.speci Integer followers = spotifyArtist.getFollowers() != null ? spotifyArtist.getFollowers().getTotal() : null; - String imageUrl = pickImageUrl(spotifyArtist.getImages()); - String artistTypeStr = inferArtistType(spotifyArtist); - ArtistType artistType = ArtistType.valueOf(artistTypeStr); + String imageUrl = spotifyDetailService.pickImageUrl(spotifyArtist.getImages()); + String artistTypeStr = inferArtistType(spotifyArtist); + ArtistType artistType = ArtistType.valueOf(artistTypeStr); - List genreList = genres != null - ? Arrays.stream(genres).filter(Objects::nonNull).filter(g -> !g.isBlank()).collect(toList()) - : List.of(); + List genreList = genres != null + ? Arrays.stream(genres).filter(Objects::nonNull).filter(g -> !g.isBlank()).collect(toList()) + : List.of(); return new ArtistData(spotifyId, name.trim(), artistType, imageUrl, genreList, popularity, followers); } @@ -630,9 +624,7 @@ private List applyFilteringPipeline(List artistDataList) return selected; } - /** - * 국내/해외/Unknown 분류 결과 - */ + // 국내/해외 Unknown 분류 결과 private static class KoreanClassificationResult { final List strongKorean; // 확정 국내 (k-* 장르) final List weakKorean; // 가능 국내 (한글 포함) @@ -648,9 +640,7 @@ private static class KoreanClassificationResult { } } - /** - * 2단계: 국내/해외/Unknown 분류 - */ + // 2단계: 국내/해외/Unknown 분류 private KoreanClassificationResult classifyKoreanArtists(List candidates) { List strongKorean = new ArrayList<>(); List weakKorean = new ArrayList<>(); @@ -679,9 +669,7 @@ private KoreanClassificationResult classifyKoreanArtists(List candid return new KoreanClassificationResult(strongKorean, weakKorean, globalArtists, unknown); } - /** - * 국내 타입 분류 - */ + // 국내 타입 분류 private enum KoreanType { STRONG_KOREAN, // 확정 국내 (k-* 장르) WEAK_KOREAN, // 가능 국내 (한글 포함) @@ -689,9 +677,7 @@ private enum KoreanType { UNKNOWN // 애매한 경우 } - /** - * 아티스트의 국내 타입 분류 - */ + // 아티스트의 국내 타입 분류 private KoreanType classifyKoreanType(ArtistData data) { // Strong Korean: genres에 k-* 장르 포함 if (data.genres != null && !data.genres.isEmpty()) { @@ -725,9 +711,7 @@ private KoreanType classifyKoreanType(ArtistData data) { return KoreanType.UNKNOWN; } - /** - * 3단계: 국내를 먼저 목표 수까지 채우기 - */ + // 3단계: 국내를 먼저 목표 수까지 채우기 private List selectKoreanArtists(KoreanClassificationResult classification) { List selected = new ArrayList<>(); @@ -765,9 +749,7 @@ private List selectKoreanArtists(KoreanClassificationResult classifi return selected; } - /** - * 4단계: 해외는 기준 완화 + 쿼터제 적용 - */ + // 4단계: 해외는 기준 완화 + 쿼터제 적용 private List selectGlobalArtists(List globalCandidates, int currentCount) { if (globalCandidates.isEmpty()) { return new ArrayList<>(); @@ -803,9 +785,7 @@ private List selectGlobalArtists(List globalCandidates, return qualified; } - /** - * 5단계: Fallback - 부족분 채우기 - */ + // 5단계: Fallback - 부족분 채우기 private List fillRemainingSlots(KoreanClassificationResult classification, int currentCount, Set selectedSpotifyIds) { int remaining = MAX_SEED_COUNT - currentCount; @@ -897,46 +877,6 @@ private double calculateConcertScore(Integer popularity, Integer followers) { return popScore + followerScore; } - // 장르로 카테고리 추론 - private String inferCategoryFromGenres(List genres) { - if (genres == null || genres.isEmpty()) { - return "유명 솔로"; - } - - String genresStr = String.join(" ", genres).toLowerCase(); - - // 아이돌 - if (genresStr.contains("k-pop") && (genresStr.contains("girl group") || - genresStr.contains("boy group") || genresStr.contains("idol"))) { - return "아이돌(걸그룹/보이그룹)"; - } - - // 힙합/R&B - if (genresStr.contains("hip hop") || genresStr.contains("k-rap") || - genresStr.contains("k-r&b") || genresStr.contains("r&b")) { - return "힙합/인디/R&B"; - } - - // 밴드 - if (genresStr.contains("rock") || genresStr.contains("band") || - genresStr.contains("indie") || genresStr.contains("alternative")) { - return "밴드"; - } - - // 발라드/OST - if (genresStr.contains("ballad") || genresStr.contains("ost") || - genresStr.contains("k-ballad")) { - return "발라드/OST"; - } - - // 글로벌 - if (!genresStr.contains("k-pop") && !genresStr.contains("korean")) { - return "글로벌"; - } - - return "유명 솔로"; - } - private int saveArtistsToDatabase(List finalArtists) { Map artistMap = upsertArtists(finalArtists); Map genreMap = processGenres(finalArtists); @@ -981,20 +921,20 @@ private Map upsertArtists(List finalArtists) { if (artist != null) { // 기존 아티스트 업데이트 - artist.setArtistName(data.name); - artist.setArtistType(data.artistType); - artist.setImageUrl(data.imageUrl); - artist.getArtistGenres().clear(); - } else { + artist.setArtistName(data.name); + artist.setArtistType(data.artistType); + artist.setImageUrl(data.imageUrl); + artist.getArtistGenres().clear(); + } else { // 신규 아티스트 생성 - artist = new Artist(data.spotifyId, data.name, null, data.artistType); - artist.setImageUrl(data.imageUrl); - } - + artist = new Artist(data.spotifyId, data.name, null, data.artistType); + artist.setImageUrl(data.imageUrl); + } + toSave.add(artist); - artistMap.put(data.spotifyId, artist); - } - + artistMap.put(data.spotifyId, artist); + } + // 4. Bulk 저장 (한 번에 저장) if (!toSave.isEmpty()) { artistRepository.saveAll(toSave); @@ -1009,7 +949,7 @@ private Map processGenres(List finalArtists) { .flatMap(data -> data.genres.stream()) .filter(Objects::nonNull) .filter(g -> !g.isBlank()) - .map(this::normalizeGenre) + .map(genreNormalizationService::normalizeGenre) .filter(Objects::nonNull) .filter(g -> !g.isBlank()) .collect(Collectors.toSet()); @@ -1041,88 +981,7 @@ private Map processGenres(List finalArtists) { .collect(Collectors.toMap(Genre::getGenreName, g -> g, (g1, g2) -> g1)); } - /** - * 원본 장르명을 통합된 카테고리로 변환 - * 우선순위 순서대로 체크하여 매칭되는 첫 번째 카테고리 반환 - */ - private String normalizeGenre(String originalGenre) { - if (originalGenre == null || originalGenre.isBlank()) { - return null; - } - - String lowerGenre = originalGenre.toLowerCase().trim(); - - // 1순위: KOREAN (k-로 시작) - if (lowerGenre.startsWith("k-")) { - return "KOREAN"; - } - - // 2순위: HIPHOP/RAP - if (containsAny(lowerGenre, "hip hop", "rap", "drill", "grime", "boom bap", "hip-hop", "hiphop")) { - return "HIPHOP/RAP"; - } - - // 3순위: R&B/SOUL - if (containsAny(lowerGenre, "r&b", "rnb", "soul", "r and b")) { - return "R&B/SOUL"; - } - - // 4순위: METAL - if (lowerGenre.contains("metal")) { - return "METAL"; - } - - // 5순위: ROCK - if (containsAny(lowerGenre, "rock", "grunge", "shoegaze", "britpop", "classic rock")) { - return "ROCK"; - } - - // 6순위: INDIE/ALT - if (containsAny(lowerGenre, "indie", "alternative", "art rock", "neo-psychedelic", "jangle pop", "alt")) { - return "INDIE/ALT"; - } - - // 7순위: LATIN - if (containsAny(lowerGenre, "latin", "reggaeton", "urbano", "bachata", "latin afrobeats")) { - return "LATIN"; - } - - // 8순위: REGGAE - if (lowerGenre.contains("reggae")) { - return "REGGAE"; - } - - // 9순위: JAPAN - if (containsAny(lowerGenre, "j-pop", "j-rock", "jpop", "jrock", "vocaloid", "shibuya-kei", "japanese", "city pop", "japanese indie")) { - return "JAPAN"; - } - - // 10순위: SOUNDTRACK/ANIME - if (containsAny(lowerGenre, "soundtrack", "anime", "bollywood", "tollywood", "kollywood", "ost")) { - return "SOUNDTRACK/ANIME"; - } - - // 11순위: POP (pop이 포함된 경우) - if (lowerGenre.contains("pop")) { - return "POP"; - } - - // 12순위: ETC (그 외 모든 경우) - return "ETC"; - } - - /** - * 문자열이 주어진 키워드들 중 하나라도 포함하는지 확인 - */ - private boolean containsAny(String text, String... keywords) { - for (String keyword : keywords) { - if (text.contains(keyword)) { - return true; - } - } - return false; - } - + // 우선순위 순서대로 체크하여 매칭되는 첫 번째 카테고리 반환 // Batch 저장: ArtistGenre를 별도로 수집하여 saveAll로 한 번에 저장 private int createArtistGenreMappings(List finalArtists, Map artistMap, Map genreMap) { @@ -1130,10 +989,10 @@ private int createArtistGenreMappings(List finalArtists, Map seenMappings = new HashSet<>(); // 중복 매핑 방지 for (ArtistData data : finalArtists) { - Artist artist = artistMap.get(data.spotifyId); - if (artist == null) { - continue; - } + Artist artist = artistMap.get(data.spotifyId); + if (artist == null) { + continue; + } for (String originalGenreName : data.genres) { if (originalGenreName == null || originalGenreName.isBlank()) { @@ -1141,25 +1000,25 @@ private int createArtistGenreMappings(List finalArtists, Map activeArtists; - private final List mbFailedArtists; - - MusicBrainzFilterResult(List activeArtists, List mbFailedArtists) { - this.activeArtists = activeArtists; - this.mbFailedArtists = mbFailedArtists; - } - - List getActiveArtists() { - return activeArtists; - } - - List getMbFailedArtists() { - return mbFailedArtists; - } - } - - private MusicBrainzFilterResult filterByMusicBrainzEnded(List candidates) { - List activeArtists = new ArrayList<>(); - List mbFailedArtists = new ArrayList<>(); - - final double UNCONDITIONAL_SCORE = calculatePopularityScore(UNCONDITIONAL_POPULARITY, UNCONDITIONAL_FOLLOWERS); - final double KOREAN_LEGACY_SCORE = calculatePopularityScore(KOREAN_LEGACY_POPULARITY, KOREAN_LEGACY_FOLLOWERS); - final double GLOBAL_LEGACY_SCORE = calculatePopularityScore(GLOBAL_LEGACY_POPULARITY, GLOBAL_LEGACY_FOLLOWERS); - - for (ArtistData data : candidates) { - double score = calculatePopularityScore(data.popularity, data.followers); - - if (score >= UNCONDITIONAL_SCORE) { - activeArtists.add(data); - continue; - } - - musicBrainzRateLimiter.acquire(); - - Boolean isEnded = null; - boolean mbSucceeded = false; - - try { - Optional artistInfoOpt = - musicBrainzClient.searchArtist(data.name); - - if (artistInfoOpt.isPresent()) { - String mbid = artistInfoOpt.get().getMbid(); - if (mbid != null && !mbid.isBlank()) { - Optional endedOpt = musicBrainzClient.isEnded(mbid); - if (endedOpt.isPresent()) { - isEnded = endedOpt.get(); - mbSucceeded = true; - } - } - } - } catch (Exception e) { - // MusicBrainz 조회 실패는 Wikidata로 확인 대상에 추가 - } - - if (!mbSucceeded) { - boolean isVeryLowPopularity = (data.popularity == null || data.popularity < MB_FAILED_MIN_POPULARITY) && - (data.followers == null || data.followers < MB_FAILED_MIN_FOLLOWERS); - if (!isVeryLowPopularity) { - activeArtists.add(data); - } else { - mbFailedArtists.add(data); - } - continue; - } - - if (isEnded != null && !isEnded) { - activeArtists.add(data); - continue; - } - - if (isEnded != null && isEnded) { - boolean isKorean = data.genres.stream() - .anyMatch(g -> g != null && (g.toLowerCase().contains("k-pop") || - g.toLowerCase().contains("korean"))); - - double legacyThreshold = isKorean ? KOREAN_LEGACY_SCORE : GLOBAL_LEGACY_SCORE; - - if (score >= legacyThreshold) { - activeArtists.add(data); - } - continue; - } - - activeArtists.add(data); - } - - return new MusicBrainzFilterResult(activeArtists, mbFailedArtists); - } - - private double calculatePopularityScore(Integer popularity, Integer followers) { - double popularityNorm = (popularity != null && popularity >= 0) ? popularity / 100.0 : 0.0; - - double followersNorm = 0.0; - if (followers != null && followers > 0) { - double logFollowers = Math.log10(followers); - followersNorm = Math.max(0.0, Math.min(1.0, (logFollowers - 3.0) / 5.0)); - } - - return popularityNorm * 0.6 + followersNorm * 0.4; - } - - private List filterByWikidataDissolved(List candidates) { - List activeArtists = new ArrayList<>(); - - for (ArtistData data : candidates) { - boolean isDissolved = false; - - try { - Optional qidOpt = wikidataClient.searchWikidataIdBySpotifyId(data.spotifyId); - if (qidOpt.isEmpty()) { - activeArtists.add(data); - continue; - } - - String qid = qidOpt.get(); - - Optional entityOpt = wikidataClient.getEntityInfo(qid); - if (entityOpt.isEmpty()) { - activeArtists.add(data); - continue; - } - - JsonNode entity = entityOpt.get(); - - Optional p576Opt = getTimeClaim(entity, "P576"); - if (p576Opt.isPresent()) { - isDissolved = true; - } - - if (!isDissolved) { - Optional p582Opt = getTimeClaim(entity, "P582"); - if (p582Opt.isPresent()) { - isDissolved = true; - } - } - - } catch (Exception e) { - // Wikidata 조회 실패는 다음 단계로 진행 (보수적 접근) - } - - if (!isDissolved) { - activeArtists.add(data); - } - } - - return activeArtists; - } - - private Optional getTimeClaim(JsonNode entity, String propertyId) { - try { - JsonNode claims = entity.path("claims").path(propertyId); - if (!claims.isArray() || claims.isEmpty()) { - return Optional.empty(); - } - - JsonNode value = claims.get(0) - .path("mainsnak") - .path("datavalue") - .path("value"); - - JsonNode timeNode = value.path("time"); - if (!timeNode.isMissingNode() && !timeNode.asText().isBlank()) { - return Optional.of(timeNode.asText()); - } - - return Optional.empty(); - } catch (Exception e) { - return Optional.empty(); - } - } @Transactional(readOnly = true) public ArtistDetailResponse getArtistDetail( @@ -1519,7 +1204,7 @@ public ArtistDetailResponse getArtistDetail( ) { try { // 1. Redis 캐시에서 조회 시도 - SpotifyArtistDetailCache cached = getCachedSpotifyDetail(spotifyArtistId); + SpotifyArtistDetailCache cached = spotifyCacheService.getCached(spotifyArtistId); SpotifyArtistDetailCache spotifyData; if (cached != null) { @@ -1528,7 +1213,10 @@ public ArtistDetailResponse getArtistDetail( } else { log.debug("Spotify 상세 정보 캐시 MISS: spotifyArtistId={}", spotifyArtistId); // 2. 캐시 스탬피드 방지: Redis 락으로 동시 API 호출 제한 - spotifyData = fetchSpotifyDetailWithLock(spotifyArtistId); + spotifyData = spotifyCacheService.getOrFetchWithLock( + spotifyArtistId, + () -> spotifyDetailService.fetchDetailFromApi(spotifyArtistId) + ); } // 4. DB에서 추가 정보 조회 (캐시하지 않는 데이터) @@ -1537,7 +1225,7 @@ public ArtistDetailResponse getArtistDetail( String nameKo = dbArtist != null ? dbArtist.getNameKo() : null; // 5. Related Artists 조회 (DB 기반 로직, 캐시하지 않음) - List relatedResponses = getRelatedArtists( + List relatedResponses = relatedArtistService.getRelatedArtists( artistId, artistGroup, artistType, @@ -1571,594 +1259,5 @@ public ArtistDetailResponse getArtistDetail( throw new BusinessException(ArtistErrorCode.SPOTIFY_API_ERROR); } } - - /** - * Redis 캐시에서 Spotify 상세 정보 조회 - */ - private SpotifyArtistDetailCache getCachedSpotifyDetail(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; - } - } - - /** - * Spotify API에서 상세 정보 조회 - */ - private SpotifyArtistDetailCache fetchSpotifyDetailFromApi(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 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 - ); - } - - /** - * Redis 캐시에 Spotify 상세 정보 저장 - */ - private void saveSpotifyDetailToCache(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 호출 제한 - * - * 1. 락 획득 시도 - * 2. 락 획득 성공 → Spotify API 호출 → 캐시 저장 → 락 해제 - * 3. 락 획득 실패 → 짧은 대기 후 캐시 재조회 (다른 스레드가 저장했을 수 있음) - */ - private SpotifyArtistDetailCache fetchSpotifyDetailWithLock(String spotifyArtistId) { - 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 = getCachedSpotifyDetail(spotifyArtistId); - if (doubleCheck != null) { - log.debug("락 획득 후 캐시 재조회 HIT: spotifyArtistId={}", spotifyArtistId); - return doubleCheck; - } - - // Spotify API 호출 - SpotifyArtistDetailCache spotifyData = fetchSpotifyDetailFromApi(spotifyArtistId); - - // 캐시에 저장 - saveSpotifyDetailToCache(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 = getCachedSpotifyDetail(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 = getCachedSpotifyDetail(spotifyArtistId); - if (retryCache != null) { - log.debug("락 대기 중 캐시 재조회 HIT ({}ms 후): spotifyArtistId={}", (i + 1) * 100, spotifyArtistId); - return retryCache; - } - } - - // 최종적으로도 캐시가 없으면 직접 API 호출 (락이 만료되었을 수 있음) - log.warn("락 대기 후에도 캐시 없음, 직접 API 호출: spotifyArtistId={}", spotifyArtistId); - SpotifyArtistDetailCache spotifyData = fetchSpotifyDetailFromApi(spotifyArtistId); - saveSpotifyDetailToCache(spotifyArtistId, spotifyData); - return spotifyData; - } - } - - /** - * 캐시 키 생성 - */ - private String getCacheKey(String spotifyArtistId) { - return CACHE_KEY_PREFIX + spotifyArtistId; - } - - /** - * 락 키 생성 - */ - private String getLockKey(String spotifyArtistId) { - return LOCK_KEY_PREFIX + spotifyArtistId; - } - - 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 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; - } - } - - /** - * 관련 아티스트 추천 (3단계: Recall -> Score -> Diversity) - */ - private List getRelatedArtists( - long artistId, - String artistGroup, - ArtistType artistType, - Long genreId - ) { - try { - // 1단계: 후보 뽑기 (Recall) - Set candidates = collectRelatedCandidates(artistId, artistGroup, artistType, genreId); - - if (candidates.isEmpty()) { - return List.of(); - } - - // 2단계: 점수 매기기 (Score) - List scoredArtists = scoreCandidates(candidates, artistGroup, artistType, genreId, artistId); - - // 3단계: 4~5명 뽑기 + 도배 방지 (Diversity) - List selectedArtists = selectWithDiversity(scoredArtists, artistGroup, genreId); - - // RelatedArtistResponse로 변환 - return selectedArtists.stream() - .map(a -> new RelatedArtistResponse( - a.getId(), - a.getArtistName(), - a.getNameKo(), - a.getImageUrl(), - a.getSpotifyArtistId() - )) - .toList(); - - } catch (Exception e) { - log.error("관련 아티스트 조회 실패: artistId={}", artistId, e); - return List.of(); - } - } - - /** - * 1단계: 후보 뽑기 (Recall) - * - 같은 genre인 아티스트들 (최대 200명, 결정론적 다양성을 위해 넓은 후보 풀 확보) - * - 같은 artistGroup인 아티스트들 (최대 5명, artistGroup이 있을 때만) - * - 같은 artistType인 아티스트들 (최대 50명, fallback: 장르/그룹 후보가 부족할 때만) - */ - private Set collectRelatedCandidates( - long artistId, - String artistGroup, - ArtistType artistType, - Long genreId - ) { - Set candidates = new HashSet<>(); - final int MAX_GENRE_CANDIDATES = 200; // 결정론적 다양성을 위해 후보 풀 확장 - final int MAX_GROUP_CANDIDATES = 5; - final int MAX_TYPE_CANDIDATES = 50; - final int MIN_CANDIDATES_FOR_FALLBACK = 10; // 이보다 적으면 타입 후보 추가 - - // 같은 genre인 아티스트들 - if (genreId != null) { - List sameGenre = artistRepository.findByGenreIdAndIdNot( - genreId, artistId, - org.springframework.data.domain.PageRequest.of(0, MAX_GENRE_CANDIDATES) - ); - candidates.addAll(sameGenre); - } - - // 같은 artistGroup인 아티스트들 (artistGroup이 있을 때만) - if (artistGroup != null && !artistGroup.isBlank()) { - List sameGroup = artistRepository.findByArtistGroupAndIdNot( - artistGroup, artistId, - org.springframework.data.domain.PageRequest.of(0, MAX_GROUP_CANDIDATES) - ); - candidates.addAll(sameGroup); - } - - // 같은 artistType인 아티스트들 (fallback: 후보가 부족할 때만) - if (artistType != null && candidates.size() < MIN_CANDIDATES_FOR_FALLBACK) { - List sameType = artistRepository.findByArtistTypeAndIdNot( - artistType, artistId, - org.springframework.data.domain.PageRequest.of(0, MAX_TYPE_CANDIDATES) - ); - candidates.addAll(sameType); - } - - return candidates; - } - - /** - * 2단계: 점수 매기기 (Score) - * - 같은 그룹: +80 - * - 같은 장르: +60 (그룹 점수가 있을 때는 +30으로 완화) - * - 같은 타입: +15 - * - likeCount 보정: + 5 * log(likeCount+1), 최대 15점 (기본 연관 점수가 30 이상일 때만 적용) - * - hash 기반 미세 조정: 점수에 직접 반영하여 기준 아티스트별로 다른 결과 보장 - * - * 정렬: 점수(내부에 hash 반영) → likeCount → 이름 → Spotify ID → id - * hash를 점수에 직접 반영하여 같은 점수/likeCount를 가진 아티스트들도 기준 아티스트별로 다른 순서를 보장 - */ - private List scoreCandidates( - Set candidates, - String artistGroup, - ArtistType artistType, - Long genreId, - long baseArtistId // 기준 아티스트 ID (hash 계산용) - ) { - List scored = new ArrayList<>(); - final double MAX_LIKECOUNT_BONUS = 15.0; - final double MIN_BASE_SCORE_FOR_LIKECOUNT = 30.0; // 기본 연관 점수가 이 이상일 때만 likeCount 보정 적용 - - for (Artist candidate : candidates) { - double score = 0.0; - boolean hasGroupScore = false; - - // 같은 그룹이면 +80 - if (artistGroup != null && !artistGroup.isBlank() && - candidate.getArtistGroup() != null && - candidate.getArtistGroup().equals(artistGroup)) { - score += 80; - hasGroupScore = true; - } - - // 같은 장르면 +60 (그룹 점수가 있을 때는 +30으로 완화하여 중복 가산 완화) - if (genreId != null) { - boolean hasSameGenre = candidate.getArtistGenres().stream() - .anyMatch(ag -> ag.getGenre().getId() == genreId); - if (hasSameGenre) { - // 그룹 점수가 있으면 장르 점수를 절반으로 완화 - score += hasGroupScore ? 30 : 60; - } - } - - // 같은 타입이면 +15 - if (artistType != null && candidate.getArtistType() == artistType) { - score += 15; - } - - // likeCount 보정: 기본 연관 점수가 일정 수준 이상일 때만 적용, 최대 15점 - double baseScore = score; // likeCount 보정 전 점수 - if (baseScore >= MIN_BASE_SCORE_FOR_LIKECOUNT && candidate.getLikeCount() > 0) { - double likeCountBonus = 5.0 * Math.log(candidate.getLikeCount() + 1); - score += Math.min(likeCountBonus, MAX_LIKECOUNT_BONUS); - } - - // hash 기반 tie-breaker 값 계산 (기준 아티스트 ID와 후보 아티스트 ID 조합) - int hashValue = calculateHashForTieBreaker(baseArtistId, candidate.getId()); - - // hash를 점수에 반영하여 기준 아티스트별로 다른 순서 보장 - // hashValue를 0~1 범위로 정규화하여 점수에 더함 (최대 약 1점 차이) - // 음수 hash도 처리하기 위해 절댓값 사용 후 modulo 연산 - double normalizedHash = (Math.abs(hashValue) % 10000) / 10000.0; // 0.0 ~ 0.9999 - score += normalizedHash; // 최대 약 1점 차이로 같은 점수/likeCount를 가진 아티스트들도 순서가 달라짐 - - scored.add(new ScoredArtist(candidate, score, hashValue)); - } - - // 점수 내림차순 정렬, 동점일 때는 의미 있는 기준으로 정렬 - scored.sort((a, b) -> { - // 1순위: 점수 내림차순 (이미 hash가 반영되어 있음) - int scoreCompare = Double.compare(b.score, a.score); - if (scoreCompare != 0) { - return scoreCompare; - } - - // 2순위: likeCount 내림차순 - int likeCountCompare = Integer.compare(b.artist.getLikeCount(), a.artist.getLikeCount()); - if (likeCountCompare != 0) { - return likeCountCompare; - } - - // 3순위: 이름 오름차순 (nameKo 우선, 없으면 artistName) - String nameA = a.artist.getNameKo() != null && !a.artist.getNameKo().isBlank() - ? a.artist.getNameKo() - : a.artist.getArtistName(); - String nameB = b.artist.getNameKo() != null && !b.artist.getNameKo().isBlank() - ? b.artist.getNameKo() - : b.artist.getArtistName(); - int nameCompare = nameA.compareTo(nameB); - if (nameCompare != 0) { - return nameCompare; - } - - // 4순위: Spotify ID 오름차순 - if (a.artist.getSpotifyArtistId() != null && b.artist.getSpotifyArtistId() != null) { - int spotifyIdCompare = a.artist.getSpotifyArtistId().compareTo(b.artist.getSpotifyArtistId()); - if (spotifyIdCompare != 0) { - return spotifyIdCompare; - } - } - - // 최종 tie-breaker: id 오름차순 - return Long.compare(a.artist.getId(), b.artist.getId()); - }); - - return scored; - } - - /** - * 기준 아티스트 ID와 후보 아티스트 ID를 조합하여 hash 값 계산 - * - * 같은 기준 아티스트에 대해서는 항상 동일한 hash 값을 반환하지만, - * 기준 아티스트가 다르면 같은 후보라도 다른 hash 값을 가져 결정론적 다양성을 보장합니다. - * - * @param baseArtistId 기준 아티스트 ID - * @param candidateArtistId 후보 아티스트 ID - * @return hash 값 (정렬용) - */ - private int calculateHashForTieBreaker(long baseArtistId, long candidateArtistId) { - // 두 ID를 조합하여 hash 계산 - String combined = baseArtistId + "-" + candidateArtistId; - return combined.hashCode(); - } - - /** - * 3단계: 슬롯 기반 최종 선택 (Diversity) - * - * 슬롯 구조로 구성 비율을 강제하여 장르 편향을 완화합니다. - * 랜덤 요소 없이 점수 순으로 고정적으로 선별하므로, 동일한 아티스트 조회 시 항상 동일한 결과를 보장합니다. - * - * 슬롯 구성: - * - 그룹 슬롯: 같은 그룹 최대 2명 - * - 장르 슬롯: 같은 장르(그룹 아님) 최대 3명 - * - 그 외 슬롯: 나머지 - * - * 목표: 최대 5명 - */ - private List selectWithDiversity( - List scoredArtists, - String artistGroup, - Long genreId - ) { - final int MAX_SAME_GROUP = 2; - final int MAX_SAME_GENRE = 3; - final int TARGET_COUNT = 5; - - // 슬롯별로 후보 분류 - List groupSlot = new ArrayList<>(); - List genreSlot = new ArrayList<>(); - List otherSlot = new ArrayList<>(); - - for (ScoredArtist scored : scoredArtists) { - Artist candidate = scored.artist; - - // 같은 그룹 체크 - boolean isSameGroup = artistGroup != null && !artistGroup.isBlank() && - candidate.getArtistGroup() != null && - candidate.getArtistGroup().equals(artistGroup); - - // 같은 장르 체크 - boolean isSameGenre = genreId != null && candidate.getArtistGenres().stream() - .anyMatch(ag -> ag.getGenre().getId() == genreId); - - if (isSameGroup) { - groupSlot.add(scored); - } else if (isSameGenre) { - genreSlot.add(scored); - } else { - otherSlot.add(scored); - } - } - - // 슬롯별로 최종 선택 (각 슬롯 내에서는 이미 점수 순으로 정렬되어 있음) - List selected = new ArrayList<>(); - - // 1. 그룹 슬롯에서 최대 2명 선택 - for (int i = 0; i < Math.min(MAX_SAME_GROUP, groupSlot.size()) && selected.size() < TARGET_COUNT; i++) { - selected.add(groupSlot.get(i).artist); - } - - // 2. 장르 슬롯에서 선택 (그룹 슬롯 선택 후 남은 자리만큼, 최대 3명) - int remainingSlots = TARGET_COUNT - selected.size(); - int genreCount = Math.min(MAX_SAME_GENRE, Math.min(genreSlot.size(), remainingSlots)); - for (int i = 0; i < genreCount && selected.size() < TARGET_COUNT; i++) { - selected.add(genreSlot.get(i).artist); - } - - // 3. 그 외 슬롯에서 나머지 채우기 (5명이 될 때까지) - for (ScoredArtist scored : otherSlot) { - if (selected.size() >= TARGET_COUNT) { - break; - } - selected.add(scored.artist); - } - - // 4. 장르 슬롯에서 추가로 채우기 (5명이 안 되면 장르 슬롯에서 더 선택) - if (selected.size() < TARGET_COUNT && genreSlot.size() > genreCount) { - for (int i = genreCount; i < genreSlot.size() && selected.size() < TARGET_COUNT; i++) { - selected.add(genreSlot.get(i).artist); - } - } - - // 5. 그룹 슬롯에서 추가로 채우기 (5명이 안 되면 그룹 슬롯에서 더 선택) - if (selected.size() < TARGET_COUNT && groupSlot.size() > MAX_SAME_GROUP) { - for (int i = MAX_SAME_GROUP; i < groupSlot.size() && selected.size() < TARGET_COUNT; i++) { - selected.add(groupSlot.get(i).artist); - } - } - - return selected; - } - - /** - * 점수가 매겨진 아티스트 - */ - private static class ScoredArtist { - final Artist artist; - final double score; - final int hashValue; // hash 기반 tie-breaker 값 - - ScoredArtist(Artist artist, double score) { - this.artist = artist; - this.score = score; - this.hashValue = 0; // 하위 호환성 - } - - ScoredArtist(Artist artist, double score, int hashValue) { - this.artist = artist; - this.score = score; - this.hashValue = hashValue; - } - } - - private 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 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 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()); - } } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/application/SpotifyDetailService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/application/SpotifyDetailService.java new file mode 100644 index 00000000..e185ce69 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/application/SpotifyDetailService.java @@ -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 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 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 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 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()); + } +} + diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/cache/SpotifyCacheService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/cache/SpotifyCacheService.java new file mode 100644 index 00000000..6e8f2d41 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/cache/SpotifyCacheService.java @@ -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 redisTemplate; + private final RedisTemplate 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 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; + } +} + diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/ArtistData.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/dto/ArtistData.java similarity index 85% rename from src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/ArtistData.java rename to src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/dto/ArtistData.java index ff16854a..31da727c 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/ArtistData.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/dto/ArtistData.java @@ -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; diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/CategoryConfig.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/genre/CategoryConfig.java similarity index 82% rename from src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/CategoryConfig.java rename to src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/genre/CategoryConfig.java index ab85f77e..719e1429 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/CategoryConfig.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/genre/CategoryConfig.java @@ -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 keywords; public final int targetCount; diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/genre/GenreNormalizationService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/genre/GenreNormalizationService.java new file mode 100644 index 00000000..29d436e0 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/genre/GenreNormalizationService.java @@ -0,0 +1,87 @@ +package com.back.web7_9_codecrete_be.domain.artists.service.spotify.genre; + +import org.springframework.stereotype.Service; + + +@Service +// 장르 정규화 서비스 : Spotify의 다양한 장르명을 통합된 카테고리로 변환 +public class GenreNormalizationService { + + // Spotify 장르명을 통합된 카테고리로 정규화 + public String normalizeGenre(String originalGenre) { + if (originalGenre == null || originalGenre.isBlank()) { + return null; + } + + String lowerGenre = originalGenre.toLowerCase().trim(); + + // 1순위: KOREAN (k-로 시작) + if (lowerGenre.startsWith("k-")) { + return "KOREAN"; + } + + // 2순위: HIPHOP/RAP + if (containsAny(lowerGenre, "hip hop", "rap", "drill", "grime", "boom bap", "hip-hop", "hiphop")) { + return "HIPHOP/RAP"; + } + + // 3순위: R&B/SOUL + if (containsAny(lowerGenre, "r&b", "rnb", "soul", "r and b")) { + return "R&B/SOUL"; + } + + // 4순위: METAL + if (lowerGenre.contains("metal")) { + return "METAL"; + } + + // 5순위: ROCK + if (containsAny(lowerGenre, "rock", "grunge", "shoegaze", "britpop", "classic rock")) { + return "ROCK"; + } + + // 6순위: INDIE/ALT + if (containsAny(lowerGenre, "indie", "alternative", "art rock", "neo-psychedelic", "jangle pop", "alt")) { + return "INDIE/ALT"; + } + + // 7순위: LATIN + if (containsAny(lowerGenre, "latin", "reggaeton", "urbano", "bachata", "latin afrobeats")) { + return "LATIN"; + } + + // 8순위: REGGAE + if (lowerGenre.contains("reggae")) { + return "REGGAE"; + } + + // 9순위: JAPAN + if (containsAny(lowerGenre, "j-pop", "j-rock", "jpop", "jrock", "vocaloid", "shibuya-kei", "japanese", "city pop", "japanese indie")) { + return "JAPAN"; + } + + // 10순위: SOUNDTRACK/ANIME + if (containsAny(lowerGenre, "soundtrack", "anime", "bollywood", "tollywood", "kollywood", "ost")) { + return "SOUNDTRACK/ANIME"; + } + + // 11순위: POP (pop이 포함된 경우) + if (lowerGenre.contains("pop")) { + return "POP"; + } + + // 12순위: ETC (그 외 모든 경우) + return "ETC"; + } + + // 문자열이 주어진 키워드들 중 하나라도 포함하는지 확인 + private boolean containsAny(String text, String... keywords) { + for (String keyword : keywords) { + if (text.contains(keyword)) { + return true; + } + } + return false; + } +} + diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SimpleRateLimiter.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/rate_limit/SimpleRateLimiter.java similarity index 97% rename from src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SimpleRateLimiter.java rename to src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/rate_limit/SimpleRateLimiter.java index 16385539..5baf96a3 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SimpleRateLimiter.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/rate_limit/SimpleRateLimiter.java @@ -1,7 +1,6 @@ -package com.back.web7_9_codecrete_be.domain.artists.service.spotifyService; +package com.back.web7_9_codecrete_be.domain.artists.service.spotify.rate_limit; // 모든 Spotify API 호출 전에 최소 간격을 보장하여 Rate Limit 위반 방지 - public class SimpleRateLimiter { private final long minIntervalMs; private long lastCallAt = 0; diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/rate_limit/SpotifyRateLimitHandler.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/rate_limit/SpotifyRateLimitHandler.java new file mode 100644 index 00000000..6c122f76 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/rate_limit/SpotifyRateLimitHandler.java @@ -0,0 +1,132 @@ +package com.back.web7_9_codecrete_be.domain.artists.service.spotify.rate_limit; + +import com.back.web7_9_codecrete_be.global.spotify.SpotifyClient; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + + + +@Slf4j +@Component +@RequiredArgsConstructor +// 429 에러 처리 및 전역 쿨다운 관리 +// 401 에러 처리 (토큰 만료 시 자동 재발급) +public class SpotifyRateLimitHandler { + + private final SpotifyClient spotifyClient; + + private static final long SPOTIFY_RATE_LIMIT_INTERVAL_MS = 500; // 초당 2회 + private static final long MUSICBRAINZ_RATE_LIMIT_INTERVAL_MS = 1000; // 초당 1회 + private static final long GLOBAL_COOLDOWN_DURATION_MS = 60_000; // 60초 + private static final int MAX_CONSECUTIVE_429 = 3; // 연속 3회 429 발생 시 쿨다운 + + private final SimpleRateLimiter spotifyRateLimiter = new SimpleRateLimiter(SPOTIFY_RATE_LIMIT_INTERVAL_MS); + private final SimpleRateLimiter musicBrainzRateLimiter = new SimpleRateLimiter(MUSICBRAINZ_RATE_LIMIT_INTERVAL_MS); + + private volatile long globalCooldownUntil = 0; + private volatile int globalConsecutive429Count = 0; + + // Rate Limit 및 401 에러를 처리하며 API 호출 실행 + public T callWithRateLimitRetry(java.util.function.Supplier apiCall, String context) { + int maxRetry = 3; + int retryCount = 0; + + while (retryCount < maxRetry) { + try { + // 전역 쿨다운 확인 + waitForGlobalCooldown(); + + // API 호출 실행 + T result = apiCall.get(); + + // 성공 시 연속 429 카운트 리셋 + globalConsecutive429Count = 0; + + return result; + + } catch (RuntimeException e) { + String errorMessage = e.getMessage(); + + // 401 Unauthorized 에러 처리 (토큰 만료) + if (errorMessage != null && errorMessage.contains("401")) { + log.warn("Spotify API 401 에러 발생 (토큰 만료): {}, 토큰 재발급 후 재시도", context); + spotifyClient.forceRefreshToken(); + + // 재시도 + retryCount++; + if (retryCount >= maxRetry) { + log.error("Spotify API 401 에러 재시도 모두 실패: {}", context); + throw new RuntimeException("rate limit retry exhausted: " + context, e); + } + + try { + Thread.sleep(1000 * retryCount); // 지수 백오프 + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted during retry", ie); + } + continue; + } + + // 429 Too Many Requests 에러 처리 + if (errorMessage != null && errorMessage.contains("429")) { + globalConsecutive429Count++; + log.warn("Spotify API 429 에러 발생 (재시도 {}/{}): {}", + retryCount + 1, maxRetry, context); + + // 연속 3회 429 발생 시 전역 쿨다운 활성화 + if (globalConsecutive429Count >= MAX_CONSECUTIVE_429) { + log.error("Spotify API 연속 429 에러 {}회 발생, 전역 쿨다운 {}초 활성화", + MAX_CONSECUTIVE_429, GLOBAL_COOLDOWN_DURATION_MS / 1000); + activateGlobalCooldown(); + globalConsecutive429Count = 0; + } + + // 재시도 + retryCount++; + if (retryCount >= maxRetry) { + log.error("Spotify API 429 에러 재시도 모두 실패: {}", context); + throw new RuntimeException("rate limit retry exhausted: " + context, e); + } + + // 지수 백오프: 1초, 2초, 4초 + long backoffMs = (long) Math.pow(2, retryCount - 1) * 1000; + try { + Thread.sleep(backoffMs); + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted during retry", ie); + } + continue; + } + + // 429, 401이 아닌 다른 에러는 즉시 throw + throw e; + } + } + + throw new RuntimeException("rate limit retry exhausted: " + context); + } + + // 전역 쿨다운이 활성화되어 있으면 대기 + private void waitForGlobalCooldown() { + long now = System.currentTimeMillis(); + if (now < globalCooldownUntil) { + long waitMs = globalCooldownUntil - now; + log.info("전역 쿨다운 대기 중: {}ms 남음", waitMs); + try { + Thread.sleep(waitMs); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new RuntimeException("Interrupted during global cooldown", e); + } + } + } + + // 전역 쿨다운 활성화 + private void activateGlobalCooldown() { + globalCooldownUntil = System.currentTimeMillis() + GLOBAL_COOLDOWN_DURATION_MS; + } +} + diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/related/RelatedArtistService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/related/RelatedArtistService.java new file mode 100644 index 00000000..51f4b851 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/related/RelatedArtistService.java @@ -0,0 +1,275 @@ +package com.back.web7_9_codecrete_be.domain.artists.service.spotify.related; + +import com.back.web7_9_codecrete_be.domain.artists.dto.response.RelatedArtistResponse; +import com.back.web7_9_codecrete_be.domain.artists.entity.Artist; +import com.back.web7_9_codecrete_be.domain.artists.entity.ArtistType; +import com.back.web7_9_codecrete_be.domain.artists.repository.ArtistRepository; +import com.back.web7_9_codecrete_be.domain.artists.service.spotify.related.model.ScoredArtist; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; + +import java.util.*; + +@Slf4j +@Service +@RequiredArgsConstructor +// 관련 아티스트 추천 서비스 : 3단계 파이프라인: Recall -> Score -> Diversity +public class RelatedArtistService { + + private final ArtistRepository artistRepository; + + private static final int MAX_GENRE_CANDIDATES = 200; // 결정론적 다양성을 위해 후보 풀 확장 + private static final int MAX_GROUP_CANDIDATES = 5; + private static final int MAX_TYPE_CANDIDATES = 50; + private static final int MIN_CANDIDATES_FOR_FALLBACK = 10; // 이보다 적으면 타입 후보 추가 + private static final double MAX_LIKECOUNT_BONUS = 15.0; + private static final double MIN_BASE_SCORE_FOR_LIKECOUNT = 30.0; // 기본 연관 점수가 이 이상일 때만 likeCount 보정 적용 + private static final int MAX_SAME_GROUP = 2; + private static final int MAX_SAME_GENRE = 3; + private static final int TARGET_COUNT = 5; + + // 관련 아티스트 추천 (3단계: Recall -> Score -> Diversity) + public List getRelatedArtists( + long artistId, + String artistGroup, + ArtistType artistType, + Long genreId + ) { + try { + // 1단계: 후보 뽑기 (Recall) + Set candidates = collectRelatedCandidates(artistId, artistGroup, artistType, genreId); + + if (candidates.isEmpty()) { + return List.of(); + } + + // 2단계: 점수 매기기 (Score) + List scoredArtists = scoreCandidates(candidates, artistGroup, artistType, genreId, artistId); + + // 3단계: 4~5명 뽑기 + 도배 방지 (Diversity) + List selectedArtists = selectWithDiversity(scoredArtists, artistGroup, genreId); + + // RelatedArtistResponse로 변환 + return selectedArtists.stream() + .map(a -> new RelatedArtistResponse( + a.getId(), + a.getArtistName(), + a.getNameKo(), + a.getImageUrl(), + a.getSpotifyArtistId() + )) + .toList(); + + } catch (Exception e) { + log.error("관련 아티스트 조회 실패: artistId={}", artistId, e); + return List.of(); + } + } + + // 1단계: 후보 뽑기 (Recall) + private Set collectRelatedCandidates( + long artistId, + String artistGroup, + ArtistType artistType, + Long genreId + ) { + Set candidates = new HashSet<>(); + + // 같은 genre인 아티스트들 + if (genreId != null) { + List sameGenre = artistRepository.findByGenreIdAndIdNot( + genreId, artistId, + PageRequest.of(0, MAX_GENRE_CANDIDATES) + ); + candidates.addAll(sameGenre); + } + + // 같은 artistGroup인 아티스트들 (artistGroup이 있을 때만) + if (artistGroup != null && !artistGroup.isBlank()) { + List sameGroup = artistRepository.findByArtistGroupAndIdNot( + artistGroup, artistId, + PageRequest.of(0, MAX_GROUP_CANDIDATES) + ); + candidates.addAll(sameGroup); + } + + // 같은 artistType인 아티스트들 (fallback: 후보가 부족할 때만) + if (artistType != null && candidates.size() < MIN_CANDIDATES_FOR_FALLBACK) { + List sameType = artistRepository.findByArtistTypeAndIdNot( + artistType, artistId, + PageRequest.of(0, MAX_TYPE_CANDIDATES) + ); + candidates.addAll(sameType); + } + + return candidates; + } + + // 2단계: 점수 매기기 (Score) + private List scoreCandidates( + Set candidates, + String artistGroup, + ArtistType artistType, + Long genreId, + long baseArtistId + ) { + List scored = new ArrayList<>(); + + for (Artist candidate : candidates) { + double score = 0.0; + boolean hasGroupScore = false; + + // 같은 그룹이면 +80 + if (artistGroup != null && !artistGroup.isBlank() && + candidate.getArtistGroup() != null && + candidate.getArtistGroup().equals(artistGroup)) { + score += 80; + hasGroupScore = true; + } + + // 같은 장르면 +60 (그룹 점수가 있을 때는 +30으로 완화) + if (genreId != null) { + boolean hasSameGenre = candidate.getArtistGenres().stream() + .anyMatch(ag -> ag.getGenre().getId() == genreId); + if (hasSameGenre) { + score += hasGroupScore ? 30 : 60; + } + } + + // 같은 타입이면 +15 + if (artistType != null && candidate.getArtistType() == artistType) { + score += 15; + } + + // likeCount 보정: 기본 연관 점수가 일정 수준 이상일 때만 적용, 최대 15점 + double baseScore = score; + if (baseScore >= MIN_BASE_SCORE_FOR_LIKECOUNT && candidate.getLikeCount() > 0) { + double likeCountBonus = 5.0 * Math.log(candidate.getLikeCount() + 1); + score += Math.min(likeCountBonus, MAX_LIKECOUNT_BONUS); + } + + // hash 기반 tie-breaker 값 계산 + int hashValue = calculateHashForTieBreaker(baseArtistId, candidate.getId()); + + // hash를 점수에 반영하여 기준 아티스트별로 다른 순서 보장 + double normalizedHash = (Math.abs(hashValue) % 10000) / 10000.0; + score += normalizedHash; + + scored.add(new ScoredArtist(candidate, score, hashValue)); + } + + // 점수 내림차순 정렬 + scored.sort((a, b) -> { + int scoreCompare = Double.compare(b.score, a.score); + if (scoreCompare != 0) { + return scoreCompare; + } + + int likeCountCompare = Integer.compare(b.artist.getLikeCount(), a.artist.getLikeCount()); + if (likeCountCompare != 0) { + return likeCountCompare; + } + + String nameA = a.artist.getNameKo() != null && !a.artist.getNameKo().isBlank() + ? a.artist.getNameKo() + : a.artist.getArtistName(); + String nameB = b.artist.getNameKo() != null && !b.artist.getNameKo().isBlank() + ? b.artist.getNameKo() + : b.artist.getArtistName(); + int nameCompare = nameA.compareTo(nameB); + if (nameCompare != 0) { + return nameCompare; + } + + if (a.artist.getSpotifyArtistId() != null && b.artist.getSpotifyArtistId() != null) { + int spotifyIdCompare = a.artist.getSpotifyArtistId().compareTo(b.artist.getSpotifyArtistId()); + if (spotifyIdCompare != 0) { + return spotifyIdCompare; + } + } + + return Long.compare(a.artist.getId(), b.artist.getId()); + }); + + return scored; + } + + // 기준 아티스트 ID와 후보 아티스트 ID를 조합하여 hash 값 계산 + private int calculateHashForTieBreaker(long baseArtistId, long candidateArtistId) { + String combined = baseArtistId + "-" + candidateArtistId; + return combined.hashCode(); + } + + // 3단계: 슬롯 기반 최종 선택 (Diversity) + private List selectWithDiversity( + List scoredArtists, + String artistGroup, + Long genreId + ) { + // 슬롯별로 후보 분류 + List groupSlot = new ArrayList<>(); + List genreSlot = new ArrayList<>(); + List otherSlot = new ArrayList<>(); + + for (ScoredArtist scored : scoredArtists) { + Artist candidate = scored.artist; + + boolean isSameGroup = artistGroup != null && !artistGroup.isBlank() && + candidate.getArtistGroup() != null && + candidate.getArtistGroup().equals(artistGroup); + + boolean isSameGenre = genreId != null && candidate.getArtistGenres().stream() + .anyMatch(ag -> ag.getGenre().getId() == genreId); + + if (isSameGroup) { + groupSlot.add(scored); + } else if (isSameGenre) { + genreSlot.add(scored); + } else { + otherSlot.add(scored); + } + } + + // 슬롯별로 최종 선택 + List selected = new ArrayList<>(); + + // 1. 그룹 슬롯에서 최대 2명 선택 + for (int i = 0; i < Math.min(MAX_SAME_GROUP, groupSlot.size()) && selected.size() < TARGET_COUNT; i++) { + selected.add(groupSlot.get(i).artist); + } + + // 2. 장르 슬롯에서 선택 + int remainingSlots = TARGET_COUNT - selected.size(); + int genreCount = Math.min(MAX_SAME_GENRE, Math.min(genreSlot.size(), remainingSlots)); + for (int i = 0; i < genreCount && selected.size() < TARGET_COUNT; i++) { + selected.add(genreSlot.get(i).artist); + } + + // 3. 그 외 슬롯에서 나머지 채우기 + for (ScoredArtist scored : otherSlot) { + if (selected.size() >= TARGET_COUNT) { + break; + } + selected.add(scored.artist); + } + + // 4. 장르 슬롯에서 추가로 채우기 + if (selected.size() < TARGET_COUNT && genreSlot.size() > genreCount) { + for (int i = genreCount; i < genreSlot.size() && selected.size() < TARGET_COUNT; i++) { + selected.add(genreSlot.get(i).artist); + } + } + + // 5. 그룹 슬롯에서 추가로 채우기 + if (selected.size() < TARGET_COUNT && groupSlot.size() > MAX_SAME_GROUP) { + for (int i = MAX_SAME_GROUP; i < groupSlot.size() && selected.size() < TARGET_COUNT; i++) { + selected.add(groupSlot.get(i).artist); + } + } + + return selected; + } +} + diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/related/model/ScoredArtist.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/related/model/ScoredArtist.java new file mode 100644 index 00000000..acf1da46 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/related/model/ScoredArtist.java @@ -0,0 +1,17 @@ +package com.back.web7_9_codecrete_be.domain.artists.service.spotify.related.model; + +import com.back.web7_9_codecrete_be.domain.artists.entity.Artist; + +//점수가 매겨진 아티스트 (관련 아티스트 추천용) : 관련 아티스트 추천 로직에서만 사용되는 내부 모델 +public class ScoredArtist { + public final Artist artist; + public final double score; + public final int hashValue; // hash 기반 tie-breaker 값 + + public ScoredArtist(Artist artist, double score, int hashValue) { + this.artist = artist; + this.score = score; + this.hashValue = hashValue; + } +} + diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyRateLimitHandler.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyRateLimitHandler.java deleted file mode 100644 index 9b1b51d0..00000000 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyRateLimitHandler.java +++ /dev/null @@ -1,250 +0,0 @@ -package com.back.web7_9_codecrete_be.domain.artists.service.spotifyService; - -import com.back.web7_9_codecrete_be.global.spotify.SpotifyClient; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -// 429 에러 처리 및 전역 쿨다운 관리 -// 401 에러 처리 (토큰 만료 시 자동 재발급) - -@Slf4j -@Component -@RequiredArgsConstructor -public class SpotifyRateLimitHandler { - - private final SpotifyClient spotifyClient; - - private static final long SPOTIFY_RATE_LIMIT_INTERVAL_MS = 500; // 초당 2회 - private static final long MUSICBRAINZ_RATE_LIMIT_INTERVAL_MS = 1000; // 초당 1회 - private static final long GLOBAL_COOLDOWN_DURATION_MS = 60_000; // 60초 - private static final int MAX_CONSECUTIVE_429 = 3; // 연속 3회 429 발생 시 쿨다운 - - private final SimpleRateLimiter spotifyRateLimiter = new SimpleRateLimiter(SPOTIFY_RATE_LIMIT_INTERVAL_MS); - private final SimpleRateLimiter musicBrainzRateLimiter = new SimpleRateLimiter(MUSICBRAINZ_RATE_LIMIT_INTERVAL_MS); - - private volatile long globalCooldownUntil = 0; - private volatile int globalConsecutive429Count = 0; - - public SimpleRateLimiter getSpotifyRateLimiter() { - return spotifyRateLimiter; - } - - public SimpleRateLimiter getMusicBrainzRateLimiter() { - return musicBrainzRateLimiter; - } - - /** - * Rate Limit 재시도 로직 (429 에러 처리) - * 429면 Retry-After 헤더 확인 후 대기하고 재시도 (최대 3회) - * 연속 429 발생 시 전역 쿨다운 적용 - * - * @param supplier API 호출 함수 - * @param context 컨텍스트 정보 (로깅용) - * @return API 호출 결과 - * @throws RuntimeException 재시도 모두 실패 시 - */ - public T callWithRateLimitRetry(java.util.function.Supplier supplier, String context) { - final int maxRetry = 3; - - for (int attempt = 1; attempt <= maxRetry; attempt++) { - // 전역 쿨다운 확인 - long now = System.currentTimeMillis(); - if (globalCooldownUntil > now) { - long remainingSec = (globalCooldownUntil - now) / 1000; - log.warn("전역 쿨다운 중: {}초 남음. API 호출 대기", remainingSec); - try { - Thread.sleep(remainingSec * 1000); - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - throw new RuntimeException(context + ": interrupted during global cooldown", ie); - } - // 쿨다운이 끝났으면 카운터 리셋 - if (System.currentTimeMillis() >= globalCooldownUntil) { - globalConsecutive429Count = 0; - } - } - - try { - T result = supplier.get(); - // 성공 시 전역 429 카운터 리셋 - globalConsecutive429Count = 0; - return result; - } catch (Exception e) { - // 401 에러 확인 (Unauthorized - 토큰 만료) - boolean is401 = is401Error(e); - - if (is401) { - log.warn("401 Unauthorized 에러 감지 - 토큰 재발급 후 재시도: attempt={}/{}", attempt, maxRetry); - // 토큰 강제 재발급 - spotifyClient.forceRefreshToken(); - - if (attempt < maxRetry) { - // 재시도 - continue; - } else { - log.error("401 에러 재시도 횟수 초과 ({}회)", maxRetry); - throw new RuntimeException(context + ": 401 Unauthorized - 토큰 재발급 후에도 실패", e); - } - } - // 원본 예외 확인 (래핑된 경우 cause 확인) - Throwable originalException = e; - Throwable current = e; - - // 예외 체인을 따라가며 TooManyRequestsException 찾기 - while (current != null) { - String currentClassName = current.getClass().getSimpleName(); - if (currentClassName.contains("TooManyRequests")) { - originalException = current; - break; - } - current = current.getCause(); - } - - // 래핑된 경우 cause 확인 - if (originalException == e && e instanceof RuntimeException && e.getCause() != null) { - originalException = e.getCause(); - } - - String className = originalException.getClass().getSimpleName(); - String errorMsg = originalException.getMessage(); - String wrapperClassName = e.getClass().getSimpleName(); - String wrapperErrorMsg = e.getMessage(); - - // 429 에러 확인 (원본 예외와 래핑된 예외 모두 확인) - boolean is429 = className.contains("TooManyRequests") || - (errorMsg != null && (errorMsg.contains("429") || errorMsg.contains("Too Many Requests"))) || - wrapperClassName.contains("TooManyRequests") || - (wrapperErrorMsg != null && (wrapperErrorMsg.contains("429") || wrapperErrorMsg.contains("Too Many Requests"))); - - // 예외 체인 전체 확인 - if (!is429) { - current = e; - while (current != null) { - String currentClassName = current.getClass().getSimpleName(); - String currentMsg = current.getMessage(); - if (currentClassName.contains("TooManyRequests") || - (currentMsg != null && (currentMsg.contains("429") || currentMsg.contains("Too Many Requests")))) { - is429 = true; - originalException = current; - break; - } - current = current.getCause(); - } - } - - // 디버깅 로그 - if (is429) { - log.warn("429 에러 감지: attempt={}/{}, className={}, wrapperClassName={}, errorMsg={}", - attempt, maxRetry, className, wrapperClassName, errorMsg); - } else { - log.debug("예외 발생 (429 아님): attempt={}/{}, className={}, wrapperClassName={}, errorMsg={}", - attempt, maxRetry, className, wrapperClassName, errorMsg); - } - - if (is429) { - // 전역 429 카운터 증가 - synchronized (this) { - globalConsecutive429Count++; - - // 연속 429 발생 시 전역 쿨다운 활성화 - if (globalConsecutive429Count >= MAX_CONSECUTIVE_429) { - globalCooldownUntil = System.currentTimeMillis() + GLOBAL_COOLDOWN_DURATION_MS; - log.error("전역 연속 {}회 429 발생 → 전역 쿨다운 {}초 활성화", - globalConsecutive429Count, GLOBAL_COOLDOWN_DURATION_MS / 1000); - } - } - - if (attempt < maxRetry) { - // Retry-After 헤더는 원본 예외에서 추출 - Throwable headerException = originalException; - // Retry-After 헤더 추출 시도 - long waitSec = 5; // 기본값 5초 (더 보수적으로) - final long minWaitSec = 3; // 최소 대기 시간 3초 - final long bufferSec = 2; // 여유 시간 2초 추가 - - try { - // Spotify SDK의 예외에서 Retry-After 헤더 추출 시도 - // reflection을 통해 헤더 정보 확인 (원본 예외에서) - java.lang.reflect.Method getHeadersMethod = null; - try { - getHeadersMethod = headerException.getClass().getMethod("getResponseHeaders"); - } catch (NoSuchMethodException ignored) { - // 메서드가 없으면 기본값 사용 - } - - if (getHeadersMethod != null) { - try { - Object headers = getHeadersMethod.invoke(headerException); - if (headers != null && headers instanceof java.util.Map) { - @SuppressWarnings("unchecked") - java.util.Map> headerMap = - (java.util.Map>) headers; - java.util.List retryAfterList = headerMap.get("Retry-After"); - if (retryAfterList != null && !retryAfterList.isEmpty()) { - try { - long headerValue = Long.parseLong(retryAfterList.get(0)); - // 헤더 값 + 여유 시간, 최소값 보장 - waitSec = Math.max(minWaitSec, headerValue + bufferSec); - } catch (NumberFormatException ignored) { - // 파싱 실패 시 기본값 사용 - } - } - } - } catch (Exception ignored) { - // 헤더 추출 실패 시 기본값 사용 - } - } - } catch (Exception ignored) { - // reflection 실패 시 기본값 사용 - } - - log.warn("{}: 429 Too Many Requests. attempt={}/{}. retry-after={}s (헤더값+{}초 여유)", - context, attempt, maxRetry, waitSec, bufferSec); - - try { - Thread.sleep(waitSec * 1000L); - } catch (InterruptedException ie) { - Thread.currentThread().interrupt(); - throw new RuntimeException(context + ": interrupted during retry wait", ie); - } - - // 재시도 - continue; - } else { - // 재시도 횟수 초과 - log.error("{}: 429 Too Many Requests. 재시도 횟수 초과 ({}회)", context, maxRetry); - } - } - - // 429가 아니거나 재시도 횟수 초과 시 예외 재throw - // IOException 등은 그대로 throw (호출부에서 처리) - throw e; - } - } - - throw new RuntimeException(context + ": rate limit retry exhausted"); - } - - // 401 에러인지 확인 - private boolean is401Error(Throwable e) { - Throwable current = e; - while (current != null) { - String className = current.getClass().getSimpleName(); - String errorMsg = current.getMessage(); - - // 401 에러 확인 - if (className.contains("Unauthorized") || - className.contains("401") || - (errorMsg != null && (errorMsg.contains("401") || - errorMsg.contains("Unauthorized") || - errorMsg.contains("Invalid access token")))) { - return true; - } - - current = current.getCause(); - } - return false; - } -} - diff --git a/src/test/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyRateLimitHandlerTest.java b/src/test/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyRateLimitHandlerTest.java index faf1b992..7e12d706 100644 --- a/src/test/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyRateLimitHandlerTest.java +++ b/src/test/java/com/back/web7_9_codecrete_be/domain/artists/service/spotifyService/SpotifyRateLimitHandlerTest.java @@ -1,5 +1,6 @@ package com.back.web7_9_codecrete_be.domain.artists.service.spotifyService; +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 org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; From 2a96c938bddc5e3b5092e36eb1bd3417806d8cbe Mon Sep 17 00:00:00 2001 From: Jung Yunseo Date: Tue, 6 Jan 2026 14:42:16 +0900 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20Redis=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=EC=BD=94=EB=93=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../spotify/cache/SpotifyCacheService.java | 123 +++-- .../cache/SpotifyCacheServiceTest.java | 438 ++++++++++++++++++ 2 files changed, 499 insertions(+), 62 deletions(-) create mode 100644 src/test/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/cache/SpotifyCacheServiceTest.java diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/cache/SpotifyCacheService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/cache/SpotifyCacheService.java index 6e8f2d41..cbbdcb01 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/cache/SpotifyCacheService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/cache/SpotifyCacheService.java @@ -8,7 +8,7 @@ import org.springframework.stereotype.Service; import java.util.concurrent.TimeUnit; - +import java.util.function.Supplier; @Slf4j @Service @@ -22,25 +22,23 @@ public class SpotifyCacheService { 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 호출 완료 대기 시간) + private static final long CACHE_TTL_SECONDS = 3600; // 1시간 + private static final long LOCK_TTL_SECONDS = 30; // 30초 + + private static final long RETRY_SLEEP_MS = 100; + private static final int MAX_RETRIES = 30; // 100ms * 30 = 3초 - // Redis 캐시에서 Spotify 상세 정보 조회 public SpotifyArtistDetailCache getCached(String spotifyArtistId) { try { String cacheKey = getCacheKey(spotifyArtistId); Object cached = objectRedisTemplate.opsForValue().get(cacheKey); - if (cached == null) { - return null; - } + 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); @@ -48,8 +46,9 @@ public SpotifyArtistDetailCache getCached(String spotifyArtistId) { } } - // Redis 캐시에 Spotify 상세 정보 저장 public void save(String spotifyArtistId, SpotifyArtistDetailCache data) { + if (data == null) return; // (선택) supplier가 null 반환 시 방어 + try { String cacheKey = getCacheKey(spotifyArtistId); objectRedisTemplate.opsForValue().set( @@ -61,88 +60,89 @@ public void save(String spotifyArtistId, SpotifyArtistDetailCache data) { 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 apiCallSupplier + Supplier apiCallSupplier ) { String lockKey = getLockKey(spotifyArtistId); - // 락 획득 시도 (SETNX 방식) - Boolean lockAcquired = redisTemplate.opsForValue().setIfAbsent( - lockKey, - "locked", - LOCK_TTL_SECONDS, - TimeUnit.SECONDS - ); + // ✅ 핵심 수정 1) 락 획득 자체가 예외면 fallback + final Boolean lockAcquired; + try { + lockAcquired = redisTemplate.opsForValue().setIfAbsent( + lockKey, + "locked", + LOCK_TTL_SECONDS, + TimeUnit.SECONDS + ); + } catch (Exception e) { + log.warn("Redis 락 획득 실패(예외) → 직접 API 호출로 fallback: spotifyArtistId={}", spotifyArtistId, e); + SpotifyArtistDetailCache spotifyData = apiCallSupplier.get(); + save(spotifyArtistId, spotifyData); + return spotifyData; + } if (Boolean.TRUE.equals(lockAcquired)) { - // 락 획득 성공: 이 스레드가 API 호출 담당 try { log.debug("Spotify API 호출 락 획득: spotifyArtistId={}", spotifyArtistId); - // 다시 한 번 캐시 확인 (락 획득 대기 중 다른 스레드가 저장했을 수 있음) + // Double-check 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); + // ✅ 핵심 수정 2) 락 해제도 예외 방어 + try { + redisTemplate.delete(lockKey); + } catch (Exception e) { + log.warn("Redis 락 해제 실패: lockKey={}", lockKey, e); + } } - } else { - // 락 획득 실패: 다른 스레드가 API 호출 중 - log.debug("Spotify API 호출 락 획득 실패 (다른 스레드가 처리 중): spotifyArtistId={}", spotifyArtistId); + } - // 짧은 대기 후 캐시 재조회 (다른 스레드가 저장 완료했을 수 있음) - try { - Thread.sleep(100); // 100ms 대기 - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - } + // 락 획득 실패: 다른 스레드가 API 호출 중 + log.debug("Spotify API 호출 락 획득 실패 (다른 스레드가 처리 중): spotifyArtistId={}", spotifyArtistId); + + sleepQuietly(RETRY_SLEEP_MS); + + SpotifyArtistDetailCache retryCache = getCached(spotifyArtistId); + if (retryCache != null) { + log.debug("락 대기 후 캐시 재조회 HIT: spotifyArtistId={}", spotifyArtistId); + return retryCache; + } - // 캐시 재조회 - SpotifyArtistDetailCache retryCache = getCached(spotifyArtistId); + for (int i = 0; i < MAX_RETRIES; i++) { + sleepQuietly(RETRY_SLEEP_MS); + + retryCache = getCached(spotifyArtistId); if (retryCache != null) { - log.debug("락 대기 후 캐시 재조회 HIT: spotifyArtistId={}", spotifyArtistId); + log.debug("락 대기 중 캐시 재조회 HIT ({}ms 후): spotifyArtistId={}", (i + 1) * RETRY_SLEEP_MS, 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; - } - } + // 최종 fallback + log.warn("락 대기 후에도 캐시 없음, 직접 API 호출: spotifyArtistId={}", spotifyArtistId); + SpotifyArtistDetailCache spotifyData = apiCallSupplier.get(); + save(spotifyArtistId, spotifyData); + return spotifyData; + } - // 최종적으로도 캐시가 없으면 직접 API 호출 (락이 만료되었을 수 있음) - log.warn("락 대기 후에도 캐시 없음, 직접 API 호출: spotifyArtistId={}", spotifyArtistId); - SpotifyArtistDetailCache spotifyData = apiCallSupplier.get(); - save(spotifyArtistId, spotifyData); - return spotifyData; + private void sleepQuietly(long millis) { + try { + Thread.sleep(millis); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); } } @@ -154,4 +154,3 @@ private String getLockKey(String spotifyArtistId) { return LOCK_KEY_PREFIX + spotifyArtistId; } } - diff --git a/src/test/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/cache/SpotifyCacheServiceTest.java b/src/test/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/cache/SpotifyCacheServiceTest.java new file mode 100644 index 00000000..26f7d2d0 --- /dev/null +++ b/src/test/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/cache/SpotifyCacheServiceTest.java @@ -0,0 +1,438 @@ +package com.back.web7_9_codecrete_be.domain.artists.service.spotify.cache; + +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.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ValueOperations; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicInteger; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.verifyNoInteractions; + +@ExtendWith(MockitoExtension.class) +@DisplayName("SpotifyCacheService Redis 캐시 테스트") +class SpotifyCacheServiceTest { + + @Mock + private RedisTemplate redisTemplate; + + @Mock + private RedisTemplate objectRedisTemplate; + + @Mock + private ValueOperations stringValueOps; + + @Mock + private ValueOperations objectValueOps; + + @Mock + private ObjectMapper objectMapper; + + // ✅ @InjectMocks 제거 (RedisTemplate 2개 제네릭 주입 꼬임 방지) + private SpotifyCacheService spotifyCacheService; + + // 서비스 상수와 동일하게 유지 + private static final long CACHE_TTL_SECONDS = 3600L; + private static final long LOCK_TTL_SECONDS = 30L; + + private static final String SPOTIFY_ARTIST_ID = "test_spotify_id_123"; + private static final String CACHE_KEY = "artist:detail:spotify:" + SPOTIFY_ARTIST_ID; + private static final String LOCK_KEY = "artist:detail:spotify:lock:" + SPOTIFY_ARTIST_ID; + + private SpotifyArtistDetailCache testCacheData; + + @BeforeEach + void setUp() { + spotifyCacheService = new SpotifyCacheService(redisTemplate, objectRedisTemplate, objectMapper); + + // ✅ getCached/save가 공통으로 사용하는 objectRedisTemplate만 기본 스텁 (불필요 stubbing 방지) + when(objectRedisTemplate.opsForValue()).thenReturn(objectValueOps); + + testCacheData = new SpotifyArtistDetailCache( + "Test Artist", + "https://example.com/image.jpg", + 85.5, + List.of( + new TopTrackResponse("Track 1", "https://spotify.com/track1"), + new TopTrackResponse("Track 2", "https://spotify.com/track2") + ), + List.of( + new AlbumResponse("Album 1", "2024-01-01", "album", "https://example.com/album1.jpg", "https://spotify.com/album1"), + new AlbumResponse("Album 2", "2024-02-01", "single", "https://example.com/album2.jpg", "https://spotify.com/album2") + ), + 10 + ); + } + + // ========================= + // getCached / save 테스트 + // ========================= + + @Test + @DisplayName("캐시 HIT - Redis에서 데이터를 성공적으로 조회") + void getCached_whenCacheExists_shouldReturnCachedData() { + // given + given(objectValueOps.get(CACHE_KEY)).willReturn(testCacheData); + + // when + SpotifyArtistDetailCache result = spotifyCacheService.getCached(SPOTIFY_ARTIST_ID); + + // then + assertThat(result).isNotNull(); + assertThat(result.artistName()).isEqualTo("Test Artist"); + assertThat(result.profileImageUrl()).isEqualTo("https://example.com/image.jpg"); + assertThat(result.popularity()).isEqualTo(85.5); + assertThat(result.topTracks()).hasSize(2); + assertThat(result.albums()).hasSize(2); + assertThat(result.totalAlbums()).isEqualTo(10); + + verify(objectValueOps).get(CACHE_KEY); + verifyNoInteractions(objectMapper); + } + + @Test + @DisplayName("캐시 MISS - Redis에 데이터가 없을 때 null 반환") + void getCached_whenCacheNotExists_shouldReturnNull() { + // given + given(objectValueOps.get(CACHE_KEY)).willReturn(null); + + // when + SpotifyArtistDetailCache result = spotifyCacheService.getCached(SPOTIFY_ARTIST_ID); + + // then + assertThat(result).isNull(); + verify(objectValueOps).get(CACHE_KEY); + verifyNoInteractions(objectMapper); + } + + @Test + @DisplayName("Redis 조회 실패 시 예외 처리 - null 반환") + void getCached_whenRedisFails_shouldReturnNull() { + // given + given(objectValueOps.get(CACHE_KEY)) + .willThrow(new RuntimeException("Redis connection failed")); + + // when + SpotifyArtistDetailCache result = spotifyCacheService.getCached(SPOTIFY_ARTIST_ID); + + // then + assertThat(result).isNull(); + verify(objectValueOps).get(CACHE_KEY); + verifyNoInteractions(objectMapper); + } + + @Test + @DisplayName("LinkedHashMap 등으로 역직렬화된 경우 ObjectMapper로 변환") + void getCached_whenLinkedHashMap_shouldConvertWithObjectMapper() { + // given + LinkedHashMap linkedHashMap = new LinkedHashMap<>(); + linkedHashMap.put("artistName", "Test Artist"); + linkedHashMap.put("profileImageUrl", "https://example.com/image.jpg"); + linkedHashMap.put("popularity", 85.5); + linkedHashMap.put("topTracks", List.of()); + linkedHashMap.put("albums", List.of()); + linkedHashMap.put("totalAlbums", 10); + + given(objectValueOps.get(CACHE_KEY)).willReturn(linkedHashMap); + given(objectMapper.convertValue(linkedHashMap, SpotifyArtistDetailCache.class)) + .willReturn(testCacheData); + + // when + SpotifyArtistDetailCache result = spotifyCacheService.getCached(SPOTIFY_ARTIST_ID); + + // then + assertThat(result).isEqualTo(testCacheData); + verify(objectValueOps).get(CACHE_KEY); + verify(objectMapper).convertValue(linkedHashMap, SpotifyArtistDetailCache.class); + } + + @Test + @DisplayName("캐시 저장 - Redis에 데이터를 성공적으로 저장") + void save_shouldStoreDataInRedis() { + // given + willDoNothing().given(objectValueOps).set(anyString(), any(), anyLong(), any(TimeUnit.class)); + + // when + spotifyCacheService.save(SPOTIFY_ARTIST_ID, testCacheData); + + // then + verify(objectValueOps).set( + eq(CACHE_KEY), + eq(testCacheData), + eq(CACHE_TTL_SECONDS), + eq(TimeUnit.SECONDS) + ); + } + + @Test + @DisplayName("캐시 저장 실패 시 예외 처리 - 로그만 남기고 계속 진행") + void save_whenRedisFails_shouldLogAndContinue() { + // given + willThrow(new RuntimeException("Redis connection failed")) + .given(objectValueOps).set(anyString(), any(), anyLong(), any(TimeUnit.class)); + + // when & then + assertThatCode(() -> spotifyCacheService.save(SPOTIFY_ARTIST_ID, testCacheData)) + .doesNotThrowAnyException(); + + verify(objectValueOps).set(eq(CACHE_KEY), eq(testCacheData), eq(CACHE_TTL_SECONDS), eq(TimeUnit.SECONDS)); + } + + // ========================= + // getOrFetchWithLock 테스트 + // (락 템플릿 스텁은 각 테스트에서만 추가) + // ========================= + + @Test + @DisplayName("락 획득 성공 시 API 호출 후 캐시 저장") + void getOrFetchWithLock_whenLockAcquired_shouldCallApiAndSaveCache() { + // given + when(redisTemplate.opsForValue()).thenReturn(stringValueOps); + given(stringValueOps.setIfAbsent(eq(LOCK_KEY), eq("locked"), eq(LOCK_TTL_SECONDS), eq(TimeUnit.SECONDS))) + .willReturn(true); + + given(objectValueOps.get(CACHE_KEY)).willReturn(null); // double-check MISS + willDoNothing().given(objectValueOps).set(anyString(), any(), anyLong(), any(TimeUnit.class)); + given(redisTemplate.delete(LOCK_KEY)).willReturn(true); + + // when + SpotifyArtistDetailCache result = spotifyCacheService.getOrFetchWithLock(SPOTIFY_ARTIST_ID, () -> testCacheData); + + // then + assertThat(result).isEqualTo(testCacheData); + verify(stringValueOps).setIfAbsent(eq(LOCK_KEY), eq("locked"), eq(LOCK_TTL_SECONDS), eq(TimeUnit.SECONDS)); + verify(objectValueOps, atLeastOnce()).get(CACHE_KEY); + verify(objectValueOps).set(eq(CACHE_KEY), eq(testCacheData), eq(CACHE_TTL_SECONDS), eq(TimeUnit.SECONDS)); + verify(redisTemplate).delete(LOCK_KEY); + } + + @Test + @DisplayName("락 획득 후 Double-check에서 캐시 HIT (API 호출 X, 저장 X)") + void getOrFetchWithLock_whenLockAcquiredButCacheExists_shouldReturnCachedData() { + // given + when(redisTemplate.opsForValue()).thenReturn(stringValueOps); + given(stringValueOps.setIfAbsent(eq(LOCK_KEY), eq("locked"), eq(LOCK_TTL_SECONDS), eq(TimeUnit.SECONDS))) + .willReturn(true); + + given(objectValueOps.get(CACHE_KEY)).willReturn(testCacheData); // double-check HIT + given(redisTemplate.delete(LOCK_KEY)).willReturn(true); + + // when + SpotifyArtistDetailCache result = spotifyCacheService.getOrFetchWithLock( + SPOTIFY_ARTIST_ID, + () -> { throw new RuntimeException("API should not be called"); } + ); + + // then + assertThat(result).isEqualTo(testCacheData); + verify(objectValueOps).get(CACHE_KEY); + verify(objectValueOps, never()).set(anyString(), any(), anyLong(), any(TimeUnit.class)); + verify(redisTemplate).delete(LOCK_KEY); + } + + @Test + @DisplayName("락 획득 성공인데 API 호출이 예외를 던지는 경우 - 락은 반드시 해제") + void getOrFetchWithLock_whenLockAcquiredButApiFails_shouldReleaseLock() { + // given + when(redisTemplate.opsForValue()).thenReturn(stringValueOps); + given(stringValueOps.setIfAbsent(eq(LOCK_KEY), eq("locked"), eq(LOCK_TTL_SECONDS), eq(TimeUnit.SECONDS))) + .willReturn(true); + + given(objectValueOps.get(CACHE_KEY)).willReturn(null); + given(redisTemplate.delete(LOCK_KEY)).willReturn(true); + + RuntimeException apiException = new RuntimeException("API call failed"); + + // when + assertThatThrownBy(() -> spotifyCacheService.getOrFetchWithLock( + SPOTIFY_ARTIST_ID, + () -> { throw apiException; } + )).isSameAs(apiException); + + // then + verify(objectValueOps).get(CACHE_KEY); + verify(objectValueOps, never()).set(anyString(), any(), anyLong(), any(TimeUnit.class)); + verify(redisTemplate).delete(LOCK_KEY); + } + + @Test + @DisplayName("락 획득 실패 시 대기 후 캐시 재조회 HIT → API 호출 X") + void getOrFetchWithLock_whenLockFailed_shouldWaitAndRetryCache() { + // given + when(redisTemplate.opsForValue()).thenReturn(stringValueOps); + given(stringValueOps.setIfAbsent(eq(LOCK_KEY), eq("locked"), eq(LOCK_TTL_SECONDS), eq(TimeUnit.SECONDS))) + .willReturn(false); + + given(objectValueOps.get(CACHE_KEY)).willReturn(testCacheData); + + // when + SpotifyArtistDetailCache result = spotifyCacheService.getOrFetchWithLock( + SPOTIFY_ARTIST_ID, + () -> { throw new RuntimeException("API should not be called"); } + ); + + // then + assertThat(result).isEqualTo(testCacheData); + verify(objectValueOps, atLeastOnce()).get(CACHE_KEY); + verify(redisTemplate, never()).delete(LOCK_KEY); + verify(objectValueOps, never()).set(anyString(), any(), anyLong(), any(TimeUnit.class)); + } + + @Test + @DisplayName("락 획득 실패 후 재시도 중 캐시 HIT → API 호출 X") + void getOrFetchWithLock_whenLockFailedAndRetrySucceeds_shouldReturnCachedData() { + // given + when(redisTemplate.opsForValue()).thenReturn(stringValueOps); + given(stringValueOps.setIfAbsent(eq(LOCK_KEY), eq("locked"), eq(LOCK_TTL_SECONDS), eq(TimeUnit.SECONDS))) + .willReturn(false); + + given(objectValueOps.get(CACHE_KEY)) + .willReturn(null) + .willReturn(testCacheData); + + // when + SpotifyArtistDetailCache result = spotifyCacheService.getOrFetchWithLock( + SPOTIFY_ARTIST_ID, + () -> { throw new RuntimeException("API should not be called"); } + ); + + // then + assertThat(result).isEqualTo(testCacheData); + verify(objectValueOps, atLeast(2)).get(CACHE_KEY); + verify(objectValueOps, never()).set(anyString(), any(), anyLong(), any(TimeUnit.class)); + } + + @Test + @DisplayName("락 획득 실패 후 최종적으로도 캐시 없으면 직접 API 호출 후 캐시 저장") + void getOrFetchWithLock_whenLockFailedAndNoCache_shouldCallApiDirectly() { + // given + when(redisTemplate.opsForValue()).thenReturn(stringValueOps); + given(stringValueOps.setIfAbsent(eq(LOCK_KEY), eq("locked"), eq(LOCK_TTL_SECONDS), eq(TimeUnit.SECONDS))) + .willReturn(false); + + given(objectValueOps.get(CACHE_KEY)).willReturn(null); + willDoNothing().given(objectValueOps).set(anyString(), any(), anyLong(), any(TimeUnit.class)); + + AtomicInteger apiCalls = new AtomicInteger(0); + + // when + SpotifyArtistDetailCache result = spotifyCacheService.getOrFetchWithLock( + SPOTIFY_ARTIST_ID, + () -> { + apiCalls.incrementAndGet(); + return testCacheData; + } + ); + + // then + assertThat(result).isEqualTo(testCacheData); + assertThat(apiCalls.get()).isEqualTo(1); + + verify(objectValueOps, atLeast(2)).get(CACHE_KEY); + verify(objectValueOps).set(eq(CACHE_KEY), eq(testCacheData), eq(CACHE_TTL_SECONDS), eq(TimeUnit.SECONDS)); + } + + @Test + @DisplayName("setIfAbsent()가 Redis 예외를 던지면 fallback으로 API 호출 + 캐시 저장") + void getOrFetchWithLock_whenSetIfAbsentThrows_shouldFallbackToApiAndCache() { + // given + when(redisTemplate.opsForValue()).thenReturn(stringValueOps); + given(stringValueOps.setIfAbsent(eq(LOCK_KEY), eq("locked"), eq(LOCK_TTL_SECONDS), eq(TimeUnit.SECONDS))) + .willThrow(new RuntimeException("Redis down")); + + willDoNothing().given(objectValueOps).set(anyString(), any(), anyLong(), any(TimeUnit.class)); + + AtomicInteger apiCalls = new AtomicInteger(0); + + // when + SpotifyArtistDetailCache result = spotifyCacheService.getOrFetchWithLock( + SPOTIFY_ARTIST_ID, + () -> { + apiCalls.incrementAndGet(); + return testCacheData; + } + ); + + // then + assertThat(result).isEqualTo(testCacheData); + assertThat(apiCalls.get()).isEqualTo(1); + + verify(objectValueOps).set(eq(CACHE_KEY), eq(testCacheData), eq(CACHE_TTL_SECONDS), eq(TimeUnit.SECONDS)); + verify(redisTemplate, never()).delete(LOCK_KEY); + } + + @Test + @DisplayName("동시 요청 시 캐시 스탬피드 방지 - 정확히 1번만 API 호출") + void getOrFetchWithLock_whenConcurrentRequests_shouldCallApiExactlyOnce() throws InterruptedException { + // given + when(redisTemplate.opsForValue()).thenReturn(stringValueOps); + + int threadCount = 10; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + CountDownLatch endLatch = new CountDownLatch(threadCount); + + AtomicInteger apiCallCount = new AtomicInteger(0); + AtomicInteger lockAttemptCount = new AtomicInteger(0); + AtomicBoolean cached = new AtomicBoolean(false); + + given(stringValueOps.setIfAbsent(eq(LOCK_KEY), eq("locked"), eq(LOCK_TTL_SECONDS), eq(TimeUnit.SECONDS))) + .willAnswer(inv -> lockAttemptCount.getAndIncrement() == 0); + + given(objectValueOps.get(CACHE_KEY)).willAnswer(inv -> cached.get() ? testCacheData : null); + + willAnswer(inv -> { + cached.set(true); + return null; + }).given(objectValueOps).set(eq(CACHE_KEY), any(), eq(CACHE_TTL_SECONDS), eq(TimeUnit.SECONDS)); + + given(redisTemplate.delete(LOCK_KEY)).willReturn(true); + + // when + for (int i = 0; i < threadCount; i++) { + executor.submit(() -> { + try { + startLatch.await(); + spotifyCacheService.getOrFetchWithLock( + SPOTIFY_ARTIST_ID, + () -> { + apiCallCount.incrementAndGet(); + // 테스트 안정성: supplier 진입 시 cached=true로 올려도 됨 + cached.set(true); + return testCacheData; + } + ); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + endLatch.countDown(); + } + }); + } + + startLatch.countDown(); + endLatch.await(5, TimeUnit.SECONDS); + executor.shutdown(); + + // then + assertThat(apiCallCount.get()).isEqualTo(1); + } +} From 796cf4a89622b3b1fc7d48d4c8d72d51ff5105c3 Mon Sep 17 00:00:00 2001 From: Jung Yunseo Date: Wed, 7 Jan 2026 09:35:10 +0900 Subject: [PATCH 7/7] =?UTF-8?q?fix:=20Redis=20=EB=AC=B4=ED=95=9C=EB=8C=80?= =?UTF-8?q?=EA=B8=B0=20=EC=98=A4=EB=A5=98=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/artists/repository/ArtistRepository.java | 10 ++++++++++ .../domain/artists/service/ArtistService.java | 2 +- .../spotify/application/SpotifyDetailService.java | 6 ++++++ .../service/spotify/cache/SpotifyCacheService.java | 2 +- .../web7_9_codecrete_be/global/config/RedisConfig.java | 5 ++++- 5 files changed, 22 insertions(+), 3 deletions(-) diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/repository/ArtistRepository.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/repository/ArtistRepository.java index 9472fcb3..9be78af7 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/repository/ArtistRepository.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/repository/ArtistRepository.java @@ -5,6 +5,7 @@ import org.springframework.data.domain.Slice; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.List; @@ -14,6 +15,15 @@ public interface ArtistRepository extends JpaRepository { boolean existsBySpotifyArtistId(String spotifyArtistId); java.util.Optional findBySpotifyArtistId(String spotifyArtistId); + // 아티스트 상세 조회용 - artistGenres와 genre를 fetch join하여 N+1 문제 방지 + @Query(""" + SELECT DISTINCT a FROM Artist a + LEFT JOIN FETCH a.artistGenres ag + LEFT JOIN FETCH ag.genre + WHERE a.id = :id + """) + java.util.Optional findByIdWithArtistGenres(@Param("id") Long id); + @Query("SELECT a FROM Artist a WHERE a.nameKo IS NULL ORDER BY a.id ASC") List findByNameKoIsNullOrderByIdAsc(Pageable pageable); diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistService.java index 8f367abb..21082328 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/ArtistService.java @@ -96,7 +96,7 @@ public Slice listArtist(Pageable pageable, User user, Artist @Transactional(readOnly = true) public ArtistDetailResponse getArtistDetail(Long artistId) { - Artist artist = artistRepository.findById(artistId) + Artist artist = artistRepository.findByIdWithArtistGenres(artistId) .orElseThrow(() -> new BusinessException(ArtistErrorCode.ARTIST_NOT_FOUND)); if (artist.getSpotifyArtistId() == null) { diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/application/SpotifyDetailService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/application/SpotifyDetailService.java index e185ce69..74aad837 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/application/SpotifyDetailService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/application/SpotifyDetailService.java @@ -49,6 +49,8 @@ public SpotifyArtistDetailCache fetchDetailFromApi(String spotifyArtistId) { throw e; } catch (Exception e) { throw new RuntimeException("Exception during getArtist API call", e); + } finally { + spotifyRateLimiter.release(); } }, "getArtistDetail getArtist spotifyId=" + spotifyArtistId); @@ -80,6 +82,8 @@ private Track[] safeGetTopTracks(SpotifyApi api, String artistId) { throw e; } catch (Exception e) { throw new RuntimeException("Exception during getArtistsTopTracks API call", e); + } finally { + spotifyRateLimiter.release(); } }, "safeGetTopTracks artistId=" + artistId); } catch (RuntimeException e) { @@ -103,6 +107,8 @@ private Paging safeGetAlbums(SpotifyApi api, String artistId) { throw e; } catch (Exception e) { throw new RuntimeException("Exception during getArtistsAlbums API call", e); + } finally { + spotifyRateLimiter.release(); } }, "safeGetAlbums artistId=" + artistId); } catch (RuntimeException e) { diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/cache/SpotifyCacheService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/cache/SpotifyCacheService.java index cbbdcb01..8dca4658 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/cache/SpotifyCacheService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/artists/service/spotify/cache/SpotifyCacheService.java @@ -41,7 +41,7 @@ public SpotifyArtistDetailCache getCached(String spotifyArtistId) { return objectMapper.convertValue(cached, SpotifyArtistDetailCache.class); } catch (Exception e) { - log.warn("Redis 캐시 조회 실패: spotifyArtistId={}", spotifyArtistId, e); + log.error("Redis 캐시 조회 실패: spotifyArtistId={}", spotifyArtistId, e); return null; } } diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/config/RedisConfig.java b/src/main/java/com/back/web7_9_codecrete_be/global/config/RedisConfig.java index 5a0ee0d0..4458caa3 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/global/config/RedisConfig.java +++ b/src/main/java/com/back/web7_9_codecrete_be/global/config/RedisConfig.java @@ -25,7 +25,10 @@ public class RedisConfig { @Bean public RedisConnectionFactory redisConnectionFactory() { final RedisStandaloneConfiguration configuration = new RedisStandaloneConfiguration(host,port); - return new LettuceConnectionFactory(configuration); + LettuceConnectionFactory factory = new LettuceConnectionFactory(configuration); + // Redis 연결 타임아웃 설정 (5초) + factory.setTimeout(5000); + return factory; } //문자열만 사용할 때(refreshToken)