개발/안드로이드

종속 항목 삽입

스몰스테핑 2024. 5. 23. 16:37

이전글에서 이어지는 내용입니다.

https://small-stepping.tistory.com/956

 

UI 레이어와 데이터 레이어 분리

레이어를 분리하는 이유코드를 여러 레이어로 분리하면 앱의 확장성이 높아지며 앱이 더 견고해지고 테스트하기 더 쉬워진다. 또한 경계가 명확히 정의된 여러 레이어를 사용하면 여러 개발자

small-stepping.tistory.com

 

 

 

클래스가 작동하려면 다른 클래스의 객체가 필요한 경우가 많다. 클래스에 다른 클래스가 필요한 경우 필요한 클래스를 종속 항목이라고 한다.

 

다음 예에서 Car 객체는 Engine 객체에 종속된다.

필요한 이 객체를 클래스가 가져오는 방법에는 두 가지가 존재한다.

 

  • 클래스가 필요한 객체 자체를 인스턴스화 하는 것
interface Engine {
    fun start()
}

class GasEngine : Engine {
    override fun start() {
        println("GasEngine started!")
    }
}

class Car {

    private val engine = GasEngine()

    fun start() {
        engine.start()
    }
}

fun main() {
    val car = Car()
    car.start()
}

 

 

  • 필요한 객체를 인수로 전달하는 것
interface Engine {
    fun start()
}

class GasEngine : Engine {
    override fun start() {
        println("GasEngine started!")
    }
}

class Car(private val engine: Engine) {
    fun start() {
        engine.start()
    }
}

fun main() {
    val engine = GasEngine()
    val car = Car(engine)
    car.start()
}

 

클래스가 필요한 객체를 인스턴스화하도록 하기는 쉬우나, 이러한 접근 방식은 클래스와 필요한 객체 간의 긴밀한 결합으로 인해 코드의 유연성이 파괴되고, 테스트가 어려워진다.

 

호출 클래스가 구현 세부정보인 객체의 생성자를 호출해야한다. 생성자가 변경되면 호출 코드 또한 변경해야한다.

 

코드의 유연성과 적응성을 높이기 위해 클래스가 종속되는 객체를 인스턴스화하면 안된다. 종속되는 객체는 클래스 외부에서 인스턴스화한 후 전달해야 한다. 이 접근 방식을 사용하면 클래스가 더 이상 하나의 특정 객체에 하드코딩되지 않으므로 더 유연한 코드가 생성된다. 호출 코드를 수정할 필요 없이 필요한 객체의 구현을 변경할 수 있다.

 

이전의 예시에서 ElectricEngine이 필요한 경우 이를 생성하여 Car 클래스에 전달할 수 있다. Car 클래스를 어떤 식으로든 수정할 필요가 없는 것이다.

interface Engine {
    fun start()
}

class ElectricEngine : Engine {
    override fun start() {
        println("ElectricEngine started!")
    }
}

class Car(private val engine: Engine) {
    fun start() {
        engine.start()
    }
}

fun main() {
    val engine = ElectricEngine()
    val car = Car(engine)
    car.start()
}

 

필요한 객체를 전달하는 것을 종속 항목 삽입(DI)이라고 한다. 컨트롤 반전이라고도 한다.

DI는 종속 항목이 호출 클래스에 하드코딩되는 대신 런타임에 제공되는 경우를 말한다.

 

종속 항목 삽입을 구현하면 다음과 같은 것들이 가능하다.

  • 코드 재사용성 지원 - 코드가 특정 객체에 종속되지 않으므로 유연성이 높다.
  • 리팩터링 편의성 향상 - 코드가 느슨하게 연결되므로 코드의 한 섹션을 리팩터링해도 다른 섹션에 영향을 미치지 않는다.
  • 테스트 지원 - 테스트 중에 테스트 객체를 전달할 수 있다.

DI가 테스트에 도움이 되는 예로 네트워크 호출 코드 테스트를 들 수 있다. 이 테스트에서는 네트워크 호출이 이뤄지는지, 그리고 호출 후 데이터가 반환되는지 실제로 테스트한다. 테스트 중 네트워크 요청을 할 때마다 비용을 지불해야 하는 경우 비용이 많이 들 수 있으므로 이 코드 테스트를 건너뛰겠다고 생각할 수도 있다. 테스트용으로 가짜 네트워크 요청을 할 수 있다면 어떨까? 얼마나 만족스럽고 비용 절감에 도움이 될까? 테스트를 위해, 네트워크 호출을 실제로 실행하지 않고 호출될 때 모조 데이터를 반환하는 테스트 객체를 저장소에 전달할 수 있다.

 

ViewModel을 테스트하기 쉽게 만들고 싶지만 현재 이 코드는 실제 네트워크 호출을 실행하는 저장소에 종속된다. 실제 프로덕션 저장소로 테스트할 때는 많은 네트워크 호출을 실행한다. 이 문제를 해결하려면 ViewModel로 저장소를 만드는 대신 프로덕션에 사용하고 동적으로 테스트할 저장소 인스턴스를 결정하고 전달하는 방법이 필요하다.

 

ViewModel에 저장소를 제공하는 애플리케이션 컨테이너를 구현하면 이 프로세스가 완료된다.

 

컨테이너는 앱에 필요한 종속 항목이 포함된 객체이다. 이러한 종속 항목은 전체 애플리케이션에 걸쳐 사용되므로 모든 활동에서 사용할 수 있는 일반적인 위치에 배치해야한다. Application 클래스의 서브 클래스를 만들고 컨테이너 참조를 저장할 수 있다.

 

 

