Engineering
Android Architecture 패턴: MV 형제들, 옆에서 볼까 앞에서 볼까?
2022년 9월 7일
원문에서 보기 ↗
오늘 할 이야기는 면접에 자주 등장하는 질문 주제인 소프트웨어 아키텍처입니다.
코드의 품질을 향상시키기 위해 소프트웨어 아키텍처를 적용한다는 것은 막연하게나마 모두 알고 있지만, 실제로 그 개념을 정확하게 이해하고 적용하는 것은 큰 차이가 있습니다. 소프트웨어 아키텍처가 등장한 배경부터 알아봅시다.
1. 소프트웨어 아키텍처?
토이 프로젝트를 할 때 혼자 무언가를 개발해 보신 적이 있으실 겁니다. 대충 붙인 코드 네임, 없다시피 한 주석. 혼자서 개발하면 각각의 함수가 어떤 역할을 하는지 본인은 알고 있기 때문입니다.
하지만 협업에서는 어떨까요?
협업하는 사람은 해당 메서드가 무슨 역할을 하는지 전혀 모르기 때문에 누군가 작성해 놓은 코드를 디버깅하면서 분석해 볼 수밖에 없고, 이는 곧 리소스의 낭비가 됩니다. 그래서 협업 환경에서 코드 네이밍과 자세한 주석은 곧 동료 작업자에 대한 배려이자, 실력의 척도가 되었습니다.
그리고 어느 유명한 개발자들은 생각했습니다.
구조를 만들고 같은 역할을 하는 class를 묶어, 신입 개발자가 들어와도 적응하기 쉽게 만들자.
이렇게 소프트웨어 아키텍처는 공식화 되었고, 해당 아키텍처를 아는 사람은 조금 더 수월하게 프로젝트를 이해할 수 있게 되었습니다. 실제로 아키텍처 패턴은 반복적으로 발견되고 재사용할 수 있는, 잘 정의된 속성을 가진 아키텍처 클래스를 의미합니다!
1. MVC (Model-View-Controller)
구조

먼저 MVC에 대해 설명하겠습니다. 가장 보편적인 아키텍처 패턴이라고도 알려져 있습니다.
- Model 애플리케이션에 사용되는 데이터와 처리 로직이 담겨 있습니다. View 또는 Controller와 묶이지 않기 때문에 재사용할 수 있습니다.
- View (UI) 사용자에게 보이는 화면입니다.
- Controller 사용자로부터 입력을 받고, 모델에 의해 View 정의를 하게 됩니다.
Web에서의 MVC구조는 이렇습니다만, Android는 View와 Controller가 Activity에 포함되어 있습니다 . 이러한 문제점 때문에 Activity 에 코드들이 몰리는 현상이 발생하게 됩니다. 
코드
Activity (Controller + View)
class MainActivity : AppCompatActivity() {
lateinit var binding: ActivityMainBinding
@Inject
lateinit var todoRepository: TodoRepository
lateinit var todoListAdapter: TodoListAdapter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
// UI setting
runCatching {
//Model에서 데이터 가져오기
todoRepository.getTodoList()
}.onSuccess {todoList->
//UI 갱신
todoList.forEach{todoListAdapter.addItem(it)}
}.onFailure {
//실패 로직
}
binding.buttonInsert.setOnClickListener {
todoRepository.insertTodoList(Todo("1", "예제", "예제"))
}
}
}
장점
구현이 쉽고, 이해하기 쉬우며, 구현 속도도 가장 빠릅니다.
단점
- Controller와 View가 결합되어 있기 때문에 UnitTest에 어려움이 있습니다. UI 로직과 Controller에서 실행하는 로직들을 분리하기 어렵기 때문입니다.
- UI변경점이 있을 때, Controller의 코드 변경 또한 불가피합니다.
- Model과 View의 의존성이 높기 때문에 앱이 커질수록 스파게티 코드가 될 위험이 높아집니다(MVP의 등장 배경).
참고로 안드로이드에서는 Activity에서 MVC 모두 처리하게 되는 문제점이 있기 때문에 안드로이드에서는 최근에 거의 쓰이지 않습니다.
2. MVP (Model-View-Presenter)
구조

MVP의 특징은 Activity가 View의 역할만 한다는 겁니다. Android에 있어서 MVP는 컴포넌트 분리가 좀더 명확하게 이루어졌다고 할 수 있습니다.
- Model MVC의 모델과 동일합니다.
- View (UI) Activity를 자연스럽게 View의 일부로 간주하게 되었습니다.
- Presenter Controller과 같은 역할을 하나, Interface로 이루어져 UnitTest에 자유롭습니다.

