9주차. FLO 앱 클론 코딩 - Network 통신 & API

변현섭·2023년 11월 15일
0

5th UMC Android Study

목록 보기
9/10

✅ 9주차 목표

  • API를 활용하여 사용자 회원가입 및 로그인을 구현할 수 있다.
  • API를 모듈화할 수 있다.
  • 자동 로그인 프로세스를 이해하고 이를 구현할 수 있다.

UMC에서 제공하는 서버가 현재 막힌 관계로 실제 API가 연동되는 모습을 확인시켜드릴 수 없는 점 양해바랍니다.

1. API를 활용하여 회원가입 구현하기

이제부터는 RoomDB가 아닌 서버와의 통신을 통해 회원가입 및 로그인을 구현할 것이다.

1) API 관련 설정

① Moudule 수준의 build.gradle에 아래의 의존성을 추가한다.

// Retrofit
implementation("com.squareup.retrofit2:retrofit:2.9.0")
implementation("com.squareup.retrofit2:converter-gson:2.9.0")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.5.2")
implementation("com.squareup.retrofit2:adapter-rxjava2:2.9.0")

// okHttp
implementation("com.squareup.okhttp3:okhttp:4.9.0")
implementation("com.squareup.okhttp3:logging-interceptor:4.9.0")

// Glide
implementation("com.github.bumptech.glide:glide:4.12.0")
annotationProcessor("com.github.bumptech.glide:compiler:4.12.0")

② Manifest 파일에 인터넷 관련 설정이 허용되어 있는지 확인한다.

<uses-permission android:name="android.permission.INTERNET" />

③ Postman을 켜고 아래의 API 명세서에 있는 내용을 확인한다. 또한, 직접 API의 엔드포인트에 더미데이터를 넣어 테스트해본다.
>> API 명세서

2) API 연동

① 먼저 User data class에 name 필드를 추가하고, Json 변환을 위해 @SerializedName 어노테이션을 추가한다.

@Entity(tableName = "UserTable")
data class User(
    @SerializedName("email")
    var email : String,
    
    @SerializedName("password")
    var password : String,
    
    @SerializedName("name")
    var name : String
) {
    @PrimaryKey(autoGenerate = true) var id : Int = 0
}

② 서버의 응답을 받을 data class를 정의하기 위해 BaseResponse data class를 생성한다.

data class BaseResponse(
    val isSuccess : Boolean,
    val code : Int,
    val message : String
)

③ AuthApi라는 이름의 인터페이스를 생성한다.

interface AuthApi {
    @POST("/users")
    fun signUp(@Body user : User) : Call<AuthResponse>
}

④ ApiRepository 클래스를 생성한다.

class ApiRepository {
    companion object {
        const val BASE_URL = "https://edu-api-test.softsquared.com"
    }
}

⑤ RetrofitInstance라는 이름의 클래스를 생성한다.

class RetrofitInstance {
    companion object {
        private val retrofit by lazy {
            Retrofit.Builder()
                .baseUrl(ApiRepository.BASE_URL)
                .addConverterFactory(GsonConverterFactory.create())
                .build()
        }
        val authApi = retrofit.create(AuthApi::class.java)
    }
}

⑥ SignUpActivity를 아래와 같이 수정한다.

class SignUpActivity : AppCompatActivity() {

    lateinit var binding: ActivitySignUpBinding

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        binding = ActivitySignUpBinding.inflate(layoutInflater)
        setContentView(binding.root)

        binding.signUpSignUpBtn.setOnClickListener {
            signUp()
        }
    }

    private fun getUser() : User {
        val email : String = binding.signUpIdEt.text.toString() + "@" + binding.signUpDirectInputEt.text.toString()
        val pwd : String = binding.signUpPasswordEt.text.toString()
        var name : String = binding.signUpNameEt.text.toString()

        return User(email, pwd, name)
    }

    private fun signUp() : Boolean {
        if(binding.signUpIdEt.text.toString().isEmpty() || binding.signUpDirectInputEt.text.toString().isEmpty()) {
            Toast.makeText(this, "이메일 형식이 잘못되었습니다.", Toast.LENGTH_SHORT).show()
            return false
        }

        if(binding.signUpPasswordEt.text.toString() != binding.signUpPasswordCheckEt.text.toString()) {
            Toast.makeText(this, "비밀번호가 일치하지 않습니다.", Toast.LENGTH_SHORT).show()
            return false
        }

        if(binding.signUpNameEt.text.toString().isEmpty()) {
            Toast.makeText(this, "이름 형식이 잘못되었습니다.", Toast.LENGTH_SHORT).show()
            return false
        }

        RetrofitInstance.authApi.signUp(getUser()).enqueue(object: Callback<BaseResponse> {
            override fun onResponse(call: Call<BaseResponse>, response: Response<BaseResponse>) {
                Log.d("SignUp-Success", response.toString())
                val response : BaseResponse = response.body()!!
                when(response.code) {
                    1000 -> finish() // 성공
                    2016, 2018 -> {
                        binding.signUpEmailErrorTv.visibility = View.VISIBLE
                        binding.signUpEmailErrorTv.text = response.message
                    }
                }
                
            }

            override fun onFailure(call: Call<BaseResponse>, t: Throwable) {
                Log.d("SignUp-Failure", t.message.toString())
            }
        })
        Log.d("SignUpActivity", "All Finished")

        return true
    }
}

