안드로이드 개발에 사용되는 MVP/MVVM 패턴
게시일:
Overview
오늘은 추석이다. 올해의 추석은 집에서 보내게 되었기 때문에 평소처럼 공부를 해야하나라는 내적갈등을 하다가 드로이드나이츠에서 언급되어 새로 알게된 키워드인 안드로이드 디자인 패턴에 대해서 한 번 공부해보기로 하였다. 여기서 말하는 디자인패턴은 싱글톤 패턴과 같은 개념이 아닌 Django의 MVT와 같이 역할을 나누어 개발하여 개발의 효율성을 높이는데 사용된다. 이 시간을 통해서 다음의 패턴에 대한 학습이 이루어질 것이다.
- MVP
- MVVM
MVC 패턴
MVC 패턴은 모델-뷰-컨트롤러로 이루어진 소프트웨어 디자인 패턴이다. 주 목적은 사용자 인터페이스와 비지니스 로직을 분리하여 유지보수가 용이하도록 하는데 목적이 있다.
위키피디아를 보면 아래와 같은 사진을 볼 수 있다.
![]()
Model은 DB의 데이터와 같이 앱에서 사용되는 데이터와 연관된 작업을 수행하는 부분이다. Django의 예로 들면 model.py는 데이터베이스에 저장될 각각의 필드에 대한 값들을 의미한다. 앱의 입장에서 Model에 의해 관리되는 변수들은 결국 사용자에 의해 추가/수정/삭제되는 데이터들을 의미한다.
View는 Model로부터 값을 가져와서 렌더해주는데 사용된다. 체크박스나 표에 들어가는 데이터들을 model로부터 가져와 화면에 뿌려주는 인터페이스의 역할을 수행한다. view.py에서 템플릿의 특정 부분에 들어간 값들을 model로부터 가져와서 화면에 뿌려주는 역할을 수행한다.
Controller는 사용자가 특정 행위를 통해 데이터의 변경이 필요한 경우 처리에 대한 로직을 수행한 뒤 model에 통보하는 역할을 수행한다. 변경이 일어나게 되면 model이 view에게 변경된 상태를 알리면 view가 최신화를 수행하게 된다.
흐름을 대략적으로 생각해보면 다음과 같다.
- controller가 model에게 데이터를 요청한다.
- 가져온 데이터를 가공한 뒤 이를 토대로 view가 화면에 렌더링한다.
- 사용자가 특정 행위를 수행해서 데이터 변경을 요청한다.
- controller가 action을 확인한 뒤 로직을 수행하여 model을 업데이트한다.
- model에 연결된 observer가 view에게 변경사항을 업데이트하도록 한다.
문제점
공부를 하면서 아래 블로그로부터 MVC 패턴의 단점을 알게 되었다.
MVC에서 Controller가 model의 데이터를 업데이트하게 되면 view가 다시 화면에 뿌릴 데이터를 업데이트하게 된다. 하지만 view가 model을 업데이트할 수 있기 때문에 연쇄적으로 연관된 model이 view로 인해 업데이트 되고 그에 의하여 다른 view가 업데이트되는 행위가 반복될 수 있다. 이런 양방향 흐름에 의하여 side-effect가 발생할 수 있다는 것이다.
MVP 패턴
MVP 패턴은 Model, View 그리고 Presenter가 합쳐진 용어로 Controller가 Presenter로 대체되었다. Presenter가 Controller와 비슷하게 view와 model과 상호작용을 하는 형태로 수행된다. 여기서 다른 점은 Presenter가 Model과 View의 인스턴스를 내부에 가지고 있고 이는 1:1 관계를 이루고 있으며 MVC에서 문제가 있었던 model과 view의 의존성을 제거하였다는 것이다. 하지만 Model과 View의 의존성 문제는 해결되었지만 반대로 Presenter와 View의 1:1 의존성이 존재한다는 단점이 있다.
Kotlin으로 예제 만들어보기
아래의 블로그에서 kotlin으로 작성된 Android MVP pattern에 대한 학습이 가능하다. 이 예제를 따라하면서 직접 예제를 만들어보기로 하였다.
먼저 공통적인 코드들을 위 블로그의 필자처럼 Base 패키지 아래에 만들었다. 안드로이드에서는 View 부분을 Activity에서 그려주기 때문에 Activity에서 Presenter와 View를 연결해주어야 된다. 이를 위해 아래와 같이 initPresenter를 가진 Prototype을 생성해준다.
package wizley.android.mvp.base
import androidx.appcompat.app.AppCompatActivity
import android.os.Bundle
import wizley.android.mvp.R
// View와 Presenter를 연결하기 위한 1:1 의존성 주입에 사용됨
abstract class BaseActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
initPresenter()
}
abstract fun initPresenter()
}
Presenter는 View와 상호작용을 하기 때문에 공통적으로 모든 Presenter는 View와 1:1 관계를 생성한다. 이를 위한 두 함수를 ProtoType에 추가한다.
package wizley.android.mvp.base
// Presenter와 View는 1:1 관계
interface BasePresenter<T> {
fun bindView(view: T);
fun unbindView();
}
View는 아래와 같이 생성해주었다.
package wizley.android.mvp.base
interface BaseView {
fun logString(log: String)
}
이제 Contract를 구현하게 되는데 이 Contract는 View와 Presenter가 구현해야 되는 정보들을 담고 있게 된다.
package wizley.android.mvp.presenter
import wizley.android.mvp.base.BasePresenter
import wizley.android.mvp.base.BaseView
import wizley.android.mvp.model.Student
interface ExampleContract {
interface View: BaseView {
fun showStudentAge(student: Student)
fun showStudentName(student: Student)
}
interface Presenter: BasePresenter<View> {
fun getStudent()
}
}
Examplecontract는 View와 Presenter를 가지게 되는데 이 두 interface는 Base로부터 상속받는 상태이다. 여기서 추가로 ExampleActivity에서 구현이 필요한 함수인 showStudentAge와 showStudentName, getStudent을 추가해주었다.
package wizley.android.mvp.presenter
import wizley.android.mvp.model.StudentInfo
class ExamplePresenter: ExampleContract.Presenter {
private var mView: ExampleContract.View?= null
override fun bindView(view: ExampleContract.View) {
mView = view
}
override fun unbindView() {
mView = null
}
override fun getStudent() {
val student = StudentInfo.getStudentInfo()
mView?.showStudentName(student)
mView?.showStudentAge(student)
}
}
이제 실질적으로 사용될 Presenter를 Contract로부터 상속받아 정의해주는데 BasePresenter에 정의된 bindView와 unbindView에서 View와 연결 처리를 수행하고 getStudent 부분에서 model로 부터 정보를 가져온다. 그리고 이렇게 가져온 model의 정보를 view의 showStudentName과 showStudentAge로 호출하여 뿌려주게 된다.
package wizley.android.mvp.model
data class Student(
val name: String,
val age: Int
)
model의 경우 의존성이 존재하지 않기 때문에 다음과 같이 선언해주었다.
package wizley.android.mvp.model
object StudentInfo {
fun getStudentInfo() : Student {
return Student("Wizley", 0)
}
}
실제로는 서버 또는 DB로부터 값을 가져와 처리하는 로직이 필요하지만 예제에서는 임의로 객체를 하나 생성해서 돌려주도록 편의를 위한 코드를 위와 같이 작성하였다. Presenter가 model로부터 StudentInfo의 getStudentInfo의 결과를 가져오게 되면 Student의 정보가 Presenter의 getStudent 함수 내부의 student에 들어가게 된다. 그리고 이 정보를 토대로 View의 두 함수가 호출이 된다.
package wizley.android.mvp.view
import android.os.Bundle
import android.util.Log
import wizley.android.mvp.base.BaseActivity
import wizley.android.mvp.model.Student
import wizley.android.mvp.presenter.ExampleContract
import wizley.android.mvp.presenter.ExamplePresenter
class ExampleActivity: BaseActivity(), ExampleContract.View {
private val TAG = "MVP"
private lateinit var mPresenter: ExamplePresenter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
mPresenter.bindView(this)
mPresenter.getStudent()
}
override fun onDestroy() {
super.onDestroy()
mPresenter.unbindView()
}
override fun initPresenter() {
mPresenter = ExamplePresenter()
}
override fun showStudentAge(student: Student) {
Log.e(TAG, "age : ${student.age}")
}
override fun showStudentName(student: Student) {
Log.e(TAG, "name : ${student.name}")
}
override fun logString(log: String) {
}
}
마지막으로 Activity를 생성해주는데 Activity는 Contract의 View 인터페이스 함수들의 실질적인 코드가 위치한 곳이다. onCreate 부분에서 Presenter를 생성하여 presenter가 View와 연결되도록 한다. 그 후 getStudent를 함수를 Presenter를 통해 호출하여 student에 대한 정보를 가져오고 이를 다시 view의 showStudentAge, showStudentName의 파라미터로 전달하게 되면 Log가 아래와 같이 찍히게 된다.
2020-10-01 15:12:12.026 12205-12205/wizley.android.mvp E/MVP: name : Wizley
2020-10-01 15:12:12.026 12205-12205/wizley.android.mvp E/MVP: age : 0
onDestroy가 호출이 될 때면 Presenter와 view의 1:1 관계를 제거해줌으로써 액티비티가 종료되게 된다.
위와 같은 패턴을 수행하게 되면 View가 직접적으로 model에 대한 정보를 가지고 있지 않기 때문에 유지보수에 효율적이라는 생각이 들었다. 하지만 Contract를 생성하고 연결하는 과정에서 View의 개수만큼 Presenter를 생성해야 하기 때문에 그에 따라오는 불편함도 있는 것 같다. 하지만 역시 패턴을 사용하면 코드 리딩을 하는 입장에서 편할 것 같다는 생각이 들었다.
MVVM 패턴
MVVM 패턴에 대한 공부는 아래의 사이트들을 참고하였다.
MVVM은 어떻게 보면 MVP에서 파생된 패턴인데 view model과 data binding을 활용하여 MVP의 presenter와는 다르게 view model은 view와 직접적인 관계가 필요없다는 장점이 있다. 이 패턴을 제안한 John Gossman에 의하면 이 패턴의 목적은 UI와 비지니스 로직 작업을 나누는데 있다고 한다.
MVVM은 MVP의 view와 달리 스스로 ViewModel의 데이터에 변화가 이루어져있는지를 LiveData 또는 ObservableField 등의 observer를 통해 관찰한다. 하지만 Activity 코드 상에서 observer를 활성화하는 방법은 의존성을 제거하지 못하기 때문에 data binding을 사용하기를 권장한다.
databinding을 활용하면 viewmodel과 view간에 직접적인 연결고리가 없더라도 layout에 정의된 data binding이 처리를 도와준다. 이로 인하여 view와 view model 사이의 의존성이 제거되어 독립성이 높아진다는 장점이 있다.
위키피디아를 보면 아래와 같은 구성도를 확인할 수 있다.

