
기존에는 MVP 패턴을 사용해 Android 앱을 구현 했지만
차세대 프로젝트 진행에 따라 MVI 패턴 적용으로 해당 패턴 학습을 진행 해보려한다.
제일 먼저 MVI 를 사용하면 어떤 장점이 있을까?
MVI 패턴의 핵심 키워드는 "단방향" 이라고 생각된다.
필자의 경우 실제로 회사에서 프로젝트를 진행 하다보면
다양한 "상태"를 관리할 일이 다수 있었다.
Android 월패드와 조명 등 다양한 스마트 홈 기기들이 서로 통신을 주고 받을 때 무수한 콜백의 무덤으로 인해 로딩이 끝났는데 로딩 애니메이션이 재생되거나 하는 등의 문제가 발생할 때도 있었다.
하지만 MVI 디자인 패턴을 적용하면 이런 비동기적인 상황에서 상태를 예측하고, 대응 할 수 있다!

MVI를 검색하면 제일 많이 보이는 형태의 이미지이다.
하지만, Android 개발자 입장에서 봤을 때 용어가 다소 혼동이 올 수 있다.
가령 예를 들어 해당 이미지에 포함된 Intent는 우리가 알고있는 Android Intent가 아니다.
일단 용어에 대해서 천천히 알아보자.
User는 일종의 액션을 만드는 주체이다.
액션은 뭘까?액션은 사용자의 "버튼의 클릭, 텍스트의 입력" 등 다양한 행동을 뜻한다.
Intent는 User의 액션을 보다 명시적으로 만든 단계라고 생각하면 쉽다.
아래 예시를 살펴보자
- "User는 데이터 호출 버튼을 클릭할 거야." - 액션1
- "User는 친구 삭제 버튼을 클릭할 거야." - 액션2
- "User는 데이터 리셋 버튼을 클릭할 거야." - 액션3
SideEffect는 기존 4 방향으로 순환 되는 단방향 Flow에 의도치 않은 부가적인 상황을 나타낼 수 있다.
예를 들어, List data를 불러오고, Toast Message를 띄워야한다면?
Intent에 "List Error Toast Message Event + Message" 와 같이 모든 상황을 선언할 수는 없을 것이다.이런, 부수적인 상황을 MVI 패턴에서 보강 해주기 위해 사용되는 요소라고 생각하면 쉽다.
사용자의 액션을 통해 데이터를 가져오고 변경 해주는 역할을 한다.
예를 들어, 사용자의 데이터 호출 버튼 이벤트가 Model에 전달 되면, Model은 데이터를 실제로 받아와 상태를 변경한다.
우리가 가장 친근하게 생각하는 요소 중 하나이다.
말 그대로 사용자에게 직접적으로 보여지는 화면으로 Intent를 입력 받고,
그에 따른 결과를 보여주는 부분이다.
MVI를 보다 자세하게 시작 하기에 앞서 단방향성과 상태에 대해 알아보자.
단방향성?

