본문 바로가기

안드로이드

Compose에서 MVI 아키텍처 적용하기

반응형

MVI(Model-View-Intent)는 단방향 데이터 흐름으로 UI 상태를 예측 가능하게 만드는 아키텍처 패턴이다.
Jetpack Compose의 선언적 UI 모델단일 상태 관리라는 특징과 잘 맞기 때문에, 복잡한 화면에서도 상태를 명확하게 추적할 수 있다.

이 글에서는 아래와 같은 내용을 정리한다.

  • 기존 MVVM 패턴의 한계
  • MVI의 핵심 개념 (State / Intent / Result / Reducer)
  • ViewModel + Compose UI 연결
  • 테스트 전략과 도입 시 고려사항

1. MVVM의 한계

MVVM 패턴으로 개발하다 보면 ViewModel에 상태가 점점 쌓이는 문제가 생긴다.

위반 사례

class HomeViewModel : ViewModel() {
    private val _isLoading = MutableLiveData<Boolean>()
    private val _error = MutableLiveData<String?>()
    private val _movies = MutableLiveData<List<Movie>>()
    private val _isRefreshing = MutableLiveData<Boolean>()
    private val _selectedFilter = MutableLiveData<Filter>()
    // 상태가 계속 늘어남
}

 

문제점:

  • 상태가 여러 LiveData에 분산되어 있어 현재 화면 상태를 한눈에 파악하기 어렵다.
  • 여러 상태가 동시에 변경될 때 일관성을 보장하기가 어렵다.
  • 상태 변경 로직이 ViewModel 전체에 흩어져 있어 디버깅이 힘들다.

이런 이유로 “화면의 상태를 하나의 객체로 관리”하는 MVI가 잘 맞는다.

2. MVI의 핵심 개념

MVI는 크게 네 가지 요소로 생각할 수 있다.

  • Intent: 사용자의 의도/요청
  • Result: 비동기 작업의 결과
  • State: 화면에 그릴 단일 상태
  • Reducer: Result → State 로 변환하는 순수 함수

원래 이론적인 MVI에서는 Intent가 바로 Reducer로 들어가지만, Android에서는 대부분 비동기 작업(API, DB)이 필요하기 때문에 실무에서는 중간에 Result를 두는 케이스가 많다.

순수 MVI vs 실용적 MVI

순수 MVI (이론)

User Action → Intent → Reducer → State → UI

Intent가 바로 Reducer로 전달된다. 구조는 단순하지만, 비동기 처리를 끼워 넣기 어렵다.

실용적 MVI (이 글에서 사용하는 형태)

User Action → Intent → handleIntent() → (API/DB) → Result → Reducer → State → UI
↑                                                                              │
└──────────────────────────────────────────────────────────────────────────────┘
  • Intent: 사용자가 “무엇을 하려는지”에 대한 요청
  • handleIntent(): 비동기 작업 수행 (API 호출, DB 조회 등)
  • Result: 비동기 작업의 성공/실패/로딩 결과
  • Reducer: Result를 받아 새로운 State를 만드는 순수 함수

예를 들어 Intent.LoadMovies 는 “영화 목록을 로드해달라”는 요청이고,
Result.MoviesLoaded 는 “영화 목록 로드가 완료됐다”는 결과에 해당한다.
둘을 분리해두면 비동기 작업을 훨씬 자연스럽게 다룰 수 있다.

State (상태)

화면의 모든 상태를 담는 불변 데이터 클래스다.

data class HomeState(
    val isLoading: Boolean = false,
    val movies: List<Movie> = emptyList(),
    val error: String? = null,
    val selectedFilter: Filter = Filter.ALL
) {
    val isEmpty: Boolean get() = movies.isEmpty() && !isLoading
}
  • State는 반드시 불변(Immutable) 이어야 한다.
  • data class + val 조합을 사용하고,
  • 변경 시에는 copy()로 새 객체를 만들어야 한다.

Intent (의도)

사용자 액션이나 시스템 이벤트를 나타내는 sealed class다.

sealed class HomeIntent {
    object LoadMovies : HomeIntent()
    object Refresh : HomeIntent()
    data class SelectFilter(val filter: Filter) : HomeIntent()
    data class MovieClicked(val movieId: Long) : HomeIntent()
}

 

Intent는 사용자가 “무엇을 하려는지”를 표현해야 한다.
구체적인 구현 세부사항보다는 비즈니스 의미가 드러나는 이름이 좋다.

Result & Reducer (상태 변환기)

Result는 비동기 작업의 결과를 표현하고,
Reducer는 (현재 State, Result) → 새로운 State 를 만들어내는 순수 함수다.

sealed class HomeResult {
    object Loading : HomeResult()
    data class MoviesLoaded(val movies: List<Movie>) : HomeResult()
    data class Error(val message: String) : HomeResult()
    data class FilterChanged(val filter: Filter) : HomeResult()
}

