본문 바로가기
Android

Android clean architecture에서 domain을 좀 더 domain 답게!

by 안솝우화 2022. 8. 18.
반응형

안녕하세요, 우선 본 글은 인프런에 업로드된 강의의 변동 상항에 관한 내용이며 읽으시기 전에 앞서 변동사항이 있기 때문에 앞 글을 먼저 읽어주시면 좋을 것 같습니다

https://www.inflearn.com/course/%EC%95%88%EB%93%9C%EB%A1%9C%EC%9D%B4%EB%93%9C-%EC%9D%B4%EB%A6%84%EA%B6%81%ED%95%A9#

 

노래 들으며 실전 프로젝트로 안드로이드 최신 기술을 공부해 보자! - 이름 궁합 편 - 인프런 | 강

노래를 들으면서, Clean Architecture를 지향하고 MVVM 디자인 패턴을 이용한 안드로이드 앱을 제작해봐요!, - 강의 소개 | 인프런...

www.inflearn.com

2022.08.11 - [Android] - Kotlin DSL을 Kotlin DSL답게 사용하기!

 

Kotlin DSL을 Kotlin DSL답게 사용하기!

기존 제가 사용하던 DSL 방법이 빌드를 해보니 오류가 났던 기억이 있어 글을 작성하게 되었습니다 강의에서 사용했던 Gradle를 아래 소개해 드리는 방법으로 변경해 주세요! Gradle 파일 이름 변경

asuhdevstory.tistory.com

 

그럼 시작해 보겠습니다!

Clean architecture라는 말을 개발을 하며 한 번쯤 들어보셨을 겁니다. 클린 아키텍처는 4개의 레이어로 이루어져 있지만 안드로이드에서는 모두가 익숙한 3개의 presentation, domain, data로 나뉩니다.

이때 domain 레이어는 안드로이드의 의존성을 가지지 않고 Java와 Kotlin으로 이루어져 있어야 하고 다른 애플리케이션에서도 사용할 수 있어야 합니다. 때문에 최대한 domain 모듈의 라이브러리 종속성을 줄여주는 것이 좋습니다.

 

Domain 모듈 gradle 변경

현재 강의에서 소개한 domain layer의 gradle 입니다

plugins {
    id ("com.android.library")
    id ("kotlin-android")
    id ("kotlin-kapt")
    id ("dagger.hilt.android.plugin")
    id ("com.google.gms.google-services")
}

android {
    compileSdk = SdkVersions.compileSdk

    defaultConfig {
        minSdk = SdkVersions.minSdk
        targetSdk = SdkVersions.targetSdk

        testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
        consumerProguardFiles("consumer-rules.pro")
    }

    buildTypes {
        release {
            isMinifyEnabled = false
            proguardFiles(getDefaultProguardFile("proguard-android.txt"), "proguard-rules.pro")
        }
    }

    compileOptions {
        sourceCompatibility = JavaVersion.VERSION_1_8
        targetCompatibility = JavaVersion.VERSION_1_8
    }

    kotlinOptions {
        jvmTarget = "1.8"
    }
}

dependencies {
    implementation (KTX.CORE)
    implementation (AndroidX.APP_COMPAT)
    implementation (Google.MATERIAL)
    testImplementation (TestTool.JUNIT)
    androidTestImplementation (TestTool.ANDROID_X_JUNIT)
    androidTestImplementation (TestTool.ANDROID_X_ESPRESSO)
    implementation (Firebase.FIREBASE_DATABASE_KTX)
    implementation (Firebase.FIREBASE_FIRESTORE_KTX)

    // Retrofit
    implementation (Retrofit.RETROFIT)
    implementation (Retrofit.CONVERTER_GSON)
    implementation (Retrofit.CONVERTER_JAXB)

    //okHttp
    implementation (OkHttp.OKHTTP)
    implementation (OkHttp.LOGGING_INTERCEPTOR)

    // dager hilt
    implementation (DaggerHilt.DAGGER_HILT)
    kapt (DaggerHilt.DAGGER_HILT_COMPILER)
    implementation (DaggerHilt.DAGGER_HILT_VIEW_MODEL)
    kapt (DaggerHilt.DAGGER_HILT_ANDROIDX_COMPILER)
}

plugins

우선 plugins 부분부터 보겠습니다. 앞에서 말한 것처럼 라이브러리에 의존성을 최대한 줄이고 android의 의존성을 가지면 안 되기 때문에 다음과 같이 바꿔줍니다.

plugins {
    id ("org.jetbrains.kotlin.jvm")
    id ("kotlin-kapt")
}

android

