88import org .springframework .stereotype .Service ;
99
1010import java .util .concurrent .TimeUnit ;
11-
11+ import java . util . function . Supplier ;
1212
1313@ Slf4j
1414@ Service
@@ -22,34 +22,33 @@ public class SpotifyCacheService {
2222
2323 private static final String CACHE_KEY_PREFIX = "artist:detail:spotify:" ;
2424 private static final String LOCK_KEY_PREFIX = "artist:detail:spotify:lock:" ;
25- private static final long CACHE_TTL_SECONDS = 3600 ; // 1시간 (기본값, 추후 3~6시간 조정 가능)
26- private static final long LOCK_TTL_SECONDS = 30 ; // 락 TTL: 30초 (API 호출 완료 대기 시간)
25+ private static final long CACHE_TTL_SECONDS = 3600 ; // 1시간
26+ private static final long LOCK_TTL_SECONDS = 30 ; // 30초
27+
28+ private static final long RETRY_SLEEP_MS = 100 ;
29+ private static final int MAX_RETRIES = 30 ; // 100ms * 30 = 3초
2730
28- // Redis 캐시에서 Spotify 상세 정보 조회
2931 public SpotifyArtistDetailCache getCached (String spotifyArtistId ) {
3032 try {
3133 String cacheKey = getCacheKey (spotifyArtistId );
3234 Object cached = objectRedisTemplate .opsForValue ().get (cacheKey );
3335
34- if (cached == null ) {
35- return null ;
36- }
36+ if (cached == null ) return null ;
3737
38- // Object를 SpotifyArtistDetailCache로 변환
3938 if (cached instanceof SpotifyArtistDetailCache ) {
4039 return (SpotifyArtistDetailCache ) cached ;
4140 }
4241
43- // LinkedHashMap 등으로 역직렬화된 경우 ObjectMapper로 변환
4442 return objectMapper .convertValue (cached , SpotifyArtistDetailCache .class );
4543 } catch (Exception e ) {
4644 log .warn ("Redis 캐시 조회 실패: spotifyArtistId={}" , spotifyArtistId , e );
4745 return null ;
4846 }
4947 }
5048
51- // Redis 캐시에 Spotify 상세 정보 저장
5249 public void save (String spotifyArtistId , SpotifyArtistDetailCache data ) {
50+ if (data == null ) return ; // (선택) supplier가 null 반환 시 방어
51+
5352 try {
5453 String cacheKey = getCacheKey (spotifyArtistId );
5554 objectRedisTemplate .opsForValue ().set (
@@ -61,88 +60,89 @@ public void save(String spotifyArtistId, SpotifyArtistDetailCache data) {
6160 log .debug ("Spotify 상세 정보 캐시 저장: spotifyArtistId={}, ttl={}초" , spotifyArtistId , CACHE_TTL_SECONDS );
6261 } catch (Exception e ) {
6362 log .warn ("Redis 캐시 저장 실패: spotifyArtistId={}" , spotifyArtistId , e );
64- // 캐시 저장 실패해도 API 호출은 성공했으므로 계속 진행
6563 }
6664 }
6765
68- // 캐시 스탬피드 방지: Redis 락을 사용하여 동시 API 호출 제한
6966 public SpotifyArtistDetailCache getOrFetchWithLock (
7067 String spotifyArtistId ,
71- java . util . function . Supplier <SpotifyArtistDetailCache > apiCallSupplier
68+ Supplier <SpotifyArtistDetailCache > apiCallSupplier
7269 ) {
7370 String lockKey = getLockKey (spotifyArtistId );
7471
75- // 락 획득 시도 (SETNX 방식)
76- Boolean lockAcquired = redisTemplate .opsForValue ().setIfAbsent (
77- lockKey ,
78- "locked" ,
79- LOCK_TTL_SECONDS ,
80- TimeUnit .SECONDS
81- );
72+ // ✅ 핵심 수정 1) 락 획득 자체가 예외면 fallback
73+ final Boolean lockAcquired ;
74+ try {
75+ lockAcquired = redisTemplate .opsForValue ().setIfAbsent (
76+ lockKey ,
77+ "locked" ,
78+ LOCK_TTL_SECONDS ,
79+ TimeUnit .SECONDS
80+ );
81+ } catch (Exception e ) {
82+ log .warn ("Redis 락 획득 실패(예외) → 직접 API 호출로 fallback: spotifyArtistId={}" , spotifyArtistId , e );
83+ SpotifyArtistDetailCache spotifyData = apiCallSupplier .get ();
84+ save (spotifyArtistId , spotifyData );
85+ return spotifyData ;
86+ }
8287
8388 if (Boolean .TRUE .equals (lockAcquired )) {
84- // 락 획득 성공: 이 스레드가 API 호출 담당
8589 try {
8690 log .debug ("Spotify API 호출 락 획득: spotifyArtistId={}" , spotifyArtistId );
8791
88- // 다시 한 번 캐시 확인 (락 획득 대기 중 다른 스레드가 저장했을 수 있음)
92+ // Double-check
8993 SpotifyArtistDetailCache doubleCheck = getCached (spotifyArtistId );
9094 if (doubleCheck != null ) {
9195 log .debug ("락 획득 후 캐시 재조회 HIT: spotifyArtistId={}" , spotifyArtistId );
9296 return doubleCheck ;
9397 }
9498
95- // Spotify API 호출
9699 SpotifyArtistDetailCache spotifyData = apiCallSupplier .get ();
97-
98- // 캐시에 저장
99100 save (spotifyArtistId , spotifyData );
100-
101101 return spotifyData ;
102+
102103 } finally {
103- // 락 해제
104- redisTemplate .delete (lockKey );
104+ // ✅ 핵심 수정 2) 락 해제도 예외 방어
105+ try {
106+ redisTemplate .delete (lockKey );
107+ } catch (Exception e ) {
108+ log .warn ("Redis 락 해제 실패: lockKey={}" , lockKey , e );
109+ }
105110 }
106- } else {
107- // 락 획득 실패: 다른 스레드가 API 호출 중
108- log .debug ("Spotify API 호출 락 획득 실패 (다른 스레드가 처리 중): spotifyArtistId={}" , spotifyArtistId );
111+ }
109112
110- // 짧은 대기 후 캐시 재조회 (다른 스레드가 저장 완료했을 수 있음)
111- try {
112- Thread .sleep (100 ); // 100ms 대기
113- } catch (InterruptedException e ) {
114- Thread .currentThread ().interrupt ();
115- }
113+ // 락 획득 실패: 다른 스레드가 API 호출 중
114+ log .debug ("Spotify API 호출 락 획득 실패 (다른 스레드가 처리 중): spotifyArtistId={}" , spotifyArtistId );
115+
116+ sleepQuietly (RETRY_SLEEP_MS );
117+
118+ SpotifyArtistDetailCache retryCache = getCached (spotifyArtistId );
119+ if (retryCache != null ) {
120+ log .debug ("락 대기 후 캐시 재조회 HIT: spotifyArtistId={}" , spotifyArtistId );
121+ return retryCache ;
122+ }
116123
117- // 캐시 재조회
118- SpotifyArtistDetailCache retryCache = getCached (spotifyArtistId );
124+ for (int i = 0 ; i < MAX_RETRIES ; i ++) {
125+ sleepQuietly (RETRY_SLEEP_MS );
126+
127+ retryCache = getCached (spotifyArtistId );
119128 if (retryCache != null ) {
120- log .debug ("락 대기 후 캐시 재조회 HIT: spotifyArtistId={}" , spotifyArtistId );
129+ log .debug ("락 대기 중 캐시 재조회 HIT ({}ms 후) : spotifyArtistId={}" , ( i + 1 ) * RETRY_SLEEP_MS , spotifyArtistId );
121130 return retryCache ;
122131 }
132+ }
123133
124- // 여전히 캐시가 없으면 최대 3초까지 대기하며 재시도
125- int maxRetries = 30 ; // 100ms * 30 = 3초
126- for (int i = 0 ; i < maxRetries ; i ++) {
127- try {
128- Thread .sleep (100 );
129- } catch (InterruptedException e ) {
130- Thread .currentThread ().interrupt ();
131- break ;
132- }
133-
134- retryCache = getCached (spotifyArtistId );
135- if (retryCache != null ) {
136- log .debug ("락 대기 중 캐시 재조회 HIT ({}ms 후): spotifyArtistId={}" , (i + 1 ) * 100 , spotifyArtistId );
137- return retryCache ;
138- }
139- }
134+ // 최종 fallback
135+ log .warn ("락 대기 후에도 캐시 없음, 직접 API 호출: spotifyArtistId={}" , spotifyArtistId );
136+ SpotifyArtistDetailCache spotifyData = apiCallSupplier .get ();
137+ save (spotifyArtistId , spotifyData );
138+ return spotifyData ;
139+ }
140140
141- // 최종적으로도 캐시가 없으면 직접 API 호출 (락이 만료되었을 수 있음)
142- log . warn ( "락 대기 후에도 캐시 없음, 직접 API 호출: spotifyArtistId={}" , spotifyArtistId );
143- SpotifyArtistDetailCache spotifyData = apiCallSupplier . get ( );
144- save ( spotifyArtistId , spotifyData );
145- return spotifyData ;
141+ private void sleepQuietly ( long millis ) {
142+ try {
143+ Thread . sleep ( millis );
144+ } catch ( InterruptedException e ) {
145+ Thread . currentThread (). interrupt () ;
146146 }
147147 }
148148
@@ -154,4 +154,3 @@ private String getLockKey(String spotifyArtistId) {
154154 return LOCK_KEY_PREFIX + spotifyArtistId ;
155155 }
156156}
157-
0 commit comments