Model은 MVP와 동일하게 데이터 영역을 의미한다.
View는 화면에 보이는 UI 요소를 의미하며 패턴의 목적에 의하여 비지니스 로직에 대한 구현을 담당하지 않고 오직 UI관련 로직만을 수행하기 위한 목적으로 사용된다.
View Model은 View에서 사용되는 데이터와 명령을 구성하며 상태변화에 대하여 View에게 전달하는데 사용된다. 이렇게 전달된 결과를 View가 반영할지 여부를 선택하게 된다.
Binder는 앞서 말했듯이 View Model과 View 사이의 의존성을 최소화하기 위해 사용되며 데이터의 동기화를 도와준다. 이를 통해 View Model이 View에 대하여 알지 못하더라도 View가 옵저버를 사용함으로써 View Model의 변화를 알아챌 수 있다.
Model로부터 읽어온 값을 토대로 View Model이 비지니스 로직을 처리한 뒤 Binder에게 통보를 하면 중간자인 Binder(옵저버)가 이 변화에 대해 View에게 통보한다. Binder의 경우 옵저버를 직접 생성하지 않고도 안드로이드의 Data Binding을 활용하게 되면 직접적으로 코드에 명시하지 않아도 view model을 layout에 바인딩함으로써 UI 변화를 갱신할 수 있게 된다.
Kotlin으로 예제 만들어보기
간단한 예제를 data binding을 활용하여 만들어보았다.
먼저 User라는 간단한 Model 객체를 생성하였다.
data class User(
val name: String,
val age: Int
)
repository를 생성하여 ViewModel로부터 데이터를 요청받아 가져오는 작업을 수행하도록 설계를 한다. 예제에서는 임의로 User를 생성하여 돌려주도록 하였다.
class UserRepository {
fun fetchUser() : User{
return User("Wizley", 1)
}
}
UserViewModel은 ViewModel을 상속받아 생성하는데 MutableLiveData를 활용하여 User에 대한 변화를 감지할 수 있도록 하였다. 내부적으로 fetchUser가 호출이 되면 LiveData인 User가 repository에 정보를 요청하게 되고 User(wizley, 1)의 정보를 가져오게 된다.
class UserViewModel(private val userRepository: UserRepository): ViewModel(){
private val users = MutableLiveData<List<User>>().apply {
value = emptyList()
}
var user = MutableLiveData<User>()
fun fetchUser(){
user.value = userRepository.fetchUser()
}
}
activity의 layout에 data binding으로 UserViewModel을 vm으로 선언해주고 textView에서 vm 내부 user의 name과 age 정보를 text로 바인딩 해주었다. MutableLiveData이기 때문에 observer에 의해 값에 변화가 있으면 자동으로 갱신이 되게 된다.
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<data>
<variable
name="vm"
type="wizley.android.mvvm.viewmodel.UserViewModel" />
</data>
<LinearLayout
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_height="match_parent">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@{vm.user.name}"
android:id="@+id/name"/>
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@{Integer.toString(vm.user.age)}"
android:id="@+id/age"/>
</LinearLayout>
</layout>
view를 담당하는 UserActivity에서 UserViewModel 객체를 선언해주고 이를 data binding에 엮어준다. 이렇게 되면 View -> ViewModel로의 단방향 의존성이 생성된다.
class UserActivity : AppCompatActivity() {
private lateinit var viewModel: UserViewModel
private lateinit var dataBinding: ActivityUserBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_user)
dataBinding = DataBindingUtil.setContentView(this, R.layout.activity_user)
initViewModel()
}
override fun onResume() {
super.onResume()
// Command Pattern
viewModel.fetchUser()
}
private fun initViewModel() {
viewModel = ViewModelProvider(this,
Injection.ProvideUserViewodelFactory()).get(UserViewModel::class.java)
dataBinding.vm = viewModel
}
}
View에 Action이 들어오면 ViewModel에 해당 Action을 처리하도록 한 뒤 ViewModel에서 비지니스 로직이 처리된다. 그 후 Model과 상호작용이 되어 정보가 변경되게 되면 data binding에 의하여 view의 연관된 부분들에 대한 변경이 이루어진다.
단방향 의존성과 UI와 비지니스 로직을 분리할 수 있다는 장점이 있지만 또 한편으로 생각해보면 ViewModel의 크기가 커지면 그만큼 메모리가 사용된다는 단점도 존재하는 것 같다.
조만간 두 패턴을 써가며 kotlin 앱을 개발해볼 예정이다.