위 그림은 실제로 MVI 패턴을 구현하고, 사용 했을 때 필자가 느꼈던 내용을 시각적으로
그려보았다.
아래와 같은 흐름을 가진다.
- View에서 사용자(User)의 입력을 받는다.
- User의 액션을 Model에 전달한다.
- Model은 전달 받은 액션에 따라 State를 전달한다.
- State는 Model에게 전달 받은 State에 맞게 갱신된다.
- View는 State가 변경되면 View를 최신화 시킨다.
그럼 반대로 흐름이 역행할 수 있을까?
1번과 2번을 다시 생각해보면 답을 쉽게 구해볼 수 있다.
- 사용자가 View에 입력을 한다.
- 사용자가 View에 손을 집어 넣어 다시 입력한 액션을 수거한다 ??
실제로 일어날 수 없는 일이다.
그럼 Model에서 유저의 액션을 선택 할 수 있을까?
이것 또한, 일어날 수 없는 일이다.
이처럼 MVI 패턴을 적용하고, User의 액션을 명시적으로 처리함으로써
올바른 단방향성이 유지가 될 수 있다.
상태는 영어로 State가 된다.
State는 다양하게 적용될 수 있다.
앱의 상태, 데이터의 상태, 통신 상태 등 다양한 범위에 광범위하게 사용된다.
하지만, 위 표를 기준으로 설명 해본다면 사용자의 액션에 따른 결과라고 정리할 수 있을 것 같다.
지금부터는 우리가 위에서 알아본 내용을 코드로 바꿔보며 MVI 패턴을 만들어보자.
제일 먼저, 액션을 정의해보자.
"Lee" 라는 유저가 친구 리스트를 호출 하는 상황이고, 앱의 기능은 아래와 같다.
- 데이터 호출
- 친구 리스트 삭제
- 데이터 리셋
다음과 같이 정의해볼 수 있다.
sealed interface MainActivityEvent {
data object callUserData : MainActivityEvent
class removeFriendData(val name : String) : MainActivityEvent
data object resetUserData : MainActivityEvent
}
- 데이터를 호출 하는 callUserData ------------------------------ 사용자의 데이터 호출 클릭 액션
- name을 인자 값으로 받아, remove 해주는 removeFriendData -- 사용자의 친구 리스트 삭제 액션
- 데이터를 초기화 하는 resetUserData ----------------------------- 사용자의 데이터 리셋 클릭 액션
위와 같이 3가지 인텐트(액션)을 정의해보았으니, 다음은 상태를 정의해보자.
상태는 사용자의 호출/삭제/리셋에 대한 UserData 일 것이다.
아래와 같이 정의 해보자.
data class UserState(val userName: String? = null, val outLookList: List<String> = emptyList())
자 이제, MVI에서 가장 중요한 상태와, 인텐트의 정의가 끝났다.
그럼, 해당 인텐트를 받아 처리하는 viewModel과 model을 작성해보자.
class MainModel {
fun getOutLookList() : UserState {
return UserState("Lee", listOf("친구1", "친구2", "친구3", "친구4"))
}
}
실제로 데이터 호출 로직이 적용되면 보다 복잡한 코드가 작성되겠지만, 우리는 기술적인 요소가 아닌 디자인 패턴의 본질적 경험을 위해 getOutLookList()가 호출 되면, 바로 State가 리턴되도록 구성 해보았다.
class MainViewModel : ViewModel() {
private val mainModel = MainModel()
/**
* 데이터 호출을 위한 MainModel() 생성.
**/
private val mainUserEventChannel = Channel<MainActivityEvent>()
private val sideEffectChannel = Channel<Boolean>()
/**
* 사용자의 인탠트를 처리하기 전 대기 큐 Channel
* 해당 방식으로 구현 했을 경우 사용자의 여러 입력을 Channel로 관리해 입력의 누락 등을 방지 할 수 있음.
**/
val state: StateFlow<UserState> = mainUserEventChannel.receiveAsFlow()
.runningFold(UserState(), ::reducer)
.stateIn(viewModelScope, SharingStarted.Eagerly, UserState())
val sideEffect = sideEffectChannel.receiveAsFlow()
/**
* 유저의 Intent를 Flow로 받아, redcuer 함수를 통해 관리.
* SideEffect 또한, sideEffectChannel을 통해 Flow로 받아 관리.
**/
private fun reducer(current: UserState, event: MainActivityEvent): UserState {
return when (event) {
MainActivityEvent.callUserData -> current.copy(
mainModel.getOutLookList().userName,
mainModel.getOutLookList().outLookList
)
MainActivityEvent.resetUserData -> current.copy("", emptyList())
is MainActivityEvent.removeFriendData -> current.copy(event.name, emptyList())
}
}
/**
* Thread Safe 및 state 접근 범위 지정 등을 개선한 Redcuer
* Reducer는 현재의 상태와 전달 받은 이벤트를 참고해 새로운 상태를 만드는 것을 말한다.
**/
fun getUserData() {
viewModelScope.launch {
sideEffectChannel.send(true)
delay(3000)
mainUserEventChannel.send(MainActivityEvent.callUserData)
sideEffectChannel.send(false)
}
}
fun resetUserData() {
viewModelScope.launch {
sideEffectChannel.send(true)
delay(3000)
mainUserEventChannel.send(MainActivityEvent.resetUserData)
sideEffectChannel.send(false)
}
}
fun removeFriendData(name: String) {
viewModelScope.launch {
sideEffectChannel.send(true)
delay(3000)
mainUserEventChannel.send(MainActivityEvent.removeFriendData(name))
sideEffectChannel.send(false)
}
}
/** 유저 행동에 따른 함수 정의 및 흐름 정의 **/
}
위 코드를 통해 주석으로 간략하게 설명을 기록해 놓았다.
글 시작 전에 MVI를 사용하면 상태를 예측할 수 있다고 적어놓았다.
우리는 어떻게 상태를 예측할 수 있을까?
함수 하나를 분해해보자.
fun removeFriendData(name: String) {
viewModelScope.launch {
sideEffectChannel.send(true)
delay(3000)
mainUserEventChannel.send(MainActivityEvent.removeFriendData(name))
sideEffectChannel.send(false)
}
}
사용자가 만약 친구 정보 삭제 버튼을 눌렀을 때 어떤 과정이 일어날까?
- sideEffectChannel에 true를 전송 < 차후 로딩 boolean 변수로 사용됨 >
- delay가 3초간 발생.
- mainUserEventChannel에 removeFrinedData(name) 이벤트 전송.
- reducer() 함수 안에
-> is mainActivityEvent.removeFriendData -> current.copy(event.name, emptyList())로 인해 데이터 삭제.- sideEffectChannel에 false 전송.
위와 같이 나열 할 수 있다.
만약, 해당 함수에서 데이터가 안불러와지거나, 로딩 애니메이션이 정상 작동 하지 않으면
우리는 어떤 부분을 살펴봐야할지 직관적으로 확인하고 예측할 수 있는것이다.
필자는 JetPack Compose를 사용해 View를 구성하고 사용했다.
아래 코드를 확인해보자!
class MainActivity : ComponentActivity() {
private val mainViewModel = MainViewModel()
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
MyMVI_ExampleTheme {
Scaffold(modifier = Modifier.fillMaxSize()) { innerPadding ->
Column {
UserDataView()
GetDataButton()
RemoveDataButton("Lee")
ResetDataButton()
}
}
}
}
}
@Composable
fun LoadingView() {
CircularProgressIndicator(modifier = Modifier.size(120.dp))
}
@Composable
fun UserDataView() {
val userData = mainViewModel.state.collectAsState()
val loadingState = mainViewModel.sideEffect.collectAsState(false)
Spacer(modifier = Modifier.padding(top = 100.dp))
Text("user Name : " + userData.value.userName)
Spacer(modifier = Modifier.padding(top = 50.dp))
Text("friend List:")
Row (Modifier.height(120.dp)){
if (loadingState.value) {
LoadingView()
} else {
LazyColumn {
items(userData.value.outLookList.size) { index ->
Text(userData.value.outLookList[index])
}
}
}
}
}
@Composable
fun GetDataButton() {
Button(onClick = { mainViewModel.getUserData() }) {
Text("데이터 호출 하기")
}
}
@Composable
fun RemoveDataButton(name : String) {
Button(onClick = { mainViewModel.removeFriendData(name) }) {
Text("친구 삭제 하기")
}
}
@Composable
fun ResetDataButton() {
Button(onClick = { mainViewModel.resetUserData() }) {
Text("데이터 리셋 하기")
}
}
}
위와 같이 간단한 View를 구성하고 버튼을 눌렀을 때 적절하게 MVI 패턴이 작동하며,
데이터가 갱신됨을 확인할 수 있다.

MVI를 아직 100% 이해하진 못 했지만, 비동기에 매우 강력한 패턴인 것 같다.
또한, 차후 학습을 통해 다양한 상태에 대해 유연하게 대처함으로써 보다 사용성 높은 앱을 만들 수 있을 것 같다.

긴 글 읽어주셔서 감사합니다.
틀린 내용은 언제든지 댓글로 피드백 해주세요!감사합니다.
Reference