MVVM 패턴은 UI와 비즈니스 로직을 분리함으로써, 코드의 복잡성을 줄이고 유지보수성과 테스트 용이성을 높이도록 하는 디자인 패턴이다. 이러한 장점 덕분에 안드로이드 앱을 개발하면서, 보편적으로 사용되고 있다.
그래서 MVVM을 올바르게 사용하는 것이 중요하다고 생각해, 내가 MVVM 패턴을 사용하면서 겪었던 실수와 문제점, 그리고 이를 개선하기 위한 방법들을 정리해 보았다.
class MyViewModel : ViewModel() {
fun fetchData() {
// 복잡한 비즈니스 로직
val result = performComplexCalculation()
// API에서 데이터 가져오기
val data = api.getData()
// UI 직접 업데이트
textView.text = data.toString()
}
}
위 예제에서는 ViewModel이 비즈니스 로직, UI 로직, 데이터 처리까지 담당하고 있다.
이렇게 되면 ViewModel이 SRP를 위반하게 된다. 또한, UI 요소와 직접적으로 결합되어 테스트하기 어렵고 유지보수가 어려워진다.
따라서 아래와 같이 ViewModel은 UI에 제공되는 데이터 관리에 집중하고, 데이터 로직과 비즈니스 로직은 Repository(or UseCase)에 위임함으로써 관심사를 명확히 분리할 수 있다.
class MyViewModel(private val repository: MyRepository) : ViewModel() {
private val _userData = MutableLiveData<User>()
val userData: LiveData<User> get() = _userData
fun fetchUserData() {
viewModelScope.launch {
val data = repository.getUserData()
_userData.postValue(data)
}
}
}
class MyViewModel : ViewModel() {
fun updateUI(data: String) {
textView.text = data // UI를 직접 업데이트
}
}
위 예제에서는 ViewModel이 UI 요소를 직접 참조하고 업데이트하고 있다.
이렇게 되면 ViewModel과 UI 요소 간의 결합을 유발하여 테스트가 어렵고, UI 구조가 바뀔 때 ViewModel도 수정해야 하는 문제가 생긴다.
따라서 아래와 같이 ViewModel은 UI 요소와 분리해 UI의 상태만을 관리하도록 수정할 수 있다. UI는 LiveData(or Flow)를 관찰하고 UI 업데이트를 처리하므로 결합도가 낮아진다.
class MyViewModel : ViewModel() {
private val _uiState = MutableLiveData<UIState>()
val uiState: LiveData<UIState> = _uiState
fun fetchData() {
_uiState.value = UIState.Loading
// 데이터 가져오기
_uiState.value = UIState.Success(data)
}
}
class MyViewModel : ViewModel() {
private val repository = MyRepository()
fun fetchData() {
val data = repository.getData()
_uiState.value = UIState.Success(data)
}
}
위 예제에서는 ViewModel이 Repository에 직접 의존하고 있다. 의존성 주입 없이 Repository를 하드코딩하면, 다음과 같은 문제가 발생할 수 있다.
따라서 Hilt와 같은 DI 프레임워크를 통해 Repository를 ViewModel에 주입하는 것을 권장한다. 이를 통해 ViewModel은 특정 구현체에 의존하지 않고, 외부에서 의존성을 주입 받기 때문에 위의 문제들을 해결할 수 있다.
@HiltViewModel
class MyViewModel @Inject constructor(
private val repository: MyRepository
) : ViewModel() {
fun fetchData() {
viewModelScope.launch {
val data = repository.getData()
_uiState.value = UIState.Success(data)
}
}
}

ViewModel은 Activity가 파괴된 이후에 소멸된다. Activity의 onDestroy()가 호출되어도 ViewModel은 살아있으며 이후에 onCleared()가 호출되어야 ViewModel이 사라진다.
즉, ViewModel은 Activity보다 긴 생명주기를 갖고 있다는 뜻이다.
그렇다면, ViewModel에서 Activity의 Context를 사용하면 어떻게 될까?
class MyViewModel(private val context: Context) : ViewModel() {
fun showToastMessage() {
Toast.makeText(context, "Hello from ViewModel", Toast.LENGTH_SHORT).show()
}
}
Activity를 생성하고 ViewModel은 현재 Activity에 대한 Context를 가지고 있다.
만약, 가로모드 전환과 같은 Configuration Change가 발생하면 onDestroy()가 호출되어 Activity는 파괴되어 Context도 같이 소멸되어야한다.
하지만 onDestroy()함수가 호출되었음에도 불구하고 ViewModel은 Activity보다 긴 생명주기를 갖고 있기 때문에 여전히 Context를 참조하고 있어 메모리 상에 Context가 남아있게 된다.
또한 다시 Configuration Change가 발생하게되면 새로운 Activity의 Context가 생성되고 기존에 존재했던 Context와 같이 존재해 충돌이 일어날 수 있다.
이러한 이유로 ViewModel에서 Context를 사용하는 것은 절대로 하지 말아야할 행위 중 하나이다.
class MyViewModel : ViewModel() {
fun fetchData() {
// 잘못된 스코프(GlobalScope)에서 코루틴 실행
GlobalScope.launch {
val data = repository.getData()
_uiState.value = UIState.Success(data)
}
}
}
ViewModel은 UI 관련 데이터를 관리하는 역할을 하고 있으며, 이러한 데이터를 불러오고 갱신하는 작업에는 데이터베이스에서 조회하거나 네트워크 통신을 통해 데이터를 가져오는 작업이 필요하다.
하지만 이러한 작업들은 시간이 오래 걸릴 수 있는 작업이기 때문에, UI 스레드를 차단하지 않고 비동기적으로 처리할 필요가 있다.
따라서 코루틴을 이용해 이러한 작업들을 비동기적으로 실행함으로써, UI 스레드의 차단을 방지할 수 있다.
하지만 ViewModel의 생명주기를 고려하지 않고 코루틴을 사용하면 메모리 누수가 발생할 수 있다.
위 예제에서는, ViewModel 내에서 GlobalScope를 사용하여 코루틴을 실행하고 있는데, GlobalScope는 ViewModel보다 생명주기가 더 길다.
따라서 사용자가 화면을 떠나거나 ViewModel이 소멸되었음에도, GlobalScope에서 실행 중인 코루틴은 취소되지 않는다. 화면이 변경된 상태에서 백그라운드에서 불필요하게 실행된 코루틴이 데이터를 업데이트하려 하거나, 아직 필요한 리소스를 점유하고 있을 수 있기 때문에 메모리 누수로 이어질 수 있다.
따라서 ViewModel 내에서는 GlobalScope를 사용하는 대신, viewModelScope를 사용해야 한다. viewModelScope는 ViewModel의 생명주기에 맞게 관리되며, ViewModel이 소멸되면 코루틴도 자동으로 취소되므로, 이러한 문제를 방지할 수 있다.
class MyViewModel : ViewModel() {
fun fetchData() {
// viewModelScope를 사용하여 ViewModel의 생명주기와 함께 관리되는 코루틴 실행
viewModelScope.launch {
val data = repository.getData()
_uiState.value = UIState.Success(data)
}
}
}