이제 Activity단에서 바로 Model을 호출하는 것이 아닌, Presenter에서 View로 전달 받게 되었습니다.
코드
Activity
class MainActivity2 : AppCompatActivity(), MainContract.View {
lateinit var binding: ActivityMainBinding
lateinit var mAdapter: TodoListAdapter
@Inject
lateinit var mainPresenter: MainPresenter
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
// presenter에 event 발생했다고 통신
mainPresenter.loadItem()
binding.buttonInsert.setOnClickListener {
mainPresenter.addItem(Todo("", "더미 " , "더미"))
}
}
override fun setUpUI(list : List<Todo>) {
mAdapter= TodoListAdapter(list.toMutableList())
}
override fun addUI(todo: Todo) {
mAdapter.addItem(todo)
mAdapter.notifyDataSetChanged()
}
}
Presenter
class MainPresenter @Inject constructor(
val view: MainContract.View,
val todoRepository: TodoRepository
) :MainContract.Presenter {
//item 처리 로직
override fun loadItem() {
view.setUpUI(todoRepository.getTodoList())
}
override fun addItem(todo: Todo) {
todoRepository.insertTodoList(todo)
view.addUI(todo)
}
}
Contract
interface MainContract {
interface View{
fun setUpUI(list: List\<Todo>)
fun addUI(todo: Todo)
}
interface Presenter{
fun loadItem()
fun addItem(todo: Todo)
}
}
장점
- Model과 View의 의존성이 없어졌기 때문에 관련된 코드만 수정하기 편합니다.
- UI와 Data파트가 명확히 구분되어 간편하게 코딩할 수 있습니다.
단점
- 애플리케이션이 커질수록 View와 Presenter 사이의 의존성이 강해집니다.
- 기능이 많아지면 Presenter의 비중이 커지기 때문에 분리하기가 어렵습니다.
MVC와 다르게 명확하게 컴포넌트가 구분되기 때문에 역할에 따라 분리가 잘된 코드를 볼 수 있지만, Presenter와 View가 1:1 관계를 갖고 있기 때문에 프로젝트가 커질수록 Presenter또한 비례해서 늘어나게 됩니다.
3. MVVM (Model-View-Viewmodel)
어떻게 하면 View와의 관계를 끊어 버릴 수 있을까요? Activity에 비례해 같이 늘어나는 Presenter를 어떻게 처리하면 좋을까요?
MVVM은 presenter에 의존하지 않고, Observer pattern을 이용해 객체의 변경이 일어날 때마다 UI를 갱신하도록 만들었습니다.
Observer Pattern 상태 변화가 있을 때마다 메서드를 통하여 관찰 대상자가 직접 옵저버들에게 통지하여 상태를 동기화할 수 있도록 하는 디자인 패턴을 의미합니다. 즉, 객체에 변화가 일어날 때마다 콜백이 일어나게 됩니다.
구조

MVVM의 특징은 다음과 같습니다.
- Model MVC의 모델과 동일합니다.
- View (UI) ViewModel의 데이터들을 구독하고 있다가 객체가 변할 때 UI를 업데이트합니다.
- ViewModel Model과 상호 작용하며, View에 종속되지 않고 1:N 구조를 갖습니다. MVVM의 가장 큰 특징은 View가 어떤 종속성도 가지지 않았다는 것입니다. 그래서 ViewModel을 다른 View에서도 활용할 수 있습니다.

