[Android] MVI 패턴이란?
MVVM 패턴 이후 MVI라는 새 패턴이 등장했다. MVI 패턴을 다룬 글은 2019년에도 있어서 등장한지 꽤 오래됐다고 생각된다.
각설하고 MVI 패턴은 무엇인지 먼저 확인한다.
https://medium.com/@mohammedkhudair57/mvi-architecture-pattern-in-android-0046bf9b8a2e
MVI(모델-뷰-인텐트) 아키텍처는 앱을 3가지 주요 컴포넌트로 나눠서 깔끔한 코드, 명확한 관심사 분리를 강조한다. 이런 단방향 데이터 흐름과 뚜렷한 역할은 앱을 더 쉽게 이해하고 빌드, 유지보수하는 데 기여한다. 또한 이 구조는 예측 가능성과 테스트 가능성을 향상시켜 개발이 더 원활해지고 버그가 줄어든다
< 주요 기능 >
- 단방향 데이터 흐름 : 데이터가 모델 -> 뷰 -> 인텐트라는 한 방향으로 흐른다는 의미다. 이를 통해 아키텍처의 명확성, 예측 가능성, 유지보수 용이성을 보장한다
- 관심사 분리 : 각 컴포넌트의 고유 역할을 의미한다. 모델은 상태를 관리하고 뷰는 UI 렌더링을 처리하며 인텐트는 사용자 작업을 캡쳐, 전달한다
- 불변성 : 한 번 설정된 모델의 상태가 바뀌지 않게 보장한다. 이를 통해 예측 가능서을 보장하고 예상치 못한 부작용을 제거하며 안정적이고 신뢰할 수 있는 앱 상태를 촉진한다
< 구성요소 >
- 모델 : 앱의 모든 데이터, 로직을 갖는다. 직접 바뀌지 않고 새 상태를 생성해 업데이트된다
- 뷰 : 비즈니스 로직을 처리하지 않고 앱 상태를 유저에게 표시한다. 모델의 상태 변경에 따라 업데이트된다
- 인텐트 : 버튼 클릭, 텍스트 입력 같은 유저 작업 or 앱 자체를 나타내며 유저가 앱에서 수행하려는 작업에 대한 것이다. 뷰는 이런 인텐트를 파악해서 모델로 전송하고, 모델은 앱 상태 업데이트 같은 작업을 수행한다
< MVI는 어떻게 작동하는가? >
데이터는 항상 유저로부터 시작해서 인텐트를 통해 유저에게 전달된다. 이 반대일 수는 없어서 단방향 아키텍처라고 한다. 유저가 한 번 더 작업을 수행하면 같은 사이클이 반복되므로 순환형 아키텍처다...(중략)
https://munseong.dev/android/mvi_architecture/
MVVM 패턴은...(중략)...충분히 이상적인 패턴이지만 화면에 대한 요구사항이 커지고 상태가 늘어남에 따라 복잡한 데이터 흐름, 상태 충돌, 쓰레드 안전성이란 문제에 대해 한계가 존재한다. 데이터 바인딩으로 뷰가 데이터를 구독할 수 있지만 뷰모델에서의 데이터 바인딩, 뷰 안에서 스스로 바인딩하는 경우가 있고 뷰와 뷰모델 간의 핑퐁으로 로직을 처리하면 복잡한 데이터 흐름으로 파악하기 힘들 때도 있었다. 즉 뷰, 뷰모델의 양방향 참조가 가능해 생기는 문제가 있다
데이터 흐름을 제어하지 못하는 게 문제였기 때문에 MVI는 단일 상태 관리, 단방향 데이터 흐름을 통해 MVVM의 문제를 해결하려고 했다. UI에서 뷰모델로의 직접적 호출이 아니라 인텐트에 기반하기 때문에 좀 더 느슨한 결합을 유지하게 된다는 장점을 느꼈다. UI는 이벤트를 던지고 상태를 받기만 하면 된다...(중략)
다른 곳에서도 위 내용들에서 크게 벗어나는 설명은 하지 않는다.
MVI 패턴이 무엇인지와 특징을 정리하면 아래와 같다.
- 모델, 뷰, 인텐트의 3가지 컴포넌트를 사용하는 아키텍처 패턴이다
- 양방향 데이터 흐름이 가능한 MVVM과 달리 단방향 데이터 흐름 형태로 데이터가 이동한다
뷰모델 내용이 없다 해서 MVI 패턴에서 뷰모델을 쓰지 않는 것은 아니다.
MVI 패턴에서도 뷰모델은 사용되고, 컴포즈에서만 사용할 수 있는 패턴이 아닌 액티비티, 프래그먼트를 사용하는 XML 프로젝트에서도 충분히 적용할 수 있는 패턴이다.
이제 컴포즈에서 MVI 패턴을 어떻게 적용하는지 확인한다.
버튼을 누르면 숫자를 하나씩 올리거나 내릴 수 있다.
sealed class CounterIntent {
data object Increment: CounterIntent()
data object Decrement: CounterIntent()
}
먼저 숫자의 증가, 감소 인텐트를 정의해야 한다. 보통 사용되는 형태는 sealed class 안에 object class를 만들어서 인텐트들을 하나씩 정의하는 것이다.
앞서 말했듯 숫자의 증감만 수행하는 앱을 만들 것이기 때문에 저 2가지만 정의한다.
그럼 왜 sealed class일까? sealed class는 닫혀 있는 서브클래스들의 집합을 정의하기 위해 사용된다.
정의된 서브클래스들 이외의 서브클래스는 생성될 수 없고, sealed class가 정의된 파일 안에서만 서브클래스를 정의할 수 있다. 다른 파일에선 추가할 수 없다.
그리고 when을 통해 가능한 모든 경우를 처리하도록 강제된다. 예를 들어 Decrement 인텐트를 받은 경우의 로직을 빼먹으면 컴파일 에러가 발생한다. 코틀린 공식문서에서도 when을 사용하는 시나리오에 가장 적합하다고 표현하고 있다.
https://kotlinlang.org/docs/sealed-classes.html
data class CounterState(
val count: Int = 0
)
MVI 패턴에서 State는 결국 현재 UI의 상태를 의미한다. UI의 상태는 카운트밖에 없으므로 CounterState라는 data class를 만들어서 증감하는 카운트 값을 담아둔다.
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
class CounterViewModel: ViewModel() {
private val _counterState: MutableStateFlow<CounterState> = MutableStateFlow(CounterState())
val counterState: StateFlow<CounterState> = _counterState.asStateFlow()
fun handleIntent(intent: CounterIntent) = viewModelScope.launch {
when (intent) {
is CounterIntent.Increment -> incrementCount()
is CounterIntent.Decrement -> decrementCount()
}
}
private fun incrementCount() {
_counterState.value = _counterState.value.copy(
count = _counterState.value.count + 1
)
}
private fun decrementCount() {
_counterState.value = _counterState.value.copy(
count = _counterState.value.count - 1
)
}
}
이제 뷰모델을 구현할 차례다. 전달받은 인텐트에 따라 증가, 감소 함수를 호출하는 handleIntent()를 선언한다.
그 외에 특이한 것은 copy()를 사용하는 것이다. copy()를 사용하는 궁극적인 이유는 아래와 같다.
- CounterState는 불변 객체로 정의됐기 때문에 상태 변경 시마다 새 객체를 만들어서 기존 객체는 바뀌지 않게 한다
- data class를 정의하면 copy()를 자동 생성한다. 이를 통해 일부 속성만 바뀐 새 객체를 생성할 수 있다
이를 통해 앱의 안전성을 보장할 수 있고 상태가 중구난방으로 바뀌는 것을 막아서 디버깅이 조금이라도 더 편해지고, 발생할 수 있는 사이드 이펙트를 막는 효과도 얻을 수 있다.
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.activity.viewModels
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.example.testapplication.ui.theme.TestApplicationTheme
class MainActivity : ComponentActivity() {
private val counterViewModel: CounterViewModel by viewModels()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
TestApplicationTheme {
Surface {
CounterScreen(viewModel = counterViewModel)
}
}
}
}
}
@Composable
fun CounterScreen(
viewModel: CounterViewModel
) {
val state: CounterState by viewModel.counterState.collectAsState()
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = "Count : ${state.count}",
style = MaterialTheme.typography.headlineLarge
)
Spacer(modifier = Modifier.height(16.dp))
Row {
Button(onClick = {
viewModel.handleIntent(CounterIntent.Increment)
}) {
Text(text = "+1 증가")
}
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = {
viewModel.handleIntent(CounterIntent.Decrement)
}) {
Text(text = "-1 감소")
}
}
}
}
단순하게 카운트를 표시하는 Text와 증가, 감소 버튼만 중앙 정렬돼 표시되는 간단한 화면이다.
생성한 뷰모델을 CounterScreen()의 매개변수로 넘겨서 받아온 다음, counterState에 접근해서 collectAsState()를 통해 StateFlow에서 최신 값을 가져온다.
아래는 collectAsState()를 다룬 안드로이드 디벨로퍼 링크들이다.
이 StateFlow에서 값을 collect하고 State를 통해 최신 값을 나타낸다. StateFlow.value는 초기값으로 쓰인다. StateFlow에 새 값이 게시될 때마다 리턴된 State가 업데이트되서 모든 State.value 사용이 재구성된다
collectAsState()도 Flow에서 값을 수집해 Compose State로 바꾼다는 점에서 collectAsStateWithLifecycle()과 유사하다. 플랫폼 제약이 없는 코드에선 안드로이드 전용인 collectAsState()를 써라. collectAsState()는 compose-runtime에서 사용할 수 있어서 추가 종속 항목이 필요없다
collectAsState()로 StateFlow의 최신 값을 가져온 다음 Text에 표시한다. 값의 변경은 당연히 버튼이 눌릴 때마다 즉각적으로 반영되고, 화면을 회전해도 변하지 않는다.
다음은 XML을 사용하는 액티비티에서의 구현이다.
<?xml version="1.0" encoding="utf-8"?>
<layout xmlns:android="http://schemas.android.com/apk/res/android">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/tvCount"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="24sp" />
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:orientation="horizontal">
<Button
android:id="@+id/btnIncrement"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="Increment" />
<Button
android:id="@+id/btnDecrement"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:text="Decrement" />
</LinearLayout>
</LinearLayout>
</layout>
import android.os.Bundle
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.databinding.DataBindingUtil
import androidx.lifecycle.lifecycleScope
import com.example.testapplication.databinding.ActivitySecondBinding
import kotlinx.coroutines.launch
class SecondActivity : AppCompatActivity() {
private val counterViewModel: CounterViewModel by viewModels()
lateinit var binding: ActivitySecondBinding
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = DataBindingUtil.setContentView(this, R.layout.activity_second)
lifecycleScope.launch {
counterViewModel.counterState.collect { state ->
binding.tvCount.text = state.count.toString()
}
}
binding.run {
btnIncrement.setOnClickListener { counterViewModel.handleIntent(CounterIntent.Increment) }
btnDecrement.setOnClickListener { counterViewModel.handleIntent(CounterIntent.Decrement) }
}
}
}
XML을 사용하는 액티비티라고 특별하게 다른 건 없다. 뷰 바인딩만 사용했지만 데이터 바인딩을 사용한다면 더 간단하게 구현할 수 있다.