이제 코드를 실행해보자. 지금은 서버가 켜져있지 않아 회원가입이 잘 처리되는 것까지는 확인할 수 없지만, 네트워크 연결이 없는 상태에서 회원가입을 시도해도, 프로세스가 종료되지 않고 로그를 찍는다면 성공한 것이다.

2. API 모듈화

Activity에 여러가지 API를 정의하다보면, 코드의 직관성과 가독성이 크게 저해된다. 그러므로 이러한 일을 방지하려면, API를 모듈화하여 정의하는 것이 좋다. 그러면 지금부터 위에서 실습한 내용을 모듈화하는 방법에 대해 알아보자.

① SignUpView 인터페이스를 생성한다.

  • SignUpActivity와 회원가입 API 간의 인터페이스이다.
interface SignUpView {
    fun onSignUpSuccess()
    fun onSignUpFailure(message : String)
}

② SignUpActivity에서 SignUpView 인터페이스를 상속해야 한다.

class SignUpActivity : AppCompatActivity(), SignUpView {

③ AuthService 클래스를 생성한다.

class AuthService {
    private lateinit var signUpView: SignUpView

    fun setSignUpView(signUpView: SignUpView) {
        this.signUpView = signUpView
    }

    fun signUp(user : User) {
        RetrofitInstance.authApi.signUp(user).enqueue(object: Callback<BaseResponse> {
            override fun onResponse(call: Call<BaseResponse>, response: Response<BaseResponse>) {
                Log.d("SignUp-Success", response.toString())
                val response : BaseResponse = response.body()!!
                when(response.code) {
                    1000 -> signUpView.onSignUpSuccess()
                    else -> signUpView.onSignUpFailure(response.message)
                }
            }
            override fun onFailure(call: Call<BaseResponse>, t: Throwable) {
                Log.d("SignUp-Failure", t.message.toString())
            }
        })
        Log.d("SignUpActivity", "All Finished")
    }
}
  • setSignUpView
    • 연결될 Activity를 설정한다.
    • 연결될 Activity에 onSignUpSuccess(), onSignUpFailure()가 정의되어 있어야 한다.
  • 서버의 응답 코드가 1000이면, 연결된 Activity(SignUpActivity)의 onSignUpSuccess() 메서드를 호출하고, 그 외의 응답 코드에 대해서는 onSignUpFailure() 메서드를 호출한다.

④ SignUpActivity를 아래와 같이 수정한다.

class SignUpActivity : AppCompatActivity(), SignUpView {
	...
    
    private fun signUp() : Boolean {
        if(binding.signUpIdEt.text.toString().isEmpty() || binding.signUpDirectInputEt.text.toString().isEmpty()) {
            Toast.makeText(this, "이메일 형식이 잘못되었습니다.", Toast.LENGTH_SHORT).show()
            return false
        }

        if(binding.signUpPasswordEt.text.toString() != binding.signUpPasswordCheckEt.text.toString()) {
            Toast.makeText(this, "비밀번호가 일치하지 않습니다.", Toast.LENGTH_SHORT).show()
            return false
        }

        if(binding.signUpNameEt.text.toString().isEmpty()) {
            Toast.makeText(this, "이름 형식이 잘못되었습니다.", Toast.LENGTH_SHORT).show()
            return false
        }

        val authService = AuthService()
        authService.setSignUpView(this) // 객체를 통한 멤버 함수 호출


        authService.signUp(getUser())
        return true
    }

    override fun onSignUpSuccess() {
        finish()
    }

    override fun onSignUpFailure(message : String) {
        binding.signUpEmailErrorTv.visibility = View.VISIBLE
        binding.signUpEmailErrorTv.text = message
    }
}

API 모듈화를 적용함으로써 SignUpActivity의 직관성 및 가독성이 크게 향상된 것을 확인할 수 있다.

3. API를 활용하여 로그인 구현하기

① LoginView 인터페이스를 생성한다.

  • LoginActivity와 로그인 API 간의 인터페이스이다.
interface LoginView {
    fun onLoginSuccess(code : Int, result : Result)
    fun onLoginFailure(message : String)
}

② LoginActivity에서 LoginView 인터페이스를 상속해야 한다.

class LoginActivity : AppCompatActivity(), LoginView {

③ AuthApi에 아래의 내용을 추가한다.

@POST("/users/login")
fun login(@Body user : User) : Call<BaseResponse>

④ 로그인 API에는 응답객체의 Body에 result 값이 포함되므로, BaseResponse를 아래와 같이 수정해야 한다.

data class BaseResponse(
    @SerializedName("isSuccess") val isSuccess : Boolean,
    @SerializedName("code") val code : Int,
    @SerializedName("message") val message : String,
    @SerializedName("result") val result : Result?
)

data class Result (
    @SerializedName("userIdx") var userIdx : Int,
    @SerializedName("jwt") var jwt : String
)

⑤ AuthService에 아래의 내용을 입력한다.

class AuthService {
    private lateinit var signUpView: SignUpView
    private lateinit var loginView: LoginView

    fun setSignUpView(signUpView: SignUpView) {
        this.signUpView = signUpView
    }

    fun setLoginView(loginView: LoginView) {
        this.loginView = loginView
    }

