공부를 진행하다가 MVI Pattern에 대한 내용을 접하게 되었다.
현재 진행하고 있는 프로젝트들은 MVVM 기준으로 구조가 잡혀있기 때문에 MVI Pattern은 사용하지 않고 있었는데, 이전 아키텍처를 공부할 때 샘플을 만들어 본 이후로 MVI Pattern으로 구현을 해보지 않았다는 것을 깨닫게 되었다.
더군다나, Jetpack Compose 환경에서는 MVI Pattern을 사용한 적이 없어서 이번 기회에 간단한 샘플을 만들어 구현해보고자 하였다.
우선,
MVI Pattern이 무엇인지부터 다시 복기해 보도록 하자.
2022.04.30 - [Android/Architecture] - [Android] MVI Pattern?
필자가 2년도 더 전에 아키텍처를 공부하면서 작성했던 글이다. 자세한 설명은 해당 글을 가볍게 읽으면 알 수 있을 것이니, 이번 글에서는 간단하게 개념을 설명하고 넘어가도록 하겠다.
MVI Pattern이란,
Model - View - Intent로 구성된 디자인 패턴으로, 단방향 데이터 흐름이라는 가장 큰 특성을 갖고 있는 디자인 패턴이라고 생각하면 된다.
물론 디테일하게 설명하자면 이것저것 설명할 것이 많기는 하겠지만, 가장 간단하게 MVI Pattern에 대해 설명하는 것으로는 이만한 것이 없을 것이라고 생각한다.
Intent를 기반으로 UI가 변경되게 되는데, 이에 대한 Flow는 다음과 같다.
- View -> Intent: 사용자 액션
- Intent -> ViewModel: 상태 변경 처리
- ViewModel -> State: 새로운 상태 발행
- State -> View: UI 업데이트
22년도에 작성한 게시글에선 뭔가 인터페이스도 많고 이것저것 많았지만 그것은 가장 기본적인 개념과 가장 정석적인 구조로 사용한 것이고, 지금 샘플에서는 조금 더 간략하게 작성해보고자 한다.
UI 단에서는 단순하게 state만 구독한 상태에서 버튼을 통해 변경되는 데이터를 보여주기만 할 것이므로, 위의 flow들을 담고있는 viewModel을 작성해보도록 하자.
1번 Flow인 사용자 액션을 받아오기 위한 이벤트를 정의하도록 하자.
sealed class MVIExampleEvent {
object ButtonClicked1 : MVIExampleEvent()
object ButtonClicked2 : MVIExampleEvent()
object ButtonClicked3 : MVIExampleEvent()
object ButtonClicked4 : MVIExampleEvent()
object FetchData : MVIExampleEvent()
object NavigateBack : MVIExampleEvent()
}
4개의 버튼 클릭 이벤트, 1개의 API 통신 이벤트, 1개의 뒤로 가기 이벤트를 추가해 주었다.
한 화면에서 사용자의 액션에 의하여 데이터가 변경되는 모든 케이스를 선언해 둔 sealed Class이다.
sealed class를 사용한 이유는, 해당 클래스를 사용한 when 구문을 통해 받아올 수 있는 모든 이벤트를 컨트롤할 수 있고 이벤트가 추가되었을 때 이벤트를 추가하지 않으면 컴파일 단계에서 에러가 발생하므로 놓치는 경우를 방지할 수 있다.
즉, 다양한 장점이 있지만 그중 사용자 이벤트를 누락 없이 모두 컨트롤하기 위해서 sealed class를 사용한다고 생각해도 된다.
1번인 사용자 액션을 받았으므로, 2번인 상태 변경 처리를 해줄 차례이다.
상태 변경을 해야 하므로, 변경될 데이터 클래스를 선언해서 사용하도록 하자.
data class MVIExampleState(
val items: List<String> = listOf("Item 1", "Item 2", "Item 3", "Item 4"),
val apiData: String = "",
val counter: Int = 0
)
간단하게 사용하기 위하여 이처럼 선언하였다.
사용되는 데이터의 개수가 많아지고, 이벤트가 많아지게 되면 이벤트 별로 컨트롤하는 아이템들을 별도로 만들어서 사용할 수 있는데, 지금과 같은 경우 간단한 예제이기 때문에 그렇게까지 하지 않았다.
만약, 이벤트 별 컨트롤하는 데이터가 많은 경우 다음과 같은 방식으로 선언해서 사용하면 된다.
data class ButtonEventData(
val items: List<String> = listOf("Item 1", "Item 2", "Item 3", "Item 4"),
)
data class ApiEventData(
val apiData: String = "",
val counter: Int = 0
)
data class MVIExampleState(
val buttonEventData: ButtonEventData = ButtonEventData(),
val apiEventData: ApiEventData = ApiEventData()
)
이처럼 선언 후 MVIExampleState를 쓰되, 이벤트 별로 변경되는 데이터를 제한하여 사용해도 무관하다.
하지만 이번 예제에서는 위에 말했듯이, 간단한 예제이기 때문에 처음에 선언한 State대로 사용하도록 한다.
사용할 클래스들을 선언해 두었으니, viewModel에서 상태를 변경해 주는 코드를 작성하도록 하자.
class MVIExampleViewModel(private val fetchDataUseCase: FetchDataUseCase) : ViewModel() {
private val _state = MutableStateFlow(MVIExampleState())
val state: StateFlow<MVIExampleState> = _state
fun onEvent(event: MVIExampleEvent) {
val updatedItems = _state.value.items.toMutableList()
when (event) {
is MVIExampleEvent.ButtonClicked1 -> {
updatedItems[0] = "Item 1 Changed!"
}
is MVIExampleEvent.ButtonClicked2 -> {
updatedItems[1] = "Item 2 Changed!"
}
is MVIExampleEvent.ButtonClicked3 -> {
updatedItems[2] = "Item 3 Changed!"
}
is MVIExampleEvent.ButtonClicked4 -> {
updatedItems[3] = "Item 4 Changed!"
}
is MVIExampleEvent.FetchData -> {
val newCounter = _state.value.counter + 1
val data = fetchDataUseCase.execute()
_state.value = _state.value.copy(
apiData = "$data - Count: $newCounter",
counter = newCounter
)
}
is MVIExampleEvent.NavigateBack -> { ... }
}
_state.value = _state.value.copy(items = updatedItems)
}
}
우선 아주 간단하게 코드를 만들어 보았다.
onEvent를 통해 MVIExampleEvent를 받고, 받은 Event에 따라 별개의 처리를 해주도록 하였다.
우선 중복된 코드도 있고 비효율적인 부분이 있지만 그 부분은 넘어가고 어떻게 사용되고 있는지에 대해 확인해 보자.
viewModel의 코드는 아주 간단하다. event를 UI단에서 구독해야 하기 때문에 flow를 하나 선언해 두고,
when절을 통해 모든 이벤트 케이스에 따른 상태 변경을 진행해 주었다.
api 호출하는 부분은 실제로 api를 호출하지는 않고, clean architecture 구조를 사용하여 data, domain 영역에 각각 repository와 구현체를 선언해 두고, 데이터를 가져오는 부분은 하드코딩 된 값을 가져오도록 구현해 두었다.
해당 부분은 MVI 패턴에서 중요하지 않으므로, 글의 끝에 걸어둘 예제 링크를 확인하면 될 것이다.
또한, 일단 해당 부분에서 navigateBack 부분은 구현하지 않았기 때문에 빈 값으로 두었음을 전달하고 넘어가도록 한다.
viewModel의 기본형이 나왔으니, UI단을 확인해 보도록 하자.
@Composable
fun MVIExampleUI(onBackEvent: () -> Unit) {
val viewModel: MVIExampleViewModel = koinViewModel()
val state by viewModel.state.collectAsState()
Column(modifier = Modifier.fillMaxSize()) {
MainHeader(title = "MVI Example", onBackIconClicked = onBackEvent)
Spacer(modifier = Modifier.height(16.dp))
state.items.forEachIndexed { index, item ->
Text(text = "Current State: $item")
Spacer(modifier = Modifier.height(8.dp))
}
Button(onClick = { viewModel.onEvent(MVIExampleEvent.ButtonClicked1) }) {
Text("Change Item 1")
}
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { viewModel.onEvent(MVIExampleEvent.ButtonClicked2) }) {
Text("Change Item 2")
}
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { viewModel.onEvent(MVIExampleEvent.ButtonClicked3) }) {
Text("Change Item 3")
}
Spacer(modifier = Modifier.height(8.dp))
Button(onClick = { viewModel.onEvent(MVIExampleEvent.ButtonClicked4) }) {
Text("Change Item 4")
}
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { viewModel.onEvent(MVIExampleEvent.FetchData) }) {
Text("Fetch Data")
}
Spacer(modifier = Modifier.height(8.dp))
Text(text = "API Data: ${state.apiData}")
}
}
아주 간단하게 viewModel에 선언해 둔 state를 구독하고, 버튼을 클릭했을 때 viewModel.onEvent 함수를 호출하여 state를 갱신해 주도록 하였다.
이렇게 구현을 해두면, 가장 기본적인 구조의 MVI Pattern를 만족하는 구조가 완성이 된다.
해당 구조에서 유저 flow는 다음과 같다.
- 버튼 클릭으로 사용자 액션 발생 : View -> MVIExampleEvent
- 사용자 액션에 따른 상태 변경 처리 : MVIExampleEvent -> viewModel.onEvent
- 이벤트에 따른 상태 변경 : viewModel.onEvent -> when 구문으로 인한 data update
- 구독하고 있는 state 변경으로 인한 UI 갱신 : _state.value 업데이트 -> state 변경 -> UI 갱신.
맨 처음에 작성한 MVI Pattern의 flow와 동일하게 작성이 되었다.
그런데, 여기서 MVI pattern에서 확인할 수 있는 SideEffect 도 존재하지 않고, 함수 자체도 중복되는 부분이 있는 등 고도화가 전혀 되지 않았다.
이런 부분들을 다시 한번 개선해 보도록 하자.
우선 추가해야 할 부분은 SideEffect 부분이다.
SideEffect는 주로 UI에서 일회성으로 처리해야 하는 이벤트를 정의하는 데 사용되거나, 사용자에 대한 즉각적인 피드백, 내비게이션 같은 작업을 수행할 때 사용한다.
즉, 현재 화면에서 발생할 수 있는 SideEffect는 뒤로 가기 버튼을 눌렀을 때 Navigation 하는 부분 밖에 존재하지 않는다.
하지만 그러면 샘플이 될 수 없기 때문에, 사용자에 대한 피드백인 toast 메세지를 추가해줘서 작업해보도록 하겠다.
sealed class MVISideEffect {
data class ShowToast(val message: String) : MVISideEffect()
data class ShowError(val error: String) : MVISideEffect()
object NavigateBack : MVISideEffect()
}
이와 같이 SideEffect에 관련된 sealed class를 선언해 두도록 하자.
그리고 viewModel에서 sideEffect를 저장할 변수를 선언하자.
private val _sideEffect = Channel<MVISideEffect>()
val sideEffect = _sideEffect.receiveAsFlow()
이곳에서 flow를 사용하여 데이터를 저장 및 구독하여 사용하지 않고,
Channel을 사용하여 데이터를 저장하고 receiveAsFlow를 사용해 Flow형태로 바꾸어 구독을 시키는 것을 볼 수 있는데, 이렇게 구현한 이유는 다음과 같다.
SideEffect라는 이벤트 자체가 앞서 작성했다시피 일회성 이벤트를 처리할 때 사용하는 이벤트들의 모음이다.
Flow는 데이터 스트림을 처리하는데 적합하지만 channel은 일회성 이벤트를 처리하는데 적합하다.
또한, receiveAsFlow를 통해 channel을 flow로 변환하여 사용하는데, 이렇게 변환함으로써 flow의 연산자, 구독했을 때만 이벤트를 처리할 수 있는 특성을 이용할 수 있으며 flow를 UI에 바인딩하여 사용하는 기존의 방식을 그대로 동일하게 사용할 수 있게 해준다.
이 상태에서 아까 작업하지 않았던 MVIExampleEvent.NavigateBack을
is MVIExampleEvent.NavigateBack -> sendEffect(MVISideEffect.NavigateBack)
이처럼 선언해 주고,
private fun sendEffect(effect: MVISideEffect) {
viewModelScope.launch {
_sideEffect.send(effect)
}
}
이와 같이 sideEffect를 전달하는 함수를 별도로 만들어 사용하도록 한다.
SideEffect까지 추가를 했으므로 다시 UI단에서 해당 데이터를 구독하고, 데이터를 사용하기 위한 작업을 추가하도록 하자.
val state by viewModel.state.collectAsState()
val context = LocalContext.current
// SideEffect를 구독
LaunchedEffect(Unit) {
viewModel.sideEffect.collect { effect ->
when (effect) {
is MVISideEffect.ShowToast -> {
Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
}
is MVISideEffect.ShowError -> {
Toast.makeText(context, effect.error, Toast.LENGTH_LONG).show()
}
MVISideEffect.NavigateBack -> onBackEvent()
}
}
}
...
MainHeader(title = "MVI Example", onBackIconClicked = {
viewModel.onEvent(MVIExampleEvent.NavigateBack)
})
LaunchedEffect를 사용하여 viewModel에 있는 sideEffect를 구독하여 사용하도록 하였다.
LaunchedEffect를 사용하게 되면, 비동기 작업도 별다른 코루틴의 추가 없이 처리할 수 있을뿐더러 main thread에서 동작하기 때문에 선언되는 작업들을 안전하게 수행시킬 수 있다. 또한, recompositon이 발생할 때 알아서 구독을 끊고 새롭게 만들어 주기 때문에 메모리 누수의 걱정도 없다.
navigateBackEvent의 경우 MVIExampleEvent에도 선언되어 있으므로, onEvent를 통해서 해당 Evnet를 수행하여 Intent를 전달하고, 그 전달된 Intent로 인해 SideEffect가 수행, SideEffect의 수행 결과로 UI가 변경되는 Flow를 타게 된다.
즉, 위에 선언한 MVI flow에서 3번과 4번 사이에 SideEffect에 대한 처리가 추가적으로 들어간다고 생각하면 될 것이다.
이것으로 SideEffect까지 포함된 간단한 Compose 환경에서 구축하는 MVI Pattern에 대하여 알아보았다.
그렇게 복잡한 예제는 아니지만, 간단하게 MVI Pattern의 특징을 살려서 예제를 만들어 보았다.
Compose를 사용하기 때문에 Flow를 통해 쉽게 변경되는 상태를 구독할 수 있고, 이것을 통해서 보다 쉽게 MVI를 구현할 수 있는 것 같다.
하나의 State를 통해 한 화면 전체를 갱신한다는 것 자체가 MVVM을 사용하면서 생각해보지 못했던 것인데, 이렇게 State를 직접 선언해서 변경되는 데이터만 copy 키워드를 통해 변경하여 recomposition을 최소화할 수 있다는 게 좋았고,
무엇보다 단방향 데이터 흐름으로 얻을 수 있는 데이터의 안정성에 대해 확실히 인지할 수 있었다.
아직 익숙지 않아 MVI 구조로 예제를 구현할 때 어떤 형태로 데이터 클래스를 나눠야 할지 고민을 많이 했는데, 앞으로 예제를 만들 때 해당 구조를 좀 많이 사용해 보면서 익숙해져야 실제로 프로젝트를 진행할 때도 MVI를 자연스럽게 사용할 수 있을 것 같다.
또한, 지금까지 진행했던 프로젝트는 대부분 MVVM으로 되어있는데, MVI도 섞어서 개발을 한다면 상황에 맞춰 보다 더 안정적인 구조로 개발을 할 수 있을 것이라고 생각한다.
해당 게시글에 사용한 예제는 Github에 올려두었다.
'Android > Architecture' 카테고리의 다른 글
[Android] Single Activity Architecture (SAA) + Navigation - 이슈 해결 (0) | 2022.07.08 |
---|---|
[Android] Single Activity Architecture (SAA) + Navigation (3) | 2022.07.04 |
[Android] MVP Pattern을 적용해보자. (0) | 2022.05.05 |
[Android] MVI Pattern을 적용해보자. (2) | 2022.05.03 |
[Android] MVI Pattern ? (0) | 2022.04.30 |