안드로이드 앱 코드를 작성하다 보면 UI Layer의 코드가 굉장히 복잡하게 작성된다는 느낌을 받을 수 있습니다. ViewModel 코드를 작성하다 보면 데이터를 UiState로 변환해주는 책임을 가지면서 이 데이터를 가지는 Data Holder의 역할만 해주면서 UI의 이벤트가 발생했을 때 이에 대한 처리도 같이하므로 자연스럽게 그 크기가 비대해질 수밖에 없게 됩니다.
코드가 많아지면서 적절하게 기능 분리를 하는 것이 더더욱 어려워지고 이에 따라 가독성이 저하되어 결국에는 기능 추가/수정을 할 때 어떤 부분을 건드려야할 지, 이 부분을 건드리면 다른 사이드 이펙트가 발생하지 않을까 자연스럽게 생각을 하지 않을 수가 없게 될 것입니다. 즉 개발자들이 흔히 말하는 유지보수가 어려워진다라는 문제의 직면하고 말게 되는 것이죠.
제가 사이드 프로젝트를 진행하면서 마주한 문제도 이와 같습니다. 한 ViewModel이 여러 화면에서 사용되면서 공통적으로 사용되는 로직만 있게 될뿐만 아니라 각 화면에서 사용되는 로직도 공존하게 되어 어떤 화면에서 이 기능을 사용하게 되는 지 찾기가 어려워진 상황에 놓이고 말았습니다.
(코드 내용 중 일부를 생략하여 올렸으니 이 점 참고하여 봐주시면 감사하겠습니다)
// SearchActivity
class SearchActivity : AppCompatActivity() {
private val viewModel by viewModels<SearchViewModel>()
}
// RecentKeywordFragment
class RecentKeywordFragment : Fragment() {
private val viewModel by viewModels<SearchViewModel>()
}
// SearchResultFragment
class SearchResultFragment : Fragment() {
private val viewModel by viewModels<SearchViewModel>()
}
// SearchViewModel
class SearchViewModel(
private val searchKeywordRepository: SearchKeywordRepository,
private val searchMoimRepository: SearchMoimRepository,
private val searchBookRepository: SearchBookRepository
): ViewModel() {
private val _toastMessage = MutableSharedFlow<String>()
val toastMessage = _toastMessage.asStateFlow()
private val _keywordList: MutableLiveData<List<RecentKeyword>> = MutableLiveData()
val keywordList: LiveData<List<RecentKeyword>> = _keywordList
// 전체 화면에서 사용
val query = MutableStateFlow("")
// SearchResultFragment에서만 사용
val moimList: (String) -> Flow<SearchItem.MoimItem> = { title ->
searchMoimRepository.getMoimList(title)
}
// SearchResultFragment에서만 사용
val savedBookList: (String) -> Flow<SearchItem.BookItem> = { title ->
searchBookRepository.getBookList(title)
}
// SearchResultFragment에서만 사용
val bookList: (String) -> Flow<SearchItem.BookItem> = { title ->
searchBookRepository.getBookList(title)
}
// RecentKeywordFragment에서 사용
fun getKeywordList() {
viewModelScope.launch {
_keywordList.value = searchKeywordRepository.getRecentKeywordList()
}
}
}
리팩토링을 할 때 각 기능이 어떤 화면에 들어가는 지 빠르게 알아낼 수 있는가를 많이 고려했었습니다. 아키텍처를 사용할 때 그 목적은 역할에 맞게 기능을 분리함으로써 클래스와 함수를 조망했을 때 쉽게 그 기능이 어떤 역할을 하는지 파악할 수 있고 다른 사이드 이펙트의 부담을 줄여주는 것에 있습니다.
이 원칙에 입각했을 때, 현재의 ViewModel은 각 기능에 대하여 분리가 안 되어있다는 것을 파악했고 각 화면에서 사용되는 기능에 따라서 분리를 하려고 했습니다.
하지만 ViewModel 내부에 모든 화면에서 공통적으로 사용되는 기능이 있었고 분리를 하면서 공통된 로직을 추가적으로 재작성해야하는 부분이 있었기에
이 없을가 고민하던 찰나에 Interface 분리 원칙을 활용하여 기능을 분리해보는 것이 어떨까 생각했습니다.
Interface 분리 원칙에서는 Interface는 '해당 Interface를 사용하는 Client' 기준으로 분리해야 한다고 서술되어 있습니다. 즉, 그 기능을 사용하지 않은 Client에게는 해당 기능을 제공하지 않는 것이 원칙인 것이죠. 그렇다면 저희의 ViewModel은 아래와 같은 Interface들로 분리를 할 수 있겠습니다.
interface RecentKeywordViewModel {
val query: MutableStateFlow<String>
val keywordList: LiveData<List<RecentKeyword>>
fun getKeywordList()
}
interface SearchViewModel {
val query: MutableStateFlow<String>
}
interface SearchResultViewModel {
val query: MutableStateFlow<String>
val bookList: (String) -> Flow<SearchItem.BookItem>
val moimList: (String) -> Flow<SearchItem.MoimItem>
val toastMessage: SharedFlow<String>
}
그리고 AAC ViewModel을 아래와 같이 해당 ViewModel들을 구현하는 형태로 리팩토링을 해봅니다. 이와 같이 공통된 로직도 한 클래스에서 사용할 수 있고, 각 기능에 맞는 기능을 인터페이스에 맞게 구현하는 구현체 형태의 AAC ViewModel을 만들 수 있습니다.
class SearchViewModelImpl(
private val searchKeywordRepository: SearchKeywordRepository,
private val searchMoimRepository: SearchMoimRepository,
private val searchBookRepository: SearchBookRepository
) : ViewModel(), RecentKeywordViewModel, SearchViewModel, SearchResultViewModel {
private val _toastMessage = MutableSharedFlow<String>()
override val toastMessage: SharedFlow<String> = _toastMessage.asSharedFlow()
override val query = MutableStateFlow("")
override val moimList: (String) -> Flow<SearchItem.BookItem> = { title ->
searchMoimRepository.getMoimList(title)
}
override val bookList: (String) -> Flow<SearchItem.MoimItem> = { title ->
searchBookRepository.getBookList(title)
}
private val _keywordList = MutableLiveData<List<RecentKeyword>>()
override val keywordList: LiveData<List<RecentKeyword>>
get() = _keywordList
override fun getKeywordList() {
viewModelScope.launch {
_keywordList.value = searchRepository.getRecentKeywordList()
}
}
}
이를 사용하는 View에서는 아래와 같은 형식으로 ViewModelImpl을 참조하여 사용할 수 있습니다.
// SearchActivity
class SearchActivity : AppCompatActivity() {
private val viewModel: SearchViewModel by viewModels<SearchViewModelImpl>()
}
// RecentKeywordFragment
class RecentKeywordFragment : Fragment() {
private val viewModel: RecentKeywordViewModel by viewModels<SearchViewModelImpl>()
}
// SearchResultFragment
class SearchResultFragment : Fragment() {
private val viewModel: SearchResultViewModel by viewModels<SearchViewModelImpl>()
}
절대로 이 방식이 ViewModel 구현의 최선이라고 볼 수 없습니다.
안드로이드에서 ViewModel은 Shared-ViewModel보단 View와 1:1 형식의 ViewModel로 많이 구현할 것이고 이 경우 위와 같은 상황이 발생할 가능성은 매우 적을 것이므로 위와 같이 ViewModel을 추상화하고 구현하는 것은 오버 엔지니어링이 될 수 있을뿐더러 필요없는 Interface를 제작함으로써 구현체 내부를 살펴보려고 할 때 검색의 depth를 한 층 추가했다고도 볼 수 있을 것입니다.
기능을 구현할 때에는 개발팀의 현 상황, 기능 구현 시 Convention, 기술부채 등 다양한 변수가 존재하므로 어떤 진영에서 정말 찬양받는 방법론이 어떤 팀에서는 필요없는, 어쩌면 생산성을 더 떨어뜨리는 방법론이 될 수 있습니다. 이에 유의해서 이번 아티클을 참고하신다면 더더욱 좋은 코드를 개발하실 수 있을 것이라 생각합니다.
안녕하세요 혹시 뷰모델을 추상화하는 경우, 비즈니스 모델 데이터 타입은 어떻게 다루나요? 뷰에서 비즈니스 모델 데이터를 뷰모델로부터 받아 사용할 때 타입 캐스팅이 불가피하다고 생각되는데 적절한 해결방안이 있는지 궁금합니다.