    fun signUp(user : User) {
        RetrofitInstance.authApi.signUp(user).enqueue(object: Callback<BaseResponse> {
            override fun onResponse(call: Call<BaseResponse>, response: Response<BaseResponse>) {
                Log.d("SignUp-Success", response.toString())
                val response : BaseResponse = response.body()!!
                when(response.code) {
                    1000 -> signUpView.onSignUpSuccess()
                    else -> signUpView.onSignUpFailure(response.message)
                }
            }
            override fun onFailure(call: Call<BaseResponse>, t: Throwable) {
                Log.d("SignUp-Failure", t.message.toString())
            }
        })
        Log.d("SignUpActivity", "All Finished")
    }

    fun login(user : User) {
        RetrofitInstance.authApi.login(user).enqueue(object: Callback<BaseResponse> {
            override fun onResponse(call: Call<BaseResponse>, response: Response<BaseResponse>) {
                Log.d("Login-Success", response.toString())
                val response : BaseResponse = response.body()!!
                when(val code = response.code) {
                    1000 -> loginView.onLoginSuccess(code, response.result!!)
                    else -> loginView.onLoginFailure(response.message)
                }
            }
            override fun onFailure(call: Call<BaseResponse>, t: Throwable) {
                Log.d("Login-Failure", t.message.toString())
            }
        })
        Log.d("LoginActivity", "All Finished")
    }
}

⑥ LoginActivity에 아래의 내용을 추가한다.

class LoginActivity : AppCompatActivity(), LoginView {
	...
    
    private fun login() {
        if (binding.loginIdEt.text.toString().isEmpty() || binding.loginDirectInputEt.text.toString().isEmpty()) {
            Toast.makeText(this, "이메일을 입력해주세요", Toast.LENGTH_SHORT).show()
            return
        }

        if (binding.loginPasswordEt.text.toString().isEmpty()) {
            Toast.makeText(this, "비밀번호를 입력해주세요", Toast.LENGTH_SHORT).show()
            return
        }

        val email : String = binding.loginIdEt.text.toString() + "@" + binding.loginDirectInputEt.text.toString()
        val pwd : String = binding.loginPasswordEt.text.toString()

        val authService = AuthService()
        authService.setLoginView(this)

        authService.login(User(email, pwd, ""))
    }
    
    private fun saveJwtFromServer(jwt : String) {
        val spf = getSharedPreferences("auth2", MODE_PRIVATE)
        val editor = spf.edit()

        editor.putString("jwt", jwt)
        editor.apply()
	}

	override fun onLoginSuccess(code : Int, result : Result) {
        saveJwtFromServer(result.jwt)
        startMainActivity()
    }

    override fun onLoginFailure(message : String) {
        Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
    }
	...

⑦ MainActivity에 아래의 내용을 추가한다.

class MainActivity : AppCompatActivity() {
	...
    
    override fun onCreate(savedInstanceState: Bundle?) {
		...
    	Log.d("MainActivity", getJwt().toString())
    }
    
	private fun getJwt() : String? {
	    val spf = this.getSharedPreferences("auth2", MODE_PRIVATE)
	    
	    return spf!!.getString("jwt", "")
	}
    ...

이제 코드를 실행한 후 회원가입 한 계정으로 로그인을 시도해보자. MainActivity 태그의 로그를 확인해보면, jwt 토큰 정보가 콘솔에 출력되는 것을 확인할 수 있을 것이다.

3. LookFragment의 Song List를 API로 구성하기

저번에 더미 데이터로 구성했던 LookFragment를 이번에는 실제 API를 이용하여 구성해보기로 하자.

① SongResponse라는 이름의 data class를 생성한다.

data class SongResponse(
    @SerializedName("isSuccess") val isSuccess: Boolean,
    @SerializedName("code") val code: Int,
    @SerializedName("message") val message: String,
    @SerializedName("result") val result: FloChartResult
)

data class FloChartResult(
    @SerializedName("songs") val songs: List<FloChartSongs>
)

data class FloChartSongs(
    @SerializedName("songIdx") val songIdx: Int,
    @SerializedName("albumIdx") val albumIdx: Int,
    @SerializedName("singer") val singer: String,
    @SerializedName("title") val title:String,
    @SerializedName("coverImgUrl") val coverImgUrl : String
)

② SongApi라는 이름의 인터페이스를 생성한다.

interface SongApi {
    @GET("/songs")
    fun getSongs(): Call<SongResponse>
}

③ LookView 인터페이스를 생성한다.

interface LookView {
    fun onGetSongLoading()
    fun onGetSongSuccess(code: Int, result: FloChartResult)
    fun onGetSongFailure(code: Int, message: String)
}

④ LookFragment에서 LookView 인터페이스를 상속해야 한다.

class LookFragment : Fragment(), LookView {

⑤ fragment_look.xml 파일에 아래의 내용을 입력한다.

<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.constraintlayout.widget.ConstraintLayout
        android:layout_width="match_parent"
        android:layout_height="wrap_content">

        <TextView
            android:id="@+id/look_big_title_tv"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginStart="20dp"
            android:layout_marginTop="40dp"
            android:text="둘러보기"
            android:textColor="#000000"
            android:textSize="22sp"
            android:textStyle="bold"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />

        <HorizontalScrollView
            android:id="@+id/look_chip_scroll_hs"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:paddingStart="20dp"
            android:scrollbars="none"
            android:layout_marginTop="30dp"
            app:layout_constraintTop_toBottomOf="@id/look_big_title_tv">