이 부분은 전체 삭제해 줍니다

dependencies

추가한 라이브러리들을 모두 지워줍니다. 다만 domain layer에서도 hilt 의존성 주입은 필요하기 때문에 기존 hilt 종속성 구문은 삭제해주시고 새로운 종속성을 추가해 줍니다

dependencies {

    // dager hilt
    implementation (DaggerHilt.DAGGER_HILT_JAVAX)
}

buildSrc모듈의 디펜던시 파일에 DaggerHilt 부분에 추가해 줍니다

object DaggerHilt {
    const val DAGGER_HILT = "com.google.dagger:hilt-android:2.40.5"
    const val DAGGER_HILT_COMPILER = "com.google.dagger:hilt-android-compiler:2.40.5"
    const val DAGGER_HILT_VIEW_MODEL = "androidx.hilt:hilt-lifecycle-viewmodel:1.0.0-alpha03"
    const val DAGGER_HILT_ANDROIDX_COMPILER = "androidx.hilt:hilt-compiler:1.0.0"
    const val DAGGER_HILT_JAVAX = "javax.inject:javax.inject:1" //추가된 부분
}

 

그럼 최종적으로 domain 모듈의 gradle는 다음과 같이 변경됩니다

plugins {
    id ("org.jetbrains.kotlin.jvm")
    id ("kotlin-kapt")
}

dependencies {

    // dager hilt
    implementation (DaggerHilt.DAGGER_HILT_JAVAX)
}

 

Domain 모듈의 코드에서 라이브러리 의존도 삭제

이대로 빌드를 하게 되면 당연히 오류가 나게 됩니다. 아직 코드에서 라이브러리에 관련된 코드를 지우지 않았기 때문이죠.

Domain 모듈의 로직을 수정하게 되면 presentation 모듈도 영향을 받게 되어 꽤 많은 코드들을 변경해 주어야 합니다..ㅠㅠ

전체 다 소개하기는 너무 길어지기 때문에 일부분만 설명하고 커밋내용 링크로 대체하겠습니다.

Domain 모듈의 외부 의존도를 줄이기 위해서 domain 모듈 안에 있는 MainRepository 코드를 보겠습니다

interface MainRepository {
    suspend fun checkLoveCalculator(remoteErrorEmitter: RemoteErrorEmitter, host : String, key : String, mName : String, wName : String) : DomainLoveResponse?

    fun getStatistics() : Task<DataSnapshot>

    fun setStatistics(plusResult : Int) : Task<Void>

    fun setScore(score: DomainScore) : Task<Void>

    fun getScore(): Task<QuerySnapshot>
}

반환 값에 Task와 DataSnapshot, QuerySnapshot이 보이실 겁니다. 모두 firebase 의존성을 추가해 사용할 수 있는 함수들입니다. 이 부분을 의존도를 줄이기 위해 모두 없애줘야 하는 게 목표입니다. 그렇다면 어떻게 삭제할 수 있을까요??

바로 DataClass로 반환 값을 대체하는 것입니다.

suspend fun getStatistics() : GetFirebaseResponse<String>

suspend fun setStatistics(plusResult : Int) : SetFirebaseResponse

suspend fun setScore(score: DomainScore) : SetFirebaseResponse

suspend fun getScore(): GetFirebaseResponse<List<DomainScore>>

저는 이런 식으로 대체했고 다른 DataSource와 같은 인터페이스도 동일하게 바꿔주셔야 합니다. suspend를 사용하는 이유는 좀 있다 소개드릴 data layer에서 coroutine을 이용해 await를 사용하기 때문입니다.

각각 Response에 data class에 대해 보여드리겠습니다 (domain layer에 포함되어 있음)

data class GetFirebaseResponse<T>(
    val state : FirebaseState,
    val result : T?
)

data class SetFirebaseResponse(
    val state : FirebaseState
)

Set 하는 명령에는 반환 값이 필요 없기 때문에 성공 여부만 반환할 수 있게 하였고 Get 명령에는 가져오는 반환 값과 성공 여부를 가져오게 만들었습니다. 여기서 제네릭을 사용한 이유는 반환 값을 String이나 Int등으로 단정지을수 없기 때문에 유동정으로 변경하여 사용할 수 있도록 하기 위함입니다.

그럼 어떻게 반환 값을 알 수 있을까요?? 간단합니다. 변경전의 코드의 presentation 레이어의 코드에서 get 명령에서 어떤 반환값을 가져와 사용하였는지 또는 직접 firebase에서 값을 보고 어떤 값이 들어올지 확인하시면 됩니다.

