Skip to content

Commit ee3aeeb

Browse files
Merge pull request #291 from prgrms-web-devcourse-final-project/feat/#290
[Artist] Spotify AccessToken 자동 재발급 로직 추가
2 parents 479928a + 6909477 commit ee3aeeb

4 files changed

Lines changed: 472 additions & 8 deletions

File tree

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
11
package com.back.web7_9_codecrete_be.domain.artists.service.spotifyService;
22

3+
import com.back.web7_9_codecrete_be.global.spotify.SpotifyClient;
4+
import lombok.RequiredArgsConstructor;
35
import lombok.extern.slf4j.Slf4j;
46
import org.springframework.stereotype.Component;
57

68
// 429 에러 처리 및 전역 쿨다운 관리
9+
// 401 에러 처리 (토큰 만료 시 자동 재발급)
710

811
@Slf4j
912
@Component
13+
@RequiredArgsConstructor
1014
public class SpotifyRateLimitHandler {
1115

16+
private final SpotifyClient spotifyClient;
17+
1218
private static final long SPOTIFY_RATE_LIMIT_INTERVAL_MS = 500; // 초당 2회
1319
private static final long MUSICBRAINZ_RATE_LIMIT_INTERVAL_MS = 1000; // 초당 1회
1420
private static final long GLOBAL_COOLDOWN_DURATION_MS = 60_000; // 60초
@@ -65,6 +71,22 @@ public <T> T callWithRateLimitRetry(java.util.function.Supplier<T> supplier, Str
6571
globalConsecutive429Count = 0;
6672
return result;
6773
} catch (Exception e) {
74+
// 401 에러 확인 (Unauthorized - 토큰 만료)
75+
boolean is401 = is401Error(e);
76+
77+
if (is401) {
78+
log.warn("401 Unauthorized 에러 감지 - 토큰 재발급 후 재시도: attempt={}/{}", attempt, maxRetry);
79+
// 토큰 강제 재발급
80+
spotifyClient.forceRefreshToken();
81+
82+
if (attempt < maxRetry) {
83+
// 재시도
84+
continue;
85+
} else {
86+
log.error("401 에러 재시도 횟수 초과 ({}회)", maxRetry);
87+
throw new RuntimeException(context + ": 401 Unauthorized - 토큰 재발급 후에도 실패", e);
88+
}
89+
}
6890
// 원본 예외 확인 (래핑된 경우 cause 확인)
6991
Throwable originalException = e;
7092
Throwable current = e;
@@ -203,5 +225,26 @@ public <T> T callWithRateLimitRetry(java.util.function.Supplier<T> supplier, Str
203225

204226
throw new RuntimeException(context + ": rate limit retry exhausted");
205227
}
228+
229+
// 401 에러인지 확인
230+
private boolean is401Error(Throwable e) {
231+
Throwable current = e;
232+
while (current != null) {
233+
String className = current.getClass().getSimpleName();
234+
String errorMsg = current.getMessage();
235+
236+
// 401 에러 확인
237+
if (className.contains("Unauthorized") ||
238+
className.contains("401") ||
239+
(errorMsg != null && (errorMsg.contains("401") ||
240+
errorMsg.contains("Unauthorized") ||
241+
errorMsg.contains("Invalid access token")))) {
242+
return true;
243+
}
244+
245+
current = current.getCause();
246+
}
247+
return false;
248+
}
206249
}
207250

Lines changed: 83 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,107 @@
11
package com.back.web7_9_codecrete_be.global.spotify;
22

33
import lombok.RequiredArgsConstructor;
4+
import lombok.extern.slf4j.Slf4j;
45
import org.springframework.stereotype.Component;
56
import se.michaelthelin.spotify.SpotifyApi;
67
import se.michaelthelin.spotify.model_objects.credentials.ClientCredentials;
78
import se.michaelthelin.spotify.requests.authorization.client_credentials.ClientCredentialsRequest;
89

10+
import java.util.concurrent.locks.ReentrantLock;
11+
12+
// Spotify AccessToken 중앙 관리 클래스
13+
14+
// 토큰 만료 시간 관리 (1시간 유효)
15+
// 만료 전 자동 재발급 (5분 여유)
16+
// 401 에러 발생 시 자동 재발급 및 재요청 지원
17+
18+
@Slf4j
919
@Component
1020
@RequiredArgsConstructor
1121
public class SpotifyClient {
1222

1323
private final SpotifyApi spotifyApi;
14-
15-
public String getAccessToken() {
24+
25+
// 토큰 관리 필드
26+
private volatile String cachedAccessToken;
27+
private volatile long tokenExpiresAt = 0; // 만료 시각 (밀리초)
28+
private final ReentrantLock tokenLock = new ReentrantLock();
29+
30+
// 토큰 만료 여유 시간 (5분 = 300초)
31+
private static final long TOKEN_BUFFER_SECONDS = 300;
32+
private static final long TOKEN_BUFFER_MS = TOKEN_BUFFER_SECONDS * 1000;
33+
34+
// AccessToken 발급
35+
private String refreshAccessToken() {
36+
tokenLock.lock();
1637
try {
38+
// Double-check: 다른 스레드가 이미 토큰을 발급했는지 확인
39+
if (cachedAccessToken != null && System.currentTimeMillis() < tokenExpiresAt) {
40+
return cachedAccessToken;
41+
}
42+
43+
log.info("Spotify AccessToken 발급 시작");
1744
ClientCredentialsRequest request = spotifyApi.clientCredentials().build();
1845
ClientCredentials credentials = request.execute();
19-
spotifyApi.setAccessToken(credentials.getAccessToken());
20-
return credentials.getAccessToken();
46+
47+
String newToken = credentials.getAccessToken();
48+
// Spotify 토큰은 3600초(1시간) 유효
49+
// 여유 시간을 빼서 실제 만료 시각 계산
50+
long expiresInSeconds = credentials.getExpiresIn();
51+
tokenExpiresAt = System.currentTimeMillis() + (expiresInSeconds * 1000) - TOKEN_BUFFER_MS;
52+
53+
cachedAccessToken = newToken;
54+
spotifyApi.setAccessToken(newToken);
55+
56+
log.info("Spotify AccessToken 발급 완료. 만료 시각: {} ({}초 후)",
57+
new java.util.Date(tokenExpiresAt), expiresInSeconds);
58+
59+
return newToken;
2160
} catch (Exception e) {
61+
log.error("Spotify 토큰 발급 실패", e);
62+
// 토큰 발급 실패 시 캐시 초기화
63+
cachedAccessToken = null;
64+
tokenExpiresAt = 0;
2265
throw new RuntimeException("Spotify 토큰 발급 실패", e);
66+
} finally {
67+
tokenLock.unlock();
2368
}
2469
}
25-
26-
public SpotifyApi getAuthorizedApi() {
27-
if (spotifyApi.getAccessToken() == null) {
28-
getAccessToken();
70+
71+
// AccessToken 조회 (만료 체크 포함) - 만료되었거나 곧 만료될 경우 자동 재발급
72+
public String getAccessToken() {
73+
long now = System.currentTimeMillis();
74+
75+
// 토큰이 없거나 만료되었거나 곧 만료될 경우 재발급
76+
if (cachedAccessToken == null || now >= tokenExpiresAt) {
77+
return refreshAccessToken();
2978
}
79+
80+
return cachedAccessToken;
81+
}
82+
83+
// 인증된 SpotifyApi 반환 - 토큰이 없거나 만료된 경우 자동으로 재발급
84+
public SpotifyApi getAuthorizedApi() {
85+
getAccessToken(); // 만료 체크 및 필요시 재발급
3086
return spotifyApi;
3187
}
88+
89+
// 401 에러 발생 시 토큰 강제 재발급 (SpotifyRateLimitHandler에서 자동 호출)
90+
public void forceRefreshToken() {
91+
log.warn("401 에러 감지 - Spotify AccessToken 강제 재발급");
92+
tokenLock.lock();
93+
try {
94+
// 캐시 초기화 후 재발급
95+
cachedAccessToken = null;
96+
tokenExpiresAt = 0;
97+
refreshAccessToken();
98+
} finally {
99+
tokenLock.unlock();
100+
}
101+
}
102+
103+
// 토큰 만료 여부 확인
104+
public boolean isTokenExpired() {
105+
return cachedAccessToken == null || System.currentTimeMillis() >= tokenExpiresAt;
106+
}
32107
}
Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package com.back.web7_9_codecrete_be.domain.artists.service.spotifyService;
2+
3+
import com.back.web7_9_codecrete_be.global.spotify.SpotifyClient;
4+
import org.junit.jupiter.api.BeforeEach;
5+
import org.junit.jupiter.api.DisplayName;
6+
import org.junit.jupiter.api.Test;
7+
import org.junit.jupiter.api.extension.ExtendWith;
8+
import org.mockito.InjectMocks;
9+
import org.mockito.Mock;
10+
import org.mockito.junit.jupiter.MockitoExtension;
11+
12+
import java.util.concurrent.atomic.AtomicInteger;
13+
14+
import static org.assertj.core.api.Assertions.assertThat;
15+
import static org.assertj.core.api.Assertions.assertThatThrownBy;
16+
import static org.mockito.BDDMockito.*;
17+
import static org.mockito.Mockito.*;
18+
19+
@ExtendWith(MockitoExtension.class)
20+
@DisplayName("SpotifyRateLimitHandler 401 에러 처리 테스트")
21+
class SpotifyRateLimitHandlerTest {
22+
23+
@Mock
24+
private SpotifyClient spotifyClient;
25+
26+
@InjectMocks
27+
private SpotifyRateLimitHandler rateLimitHandler;
28+
29+
@BeforeEach
30+
void setUp() {
31+
// 각 테스트 전에 상태 초기화
32+
}
33+
34+
@Test
35+
@DisplayName("401 에러 발생 시 토큰 재발급 후 재시도 성공")
36+
void callWithRateLimitRetry_when401Error_shouldRefreshTokenAndRetry() {
37+
// given
38+
RuntimeException unauthorizedException = new RuntimeException("401 Unauthorized");
39+
String successResult = "success-result";
40+
41+
// forceRefreshToken은 void 메서드이므로 doNothing() 사용
42+
doNothing().when(spotifyClient).forceRefreshToken();
43+
44+
// 첫 번째 호출은 401 에러, 두 번째 호출은 성공하도록 설정
45+
AtomicInteger callCount = new AtomicInteger(0);
46+
47+
// when
48+
String result = rateLimitHandler.callWithRateLimitRetry(
49+
() -> {
50+
int count = callCount.incrementAndGet();
51+
if (count == 1) {
52+
// 첫 번째 호출 시 401 에러
53+
throw unauthorizedException;
54+
}
55+
// 두 번째 호출 시 성공
56+
return successResult;
57+
},
58+
"test-context"
59+
);
60+
61+
// then
62+
assertThat(result).isEqualTo(successResult);
63+
assertThat(callCount.get()).isEqualTo(2); // 재시도로 2번 호출됨
64+
verify(spotifyClient, times(1)).forceRefreshToken();
65+
}
66+
67+
@Test
68+
@DisplayName("401 에러가 아닌 경우 토큰 재발급하지 않음")
69+
void callWithRateLimitRetry_whenNot401Error_shouldNotRefreshToken() {
70+
// given
71+
RuntimeException otherException = new RuntimeException("500 Internal Server Error");
72+
String successResult = "success-result";
73+
74+
// when & then
75+
assertThatThrownBy(() -> {
76+
rateLimitHandler.callWithRateLimitRetry(
77+
() -> {
78+
throw otherException;
79+
},
80+
"test-context"
81+
);
82+
}).isInstanceOf(RuntimeException.class)
83+
.hasMessageContaining("500 Internal Server Error");
84+
85+
// 401이 아니면 토큰 재발급하지 않아야 함
86+
verify(spotifyClient, never()).forceRefreshToken();
87+
}
88+
89+
@Test
90+
@DisplayName("정상 호출 시 토큰 재발급하지 않음")
91+
void callWithRateLimitRetry_whenSuccess_shouldNotRefreshToken() {
92+
// given
93+
String successResult = "success-result";
94+
95+
// when
96+
String result = rateLimitHandler.callWithRateLimitRetry(
97+
() -> successResult,
98+
"test-context"
99+
);
100+
101+
// then
102+
assertThat(result).isEqualTo(successResult);
103+
verify(spotifyClient, never()).forceRefreshToken();
104+
}
105+
106+
@Test
107+
@DisplayName("401 에러가 예외 체인에 있는 경우 감지")
108+
void is401Error_when401InExceptionChain_shouldDetect() {
109+
// given
110+
RuntimeException cause = new RuntimeException("401 Unauthorized");
111+
RuntimeException wrapper = new RuntimeException("Wrapped exception", cause);
112+
113+
// when
114+
// is401Error는 private 메서드이므로 callWithRateLimitRetry를 통해 간접 테스트
115+
// 실제로는 401 에러가 발생하면 forceRefreshToken이 호출되어야 함
116+
// 이 테스트는 통합 테스트에서 더 적합할 수 있음
117+
}
118+
}
119+

0 commit comments

Comments
 (0)