            <LinearLayout
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:orientation="horizontal">

                <TextView
                    android:id="@+id/look_chip_title_01_tv"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:text="차트"
                    android:textColor="#ffffff"
                    android:paddingVertical="8dp"
                    android:paddingHorizontal="15dp"
                    android:layout_marginEnd="15dp"
                    android:background="@drawable/fragment_look_chip_on_background"/>

                <TextView
                    android:id="@+id/look_chip_title_02_tv"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:text="영상"
                    android:textColor="#7D7D7D"
                    android:paddingVertical="8dp"
                    android:paddingHorizontal="15dp"
                    android:layout_marginEnd="15dp"
                    android:background="@drawable/fragment_look_chip_off_background"/>

                <TextView
                    android:id="@+id/look_chip_title_03_tv"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:text="장르"
                    android:textColor="#7D7D7D"
                    android:paddingVertical="8dp"
                    android:paddingHorizontal="15dp"
                    android:layout_marginEnd="15dp"
                    android:background="@drawable/fragment_look_chip_off_background"/>

                <TextView
                    android:id="@+id/look_chip_title_04_tv"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:text="상황"
                    android:textColor="#7D7D7D"
                    android:paddingVertical="8dp"
                    android:paddingHorizontal="15dp"
                    android:layout_marginEnd="15dp"
                    android:background="@drawable/fragment_look_chip_off_background"/>

                <TextView
                    android:id="@+id/look_chip_title_05_tv"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:text="분위기"
                    android:textColor="#7D7D7D"
                    android:paddingVertical="8dp"
                    android:paddingHorizontal="15dp"
                    android:layout_marginEnd="15dp"
                    android:background="@drawable/fragment_look_chip_off_background"/>

                <TextView
                    android:id="@+id/look_chip_title_06_tv"
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:text="오디오"
                    android:textColor="#7D7D7D"
                    android:paddingVertical="8dp"
                    android:paddingHorizontal="15dp"
                    android:layout_marginEnd="15dp"
                    android:background="@drawable/fragment_look_chip_off_background"/>
            </LinearLayout>

        </HorizontalScrollView>

        <TextView
            android:id="@+id/look_sub_title_chart_tv"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="차트"
            android:textColor="#000000"
            android:textStyle="bold"
            android:textSize="20sp"
            android:layout_marginTop="30dp"
            android:layout_marginStart="20dp"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/look_chip_scroll_hs" />

        <ImageView
            android:id="@+id/look_sub_title_arrow_iv"
            android:layout_width="20dp"
            android:layout_height="20dp"
            android:src="@drawable/btn_arrow_more"
            android:layout_marginStart="5dp"
            app:layout_constraintStart_toEndOf="@id/look_sub_title_chart_tv"
            app:layout_constraintTop_toTopOf="@id/look_sub_title_chart_tv"
            app:layout_constraintBottom_toBottomOf="@id/look_sub_title_chart_tv"/>

        <androidx.constraintlayout.widget.ConstraintLayout
            android:id="@+id/look_chart_total_cl"
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:elevation="5dp"
            android:layout_marginTop="20dp"
            android:layout_marginHorizontal="20dp"
            android:paddingTop="15dp"
            android:paddingBottom="15dp"
            android:background="@drawable/fragment_look_chart_background"
            app:layout_constraintTop_toBottomOf="@id/look_sub_title_chart_tv">

            <TextView
                android:id="@+id/look_chart_title_tv"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="FLO 차트"
                android:textColor="#000000"
                android:textStyle="bold"
                android:textSize="18sp"
                android:layout_marginStart="15dp"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintTop_toTopOf="parent"/>

            <TextView
                android:id="@+id/look_chart_title_time_tv"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="19시 기준"
                android:textSize="12sp"
                android:layout_marginStart="5dp"
                app:layout_constraintStart_toEndOf="@id/look_chart_title_tv"
                app:layout_constraintTop_toTopOf="@id/look_chart_title_tv"
                app:layout_constraintBottom_toBottomOf="@id/look_chart_title_tv"/>
            <TextView
                android:id="@+id/look_chart_sub_title_tv"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="최근 24시간 집계,FLO 최고 인기곡 차트!"
                android:textSize="12sp"
                android:layout_marginStart="15dp"
                app:layout_constraintTop_toBottomOf="@id/look_chart_title_tv"
                app:layout_constraintStart_toStartOf="parent"/>

            <TextView
                android:id="@+id/look_chart_listen_all_tv"
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:text="전체듣기"
                android:textSize="13sp"
                android:textColor="#000000"
                android:layout_marginEnd="15dp"
                app:layout_constraintEnd_toEndOf="parent"
                app:layout_constraintTop_toTopOf="parent"/>

            <ImageView
                android:id="@+id/look_chart_listen_all_iv"
                android:layout_width="15dp"
                android:layout_height="15dp"
                android:src="@drawable/ic_browse_arrow_right"
                android:layout_marginEnd="3dp"
                app:layout_constraintTop_toTopOf="@id/look_chart_listen_all_tv"
                app:layout_constraintBottom_toBottomOf="@id/look_chart_listen_all_tv"
                app:layout_constraintEnd_toStartOf="@id/look_chart_listen_all_tv"/>

