[DIP/FE] 온보딩 구현 - 문자열 검사 및 Spotify API로 아티스트 목록 가져오기

2026. 3. 7. 01:15·TAVE-16th

온보딩 개요

로그인 진행 후 최초 1회 실행되는 온보딩으로 닉네임, 아티스트 취향, 장르 취향을 설정한다.

 

닉네임은 한글은 1-10자 이내, 영문은 2-20자 이내로 설정하는 제한을 두었다. 프론트엔드에서 1차적으로 문자열 검사를 하고, 추가적으로 백엔드에서도 검증 로직을 한 번 더 거친다.

 

아티스트 취향은 Spotify API를 활용하였다. Spotify에서 제공하는 아티스트 정보를 기반으로 사용자가 선호하는 아티스트를 선택할 수 있게 구현하였다. Spotify API를 사용하는 함수와 구현 방식은 밑에서 자세히 설명할 예정이다.

 

장르의 경우 선택 가능한 항목이 약 10개 정도로 적기 때문에 별도의 API를 호출하지 않고 미리 장르 리스트를 정의하여 사용하였다.

 

+ 모드 설정

온보딩에서 사용하는 3개의 프래그먼트는 추후에 마이페이지에서 수정할 때도 재사용하도록 설계하였다. 따라서 모든 프래그먼트 실행 시 초기에 Bundle로 넘어온 mode 값에 따라 UI가 바뀌도록 코드를 추가하였다.

(예시 코드)

val mode = arguments?.getString("mode")
        if (mode == "edit") {
            isEditMode = true
            setupEditMode() // 수정 모드 전용 설정
        }




 

Ⅰ. 닉네임 문자열 검사

1. 닉네임 검사 로직

  1. 초기 상태: 모든 안내 문구와 아이콘은 무채색(Default)
  2. 입력 중: 글자가 들어오는 순간 테두리가 생기고, 규칙에 따라 색상이 실시간으로 변함
  3. 성공/실패: 규칙을 만족하면 초록불, 어긋나면 빨간불로 글씨 색이 바뀜
  4. 완료: 모든 조건이 충족될 때만 '다음으로' 버튼이 애니메이션과 함께 등장함

 

2. 구현 방법

1) 실시간 문자열 검사 로직(정규식)

binding.getNameInput.addTextChangedListener 안에서 유저의 입력을 실시간으로 감지한다.

// 조건 1. 특수문자 불가 (한글, 영문, 숫자만 허용)
// 정규식: ^[0-9a-zA-Z가-힣]*$ -> 특수문자가 없으면 true
val isCharValid = input.matches(Regex("^[0-9a-zA-Z가-힣]*$")) && input.isNotEmpty()

// 조건 2. 길이 검사 (한글이면 1~10자, 영어면 2~20자)
val hasKorean = input.matches(Regex(".*[가-힣]+.*"))
val len = input.length
val isLengthValid = if (input.isEmpty()) {
    false // 비어있으면 길이 조건 실패
} else if (hasKorean) {
    len in 1..10
} else {
    len in 2..20
}

 

2) 상태에 따른 실시간 UI 업데이트

검사 결과에 따라 텍스트 색상을 바꾸고, 입력창 테두리(Stroke)를 넣었다.

// 1) 조건별 텍스트뷰/아이콘 색상 업데이트 (updateConditionUI 함수 호출)
updateConditionUI(binding.nameRuleCharacter, isCharValid, input.isEmpty())
updateConditionUI(binding.nameRuleLength, isLengthValid, input.isEmpty())

// 2) 아이콘 (X / Check) 표시 로직
if (input.isEmpty()) {
    binding.validX.visibility = View.GONE
    binding.validCheck.visibility = View.GONE
} else if (isCharValid && isLengthValid) {
    binding.validX.visibility = View.GONE
    binding.validCheck.visibility = View.VISIBLE
} else {
    binding.validX.visibility = View.VISIBLE
    binding.validCheck.visibility = View.GONE
}

// 3) 테두리(Stroke) 처리: 글자가 하나라도 있으면 생성
val background = binding.getNameInput.background as? android.graphics.drawable.GradientDrawable
val strokeWidthPx = (2 * resources.displayMetrics.density).toInt()

if (input.isNotEmpty()) {
    background?.setStroke(strokeWidthPx, Color.parseColor("#4A494C"))
} else {
    background?.setStroke(0, 0)
}

 

