백엔드프론트엔드트러블슈팅

Spotify OAuth + Web Playback SDK 통합 — 3-tier 재생 엔진 (Premium SDK / iTunes preview / YouTube) — POP-SPOT v2.21 S10~S18

김동현
··8분 읽기

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 로. 최단 세그먼트에서 몇 개 운영 버그 추가 수정.

이 글에서 다루는 것

  • Spotify OAuth 백엔드 5 개 엔드포인트와 AES-256-GCM 토큰 암호화
  • 프론트 useSpotifyAuth 훅 + SpotifyConnectButton 3 상태 (미연결/Premium/Free)
  • usePreviewPlayer (HTML5 audio) + useSpotifyPlayer (SDK) 구조
  • 3-tier 재생 우선순위 이유
  • v2.21-S11.1 — @AuthenticationPrincipal UserDetails 타입 불일치 핫픽스
  • v2.21-S13.1 — Premium 인데 YouTube 나오던 버그
  • v2.21-S15 — Spotify preview_url 차단 (2024-11) 이후 iTunes 대체
  • v2.21-S17~S18 — 진행바 안 맞는 건, Spotify 콜백 처리 등 세 건
  • 모르는 단어 한 줄로

    용어한 줄 설명
    OAuth"권한 주기" 표준. 사용자가 Spotify 에 직접 로그인 후 우리에게 토큰을 주는 흐름
    AES-256-GCM대칭 암호화 방식. 인증 태그도 같이 있어서 도중 변조 검증까지 동시에
    Web Playback SDKSpotify 의 자바스크립트 SDK. 브라우저에서 풀트랙 재생 가능. Premium 필수
    preview_urlSpotify 의 30초 맛보기 음원. Free 사용자도 듣기 가능했으나 2024-11 이후 신규 앱은 제공 X
    3-tier세 단계 폴백. 우선순위 높은 게 안 되면 다음 단계

    S10 — Spotify OAuth 백엔드

    엔티티 + 암호화

    파일: popspot-backend/src/main/java/com/popspot/entity/SpotifyAuth.java (v2.21 S10 신규)

    java
    // 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"
    }

    해석

  • accessTokenEncrypted — 토큰을 날것으로 저장하면 DB 유출 시 모든 사용자 Spotify 계정 탈취. 암호화 필수
  • userId unique — 한 사용자당 Spotify 계정 하나
  • product — Premium 일 때만 Web Playback SDK 쓴다
  • 암호화 서비스

    파일: popspot-backend/src/main/java/com/popspot/security/TokenEncryption.java (v2.21 S10 신규)

    java
    // 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());
      }
    }

    해석 — 한 줄씩

  • IV_LENGTH = 12 — GCM 의 권장 IV 길이. 호출마다 새 IV — 같은 평문이어도 암호문이 달라져 패턴 추론 불가
  • TAG_LENGTH = 128 — 인증 태그 길이. 복호화 시 태그 불일치 확인 → 이상이 있으면 예외. 도중 변조 감지 가능
  • keyBytes.length != 32 검증 — 32 바이트 = 256 비트. AES-256 필수. 부팅 시 실패 — 설정 오류 조기 발견
  • IV 와 암호문을 하나로 묶음 — 이후 복호화 시 앞 12 바이트는 IV, 나머지는 암호문
  • Base64 URL-safe — / + 같은 URL 특수문자 없이 저장·전송 안전
  • OAuth 흐름

    파일: popspot-backend/src/main/java/com/popspot/controller/SpotifyAuthController.java

    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();
    }

    해석

  • scope: streaming user-read-email user-read-private — 최소 권한. streaming 은 Web Playback SDK 필수
  • consumeState — v2.19 의 1회용 state. CSRF 차단
  • 성공 시 /?tab=MUSIC&spotify=connected 로 리다이렉트 — 메인의 음악 탭으로 바로 돌아감. 쿼리의 spotify 값을 프론트가 읽어 토스트 표시

  • S11.1 — 401 핫픽스

    증상

    v2.21-S11 배포 직후 모든 Spotify endpoint 가 401. 로그인 했으면서도 서버가 "너 누구야" 라고 대답.

    원인

    파일: 같은 SpotifyAuthController.java (S11 최초 구현)

    java
    // 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 수정 후)

    java
    // 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 신규)

    typescript
    // 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;
    }

    해석

  • getOAuthToken — 리프레시토큰으로 아액세스토큰 갱신하는 콜백. 서버에 맡김 — 테크·압출 쿠키 포함. 프론트에 아액세스토큰 노출 X
  • connect() — SDK 초기화. 브라우저는 Spotify 의 "가상 재생 기기" 로 등록됨
  • script.async = true — 페이지 도달 이후에 로드. 이첤 다운로드 안 먹음
  • usePreviewPlayer (HTML5 audio)

    파일: popspot-frontend/src/components/music/usePreviewPlayer.ts (v2.21 S13 신규)

    typescript
    // 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 };
    }

    해석

  • audioRef — audio 엘레먼트를 하나만 재사용. 새 Audio() 매번 생성하면 브라우저 제스처 컴텍스트 잌어 버리고 자동재생 차단 → 제스처 없이 서버 답변 후 play() 호출하면 브라우저가 차단. 세션 unlock 이 계속 끊어짐
  • audio 엘레먼트 재사용 → 첫 클릭으로 unlock 된 그 하나를 계속 쓴다 → 비동기 이후 play 도 OK
  • MusicPlayerProvider 통합

    파일: popspot-frontend/src/components/music/MusicPlayerProvider.tsx (v1.3 이후 지속 교체)

    typescript
    // 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>;
    }

    해석

  • 우선순위 명확 — Premium 은 풀트랙 320kbps, Free 는 90 초 깨끗한 음원, 아무것도 없으면 YouTube 폴백
  • 차례대로 폴백 — 각 player 는 독립적으로 구현. 없으면 (null) 다음으로

  • S13.1 — Premium 인데 YouTube 나오던 버그

    증상

    Premium 사용자 로그인 + Spotify 연결 다 했는데 이상하게 YouTube 로 나옴.

    원인

    파일: 같은 MusicPlayerProvider.tsx (S13 와에 있던 오타)

    typescript
    // 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) 로 떨어짐.

    수정

    typescript
    // 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 신규)

    java
    // 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);
      }
    }

    해석

  • country=KR — 한국 카탈로그 우선. K-pop 매칭에 좋음
  • entity=song — 앨범·뺮 아닌 곡만
  • matchesArtist — 곡 제목은 비슷하지만 아티스트가 다른 cover 차단. v2.14 의 제목 검증 로직과 비슷한 아이디어

  • S17 — Spotify 진행바 멈췄 수정

    증상

    Spotify 풀트랙 재생 중 진행바가 멈춰 있다가 다음 곡으로 넘어갈 때만 갱신됨.

    원인

    SDK 의 player_state_changed 이벤트는 트랙 변경·재생/일시정지 · seek 등 이벤트가 있을 때만 발행. "1초 경과" 같은 주기적 신호는 없음.

    수정

    파일: popspot-frontend/src/components/music/useSpotifyPlayer.ts (S17 수정)

    typescript
    // 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]);

    해석

  • 500ms 폴링 — 너무 자주는 CPU 탐, 너무 많으면 진행바 거칤없음. 500ms 가 좋은 균형
  • !state.paused — 일시정지일 때는 업데이트 안함

  • 핵심 파일

    백엔드

  • SpotifyAuth.java (엔티티)
  • SpotifyAuthRepository.java
  • TokenEncryption.java
  • SpotifyOAuthService.java
  • SpotifyAuthController.java
  • ITunesPreviewService.java
  • V13__spotify_auth.sql
  • 프론트

  • useSpotifyAuth.ts
  • SpotifyConnectButton.tsx
  • usePreviewPlayer.ts
  • useSpotifyPlayer.ts
  • 추가 수정: MusicService · MusicPlayerProvider · GlobalMusicPlayer · MusicTab · next.config CSP

  • 관련 글

  • 이전 — v2.21 S1~S9, BROWSE + SEO + 음악 안정화
  • 다음 — v2.22, 전면 보안 감사 (IDOR·XSS·메모리 누수)
  • 공유

    댓글