            <androidx.recyclerview.widget.RecyclerView
                android:id="@+id/look_flo_chart_rv"
                android:layout_width="match_parent"
                android:layout_height="300dp"
                android:layout_marginTop="10dp"
                app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
                app:layout_constraintStart_toStartOf="parent"
                app:layout_constraintStart_toEndOf="parent"
                app:layout_constraintTop_toBottomOf="@id/look_chart_sub_title_tv"
                app:layout_constraintBottom_toBottomOf="parent" />
        </androidx.constraintlayout.widget.ConstraintLayout>

        <TextView
            android:id="@+id/look_sub_title_video_tv"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="영상"
            android:textColor="#000000"
            android:textStyle="bold"
            android:textSize="20sp"
            android:layout_marginTop="30dp"
            android:layout_marginStart="20dp"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/look_chart_total_cl" />

        <ImageView
            android:id="@+id/look_sub_title_video_arrow_iv"
            android:layout_width="20dp"
            android:layout_height="20dp"
            android:src="@drawable/btn_arrow_more"
            android:layout_marginStart="5dp"
            app:layout_constraintStart_toEndOf="@id/look_sub_title_video_tv"
            app:layout_constraintTop_toTopOf="@id/look_sub_title_video_tv"
            app:layout_constraintBottom_toBottomOf="@id/look_sub_title_video_tv"/>

        <ImageView
            android:id="@+id/look_video_present_exp_iv"
            android:layout_width="match_parent"
            android:layout_height="200dp"
            android:src="@drawable/img_video_exp"
            android:background="@color/black"
            android:layout_marginTop="20dp"
            android:layout_marginHorizontal="20dp"
            app:layout_constraintTop_toBottomOf="@id/look_sub_title_video_tv"/>

        <TextView
            android:id="@+id/look_video_present_title_tv"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="제목"
            android:textColor="#000000"
            android:textSize="15sp"
            app:layout_constraintStart_toStartOf="@id/look_video_present_exp_iv"
            app:layout_constraintTop_toBottomOf="@id/look_video_present_exp_iv"/>

        <TextView
            android:id="@+id/look_video_present_singer_tv"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="가수"
            android:textSize="13sp"
            app:layout_constraintStart_toStartOf="@id/look_video_present_exp_iv"
            app:layout_constraintTop_toBottomOf="@id/look_video_present_title_tv"/>

        <HorizontalScrollView
            android:id="@+id/look_video_music_hs"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginTop="10dp"
            android:orientation="horizontal"
            android:overScrollMode="never"
            android:scrollbars="none"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/look_video_present_singer_tv">

            <LinearLayout
                android:layout_width="wrap_content"
                android:layout_height="wrap_content"
                android:orientation="horizontal">


                <androidx.constraintlayout.widget.ConstraintLayout
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_marginStart="20dp">

                    <ImageView
                        android:id="@+id/look_video_music_album_img_01_iv"
                        android:layout_width="304dp"
                        android:layout_height="171dp"
                        android:scaleType="fitCenter"
                        android:src="@drawable/img_video_exp"
                        app:layout_constraintEnd_toEndOf="parent"
                        app:layout_constraintStart_toStartOf="parent"
                        app:layout_constraintTop_toTopOf="parent" />

                    <TextView
                        android:id="@+id/look_video_music_album_title_01_tv"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:layout_marginTop="10dp"
                        android:text="제목"
                        android:textColor="@color/black"
                        android:textSize="20sp"
                        app:layout_constraintStart_toStartOf="parent"
                        app:layout_constraintTop_toBottomOf="@id/look_video_music_album_img_01_iv" />

                    <TextView
                        android:id="@+id/look_video_music_album_title_02_iv"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:layout_marginTop="3dp"
                        android:text="가수"
                        android:textColor="#a8a8a8"
                        android:textSize="15sp"
                        app:layout_constraintStart_toStartOf="parent"
                        app:layout_constraintTop_toBottomOf="@id/look_video_music_album_title_01_tv" />

                </androidx.constraintlayout.widget.ConstraintLayout>

                <androidx.constraintlayout.widget.ConstraintLayout
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_marginStart="20dp">

                    <ImageView
                        android:id="@+id/look_video_music_album_img_02_iv"
                        android:layout_width="304dp"
                        android:layout_height="171dp"
                        android:scaleType="fitCenter"
                        android:src="@drawable/img_video_exp"
                        app:layout_constraintEnd_toEndOf="parent"
                        app:layout_constraintStart_toStartOf="parent"
                        app:layout_constraintTop_toTopOf="parent" />

                    <TextView
                        android:id="@+id/look_video_music_album_title_03_tv"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:layout_marginTop="10dp"
                        android:text="제목"
                        android:textColor="@color/black"
                        android:textSize="20sp"
                        app:layout_constraintStart_toStartOf="parent"
                        app:layout_constraintTop_toBottomOf="@id/look_video_music_album_img_02_iv" />

                    <TextView
                        android:id="@+id/look_video_music_album_title_04_iv"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:layout_marginTop="3dp"
                        android:text="가수"
                        android:textColor="#a8a8a8"
                        android:textSize="15sp"
                        app:layout_constraintStart_toStartOf="parent"
                        app:layout_constraintTop_toBottomOf="@id/look_video_music_album_title_03_tv" />

                </androidx.constraintlayout.widget.ConstraintLayout>

