[Android] MVVM 아키텍어 View Model을 설계하는 꿀팁 (feat. 현업 풀스택 개발자)
애플리케이션을 개발할 때 View와 비즈니스 로직을 분리하는 게 좋은데요. 그 이유는 아래와 같습니다.
- 비즈니스 로직 단위 테스트하기 훨씬 편해진다. View와 비즈니스 로직이 얽혀있는 경우 테스트하기 매우 까다로워질 수 있다.
- 유지보수가 편리하다. 새로운 기능 추가나 UI 변경에도 유연하게 대처할 수 있다.
- 코드의 재사용성이 높아진다. UI가 달라도 로직은 똑같을 수 있는데 분리된 경우 재사용이 가능하다.
- 팀이 함께 작업하기 편리하다. 개발자와 디자이너가 협업하기에도 편리하고 여러 명의 개발자가 동시에 작업하기에도 편리하다.
MVVM 아키텍처에서 View Model을 어떻게 설계하느냐에 따라 3번째 이유는 코드 재사용성이 높아질 수도, 아닐 수도 있습니다.
Live Data 덕분에 비즈니스 로직과 View의 분리가 훨씬 편하게 됐고 View Model을 재사용하기 더 편리해졌는데요.
재사용이 가능한 View Model을 어떻게 만들 수 있는지 몇가지 예시를 통해 알아보도록 하겠습니다.
그전에, View Model에 대한 개념을 다시 한번 되짚어 볼 필요가 있습니다. View Model은 말 그대로 특정 View의 Model입니다.
보여줄 데이터를 가공해서 내가 원하는 View에서 보여줄 수 있는 형태로 전달해주는 것이 View Model의 역할입니다.
하지만 대다수의 경우 Activity를 하나의 View로 인식해서 이에 맞게 View Model을 만듭니다. 물론 필요에 따라 이렇게 구현할 수도 있고 비슷한 Activity가 있는 경우 재사용도 가능합니다만, 많은 경우 재사용성이 떨어질 가능성이 큽니다.
MVP 아키텍처의 Presenter를 이렇게 구현하는 경우가 많죠. Presenter의 경우 View와 비즈니스 로직의 분리는 가능하지만 하나의 Activity와 Presenter가 1대 1 대응을 하게 되어 코드 재사용성이 떨어질 수 있습니다. 로직이 동일한데도 다른 Activity에서 쓰지 못하고 똑같은 메서드나 코드를 작성하는 경우도 더러 있었죠.
View Model을 구현할 때 Activity가 하나의 View라는 생각에서 벗어나면 재사용성이 뛰어난 View Model을 구현할 수 있습니다. 제가 실제 프로젝트에서 적용한 3가지 View Model을 예시로 보여드릴 텐데요. 이 3가지로 많은 Activity에서 재사용이 가능했습니다. 당연히 유지 보수성도 높아질뿐더러 개발 생산성에 도움이 됐죠.
- Recycler View Model : 프로젝트에 Recycler View가 꽤 많이 들어갔습니다. 대다수 앱의 경우 필수적으로 들어가는 View라고 할 수 있습니다. RecyclerView에 대한 View Model을 만들어서 재사용이 가능하도록 구현했습니다.
- Simple Data View Model : 프로젝트에서는 주로 API 호출을 통해 데이터를 가지고 왔습니다. 데이터가 List 형태로 오는 경우 Recycler View Model에서 처리하고 단일 데이터가 오는 경우 처리하는 View Model을 따로 만들었습니다.
- Single Event View Model : API 호출 시 꼭 데이터를 리턴하지 않는 경우도 있습니다. REST API에서 POST나 DELETE를 통해 데이터를 건드리게 되는 경우 흔히 응답 값은 성공 / 실패로 오게 됩니다. 꼭 데이터가 필요한 것도 아니고 HTTP status code만 보고도 처리할 수 있는 경우가 있죠. 이런 때에 성공인지 실패인지에 따라 View에서 알맞은 화면을 보여줄 수 있게끔 해주는 View Model을 구현했습니다.
Recycler View Model
/*
* 재사용 가능한 RecyclerView Model
* */
class RecyclerViewModel(private val api: Int? = null): ViewModel() {
// 데이터 로드 실패시 에러 이벤트
private val _navigateToLoadFail = SingleLiveEvent<Any>()
val navigateToLoadFail : LiveData<Any>
get() = _navigateToLoadFail
// 페이징이 끝났을 때 이벤트
private val _pagingEnd = SingleLiveEvent<Any>()
val pagingEnd : LiveData<Any>
get() = _pagingEnd
// 아이템 개수 Live Data
private val _count = MutableLiveData<Int>()
val itemCount : LiveData<Int> = _count
// 데이터를 추가할 것인지 초기화할 것인지 설정하는 변수
private var mode = INIT
// Live Data 갱신을 위해 데이터목록을 따로 관리
private var list = mutableListOf<Any?>()
private val _dataList = MutableLiveData<List<Any?>>()
val dataList: LiveData<List<Any?>> = _dataList
companion object{
// 페이징을 위한 변수
const val ADD = -1 // 기존 데이터에 덧붙인다
const val INIT = -2 // 기존 데이터를 덮어씌운다
// API 변수
const val exampleApi1 = 0
const val exampleApi2 = 1
}
// 데이터 리스트 받았을 때 공통 처리 call back 함수
private val callback = object: BaseNetworkInterface.SimpleListDataResponseCallback{
override fun onSuccess(list: List<Any?>) {
if(list.isEmpty()){
// 불러올 데이터가 더이상 없으면 이벤트 호출한다
_pagingEnd.call()
}
else if(mode == ADD){
// 페이징 데이터를 추가한다
this@RecyclerViewModel.list.addAll(list)
}
else{
// 현재 데이터를 초기화한다
this@RecyclerViewModel.list = list.toMutableList()
}
_dataList.value = this@RecyclerViewModel.list
}
override fun onFailure(statusCode: Int?) {
_navigateToLoadFail.call()
}
}
// 데이터 갯수가 포함된 데이터 리스트를 처리하는 콜백함수
private val countCallback = object: BaseNetworkInterface.CountListDataResponseCallback{
override fun onSuccess(list: List<Any?>, count: Int) {
if(list.isEmpty()){
// 불러올 데이터가 더이상 없으면 이벤트 호출한다
_pagingEnd.call()
}
else if(mode == ADD){
// 페이징 데이터를 추가한다
this@RecyclerViewModel.list.addAll(list)
}
else{
// 현재 데이터를 초기화한다
this@RecyclerViewModel.list = list.toMutableList()
}
_dataList.value = this@RecyclerViewModel.list
_count.value = count
}
override fun onFailure(statusCode: Int?) {
_navigateToLoadFail.call()
}
}
// 설정한 API의 첫번째 페이지를 불러오는 메서드
fun init(api : Int? = this.api, page: Int? = 1, mode: Int? = INIT){
// Recycler View Model 생성시 어떤 API를 호출할 것인지 설정한다
api?.let {
this.mode = mode ?: INIT
when(api){
exampleApi1 -> {
requestMainBannerApi(mode = mode, page = page)
}
exampleApi2 -> {
requestFeaturedProductApi(mode =mode, page = page)
}
}
}
}
// 아이템을 삭제한다
fun delete(item: Any, api: Int? = null){
// list에서 아이템을 삭제한다
list.remove(item)
_dataList.value = list
// API 호출이 있는 경우 알맞은 것을 호출한다
when(api){
deleteRecentKeyword -> {
deleteRecentKeyword(item)
}
}
}
// 다음 페이징 아이템을 불러온다
fun next(page: Int){
init(api = this.api, mode = ADD, page= page)
}
// 예시 API 호출
private fun exampleApi1(page: Int? = 1, mode: Int? = INIT){
// 데이터 호출 로직
}
// 예시 API 호출
private fun exampleApi2(page: Int? = 1, mode: Int? = INIT){
// 데이터 호출 로직
}
}
RecyclerView에 데이터 목록을 보여주게 되면 거의 대부분 페이징과 당겨서 새로고침(SwipeRefreshLayout) 기능이 적용됩니다. 따라서 View Model에서도 이 기능들을 지원할 수 있게 구현해두면 새로운 RecyclerView가 추가돼도 굉장히 빨리 구현할 수 있죠.
이 View Model에서 눈여겨봐야 할 점들이 있는데요.
먼저 Live Data와 데이터 목록을 따로 관리한다는 점입니다.
단일 데이터 변경시에는 Observer의 콜백 함수가 잘 호출이 되는데 List 형태가 되면 그렇지 않습니다. 그렇기 때문에 제대로 콜백 함수가 호출될 수 있도록 먼저 데이터 목록을 갱신한 뒤 Live Data의 value 값을 업데이트해주는 방식을 선택했습니다.
그다음은 페이징 관련된 부분입니다. 저의 경우 Google에서 제공하는 Paging이 아닌 가벼운 Paging 라이브러리를 적용했습니다. View Model에서 해줘야 할 부분은 새로운 페이징 데이터 요청을 하는 것인데요. 내부적으로 호출되는 API에 페이지 파라미터를 전달해서 데이터를 가져올 수 있도록 구현했습니다. 이 부분에서 또 중요한 것이 바로 데이터를 추가할 것이나 초기화할 것이냐 하는 부분인데요. 페이징으로 가져온 데이터의 경우 기존 항목에 추가가 되어야 하고, 당겨서 새로 고침을 한 경우에는 데이터 목록을 초기화해야 하기 때문에 내부적으로 mode라는 변수를 설정해서 처리했습니다.
또, 더 이상 가져올 데이터가 없는 경우에는 페이징이 끝났다는 것을 알려주어야 합니다. 그래야 Paging을 관리하는 클래스에서 더 이상 API 호출을 하지 않을 수 있습니다. 이를 위해 Live Data 변수를 설정해서 View 레벨에서 observing 할 수 있도록 구현했습니다.
// Section 1 Adapter에 Live Data를 연결
section2ViewModel.dataList.observe(
this@HomeFragment.viewLifecycleOwner,
Observer {
val featuredProductList = it.filterIsInstance<Product>()
section2Adapter.setDataList(featuredProductList)
}
)
View 레벨에서 사용할 때에는 위와 같이 Observer의 콜백 함수를 구현하는데요.
Recycler View Model의 데이터 리스트 타입이 Any이다보니 사용하려는 타입에 맞게 List가 가지고 있는 데이터 타입을 변경시켜줘야 합니다. Kotlin의 경우 런타임에 데이터 타입을 체크할 방법이 없어 위와 같이 filter 메서드를 통해 변환을 하면 됩니다.
Simple Data View Model
/**
* API 호출을 통해 하나의 데이터셋을 리턴받는 View Model
* */
class SimpleDataViewModel(private val api: Int): ViewModel() {
// 호출할 API 매핑
companion object{
// 회사정보 가져오기 API
const val requestCompanyInfo = 0
}
// API로부터 받을 데이터
private val _data = MutableLiveData<Any>()
val data: LiveData<Any>
get() = _data
// API 호출 실패시 호출할 이벤트
private val _navigateToLoadFail = SingleLiveEvent<Any>()
val navigateToLoadFail : LiveData<Any>
get() = _navigateToLoadFail
// 초기화 시 API를 호출해서 데이터를 받는다
init {
when(api){
requestCompanyInfo -> {
requestCompanyInfo()
}
}
}
// 회사정보 가져오기 API 로직
private fun requestCompanyInfo(){
// API 호출 로직
}
}
Simple Data View Model의 경우 Live Data를 통해 특정 데이터를 받아와서 View 레벨에 넘겨줄 수 있습니다.
Recycler View Model과 마찬가지로 View Model에 API를 호출하는 메서드들을 구현해서 사용할 수 있게 만들었습니다.
// 회사정보 초기화
companyInfoViewModel.data.observe(viewLifecycleOwner, Observer {
val footerLayout = binding.businessInfoLayout
footerLayout.info = it as Company
})
View 레벨에서 사용할 때에는 이렇게 타입을 변환한 뒤 사용하면 됩니다.
Single Event View Model
/**
* API 호출 뒤 결과에 따라 이벤트를 발생시키는 View Model
* */
class SingleEventViewModel: ViewModel() {
// API 호출 성공시 호출할 이벤트 변수
private val _successEvent = SingleLiveEvent<Any>()
val successEvent: SingleLiveEvent<Any>
get() = _successEvent
// API 호출 실패시 호출할 이벤트 변수
private val _failEvent = SingleLiveEvent<Any>()
val failEvent: SingleLiveEvent<Any>
get() = _failEvent
// 상품 찜하기 API 호출 로직
fun registerWishlist(product: Product){
val storeRepo = ServiceLocator.provideStoreRepository()
storeRepo.registerWishlist(
product = product,
callback = object:BaseNetworkInterface.SimpleDataResponseCallback{
override fun onSuccess(data: Any?) {
_successEvent.call()
}
override fun onFailure(statusCode: Int?) {
_failEvent.call()
}
}
)
}
// 상품 찜 취소 API 호출 로직
fun unregisterWishlist(product: Product){
val storeRepo = ServiceLocator.provideStoreRepository()
storeRepo.unregisterWishlist(
product = product,
callback = object:BaseNetworkInterface.SimpleDataResponseCallback{
override fun onSuccess(data: Any?) {
_successEvent.call()
}
override fun onFailure(statusCode: Int?) {
_failEvent.call()
}
}
)
}
}
앞서 Recycler View Model과 Single Data View Model에서 Single Live Event라는 클래스가 등장을 했었는데요.
이 클래스는 특정 이벤트가 발생했을 때 딱 한 번만 호출되는 형태의 Live Data입니다. 기존의 Live Data의 경우 데이터 값이 달라지면 알아서 콜백 함수를 호출합니다. 하지만 POST나 DELETE Api처럼 호출된 뒤 어떻게 됐는지 결과만 View에 넘겨주면 되는 이벤트의 경우 Live Data 사용이 조금 애매해집니다. 그래서 call이라는 메서드를 호출할 때 딱 한 번만 콜백 함수를 실행할 수 있도록 특별히 구현한 게 바로 Single Live Event 클래스입니다. 이것을 활용하면 API 성공 여부에 따라 이벤트를 발생시켜 View에서 알맞은 로직을 실행하도록 할 수 있습니다.
/**
* A lifecycle-aware observable that sends only new updates after subscription, used for events like
* navigation and Snackbar messages.
* <p>
* This avoids a common problem with events: on configuration change (like rotation) an update
* can be emitted if the observer is active. This LiveData only calls the observable if there's an
* explicit call to setValue() or call().
* <p>
* Note that only one observer is going to be notified of changes.
*/
class SingleLiveEvent<T> : MutableLiveData<T>() {
private val TAG : String = "SingleLiveEvent"
private val mPending = AtomicBoolean(false)
@MainThread
override fun observe(owner: LifecycleOwner, observer: Observer<in T>) {
if (hasActiveObservers()) {
Log.w(TAG,"Multiple observers registered but only one will be notified of changes.")
}
// Observe the internal MutableLiveData
super.observe(owner, Observer<T> { t ->
if (mPending.compareAndSet(true, false)) {
observer.onChanged(t)
}
})
}
@MainThread
override fun setValue(@Nullable t: T?) {
mPending.set(true)
super.setValue(t)
}
/**
* Used for cases where T is Void, to make calls cleaner.
*/
@MainThread
fun call() {
value = null
}
}
이게 Single Live Event를 구현한 클래스입니다.
이렇게 애플리케이션 전반에 걸쳐서 동일하게 데이터가 처리되는 부분을 찾아 View Model로 구현하게 되면 개발 생산성도 높아지고 유지보수성도 높아지는 긍정적인 경험을 할 수 있습니다.