
지난 포스팅에서 MVP에 대해 정리해 보았다. 오늘은 이어서 MVVM에 대해 정리해 보려고 한다. 다음은 글의 목차이다.
목차
- Intro
- MVVM이란?
- MVVM 예제
- Outro
이전 포스팅에서 MVP의 문제점에 대해 다뤘다. MVP 패턴에서 Presenter는 여전히 추상화된 Contract.View에 의존하고 있다. 특정 View의 Contract에 의존하기 때문에, 다른 View에서 Presenter를 재사용하기 어렵다. 즉, Presenter는 독립적인 존재가 아니다.
이 문제점들을 ViewModel이라는 개념을 도입한 MVVM 패턴을 활용해 해결할 수 있다. ViewModel은 View를 참조할 필요가 없기 때문이다.
MVVM 패턴은 Model, View, ViewModel로 이루어져 있다.
ViewModel은 마치 중개자와 같다. 제3자로서 View와 Model 사이에 서서 일을 주선하는 어댑터 역할을 하고 있다. 역할만 보면 Presenter와 유사해 보인다.

ViewModel과 Presenter 사이의 주요한 차이점은 의존성의 방향이다. MVP에서는 Presenter가 View를 참조하기 때문에 둘 사이의 의존성은 양방향이다. 하지만 MVVM에서는 의존성이 단방향으로 흐른다. View → ViewModel → Model 순으로만 참조가 이루어지며, 역방향 참조는 발생하지 않기 때문이다.
// MVP
class Presenter(private val view: Contract.View): Contract.Presenter { ... }
class MVPActivity : AppCompatActivity(), Contract.View {
private val presenter = Presenter(view = this) // Presenter는 View를 참조
...
}
// MVVM
class ViewModel: ViewModel() { ... }
class MVVMActivity : AppCompatActivity() {
private val viewModel: ViewModel by viewModels() // ViewModel은 View를 참조하지 않음
...
}
앞서 ViewModel은 View를 참조하지 않는다고 했다. 그렇다면 View에 데이터를 전달하려면 어떻게 해야 할까? 바로 Observer(관찰자) 패턴을 활용하면 된다.
MVVM 구조에서 View는 ViewModel의 데이터를 관찰하고, ViewModel의 데이터가 변경되면 UI가 자동으로 업데이트된다. 안드로이드 개발에서는 DataBinding, LiveData, StateFlow 등 Observer 패턴을 구현할 수 있는 다양한 방법들이 있다.
이제 위 수단 중 DataBinding과 LiveData를 활용해 예제를 작성해 보겠다.
마이크로소프트의 .NET MAUI에서 MVVM 패턴을 구현하는 데 DataBinding을 중요한 요소로 사용한다. 안드로이드 View 환경 역시 DataBinding은 MVVM 패턴을 구현하기 위한 아주 중요한 요소이다.
Android Developers Docs: DataBinding
The Data Binding Library is a support library that allows you to bind UI components in your layouts to data sources in your app using a declarative format rather than programmatically.
안드로이드 공식 문서에서도 선언적 형식을 사용하여 Layout의 UI 구성 요소를 앱의 데이터 소스에 결합하는 것을 권장하고 있다. 이때 LiveData를 활용하여 데이터의 변경을 View에 알릴 수 있다. 이렇게 ViewModel은 View를 직접 참조하지 않고도 Observer 패턴을 활용하여 이벤트를 전달할 수 있다.
또한 ViewModel은 독립적인 존재이기 때문에 ViewModel과 View는 1:n 관계가 가능하다. MVVM 패턴에서는 1:n 관계가 가능하므로 MVP 패턴에서 Presenter가 특정 View의 Contract에 의존하여 다른 View에서 재사용하기 어려운 문제를 해결할 수 있다.
예제를 통해 MVVM에 대해 좀 더 자세히 알아보겠다.
MVP 예제에서 사용했던 기능 명세서, 도메인 모델, UI를 활용해 예제를 작성해 보자.
📋 기능 명세서
🎨 View가 할 일
- 사용자가 선택한 티켓 장수 보여주기
- 발권된 티켓(티켓 장수, 발권인 이름) 보여주기
- 반가워요! 해나님, 2장의 티켓이 예매 되었어요.
🕹️
Presenter가 할 일→ ViewModel이 할 일
- 티켓 수 증가
- 현재 티켓 수에서 1 증가
- 티켓 수 감소
- 현재 티켓 수에서 1 감소 (최소 티켓 수는 1)
- 티켓 발행
- 티켓 수와 발권인 이름 적힌 티켓을 발행
class Reservation {
private var count: Int = 1
fun increase(): Int = ++count
fun decrease(): Int = if (count == 1) 1 else --count
fun makeTicket(): Ticket = Ticket(count)
}
// MVVM을 알아보는 예제이므로, name은 입력 받지 않고 기본 인자로 설정했다.
data class Ticket(
val count: Int,
val name: String = "해나",
)
MVP 패턴과 MVVM 패턴의 가장 큰 차이는 Contract의 유무이다. MVP 패턴을 구현하기 위해서는 일반적으로 Contract가 필요하지만 MVVM 패턴은 Contract가 필요하지 않다. ViewModel만 구현하면 된다.
ViewModel은 ViewModel 라이브러리(AAC ViewModel)를 사용해 구현할 수 있다. AAC ViewModel을 활용하지 않는다고 MVVM 패턴을 적용하지 못하는 것은 아니다. AAC ViewModel은 수명 주기를 인식해 수명 주기에 따라 UI 데이터를 저장하고 관리하는 역할을 하는 컴포넌트다. AAC ViewModel은 ViewModel() 추상 클래스를 상속받아서 구현하면 된다.
class ReservationViewModel: ViewModel() {
...
}
그리고 LiveData를 사용해 데이터를 관찰할 수 있는 형태로 만들어 주면 된다. ViewModel 내부에서는 데이터의 값을 변경할 수 있도록 MutableLiveData로 데이터를 감싼다. MutableLiveData 로 감싼 값은 외부에서 변경하지 못하도록 private으로 선언해야 한다. 외부에서는 이 데이터를 관찰할 수는 있지만 변경할 수 없어야 한다. 따라서 데이터를 외부에 노출할 때는 관찰만 할 수 있도록 LiveData로 감싸야 한다.
class ReservationViewModel : ViewModel() {
private val _count: MutableLiveData<Int> = MutableLiveData(1)
val count: LiveData<Int> get() = _count
private val _ticket: MutableLiveData<Ticket> = MutableLiveData(null)
val ticket: LiveData<Ticket> get() = _ticket
...
}
MutableLiveData 내의 데이터는 value로 접근하여 값을 변경할 수 있다.
ViewModel의 메서드들도 Presenter의 메서드들처럼 값을 반환하지 않는다.
class ReservationViewModel : ViewModel() {
private val reservation = Reservation()
private val _count: MutableLiveData<Int> = MutableLiveData(1)
val count: LiveData<Int> get() = _count
private val _ticket: MutableLiveData<Ticket> = MutableLiveData(null)
val ticket: LiveData<Ticket> get() = _ticket
fun increaseCount() {
val newCount = reservation.increase()
_count.value = newCount
}
fun decreaseCount() {
val newCount = reservation.decrease()
_count.value = newCount
}
fun issueTicket() {
val ticket: Ticket = reservation.makeTicket()
_ticket.value = ticket
}
}
LiveData에 대해 더 자세히 알고 싶다면 아래 글을 참고하자.
ObservableField와 LiveData의 이해(+ Data Binding와 결합하기)
마지막으로 View(Activity, XML)를 살펴보자.
우선 DataBinding을 사용하기 위해 의존성을 추가해야 한다.
// build.gradle.kts(Module: app)
android {
...
buildFeatures {
dataBinding true
}
}
다음으로는 Layout 파일에서 ViewModel의 데이터에 접근할 수 있도록 속성을 추가한다. 이 속성을 사용해서 ViewModel의 데이터를 Layout에 결합할 수 있다.
<?xml version="1.0" encoding="utf-8"?>
<layout ...>
<data>
<variable
name="viewModel"
type="com.woowacourse.blog_challenge.mvvm.ReservationViewModel" />
</data>
...
</layout>
이제 Activity에서 ViewModel을 생성한 후, 이를 Layout 속성에 선언한 viewModel과 연결하면 된다.
필자는 KTX의 by viewModels() API를 활용해 ViewModel을 생성했다. viewModels() 내부에서 ViewModelProvider를 활용하기 때문에 개발자가 직접 ViewModelProvider를 구현하지 않아도 된다.
LiveData는 데이터가 변경되면 Observer에게 알린다. View에서 ViewModel을 생성하면, Observer가 데이터 변경을 감지하고 UI를 업데이트한다. LiveData는 수명 주기를 인식하여 UI가 활성 상태일 때만 데이터를 관찰하므로 Layout에 LifecycleOwner를 전달해야 데이터값이 자동으로 변경된다.
// build.gradle.kts(Module: app)
dependencies {
implementation("androidx.activity:activity-ktx:${version}")
}
import androidx.activity.viewModels
class MVVMActivity : AppCompatActivity() {
private val binding: ActivityMvvmBinding by lazy { DataBindingUtil.setContentView(this, R.layout.activity_mvvm) }
// activity.activity-ktx를 활용해 ViewModel 생성
private val viewModel: ReservationViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
// 생성한 viewModel을 Layout 속성에 선언한 viewModel과 연결
binding.viewModel = viewModel
// Layout에 LifecyclerOwner 전달
binding.lifecycleOwner = this
}
}
예제에 사용할 UI는 아래와 같이 만들었다.
| 예매 전 | 예매 후 |
|---|---|
![]() | ![]() |
위 UI를 구현한 전체 XML 코드는 다음과 같다. 필자는 DataBinding과 BindingAdapter를 사용하고 있다. BindingAdapter는 ticket의 null 여부에 따라 다른 문구를 출력하기 위해 사용했다. ViewModel의 LiveData 값을 binding expression(@{})에 할당하면, 개발자가 값을 업데이트하는 코드를 작성하지 않아도 값이 자동으로 반영된다.
BindingAdapter
@BindingAdapter("ticket") // Layout 파일에서 "ticket"이라는 속성으로 사용할 수 있도록 정의
fun TextView.setReservationDetail(ticket: Ticket?) {
text = if (ticket == null) "예매 내역이 존재하지 않습니다."
else "반가워요! ${ticket.name}님, ${ticket.count}장의 티켓이 예매 되었어요."
}
XML
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:bind="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:background="@color/white">
<data>
<variable
name="viewModel"
type="com.woowacourse.blog_challenge.mvvm.ReservationViewModel" />
</data>
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".mvvm.MVVMActivity">
<androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintGuide_percent="0.2"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/btn_decrease"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:backgroundTint="@color/black"
android:onClick="@{() -> viewModel.decreaseCount()}"
android:text="-"
android:textSize="20dp"
app:layout_constraintBottom_toBottomOf="@id/tv_count"
app:layout_constraintEnd_toStartOf="@id/tv_count"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="@id/tv_count" />
<TextView
android:id="@+id/tv_count"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@{String.valueOf(viewModel.count)}"
android:textSize="20dp"
app:layout_constraintBottom_toBottomOf="@id/btn_reservation"
app:layout_constraintEnd_toStartOf="@id/btn_increase"
app:layout_constraintStart_toEndOf="@id/btn_decrease"
app:layout_constraintTop_toTopOf="@id/guideline"
tools:text="0" />
<Button
android:id="@+id/btn_increase"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:backgroundTint="@color/black"
android:onClick="@{() -> viewModel.increaseCount()}"
android:text="+"
android:textSize="20dp"
app:layout_constraintBottom_toBottomOf="@id/tv_count"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/tv_count"
app:layout_constraintTop_toTopOf="@id/tv_count" />
<Button
android:id="@+id/btn_reservation"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:backgroundTint="@color/black"
android:onClick="@{() -> viewModel.issueTicket()}"
android:text="예매"
app:layout_constraintEnd_toEndOf="@id/btn_increase"
app:layout_constraintStart_toStartOf="@id/btn_decrease"
app:layout_constraintTop_toBottomOf="@id/tv_count" />
<!--bind:ticket은 BindingAdapter를 활용한 코드-->
<TextView
android:id="@+id/tv_ticket"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginHorizontal="36dp"
android:gravity="center"
android:textSize="16dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/btn_reservation"
bind:ticket="@{viewModel.ticket}"
tools:text="반가워요! 해나님, 4장의 티켓이 예매 되었어요." />
</androidx.constraintlayout.widget.ConstraintLayout>
</layout>
이로써 MVVM 패턴 적용이 끝났다. 과정을 다시 정리해 보자면 다음과 같다.
- View가 사용자 입력을 받음
- View는 입력된 데이터를 처리하거나 UI를 업데이트하기 위해 ViewModel에게 작업을 요청
- ViewModel은 Model과 상호작용하여 필요한 데이터를 가져오거나 비즈니스 로직을 수행
- ViewModel은 데이터의 상태 변경을 View에 알리기 위해 LiveData를 활용하여 이벤트 전달
🤔 AAC ViewModel을 쓴다고 MVVM인가?
ViewModel을 활용한다고 해서 MVVM 패턴을 적용했다고 볼 수 있을까? 아니다. 단순히 AAC ViewModel을 사용한다고 해서 MVVM 패턴을 구현했다고 할 수는 없다.
MVVM 예제를 구현하는 과정에서 DataBinding과 LiveData를 활용해 ViewModel이 View에 의존하지 않도록 만들었다. 또한, Model을 분리해 비즈니스 로직을 View로부터 제거했다. 이처럼 MVVM의 가장 큰 목적은 비즈니스 로직과 프레젠테이션 로직을 UI로부터 분리하는 것이기 때문에 이를 만족하는 것이 MVVM의 핵심이다. 이를 통해 테스트 가능하고 재사용 가능한 설계를 구현할 수 있다.
Learn Microsoft: MVVM(Model-View-ViewModel)