                <androidx.constraintlayout.widget.ConstraintLayout
                    android:layout_width="wrap_content"
                    android:layout_height="wrap_content"
                    android:layout_marginStart="20dp">

                    <ImageView
                        android:id="@+id/look_video_music_album_img_03_iv"
                        android:layout_width="304dp"
                        android:layout_height="171dp"
                        android:scaleType="fitCenter"
                        android:src="@drawable/img_video_exp"
                        app:layout_constraintEnd_toEndOf="parent"
                        app:layout_constraintStart_toStartOf="parent"
                        app:layout_constraintTop_toTopOf="parent" />

                    <TextView
                        android:id="@+id/look_video_music_album_title_05_tv"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:layout_marginTop="10dp"
                        android:text="제목"
                        android:textColor="@color/black"
                        android:textSize="20sp"
                        app:layout_constraintStart_toStartOf="parent"
                        app:layout_constraintTop_toBottomOf="@id/look_video_music_album_img_03_iv" />

                    <TextView
                        android:id="@+id/look_video_music_album_title_06_iv"
                        android:layout_width="wrap_content"
                        android:layout_height="wrap_content"
                        android:layout_marginTop="3dp"
                        android:text="가수"
                        android:textColor="#a8a8a8"
                        android:textSize="15sp"
                        app:layout_constraintStart_toStartOf="parent"
                        app:layout_constraintTop_toBottomOf="@id/look_video_music_album_title_05_tv" />

                </androidx.constraintlayout.widget.ConstraintLayout>

            </LinearLayout>

        </HorizontalScrollView>

        <TextView
            android:id="@+id/look_jenre_title_tv"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:text="장르"
            android:textStyle="bold"
            android:textSize="18sp"
            android:textColor="@color/black"
            android:layout_marginStart="20dp"
            android:layout_marginTop="20dp"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toBottomOf="@id/look_video_music_hs"/>

        <GridLayout
            android:layout_width="match_parent"
            android:layout_height="wrap_content"
            android:layout_marginHorizontal="20dp"
            android:columnCount="1"
            android:layout_marginTop="15dp"
            app:layout_constraintTop_toBottomOf="@id/look_jenre_title_tv">

            <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:orientation="horizontal"
                android:layout_marginBottom="5dp">

                <ImageView
                    android:id="@+id/look_jenre_img_01_iv"
                    android:layout_width="0dp"
                    android:layout_height="80dp"
                    android:scaleType="fitCenter"
                    android:layout_weight="1"
                    android:src="@drawable/img_jenre_exp_1"
                    android:layout_marginEnd="5dp" />

                <ImageView
                    android:id="@+id/look_jenre_img_02_iv"
                    android:layout_width="0dp"
                    android:layout_height="80dp"
                    android:scaleType="fitCenter"
                    android:layout_weight="1"
                    android:src="@drawable/img_jenre_exp_2" />

            </LinearLayout>

            <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:orientation="horizontal"
                android:layout_marginBottom="5dp">

                <ImageView
                    android:id="@+id/look_jenre_img_03_iv"
                    android:layout_width="0dp"
                    android:layout_height="80dp"
                    android:scaleType="fitCenter"
                    android:layout_weight="1"
                    android:src="@drawable/img_jenre_exp_3"
                    android:layout_marginEnd="5dp" />

                <ImageView
                    android:id="@+id/look_jenre_img_04_iv"
                    android:layout_width="0dp"
                    android:layout_height="80dp"
                    android:scaleType="fitCenter"
                    android:layout_weight="1"
                    android:src="@drawable/img_jenre_exp_1"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintTop_toTopOf="parent" />

            </LinearLayout>

            <LinearLayout
                android:layout_width="match_parent"
                android:layout_height="wrap_content"
                android:orientation="horizontal"
                android:layout_marginBottom="20dp">

                <ImageView
                    android:id="@+id/look_jenre_img_05_iv"
                    android:layout_width="0dp"
                    android:layout_height="80dp"
                    android:scaleType="fitCenter"
                    android:layout_weight="1"
                    android:src="@drawable/img_jenre_exp_1"
                    android:layout_marginEnd="5dp"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintTop_toTopOf="parent" />

                <ImageView
                    android:id="@+id/look_jenre_img_06_iv"
                    android:layout_width="0dp"
                    android:layout_height="80dp"
                    android:scaleType="fitCenter"
                    android:layout_weight="1"
                    android:src="@drawable/img_jenre_exp_2"
                    app:layout_constraintEnd_toEndOf="parent"
                    app:layout_constraintStart_toStartOf="parent"
                    app:layout_constraintTop_toTopOf="parent" />

            </LinearLayout>
        </GridLayout>

        <ProgressBar
            android:id="@+id/look_loading_pb"
            android:layout_width="wrap_content"
            android:layout_height="wrap_content"
            android:layout_marginBottom="8dp"
            android:indeterminateTint="@color/flo"
            app:layout_constraintBottom_toBottomOf="parent"
            app:layout_constraintEnd_toEndOf="parent"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent"
            android:visibility="gone"/>
    </androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>

⑥ RetrofitInstace에 아래의 내용을 추가한다.

val songApi = retrofit.create(SongApi::class.java)

⑦ SongService 클래스를 생성한다.

class SongService() {
    private lateinit var lookView: LookView

    fun setLookView(lookView: LookView) {
        this.lookView = lookView
    }

