Conversation
There was a problem hiding this comment.
Pull request overview
Spotify 검색 기능을 Add 탭에 연결하기 위해 Spotify API 서비스/모델을 추가하고, Add 탭 UI를 검색 중심 구조로 리팩토링한 PR입니다.
Changes:
- Spotify 토큰 발급 및 트랙 검색 API 연동(서비스/모델) 추가
- Add 탭 UI를 검색 필드 + 로딩/에러/빈결과/리스트 상태로 재구성
- 프로젝트 버전/빌드 넘버 업데이트 및 Info.plist에 Spotify 인증 설정 키 추가
Reviewed changes
Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| KillingPart/Views/Screens/Main/Add/Components/AddTrackListView.swift | Spotify 트랙 리스트/행/아트워크 표시 UI 추가 |
| KillingPart/Views/Screens/Main/Add/Components/AddSearchFieldView.swift | 검색 입력 필드 및 clear 버튼 컴포넌트 추가 |
| KillingPart/Views/Screens/Main/Add/Components/AddSearchContentView.swift | 로딩/에러/빈 결과/리스트 상태별 콘텐츠 뷰 추가 |
| KillingPart/Views/Screens/Main/Add/AddTabView.swift | Add 탭을 검색 기반 화면으로 리팩토링하고 VM 연결 |
| KillingPart/ViewModels/Add/AddTabViewModel.swift | 검색 상태/태스크 관리 및 Spotify 검색 호출 로직 추가 |
| KillingPart/Services/SpotifyService.swift | Spotify 토큰 발급 및 트랙 검색 네트워크 레이어 추가 |
| KillingPart/Models/SpotifyModel.swift | Spotify 응답 디코딩 모델 및 화면용 간단 트랙 모델 추가 |
| KillingPart/Info.plist | SPOTIFY_BASIC_AUTH 키 추가(빌드 설정 치환) |
| KillingPart.xcodeproj/project.pbxproj | 마케팅/빌드 버전 증가(1.0.6/6) |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| private func fetchAccessToken(forceRefresh: Bool) async throws -> String { | ||
| if !forceRefresh, let cachedToken = await Self.tokenCache.validToken() { | ||
| return cachedToken | ||
| } | ||
|
|
||
| let basicAuth = try spotifyBasicAuth() | ||
| guard let url = URL(string: "https://accounts.spotify.com/api/token") else { | ||
| throw SpotifyServiceError.invalidResponse | ||
| } | ||
|
|
||
| var request = URLRequest(url: url) | ||
| request.httpMethod = HTTPMethod.post.rawValue | ||
| request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") | ||
| request.setValue("application/json", forHTTPHeaderField: "Accept") | ||
| request.setValue(basicAuth, forHTTPHeaderField: "Authorization") | ||
| request.httpBody = "grant_type=client_credentials".data(using: .utf8) | ||
|
|
||
| let (data, response) = try await performDataTask(with: request) | ||
| switch response.statusCode { | ||
| case 200..<300: | ||
| let decoded: SpotifyTokenResponse | ||
| do { | ||
| decoded = try decoder.decode(SpotifyTokenResponse.self, from: data) | ||
| } catch { | ||
| throw SpotifyServiceError.decodingFailed | ||
| } | ||
| await Self.tokenCache.save( | ||
| token: decoded.accessToken, | ||
| expiresIn: decoded.expiresIn | ||
| ) | ||
| return decoded.accessToken | ||
| case 401: | ||
| await Self.tokenCache.clear() | ||
| throw SpotifyServiceError.unauthorized | ||
| default: | ||
| throw SpotifyServiceError.serverError( | ||
| statusCode: response.statusCode, | ||
| message: responseMessage(from: data) | ||
| ) | ||
| } | ||
| } | ||
|
|
||
| private func spotifyBasicAuth() throws -> String { | ||
| let rawValue = (Bundle.main.object(forInfoDictionaryKey: "SPOTIFY_BASIC_AUTH") as? String ?? "") | ||
| .trimmingCharacters(in: .whitespacesAndNewlines) | ||
| guard !rawValue.isEmpty, rawValue != "$(SPOTIFY_BASIC_AUTH)" else { | ||
| throw SpotifyServiceError.missingBasicAuth | ||
| } | ||
| return rawValue | ||
| } |
There was a problem hiding this comment.
현재 구현은 Spotify Client Credentials 플로우를 위해 SPOTIFY_BASIC_AUTH(클라이언트 ID/Secret 기반 Basic 인증값)를 앱 번들(Info.plist)에서 읽어 토큰을 발급받습니다. 클라이언트 앱에 client secret(또는 그로부터 생성된 Basic auth)을 포함하면 추출이 가능해 보안상 취약하며, Spotify 정책/일반적인 OAuth 보안 관점에서도 권장되지 않습니다. 토큰 발급은 백엔드에서 프록시로 처리하거나, 사용자 인증이 필요하다면 Authorization Code + PKCE 등 secret이 필요 없는 플로우로 전환하는 방향으로 수정해 주세요.
| <key>SPOTIFY_BASIC_AUTH</key> | ||
| <string>$(SPOTIFY_BASIC_AUTH)</string> |
There was a problem hiding this comment.
SPOTIFY_BASIC_AUTH 값을 Info.plist에 포함시키면(빌드 설정 치환이더라도) 최종 앱 번들에 노출되어 역공학으로 추출될 수 있습니다. Spotify client secret/Basic auth를 클라이언트에 포함하지 않도록 구성(백엔드 프록시/PKCE 전환 등)으로 변경하는 게 안전합니다.
| <key>SPOTIFY_BASIC_AUTH</key> | |
| <string>$(SPOTIFY_BASIC_AUTH)</string> |
| albumImageUrl: item.album.images.sorted { (lhs, rhs) in | ||
| (lhs.width ?? 0) > (rhs.width ?? 0) | ||
| }.first?.url, |
There was a problem hiding this comment.
앨범 이미지 선택을 위해 images.sorted { ... }.first로 매번 전체 정렬을 수행하고 있는데, 여기서는 가장 큰 width 1개만 필요하므로 max(by:) 등으로 최대값만 선택하면 불필요한 정렬 비용을 줄일 수 있습니다 (limit을 늘릴 경우에도 유리).
| albumImageUrl: item.album.images.sorted { (lhs, rhs) in | |
| (lhs.width ?? 0) > (rhs.width ?? 0) | |
| }.first?.url, | |
| albumImageUrl: item.album.images.max { (lhs, rhs) in | |
| (lhs.width ?? 0) < (rhs.width ?? 0) | |
| }?.url, |
작업내용