이렇게 도메인 부분의 의존성을 해결했다면 이제 data 레이어에서 어떻게 이러한 Response로 전달을 해주냐가 문제입니다.

data layer로 넘어가서 가장 중요한 BaseDataSource 부분을 봅시다. 아래 코드는 추가한 부분입니다

이때 firebase를 coroutine을 사용해 결괏값을 await()해주기 때문에 아래 종속성을 data module gradle에 추가해 주어야 합니다.

//data layer gradle 파일
implementation (Coroutines.COROUTINES_PLAY_SERVICES)

//buildSrc -> Dependency 파일
const val COROUTINES_PLAY_SERVICES = "org.jetbrains.kotlinx:kotlinx-coroutines-play-services:1.1.1"
suspend inline fun <T> safeGetFirebaseRTDBCall(crossinline callFunction: () -> Task<T>) : GetFirebaseResponse<T> {
    var state = FirebaseState.FAILURE
    var result : T? = null
    callFunction.invoke()
        .addOnSuccessListener {
            result = it
            state = FirebaseState.SUCCESS
        }
        .addOnFailureListener {
            state = FirebaseState.FAILURE
        }.await()

    return GetFirebaseResponse(state = state, result = result)
}

suspend inline fun <T> safeSetFirebaseRTDBCall(crossinline callFunction: () -> Task<T>) : SetFirebaseResponse {
    var state = FirebaseState.FAILURE
    callFunction.invoke()
        .addOnSuccessListener {
            state = FirebaseState.SUCCESS
        }
        .addOnFailureListener {
            state = FirebaseState.FAILURE
        }.await()

    return SetFirebaseResponse(state = state)
}

suspend inline fun safeSetFireStoreCall(crossinline callFunction: () -> Task<QuerySnapshot>) : GetFirebaseResponse<List<DomainScore>> {
    var state = FirebaseState.FAILURE
    val result = arrayListOf<DomainScore>()

    callFunction.invoke()
        .addOnSuccessListener {
            result.clear()
            for (item in it.documents) {
                item.toObject(DomainScore::class.java).let {
                    result.add(it!!)
                }
            }
            state = FirebaseState.SUCCESS
        }
        .addOnFailureListener {
            state = FirebaseState.FAILURE
        }.await()

    return GetFirebaseResponse(state = state, result = result)
}

이 코드들은 firebase 호출을 해 오류가 나는지 안 나는지 판단하고 state에 성공 여부를 담고 result에 반환 값이 있다면 넣어 원하는 Response로 가공하는 코드입니다.

실제 사용은 다음과 같이 합니다.

class MainDataSourceImpl @Inject constructor(
    private val loveCalculatorApi: LoveCalculatorApi,
    private val firebaseRtdb: FirebaseDatabase,
    private val fireStore: FirebaseFirestore
) : BaseDataSource(), MainDataSource {

    override suspend fun checkLoveCalculator(remoteErrorEmitter: RemoteErrorEmitter, host : String, key : String, mName : String, wName : String): DataLoveResponse? {
        return safeApiCall(remoteErrorEmitter){
            loveCalculatorApi.getPercentage(host = host, key = key, fName = wName, sName = mName)
        }?.body()
    }

    override suspend fun getStatistics(): GetFirebaseResponse<String> {
        return safeGetFirebaseRTDBCall{
            firebaseRtdb.reference.child("statistics").get()
        }.toResultString()
    }

    override suspend fun setStatistics(plusResult: Int): SetFirebaseResponse {
        return safeSetFirebaseRTDBCall{
            firebaseRtdb.reference.child("statistics").setValue(plusResult)
        }
    }

    override suspend fun setScore(score: DataScore): SetFirebaseResponse {
        return safeSetFirebaseRTDBCall{
            fireStore.collection("score").document().set(score)
        }
    }

    override suspend fun getScore(): GetFirebaseResponse<List<DomainScore>> {
        return safeSetFireStoreCall {
            fireStore.collection("score").orderBy("date", Query.Direction.DESCENDING).get()
        }
    }
}

여기서 toResultString는 FirebaseMapper를 하나 만들어서 사용했습니다

object FirebaseMapper {

    fun GetFirebaseResponse<DataSnapshot>.toResultString(): GetFirebaseResponse<String> {
        return GetFirebaseResponse(
            result = this.result?.value.toString(),
            state = this.state
        )
    }
}

이렇게 다 바꿔줬다면 presentation 레이어로 이동해 기존 코드에서 어떻게 바뀌는지 예시 1가지를 확인해 봅시다

우선 ViewModel 부분인데 다음과 같이 2가지 사례를 보여드리겠습니다, 1번째는 바뀌기 전 코드이고 2번째는 바뀌고 나서입니다