    fun getSongs() {
        lookView.onGetSongLoading()

        RetrofitInstance.songApi.getSongs().enqueue(object : Callback<SongResponse> {
            override fun onResponse(call: Call<SongResponse>, response: Response<SongResponse>) {
                if (response.isSuccessful && response.code() == 200) {
                    val songResponse: SongResponse = response.body()!!

                    Log.d("SONG-RESPONSE", songResponse.toString())

                    when (val code = songResponse.code) {
                        1000 -> {
                            lookView.onGetSongSuccess(code, songResponse.result!!)
                        }

                        else -> lookView.onGetSongFailure(code, songResponse.message)
                    }
                }
            }

            override fun onFailure(call: Call<SongResponse>, t: Throwable) {
                lookView.onGetSongFailure(400, "네트워크 오류가 발생했습니다.")
            }
        })
    }
}

⑧ item_song.xml 파일을 생성한다.

<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout
    xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    android:paddingLeft="18dp"
    android:paddingVertical="10dp">

    <androidx.cardview.widget.CardView
        android:id="@+id/item_song_img_cardView"
        android:layout_width="50dp"
        android:layout_height="50dp"
        app:cardCornerRadius="7dp"
        app:cardElevation="0dp"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintStart_toStartOf="parent">
        <ImageView
            android:id="@+id/item_song_img_iv"
            android:layout_width="match_parent"
            android:layout_height="match_parent"
            android:scaleType="fitCenter"
            android:src="@drawable/img_album_exp2"
            app:layout_constraintStart_toStartOf="parent"
            app:layout_constraintTop_toTopOf="parent" />
    </androidx.cardview.widget.CardView>

    <TextView
        android:id="@+id/item_song_title_tv"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="Lilac"
        android:textSize="16dp"
        android:textColor="@color/black"
        android:layout_marginLeft="10dp"
        app:layout_constraintTop_toTopOf="@id/item_song_img_cardView"
        app:layout_constraintBottom_toTopOf="@id/item_song_singer_tv"
        app:layout_constraintStart_toEndOf="@id/item_song_img_cardView"/>

    <TextView
        android:id="@+id/item_song_singer_tv"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:text="아이유 (IU)"
        android:textSize="13dp"
        android:layout_marginLeft="10dp"
        app:layout_constraintTop_toBottomOf="@+id/item_song_title_tv"
        app:layout_constraintBottom_toBottomOf="@id/item_song_img_cardView"
        app:layout_constraintStart_toEndOf="@id/item_song_img_cardView"/>

    <ImageView
        android:id="@+id/item_song_play_iv"
        android:layout_width="30dp"
        android:layout_height="30dp"
        android:src="@drawable/btn_player_play"
        android:layout_marginRight="5dp"
        app:layout_constraintEnd_toStartOf="@id/item_song_more_iv"
        app:layout_constraintTop_toTopOf="@id/item_song_more_iv"
        app:layout_constraintBottom_toBottomOf="@id/item_song_more_iv"/>

    <ImageView
        android:id="@+id/item_song_more_iv"
        android:layout_width="30dp"
        android:layout_height="30dp"
        android:src="@drawable/btn_player_more"
        android:layout_marginRight="5dp"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:layout_constraintBottom_toBottomOf="parent" />

</androidx.constraintlayout.widget.ConstraintLayout>

⑨ SongRVadapter 클래스를 생성한다.

class SongRVAdapter(val context: Context, val result : FloChartResult) : RecyclerView.Adapter<SongRVAdapter.ViewHolder>() {


    override fun onCreateViewHolder(viewGroup: ViewGroup, viewType: Int): SongRVAdapter.ViewHolder {
        val binding: ItemSongBinding = ItemSongBinding.inflate(LayoutInflater.from(viewGroup.context), viewGroup, false)

        return ViewHolder(binding)
    }

    override fun onBindViewHolder(holder: SongRVAdapter.ViewHolder, position: Int) {
        //holder.bind(result.songs[position])

        if(result.songs[position].coverImgUrl == "" || result.songs[position].coverImgUrl == null){

        } else {
            Log.d("image",result.songs[position].coverImgUrl )
            Glide.with(context).load(result.songs[position].coverImgUrl).into(holder.coverImg)
        }
        holder.title.text = result.songs[position].title
        holder.singer.text = result.songs[position].singer

    }

    override fun getItemCount(): Int = result.songs.size


    inner class ViewHolder(val binding: ItemSongBinding) : RecyclerView.ViewHolder(binding.root){

        val coverImg : ImageView = binding.itemSongImgIv
        val title : TextView = binding.itemSongTitleTv
        val singer : TextView = binding.itemSongSingerTv

    }

    interface MyItemClickListener{
        fun onRemoveSong(songId: Int)
    }

    private lateinit var mItemClickListener: MyItemClickListener

    fun setMyItemClickListener(itemClickListener: MyItemClickListener){
        mItemClickListener = itemClickListener
    }
}

⑩ LookFragment를 아래와 같이 수정한다.

class LookFragment : Fragment(), LookView {
	...
    
    override fun onCreateView(
        inflater: LayoutInflater, container: ViewGroup?,
        savedInstanceState: Bundle?
    ): View? {
        binding = FragmentLookBinding.inflate(inflater, container, false)
        songDB = SongDatabase.getInstance(requireContext())!! 

        return binding.root
    }

    override fun onStart() {
        super.onStart()
        getSongs()
    }

