[Hilt 들고 MVVM 정복] 2. ViewModel

너 오늘 코드 짰니?·2024년 1월 31일
4

Hilt 들고 MVVM 정복

목록 보기
2/4

안드로이드 ViewModel 클래스란?

MVVM 아키텍처를 구현하기 위해 Android AAC에서는 ViewModel이라는 클래스를 제공하고 있습니다. 이를 활용하면 매우 쉽게 MVVM 아키텍처를 안드로이드에서 구현할 수 있습니다.
명백히 MVVM 아키텍처의 ViewModel과 Android AAC가 제공하는 ViewModel 클래스가 완벽히 동일하다고 볼 수 는 없지만 MVVM 아키텍처의 ViewModel(추상적 개념)을 구현하기 위해 Android AAC의 ViewModel 클래스(구체적 개념)을 활용한다고 생각하면 됩니다.

그럼 ViewModel 클래스를 무작정 사용하기 전에 얘가 뭔지부터 알고 가야겠죠.

공식문서에서는 ViewModel을 비즈니스 로직 또는 화면 수준 상태 홀더로 정의하고 있습니다.
아... 영어로 된 어려운 공식문서를 한글로 번역해 놓으니까 이 한 줄로된 정의로써는 더욱더 무슨말인지 알아듣기 힘듭니다.

쉽게 말해 안드로이드 UI의 상태 구성요소는 크게 3 가지로 구성됩니다.

  • 비즈니스 로직 : 앱이 동작하기 위해 필요한 데이터(눈에 보이지 않는 추상적인 데이터)를 처리하기 위한 내부적인 로직
  • 화면 UI 상태 : 화면에 표시해야 하는 항목. 즉 화면에 눈으로 보여야 할 정적인 데이터
  • UI 로직 : 화면에 UI 상태를 표시하는 방법. 즉 위에서 기술한 화면 UI 상태 데이터를 눈에 보이도록 화면에 띄우기 위한 로직 (Toast.show() 메서드 같은 로직)

위 세 가지 개념은 이번 포스팅에서 지속적으로 언급할 예정이니 눈에 콕 박아 잘 기억해두세요.

예시를 들어볼까요?
카카오톡 메시지를 보내고 있다고 가정합시다. 입력창에 메시지를 치고 엔터를 누르면 해당 메시지가 EditText 뷰에 입력되다가 엔터를 누르는 순간 위에 보이는 채팅창으로 올라가면서 전송됩니다.

  • 엔터를 눌렀을 때 실제 서버와 통신하여 EditText 에 있는 문자열을 전송하는 역할을 하는 것이 비즈니스 로직입니다.
  • 엔터를 누르기 전에 EditText에 올려져 있는 문자열이 화면 UI 상태 입니다.
  • 엔터를 눌렀을 때 EditText가 비워지고 위쪽의 채팅창에 내가 보낸 메시지가 한줄 올라가는 과정을 구현한 로직이 UI 로직입니다.

아무런 앱 아키텍처를 사용하지 않고 액티비티 클래스에 모든 로직을 때려박아 구현했을 땐 눈치채지 못했지만 이 3가지 구성요소의 삼박자가 맞아 떨어져 앱이 동작하고 있던 것입니다.

이제 Clean Architecture에 대해 대충 알아봅시다


MVVM을 알아가기에도 벅찬데 갑자기 무슨 클린아키텍처냐!!!!

안드로이드의 MVVM은 클린아키텍처와 매우 밀접합니다. 음... 클린 아키텍처는 안드로이드 프로젝트를 설계할 때 어떻게 설계하면 깔끔하게 설계할 수 있을까? 에서 탄생한 매우 추상적인 개념이고, MVVM은 어떻게 하면 클린 아키텍처를 깔끔하게 구현할 수 있을까? 에서 탄생한 적당히 추상적인 개념이라고 생각해주세요.

클린 아키텍처를 달성하기 위한 MVVM 아키텍처를 설계하기 위한 ViewModel클래스

