*노션에 정리한 내용을 블로그로 가져왔습니다.
아키텍처는 도구이다. 문제를 해결하는 데 가장 적합한 도구를 선택하자.
라는 말 처럼, 모두가 따라한다고 아키텍처를 선택하기 보다는, 현 상황의 문제와 해결책이 될 수 있는 아키텍처를 선택하는 것이 중요하다고 생각한다. 따라서 아키텍처가 어떻게 구성되어있고, 어떤 이점을 갖는지 왜 등장하게 되었는지를 잘 알아두고자 포스팅을 작성하게 되었다.
왜 MVI는 등장하게 되었을까?
기존 MVVM의 장점과 한계
MVVM (Model - View - ViewModel)은 안드로이드에서 널리 사용되는 아키텍처이다.
- ViewModel은 비즈니스 로직을 담당
- View는 LiveData/StateFlow를 구독하여 UI를 그리기
- Model은 데이터 소스를 캡슐화
이렇게 구성되어있는 MVVM은 앱이 커질수록 아래와 같은 문제가 발생했다.
MVVM의 한계
- 양방향 바인딩으로 인한 복잡성
- View → ViewModel → View 순환 구조로 예측 불가능한 UI 상태 발생
- 상태 분산 관리
- LiveData , State<>를 여러개로 분리하다 보면, UI 전체 상태를 한눈에 보기가 어렵다.
- 비동기 처리 중복
- 여러 코루틴에서 상태를 동시에 변경하면, race Condition위험 증가
- UI 상태와 이벤트가 뒤섞임
- Navigation, Toast, SnackBar 같은 단발성 이벤트 관리가 번거롭다.
그래서 등장한 MVI (Model- View- Intent)
MVI란?