fun reduce(state: HomeState, result: HomeResult): HomeState {
    return when (result) {
        is HomeResult.Loading -> state.copy(
            isLoading = true,
            error = null
        )
        is HomeResult.MoviesLoaded -> state.copy(
            isLoading = false,
            movies = result.movies
        )
        is HomeResult.Error -> state.copy(
            isLoading = false,
            error = result.message
        )
        is HomeResult.FilterChanged -> state.copy(
            selectedFilter = result.filter
        )
    }
}

 

Reducer는 순수 함수이기 때문에

  • 같은 입력(State + Result)에 대해 항상 같은 출력을 내고,
  • API 호출, DB 접근 같은 부수 효과는 가지지 않는다.

이 덕분에 테스트가 매우 쉬워진다.

3. MVI ViewModel 구현

베이스 클래스

재사용 가능한 MVI 베이스 ViewModel을 하나 만들어 놓고, 실제 화면 ViewModel은 이를 상속해서 사용하는 방식이다.

abstract class MviViewModel<State : Any, Intent : Any, Result : Any>(
    initialState: State
) : ViewModel() {

    private val _state = MutableStateFlow(initialState)
    val state: StateFlow<State> = _state.asStateFlow()

    protected val currentState: State get() = _state.value

    private val _sideEffect = Channel<SideEffect>(Channel.BUFFERED)
    val sideEffect: Flow<SideEffect> = _sideEffect.receiveAsFlow()

    fun dispatch(intent: Intent) {
        viewModelScope.launch {
            handleIntent(intent)
        }
    }

    protected abstract suspend fun handleIntent(intent: Intent)
    protected abstract fun reduce(state: State, result: Result): State

    protected fun updateState(result: Result) {
        _state.update { state ->
            reduce(state, result)
        }
    }

    protected fun sendSideEffect(effect: SideEffect) {
        viewModelScope.launch {
            _sideEffect.send(effect)
        }
    }
}

sealed class SideEffect {
    data class Toast(val message: String) : SideEffect()
    data class Navigate(val route: String) : SideEffect()
}

 

여기서 sideEffect는 Toast, Navigation 같은 일회성 이벤트를 위한 스트림이다.
이런 값들은 State에 넣으면 안 된다. (화면 회전/재구성 시 다시 발생하면 안 되기 때문)

 

더 일반화하고 싶다면 SideEffect도 타입 파라미터로 빼서

MviViewModel<State, Intent, Result, Effect> 형태로 만들어도 된다.

실제 ViewModel

@HiltViewModel
class HomeViewModel @Inject constructor(
    private val getMoviesUseCase: GetMoviesUseCase
) : MviViewModel<HomeState, HomeIntent, HomeResult>(HomeState()) {

    init {
        dispatch(HomeIntent.LoadMovies)
    }

    override suspend fun handleIntent(intent: HomeIntent) {
        when (intent) {
            is HomeIntent.LoadMovies -> loadMovies()
            is HomeIntent.Refresh -> refresh()
            is HomeIntent.SelectFilter -> selectFilter(intent.filter)
            is HomeIntent.MovieClicked -> navigateToDetail(intent.movieId)
        }
    }

    private suspend fun loadMovies() {
        updateState(HomeResult.Loading)

        getMoviesUseCase(currentState.selectedFilter)
            .catch { e ->
                updateState(HomeResult.Error(e.message ?: "Unknown error"))
            }
            .collect { movies ->
                updateState(HomeResult.MoviesLoaded(movies))
            }
    }

    private suspend fun refresh() {
        loadMovies()
    }

    private fun selectFilter(filter: Filter) {
        updateState(HomeResult.FilterChanged(filter))
        viewModelScope.launch { loadMovies() }
    }

    private fun navigateToDetail(movieId: Long) {
        sendSideEffect(SideEffect.Navigate("movie/$movieId"))
    }

    override fun reduce(state: HomeState, result: HomeResult): HomeState {
        return when (result) {
            is HomeResult.Loading -> state.copy(isLoading = true, error = null)
            is HomeResult.MoviesLoaded -> state.copy(isLoading = false, movies = result.movies)
            is HomeResult.Error -> state.copy(isLoading = false, error = result.message)
            is HomeResult.FilterChanged -> state.copy(selectedFilter = result.filter)
        }
    }
}
  • dispatch()로 Intent를 보내면 → handleIntent()에서 분기 처리
  • 비동기 작업 결과는 HomeResult로 포장해서 updateState() 호출
  • 네비게이션, Toast는 sendSideEffect()로 전달

4. Compose UI 연결

State 수집 및 SideEffect 처리