코드
Activity
class MainActivity : AppCompatActivity() {
lateinit var binding: ActivityMainBinding
lateinit var mAdapter: TodoListAdapter
private val mainViewModel: MainViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
with(binding) {
binding.viewmodel = mainViewModel
binding.lifecycleOwner = this@MainActivity
}
mainViewModel.todoLiveData.observe(this, Observer { todoList ->
todoList.forEach { mAdapter.addItem(it) }
})
lifecycleScope.launch {
//onStop일 때 멈추고, onStart일 때 아래의 코드 블록을 (재)시작한다.
repeatOnLifecycle(Lifecycle.State.STARTED) {
mainViewModel.todoFlow.collect { todoList ->
todoList.forEach { mAdapter.addItem(it) }
}
}
}
}
}
ViewModel
class MainViewModel @Inject constructor(
val todoRepository: TodoRepository
) : ViewModel() {
// 목록(liveData)
private val _todoLiveData: MutableLiveData<List<Todo>> = MutableLiveData()
val todoLiveData: LiveData<List<Todo>> get() = _todoLiveData
private val _insertTodoData: MutableLiveData<Todo> = MutableLiveData()
val insertTodoData: LiveData<Todo> get() = _insertTodoData
private val _eventFlow: MutableStateFlow<UiState> = MutableStateFlow(UiState.Loading)
val eventFlow: StateFlow<UiState> = _eventFlow.asStateFlow()
// 목록(stateFlow)
private val _todoFlow: MutableStateFlow<List<Todo>> = MutableStateFlow(emptyList<Todo>())
val todoFlow: StateFlow<List<Todo>> = _todoFlow.asStateFlow()
private val _insertTodoFlow: MutableStateFlow<Todo> = MutableStateFlow(Todo(""))
val insertTodoFlow: StateFlow<Todo> = _insertTodoFlow.asStateFlow()
fun setUpUI() =
viewModelScope.launch {
//livedata
_eventFlow.value = UiState.Loading
_todoLiveData.value = todoRepository.getTodoList()
//stateflow
_todoFlow.value = todoRepository.getTodoList()
_eventFlow.value = UiState.Susscess("와 성공!")
}
fun insertTodo() = viewModelScope.launch {
_eventFlow.value = UiState.Loading
_insertTodoData.value?.let { todo ->
todoRepository.insertTodoList(todo)
} ?: run {
_eventFlow.value = UiState.Error("와 실패")
}
}
}
장점
- View와 Model이 독립되어 있습니다.
- 바인딩하기 때문에 코드의 양이 감소합니다.
- ViewModel에서 View 코드가 없기 때문에 UnitTest를 쉽게 할 수 있습니다.
단점
- 데이터 바인딩이 필수적으로 요구됩니다.
- 간단한 UI에서 ViewModel을 설계해야 하고, 그만큼 과도한 엔지니어링이 일어날 수 있습니다.
- 다양한 곳에서 많은 데이터를 받기 때문에 관리를 제대로 못 할 경우 버그가 발생합니다.
AAC ViewModel?
이 주제만 가지고도 포스팅을 하나 작성할 수 있을 것 같으니, 다음에 좀더 깊게 설명하겠습니다. AndroidX에 있는 AAC ViewModel은 MVVM의 ViewModel과 다릅니다. 원문을 확인해 봅시다.
ViewModels can also be used as a communication layer between different Fragments of an Activity. Each Fragment can acquire the ViewModel using the same key via their Activity. This allows communication between Fragments in a de-coupled fashion such that they never need to talk to the other Fragment directly.
Android에서의 ViewModel은 Fragment와 Activity의 데이터 전달, 화면 전환 시 잃어버리는 데이터를 저장하는 저장소인 셈입니다. LiveData와 StateFlow를 추가하여 MVVM Architecture 를 구현한 것이기 때문에 결론은 AAC ViewModel을 쓴다고 해서 MVVM을 사용한다고 할 수 없습니다.
마찬가지로 MVVM을 한다고 해서 AAC ViewModel을 사용할 필요는 없기에, 어떤 기업은 AAC ViewModel을 사용하지 않고도 MVVM을 구현한다고 합니다.
4. MVI (Model-View-Intent)
MVVM에서 해결하지 못하는 문제가 제기되었습니다. 바로 상태 문제와 부수 효과였는데요.
상태 문제 프로그레스 바의 상태, 버튼 활성화 상태와 같이 화면에 나타나는 모든 정보를 상태라고 하는데, 의도치 않은 방향으로 상태가 제어된다면 우리는 이것을 상태 문제라고 합니다.
부수 효과(side effect) 원래의 목적과 다른 효과 또는 부작용이 발생하는 상태를 의미합니다. 예를 들어 네트워크 통신 때 데이터를 가져오는 함수는 데이터를 못 가져올 수도 있습니다. function add(a, b){a+b+c}는 a와 b를 더한 값의 예상과 다르게 c 때문에 값이 변경될 수도 있습니다.