MVI는 모든 상태 변화가 하나의 단방향 흐름으로 통제되는 구조이다.
사용자 행동 (Intent)
↓
ViewModel이 Intent 처리 (Reducer)
↓
새로운 UI 상태 (State)
↓
UI는 오직 이 상태를 기반으로 렌더링
MVI 구성요소
Intent : 사용자의 행동 or 앱 이벤트를 의미한다.
State : 앱의 단일 UI 상태
ViewModel : Intent를 받아 State로 전환하는 로직 담당
→ 이렇게 구성한 MVI가 갖는 장점은 무엇일까?
MVI의 장점
MVVM이 가졌던 한계들을 MVI는 아래와 같이 해결한다.
문제상황
MVVM이 가졌던 문제상황은 다음과 같다.
- 상태가 여러 곳에 퍼짐 → LiveData 여러개로 존재한다.
- 동시 상태 변경 → MutableLiveData 충돌
- 단발성 이벤트 처리
- 복잡한 UI 상태 → 여러 흐름을 조합
MVI의 해결
- State 단일 객체로 상태를 하나로 관리한다.
- Intent 처리 순서를 제어하여, 동시 상태 변경을 방지한다.
- 단발성 이벤트는 Effect를 따로 정의한다.
- 복잡한 UI 상태는 Reducer에서 명확히 결정한다.
Compose와 MVI의 조합
MVI 아키텍처는 Compose UI를 사용할 때, 더욱 널리 사용된다.
이는 바로 Compose는 State → UI 렌더링 방식이기 때문이다. MVI는 State→View 개념의 아키텍처이기에 Compose의 렌더링 방식과 적합하여 더욱 사용된다.
Compsoe에서 MVI로 로그인 화면 만들기
그렇다면 Compose에서 사용되는 MVI 패턴 예시를 보자
1. State 정의
state라는 것은 → UI가 보여줘야 할 상태, 결과를 의미한다.
따라서 로그인 화면에서 보여질 State들은 아래와 같다.
data class LoginState(
val email: String = "",
val password: String = "",
val isLoading: Boolean = false,
val errorMessage: String? = null,
val isSuccess: Boolean = false
)
2. Intent 정의
intent → 유저의 행동을 의미한다.
intent는 유저의 액션 시 발생하는 것들로, Login 화면에서 발생가능한 Intent는 아래와 같다.
sealed class LoginIntent {
data class EnterEmail(val value: String) : LoginIntent()
data class EnterPassword(val value: String) : LoginIntent()
object Submit : LoginIntent()
}
3. ViewModel 구현
사용자의 액션인 Intent를 트리거 해, View에게 State로 반환해줄 ViewModel을 구현해야 한다.
@HiltViewModel
class LoginViewModel @Inject constructor(
private val authRepository: AuthRepository
) : ViewModel() {
private val _state = MutableStateFlow(LoginState())
val state: StateFlow<LoginState> = _state.asStateFlow()
fun dispatch(intent: LoginIntent) {
when (intent) {
is LoginIntent.EnterEmail -> {
_state.update { it.copy(email = intent.value, errorMessage = null) }
}
is LoginIntent.EnterPassword -> {
_state.update { it.copy(password = intent.value, errorMessage = null) }
}
LoginIntent.Submit -> {
login()
}
}
}
private fun login() {
val current = _state.value
if (current.email.isBlank() || current.password.isBlank()) {
_state.update { it.copy(errorMessage = "이메일과 비밀번호를 모두 입력해주세요") }
return
}
viewModelScope.launch {
_state.update { it.copy(isLoading = true, errorMessage = null) }
val result = runCatching {
authRepository.login(current.email, current.password)
}
result.onSuccess {
_state.update { it.copy(isLoading = false, isSuccess = true) }
}.onFailure {
_state.update { it.copy(isLoading = false, errorMessage = it.message ?: "알 수 없는 오류") }
}
}
}
}
- 단일 State로 묶은 LoginState를 선언한다.
- dispatch를 통해서 사용자의 액션 intent를 받고, 각각의 액션에 맞는 state로의 변경 메서드를 실행한다.
- 이 때 메서드들은 dataSource → repository → useCase로부터 받아온 API 나 local 내 변경이 된다.
4. UI 구성
ViewModel로부터 받아온 State를 UI에 변경시키는 Screen화면 구현이 필요하다.
@Composable
fun LoginScreen(
state: LoginState,
onIntent: (LoginIntent) -> Unit
) {
Column(modifier = Modifier.padding(16.dp)) {
TextField(
value = state.email,
onValueChange = { onIntent(LoginIntent.EnterEmail(it)) },
label = { Text("이메일") }
)
Spacer(modifier = Modifier.height(8.dp))
TextField(
value = state.password,
onValueChange = { onIntent(LoginIntent.EnterPassword(it)) },
label = { Text("비밀번호") },
visualTransformation = PasswordVisualTransformation()
)
if (state.errorMessage != null) {
Text(state.errorMessage, color = Color.Red)
}
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = { onIntent(LoginIntent.Submit) },
enabled = !state.isLoading
) {
if (state.isLoading) {
CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp)
} else {
Text("로그인")
}
}
}
}
- Screen에서는 State Hoisting을 적용하여, State를 매개변수로 받아 처리한다.
- TextField내 value가 변경되는 사항에 대해서도, LoginIntent내에 정의된 액션에 맞춰 메서드를 활용한다.
그렇다면 onIntent는 무엇으로 Screen에 전달되는걸까?
우리는 Navigation 을 사용하기 위해서, 이동을 담당하는 Route 클래스를 Screen 이전에 둔다.
따라서 Route에서 State를 Screen에 전달하는 StateHoisting 전략을 사용하게 되는데 ..
Route 클래스는 다음과 같이 구성된다.
ViewModel을 연결하고, UI를 Screen에 전달한다.
@Composable
fun LoginRoute(
viewModel: LoginViewModel = hiltViewModel(),
onLoginSuccess: () -> Unit // 네비게이션 콜백
) {
val state by viewModel.state.collectAsState()
// 로그인 성공 시 이동 처리
**LaunchedEffect(state.isSuccess) {
if (state.isSuccess) {
onLoginSuccess()
}
}**
LoginScreen(
state = state,
onIntent = { viewModel.dispatch(it) }
)
}
- Screen에 onIntent는 ViewModel.dispatch로, 사용자의 행동이 onIntent()를 통해 전달되면, dispatch(intent)내부에서 intent 종류에 따라 상태가 변경된다.
LaunchedEffect(state.isSuccess) {
if (state.isSuccess) {
onLoginSuccess()
}
}
이 때 이 부분은 isSuccess가 true일때만 한번 실행되게 하는 Compose의 side-effect이다.
전체 흐름 정리
[사용자 입력]
↓
onIntent(LoginIntent.XXX) 호출
↓
viewModel.dispatch(intent)
↓
state 업데이트
↓
Route에서 state 변화 감지 → recomposition
↓
LoginScreen 다시 그려짐
- 이 때 collectAsState()는 단일 State만 수신하므로, State내부의 하나만 바뀌어도 전체가 recomposition된다.
- 조금 더 설명하면, collectAsState()는 Flow를 구독하고, emit된 새로운 객체가 이전 객체와 다르다고 판단될 때 recomposition이 발생한다. 즉 데이터 클래스의 eqauls() 비교가 다르면 재조합된다.
그러므로 LoginState는 data class이므로 내부 값 중 하나라도 변경되면 equals 결과가 달라지고, collectAsState()가 감지해서 재조합이 일어난다.
- onIntent는 사용자 인터랙션에서 호출된다.
궁금한 점 : State vs SideEffect 비교
State : UI가 보여줘야 할 상태 결과
Side Effect : 한번만 발생하고 끝나는 일회성 이벤트
즉 SideEffect ⇒ 사용자의 인터랙션이나 상태 변화에 따라 한번만 발생해야하는 부수 효과를 의미한다.
예시
상황 | SideEffect 예 |
---|---|
로그인 성공 | 다음 화면으로 Navigation |
로그인 실패 | Toast("로그인 실패") |
상품 등록 성공 | Snackbar("등록 완료") |
뒤로가기 클릭 | popBackStack() 호출 |
다음과 같은 것들은 UI에서 영구적으로 표현되는 상태가 아니라, 한번만 발생하고 지나가야 하는 액션이다.
SideEffect는 왜 필요할까?
- MVI에서 모든 것이 State로 표현하는게 이상적이지만, State는 상태 보존 구조라 recomposition이 다시 실행될 수 있다.
- 따라서 SideEffect만 따로 관리하는 것
Side Effect 구현 방법
1. effect 클래스를 정의한다.
sealed class LoginEffect {
object NavigateToHome : LoginEffect()
data class ShowToast(val message: String) : LoginEffect()
}
2. ViewModel 내에서 Channel/SharedFlow로 방출
@HiltViewModel
class LoginViewModel @Inject constructor(...) : ViewModel() {
private val _effect = MutableSharedFlow<LoginEffect>()
val effect = _effect.asSharedFlow()
fun dispatch(intent: LoginIntent) {
when (intent) {
is LoginIntent.Submit -> {
login()
}
...
}
}
private fun login() {
viewModelScope.launch {
val result = runCatching { authRepository.login(...) }
result.onSuccess {
_effect.emit(LoginEffect.NavigateToHome)
}.onFailure {
_effect.emit(LoginEffect.ShowToast("로그인 실패"))
}
}
}
}
3. Route에서 Collect처리
@Composable
fun LoginRoute(
viewModel: LoginViewModel = hiltViewModel(),
navController: NavController
) {
val state by viewModel.state.collectAsState()
// SideEffect 처리
LaunchedEffect(Unit) {
viewModel.effect.collect { effect ->
when (effect) {
is LoginEffect.NavigateToHome -> {
navController.navigate("home")
}
is LoginEffect.ShowToast -> {
Toast.makeText(LocalContext.current, effect.message, Toast.LENGTH_SHORT).show()
}
}
}
}
LoginScreen(state = state, onIntent = { viewModel.dispatch(it) })
}
이 때 왜 SharedFlow로 처리할까?
StateFlow와 각각을 정리하면 다음과 같다.
이유 | 설명 |
---|---|
StateFlow는 최신 값만 유지 | -> SideEffect는 "발생 순간"이 중요하므로 부적합 |
Channel은 단방향 스트림 | -> cancel-safe하지 않음 |
SharedFlow는 핫 스트림, 일시 정지된 collect에 안전 | ⇒ 안정적 SideEffect 처리 가능 |
Side Effect 구조 요약
UI(View)
├── onIntent(LoginIntent.Submit)
↓
ViewModel.dispatch()
├── state 업데이트
└── _effect.emit(LoginEffect.NavigateToHome)
↓
UI(Route)
└── effect.collect { ... }
전체 마무리 정리
구분 | 설명 |
---|---|
State |
UI를 구성하는 지속 상태 |
Intent |
사용자의 입력/행동 |
SideEffect |
일회성 이벤트 (Navigation, Toast 등) |
사용 도구 | MutableSharedFlow , LaunchedEffect , collect() |
느낀점
- State와 Effect를 분리해서 생각하는 것이 중요한 것 같다.
- 정리하면 UI는 State만 보고 렌더링하고, Effect는 Route에서 처리한다.
'Android > Compose' 카테고리의 다른 글
[Android] Compose StateHoisting와 StateHolder (0) | 2025.07.07 |
---|---|
[Compose] Compose에서 MVVM 패턴을 적용해보자 (0) | 2024.11.05 |
[Jetpack Compose] Text 만들기 (0) | 2022.11.24 |