4) 다음 단계 넘어가기 (버튼 활성화 및 저장)

모든 조건이 충족되면 버튼이 나타난다. 이때 버튼 주변 그라데이션을 부드럽게 나타내기 위해서 애니메이션을 추가했다.
"다음으로" 버튼과 주변 애니메이션은 다른 프래그먼트에서도 똑같이 사용할 예정이라 이번만 설명한다.

// 두 조건이 모두 '참'일 때만 버튼을 보여줌
if (isCharValid && isLengthValid) {
    binding.nextButton.visibility = View.VISIBLE
    binding.ellipse.visibility = View.VISIBLE

    // 애니메이션 효과
    binding.nextButton.alpha = 0f
    binding.nextButton.animate().alpha(1f).setDuration(300).start()
    binding.ellipse.alpha = 0f
    binding.ellipse.animate().alpha(1f).setDuration(300).start()
} else {
    binding.nextButton.visibility = View.GONE
    binding.ellipse.visibility = View.GONE
}

binding.nextButton.setOnClickListener {
    val finalNickname = binding.getNameInput.text.toString().trim()
    viewModel.nickname = finalNickname // 뷰모델에 닉네임 저장
    viewModel.updateNickname(requireContext(), finalNickname)

    if(currentMode == "edit") {
        updateNicknameToServer(finalNickname) // 서버 전송
    } else {
        // 다음 아티스트 프래그먼트로 이동
        val nextFragment = ArtistFragment()
        parentFragmentManager.beginTransaction()
            .replace(R.id.onboarding_fragment_container, nextFragment)
            .addToBackStack(null)
            .commit()
    }
}

 

5) 헬퍼 함수: UI 스타일 제어 및 서버 통신

색상 변경 로직을 별도 함수로 빼서 코드 중복을 줄였고, 서버 통신 시 성공 및 실패 처리를 하는 함수이다.

// 텍스트뷰의 색상과 아이콘 색상을 변경하는 함수
private fun updateConditionUI(textView: TextView, isValid: Boolean, isEmpty: Boolean) {
    val color = when {
        isEmpty -> colorDefault   // 입력 없으면 회색
        isValid -> colorSuccess   // 조건 맞으면 성공색(초록)
        else -> colorError        // 조건 틀리면 에러색(빨강)
    }
    textView.setTextColor(color)
    textView.compoundDrawablesRelative[0]?.setTint(color) // 왼쪽 아이콘 틴트
}

// 서버로 닉네임 수정 요청 (Retrofit 사용)
private fun updateNicknameToServer(newName: String) {
    RetrofitClient.mypageApi.updateName(MypageNameRequest(newName)).enqueue(object : Callback<BaseResponse<String>> {
        override fun onResponse(call: Call<BaseResponse<String>>, response: Response<BaseResponse<String>>) {
            if (response.isSuccessful) {
                Toast.makeText(context, "이름이 변경되었습니다.", Toast.LENGTH_SHORT).show()
                parentFragmentManager.popBackStack()
            }
        }
        override fun onFailure(call: Call<BaseResponse<String>>, t: Throwable) {
            Log.e("SetnameFragment", "Error: ${t.message}")
        }
    })
}




 

Ⅱ. 아티스트 취향 고르기

 

1. Spotify API 설정

1) Spotify Developer 등록 순서

  1. Dashboard 앱 등록: Spotify for Developers에 로그인 후 'Create App' 진행한다.
    (Web, Android, IOS 등등 플랫폼에 따라 Documentation이 잘 정리되어 있으니 참고하면 된다.)
  2. Client ID & Client Secret: 서버나 앱에서 API를 호출할 때 신분증 역할을 하는 중요한 키 (외부에 노출하면 안 됨)
  3. Redirect URIs: 인증 후 앱으로 돌아올 콜백 주소를 적는다.

 

2) 프로젝트 설정(Gradle)

  • Spotify SDK를 설치하고 build.gradle에 디펜던시 추가한다.
  • 네트워크 통신을 위한 Retrofit 설정과 클라이언트 ID/시크릿 ID를 상수로 관리하도록 세팅한다.

 

3) 여담

처음에 로그인도 스포티파이로 설정할 생각이어서 PKCE 방법도 고려해봤다. 근데 현실적으로 스포티파이 계정이 없는 사람이 많다는 한계에 부딪혔고, 서비스 진입 장벽을 낮추기 위해 로그인은 카카오로 하기로 수정했다.

