token관리 추가

This commit is contained in:
2025-12-03 16:06:01 +09:00
parent 9b2d84ff33
commit 8de553c78b
5 changed files with 534 additions and 27 deletions

View File

@@ -1,9 +1,11 @@
package com.domain.api;
import com.domain.request.SpotifyAddTracksRequest;
import com.domain.request.SpotifyOAuthCallbackRequest;
import com.domain.request.SpotifyTokenRequest;
import com.domain.request.VibeToSpotifySearchRequest;
import com.domain.response.SpotifyAddTracksResponse;
import com.domain.response.SpotifyAuthResponse;
import com.domain.response.SpotifyPlaylistResponse;
import com.domain.response.VibeToSpotifySearchResponse;
import com.domain.service.SpotifyService;
@@ -23,28 +25,87 @@ public class SpotifyController {
private final SpotifyService spotifyService;
/**
* Spotify Access Token을 사용하여 사용자의 플레이리스트를 조회합니다.
* Spotify OAuth2.0 콜백 처리: 토큰을 저장하고 세션 ID를 반환합니다.
*
* @param request Spotify Access Token
* @return Spotify 플레이리스트 목록
* @param request OAuth 콜백 요청 (토큰 정보 포함)
* @return 인증 응답 (세션 ID 포함)
*/
@PostMapping("/playlists")
@Operation(summary = "Spotify 플레이리스트 조회", description = "Spotify Access Token으로 사용자의 플레이리스트를 조회합니다.")
public ResponseEntity<SpotifyPlaylistResponse> getSpotifyPlaylists(@RequestBody SpotifyTokenRequest request) {
log.info("Spotify 플레이리스트 조회 요청");
@PostMapping("/auth/callback")
@Operation(summary = "Spotify OAuth 콜백 처리", description = "OAuth2.0 인증 후 받은 토큰을 저장하고 세션 ID를 반환합니다.")
public ResponseEntity<SpotifyAuthResponse> handleOAuthCallback(@RequestBody SpotifyOAuthCallbackRequest request) {
log.info("Spotify OAuth 콜백 처리 요청");
// 입력 검증
if (request.getAccessToken() == null || request.getAccessToken().trim().isEmpty()) {
return ResponseEntity.badRequest()
.body(SpotifyPlaylistResponse.builder()
.body(SpotifyAuthResponse.builder()
.success(false)
.message("Access Token이 필요합니다.")
.build());
}
if (request.getRefreshToken() == null || request.getRefreshToken().trim().isEmpty()) {
return ResponseEntity.badRequest()
.body(SpotifyAuthResponse.builder()
.success(false)
.message("Refresh Token이 필요합니다.")
.build());
}
if (request.getExpiresIn() == null) {
return ResponseEntity.badRequest()
.body(SpotifyAuthResponse.builder()
.success(false)
.message("Expires In이 필요합니다.")
.build());
}
try {
SpotifyPlaylistResponse response = spotifyService.getUserPlaylists(request.getAccessToken());
SpotifyAuthResponse response = spotifyService.handleOAuthCallback(request);
if (response.isSuccess()) {
log.info("Spotify OAuth 콜백 처리 성공 - 세션 ID 생성 완료");
return ResponseEntity.ok(response);
} else {
log.warn("Spotify OAuth 콜백 처리 실패 - 메시지: {}", response.getMessage());
return ResponseEntity.status(401).body(response);
}
} catch (Exception e) {
log.error("Spotify OAuth 콜백 처리 중 예외 발생", e);
return ResponseEntity.internalServerError()
.body(SpotifyAuthResponse.builder()
.success(false)
.message("서버 오류가 발생했습니다: " + e.getMessage())
.build());
}
}
/**
* 세션 ID를 사용하여 사용자의 플레이리스트를 조회합니다.
*
* @param sessionId 세션 ID (헤더)
* @return Spotify 플레이리스트 목록
*/
@GetMapping("/playlists")
@Operation(summary = "Spotify 플레이리스트 조회", description = "세션 ID로 사용자의 플레이리스트를 조회합니다.")
public ResponseEntity<SpotifyPlaylistResponse> getSpotifyPlaylists(
@RequestHeader("X-Session-Id") String sessionId) {
log.info("Spotify 플레이리스트 조회 요청 - 세션 ID: {}", sessionId);
// 입력 검증
if (sessionId == null || sessionId.trim().isEmpty()) {
return ResponseEntity.badRequest()
.body(SpotifyPlaylistResponse.builder()
.success(false)
.message("세션 ID가 필요합니다.")
.build());
}
try {
SpotifyPlaylistResponse response = spotifyService.getUserPlaylistsWithSession(sessionId);
if (response.isSuccess()) {
log.info("Spotify 플레이리스트 조회 성공 - 플레이리스트 개수: {}",
@@ -66,22 +127,25 @@ public class SpotifyController {
}
/**
* 트랙을 플레이리스트에 추가하거나 새 플레이리스트를 생성합니다.
* 세션 ID를 사용하여 트랙을 플레이리스트에 추가하거나 새 플레이리스트를 생성합니다.
*
* @param request 트랙 추가 요청 (access_token, playlist_id, track_uris 등)
* @param sessionId 세션 ID (헤더)
* @param request 트랙 추가 요청 (playlist_id, track_uris 등)
* @return 트랙 추가 결과
*/
@PostMapping("/playlists/tracks")
@Operation(summary = "Spotify 트랙 추가", description = "기존 플레이리스트에 트랙을 추가하거나 새 플레이리스트를 생성합니다. playlist_id가 없으면 새로 생성합니다.")
public ResponseEntity<SpotifyAddTracksResponse> addTracksToPlaylist(@RequestBody SpotifyAddTracksRequest request) {
log.info("Spotify 트랙 추가 요청 - 플레이리스트 ID: {}", request.getPlaylistId());
public ResponseEntity<SpotifyAddTracksResponse> addTracksToPlaylist(
@RequestHeader("X-Session-Id") String sessionId,
@RequestBody SpotifyAddTracksRequest request) {
log.info("Spotify 트랙 추가 요청 - 플레이리스트 ID: {}, 세션 ID: {}", request.getPlaylistId(), sessionId);
// 입력 검증
if (request.getAccessToken() == null || request.getAccessToken().trim().isEmpty()) {
if (sessionId == null || sessionId.trim().isEmpty()) {
return ResponseEntity.badRequest()
.body(SpotifyAddTracksResponse.builder()
.success(false)
.message("Access Token이 필요합니다.")
.message("세션 ID가 필요합니다.")
.build());
}
@@ -104,7 +168,7 @@ public class SpotifyController {
}
try {
SpotifyAddTracksResponse response = spotifyService.addTracksToPlaylist(request);
SpotifyAddTracksResponse response = spotifyService.addTracksToPlaylistWithSession(sessionId, request);
if (response.isSuccess()) {
log.info("Spotify 트랙 추가 성공 - 플레이리스트 ID: {}, 추가된 트랙 수: {}",
@@ -126,25 +190,27 @@ public class SpotifyController {
}
/**
* Vibe 트랙 정보를 Spotify에서 검색하여 매칭합니다.
* 세션 ID를 사용하여 Vibe 트랙 정보를 Spotify에서 검색하여 매칭합니다.
*
* @param sessionId 세션 ID (헤더)
* @param request Vibe 트랙 정보 리스트 (가수, 제목)
* @return Spotify 검색 결과 (track_uri 포함)
*/
@PostMapping("/search/vibe-tracks")
@Operation(summary = "Vibe 트랙 Spotify 검색", description = "Vibe에서 받은 트랙 정보(가수, 제목)를 Spotify에서 검색하여 track_uri를 반환합니다.")
public ResponseEntity<VibeToSpotifySearchResponse> searchVibeTracksInSpotify(
@RequestHeader("X-Session-Id") String sessionId,
@RequestBody VibeToSpotifySearchRequest request) {
log.info("Vibe 트랙 Spotify 검색 요청 - 트랙 개수: {}",
request.getTracks() != null ? request.getTracks().size() : 0);
log.info("Vibe 트랙 Spotify 검색 요청 - 트랙 개수: {}, 세션 ID: {}",
request.getTracks() != null ? request.getTracks().size() : 0, sessionId);
// 입력 검증
if (request.getAccessToken() == null || request.getAccessToken().trim().isEmpty()) {
if (sessionId == null || sessionId.trim().isEmpty()) {
return ResponseEntity.badRequest()
.body(VibeToSpotifySearchResponse.builder()
.success(false)
.message("Access Token이 필요합니다.")
.message("세션 ID가 필요합니다.")
.build());
}
@@ -157,7 +223,7 @@ public class SpotifyController {
}
try {
VibeToSpotifySearchResponse response = spotifyService.searchTracksFromVibe(request);
VibeToSpotifySearchResponse response = spotifyService.searchTracksFromVibeWithSession(sessionId, request);
if (response.isSuccess()) {
log.info("Vibe 트랙 Spotify 검색 완료 - 전체: {}, 매칭: {}, 미매칭: {}",

View File

@@ -0,0 +1,39 @@
package com.domain.request;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class SpotifyOAuthCallbackRequest {
/**
* OAuth2.0 인증 후 받은 토큰 정보
*/
@JsonProperty("access_token")
private String accessToken;
@JsonProperty("token_type")
private String tokenType;
@JsonProperty("expires_in")
private Integer expiresIn;
@JsonProperty("refresh_token")
private String refreshToken;
@JsonProperty("scope")
private String scope;
/**
* 사용자 식별을 위한 암호화된 userId (선택)
* 없으면 Spotify API로 사용자 정보 조회
*/
@JsonProperty("user_id")
private String userId;
}

View File

@@ -0,0 +1,37 @@
package com.domain.response;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@JsonInclude(JsonInclude.Include.NON_NULL)
public class SpotifyAuthResponse {
private boolean success;
private String message;
/**
* 암호화된 세션 ID (userId 암호화)
*/
@JsonProperty("session_id")
private String sessionId;
/**
* 토큰 만료 시간 (초)
*/
@JsonProperty("expires_in")
private Integer expiresIn;
/**
* Spotify 사용자 ID
*/
@JsonProperty("spotify_user_id")
private String spotifyUserId;
}

View File

@@ -1,8 +1,10 @@
package com.domain.service;
import com.domain.request.SpotifyAddTracksRequest;
import com.domain.request.SpotifyOAuthCallbackRequest;
import com.domain.request.VibeToSpotifySearchRequest;
import com.domain.response.SpotifyAddTracksResponse;
import com.domain.response.SpotifyAuthResponse;
import com.domain.response.SpotifyPlaylistResponse;
import com.domain.response.VibeToSpotifySearchResponse;
import com.fasterxml.jackson.databind.JsonNode;
@@ -34,6 +36,8 @@ public class SpotifyService {
private final ObjectMapper objectMapper;
private final TranslationService translationService;
private final SpotifyTokenService spotifyTokenService;
private final com.global.util.EncryptionUtil encryptionUtil;
private static final String SPOTIFY_API_BASE_URL = "https://api.spotify.com/v1";
private static final String SPOTIFY_ME_PLAYLISTS_URL = SPOTIFY_API_BASE_URL + "/me/playlists?limit=50";
@@ -366,8 +370,200 @@ public class SpotifyService {
}
}
/**
* OAuth2.0 콜백 처리: 토큰을 Redis에 저장하고 암호화된 세션 ID를 반환합니다.
*
* @param request OAuth 콜백 요청 (토큰 정보 포함)
* @return 인증 응답 (세션 ID 포함)
*/
public SpotifyAuthResponse handleOAuthCallback(SpotifyOAuthCallbackRequest request) {
try {
// 1. Spotify API로 사용자 ID 조회
String spotifyUserId = getUserId(request.getAccessToken());
if (spotifyUserId == null) {
return SpotifyAuthResponse.builder()
.success(false)
.message("Spotify 사용자 정보 조회 실패")
.build();
}
log.info("Spotify OAuth 콜백 처리 - Spotify User ID: {}", spotifyUserId);
// 2. userId로 사용 (요청에 userId가 있으면 사용, 없으면 spotifyUserId 사용)
String userId = request.getUserId() != null && !request.getUserId().trim().isEmpty()
? request.getUserId()
: spotifyUserId;
// 3. Redis에 토큰 저장
spotifyTokenService.saveToken(
userId,
request.getAccessToken(),
request.getRefreshToken(),
request.getExpiresIn(),
request.getScope()
);
// 4. userId를 암호화하여 sessionId 생성
String sessionId = encryptionUtil.encrypt(userId);
log.info("Spotify 세션 ID 생성 완료 - userId: {}", userId);
return SpotifyAuthResponse.builder()
.success(true)
.message("인증 성공")
.sessionId(sessionId)
.expiresIn(request.getExpiresIn())
.spotifyUserId(spotifyUserId)
.build();
} catch (Exception e) {
log.error("Spotify OAuth 콜백 처리 중 오류 발생", e);
return SpotifyAuthResponse.builder()
.success(false)
.message("인증 처리 중 오류가 발생했습니다: " + e.getMessage())
.build();
}
}
/**
* 세션 ID(암호화된 userId)를 사용하여 플레이리스트를 조회합니다.
*
* @param sessionId 암호화된 세션 ID
* @return 플레이리스트 목록 응답
*/
public SpotifyPlaylistResponse getUserPlaylistsWithSession(String sessionId) {
try {
// 1. sessionId 복호화
String userId;
try {
userId = encryptionUtil.decrypt(sessionId);
log.info("세션 ID 복호화 성공 - userId: {}", userId);
} catch (Exception e) {
log.error("세션 ID 복호화 실패", e);
return SpotifyPlaylistResponse.builder()
.success(false)
.message("유효하지 않은 세션 ID입니다.")
.build();
}
// 2. Redis에서 Access Token 조회
String accessToken = spotifyTokenService.getAccessToken(userId);
if (accessToken == null) {
log.warn("Access Token을 찾을 수 없음 - userId: {}", userId);
return SpotifyPlaylistResponse.builder()
.success(false)
.message("세션이 만료되었거나 유효하지 않습니다. 다시 로그인해주세요.")
.build();
}
// 3. 플레이리스트 조회
return getUserPlaylists(accessToken);
} catch (Exception e) {
log.error("Spotify 플레이리스트 조회 중 오류 발생", e);
return SpotifyPlaylistResponse.builder()
.success(false)
.message("플레이리스트 조회 중 오류가 발생했습니다: " + e.getMessage())
.build();
}
}
/**
* 세션 ID를 사용하여 트랙을 플레이리스트에 추가합니다.
*
* @param sessionId 암호화된 세션 ID
* @param request 트랙 추가 요청
* @return 트랙 추가 응답
*/
public SpotifyAddTracksResponse addTracksToPlaylistWithSession(String sessionId, SpotifyAddTracksRequest request) {
try {
// 1. sessionId 복호화
String userId;
try {
userId = encryptionUtil.decrypt(sessionId);
log.info("세션 ID 복호화 성공 - userId: {}", userId);
} catch (Exception e) {
log.error("세션 ID 복호화 실패", e);
return SpotifyAddTracksResponse.builder()
.success(false)
.message("유효하지 않은 세션 ID입니다.")
.build();
}
// 2. Redis에서 Access Token 조회
String accessToken = spotifyTokenService.getAccessToken(userId);
if (accessToken == null) {
log.warn("Access Token을 찾을 수 없음 - userId: {}", userId);
return SpotifyAddTracksResponse.builder()
.success(false)
.message("세션이 만료되었거나 유효하지 않습니다. 다시 로그인해주세요.")
.build();
}
// 3. request에 accessToken 설정
request.setAccessToken(accessToken);
// 4. 트랙 추가
return addTracksToPlaylist(request);
} catch (Exception e) {
log.error("Spotify 트랙 추가 중 오류 발생", e);
return SpotifyAddTracksResponse.builder()
.success(false)
.message("트랙 추가 중 오류가 발생했습니다: " + e.getMessage())
.build();
}
}
/**
* 세션 ID를 사용하여 Vibe 트랙을 Spotify에서 검색합니다.
*
* @param sessionId 암호화된 세션 ID
* @param request Vibe 트랙 정보 리스트
* @return Spotify 검색 결과
*/
public VibeToSpotifySearchResponse searchTracksFromVibeWithSession(String sessionId, VibeToSpotifySearchRequest request) {
try {
// 1. sessionId 복호화
String userId;
try {
userId = encryptionUtil.decrypt(sessionId);
log.info("세션 ID 복호화 성공 - userId: {}", userId);
} catch (Exception e) {
log.error("세션 ID 복호화 실패", e);
return VibeToSpotifySearchResponse.builder()
.success(false)
.message("유효하지 않은 세션 ID입니다.")
.build();
}
// 2. Redis에서 Access Token 조회
String accessToken = spotifyTokenService.getAccessToken(userId);
if (accessToken == null) {
log.warn("Access Token을 찾을 수 없음 - userId: {}", userId);
return VibeToSpotifySearchResponse.builder()
.success(false)
.message("세션이 만료되었거나 유효하지 않습니다. 다시 로그인해주세요.")
.build();
}
// 3. request에 accessToken 설정
request.setAccessToken(accessToken);
// 4. 검색 실행
return searchTracksFromVibe(request);
} catch (Exception e) {
log.error("Vibe 트랙 Spotify 검색 중 오류 발생", e);
return VibeToSpotifySearchResponse.builder()
.success(false)
.message("검색 중 오류가 발생했습니다: " + e.getMessage())
.build();
}
}
/**
* 기존 플레이리스트에 트랙을 추가합니다.
* Spotify API 제한으로 인해 100개씩 나눠서 추가합니다.
*
* @param accessToken Spotify Access Token
* @param playlistId 플레이리스트 ID
@@ -375,6 +571,40 @@ public class SpotifyService {
* @return 추가된 트랙 개수
*/
private int addTracksToExistingPlaylist(String accessToken, String playlistId, List<String> trackUris) {
try {
final int BATCH_SIZE = 100; // Spotify API 제한: 한 번에 최대 100개
int totalAdded = 0;
// trackUris를 100개씩 나눠서 처리
for (int i = 0; i < trackUris.size(); i += BATCH_SIZE) {
int endIndex = Math.min(i + BATCH_SIZE, trackUris.size());
List<String> batchUris = trackUris.subList(i, endIndex);
log.info("트랙 추가 중... ({}/{}) - 현재 배치: {} 개",
endIndex, trackUris.size(), batchUris.size());
int addedInBatch = addTracksBatch(accessToken, playlistId, batchUris);
totalAdded += addedInBatch;
}
log.info("트랙 추가 완료 - 총 {} 개", totalAdded);
return totalAdded;
} catch (Exception e) {
log.error("트랙 추가 중 오류 발생", e);
throw new RuntimeException("트랙 추가 실패", e);
}
}
/**
* 배치 단위로 트랙을 플레이리스트에 추가합니다.
*
* @param accessToken Spotify Access Token
* @param playlistId 플레이리스트 ID
* @param trackUris 트랙 URI 목록 (최대 100개)
* @return 추가된 트랙 개수
*/
private int addTracksBatch(String accessToken, String playlistId, List<String> trackUris) {
try {
String addTracksUrl = String.format(SPOTIFY_ADD_TRACKS_URL, playlistId);
@@ -402,21 +632,21 @@ public class SpotifyService {
int statusCode = response.getCode();
String responseBody = EntityUtils.toString(response.getEntity(), StandardCharsets.UTF_8);
log.info("Spotify Add Tracks API 응답 코드: {}", statusCode);
log.debug("Spotify Add Tracks API 응답 코드: {}", statusCode);
if (statusCode == 201) {
log.info("트랙 추가 성공 - {} 개", trackUris.size());
log.debug("배치 트랙 추가 성공 - {} 개", trackUris.size());
return trackUris.size();
} else {
log.error("트랙 추가 실패. 상태 코드: {}, 응답: {}", statusCode, responseBody);
log.error("배치 트랙 추가 실패. 상태 코드: {}, 응답: {}", statusCode, responseBody);
throw new RuntimeException("트랙 추가 실패: " + responseBody);
}
}
}
} catch (Exception e) {
log.error("트랙 추가 중 오류 발생", e);
throw new RuntimeException("트랙 추가 실패", e);
log.error("배치 트랙 추가 중 오류 발생", e);
throw new RuntimeException("배치 트랙 추가 실패", e);
}
}

View File

@@ -0,0 +1,135 @@
package com.domain.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.stereotype.Service;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@Slf4j
@Service
@RequiredArgsConstructor
public class SpotifyTokenService {
private final RedisTemplate<String, Object> redisTemplate;
private static final String SPOTIFY_TOKEN_PREFIX = "spotify:token:";
/**
* userId를 키로 Spotify 토큰 정보를 Redis에 저장합니다.
*
* @param userId 사용자 아이디
* @param accessToken Access Token
* @param refreshToken Refresh Token
* @param expiresIn 만료 시간 (초)
* @param scope 권한 범위
*/
public void saveToken(String userId, String accessToken, String refreshToken, Integer expiresIn, String scope) {
String key = SPOTIFY_TOKEN_PREFIX + userId;
Map<String, String> tokenData = new HashMap<>();
tokenData.put("access_token", accessToken);
tokenData.put("refresh_token", refreshToken);
tokenData.put("scope", scope);
tokenData.put("expires_in", String.valueOf(expiresIn));
redisTemplate.opsForHash().putAll(key, tokenData);
// 토큰 만료 시간보다 조금 짧게 설정 (안전 마진 60초)
long ttlSeconds = expiresIn > 60 ? expiresIn - 60 : expiresIn;
redisTemplate.expire(key, ttlSeconds, TimeUnit.SECONDS);
log.info("Spotify 토큰 저장 완료 - userId: {}, 유효기간: {}초", userId, ttlSeconds);
}
/**
* userId로 Access Token을 조회합니다.
*
* @param userId 사용자 아이디
* @return Access Token (없으면 null)
*/
public String getAccessToken(String userId) {
String key = SPOTIFY_TOKEN_PREFIX + userId;
Object token = redisTemplate.opsForHash().get(key, "access_token");
if (token == null) {
log.info("Redis에 저장된 Spotify 토큰 없음 - userId: {}", userId);
return null;
}
log.debug("Spotify Access Token 조회 - userId: {}", userId);
return (String) token;
}
/**
* userId로 Refresh Token을 조회합니다.
*
* @param userId 사용자 아이디
* @return Refresh Token (없으면 null)
*/
public String getRefreshToken(String userId) {
String key = SPOTIFY_TOKEN_PREFIX + userId;
Object token = redisTemplate.opsForHash().get(key, "refresh_token");
if (token == null) {
log.info("Redis에 저장된 Spotify Refresh Token 없음 - userId: {}", userId);
return null;
}
return (String) token;
}
/**
* userId로 모든 토큰 정보를 조회합니다.
*
* @param userId 사용자 아이디
* @return 토큰 정보 맵 (없으면 null)
*/
@SuppressWarnings("unchecked")
public Map<String, String> getTokenData(String userId) {
String key = SPOTIFY_TOKEN_PREFIX + userId;
Map<Object, Object> entries = redisTemplate.opsForHash().entries(key);
if (entries.isEmpty()) {
log.info("Redis에 저장된 Spotify 토큰 데이터 없음 - userId: {}", userId);
return null;
}
log.debug("Spotify 토큰 데이터 조회 - userId: {}", userId);
return (Map<String, String>) (Map<?, ?>) entries;
}
/**
* userId의 토큰을 삭제합니다.
*
* @param userId 사용자 아이디
*/
public void deleteToken(String userId) {
String key = SPOTIFY_TOKEN_PREFIX + userId;
redisTemplate.delete(key);
log.info("Spotify 토큰 삭제 완료 - userId: {}", userId);
}
/**
* Access Token만 갱신합니다.
*
* @param userId 사용자 아이디
* @param accessToken 새로운 Access Token
* @param expiresIn 만료 시간 (초)
*/
public void updateAccessToken(String userId, String accessToken, Integer expiresIn) {
String key = SPOTIFY_TOKEN_PREFIX + userId;
redisTemplate.opsForHash().put(key, "access_token", accessToken);
redisTemplate.opsForHash().put(key, "expires_in", String.valueOf(expiresIn));
// TTL 갱신
long ttlSeconds = expiresIn > 60 ? expiresIn - 60 : expiresIn;
redisTemplate.expire(key, ttlSeconds, TimeUnit.SECONDS);
log.info("Spotify Access Token 갱신 완료 - userId: {}, 유효기간: {}초", userId, ttlSeconds);
}
}