안드로이드 공부를 하고 있는데, 어디서부터 이론 공부를 해야할지 도저히 감이 안 와서 닥치는 대로 기능 구현을 하고 있다.
DRF는 이전에 개발해놨던 프로젝트를 앱으로 구현 중인 거라 그대로 가져다 썼다.
장고 관련 포스팅은 주구장창 써놨으니 여기서는 API 관련 포스팅은 작성하지 않을 것이다.
여기저기 참조를 해도 기능을 구현하는 데는 아무런 문제가 없지만, 포스팅을 작성하는 데까지는 한참이 걸렸다.
첫 번째 이유는 MVVM 아키텍처를 이해하는데 시간이 오래 걸려서 도무지 글을 쓸 수 있는 상황이 아니었고,
두 번째 이유는 계속 보다보니 이해는 되는데 '왜 굳이 이렇게 구현하지?'라고 드는 부분들과 여러 커스텀 기능들을 추가해보다가 이제서야 포스팅을 시도할 수 있게 되었다.
솔직히 개발 공부는 이론부터 시작하는 게 아니라는 신념 하나로 머리 깨지면서 코드 작성하고 분석 중인 거라 틀린 내용이 많을 것이다.
그래도 열심히 분석해놔서 정리라도 해놓고 싶어서 포스팅을 작성하게 됐다.
참고로 참고한 코드를 다 뜯어서 분석해봤는데 플로우는 대강 요렇게 생겨먹었다.
내가 해보면서 느낀 건 이 내용이 어렵다고 느껴진다면 MVVM에 대한 이해가 부족하기 때문이라고 생각한다.
나도 하나도 모르겠는데, 포스팅 정리하면서 온갖 블로그 죄다 참조했더니 이제서야 조금 알 것 같은 기분이 든다.
굉장히 많은 내용들을 적어놓긴 했지만 쓰면서 중요하다 싶은 키워드는 이후 Concept part로 제대로 다룰 예정이다.
그리고 글을 쓰다보니 처음 구현한 방법과 다르게 커스텀한 내용도 있기 때문에 위의 플로우 차트는 참고용으로만 보는 게 좋다.
시작하기 전에 검토해보는 내 코드의 문제점.
- MVVM + 클린 아키텍처를 따르는 디렉토리 구조와는 상당히 멀다. (일단 개발 먼저 해보는 중)
- compose라는 기능을 이용해보고 싶은데, 지금은 일단 xml을 사용하여 view를 뿌릴 것이다.
- 불필요한 코드가 여전히 많이 존재한다고 생각된다.
- 싱글톤 패턴에서 interface의 Impl을 만들지 않고도 한 번에 처리하는 방법이 있는데, 아직 익숙칠 않아서 적용해보지 못했다.
- refresh token을 재발급 받기 위해 다섯 가지 방법 정도를 사용해보았지만 runBlocking을 거는 방법 외에 더 나은 방법을 여전히 찾지 못 했다. (그래서 해당 스니펫은 일단 포함시키지 않았다.)
목차
1. Network 환경 구축하기
2. ngrok로 외부에서 서버 접근 가능하게 만들기
3. 디렉토리 구성
4. Base Class
5. Util Class
6. Request & Response
7. DI Module
8. Repository & UseCase
9. ViewModel
10. Activity User Interface
1. Network 환경 구축하기
보통 웹 개발을 할 때는 url 값으로 http://localhost:[포트 번호]를 넘겨주면 잘 됐었지만
앱 개발은 에뮬레이터를 사용하여 접속을 해야하는데, 에뮬레이터 또한 하나의 OS로 분류하기 때문에 localhost는 자기 자신을 의미하게 되어 같은 PC에서 서버를 돌려도 API에 연결하지 못하는 불상사가 발생한다.
따라서 이런 부분들을 해결하기 위해 몇 가지 작업이 필요하다.
1️⃣ DRF API
Django의 settings.py에서 ALLOEWD_HOSTS의 값에 에스테리스트(*)를 넘겨주자.
ALLOWED_HOSTS = ['*']
Django는 서버에 접근하려는 호스트를 제한하는 기능을 지원하는데, 여기서 모든 호스트를 허용하게 만들었다.
2️⃣ Android Network
우선 gradle에 종속성을 추가해줘야 하는데, 뭐가 엄청 많다.
참고로 개인적인 개발을 이미 하던 중이라 이번 작업과는 관련없는 디펜던시도 있습니당..
// 프로젝트 레벨의 build.gradle
// Top-level build file where you can add configuration options common to all sub-projects/modules.
buildscript {
dependencies {
classpath "com.google.dagger:hilt-android-gradle-plugin:2.44.2"
}
}
plugins {
id 'com.android.application' version '7.2.0' apply false
id 'com.android.library' version '7.2.0' apply false
id 'org.jetbrains.kotlin.android' version '1.8.0' apply false
}
task clean(type: Delete) {
delete rootProject.buildDir
}
// 모듈 레벨의 build.gradle
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'kotlin-kapt'
id 'dagger.hilt.android.plugin'
id 'kotlin-android'
id 'kotlin-parcelize'
// id 'androidx.navigation.safeargs.kotlin'
}
dependencies {
implementation 'androidx.core:core-ktx:1.9.0'
implementation 'androidx.appcompat:appcompat:1.6.0'
implementation 'com.google.android.material:material:1.7.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.core:core-splashscreen:1.0.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.navigation:navigation-fragment-ktx:2.5.3'
implementation 'androidx.navigation:navigation-ui-ktx:2.5.3'
implementation 'androidx.viewpager2:viewpager2:1.1.0-beta01'
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4")
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4'
implementation 'io.reactivex.rxjava3:rxandroid:3.0.2'
implementation 'io.reactivex.rxjava3:rxjava:3.1.5'
implementation "com.squareup.okhttp3:okhttp:5.0.0-alpha.11"
implementation 'com.squareup.okhttp3:logging-interceptor:5.0.0-alpha.11'
implementation 'com.squareup.retrofit2:retrofit:2.9.0'
implementation 'com.squareup.retrofit2:converter-gson:2.9.0'
implementation 'com.google.code.gson:gson:2.10.1'
implementation "com.google.dagger:hilt-android:2.44.2"
kapt "com.google.dagger:hilt-android-compiler:2.44.2"
// implementation "androidx.hilt:hilt-lifecycle-viewmodel:1.0.0-alpha03"
kapt "androidx.hilt:hilt-compiler:1.0.0"
implementation 'io.insert-koin:koin-core:3.3.2'
implementation "androidx.room:room-runtime:2.5.0"
implementation 'androidx.annotation:annotation:1.5.0'
kapt "androidx.room:room-compiler:2.5.0"
testImplementation 'junit:junit:4.13.2'
androidTestImplementation 'androidx.test.ext:junit:1.1.5'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.5.1'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.5.1'
implementation 'androidx.fragment:fragment-ktx:1.5.5'
}
implementation "androidx.hilt:hilt-lifecycle-viewmodel:1.0.0-alpha03" 이 부분은 버전이 업그레이드 되면서 더이상 불러올 필요가 없다는 내용의 포스팅을 발견했었다.
근데 나는 필요 없는 정도가 아니라 오류가 발생해서 바로 주석처리 해놨다.
그리고 AndroidManifest.xml에 넘어가서 다음 스니핏을 추가해주자.
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
>
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
(...)
</manifest>
이 설정을 하지 않으면 앱에서 외부로 네트워크 통신이 불가능하기 때문에 정의해주어야 한다.
물론, 최근에는 보안상의 이유로 Runtime Permission을 사용자가 직접 권한을 설정하도록 선택창을 뜨게 만들지만
아직 그런 디테일을 따질 단계는 아니므로 패스~
2. ngrok로 외부에서 서버 접근 가능하게 만들기
💡 이 챕터는 해당되시는 분만 읽으시면 됩니다.
만약 API를 실행하는 PC와 에뮬레이터를 실행하는 장치가 서로 다를 때는 localhost 주소의 개념으로 접근이 불가능하다. (또한 실제 모바일 기기로 테스트 해보는 경우 또한 마찬가지)
만약, 하나의 PC에서 API 실행과 에뮬레이터를 모두 돌린다면 URL를 다음과 같이 지정해주면 된다.
http://10.0.2.2:8000
1️⃣ Ngrok
API 개발 시, 상황에 따라서는 외부 서비스 연동 혹은 접근을 허용해주어야 한다.
다만 Production이 아닌 단순 개발 단계에서 이러한 작업을 해주는 일은 다소 번거로워 지는데,
Ngrok은 아주 간단하게 외부에서 로컬로 접속할 수 있도록 도와주는 터널링 프로그램이다.
2️⃣ 설치
해당 사이트에서 프로그램을 설치하시고 토큰을 발급받아야 한다.
명령어로 ngrok authtoken [발급받은 코드]를 입력해서 권한 인증(한 번만 하면 됨)을 시행해주고
http 통신을 허용해줄 port 번호를 지정하여 ngrok http [포트 번호]를 입력해주면 터널링이 끝난다.
예를 들어, 현재 DRF API를 8000번 포트에서 실행하고 있기 때문에 ngrok http 8000을 입력해주면
이런 화면이 뜨면서 http://localhost:8000을 Forwarding한 주소를 알려주고 있다.
(포워딩 주소는 재실행할 때마다 변경되므로 매번 다시 설정해주어야 한다.)
3️⃣ 확인
확인 절차는 굉장히 단순하다.
저 url을 따라 들어가봐도 좋고, 나는 Postman으로 로그인 요청을 보내봤다.
잘 받아오고 있는 것을 확인했다면, 드디어 안드로이드 개발을 시작하면 된다!
3. 디렉토리 구성
SpringBoot나 Django 공부할 때처럼 디렉토리 구조가 뭔가 딱딱 정해져 있을 거라고 생각을 했는데,
Android는 개발 전략을 어떻게 짜느냐에 따라서 디렉토리 구조가 개발자 별로 천차만별이었다.
예를 들어, 사용자 입력의 처리를 주로 했느냐 아니면 의존성을 최대한 배제시킨 레이어냐에 따라 너무 달라서 어려웠다.
내가 만든 구조는 정말 단 하나의 예시이고, 심지어 지금도 개발 도중에 명칭이나 위치가 잘못되었다 싶으면 계속 수정 중에 있으니 정말 참고만 하고 넘기면 된다.
그리고 이실직고 하자면 현재 내가 만든 디렉토리 구조는 완전히 틀렸다. 🥲
나중에 안드로이드 개발이 어느정도 익숙해지고 난 후에나 다룰 내용이지만 아래 블로그를 참조하면 디렉토리를 구분하는 것이 왜 까다로운지를 이해할 수 있을 것이다.
수정 작업은 추후 개발을 해나가면서 고칠 예정이다.
4. Base Class
이건 뭐 해도 되고 안 해도 되는 부분이긴 한데, 난 Activity를 만들 때 중복되는 부분을 처리해주기 위해 BaseActivity 클래스를 상속받게끔 만들어 놨다.
BaseViewModel도 만들어 놓긴 했는데, 이번엔 굳이 안 써도 될 것 같아서 일단 상속은 안 받았다.
나중에 Compose를 사용하게 되면 data binding을 사용하지 않을 예정이라 또 코드를 갈아 엎을 예정이다.
// base.BaseActivity
import android.os.Bundle
import android.util.Log
import android.widget.Toast
import androidx.activity.OnBackPressedCallback
import androidx.annotation.LayoutRes
import androidx.appcompat.app.AppCompatActivity
import androidx.databinding.DataBindingUtil
import androidx.databinding.ViewDataBinding
import io.reactivex.rxjava3.disposables.CompositeDisposable
abstract class BaseActivity<B: ViewDataBinding> (
@LayoutRes val layoutId: Int
) : AppCompatActivity() {
lateinit var binding: B
private val compositeDisposable = CompositeDisposable()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, layoutId)
binding.lifecycleOwner = this
this.onBackPressedDispatcher.addCallback(this, backBtn)
}
protected fun showToast(msg: String) {
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
}
override fun onDestroy() {
super.onDestroy()
compositeDisposable.clear()
}
private val backBtn = object : OnBackPressedCallback(true) {
override fun handleOnBackPressed() {
Log.e("Back", "뒤로가기 클릭")
finish()
}
}
}
나중에 이걸 상속받는 Activity는 이런 식으로 작성해주면 된다.
class MainActivity : BaseActivity<ActMainBinding>(R.layout.act_main) {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
supportActionBar?.hide()
}
(...)
}
5. Util Class
Utility Class라 하면 전역에서 자주 사용되는 정적인 메소드로만 구성된 클래스다.
내가 참고하고 있는 블로그에선 총 3개의 소스 파일을 작성해놨다.
그런데 나는 showToast는 baseActivity에서 구현해놨으므로 굳이 만들지 않았다.
📌 showToast
✒️ Util or BaseActivity
showToast를 Util로 분리해두지 않은 이유가 성능 측면과 관련이 있기 때문은 아니다.
그저 BaseActivity에 선언해놓는다고 하더라도 복잡한 비즈니스 로직을 처리하는 함수는 아니기 때문에 굳이 추가해도 문제가 되지 않는다고 판단하여 따로 분리하지 않은 것뿐이다.
그런데 혹시나 본인이 판단했을 때, showToast를 Util로 분리하는 것이 더 좋다고 판단된다면 BaseActivity에서 showToast 함수를 제거하고 아래 코드를 선언한 뒤 호출하면 된다.
fun showToast(
context: Context,
message: String,
length: Int = Toast.LENGTH_SHORT
) {
Toast.makeText(context, message, length).show()
}
📌 BASE_URL
첫 번째는 BASE_URL 상수값을 지정해주었다.
ngrok을 사용한다면 url을 바꿔주면 되고, 단순히 에뮬레이터로 확인 작업만 할 거면 IP 주소 써놓는 게 편하다.
package likelion.project.fit_a_pet.utils
object Constants {
const val BASE_URL: String = "http://10.0.2.2:8000/api/"
}
📌 Resource
두 번째는 sealed class를 이용해 응답을 처리하는 방법이다.
package likelion.project.fit_a_pet.utils
sealed class Resource<T>(val data: T? = null, val message: String? = null) {
class Success<T>(data: T) : Resource<T>(data)
class Error<T>(message: String, data: T? = null) : Resource<T>(data, message)
class Loading<T>(data: T? = null) : Resource<T>(data)
}
💡 Android에서 API 응답을 처리하는 방법
저 Resource의 존재 목적 자체가 이해가 가질 않아서 많이 헷갈렸었다.
네트워크 작업은 비동기로 수행 되는데, 네트워크 응답이 진행 중인지, 혹은 에러가 발생했는지 여부를 알아야 다음 코드를 실행할지 말지를 프로그램이 판단할 수 있다.
즉, Resource 클래스는 네트워크 동작의 상태를 감시하면서 보고하는 역할이다.
응답이 성공하면 Success 객체, 실패하면 Error 객체를 반환할 것이고 여기서 에러 유형을 명시할 수도 있다.
(당연히 로딩 중에는 Loading 객체가 반환된다.)
이렇게 하면 ViewModel은 리턴되는 객체에 맞게 다음 작업을 수행해주면 된다.
📌 PreferenceUtil
class PreferenceUtil(context: Context) {
// MODE_PRIVATE : 생성한 Application에서만 사용 가능
private val accessPrefs: SharedPreferences =
context.getSharedPreferences("access", Context.MODE_PRIVATE)
private val refreshPrefs: SharedPreferences =
context.getSharedPreferences("refresh", Context.MODE_PRIVATE)
fun getAccessToken(key: String, defValue: String): String {
return accessPrefs.getString(key, defValue).toString()
}
fun setAccessToken(key: String, str: String) {
accessPrefs.edit().putString(key, str).apply()
}
fun getRefreshToken(key: String, defValue: String): String {
return refreshPrefs.getString(key, defValue).toString()
}
fun setRefreshToken(key: String, str: String) {
refreshPrefs.edit().putString(key, str).apply()
}
}
Preference란 전역적으로 관리해야 할 데이터들을 저장해둔다고 보면 된다.
데이터가 큰 값들을 관리할 때는 부적절하지만, token 같은 간단한 문자열 정보라면 키/값 쌍으로 저장해두는 데 제격이다.
이 방법이 싫다면 token을 관리해주는 라이브러리도 있는데, 굳이 필요할까 싶어서 안 썼다.
📌 Custom Error Class
class NetworkException(val code: Int, override val message: String?): IOException(message) {
override fun toString(): String {
return "NetworkException(code=$code, message=$message)"
}
}
이 부분은 반드시 작성할 필요는 없다.
다만, 나는 Exception에러 정보를 이용해 사용자에게 적합한 메시지를 출력하기 위해 커스텀 Error 클래스를 만들었다.
참고로 이 클래스는 이후 Interceptor에서 사용할 예정인데, Retrofit은 IOException밖에 예외 처리를 하지 못 한다고 한다.
이걸 몰라서 한참을 헤맸었다. :(
6. Request & Response
Network 통신을 위해서는 api에 알맞은 요청(Request)을 보내고, api에서 회신한 데이터를 받을 수 있는 응답(Response)을 받아야 한다.
그리고 응답받은 데이터를 저장해둘 적절한 객체(Domain)도 존재해야 한다.
최근 대부분의 api는 JSON 형태로 요청과 응답을 주고 받는다.
그렇다면 Request, Response를 객체가 아니라 JSON 형태로 만들어야 하냐고 물어볼 수 있지만,
JSON to Kotlin Plugin을 이용하면 간단히 직렬화/역직렬화가 가능하다.
우선, 회원가입과 로그인 Request 객체를 만들자.
📌 API에서 정의한 Domain field 확인
class CustomUser(AbstractBaseUser, PermissionsMixin):
user_id = models.BigAutoField(
primary_key=True,
unique=True,
editable=False,
verbose_name="user_id",
)
username = models.CharField(
max_length=45,
)
nickname = models.CharField(max_length=45, unique=True)
create_dt = models.DateTimeField(default=timezone.now, blank=True, null=True)
email = models.CharField(max_length=100)
phone = models.CharField(max_length=45, blank=True, null=True)
profile_img = models.ImageField(upload_to="users/%Y/%m/%d/", blank=True, null=True)
is_staff = models.BooleanField(default=False)
is_active = models.BooleanField(default=True)
objects = CustomAccountManger()
USERNAME_FIELD = "nickname"
REQUIRED_FIELDS = ["username", "email"]
class Meta:
db_table = "users"
verbose_name = _("user")
verbose_name_plural = _("users")
def __str__(self):
return self.username
내가 API에서 명시한 유저 객체는 위와 같다.
회원가입 요청 시에 필요한 정보가 무엇인지, 그리고 로그인을 할 때는 어떤 필드값으로 할 것인지를 선택한다.
이미지의 경우엔 테스트가 번잡스러우니 필드에서 제외해놓고 구현했다.
📌 Register, Login Request/Response
// 회원가입 요청
data class RegisterRequest(
@SerializedName("username")
val username: String,
@SerializedName("nickname")
val nickname: String,
@SerializedName("password")
val password: String,
@SerializedName("email")
val email: String,
@SerializedName("phone")
val phone: String
)
// 회원가입 응답
data class RegisterResponse(
@SerializedName("User")
val user: User
)
// 로그인 요청
@Parcelize
data class LoginRequest(
@SerializedName("nickname")
val nickname: String,
@SerializedName("password")
val password: String,
) : Parcelable
// 로그인 응답
@Parcelize
data class LoginResponse(
@SerializedName("access")
val access: String,
@SerializedName("refresh")
val refresh: String
) : Parcelable
로그인 응답/요청에만 Parcelize 어노테이션이 걸려있는데, 이건 처음에 개발할 때 제대로 작업이 안 되서 이것저것 사용해보다가 생긴 부산물이었다.
지워도 딱히 문제없이 작동하는 것으로 기억하지만, 혹여 데이터 변환이 잘 안 되는 것 같다 싶으면 써보는 것을 추천한다.
📌 User Data Class
data class User(
@SerializedName("username")
val username: String,
@SerializedName("nickname")
val nickname: String,
@SerializedName("password")
val password: String,
@SerializedName("email")
val email: String,
@SerializedName("phone")
val phone: String,
@SerializedName("create_dt")
val create_dt: String,
@SerializedName("profile_img")
val profile_img: String
)
내 경우에는 register 등록이 정상적으로 수행된 경우, user 객체를 리턴하도록 api를 설계했기 때문에 데이터를 받아줄 객체가 필요해서 만들었다.
그런데 로그인 했으면 해당 유저 정보를 가져오는 것이 당연하지 않나..?
본인이 설계한 User 객체에 맞게 필드를 정의하자.
참고로 이미지의 경우엔 아직 어떻게 처리하는지 공부하지 않아서, 대충 url로 받겠거니 하고 String 타입으로 선언해둔 것 뿐이니 정답일지 아닐지는 아직 나도 모르겠다.
📌 API End-point
interface AuthAPI {
companion object{
const val REGISTER = "users/signup/"
const val LOGIN = "users/signin/"
}
@POST(REGISTER) // 요청 URL 명시, payload 작성
suspend fun register(@Body requestRequest: RegisterRequest): RegisterResponse
@POST(LOGIN)
suspend fun login(@Body loginRequest: LoginRequest): LoginResponse
}
권한 관련 요청을 할 때의 API 명세서를 작성해준다.
@POST(혹은 GET, PUT, DELETE)는 Retrofit 라이브러리에서 제공하는 어노테이션 중 하나다.
어노테이션에는 요청을 보내고자 하는 url을 지정해주고, @Body 뒤에는 해당 요청의 payload에 데이터를 추가하는 것이라 보면된다.
7. DI Module
@POST나 @GET 어노테이션을 걸어놓는 것만으로도 네트워크 작업 시, Retrofit 객체가 알아서 생성되고 진행된다.
하지만 패킷을 송수신 할 때마다 Retrofit.Builder가 생성되는 건 비효율적이다.
따라서 Retrofit.Builder를 DI 라이브러리인 Dagger Hilt를 이용하여 Singleton으로 등록시킬 것이다.
그리고 Retrofit의 Builder 클래스를 사용하면 네트워크 작업 도중에 끼어들어서 다양한 설정도 추가할 수 있다.
GsonConverterFactory를 사용하여 Gson 객체와 Retrofit을 연결해주거나,
내가 너무나 좋아하는 Interceptor를 추가할 수도 있게 된다.
@Module
@InstallIn(SingletonComponent::class)
class ApplicationModule{ // 어플리케이션 전체에서 사용되는 종속성
(... 밑에 나올 함수들은 이 클래스에 종속되어 있는 것들 ...)
}
1️⃣ GsonBuilder
@Singleton @Provides
fun provideGsonBuilder(): Gson { // Kotlin 객체 <-> JSON
return GsonBuilder().create()
}
서버에서 API를 통해 JSON 값을 받으면, 이를 파싱해서 알맞게 값을 넣어주어야 하는 수작업이 필요하다.
하지만 이 과정을 한 번이라도 해본 사람은 알겠지만, 진짜 너무 귀찮다.
그런 귀찮음을 호소하는 개발자가 한 둘이 아니었을 테니, 자바 객체를 JSON으로(혹은 역으로) 변환하는 라이브러리가 당연히 존재한다.
그게 바로 Gson이다.
2️⃣ Retrofit
@Singleton @Provides
fun provideRetrofit( // 원격 API에 네트워크 클래스 생성
gson: Gson,
okHttpClient: OkHttpClient
): Retrofit.Builder {
return Retrofit.Builder()
.baseUrl(BASE_URL)
.addConverterFactory(GsonConverterFactory.create(gson))
.client(okHttpClient)
}
해당 함수는 Retrofit.Builder 인스턴스를 반환하는 함수를 Singleton으로 등록한다.
따라서 애플리케이션 전체에서 한 번만 Retrofit.Builder 인스턴스를 생성하고 재사용할 수 있게 된다.
3️⃣ APIService
@Singleton @Provides
fun provideAuthService(retrofit: Retrofit.Builder): AuthAPI { // Retrofit으로 구축된 API 인스턴스에서 가져온다.
return retrofit
.build()
.create(AuthAPI::class.java)
}
AuthAPI interface는 APIService를 사용하기 위한 Client 코드를 생성하는 Retrofit 객체를 필요로 한다.
Retrofit 객체를 생성하기 위한 Builder를 ApplicationModule에서 Singleton으로 등록해두었고, 이 의존성은 SingletonComponent에 포함된 클래스들이 전부 공유하고 있다.
따라서, AuthAPI도 Singleton으로 등록을 해주어야 다른 클래스들과 동일한 Retrofit 객체를 공유하여 API 호출이 가능해진다.
4️⃣ Interceptor
@Singleton @Provides
fun provideInterceptor (
@ApplicationContext context: Context,
authInterceptor: AuthInterceptor,
errorInterceptor: ErrorInterceptor,
): OkHttpClient {
val loggingInterceptor = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
return OkHttpClient.Builder()
.addInterceptor(loggingInterceptor)
.addInterceptor(errorInterceptor)
.build()
}
@Singleton @Provides
fun provideErrorInterceptor() : Interceptor {
return ErrorInterceptor()
}
@Module
@InstallIn(SingletonComponent::class)
class ErrorInterceptor @Inject constructor() : Interceptor {
// @Throws(NetworkException::class)
override fun intercept(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request())
// exception handler for network
if (!response.isSuccessful) {
Log.e("LoginInterceptor", "Login failed: ${response.code}")
Log.e("LoginInterceptor", "Login failed: ${response.message}")
throw NetworkException(response.code, response.message)
}
// exception handler for non-json type data
response.extractResponseJson()
return response
}
private fun Response.extractResponseJson(): JSONObject {
val jsonString = this.peekBody(Long.MAX_VALUE).string() ?: EMPTY_JSON
return try {
JSONObject(jsonString)
} catch (e: Exception) {
Log.e("LoginInterceptor", "not json response $jsonString")
throw NetworkException(999, "not json type")
}
}
companion object {
private const val EMPTY_JSON = "{}"
}
}
OkHttpClient를 이용하여 인터셉터를 담당하는 스니펫이다.
HttpLoggingInterceptor를 추가하면 요청을 실패하게 만든 오류의 원인을 디버깅하고 확인하는데 필수적이다.
ErrorInterceptor는 처음에 provideInterceptor 내에서 람다 함수를 이용해 만들었었는데 몇 가지 문제가 있었다.
📌 Interceptor 객체 상속 VS lambda 함수로 Interceptor 처리
1️⃣ 실행 순서
Interceptor 객체를 상속받아 처리하는 방법은 Interceptor 객체가 생성되고 등록된 이후에 Retrofit 라이브러리에서 요청을 보낼 때마다 실행된다.
반면, provideInterceptor에서 처리하는 방법은 Retrofit 라이브러리에서 OkHttpClient 객체를 생성할 때 실행된다.
따라서, Interceptro 객체를 상속받는 방법은 요청에 대한 처리를 보다 자세하게 할 수 있지만, provideInterceptor에서 처리하는 방법은 초기 설정 등의 처리를 할 수 있다.
2️⃣ 유연성
Interceptor 객체를 상속받는 방법은 각각의 Interceptor 객체가 독립적으로 동작하기 때문에 서로 다른 Interceptor 객체를 생성해서 요청에 대한 처리를 다르게 할 수 있다.
provideInterceptor에서 처리하는 방법은 모든 요청에 대해 동일한 처리를 할 수밖에 없기 때문에 유연성이 떨어질 수 있다.
따라서 요청에 대한 처리를 자세하게 하고 싶다면 Interceptor 객체를 상속받는 방법을 사용하고, 초기 설정 등의 처리를 하고 싶다면 provideInterceptor에서 처리하는 방법을 사용하는 것이 좋다.
나는 Interceptor를 이용해서 간단한 에러 처리와 access token 만료 시, refresh token을 요청하는 과정을 거치려 했기 때문에 Interceptor 객체를 상속받는 방식을 택했다.
📌 AuthApplication
루트 디렉터리에 클래스를 만들고 다음 스니펫을 추가한다.
@HiltAndroidApp
class AuthApplication: Application() {
companion object {
lateinit var prefs: PreferenceUtil
}
override fun onCreate() {
super.onCreate()
prefs = PreferenceUtil(applicationContext)
}
}
Hilt를 사용하여 의존성 주입을 구성하는 방법이다.
@HiltAndroidApp 어노테이션은 애플리케이션 클래스에 추가되어 Hilt가 애플리케이션 컨텍스트에서 사용할 수 있는 컨테이너를 생성하도록 지시한다.
이 컨테이너는 애플리케이션의 수명 주기에 따라서 의존성을 관리하고 주입하는 일을 알아서 처리해준다.
따라서 위의 스니펫은 PreferenceUtil 클래스의 인스턴스를 생성하고, 애플리케이션 수명주기 동안 유지하기 위해 Companion 객체를 사용하여 전역 변수로 유지한다.
8. Repository & UseCase
📌 Repository Module
@Module
@InstallIn(SingletonComponent::class)
class RepositoryModule {
@Singleton @Provides
fun provideAuthRepository(api: AuthAPI): AuthRepository = AuthRepository(api)
}
네트워크 요청을 통해 얻은 데이터는 Repository로 흘러간다.
따라서 다른 클래스가 해당 데이터들을 사용할 수 있도록 Repository에 액세스할 수 있도록 추가해야 한다.
AuthAPI는 위에서 명시했으므로, AuthRepository를 정의해보자.
📌 AuthRepository
class AuthRepository @Inject constructor(
private val api: AuthAPI
) {
suspend fun register(request: RegisterRequest): RegisterResponse {
try {
return api.register(request)
} catch (e: NetworkException) {
throw NetworkException(e.code, e.message)
}
}
suspend fun login(request: LoginRequest): LoginResponse {
try {
return api.login(request)
} catch (e: NetworkException) {
throw NetworkException(e.code, e.message)
}
}
}
AuthRepository 클래스는 인증 데이터를 Network Request로부터 가져와 ViewModel에 전달한다.
(정확히는 viewModel에서 AuthRepository를 호출해서 결과를 건네 받는다.)
Local DB에서 데이터를 가져올 경우 Local Data를 가져와서 ViewModel에 전달하는 데도 계속 사용한다.
중요 목표는 source를 알지 못 하는 viewModel없이 source에서 viewModel로 데이터를 전달한다는 목표다.
📌 UseCase
✒️ What is UseCase?
처음에 대체 UseCase라는 클래스가 왜 필요한가 한참을 고민했었다.
이는 Clean Architecture를 지키기 위해 domain layer에서 사용하는 클래스로서 서비스를 사용하고 있는 사용자(User)가 해당 서비스를 통해 하고자 하는 것을 의미한다.
즉, ViewModel이 어떤 것을 하고자 하는지 직관적으로 파악할 수 있으며, Repository를 직접 전달받지 않고 UseCase를 사용함으로써 의존성을 낮출 수 있는 효과까지 얻을 수 있다.
class RegisterUserUseCase @Inject constructor(private val repository: AuthRepository) {
operator fun invoke(request: RegisterRequest): Flow<Resource<RegisterResponse>> = flow {
try {
emit(Resource.Loading()) // emit: flow에서 데이터를 전달하기 위한 함수
val response = repository.register(request)
emit(Resource.Success(response))
} catch (e:NetworkException) {
Log.e("RegisterInterceptor", "Register failed exception: ${e.message}")
emit(Resource.Error(e.message))
}
}
}
class LoginUserUseCase @Inject constructor(private val repository: AuthRepository) {
operator fun invoke(request: LoginRequest): Flow<Resource<LoginResponse>> = flow {
try {
emit(Resource.Loading())
val response = repository.login(request)
emit(Resource.Success(response))
} catch (e:NetworkException) {
Log.e("LoginInterceptor", "Login failed exception: ${e.message}")
emit(Resource.Error(e.message))
}
}
}
// Kotlin invoke 연산자 : 이름 없이 호출된다. == 메서드 이름없이 호출할 수 있다.
// 람다는 invoke 함수를 가진 객체다.
// Kotlin Flow는 suspend function을 보완하기 위한 객체.
// suspend fun이 하나의 결과물을 던진다면, flow로 여러 개의 결과를 원하는 형식으로 던질 수 있다.
// suspend fun과 동일하게 비동기로 동작하며 cold stream을 지원한다.
9. ViewModel
@HiltViewModel
class AuthViewModel @Inject constructor(
private val registerUserUseCase: RegisterUserUseCase,
private val loginUserUseCase: LoginUserUseCase
) : ViewModel() {
private val _registerState: MutableStateFlow<RegisterState> = MutableStateFlow(RegisterState())
val registerState: StateFlow<RegisterState> get() = _registerState
private val _loginState: MutableStateFlow<LoginState> = MutableStateFlow(LoginState())
val loginState: StateFlow<LoginState> get() = _loginState
}
몇 년 전까지만 해도 MutableLiveData를 많이 사용했지만, 나는 MutableStateFlow를 사용했다.
이유는 .xml로 view를 다루지 않고, compose로 대체하기 위한 밑작업이다.
MutableStateFlow로 넘어오는 값을 _registerState가 관찰하고 있고, registerState로 원하는 view에 전달하는 최종값을 알려준다.
✒️ MutableLiveData vs MutableStateFlow
1. 데이터 흐름 방식
• MutableLiveData: 데이터를 일회성 이벤트로 제공하며, Observer 패턴을 사용하여 데이터 변경을 알린다. 관찰자가 데이터를 수독으로 구독하고 업데이트를 처리해야 하므로, 데이터 변화를 즉시 반영하기 위해 Observer 객체가 추가적으로 필요하다.
• MutableStateFlow: 데이터의 최신 상태를 연속적으로 제공하며, 코루틴 플로우 형태를 가지면서 데이터 변경을 자동으로 알린다. 이런 흐름 방식은 Compose와 같은 선언형 UI Framework와 잘 맞으며, 데이터가 변경될 때마다 UI가 자동으로 업데이트된다.
2. 데이터 타입
• MutableLiveData: Android Jetpack에서 제공되는 클래스로써 Android 앱에서 UI 업데이트를 위해 사용된다. LiveData는 Nullable일 수 있으며, LiveData 자체의 생명주기를 가지므로 메모리 누수를 방지할 수 있다.
• MutableStateFlow: StateFlow는 Kotlin 표준 라이브러리 중 하나로써 코루틴 플로우와 함께 사용된다. StateFlow의 Data는 Nullable일 수 없으며, 상태 값이 항상 존재한다.
3. 구독 방식
• MutableLiveData: Observer 패턴을 사용하여 데이터 변경을 알리며, Observer 객체가 LiveData에 등록되어야 한다. 일반적으로 Activity나 Fragment와 같은 Android 컴포넌트의 LifeCycle을 따른다.
• MutableStateFlow: 코루틴 플로우를 기반으로 하며, 코루틴을 사용하여 비동기적으로 데이터를 처리하고 업데이트를 수신할 수 있다. StateFlow를 구독하면 코루틴이 생성되어 데이터 변경 사항을 수진하고, 구독을 취소하면 코루틴도 취소된다.
✒️ 왜 compose를 쓸 때는 MutableStateFlow를 써야 하는가?
Compose 사용은 기존 xml을 사용하던 것과 달리 Android의 완전한 선언형 프로그래밍으로써의 탈피를 의미한다.
UI 상태의 변경이 외부에서 일어날 경우 자동으로 재구성되는데, 이런 특징으로 인해 Compose에서 상태를 관리하려면 변경 가능한(Mutable) 상태를 지원해야 한다.
LiveData를 사용해야 하는 경우, 데이터 변경을 알리기 위해 Observer 패턴을 사용해야 하나, 이는 Compose의 LifeCycle과는 맞지 않는다. 따라서 Compose에서는 Mutable State를 사용하여 상태 변화를 알리며, 최신 값을 수신하는 Flow를 사용하는 것이 권장된다.
특히, Compose는 MutableStateFlow에 대한 구독을 자동으로 관리해주기 때문에 개발자가 직접 상태 변경 알림을 처리할 필요조차 없다.
fun register(registerRequest: RegisterRequest) {
registerUserUseCase(registerRequest)
.onEach { result ->
when(result) {
is Resource.Success -> {
_registerState.value = RegisterState(data = result.data)
}
is Resource.Loading -> {}
is Resource.Error -> {
Log.e("RegisterInterceptor", "failed ViewModel ${result.message}")
_registerState.value = RegisterState(error = result.message)
}
}
}.launchIn(viewModelScope)
}
fun login(loginRequest: LoginRequest) {
loginUserUseCase(loginRequest)
.onEach { result ->
when(result) {
is Resource.Success -> {
val access = result.data?.access
val refresh = result.data?.refresh
if (access != null && refresh != null) {
AuthApplication.prefs.setAccessToken("access", access)
AuthApplication.prefs.setRefreshToken("refresh", refresh)
}
_loginState.value = LoginState(data = result.data)
}
is Resource.Loading -> {}
is Resource.Error -> {
Log.e("LoginInterceptor", "failed ViewModel ${result.message}")
_loginState.value = LoginState(error = result.message)
}
}
}.launchIn(viewModelScope)
}
📌 RegisterState & LoginState
그리고 현재 응답 상태를 RegisterState와 LoginState를 이용해서 데이터 상태를 표시하도록 정의하고 있다.
class RegisterState (
var data: RegisterResponse? = null,
var error: String? = "An error occurred"
)
class LoginState(
var data: LoginResponse? = null,
var error: String? = "An error occurred"
)
처음에는 loading 정보도 관리하고 있었는데, 생각해보니까 일단 Loading을 dialog나 showToast로 띄워버리고,
Activity에서는 data가 넘어왔는지 아닌지만 관찰하면 되는 것 아닌가? 라는 생각이 들어 해당 속성값을 지워버렸다.
Resource에서도 지워버리려다가, 해당 클래스는 전역에서 사용할 애라 나중에 쓸모가 있지 않을까 싶어 일단 제거하지 않고 놔두었다.
10. Activity User Interface
xml로 뷰를 만드는 건, 여기서 다룰 내용은 아니니까 대충 구글링해서 간단한 EditText와 버튼을 만들어주자.
이후 해당 xml과 Activity를 연결하는데 @AndroidEntryPoint 어노테이션을 추가해주어야 한다.
해당 어노테이션은 안드로이드 앱에서 Dagger Hilt를 사용하여 Dependency Injection을 해준다.
클래스 수준에 적용되면 해당 클래스는 Hilt 컨테이너에 의해 관리되며,
생성자, 필드 및 메서드 수준에서 적용되면 해당 멤버에 대한 주입을 요청한다.
여튼 Register는 건너뛰고, 좀 더 간단하게 확인 가능한 Login 동작으로 테스트해보자.
@AndroidEntryPoint
class LoginActivity : BaseActivity<ActLoginBinding>(R.layout.act_login) {
private val authViewModel: AuthViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val loginBtn = binding.loginBtn
loginBtn.setOnClickListener {
val nickname = binding.edtId.text.toString()
val pwd = binding.edtPwd.text.toString()
if (nickname.isEmpty()) {
showToast("Nickname required")
} else if (pwd.isEmpty()) {
showToast("Password required")
} else {
val loginRequest = LoginRequest(nickname, pwd)
showToast("Loading...")
authViewModel.login(loginRequest)
observeLogin()
}
}
}
private fun observeLogin() {
lifecycleScope.launch {
authViewModel.loginState.collect { data ->
if (data.data != null) {
showToast("Login successful")
finish()
} else {
showToast("Login Failure ${data.error}")
}
}
}
}
}
Http status 200이 뜨면서 refresh token과 access token이 무사히 담기는 것을 확인할 수 있다!!!!
하, 힘들었다.