적절한 추상화는 ViewModel을 이롭게 합니다

이현우·2022년 6월 12일
5

Android 기능 구현

목록 보기
13/13
post-thumbnail

Motivation

안드로이드 앱 코드를 작성하다 보면 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()
        }
    } 
}

리팩토링의 방향: Seperation of Concern

리팩토링을 할 때 각 기능이 어떤 화면에 들어가는 지 빠르게 알아낼 수 있는가를 많이 고려했었습니다. 아키텍처를 사용할 때 그 목적은 역할에 맞게 기능을 분리함으로써 클래스와 함수를 조망했을 때 쉽게 그 기능이 어떤 역할을 하는지 파악할 수 있고 다른 사이드 이펙트의 부담을 줄여주는 것에 있습니다.

이 원칙에 입각했을 때, 현재의 ViewModel은 각 기능에 대하여 분리가 안 되어있다는 것을 파악했고 각 화면에서 사용되는 기능에 따라서 분리를 하려고 했습니다.

하지만 ViewModel 내부에 모든 화면에서 공통적으로 사용되는 기능이 있었고 분리를 하면서 공통된 로직을 추가적으로 재작성해야하는 부분이 있었기에

  • 공통된 로직은 공유하면서
  • 화면별 다르게 사용되는 기능들을 분리하는 방법

이 없을가 고민하던 찰나에 Interface 분리 원칙을 활용하여 기능을 분리해보는 것이 어떨까 생각했습니다.

Interface 분리 원칙 (Interface Segregation Principle)

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>()
}

Is this the 'Silver-Bullet'

절대로 이 방식이 ViewModel 구현의 최선이라고 볼 수 없습니다.

안드로이드에서 ViewModel은 Shared-ViewModel보단 View와 1:1 형식의 ViewModel로 많이 구현할 것이고 이 경우 위와 같은 상황이 발생할 가능성은 매우 적을 것이므로 위와 같이 ViewModel을 추상화하고 구현하는 것은 오버 엔지니어링이 될 수 있을뿐더러 필요없는 Interface를 제작함으로써 구현체 내부를 살펴보려고 할 때 검색의 depth를 한 층 추가했다고도 볼 수 있을 것입니다.

기능을 구현할 때에는 개발팀의 현 상황, 기능 구현 시 Convention, 기술부채 등 다양한 변수가 존재하므로 어떤 진영에서 정말 찬양받는 방법론이 어떤 팀에서는 필요없는, 어쩌면 생산성을 더 떨어뜨리는 방법론이 될 수 있습니다. 이에 유의해서 이번 아티클을 참고하신다면 더더욱 좋은 코드를 개발하실 수 있을 것이라 생각합니다.

profile
이현우의 개발 브이로그

3개의 댓글

comment-user-thumbnail
2024년 5월 28일

안녕하세요 혹시 뷰모델을 추상화하는 경우, 비즈니스 모델 데이터 타입은 어떻게 다루나요? 뷰에서 비즈니스 모델 데이터를 뷰모델로부터 받아 사용할 때 타입 캐스팅이 불가피하다고 생각되는데 적절한 해결방안이 있는지 궁금합니다.

1개의 답글