오늘 할 이야기는 면접의 단골주자인 소프트웨어 아키텍처입니다.
사용하면 코드의 품질이 향상된다며 적용하는것은 많이 보았지만, 실제로 개념을 알고 가는것은 크나큰 차이가 있습니다.
왜 소프트웨어 아키텍처가 나왔는지부터 파고 들어가 봅시다.
토이프로젝트를 할때, 혼자 개발해보신적 있으실껍니다.
대충 붙인 코드 네이밍, 없다시피한 주석..
왜냐면 어떤 역할은 하는 함수인지 알기 때문입니다.
하지만 협업에서는 어떨까요?
협업하는 사람은 해당 메소드가 무슨역할을 하는지, 전혀 모르기때문에 작성해놓은 코드를 디버깅하면서 분석해볼수밖에 없고, 곧 시간 손해가 되어버립니다.
그래서 코드네이밍과 주석은 같이 작업하는사람의 배려이자,곧 실력의 척도가 되었습니다.
그리고 어느 유명한 개발자들은 생각했습니다.
"구조를 만들고 같은 역할을 하는 class를 모아, 신입개발자가 들어와도 적응되기 쉽게 만들자"
이렇게 소프트웨어 아키텍처는 공식화가 되었고, 해당 아키텍처를 아는사람은 조금더 쉽게 프로젝트를 이해할수 있게 되었습니다.
맨처음 설명할 MVC입니다.
가장 보편적인 아키텍처 패턴이라고도 알려져있습니다.
Model
View(UI)
Controller
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 모두 처리하게 되는 문제점이 있기 때문에, 안드로이드에서는 거의 쓰이지 않습니다.
MVP의 특징은 이제 Activity는 View의 역할만 한다는겁니다.
Android에 있어서 MVP는 컴포넌트 분리가 좀더 명확하게 이루어졌다고 할수있습니다.
Model
View(UI)
Presenter
이제 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)
}
}
어플리케이션이 커질수록, View와 Presenter 사이의 의존성이 강해집니다.
기능이 많아지면,Presenter가 뚱뚱해지기때문에 분리하기가 어렵습니다.
MVC와 다르게 명확하게 컴포넌트가 구분되기 때문에, 역할에 따라 분리가 잘된 코드를 볼수 있지만, Presenter와 View가 1:1 관계를 갖고있기때문에 프로젝트가 커질수록, Presenter또한 비례해서 늘어나게 됩니다.
어떻게 하면 View와의 관계를 끊어버릴수 있을까요?
Activity에 비례해 같이 늘어나는 Presenter를 어떻게 처리하면 좋을까요?
MVVM은 presenter에 의존하지않고, Observe Pattern을 이용해 객체의 변경이 일어날때마다 UI를 갱신하도록 만들었습니다.
Observer pattern
상태 변화가 있을 때마다 메서드를 통하여 관찰 대상자가 직접 옵저버들에게 통지하여 상태를 동기화 할 수 있도록 하는 디자인 패턴을 의미합니다.
즉, 객체에 변화가 일어날때마다 콜백이 일어나게 됩니다.
MVVM의 특징은 이렇습니다.
Model
View(UI)
ViewModel
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("와 실패")
}
}
}
이 얘기 하나만으로 포스팅을 하나 작성할수 있을것같으니, 다음에 좀더 Deep하게 설명드리겠습니다.
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을 구현한다고 합니다.
MVVM에서 해결하지 못하는 문제가 제기되었습니다.
언급된것이 바로 상태 문제와 부수 효과였는데요.
상태 문제
화면에 나타나는 모든정보, 프로그레스바의 상태, 버튼 활성화를 상태라고 부르는데, 의도치않은 방향으로 상태가 제어된다면 우리는 이것을 상태 문제라고 합니다.
부수효과(side effect)
원래의 목적과 다르게 다른 효과 또는 부작용이 나는 상태를 의미합니다.
예를들어 네트워크 통신때, 데이터를 가져오는 함수는 데이터를 못가져올수도 있습니다.
function add(a, b){ a+b+c } 는 a와 b를 더한 값의 예상과 다르게 c 때문에 값이 변경될수도 있습니다.
데이터가 로딩이 되었음에도, Progress Bar가 돌고 있습니다.
MVVM으로 구현하면서 ViewModel에서 상태를 관리하게 되는데, 여러 데이터를 분산해서 관리하다보니 관리포인트가 어쩔수 없이 늘어나게 되고,
버그가 일어나고 말았습니다.
데이터의 흐름을 제어하지 못하는 원인이 문제였기 때문에, MVI는 단일 상태 관리와 단방향 데이터 흐름을 통해 MVVM의 문제점을 해결하고자 했습니다.
Model
View(UI)
Intent
MVI에 따르면, Model에서 호출되는값은 불변하기에, 예상이 가능한 값이어야 합니다.
허나 서버나 데이터베이스에서 값을 가져올경우, 우리는 이 값을 예측할 수 없습니다.
MVI는 이러한 문제를 SideEffect
를 통해 제어합니다.
MVI는 MVP, MVVM과 다르게, I가 실제 컴포넌트를 지칭하지 않습니다.
어떤구조로 만들자의 의미 보다는 어떻게 데이터와 상태의 흐름을 어떻게 다룰것이냐는 패러다임에 가깝습니다.
그래서 MVI의 수많은 예제코드를 보면 MVVM, MVP 기반으로 작성된 코드를 많이 볼수있습니다.(또한 저의 코드도 MVVM을 기반으로 만들어졌습니다.)
해당 코드는 orbit이라는 FrameWork를 통해 작성되었습니다.
MainActivityMVI
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()
}
모든것에 장점이 있고, 단점이 있기때문에 무엇을 사용해야한다는 것은 없습니다.
작은 프로젝트에 아키텍처패턴은 오버 엔지니어링일수도 있으니까요.
최신 디자인을 따르기보다는, 규모에따라 선택해야하며, 고집보다는 팀과의 협력을 생각하며 선택하는 개발자가 되셨으면 좋겠습니다.
Happy Coding <3