그리고 원래는 앱 내 노래 재생도 고려해서 Android Remote SDK도 시도해보았다. 무료로 다른 플랫폼에서 노래를 재생할 수 있다는 점이 인상 깊어서, 다음에 기회가 있다면 Remote SDK만 정리해보겠드아...

하여튼, 앱 내 재생이 목적이 아니고 검색 위주라면 Web API만으로도 충분하다고 생각하여 우리 앱은 Web API를 통해 검색 기능을 구현하였다.

 

2. Spotify에서 제공하는 Artist API

Web API Artist Documetation 바로가기: 아티스트 API documetation

  • 요청(Request)

헤더에 발급받은 Bearer 토큰을 붙여서 보내야 한다.

http GET https://api.spotify.com/v1/artists/0TnOYISbd1XYRBk9myaseg \
  Authorization:'Bearer 1POdFZRZbvb...qqillRxMr2z'
  • 응답 예시 (Response Example)
{
  "external_urls": {
    "spotify": "string"
  },
  "followers": {
    "href": "string",
    "total": 0
  },
  "genres": [
    "Prog rock",
    "Grunge"
  ],
  "href": "string",
  "id": "string",
  "images": [
    {
      "url": "https://i.scdn.co/image/ab67616d00001e02ff9ca10b55ce82ae553c8228",
      "height": 300,
      "width": 300
    }
  ],
  "name": "string",
  "popularity": 0,
  "type": "artist",
  "uri": "string"
}
  • 가수의 이미지를 얻을 수 있어 이미지 URL을 추출해 어뎁터로 보냈다.
  • 장르나 연도를 설정할 수 있다.
  • 전략: 유저가 들어오자마자 빈 화면을 보지 않도록 "year:2025" 쿼리를 날려 인기 아티스트 50명을 먼저 로드하고 나머지는 검색으로 처리했다.

 

3. 구현 방법

1) 주요 Point

  • 아티스트 리스트뷰를 만들기 위해 어뎁터를 만들었다.
  • 하나를 고를 때 마다 해당 아티스트의 컴포넌트는 맨 상단으로 가서 보여진다.
  • 3개를 고르면 "다음으로" 버튼이 활성화 된다.
  • 검색을 하면 0.5초 마다 요청을 보내서 사용자가 원하는 아티스트를 바로바로 확인할 수 있게 하였다.
  • 초기화 버튼 누르면 지금까지 체크된 아티스트들이 모두 해제된다.

 

2) 토큰 발급 및 초기 데이터 로드

// 초기화 및 토큰 발급
private fun initTokenAndLoadData() {
    SpotifyAuthRepository.getSearchToken(
        onSuccess = { token ->
            searchToken = token
            searchSpotifyArtists("year:2025", limit = 50)
        },
        onFailure = {
            Toast.makeText(context, "서버 연결에 실패했습니다. 잠시 후 다시 시도해주세요.", Toast.LENGTH_SHORT).show()
        }
    )
}

 

3) 클릭 시 상단 재정렬

유저가 아티스트를 클릭하면 isSelected 상태를 바꾸고, 선택된 아이템을 리스트 맨 위로 올린다.

private fun handleArtistClick(artist: ArtistData) {
    if (artist.isSelected) {
        // 이미 선택됨 -> 해제
        artist.isSelected = false
        selectedArtistsMap.remove(artist.name)
    } else {
        // 선택 안 됨 -> 3개 미만일 때만 선택 허용
        if (selectedArtistsMap.size < 3) {
            artist.isSelected = true
            selectedArtistsMap[artist.name] = artist
        } else {
            Toast.makeText(context, "최대 3명까지만 선택 가능합니다.", Toast.LENGTH_SHORT).show()
            return
        }
    }

    // [기능 추가] 선택된 아이템을 맨 위로 올리기 (재정렬)
    reorderListMovingSelectionsToTop()

    // 버튼 상태 업데이트
    updateButtonVisibility()
}

 

4) 실시간 검색 로직

검색창에 글자를 칠 때마다 API를 호출하면 과부하가 걸리기 때문에, Handler를 써서 입력을 멈추고 0.5초 뒤에만 검색이 실행되도록 Debounce를 구현했다.