    private fun initRecyclerView(result: FloChartResult) {
        floCharAdapter = SongRVAdapter(requireContext(), result)

        binding.lookFloChartRv.adapter = floCharAdapter
    }

    private fun getSongs() {
        val songService = SongService()
        songService.setLookView(this)

        songService.getSongs()

    }

    override fun onGetSongLoading() {
        binding.lookLoadingPb.visibility = View.VISIBLE
    }

    override fun onGetSongSuccess(code: Int, result: FloChartResult) {
        binding.lookLoadingPb.visibility = View.GONE
        initRecyclerView(result)
    }

    override fun onGetSongFailure(code: Int, message: String) {
        binding.lookLoadingPb.visibility = View.GONE
        Log.d("LOOK-FRAG/SONG-RESPONSE", message)
    }
}

이로써 LookFragment의 차트 목록을 API의 결과를 이용해 구성할 수 있게 된다.

4. SplashActivity에 자동 로그인 구현하기

서버가 없는 관계로, 구현하는 방법에 대해서만 간략히 설명하도록 하겠다. 자동 로그인을 구현하는 방법은 여러가지가 있을 수 있는데, 가장 일반적인 방법은 앞서 배운 SharedPreferences를 이용하는 것이다.

1) SharedPreferences에 ID/PW를 저장하는 방식

SharedPrefereneces에 ID와 PW를 저장하고, 이 값을 이용해 로그인 API를 호출하면 accessToken을 얻을 수 있다. 그러나 보안적으로 좋지 못한 방법이기에 실제 서비스에서는 사용하지 않을 것을 권장한다. SharedPreferences에 보안적으로 민감한 정보를 저장하는 일은 최대한 지양해야 한다.

2) SharedPreferences에 JWT를 저장하는 방식

ID/PW를 저장하는 방식보다는 accessToken/refreshToken을 저장하는 방식이 권장된다.

JWT Token도 민감한 정보가 아니냐는 의문이 들 수도 있는데, PW와 달리 JWT Token은 어차피 어딘가에 저장되어야 할 값이라는 점에서 차이가 있다. 그럼에도 불구하고 JWT Token을 꼭 보호해야겠다고 생각된다면, SharedPreferences에 key store를 이용해 암호화하여 저장하는 방식도 고려해볼 수 있다. 본인이 설계할 애플리케이션의 보안 요구사항에 따라 적절한 방법을 사용하기 바란다. 개인적인 생각으로는, 엄청 민감한 앱이 아니라면 굳이 암호화하까지 할 필요는 없어보인다.

SharedPreferences에 accessToken과 refreshToken이 모두 저장되어 있다고 가정하자. 이 방식의 동작 원리는 아래와 같다.

① SharedPreferences에서 accessToken을 가져온다.

② 저장된 accessToken이 없다면(null), LoginActivity로 전환한다.

③ accessToken이 존재한다면, 이를 서버에 보내 만료 여부를 확인한다.

④ 만료되지 않은 경우에는 해당 토큰을 계속 사용하면 된다. (MainActivity로 전환한다.)

⑤ 만료된 경우에는 서버에 refreshToken을 보내 만료 여부를 확인한다.

⑥ refreshToken이 만료되지 않은 경우에는 서버로부터 새로운 accessToken을 발급받아 SharedPreferences에 저장한다.

⑦ refreshToken이 만료된 경우에는 LoginActivity로 전환한다.

물론, 애플리케이션 요구사항에 따라 처음부터 refreshToken을 보낼 수도 있다. 이외에도 구현 방법은 여러가지일 수 있으니, 대강의 흐름만 참고하기 바란다.

3) 파이어베이스 UID 이용하기

파이어베이스를 이용하면, accessToken 대신 uid를 이용해 유저를 식별할 수 있다. 유저의 uid가 null이면 LoginActivity로, null이 아니면 MainActivity로 전환하는 방식을 사용할 수 있다. 서버리스 아키텍쳐 또는 단순한 애플리케이션에 대해서는 자동 로그인을 구현할 때에 파이어베이스 사용을 고려해볼 수 있다.

5. 공공 API 사용방법

OpenAPI를 연동하는 방법도 서버 API를 연동하는 방법과 기본적으로 동일하기 때문에 공공 데이터 포털의 API를 사용하는 방법에 대해서만 소개하기로 한다.

① 원하는 OpenApi를 선택하고 API 활용을 신청한다.

② 마이페이지에서 본인이 활용 신청한 API와 API 인증 키를 확인할 수 있다.

③ 우하단에 보이는 인증키 설정 버튼을 클릭한다.

④ ApiKeyAuth와 ApiKeyAuth2에 마이페이지에서 확인한 Encoding 인증키와 Decoding 인증키를 순서대로 넣어주면 된다.

⑤ OpenAPI 실행 준비 버튼을 클릭하고 페이징 정보를 설정한다.

⑥ RequestUrl에서 요청 URL을 확인할 수 있다.

⑦ Postman에서도 위 URL을 사용하면 동일한 결과를 얻을 수 있다.

profile
LG전자 Connected Service 1 Unit 연구원 변현섭입니다.

1개의 댓글

comment-user-thumbnail
2024년 7월 15일

헉 저도 지금 해당강의 결제해서 실습하는중인데 API서버가 막혔나요..?ㅠㅠ

답글 달기