@Composable
fun HomeScreen(
    viewModel: HomeViewModel = hiltViewModel(),
    onMovieClick: (Long) -> Unit
) {
    val state by viewModel.state.collectAsStateWithLifecycle()

    val context = LocalContext.current
    LaunchedEffect(Unit) {
        viewModel.sideEffect.collect { effect ->
            when (effect) {
                is SideEffect.Toast -> {
                    Toast.makeText(context, effect.message, Toast.LENGTH_SHORT).show()
                }
                is SideEffect.Navigate -> {
                    onMovieClick(effect.route.substringAfter("/").toLong())
                }
            }
        }
    }

    HomeContent(
        state = state,
        onIntent = viewModel::dispatch
    )
}
  • collectAsStateWithLifecycle()은 Lifecycle을 고려해서 StateFlow를 안전하게 수집해준다.
  • LaunchedEffect 안에서 SideEffect를 한 번씩만 처리한다.

Stateless Composable

@Composable
private fun HomeContent(
    state: HomeState,
    onIntent: (HomeIntent) -> Unit
) {
    Box(modifier = Modifier.fillMaxSize()) {
        when {
            state.isLoading && state.movies.isEmpty() -> {
                CircularProgressIndicator(
                    modifier = Modifier.align(Alignment.Center)
                )
            }
            state.error != null && state.movies.isEmpty() -> {
                ErrorView(
                    message = state.error,
                    onRetry = { onIntent(HomeIntent.LoadMovies) }
                )
            }
            state.isEmpty -> {
                EmptyView()
            }
            else -> {
                MovieList(
                    movies = state.movies,
                    onMovieClick = { onIntent(HomeIntent.MovieClicked(it)) }
                )
            }
        }
    }
}

HomeContent는 State만 받아서 UI를 렌더링하는 Stateless Composable이다.
모든 사용자 액션은 onIntent 콜백으로 ViewModel에 전달한다.

5. 테스트

MVI의 가장 큰 장점 중 하나가 테스트 용이성이다.

Reducer 테스트

class HomeReducerTest {

    private val initialState = HomeState()

    @Test
    fun `Loading result sets isLoading true`() {
        val newState = reduce(initialState, HomeResult.Loading)

        assertThat(newState.isLoading).isTrue()
        assertThat(newState.error).isNull()
    }

    @Test
    fun `MoviesLoaded result updates movies`() {
        val movies = listOf(Movie(1, "Test Movie"))
        val state = initialState.copy(isLoading = true)

        val newState = reduce(state, HomeResult.MoviesLoaded(movies))

        assertThat(newState.isLoading).isFalse()
        assertThat(newState.movies).isEqualTo(movies)
    }

    @Test
    fun `Error result sets error message`() {
        val state = initialState.copy(isLoading = true)

        val newState = reduce(state, HomeResult.Error("Network error"))

        assertThat(newState.isLoading).isFalse()
        assertThat(newState.error).isEqualTo("Network error")
    }
}

 

Reducer는 순수 함수이기 때문에 입력과 출력만 검증하면 된다.
Mock이나 복잡한 설정이 필요 없다.

ViewModel 테스트

@OptIn(ExperimentalCoroutinesApi::class)
class HomeViewModelTest {

    @get:Rule
    val mainDispatcherRule = MainDispatcherRule()

    private val getMoviesUseCase: GetMoviesUseCase = mockk()

    @Test
    fun `initial load updates state with movies`() = runTest {
        val movies = listOf(createTestMovie())
        every { getMoviesUseCase(any()) } returns flowOf(movies)

        val viewModel = HomeViewModel(getMoviesUseCase)
        advanceUntilIdle()

        assertThat(viewModel.state.value.movies).isEqualTo(movies)
        assertThat(viewModel.state.value.isLoading).isFalse()
    }
}
  • runTest + advanceUntilIdle()로 코루틴을 제어하면서
  • ViewModel의 상태가 의도한 대로 변하는지 검증할 수 있다.

6. 도입 시 고려사항

보일러플레이트

  • State / Intent / Result / Reducer / ViewModel / SideEffect까지 정의하다 보면 코드량이 늘어난다.
  • 베이스 클래스를 잘 설계하거나, 정말 간단한 화면은 기존 MVVM으로 유지하는 것도 방법이다.

성능

  • State 객체가 너무 커지면 매번 copy()할 때 비용이 발생할 수 있다.
  • 특히 큰 리스트는 별도의 StateFlow로 분리하거나,
    Diffing 비용을 줄일 수 있는 구조를 고민해볼 필요가 있다.

Side Effect 처리

  • Navigation, Toast 같은 일회성 이벤트는 State에 넣지 말고
    Channel이나 SharedFlow 등으로 별도로 처리하는 것이 안전하다.

정리

  • MVI는 단방향 데이터 흐름으로 상태를 예측 가능하게 만든다.
  • State는 불변 객체, Intent는 사용자 액션, Reducer는 순수 함수다.
  • Compose의 선언적 UI와 MVI의 단일 상태 관리는 궁합이 좋다.
  • Reducer가 순수 함수이기 때문에 테스트가 쉽고 안정적이다.
  • Side Effect는 State와 분리해서 Channel/Flow로 처리하는 것이 좋다.

참고

  • Android Developers – State and Jetpack Compose
  • Android Developers – Guide to app architecture
반응형