/* --- 검색 및 디자인 로직 수정 --- */
override fun afterTextChanged(s: Editable?) {
    val query = s.toString().trim()

    // --- UI 디자인 변경 로직 ---
    val background = binding.searchArtist.background as? GradientDrawable
    val strokeWidthPx = (2 * resources.displayMetrics.density).toInt() // 2dp

    if (query.isNotEmpty()) {
        background?.setStroke(strokeWidthPx, Color.parseColor("#303032")) // 입력 있음 - 테두리 생성
        binding.searchClear.visibility = View.VISIBLE
    } else {
        background?.setStroke(0, 0) // 입력 없음 - 테두리 제거
        binding.searchClear.visibility = View.GONE
    }

    // --- 검색 로직 (Debounce) ---
    searchRunnable?.let { searchHandler.removeCallbacks(it) }
    searchRunnable = Runnable {
        if (query.isNotEmpty()) {
            searchSpotifyArtists(query)
        } else {
            searchSpotifyArtists("year:2025", limit = 50)
        }
    }
    searchHandler.postDelayed(searchRunnable!!, 500)
}

 

5) 검색 결과와 기존 선택 목록의 동기화

새로운 검색 결과가 내려와도, 사용자가 이미 선택한 아티스트는 체크 상태가 유지되어야 한다.
distinctBy로 중복을 제거하고 내 맵(selectedArtistsMap)과 대조해서 상태를 복구했다.

// 검색 결과 처리 (내 선택 목록과 합치기 + 정렬)
private fun processSearchResults(apiItems: List<com.mobile.soundscape.api.dto.ArtistSearchItem>) {
    val apiResultList = apiItems.map { item ->
        ArtistData(name = item.name, imageResId = item.images.firstOrNull()?.url ?: "", isSelected = false)
    }

    val mySelectedList = selectedArtistsMap.values.toList()

    // [병합] 이름이 같으면 내 선택 목록 데이터를 우선적으로 남김
    val combinedList = (mySelectedList + apiResultList).distinctBy { it.name }

    // [동기화] 내 맵에 있으면 isSelected를 true로
    combinedList.forEach {
        if (selectedArtistsMap.containsKey(it.name)) {
            it.isSelected = true
        }
    }

    // 선택된 아이템이 맨 위로 오도록 정렬 후 어댑터 갱신
    val sortedList = combinedList.sortedByDescending { it.isSelected }
    adapter.updateList(sortedList)
}




 

Ⅲ. 장르 취향 고르기

1) 고정 데이터 활용

장르는 개수가 많지 않아서 object를 활용해 고정 리스트로 관리했다.

package com.mobile.soundscape.data

import com.mobile.soundscape.onboarding.GenreData

object GenreDataFix {
    fun getGenreList(): MutableList<GenreData> {
        return mutableListOf(
            GenreData("팝"), GenreData("케이팝"), GenreData("밴드"),
            GenreData("힙합"), GenreData("인디"), GenreData("발라드"),
            GenreData("재즈"), GenreData("클래식"), GenreData("락"),
            GenreData("EDM"), GenreData("OST"), GenreData("R&B")
        )
    }
}

 

2) 장르 선택 로직

유저가 장르를 누를 때마다 맵에 추가하거나 제거한다. 이미 3개를 고른 상태에서 다른 걸 누르면 토스트로 메시지를 띄워 제한한다.

private fun handleGenreClick(genre: GenreData, position: Int) {
    if (genre.isSelected) {
        genre.isSelected = false
        selectedGenresMap.remove(genre.name)
    } else {
        if (selectedGenresMap.size < 3) {
            genre.isSelected = true
            selectedGenresMap[genre.name] = genre
        } else {
            Toast.makeText(context, "최대 3개까지만 선택 가능합니다.", Toast.LENGTH_SHORT).show()
            return
        }
    }
    // 어댑터에게 변경 알림 (UI 갱신)
    adapter.notifyItemChanged(position)
    updateButtonVisibility()
}

3) 데이터 저장 및 온보딩 완료

닉네임, 아티스트, 장르를 모두 OnboardingManager(로컬 저장소)에 저장하고 서버로 최종 전송한다.
온보딩은 최종 완료된 상태이기 때문에 메인으로 간 뒤에는 다시 온보딩으로 돌아오지 못하게 막아버렸다.

