Ⅰ. 라이브러리 개요

사용자가 추천받은 플레이리스트를 저장하고 다시 찾아볼 수 있는 공간인 라이브러리이다. 2열 그리드로 구성하였고, 각 플레이리스트의 커버는 최상단의 플레이리스트 앨범 커버 4개를 분할하여 보여줬다. 이를 통해 사용자는 플레이리스트 이름과 앨범 커버를 동시에 확인하면서 시각적으로 시원함을 주었다.
라이브러리의 서버 요청을 할 때에는 데이터가 많아질 가능성이 있기 때문에 페이지네이션 방식을 사용하였다. 10개 단위로 끊어서 로드했고, 스크롤 하단 도달 시 다음 페이지를 또 요청하는 방식이다.
라이브러리에 들어오면 일단 우리 서버에서 playlistId 목록을 조회한다. 처음에는 이 아이디를 다시 상세 조회 API로 호출하여 4개의 커버를 가져오는 방식으로 하였는데, 이렇게 되면 for문을 통해 1+4*N 번의 API 호출이 발생하는 문제점이 생겼다.
이를 해결하는 과정에서 Spotify에서도 4분할 커버를 제공한다는 것을 알게 되었고, Spotify API를 호출하여 앨범 커버를 받아오는 로직으로 수정하였고 1+10*N 번의 API 호출로 서버 요청 수를 크게 줄일 수 있었다.
Ⅱ. 라이브러리 API 가져오기