데이터가 로딩되었음에도, 프로그레스 바가 돌고 있습니다. MVVM으로 구현하면서 ViewModel에서 상태를 관리하게 되는데 여러 데이터를 분산하다 보니 관리 포인트가 불가피하게 늘어나고, 버그가 발생했습니다.
데이터의 흐름을 제어하지 못하는 것이 문제였기 때문에 MVI는 단일 상태 관리와 단방향 데이터 흐름을 통해 MVVM의 문제점을 해결하고자 했습니다.
구조
위 그림과 같이 단방향으로 흐르는 구조입니다.
- Model 다른 Model과 다르게 상태(State)를 의미합니다. intent로 전달받은 객체에 맞추어 새로운 불변객체를 Model로 생성합니다.
- View (UI) Model의 결과물인 상태를 구독하고 있다가 변경 시 UI 업데이트를 진행하게 됩니다.
- Intent 앱의 상태를 바꾸려는 의도를 의미합니다. Model에게 앱의 상태를 전달합니다.
MVI에 따르면, Model에서 호출되는 값은 불변하기에 예상이 가능해야 합니다. 하지만 서버나 데이터베이스에서 값을 가져올 경우 우리는 이 값을 예측할 수 없습니다.
MVI는 이러한 문제를 SideEffect를 통해 제어합니다.
- SideEffect 백그라운드 작업, 액티비티 전환과 같은 부수적인 작업을 말합니다. Android 하단 알림 바 Toast또한 SideEffect로 보는 경우가 많습니다
MVI는 MVP, MVVM과 다르게 I가 실제 컴포넌트를 지칭하지 않습니다. 어떤 구조로 만들자는 의미 보다는 어떻게 데이터와 상태의 흐름을 어떻게 다룰 것이냐는 패러다임에 가깝습니다. 그래서 MVI의 수많은 예제 코드를 보면 MVVM, MVP 기반으로 작성된 코드를 많이 볼 수 있습니다(또한 저의 코드도 MVVM을 기반으로 만들어졌습니다).
코드
해당 코드는 orbit이라는 FrameWork를 통해 작성되었습니다. 프레임워크를 사용하지 않은 MVI패턴에 대해서는 조만간 Deep 하게 다뤄볼 예정입니다.
lateinit var binding: ActivityMainBinding
@Inject
lateinit var todoRepository: TodoRepository
val mainViewModel: MainViewModelMvi by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
mainViewModel.observe(
lifecycleOwner = this,
state = ::handleState,
sideEffect = ::handleSideEffect
)
}
//state가 변할 때마다 해당 메서드로 들어온다.
private fun handleState(state: TodoState) {
if (!state.loading) {
state.exception?.let {
//성공 처리
} ?: run {
//오류 처리
}
} else {
//Todo 로딩 다이얼로그 표출하기
}
}
//사이드 이펙트
private fun handleSideEffect(sideEffect: TodoSideEffect) {
when (sideEffect) {
is TodoSideEffect.Toast -> {
Toast.makeText(this, sideEffect.text, Toast.LENGTH_LONG).show()
}
}
}
MainViewModelMvi
class MainViewModelMvi @Inject constructor(
private val todoRepository: TodoRepository
) : ContainerHost<TodoState, TodoSideEffect>, ViewModel() {
//한 개의 State와 한 개의 SideEffect를 만들어 준다.
override val container: Container<TodoState, TodoSideEffect> = container(TodoState())
fun setUpUI() = intent {
runCatching {
//State를 변경해 준다.
reduce { state.copy(loading = true) }
todoRepository.getTodoList()
}.onSuccess {todoList->
reduce { state.copy(loading = false) }
reduce { state.copy(todo = todoList , loading = false) }
}.onFailure {
reduce { state.copy(exception = Exception("오류~"))}
//사이드 이펙트 발생
postSideEffect(TodoSideEffect.Toast("성공"))
}
}
fun insertTodo(todo: Todo) = intent {
reduce { state.copy(loading = true) }
postSideEffect(TodoSideEffect.SaveData("성공"))
reduce { state.copy(todo = state.todo.toMutableList().apply {add(todo)} , loading = false) }
}
}
TodoState
data class TodoState(
val todo: List<Todo> = emptyList(),
val loading: Boolean = true,
val exception: Exception? = null
)
sealed class TodoSideEffect {
data class SaveData(val text: String) : TodoSideEffect()
data class Toast(val text: String) : TodoSideEffect()
}
장점
- 하나의 State객체만을 바라보기 때문에 상태 충돌이 일어나기 어렵습니다.
- 데이터의 흐름을 파악하기 쉽습니다.
- 불변객체이기 때문에 스레드에 대해 안전합니다.
단점
- 러닝 커브가 매우 높습니다(기본적으로 MVP와 MVVM을 깔고 갑니다).
- Model 업데이트를 위해 새로운 인스턴트를 끼워 넣기 때문에 리소스가 낭비될 수 있습니다.
- 작은 변경에도 intent를 거쳐야 합니다.
What is Best?
모든 것에는 장점과 단점이 있기 때문에 반드시 무엇을 사용해야 한다는 것은 없습니다. 작은 프로젝트에 아키텍처 패턴은 오버 엔지니어링일 수도 있으니까요. 최신 디자인을 따르기 보다는 규모에 따라 선택해야 하며, 고집보다는 팀과의 협업을 생각하며 선택하는 개발자가 되셨으면 좋겠습니다.
Happy Coding!