binding.nextButton.setOnClickListener {
    if(selectedGenresMap.size == 3) {
        // 1. 데이터 취합 및 로컬 저장
        val finalNickname = viewModel.nickname
        val finalArtist = viewModel.selectedArtists
        val finalGenre = selectedGenresMap.keys.toMutableList()

        OnboardingManager.saveNickname(requireContext(), finalNickname)
        OnboardingManager.saveArtistList(requireContext(), finalArtist)
        OnboardingManager.saveGenreList(requireContext(), finalGenre)

        // 2. 서버 전송 및 상태 변경
        if (isEditMode) {
            updateGenreToServer(finalGenre)
        } else {
            PreferenceManager.setOnboardingFinished(requireContext(), true)
            viewModel.submitOnboarding() // 최종 서버 제출
        }
    } else {
        Toast.makeText(context, "3가지를 선택해주세요.", Toast.LENGTH_SHORT).show()
    }
}

private fun moveToNextActivity() {
    // 스택 초기화하며 메인으로 이동
    val intent = Intent(requireContext(), MainActivity::class.java)
    intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
    startActivity(intent)
}




 

Ⅳ. 마무리

사실 온보딩이 프로젝트의 초반 부분이라 프래그먼트도 많이 생기고 코드도 계속 길어져서 스스로 이게 맞나 하는 걱정이 생겼다.

 

리사이클러뷰 어뎁터 다루는 부분이 가장 어려웠는데, 프래그먼트에서도 리사이클러뷰를 정의하고 기능은 어뎁터에서 구현해야 하는 로직이 이해하기 정말 어려웠다. 특히 아티스트 선택 부분에서는 이미지도 추가하기, 3개를 고르면 넘어가기, 검색을 했을 때 동기화하기 등등의 기능을 구현하는 게 까다로웠던 것 같다.

 

그래도 Spotify API를 직접 연동해본 게 정말 인상 깊었다. 신기한 SDK를 다뤄볼 수 있는게 좋은 경험이었던 것 같다. 처음 디자인팀에게 받은 레퍼런스에 실제 아티스트 사진도 추가되니까 UI도 너무 예쁜 것 같아 기뻤다.

 

깃허브 바로가기👉 https://github.com/choisio2/DIP_android.git

 

 

 

 



 

 

 

 

 

 

'TAVE-16th' 카테고리의 다른 글

[DIP/FE] 노래 추천받기(2) - Polling 방식으로 서버 요청 보내기  (0) 2026.03.08
[DIP/FE] 노래 추천받기(1) - 장소 선택, 데시벨 측정, 목표 설정  (0) 2026.03.07
[DIP/FE] 프로젝트 소개 & Kakao Oauth 구현  (0) 2026.02.23
[TAVE 스터디 5주차] 메모 앱 만들기  (0) 2026.01.04
[TAVE 스터디 4주차] 음악 목록 앱 만들기  (0) 2026.01.04
'TAVE-16th' 카테고리의 다른 글
  • [DIP/FE] 노래 추천받기(2) - Polling 방식으로 서버 요청 보내기
  • [DIP/FE] 노래 추천받기(1) - 장소 선택, 데시벨 측정, 목표 설정
  • [DIP/FE] 프로젝트 소개 & Kakao Oauth 구현
  • [TAVE 스터디 5주차] 메모 앱 만들기
choisio2
choisio2
sio2-dev 님의 블로그 입니다.
  • choisio2
    SiO2 for Developer
    choisio2
  • 전체
    오늘
    어제
    • 분류 전체보기 (46) N
      • TAVE-16th (14)
      • BDA-11th (16)
      • C++ (5)
      • 개인 프로젝트 (4)
      • 백준 (4) N
      • 컴퓨터 그래픽스 (1)
      • 잡담 (1)
  • 블로그 메뉴

    • 태그
    • 방명록
  • 링크

    • github.com/choisio2
  • 공지사항

  • 인기 글

  • 태그

    kotin
    개발자미래
    데시벨측정
    프론트엔드
    백준
    polling
    SpotifyAPI
    geminicli
    데이터분석모델링
    calculator
    BDA
    알고리즘스터디
    C++
    androidstudio
    KakaoOauth
    playconsole
    spotify
    백준1463
    frontend
    개발자
    BDAI
    알고리즘
    AI시대
    바이브코딩
    코딩테스트
    viewpager2
    kotlin
    Tave
    코테
    BDA #데이터분석모델링
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
choisio2
[DIP/FE] 온보딩 구현 - 문자열 검사 및 Spotify API로 아티스트 목록 가져오기
상단으로

티스토리툴바