
해당 글은 Medium의 Masterin Android ViewModels: Essential Dos and Don'ts Part1을 읽고 번역한 글입니다. 링크는 맨 아래에 작성해뒀습니다.
Android의 문서에 따르면, ViewModel 클래스는 비즈니스 로직이나 화면 수준의 상태를 관리하는 역할을 합니다. 이 클래스는 UI에 상태를 노출하고 관련된 비즈니스 로직을 캡슐화합니다. ViewModel의 주요 장점은 상태를 캐시하고, 구성 변경 시에도 이를 지속하는데 있습니다. 이 말은, 화면 회전과 같은 구성 변경이나 액티비티 간의 이동이 발생해도 UI가 데이터를 다시 가져올 필요 없다는 것을 의미합니다.
Android ViewModel의 init{} 블록에서 데이터 로드를 시작하는 것은 ViewModel이 생성되자마자 데이터를 초기화하는 편리한 방법처럼 보일 수 있습니다. 그러니 이 접근 방식에는 여러 단점이 있습니다. 예를 들어 ViewModel 생성과의 강한 결합, 테스트의 어려움, 유연성 제한, 구성 변경 처리, 리소스 관리, UI 반응성 등이 있습니다. 이러한 문제를 완화하기 위해서는 더 신중한 데이터 로드 접근 방식을 사용하는 것이 좋습니다. 이를 위해 LiveData나 다른 생명주기 인식 컴포넌트를 활용해 Android 생명주기를 존중하면서 데이터를 관리하는 방법을 권장합니다.
init {} 블록에서 데이터를 로드하면, 데이터 fetching과 ViewModel의 생명주기가 강하게 결합됩니다. 이는 특히 복잡한 UI에서 데이터 로드 시점을 세밀하게 제어하기 어려워질 수 있습니다. 예를 들어, 사용자 상호작용이나 다른 이벤트를 기반으로 데이터를 페칠하려 할 때 더 세밀한 제어가 필요해집니다.
ViewModel이 인스턴스화 되자마자 데이터 로드가 시작되기 때문에 테스트가 어려워집니다. 네트워크 요청이나 데이터베이스 쿼리를 자동으로 트리거하지 않고 ViewModel을 격리하여 테스트하는 것이 어려워질 수 있으며, 이는 테스트 설정을 복잡하게 만들고 불안정한 테스트로 이어질 수 있습니다.
ViewModel 인스턴스화 시 자동으로 데이터 로드가 시작되면, 다양한 사용자 흐름이나 UI 상태를 처리하는 유연성이 제한됩니다. 예를 들어, 특정 사용자 권한이 부여될 때까지 데이터를 페칭하는 것을 지연시키거나 사용자가 앱의 특정 부분으로 이동할 때까지 데이터를 페칭하는 것이 필요할 수 있습니다.
Android ViewModel은 화면 회전과 같은 구성 변경에서도 생존하도록 설계되었습니다. 그러나 init {} 블록에서 데이터 로드를 시작하면, 구성 변경 시 예상치 못한 동작이 발생하거나 데이터가 불필요하게 다시 페칭될 수 있습니다.
즉시 데이터 로드를 시작하면, 사용자가 앱이나 화면에 진입하자마자 데이터가 필요하지 않더라도 리소스를 비효율적으로 사용할 수 있습니다. 특히 많은 양의 데이터를 소비하거나 데이터를 페칭 및 처리하는데 비용이 많이 드는 애플리케이션에서는 이러한 문제가 더 심각할 수 있습니다.
init {} 블록에서 데이터를 로드하면, 특히 데이터 로드 작업이 길어지거나 메인 스레드를 차단할 경우 UI 반응성에 영향을 줄 수 있습니다. 일반적으로 init {} 블록을 가볍게 유지하고, 무거운 작업이나 비동기 작업은 백그라운드 스레드에서 처리하거나 LiveData/Flow를 사용해 데이터 변화를 관찰하는 것이 좋은 방법입니다.
이러한 문제를 완화하기 위해서는, 특정 사용자 행동이나 UI 이벤트에 반응하여 데이터를 로드하는 것과 같이 더 신중한 데이터 로드 접근 방식을 사용하는 것이 좋습니다. 또한 LiveData나 다른 생명주기 인식 컴포넌트를 활용하여 Android 생명주기를 존중하면서 데이터를 관리하면 앱이 더 반응성이 좋고, 테스트하기 쉬우며, 리소스를 효율적으로 사용할 수 있게 됩니다.
class SearchViewModel @Inject constructor(
private val searchUseCase: dagger.Lazy<SearchUseCase>,
private val wordsUseCase: GetWordsUseCase,
) : ViewModel() {
data class UiState(
val isLoading: Boolean,
val words: List<String> = emptyList()
)
init {
getWords()
}
val _state = MutableStateFlow(UiState(isLoading = true))
val state: StateFlow<UiState>
get() = _state.asStateFlow()
private fun getWords() {
viewModelScope.launch {
_state.update { UiState(isLoading = true) }
val words = wordsUseCase.invoke()
_state.update { UiState(isLoading = false, words = words) }
}
}
}
이 SearchViewModel에서 데이터 로드는 init 블록 내에서 즉시 트리거되며, 이는 데이터 페칭을 ViewModel의 인스턴스화와 강하게 결합하여 유연성을 감소시킵니다. 클래스 내부에 가변 상태인 _state를 노출하고, 잠재적인 오류나 다양한 UI 상태(로딩, 성공, 오류)를 처리하지 않으면, 견고하지 못하고 테스트하기 어려운 구현으로 이어질 수 있습니다. 이러한 접근 방식은 VIewModel의 생명주기 인식의 이점과 지연 초기화의 효율성을 저해할 수 있습니다.
어떻게 하면 이를 개선할 수 있을까요????
class SearchViewModel @Inject constructor(
private val searchUseCase: dagger.Lazy<SearchUseCase>,
private val wordsUseCase: GetWordsUseCase,
) : ViewModel() {
data class UiState(
val isLoading: Boolean = true,
val words: List<String> = emptyList()
)
val state: StateFlow<UiState> = flow {
emit(UiState(isLoading = true))
val words = wordsUseCase.invoke()
emit(UiState(isLoading = false, words = words))
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), UiState())
}
이 리팩토링은 ViewModel의 init 블록에서 데이터 페칭을 제거하고, 대신 컬렉션을 통해 데이터 로드를 시작하도록 합니다. 이러한 변경은 데이터 페칭 관리를 훨씬 더 유연하게 만들고, ViewModel 인스턴스화 시 발생하는 불필요한 작업을 줄여줍니다. 이를 통해 조기 데이터 로딩 문제를 직접 해결하고, ViewModel의 반응성과 효율성을 크게 향상시킵니다.
class SearchViewModel @Inject constructor(
private val searchUseCase: SearchUseCase,
@IoDispatcher val ioDispatcher: CoroutineDispatcher
) : ViewModel() {
private val searchQuery = MutableStateFlow("")
private val _uiState = MutableLiveData<SearchUiState>()
val uiState = _uiState
init {
viewModelScope.launch {
searchQuery.debounce(DEBOUNCE_TIME_IN_MILLIS)
.collectLatest { query ->
Timber.d("collectLatest(), query:[%s]", query)
if (query.isEmpty()) {
_uiState.value = SearchUiState.Idle
return@collectLatest
}
try {
_uiState.value = SearchUiState.Loading
val photos = withContext(ioDispatcher){
searchUseCase.invoke(query)
}
if (photos.isEmpty()) {
_uiState.value = SearchUiState.EmptyResult
} else {
_uiState.value = SearchUiState.Success(photos)
}
} catch (e: Exception) {
_uiState.value = SearchUiState.Error(e)
}
}
}
}
fun onQueryChanged(query: String?) {
query ?: return
searchQuery.value = query
}
sealed class SearchUiState {
object Loading : SearchUiState()
object Idle : SearchUiState()
data class Success(val photos: List<FlickrPhoto>) : SearchUiState()
object EmptyResult : SearchUiState()
data class Error(val exception: Throwable) : SearchUiState()
}
companion object {
private const val DEBOUNCE_TIME_IN_MILLIS = 300L
}
}
SearchViewModel의 init 블록 내에서 코루틴을 시작하여 즉시 데이터 처리를 수행하는 것은 데이터 페칭을 ViewModel의 생명주기와 너무 밀접하게 결합시켜, 비효율성과 생명주기 관리 문제를 일으킬 수 있습니다.
이 접근 방식은 불필요한 네트워크 호출의 위험을 증가시키며, 특히 UI가 이러한 정보를 처리하거나 표시할 준비가 되기 전에 오류 처리에 어려움을 초래할 수 있습니다. 또한 UI 업데이트를 위해 암묵적으로 메인 스레드로 돌아간다는 가정을 하는데, 이는 항상 안전하거나 효율적이지 않을 수 있으며, ViewModel이 인스턴스화되자마자 데이터 페칭을 시작하기 때문에 테스트를 더 어렵게 만듭니다.
다음과 같이 수정할 수 있습니다.
class SearchViewModel @Inject constructor(
private val searchUseCase: dagger.Lazy<SearchUseCase>,
) : ViewModel() {
private val searchQuery = MutableStateFlow("")
val uiState: LiveData<SearchUiState> = searchQuery
.debounce(DEBOUNCE_TIME_IN_MILLIS)
.asLiveData()
.switchMap(::createUiState)
private fun createUiState(query: @JvmSuppressWildcards String) = liveData {
Timber.d("collectLatest(), query:[%s]", query)
if (query.isEmpty()) {
emit(SearchUiState.Idle)
return@liveData
}
try {
emit(SearchUiState.Loading)
val photos = searchUseCase.get().invoke(query)
if (photos.isEmpty()) {
emit(SearchUiState.EmptyResult)
} else {
emit(SearchUiState.Success(photos))
}
} catch (e: Exception) {
emit(SearchUiState.Error(e))
}
}
fun onQueryChanged(query: String?) {
query ?: return
searchQuery.value = query
}
sealed class SearchUiState {
data object Loading : SearchUiState()
data object Idle : SearchUiState()
data class Success(val photos: List<FlickrPhoto>) : SearchUiState()
data object EmptyResult : SearchUiState()
data class Error(val exception: Throwable) : SearchUiState()
}
companion object {
private const val DEBOUNCE_TIME_IN_MILLIS = 300L
}
}
개선된 구현은 init 블록 내에서 코루틴을 직접 실행하여 searchQuery 변화를 관찰하는 대신, 코루틴 컨텍스트 외부에서 saerchQuery를 LiveData로 변환하는 리액티브 설정을 선택합니다. 이를 통해 생명주기 관리 및 코루틴 취소와 관련된 잠재적인 문제를 제기하고, 데이터 페칭이 본질적으로 생명주기를 인식하고 더 자원 효율적으로 이루어지도록 합니다. 또한 init 블록에 의존하여 사용자 입력을 관찰하고 처리하는 것을 피함으로써, ViewModel의 초기화와 데이터 페칭 로직을 분리시켜 책임의 분리가 명확해지고, 더 유지 보수하기 쉬운 코드 구조를 제공합니다.