//ViewModel에서 성공 여부를 판단하지 않는 경우
fun setStatistics(plusResult: Int) = setStatisticsUseCase.execute(plusResult)

//ViewModel에서 성공 여부를 판단하는 경우
fun getStatisticsDisplay() = getStatisticsUseCase.execute()
    .addOnSuccessListener {
        _getStatisticsDisplayEvent.value = it.value.toString().toInt()
    }
suspend fun setStatistics(plusResult: Int) = setStatisticsUseCase.execute(plusResult)

fun getStatisticsDisplay() = viewModelScope.launch {
    with(getStatisticsUseCase.execute()){
        if(this.state == FirebaseState.SUCCESS){
            _getStatisticsDisplayEvent.value = this.result.toString().toInt()
        }
    }
}

fun setStatistics는 view단에서 성공 여부를 판단하기 때문에 이렇게 사용하였습니다

//바뀌기 전 코드
private fun saveStatistics() {
    mainViewModel.getStatistics()
        .addOnSuccessListener {
            if (it != null) mainViewModel.setStatistics(it.value.toString().toInt() + 1)
                .addOnFailureListener {
                    error()
                }
        }
        .addOnFailureListener {
            error()
        }
}

//바뀌고 난 후 코드
    private fun saveStatistics() = lifecycleScope.launch {
        with(mainViewModel.getStatistics()){
            when(this.state){
                FirebaseState.SUCCESS -> if (mainViewModel.setStatistics(this.result.toString().toInt() + 1).state == FirebaseState.FAILURE) error()
                FirebaseState.FAILURE -> error()
            }
        }
    }

이런 식으로 도메인의 의존성을 줄일 수 있습니다. 전체적인 변동사항은 아래 커밋을 확인해 주세요(또는 확인하기 힘드시다면 전체 코드를 확인해 주세요)

https://github.com/ParkSangSun1/Check_Percentage/commit/4d26b9aa284a2969e8a3e13320acda2b4c8133a2

 

Update : Domain Layer 외부 의존도 줄이기 · ParkSangSun1/Check_Percentage@4d26b9a

Show file tree Showing 27 changed files with 197 additions and 146 deletions.

github.com

 

버전 관련 문제 해결

자 이제 빌드를 해보는데..?! 빌드할 때 hilt 라이브러리에서 오류가 납니다. 제가 겪은 문제와 해결방법은 다음과 같습니다

https://stackoverflow.com/questions/70550883/warning-the-following-options-were-not-recognized-by-any-proces sor-dagger-f

 

warning : The following options were not recognized by any processor: '[dagger.fastInit, kapt.kotlin.generated]'

I get this warning when I try to run or build an app in Android Studio. Why am I getting this? Do I need to heed this warning? The following options were not recognized by any processor: '[dagger.

stackoverflow.com

그리고 빌드하려 봤더니 kotlin과 gradle 버전 오류가 표시되어 모든 버전을 최신 버전으로 올렸습니다..

그래도 오류가 나서 봤더니 네... 최신버전 hilt에서는 lifecycle viewmodel 종속성이 필요 없다는군요.. 예.. 다 삭제해 줍시다

https://stackoverflow.com/questions/67256565/defaultactivityviewmodelfactory-not-found

 

DefaultActivityViewModelFactory not found

After migrating the Hilt version from 2.33-beta to 2.35 my project has stopped building with the error given below: A txt version: error: cannot access DefaultActivityViewModelFactory class ...

stackoverflow.com

그리고 버전들을 올려 viewmodel 이 좀 변경되어 오류가 발생하기 때문에 이 종속성의 버전을 올려 줍니다

implementation (AndroidX.FRAGMENT)//presentation gradle에 이미 추가되어 있음

const val FRAGMENT = "androidx.fragment:fragment-ktx:1.5.2" //buildSrc -> Dependency 파일

이제 정말 빌드를 해봅니다!!

짠, 드디어 잘 빌드되는 모습을 확인하실 수 있습니다!!

 

전체 코드

https://github.com/ParkSangSun1/Check_Percentage

 

GitHub - ParkSangSun1/Check_Percentage: SafeApiCall+Coroutine+Hilt+MVVM+CleanArchitecture = 이름궁합 앱

SafeApiCall+Coroutine+Hilt+MVVM+CleanArchitecture = 이름궁합 앱 - GitHub - ParkSangSun1/Check_Percentage: SafeApiCall+Coroutine+Hilt+MVVM+CleanArchitecture = 이름궁합 앱

github.com

 

반응형