라이브러리 화면의 데이터 로딩 흐름은 다음과 같다.
RecyclerView 세팅
↓
우리 서버에서 라이브러리 목록 조회
↓
Adapter 데이터 추가
↓
Spotify API로 커버 이미지 요청
↓
UI 업데이트
1. 우리 서버에서 현재 사용자의 라이브러리 저장 플레이리스트 목록 가져오기
1) 리사이클러뷰 세팅
라이브러리는 2열 Grid RecyclerView로 구성되어 있다.
또한 스크롤이 끝에 도달하면 다음 페이지를 요청하도록 무한 스크롤 로직을 추가했다.
recyclerView.canScrollVertically(1)
다음의 메소드를 통해 스크롤이 마지막에 도달했는지 판단하였다.
private fun setupRecyclerView() {
libraryAdapter = LibraryAdapter(playlistDataList) { selectedPlaylist ->
handlePlaylistClick(selectedPlaylist)
}
binding.rvPlaylist.apply {
layoutManager = GridLayoutManager(requireContext(), 2)
adapter = libraryAdapter
// 데코레이션 중복 방지
if (itemDecorationCount > 0) removeItemDecorationAt(0)
val spacingInPixels = (5 * resources.displayMetrics.density).toInt()
addItemDecoration(GridSpacingItemDecoration(2, spacingInPixels, true))
// 스크롤 리스너
addOnScrollListener(object : RecyclerView.OnScrollListener() {
override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) {
super.onScrolled(recyclerView, dx, dy)
// 바닥에 닿았는지 확인
if (!recyclerView.canScrollVertically(1) && dy > 0 && !isLoading && !isLastPage) {
isLoading = true
currentPage++
loadLibraryData(currentPage) // 현재 페이지 API 요청하기
}
}
})
}
}
2) 우리 서버에서 라이브러리 목록 조회
private fun loadLibraryData(page: Int) {
RetrofitClient.libraryApi
.getLibraryPlaylists(page = page, size = PAGE_SIZE)
.enqueue(object : Callback<BaseResponse<LibraryPlaylistResponse>> {
override fun onResponse(
call: Call<BaseResponse<LibraryPlaylistResponse>>,
response: Response<BaseResponse<LibraryPlaylistResponse>>
) {
val playlists = response.body()?.data?.playlists
if (playlists.isNullOrEmpty()) {
isLastPage = true
isLoading = false
return
}
if (playlists.size < PAGE_SIZE) {
isLastPage = true
}
addPlaylistsToAdapter(playlists)
}
})
// 통신 실패 시 ...
}
3) 어뎁터 데이터 추가
서버에서 받은 데이터를 RecyclerView Adapter에 먼저 삽입한다.
이 단계에서는 이미지가 아직 없기 때문에 placeholder 상태로 표시된다.
private fun addPlaylistsToAdapter(newApiPlaylists: List<PlaylistDetail>) {
val startPosition = playlistDataList.size // 데이터 추가 전 마지막 위치 기억
newApiPlaylists.forEach { detail ->
playlistDataList.add(
LibraryPlaylistModel(
playlistId = detail.playlistId,
title = detail.playlistName,
songs = emptyList(),
songCount = 10,
mainCoverUrl = null, // 이미지는 잠시 후 Spotify API로 채움
location = detail.location,
goal = detail.goal
)
)
}
// [성능 최적화] 전체 갱신 대신 추가된 범위만 업데이트
libraryAdapter.notifyItemRangeInserted(startPosition, newApiPlaylists.size)
isLoading = false
// 추가된 아이템들에 대해서만 Spotify 커버 이미지 가져오기 실행
fetchSpotifyCovers(startPosition, newApiPlaylists)
}
2. Spotify API로 커버 이미지 요청
1) Spotify API Documetation - Cover Image
스포티파이에 플레이리스트 아이디를 넘기면 사이즈별 이미지 URL 리스트를 반환한다. Endpoint와 서버 용청 interface는 다음과 같다.
// 해당 플리 id 요청 -> 라이브러리 4분할 커버 응답
@GET("v1/playlists/{playlist_id}/images")
fun getPlaylistCoverImage(
@Header("Authorization") token: String,
@Path("playlist_id") playlistId: String
): Call<List<PlaylistCoverImageResponse>>
[Response Format]
640x640, 300x300, 60x60 사이즈의 이미지가 응답으로 온다.
[
{
"url": "https://i.scdn.co/image/ab67616d00001e02ff9...",
"height": 640,
"width": 640
},
// ... 300x300, 60x60 사이즈 생략
]
2) Spotify API를 활용한 커버 이미지 동기화
각 플레이리스트의 Spotify ID를 이용하여 커버 이미지를 가져온다.
private fun fetchSpotifyCovers(startIndex: Int, newItems: List<PlaylistDetail>) {
val token = searchToken ?: return
for ((i, playlist) in newItems.withIndex()) {
val globalIndex = startIndex + i
val spotifyId = playlist.spotifyPlaylistId.toString()
SpotifyClient.api.getPlaylistCoverImage("Bearer $token", spotifyId)
.enqueue(object : Callback<List<PlaylistCoverImageResponse>> {
override fun onResponse(...) {
if (response.isSuccessful) {
val images = response.body()
if (!images.isNullOrEmpty()) {
// 받아온 이미지 URL로 해당 인덱스의 아이템만 교체
updateSinglePlaylistCover(globalIndex, images[0].url)
}
}
}
// ... 생략
})
}
}
Ⅲ. 라이브러리 상세 조회