자자 MVVM으로 구현한 클린아키텍처는 요래 생겼습니다.

아악 시작부터 머리가 으깨질것 같고 그만하고싶습니다.
그러니 지금단계에선 왼쪽은 치워두고 작고 소중한 노란색 UI Layer 부분에만 집중해주세요 ^^~~~

말 그대로 화면을 표시하고 상호작용을 하기 위한 UI layer는 ViewModel과 View 두 가지 구성요소로 이루어지고 있음을 알 수 있습니다.

왜 두개로 나눌까요? 그 이유는 바로 위에서 기술한 3가지 안드로이드 UI의 상태 구성요소들이 각각 UI 수명주기와 종속되는지 여부가 다르기 떄문입니다.

UI 수명주기란 액티비티의 onCreate, onPause, onDestroy 같은 수명주기를 의미하는데 가장 쉽게 onCreate() 메서드에 모든 로직을 때려박아 구현했다고 생각해봅시다. 이럴 경우 만약 화면 회전과 같은 이벤트가 일어나 액티비티가 다시 onCreate()부터 실행될 경우 비즈니스 로직과 화면 UI 상태가 모두 초기화 되고 처음부터 다시 실행되게 됩니다.

즉, 카카오톡에 메시지를 입력하다가 가로모드로 바꾸게 되면 onCreate() 메서드가 다시 실행되면서 입력했던 메시지들이 전부 사라지고 다시 입력해야하는 일이 생기는 것입니다.

따라서 UI layer에서는 UI 수명주기와 무관해야 하는 구성요소와 UI 수명주기에 종속되어야 하는 구성요소를 분리해야할 필요가 있으며 이를 View와 ViewModel이 담당하게 되는 것입니다.

자. 그럼 다시 처음에 공식문서가 정의한 말을 곱씹어볼까요?

ViewModel은 비즈니스 로직 또는 화면 수준 상태 홀더이다.

정리해봅시다.

MVVM 아키텍처를 사용할 때 클린아키텍처의 UI layer를 구현하기 위해서는 UI 수명주기와 무관해야하는 비즈니스로직, 화면 UI 상태 들과 UI 수명주기에 종속되는 UI 로직을 분리해야할 필요가 있으며 비즈니스 로직, 화면 UI 상태들은 ViewModel에 넣어주고 오직 UI 로직만을 View에서 구현해주면 되는것입니다.

즉 ViewModel 클래스에서는 화면에 표시해야할 데이터를 모두 가지고 있으며 비즈니스 로직 수행을 담당하고, Activity 클래스 (View에 해당)에서는 UI의 수명주기에 따라 적절한 UI 로직만을 수행하며 ViewModel 클래스가 갖고있는 데이터들을 UI 로직에 의해 화면에 표시해주기만 하면 되는 것입니다.

위의 카카오톡 전송 예시를 가져와보면 이렇게 구현하면 되겠네요!!!
이렇게 되면 View가 재시작 된다 해도 ViewModel 안의 입력 메시지 정보는 유효하기 때문에 '메시지 내용을 화면에 표시하기 위한 로직'을 통해 입력했던 텍스트를 그대로 표시함으로써 UI의 상태를 유지할 수 있습니다.

화면 UI 상태를 분리해보자

자 그럼 첫번째 포스팅에서 소개해드렸던 회원가입 화면에서 화면 UI 상태를 나타내는 부분을 ViewModel 클래스를 사용하여 분리해 보겠습니다.

이렇게 생긴 화면에서 ID를 입력하는 부분을 한번 살펴볼게요

class JoinActivity : AppcompatActivity(){
	var userId: String = ""	// 전역변수에 Id 정보 저장
    
