
사내에서 MVI를 같이 볼 일이 생겼다. 처음에는 State, Intent, Reducer, Effect 같은 이름을 먼저 정리하면 되겠다고 생각했다. 그런데 막상 작은 샘플 코드를 놓고 보니, MVI보다 먼저 막히는 부분이 따로 있었다.
var state by remember { mutableStateOf(CounterState()) }
이 한 줄부터 낯설 수 있다. remember는 무엇이고, mutableStateOf는 왜 쓰고, by는 어디서 나온 문법인지 모르면 그 다음에 나오는 copy, reduce, onIntent도 자연스럽게 이어지지 않는다.
그래서 이 글은 MVI를 거창하게 설명하는 글은 아니다. 아주 작은 Counter 샘플을 보면서, Compose 상태 코드가 어떻게 움직이고 그 위에 MVI 이름을 어떻게 붙일 수 있는지 정리한 스터디 메모에 가깝다.
샘플 코드는 GitHub에 올려두었다.
먼저 전체 흐름만 보기
이번 샘플은 숫자를 하나 보여준다. +1을 누르면 숫자가 올라가고, -1을 누르면 내려가고, Reset을 누르면 다시 0으로 돌아간다.
중요한 점은 버튼이 직접 count++를 하지 않는다는 것이다.
UI -> Intent -> reduce -> State -> UI
조금 풀어 쓰면 이렇게 볼 수 있다.
- 사용자가 버튼을 누른다.
- UI는
CounterIntent를 보낸다. reduce함수가 현재CounterState와CounterIntent를 받는다.reduce함수가 새로운CounterState를 만든다.- Compose가 새
CounterState를 보고 화면을 다시 그린다.
처음에는 이 흐름만 잡아도 충분하다. MVI에서 말하는 "단방향 흐름"은 결국 상태가 여기저기서 막 바뀌지 않고, 한 방향으로 읽히게 만들자는 이야기다.
샘플에서 먼저 볼 코드
샘플의 핵심 코드는 거의 MainActivity.kt 한 파일에 있다.
처음에는 아래 네 개만 보면 된다.
data class CounterState(...)
sealed interface CounterIntent
private fun reduce(...)
@Composable
private fun CounterScreen(...)
이 네 개가 이번 글의 전부라고 생각해도 된다.
1. State는 화면의 현재 값이다
먼저 CounterState다.
data class CounterState(
val count: Int = 0,
val lastIntent: String = "아직 버튼을 누르지 않았습니다.",
)
State는 화면이 지금 어떤 모습이어야 하는지 담는 값이다.
이 샘플에서는 화면에 보여줄 값이 두 개뿐이다.
count: 현재 숫자lastIntent: 마지막으로 들어온 행동 이름
즉 화면에 count = 0이 보여야 하는지, count = 1이 보여야 하는지는 CounterState가 결정한다.
여기서 중요한 건 CounterState가 "서버에서 받은 데이터 모델"이 아니라는 점이다. 이 샘플에서는 서버가 없지만, 실무에서도 UiState나 State는 보통 화면을 그리기 위한 현재 값에 가깝다.
2. remember와 mutableStateOf는 상태를 기억하는 상자다
이제 처음에 막혔던 줄을 다시 보자.
var state by remember { mutableStateOf(CounterState()) }
이 코드는 한 번에 읽으려고 하면 어렵다. 나눠서 보면 조금 낫다.
먼저 CounterState()는 처음 상태다.
CounterState()
기본값을 쓰기 때문에 처음에는 이런 상태가 된다.
CounterState(
count = 0,
lastIntent = "아직 버튼을 누르지 않았습니다.",
)
그 다음 mutableStateOf는 Compose가 지켜볼 수 있는 상태 상자를 만든다.
mutableStateOf(CounterState())
이 상자 안의 값이 바뀌면 Compose는 "아, 화면을 다시 그려야겠구나"라고 알 수 있다.
그런데 Compose 화면은 상태가 바뀌면 다시 실행될 수 있다. 이걸 recomposition이라고 부른다. 화면 함수가 다시 실행될 때마다 상태 상자를 새로 만들면 숫자가 계속 초기화될 것이다.
그래서 remember를 쓴다.
remember { mutableStateOf(CounterState()) }
remember는 아주 편하게 말하면 "이 값을 화면이 다시 그려져도 기억해줘"에 가깝다.
마지막으로 by는 Kotlin 문법이다.
var state by remember { mutableStateOf(CounterState()) }
by를 쓰지 않으면 대략 이런 식으로 읽을 수 있다.
val stateBox = remember { mutableStateOf(CounterState()) }
stateBox.value = CounterState(count = 1)
Text(stateBox.value.count.toString())
by를 쓰면 .value를 매번 쓰지 않아도 된다.
var state by remember { mutableStateOf(CounterState()) }
state = CounterState(count = 1)
Text(state.count.toString())
처음 볼 때는 by가 MVI 문법처럼 보일 수 있지만, 사실은 Kotlin과 Compose 상태를 편하게 쓰기 위한 문법이다.
3. Intent는 사용자의 행동을 이름 붙인 것이다
다음은 CounterIntent다.
sealed interface CounterIntent {
data object IncreaseClicked : CounterIntent
data object DecreaseClicked : CounterIntent
data object ResetClicked : CounterIntent
}
여기서 Intent는 Android의 Intent와는 다른 뜻이다. 이 글에서는 "사용자가 어떤 의도를 가지고 한 행동" 정도로 보면 된다.
예를 들어 +1 버튼을 누르면 이런 일이 생긴다.
CounterIntent.IncreaseClicked
-1 버튼을 누르면 이런 일이 생긴다.
CounterIntent.DecreaseClicked
이렇게 행동에 이름을 붙여두면, 나중에 ViewModel이나 reducer 쪽에서 "어떤 일이 들어왔는지"를 한곳에서 처리할 수 있다.
4. UI는 값을 직접 바꾸지 않는다
CounterScreen에서 버튼 부분을 보면 숫자를 직접 바꾸지 않는다.
Button(onClick = { onIntent(CounterIntent.IncreaseClicked) }) {
Text("+1")
}
여기에는 state.count++ 같은 코드가 없다.
UI는 그냥 말한다.
"+1 버튼이 눌렸어요"
그리고 그 말을 CounterIntent.IncreaseClicked라는 값으로 보낸다.
이게 익숙하지 않으면 돌아가는 길처럼 보인다. 하지만 화면이 커질수록 장점이 생긴다. 버튼마다 상태를 직접 바꾸면, 나중에 상태가 어디서 바뀌었는지 찾기 어려워진다. 반대로 모든 행동을 Intent로 보내면, "사용자 행동이 들어오는 입구"를 좁힐 수 있다.
5. send는 Intent를 reduce로 넘긴다
MviBasicApp 안에는 send 함수가 있다.
fun send(intent: CounterIntent) {
state = reduce(state, intent)
}
이 함수도 처음에는 조금 낯설 수 있다. 하지만 역할은 단순하다.
현재 state와 새 intent를 reduce에 넣고,
reduce가 돌려준 새 state로 교체한다.
즉 +1 버튼을 누르면 흐름은 이렇게 된다.
send(CounterIntent.IncreaseClicked)
그리고 send 안에서 이렇게 이어진다.
state = reduce(state, CounterIntent.IncreaseClicked)
여기서 state = ...가 실행되면 Compose가 상태 변경을 감지하고 화면을 다시 그린다.
6. reduce는 현재 State와 Intent로 새 State를 만든다
이제 reduce를 보자.
private fun reduce(
state: CounterState,
intent: CounterIntent,
): CounterState {
return when (intent) {
CounterIntent.IncreaseClicked -> {
val nextCount = state.count + 1
state.copy(
count = nextCount,
lastIntent = "IncreaseClicked",
)
}
CounterIntent.DecreaseClicked -> {
val nextCount = state.count - 1
state.copy(
count = nextCount,
lastIntent = "DecreaseClicked",
)
}
CounterIntent.ResetClicked -> {
CounterState(lastIntent = "ResetClicked")
}
}
}
reduce는 현재 상태를 받는다.
state: CounterState
그리고 사용자의 행동도 받는다.
intent: CounterIntent
그 다음 새 상태를 돌려준다.
CounterState
즉 reduce는 이렇게 읽으면 된다.
현재 상태 + 사용자의 행동 = 다음 상태
7. copy는 data class가 만들어주는 새 State 생성 함수다
여기서 또 하나 막히기 쉬운 코드가 copy다.
state.copy(
count = nextCount,
lastIntent = "IncreaseClicked",
)
CounterState는 data class다.
data class CounterState(...)
Kotlin의 data class는 copy 함수를 자동으로 만들어준다. copy는 기존 값을 바탕으로 일부 값만 바꾼 새 객체를 만든다.
예를 들어 이전 상태가 이렇다고 해보자.
CounterState(
count = 0,
lastIntent = "아직 버튼을 누르지 않았습니다.",
)
여기서 copy를 호출한다.
state.copy(
count = 1,
lastIntent = "IncreaseClicked",
)
그러면 새 상태는 이렇게 된다.
CounterState(
count = 1,
lastIntent = "IncreaseClicked",
)
기존 state 객체를 직접 고치는 느낌이라기보다, 다음 화면을 위한 새 CounterState를 만든다고 생각하면 된다.
MVI에서 이 방식이 중요한 이유는 상태 변경이 눈에 잘 보이기 때문이다.
state.copy(...)
이 코드가 있는 곳을 찾으면 "여기서 화면 상태가 바뀌는구나"를 알 수 있다.
버튼 하나를 처음부터 끝까지 따라가보기
이제 +1 버튼 하나만 처음부터 끝까지 따라가보자.
처음 상태는 이렇다.
CounterState(
count = 0,
lastIntent = "아직 버튼을 누르지 않았습니다.",
)
사용자가 +1 버튼을 누른다.
Button(onClick = { onIntent(CounterIntent.IncreaseClicked) }) {
Text("+1")
}
CounterIntent.IncreaseClicked가 send로 전달된다.
fun send(intent: CounterIntent) {
state = reduce(state, intent)
}
reduce가 현재 상태와 intent를 받는다.
reduce(
state = CounterState(count = 0, ...),
intent = CounterIntent.IncreaseClicked,
)
reduce는 새 상태를 만든다.
state.copy(
count = 1,
lastIntent = "IncreaseClicked",
)
그 새 상태가 다시 state에 들어간다.
state = CounterState(
count = 1,
lastIntent = "IncreaseClicked",
)
그리고 Compose는 새 상태를 보고 화면을 다시 그린다.
Text("count = ${state.count}")
Text("lastIntent = ${state.lastIntent}")
그래서 화면에는 count = 1이 보인다.
이 샘플이 MVI 전부는 아니다
이 샘플은 MVI를 처음 이해하기 위해 아주 작게 줄인 버전이다. 실무에서 보는 MVI와는 차이가 있다.
지금 샘플에는 아래가 없다.
ViewModelStateFlowEffectRepository- 비동기 API 호출
실무형 Android MVI는 보통 이런 모양에 가깝다.
Composable UI
-> Event / Intent
-> ViewModel
-> UseCase / Repository
-> StateFlow<State>
-> Composable UI
+ Effect
하지만 처음부터 이 구조를 다 보면 오히려 핵심이 흐려질 수 있다. 그래서 이 샘플에서는 일부러 ViewModel과 StateFlow를 빼고, 가장 작은 흐름만 남겼다.
UI -> Intent -> reduce -> State -> UI
이 감각이 잡힌 뒤에 state를 ViewModel 안으로 옮기고, StateFlow로 노출하고, Toast나 Navigation 같은 일회성 동작을 Effect로 분리하면 된다.
정리
이번 샘플에서 먼저 기억할 것은 세 가지다.
State는 화면의 현재 값이다.Intent는 사용자의 행동을 이름 붙인 것이다.reduce는 현재State와Intent로 다음State를 만든다.
그리고 Compose 쪽에서는 이 한 줄을 이렇게 읽으면 된다.
var state by remember { mutableStateOf(CounterState()) }
화면이 기억할 수 있고,
값이 바뀌면 다시 그려지는,
CounterState 상태를 하나 만든다.
이 정도까지 보이면 MVI가 조금 덜 무섭다. 다음 단계에서는 같은 구조를 ViewModel, StateFlow, Effect까지 확장해서 실제 프로젝트에서 보는 모양에 더 가깝게 바꿔볼 수 있다.