본문 바로가기

[Android] MVI 아키텍처란 무엇일까?

@hyeon.s2025. 7. 8. 12:26

*노션에 정리한 내용을 블로그로 가져왔습니다. 
아키텍처는 도구이다. 문제를 해결하는 데 가장 적합한 도구를 선택하자.

라는 말 처럼, 모두가 따라한다고 아키텍처를 선택하기 보다는, 현 상황의 문제와 해결책이 될 수 있는 아키텍처를 선택하는 것이 중요하다고 생각한다. 따라서 아키텍처가 어떻게 구성되어있고, 어떤 이점을 갖는지 왜 등장하게 되었는지를 잘 알아두고자 포스팅을 작성하게 되었다.

왜 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이 가졌던 문제상황은 다음과 같다.

  1. 상태가 여러 곳에 퍼짐 → LiveData 여러개로 존재한다.
  2. 동시 상태 변경 → MutableLiveData 충돌
  3. 단발성 이벤트 처리
  4. 복잡한 UI 상태 → 여러 흐름을 조합

MVI의 해결

  1. State 단일 객체로 상태를 하나로 관리한다.
  2. Intent 처리 순서를 제어하여, 동시 상태 변경을 방지한다.
  3. 단발성 이벤트는 Effect를 따로 정의한다.
  4. 복잡한 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에서 처리한다.
hyeon.s
@hyeon.s :: 개발로그
목차