    override fun onCreate(savedInstanceState: Bundle?){
    
      // ...

      // Id 정보 EditTextChangedListener
      binding.userIdEdit.addTextChangedListener(object : TextWatcher {
          override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}

          // 값 변경 시 실행되는 함수
          override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {

              // 입력값 담기
              userId = userIdEdit.text.toString()
          }
          // 값 유무에 따른 활성화 여부
          if (userId.isNotEmpty()) {
         		
                // 버튼 활성화 로직
         } else {
         		// 버튼 비활성화 로직
         }
}

pseudo code가 대충 저렇게 생겼는데, 보시면 userId 변수에 저장되는 값은 화면 UI 상태이고, 해당 값 유무에 따른 버튼활성화 및 비활성화 로직은 UI 로직에 해당합니다.
(네트워크 통신이나 validation 처리같은 비즈니스 로직은 일단 제외시켰습니다. 비즈니스로직은 ViewModel에 그대로 넣어도 되고 선택적으로 나중에 Usecase같은 Domain layer에서 구현해도 됩니다.)

자 그럼 ViewModel 클래스를 만들어서 userId 변수를 가지고 있도록 하고 Activity 코드에서는 ViewModel의 userId를 셋팅하거나 userId를 참조하여 화면을 업데이트 할 수 있도록 리팩토링 해보겠습니다.

생산자-소비자 패턴

생산자-소비자 패턴은 작업목록 (큐) 를 가운데 두고 작업을 생산해 내는 주체처리하는 주체로 분리시켜 설계하는 패턴입니다. 생산자는 소비자가 몇개가 어떻게 동작하는지 관심없고 그저 작업을 생산하여 큐에 쌓아두기만 하면 됩니다. 소비자 역시 생산자에 대해 알아야 할 것은 없으며 그저 큐의 작업내용이 변경되거나 혹은 큐의 데이터가 필요한 경우 작업큐를 참조하여 처리하기만 하면 됩니다.

View - ViewModel간의 관계는 기본적으로 생산자-소비자 패턴을 따릅니다.
ViewModel이 데이터를 생산하여 들고있으면 View는 필요할 경우 ViewModel이 들고 있는 데이터를 참조하여 UI를 업데이트 합니다. View가 하는 역할은 UI를 적절히 업데이트 하고 유저의 입력을 받아 ViewModel이 데이터를 생산할 수 있도록 알려주기만 합니다. ViewModel은 View의 요청에 따라 데이터를 생산하기만 합니다.

안드로이드에서 View - ViewModel 간의 생산자-소비자 패턴을 구현하기 위한 자료구조에는 크게 LiveData와 StateFlow가 있습니다.
이들에 대해 자세히 탐구하려면 이 시리즈의 논지를 조금 벗어날것 같아 간략히 소개만 하고 넘어가도록 하겠습니다.

LiveData

LiveData는 Android AAC 에서 제공하는 자료구조이며 안드로이드 플랫폼에 종속적이기 때문에 라이프사이클 변화에 따라 유기적으로 동작하도록 설계되었습니다. 따라서 옵저버 패턴을 통해 데이터의 변화를 관찰하고 UI 를 업데이트하기에 용이할 뿐더러 라이프사이클에 의한 메모리 누수또한 고려하지 않아도 됩니다.

StateFlow

StateFlow는 Kotlin 수준에서 제공하는 Flow API 중의 하나이며 LiveData와 마찬가지로 value 속성을 통해 현재 상태 값을 읽을 수 있습니다. 또한 다양한 코루틴 스코프를 통해 비동기 처리가 가능하지만 Kotlin 수준에서 제공되는 API이므로 자체적으로 안드로이드 라이프사이클을 알지 못한다는 단점이 있습니다. (물론 이를 극복할 수 있는 방법 또한 존재하긴 합니다.)


기존에는 LiveData를 사용하였지만 안드로이드 OS에 종속적이기 때문에 유닛테스트가 용이하지 않고, UI layer 이외의 Domain laer에서 사용하기 부적합하며 메인스레드에서 동작하는 단점들 때문에 StateFlow를 사용하고 있는 추세입니다. 그러나 본 포스팅에서는 처음 MVVM 입문을 다루고 있는 만큼 보다 쉽게 사용할 수 있는 LiveData를 활용하도록 하겠습니다.
(StateFlow를 사용하려면 Flow API 에 대해 또 알아가야 하므로 러닝커브가 더 높아져요...)

여담
지금단계에서 저도 StateFlow가 익숙하지 않아 그런점도 없지않아 있긴 합니다 😁 그러나 개인적으로 조금 찾아보고 공부해본 결과 Domain layer가 아닌 UI layer에서는 굳이 StateFlow 없이 LiveData를 활용하여도 충분히 얻는 이점이 많다고 생각합니다. 물론 메인스레드에서 무겁게 동작하는 단점이 존재하긴 하지만, 안드로이드 OS 친화적인 자료구조이므로 메모리 누수 걱정없이 라이프사이클에 따라 잘 동작하며 Observable 객체를 통해 보다 직관적으로 Observer 패턴을 구현할 수 있기 때문입니다.
그러나 StateFlow로 많이 넘어가고 있는 추세이고 LiveData의 단점을 StateFlow가 잘 보완하고 있는것도 사실이기 때문에 더 공부해보고 적용하여 포스팅해볼 생각입니다.

ViewModel을 써보자

class JoinViewModel : ViewModel() {
    private val _userId = MutableLiveData<String>()
    val userId: LiveData<String> = _userId

    fun setUserId(id : String){
        _userId.value = id
    }
}

JoinActivity에서 아이디 정보를 저장하는 전역변수 userId 를 ViewModel로 옮겨서 LiveData 형식으로 선언하였습니다.

setUserId 매서드는 userId 값을 셋팅하는 함수인데, Activity에서 EditText 입력 이벤트가 일어날 때 마다 해당 함수를 호출하여 뷰모델 안의 userId 값을 셋팅하도록 할 수 있습니다. (혹은 x 버튼을 누를 경우 setUserId("") 와 같이 호출하여 userId 정보를 비울 수 도 있겠죠.)

주목해봐야 할 점은 MutableLiveData와 LiveData가 함께 사용되었다는 점입니다.
LiveData 자체로는 값을 수정할 수 없는 읽기전용 자료구조이므로 이를 수정할 수 있도록 setValue(), postValue() 메서드를 제공하는 MutableLiveData를 함께 사용하였습니다.

그 이유는 userId가 외부에 의해 변경될 수 없도록 하기 위함입니다. 즉 생산자-소비자 패턴을 구현하기 위함인데, 이렇게 하면 ViewModel은 MutableLiveData를 수정하여 데이터를 생산하기만 하고, 외부의 액티비티나 프래그먼트는 LiveData 를 관찰하면서 데이터를 처리하기만 하도록 구현할 수 있습니다.

class JoinActivity : AppcompatActivity(){

	// JoinViewModel을 생성
	private val joinViewModel : JoinViewModel by lazy{
        ViewModelProvider(this).get(JoinViewModel::class.java)
    }
    
    override fun onCreate(savedInstanceState: Bundle?){
    
      // ...
		// userId를 관찰하여 UI 업데이트하는 옵저버
        joinViewModel.userId.observe(this, Observer { userId ->
        	if (userId.isNotEmpty()) {
         		
                // 버튼 활성화 로직
                
         } else {
         
         		// 버튼 비활성화 로직
         
         }
     }

		// Id 정보 EditTextChangedListener
        binding.userIdEdit.addTextChangedListener(object : TextWatcher {
            override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}

            // 값 변경 시 실행되는 함수
            override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {

                // 입력값 담기
                // userId = userIdEdit.text.toString()
                joinViewModel.setUserId(userIdEdit.text.toString())
              
            }
        }
  	}
}

자 이제 Activity가 ViewModel의 데이터를 관찰할 수 있도록 바뀌었습니다.

ViewModel 생성하기

ViewModel은 Android의 아키텍처 컴포넌트로서, 액티비티 또는 프래그먼트와 생명주기를 공유합니다. 따라서 뷰모델 호출은 액티비티 생명주기 메서드 안에서 일어나야 하므로 lazy 블럭을 통해 액티비티가 생성된 이후 필요할 때 뷰모델을 불러올 수 있도록 구현해야 합니다.

만약 val joinViewModel = JoinViewModel() 과 같이 액티비티에서 직접 생성해서 사용한다면 액티비티가 파괴된 후에 불특정한 시점에 ViewModel이 가비지컬렉터에 의해 파괴될 것입니다. 따라서 ViewModel과 액티비티간의 생명주기가 공유되지 않게 되겠죠. 따라서 ViewModelProvider라는 특수한 객체를 통해 생성해야 합니다.

open class인 ViewModelProvider의 constructor는 이렇게 구성되어 있는데요, 지금 우리가 사용할 것은 위에서 ViewModelStoreOwner 하나만을 매개변수로 받는 constructor입니다.

// JoinViewModel을 생성
	private val joinViewModel : JoinViewModel by lazy{
        ViewModelProvider(this).get(JoinViewModel::class.java)
    }

위 부분이 JoinViewModel을 생성하는 부분인데, ViewModelProvider를 통해 생성하도록 위임 하였습니다. (by 키워드) 또한 lazy 를 통해 액티비티가 완전히 onCreate 된 다음 뷰모델이 호출될 때 호출될 수 있도록 하였습니다. 그 이유는 ViewModelProvider의 인자로 context인 this가 들어가기 때문에 액티비티가 온전히 생성된 다음 호출되어야 하기 때문입니다.

private val joinViewModel : JoinViewModel by viewModels()

위 코드도 내부적으로 같은 역할을 하도록 구현되어 있습니다.

ViewModel 안의 UI 상태 변경하기

// Id 정보 EditTextChangedListener
binding.userIdEdit.addTextChangedListener(object : TextWatcher {
	override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}

	// 값 변경 시 실행되는 함수
	override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {

	// 입력값 담기
	// userId = userIdEdit.text.toString()
	joinViewModel.setUserId(userIdEdit.text.toString())
              
	}
}

위의 입력값 담기 부분 코드를 보면 보면 userId 전역변수에 바로 값을 저장했던 부분이 ViewModel 내의 메서드를 통해 ViewModel이 직접 값을 셋팅할 수 있도록 변경했습니다.
View 역할을 하는 액티비티는 ViewModel의 비즈니스 로직을 호출할 뿐, UI 상태 데이터에 대해 어떠한 작업도 수행하지 않습니다.

Observer를 통해 LiveData 관찰하기

// userId를 관찰하여 UI 업데이트하는 옵저버
joinViewModel.userId.observe(this, Observer { userId ->
	if (userId.isNotEmpty()) {
         		
		// 버튼 활성화 로직
                
	} else {
         
		// 버튼 비활성화 로직
         
}

위 코드가 ViewModel 안의 userId LiveData를 관찰하여 UI 로직을 수행하는 코드입니다.
위에서 계속 설명했듯이 View에서는 오직 UI 로직만을 수행해야 합니다.
LiveData 는 observe 메서드를 통해 관찰될 수 있으며 observe 메서드에 인자로 들어가는 Observer 객체에서 해당 관찰 데이터에 대한 UI 로직을 구현할 수 있습니다.
Observer 객체는 onChanged() 단 하나의 메서드만 가지고 있으므로 람다식으로 표현한 모습입니다.

코드 가독성과 이해를 위해 매우 간단한 예제로 다루어 봤지만 이런 방식을 통해 화면 UI 상태를 ViewModel로 분리해내고 View에서 이를 관찰해 화면을 업데이트 하도록 할 수 있습니다.

profile
안했으면 빨리 백준하나 풀고자.

0개의 댓글