Spotify OAuth + Web Playback SDK 통합 — 3-tier 재생 엔진 (Premium SDK / iTunes preview / YouTube) — POP-SPOT v2.21 S10~S18
Spotify OAuth 백엔드(AES-256-GCM 토큰 암호화) + Web Playback SDK 프론트 통합 + iTunes preview 폴백 + YouTube 폴백의 3-tier 재생 엔진. Premium 은 320kbps 풀트랙, Free/미연결은 30~90초 preview, 외엔 YouTube 폴백. S17/S18 에서 운영 버그 네 건 추가 수정.
v2.21 의 하이라이트. v1.3 이후 YouTube 재생만 쓰이던 구조에 Spotify OAuth + Web Playback SDK 를 추가해 Premium 사용자는 풀트랙 320kbps, 그 외는 iTunes preview 90초, 마지막은 YouTube 로. 최단 세그먼트에서 몇 개 운영 버그 추가 수정.
이 글에서 다루는 것
모르는 단어 한 줄로
| 용어 | 한 줄 설명 |
|---|---|
| OAuth | "권한 주기" 표준. 사용자가 Spotify 에 직접 로그인 후 우리에게 토큰을 주는 흐름 |
| AES-256-GCM | 대칭 암호화 방식. 인증 태그도 같이 있어서 도중 변조 검증까지 동시에 |
| Web Playback SDK | Spotify 의 자바스크립트 SDK. 브라우저에서 풀트랙 재생 가능. Premium 필수 |
| preview_url | Spotify 의 30초 맛보기 음원. Free 사용자도 듣기 가능했으나 2024-11 이후 신규 앱은 제공 X |
| 3-tier | 세 단계 폴백. 우선순위 높은 게 안 되면 다음 단계 |
S10 — Spotify OAuth 백엔드
엔티티 + 암호화
파일: popspot-backend/src/main/java/com/popspot/entity/SpotifyAuth.java (v2.21 S10 신규)
// v2.21 S10 — SpotifyAuth.java (신규 엔티티)
@Entity
public class SpotifyAuth {
@Id @GeneratedValue
private Long id;
@Column(nullable = false, unique = true)
private Long userId;
@Column(nullable = false, length = 512)
private String accessTokenEncrypted; // AES-256-GCM 암호화됨
@Column(nullable = false, length = 512)
private String refreshTokenEncrypted;
@Column(nullable = false)
private LocalDateTime expiresAt;
@Column(length = 32)
private String product; // "premium" / "free"
}해석
암호화 서비스
파일: popspot-backend/src/main/java/com/popspot/security/TokenEncryption.java (v2.21 S10 신규)
// v2.21 S10 — TokenEncryption.java (신규). AES-256-GCM 암·복호화
@Service
public class TokenEncryption {
private final SecretKey key;
private static final int IV_LENGTH = 12;
private static final int TAG_LENGTH = 128;
public TokenEncryption(@Value("${TOKEN_ENCRYPTION_KEY}") String base64Key) {
byte[] keyBytes = Base64.getDecoder().decode(base64Key);
if (keyBytes.length != 32) {
throw new IllegalStateException("TOKEN_ENCRYPTION_KEY 는 32 바이트");
}
this.key = new SecretKeySpec(keyBytes, "AES");
}
public String encrypt(String plaintext) {
byte[] iv = new byte[IV_LENGTH];
secureRandom.nextBytes(iv);
Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding");
cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(TAG_LENGTH, iv));
byte[] cipherText = cipher.doFinal(plaintext.getBytes(UTF_8));
ByteBuffer buf = ByteBuffer.allocate(IV_LENGTH + cipherText.length);
buf.put(iv).put(cipherText);
return Base64.getUrlEncoder().withoutPadding().encodeToString(buf.array());
}
}해석 — 한 줄씩
OAuth 흐름
파일: popspot-backend/src/main/java/com/popspot/controller/SpotifyAuthController.java
// v2.21 S10 — SpotifyAuthController.initiateLogin()
@GetMapping("/login")
public ResponseEntity<Void> initiateLogin(Authentication auth) {
String state = oauthStateService.issueState();
String url = UriComponentsBuilder
.fromHttpUrl("https://accounts.spotify.com/authorize")
.queryParam("client_id", clientId)
.queryParam("response_type", "code")
.queryParam("redirect_uri", redirectUri)
.queryParam("scope", "streaming user-read-email user-read-private")
.queryParam("state", state)
.build().toUriString();
return ResponseEntity.status(302).location(URI.create(url)).build();
}
@GetMapping("/callback")
public ResponseEntity<Void> handleCallback(
@RequestParam String code,
@RequestParam String state,
Authentication auth) {
if (!oauthStateService.consumeState(state)) {
throw new BadRequestException("유효하지 않은 state");
}
TokenResponse tokens = spotifyClient.exchangeCode(code);
Long userId = Long.parseLong(auth.getName());
spotifyAuthService.saveTokens(userId, tokens);
return ResponseEntity.status(302)
.location(URI.create("/?tab=MUSIC&spotify=connected"))
.build();
}해석
S11.1 — 401 핫픽스
증상
v2.21-S11 배포 직후 모든 Spotify endpoint 가 401. 로그인 했으면서도 서버가 "너 누구야" 라고 대답.
원인
파일: 같은 SpotifyAuthController.java (S11 최초 구현)
// v2.21 S11 — SpotifyAuthMe 의 잘못된 @AuthenticationPrincipal 타입 (UserDetails)
@GetMapping("/me")
public SpotifyAuthMe me(@AuthenticationPrincipal UserDetails user) {
// ^^^^^^^^^^^^^^^^^^^^^^^^ SecurityConfig 가 String 의 주입 → 불일치 → null → NPE → 401
Long userId = Long.parseLong(user.getUsername());
// ...
}@AuthenticationPrincipal 의 타입이 UserDetails 로 되어있는데 SecurityConfig 에서 등록한 principal 은 단순 String. 둘이 안 맞음 → null → NullPointerException → GlobalExceptionHandler 가 401 처리.
수정
파일: 같은 SpotifyAuthController.java (S11.1 수정 후)
// v2.21 S11.1 — 다른 엔드포인트와 동일한 Authentication 패턴으로 교체
@GetMapping("/me")
public SpotifyAuthMe me(Authentication auth) {
Long userId = Long.parseLong(auth.getName());
// ...
}Authentication 으로 바꾸고 auth.getName() 으로 userId String 읽기. 모든 다른 엔드포인트와 동일한 패턴.
교훈 — 새 컨트롤러 추가 시 다른 컨트롤러를 참고해서 존재하는 패턴 따라갈 것. 교과서·스옥 예제를 그대로 복사하면 활용 없는 설정과 충돌.
S12~S14 — 3-tier 재생 엔진
useSpotifyPlayer (SDK)
파일: popspot-frontend/src/components/music/useSpotifyPlayer.ts (v2.21 S12 신규)
// v2.21 S12 — useSpotifyPlayer.ts (신규). Spotify Web Playback SDK 래퍼
export function useSpotifyPlayer() {
const [player, setPlayer] = useState<Spotify.Player | null>(null);
useEffect(() => {
window.onSpotifyWebPlaybackSDKReady = () => {
const p = new window.Spotify.Player({
name: 'POP-SPOT Player',
getOAuthToken: async (cb) => {
const token = await fetchAccessToken(); // 백엔드에서 육입·갱신
cb(token);
},
volume: 0.8,
});
p.connect();
setPlayer(p);
};
const script = document.createElement('script');
script.src = 'https://sdk.scdn.co/spotify-player.js';
script.async = true;
document.body.appendChild(script);
}, []);
return player;
}해석
usePreviewPlayer (HTML5 audio)
파일: popspot-frontend/src/components/music/usePreviewPlayer.ts (v2.21 S13 신규)
// v2.21 S13 — usePreviewPlayer.ts (신규). HTML5 audio 래퍼
export function usePreviewPlayer() {
const audioRef = useRef<HTMLAudioElement | null>(null);
useEffect(() => {
audioRef.current = new Audio();
}, []);
const play = async (url: string) => {
if (!audioRef.current) return;
audioRef.current.src = url;
try {
await audioRef.current.play();
} catch (e) {
// 자동재생 차단 — 다음 클릭 시 재시도
console.warn('autoplay blocked');
}
};
return { play };
}해석
MusicPlayerProvider 통합
파일: popspot-frontend/src/components/music/MusicPlayerProvider.tsx (v1.3 이후 지속 교체)
// v2.21 S14 — MusicPlayerProvider.tsx. 3-tier 라우팅
export function MusicPlayerProvider({ children }) {
const spotifyPlayer = useSpotifyPlayer();
const previewPlayer = usePreviewPlayer();
const youtubePlayer = useYouTubePlayer();
async function play(track: Track) {
const isPremium = await checkPremium();
// tier 1: Spotify SDK (풀트랙)
if (isPremium && spotifyPlayer && track.spotifyTrackId) {
await spotifyPlayer.resume({ uri: `spotify:track:${track.spotifyTrackId}` });
return;
}
// tier 2: iTunes preview (90 초)
if (track.itunesPreviewUrl) {
await previewPlayer.play(track.itunesPreviewUrl);
return;
}
// tier 3: YouTube (폴백)
if (track.youtubeVideoId) {
await youtubePlayer.play(track.youtubeVideoId);
return;
}
// 아무것도 안 되면
onError({ message: '재생 소스 없음' });
}
return <Context.Provider value={{ play }}>{children}</Context.Provider>;
}해석
S13.1 — Premium 인데 YouTube 나오던 버그
증상
Premium 사용자 로그인 + Spotify 연결 다 했는데 이상하게 YouTube 로 나옴.
원인
파일: 같은 MusicPlayerProvider.tsx (S13 와에 있던 오타)
// v2.21 S13 까지 — 올바른 Spotify 필드는 spotifyTrackId. 이 코드는 itunesTrackId 를 쓰니 대부분 null → tier 3 으로 떨어짐
if (isPremium && spotifyPlayer && track.itunesTrackId) { // 필드 이름 잘못!track.itunesTrackId 는 iTunes 용 필드. Spotify 트랙 ID 는 track.spotifyTrackId. 대부분 row 의 itunesTrackId 가 null → 항상 false → tier 3 (YouTube) 로 떨어짐.
수정
// v2.21 S13.1 — spotifyTrackId 로 수정
if (isPremium && spotifyPlayer && track.spotifyTrackId) {TypeScript 가 잡아줟으면 좋았을 부분이지만 둘 다 string | undefined 라 타입 레벨에서는 충돌 안 됨. 일반적인 쿨 점검으로는 잡기 어려운 버그.
S15 — iTunes preview 폴백
해결해야 했던 문제
2024-11 이후 Spotify 가 신규 앱의 preview_url 제공 중단. 운영 DB 233 곡 조회시 preview_url 있는 트랙 0 개. Free 사용자는 더 이상 30 초 맛보기도 불가.
대체 — iTunes Search API
iTunes Search API 가 곡별 90 초 preview_url 제공. 무료, 인증 불필요, 동일 국가 코드와 매칭 괜찮음.
파일: popspot-backend/src/main/java/com/popspot/service/music/ITunesPreviewService.java (v2.21 S15 신규)
// v2.21 S15 — ITunesPreviewService.java (신규). Spotify preview_url 중단 대체
@Service
public class ITunesPreviewService {
private final RestTemplate restTemplate;
public Optional<String> findPreviewUrl(String artist, String title) {
String url = UriComponentsBuilder
.fromHttpUrl("https://itunes.apple.com/search")
.queryParam("term", artist + " " + title)
.queryParam("entity", "song")
.queryParam("limit", 5)
.queryParam("country", "KR")
.build().toUriString();
ITunesResponse response = restTemplate.getForObject(url, ITunesResponse.class);
if (response == null || response.results().isEmpty()) {
return Optional.empty();
}
return response.results().stream()
.filter(r -> matchesArtist(r.artistName(), artist))
.findFirst()
.map(ITunesResponse.Result::previewUrl);
}
}해석
S17 — Spotify 진행바 멈췄 수정
증상
Spotify 풀트랙 재생 중 진행바가 멈춰 있다가 다음 곡으로 넘어갈 때만 갱신됨.
원인
SDK 의 player_state_changed 이벤트는 트랙 변경·재생/일시정지 · seek 등 이벤트가 있을 때만 발행. "1초 경과" 같은 주기적 신호는 없음.
수정
파일: popspot-frontend/src/components/music/useSpotifyPlayer.ts (S17 수정)
// v2.21 S17 — 이벤트 없이도 500ms 폴링으로 진행바 갱신
useEffect(() => {
const interval = setInterval(async () => {
if (!spotifyPlayer) return;
const state = await spotifyPlayer.getCurrentState();
if (state && !state.paused) {
setPosition(state.position);
setDuration(state.duration);
}
}, 500);
return () => clearInterval(interval);
}, [spotifyPlayer]);해석
핵심 파일
백엔드
프론트