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..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 @@ -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,26 @@ public T callWithRateLimitRetry(java.util.function.Supplier supplier, Str 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/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..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 @@ -1,32 +1,107 @@ 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 에러 발생 시 토큰 강제 재발급 (SpotifyRateLimitHandler에서 자동 호출) + 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; + } } 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); + } +} +