애플리케이션 컨테이너 만들기

package com.example.marsphotos.data

import com.example.marsphotos.network.MarsApiService
import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory
import kotlinx.serialization.json.Json
import okhttp3.MediaType.Companion.toMediaType
import retrofit2.Retrofit

interface AppContainer {
    val marsPhotosRepository: MarsPhotosRepository
}

class DefaultAppContainer: AppContainer {
    private val baseUrl = "https://android-kotlin-fun-mars-server.appspot.com"

    private val retrofit: Retrofit = Retrofit.Builder()
        .addConverterFactory(Json.asConverterFactory("application/json".toMediaType()))
        .baseUrl(baseUrl)
        .build()

    private val retrofitService: MarsApiService by lazy {
        retrofit.create(MarsApiService::class.java)
    }

    override val marsPhotosRepository: MarsPhotosRepository by lazy {
        NetworkMarsPhotosRepository(retrofitService)
    }
}

 

해당 예제는 Android Jetpack Compose Tutorial Unit 5-2의 2번 과정인 저장소 및 수동 종속 항목 삽입 추가 Codelab 과정의 예제이다.

 

https://developer.android.com/courses/pathways/android-basics-compose-unit-5-pathway-2?hl=ko

 

인터넷에서 이미지 로드 및 표시  |  Android Basics Compose - Load and display images from the internet  |  Andro

앱에 아키텍처 권장사항을 적용하고 Coil을 사용하여 이미지를 다운로드하고 표시합니다.

developer.android.com

 

 

앱에 애플리케이션 컨테이너 연결

 

package com.example.marsphotos

import android.app.Application
import com.example.marsphotos.data.AppContainer
import com.example.marsphotos.data.DefaultAppContainer

class MarsPhotosApplication : Application() {
    lateinit var container: AppContainer
    override fun onCreate() {
        super.onCreate()
        container = DefaultAppContainer()
    }
}

 

<application
   android:name=".MarsPhotosApplication"
   android:allowBackup="true"
...
</application>

 

AndroidManifest.xml에도 클래스 이름을 수정한다.

 

 

ViewModel에 저장소 추가

package com.example.marsphotos.ui.screens

import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory.Companion.APPLICATION_KEY
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import com.example.marsphotos.MarsPhotosApplication
import com.example.marsphotos.data.MarsPhotosRepository
import kotlinx.coroutines.launch
import retrofit2.HttpException
import java.io.IOException

sealed interface MarsUiState {
    data class Success(val photos: String) : MarsUiState
    data object Error : MarsUiState
    data object Loading : MarsUiState
}

class MarsViewModel(private val marsPhotosRepository: MarsPhotosRepository) : ViewModel() {
    var marsUiState: MarsUiState by mutableStateOf(MarsUiState.Loading)
        private set

    init {
        getMarsPhotos()
    }

    private fun getMarsPhotos() {
        viewModelScope.launch {
            marsUiState = try {
                val listResult = marsPhotosRepository.getMarsPhotos()
                MarsUiState.Success("Success: ${listResult.size} Mars photos retrieved")
            } catch (e: IOException) {
                MarsUiState.Error
            } catch (e: HttpException) {
                MarsUiState.Error
            }
        }
    }

    companion object {
        val Factory: ViewModelProvider.Factory = viewModelFactory {
            initializer {
                val application = (this[APPLICATION_KEY] as MarsPhotosApplication)
                val marsPhotosRepository = application.container.marsPhotosRepository
                MarsViewModel(marsPhotosRepository = marsPhotosRepository)
            }
        }
    }
}

 

ViewModel이 저장소 객체를 호출하여 데이터를 검색할 수 있게 만드는 과정이다.

 

ViewModel 클래스의 선언에 매개변수로 저장소를 추가한다. Android 프레임워크는 생성 시 생성자의 ViewModel에서 값이 전달되는 것을 허용하지 않으므로 ViewModelProvider.Factory 객체를 구현하여 이 제한을 해결한다.

 

팩토리 패턴은 객체를 만드는데 사용되는 생성 패턴이다. MarsViewModel.Factory 객체는 애플리케이션 컨테이너를 사용하여 marsPhotosRepository를 검색한 후에 ViewModel 객체가 생성되면 이 저장소를 ViewModel에 전달한다.

 

컴패니언 객체를 사용하면 비용이 많이 드는 객체의 새 인스턴스를 만들 필요 없이 모두가 단일 객체 인스턴스를 사용할 수 있어 유용하다. 구현 세부정보이며, 분리하면 앱 코드의 다른 부분에 영향을 주지 않고 변경할 수 있다.

 

@Composable
fun MarsPhotosApp() {
    val scrollBehavior = TopAppBarDefaults.enterAlwaysScrollBehavior()
    Scaffold(
        modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection),
        topBar = { MarsTopAppBar(scrollBehavior = scrollBehavior) }
    ) {
        Surface(
            modifier = Modifier.fillMaxSize()
        ) {
            val marsViewModel: MarsViewModel = viewModel(factory = MarsViewModel.Factory)
            HomeScreen(
                marsUiState = marsViewModel.marsUiState,
                contentPadding = it,
            )
        }
    }
}

 

이후, ViewModel을 사용하는 Composable 함수에서 팩토리를 사용하도록 업데이트한다. 이 marsViewModel 변수는 ViewModel 생성을 위한 인수로 컴패니언 객체에서 MarsViewModel.Factory에 전달되는 viewModel() 함수를 호출하여 채워진다.

 

이제 테스트를 하면 된다.