1) 상세 정보 로드 및 데이터 동기화
라이브러리 화면에서 하나의 플레이리스트를 선택하면 상세 조회와 딥링크 연결을 할 수 있는 프래그먼트로 넘어오게 된다. Bundle로 넘어온 최소한의 정볼르 먼저 뿌려주고, 백그라운드에서 서버 API를 호출하여 전체 데이터를 완성한다.
// API 호출 및 상세 정보 저장
if (currentPlaylistId != null) {
fetchPlaylistDetail(currentPlaylistId!!)
}
private fun fetchPlaylistDetail(id: String) {
RetrofitClient.libraryApi.getPlaylistDetail(id).enqueue(object : Callback<BaseResponse<LibraryPlaylistDetailResponse>> {
override fun onResponse(call: Call<BaseResponse<LibraryPlaylistDetailResponse>>, response: Response<BaseResponse<LibraryPlaylistDetailResponse>>) {
if (response.isSuccessful) {
val data = response.body()?.data
if (data != null) {
// ★ 데이터를 멤버 변수에 저장 (나중에 수정/삭제 시 참조)
currentPlaylistData = data
updateUI(data) // 화면 갱신
}
}
}
// ... 생략
})
}
2) 더보기 바텀시트
![]() |
![]() |
|---|
우측 상단의 점 3개 버튼인 "더보기"를 누르면 이름 수정과 삭제를 할 수 있는 바텀시트가 올라온다.
private fun showMoreBottomSheet() {
val data = currentPlaylistData ?: return
val bottomSheetDialog = BottomSheetDialog(requireContext())
val view = layoutInflater.inflate(R.layout.bottom_sheet_library_more, null)
// ... UI 세팅 (앨범 커버 4분할 로드 등) ...
// 수정 버튼 클릭 시
view.findViewById<TextView>(R.id.tvEditName).setOnClickListener {
bottomSheetDialog.dismiss()
showEditNameBottomSheet(data.playlistName) // 수정창 열기
}
// 삭제 버튼 클릭 시
view.findViewById<TextView>(R.id.tvDelete).setOnClickListener {
bottomSheetDialog.dismiss()
showDeleteBottomSheet(data.playlistId.toString()) // 삭제 확인창 열기
}
bottomSheetDialog.show()
}
3) 라이브러리에서 삭제하기
삭제 버튼을 누르면 바로 서버 API를 호출한다. 성공하면 사용자에게 토스트로 삭제되었다는 것을 알리고, 현재 상세 화면을 닫아버린다(popBackStack).
private fun deletePlaylistOnServer(id: String, dialogToClose: BottomSheetDialog) {
RetrofitClient.libraryApi.deletePlaylist(id).enqueue(object : Callback<BaseResponse<String>> {
override fun onResponse(call: Call<BaseResponse<String>>, response: Response<BaseResponse<String>>) {
if (response.isSuccessful) {
// 1. 성공 피드백 (휴지통 아이콘이 포함된 토스트)
showCustomToast("플레이리스트가 삭제됐어요", isDelete = true)
// 2. 바텀시트 닫기
dialogToClose.dismiss()
// 3. [중요] 상세 화면 종료 및 목록으로 돌아가기
findNavController().popBackStack()
} else {
showCustomToast("삭제를 실패했습니다.")
}
}
override fun onFailure(call: Call<BaseResponse<String>>, t: Throwable) {
showCustomToast("네트워크 오류가 발생했습니다.")
}
})
}
Ⅳ. 마무리 회고
중간에 한 번 시간복잡도 문제가 나와서 당황했다..ㅋㅋㅋㅋ 근데 이렇게 요청하면 서버에서도 돈이 더 나가고 비효율적이기 때문에 고쳐야 하긴 했다. 찾아보면서 스포티파이에서 제공하는 API가 다양하다는 것을 알게 되었다. (스포티파이야 고마워..🥹)
그리고 초기 라이브러리 화면에서 상세 화면으로 진입할 때와 바텀시트에 적용하기 위한 데이터를 불러올 때마다 API를 호출하기 번거롭고 네트워크 응답이 느릴 경우 화면이 비어 있는 상태가 나타나는 문제가 있었다. 그래서 Bundle을 통해 이전 화면에서 전달받은 데이터를 먼저 사용하도록 했더니 체감 로딩 시간을 줄일 수 있었던 것 같다.
라이브러리는 UI 로딩도 많은 프래그먼트인데 MVVM을 사용 안 한 것이 약간 후회되는 점이기도 하다. 프로젝트 규모가 커지면 로
직을 분리하면 더 효율적일 것 같다.
👾👉 DIP Android Github
📽️👉 풀시연영상 보러가기
'TAVE-16th' 카테고리의 다른 글
| [DIP/FE] 구글 플레이스토어 앱 출시하기- 14일 비공개 테스트 (0) | 2026.03.08 |
|---|---|
| [DIP/FE] 둘러보기 - 카테고리별 API 호출하기 (0) | 2026.03.08 |
| [DIP/FE] Playlist 추천 결과 - 리스트형 & 갤러리형 보기(ViewPager2) (0) | 2026.03.08 |
| [DIP/FE] 노래 추천받기(2) - Polling 방식으로 서버 요청 보내기 (0) | 2026.03.08 |
| [DIP/FE] 노래 추천받기(1) - 장소 선택, 데시벨 측정, 목표 설정 (0) | 2026.03.07 |


