grep

Android

Compose와 MVI로 다시 태어난 Android UI: MVVM에서 MVI로의 전환기

Aiden Kim여기어때

2025년 1월 6일

원문에서 보기 ↗

안녕하세요, 여기어때컴퍼니 Android 개발팀의 에이든입니다.

최근 검색 화면을 웹에서 네이티브로 전환하는 프로젝트를 진행했습니다. 이 화면은 사용자 상호작용이 많고 데이터 갱신이 빈번하여, 효과적인 상태 관리가 필수적이었습니다. 이를 위해 기존의 MVVM 방식보다 단방향 데이터 흐름과 선언형 UI 의 장점을 극대화할 수 있는 MVI 패턴 을 선택했습니다. 이 과정에서 Jetpack Compose와 MVI 패턴을 결합하여, 더 직관적이고 효율적인 Android 개발 환경을 구축한 경험을 소개하고자 합니다.

왜 MVI를 선택했을까?

기존에 널리 쓰이던 MVVM은 안정적이고 익숙한 방식이지만, UI가 복잡하고 상태 변화가 잦은 화면에서는 관리 포인트가 늘어나는 단점이 있습니다.

Compose와 MVI를 도입한 이유

Compose와 MVI는 이러한 문제를 해결하기 위해 상호 보완적으로 작동하며, 상태 관리와 UI 동기화를 효율적으로 처리하는 데 장점이 있습니다.

Jetpack Compose

MVI 패턴

Compose와 MVI의 조합 효과

MVI는 상태 변화를 명확히 전달하며, Compose는 이를 즉각적으로 UI에 반영합니다. 이로 인해:

MVI 패턴이란?

본격적인 전환에 앞서 MVI 패턴에 대해 간단히 알아보겠습니다.

MVI 패턴은 단방향 데이터 흐름을 통해 상태 중심의 구현을 할 수 있도록 설계되었습니다. 주요 구성 요소의 역할은 다음과 같습니다:

Intent

Model

View

MVI 패턴의 흐름은 직관적입니다. 사용자의 액션(Intent)이 발생하면 Model의 상태가 변경되고, View는 변경된 상태를 기반으로 UI를 새롭게 렌더링합니다.

일회성 작업과 Side Effects(부수효과)

화면 전환이나 Toast 메시지 표시와 같은 일회성 작업 은 어떻게 처리해야 할까요? 이러한 작업이 상태에 포함된다면, 재처리를 방지해야 하는 문제가 발생합니다. 또한, UI 상태와 일회성 작업을 혼합하면 코드 관리가 복잡해질 수 있습니다. 이를 해결하기 위해 MVI에서는 Side Effects라는 개념을 도입합니다.

Side Effects

Side Effects는 UI 상태와 별도로 작업을 처리할 수 있는 구조를 제공하여 상태 관리의 일관성과 코드의 가독성을 높이는 데 기여합니다.

본격적인 전환

Compose와 MVI를 도입하여 기존 MVVM 구조의 문제점을 해결하고 MVI 패턴 기반 구조를 설계했습니다. 특히, 검색 화면처럼 상태가 복잡하고 데이터 변화가 잦은 화면에서는 MVI의 장점이 더욱 부각되었습니다. 아래에서는 전환 과정과 설계 포인트, 그리고 구현 과정을 살펴보겠습니다.

기존 MVVM 구조 살펴보기

기존 MVVM 구조에서는 StateFlow를 활용하여 UI 상태를 관리했습니다. 상태를 개별적으로 관리하며, 이는 간단한 화면에서는 유용했지만 상태와 이벤트가 많아질수록 복잡성이 증가하는 문제가 있었습니다.

ViewModel 예시

@HiltViewModel
class SearchViewModel @Inject constructor(
    ...
) : BaseViewModel() {

    // 검색 키워드
    val keyword = MutableStateFlow<String>("")

    // 일정
    private val _schedule = MutableStateFlow<String>("")
    val schedule = _schedule.asStateFlow()

    // 인원
    private val _person = MutableStateFlow<Int>(0)
    val person = _person.asStateFlow()

    // 추천 키워드 리스트
    private val _recommendKeywords = MutableStateFlow<List<RecommendKeyword>>(listOf())
    val recommendKeywords = _recommendKeywords.asStateFlow()

    // 자동 완성 검색 리스트
    private val _searchItems = MutableStateFlow<List<SearchItem>>(listOf())
    val searchItems = _searchItems.asStateFlow()

   ...     
}

View 예시

@Composable
fun Content() {
    val viewModel = hiltViewModel<SearchViewModel>()
    val keyword by viewModel.keyword.collectAsStateWithLifecycle()
    val schedule by viewModel.schedule.collectAsStateWithLifecycle()
    val person by viewModel.person.collectAsStateWithLifecycle()
    val recommendKeywords by viewModel.recommendKeywords.collectAsStateWithLifecycle()
    val searchItems by viewModel.searchItems.collectAsStateWithLifecycle()

    SearchScreen(
        keyword = keyword,
        schedule = schedule,
        person = person,
        recommendKeywords = recommendKeywords,
        searchItems = searchItems
        ...
    )
}

문제점

MVI 구조 설계하기

Compose와 MVI를 도입하기 전에, 팀 내에서 설계 방향을 명확히 하기 위해 리뷰를 진행했습니다. 프로젝트에 MVI 패턴을 처음 적용하는 만큼 아래와 같은 설계 방향 및 목표를 설정했습니다.

설계 방향

  1. 기능 추가 및 변화에 유연하게 대처
  1. 코드 일관성과 규칙을 위한 기본 틀 제공

설계 목표

  1. 단방향 데이터 흐름 구현 Intent → State → UI 갱신의 명확한 흐름을 구현하여 데이터 흐름의 예측 가능성을 높입니다.
  2. 단일 상태 관리 화면의 모든 상태를 단일 객체로 통합하여 관리하고, 유지보수를 간소화합니다.
  3. Contract 기반 설계 상태(State), 이벤트(Event), 부수 효과(SideEffect)를 명확히 분리하여 코드의 역할을 구조화합니다.
  4. BaseViewModel 활용 공통 로직을 추출해 반복 작업을 줄이고, 새로운 기능 추가 시 효율성을 극대화합니다.

MVI 구조 적용하기

먼저, MVI 패턴의 기본적인 틀을 제공하기 위해 MVI 전용 ViewModel을 구현했습니다.

MVIBaseViewModel

ViewModel의 역할은 Intent(사용자 이벤트)를 처리하기 위한 Event와 UI를 렌더링하기 위한 State, 마지막으로 일회성 작업을 담당할 SideEffect로 구성되어 있습니다.

abstract class MVIBaseViewModel<E : Event, S : State, SE : SideEffect>(defaultState: State) {

    // 이벤트를 받기 위한 Channel
    private val event = Channel<E>()

    // 상태를 유지하기 위한 StateFlow
    val state: StateFlow<S> = event.receiveAsFlow()
        .runningFold(defaultUiState, ::reduceState)
        .stateIn(viewModelScope, SharingStarted.Eagerly, defaultUiState)

    // 이벤트를 통해 최신화된 상태를 반환
    protected abstract suspend fun reduceState(current: S, event: E): State

    // 일회성 이벤트 처리를 위한 Channel
    private val _sideEffect: Channel<SE> = Channel()
    val sideEffect = _sideEffect.receiveAsFlow()

    
    protected fun postEvent(event: Event) {
        viewModelScope.launch {
            event.send(event)
        }
    }
    protected fun postEffect(effect: Effect) {
        viewModelScope.launch {
            sideEffect.send(effect)
        }
    }
}

Event: 사용자 이벤트 처리

State: 상태 관리

SideEffect: 일회성 이벤트 관리

이제 작성한 MVIBaseViewModel를 기반으로 MVI 패턴을 구현해보겠습니다.

Contract 정의

State

@Immutable
data class SearchState(
    val keyword: String = "", // 검색 키워드
    val schedule: Schedule = Schedule(), // 예약 일정
    val person: Int = "", // 예약 인원
    val recommendKeywords: ImmutableList<RecommendKeyword> = persistentListOf(), // 추천 키워드 리스트
    val searchItems: ImmutableList<SearchItem> = persistentListOf(), // 자동 완성 검색 리스트
    ...
) : State

모든 상태는 단일 객체로 관리되며, 상태는 불변입니다. UI에 표시되는 정보를 포함합니다.

Event

sealed class SearchEvent : Event {
    data class UpdateKeyword(val keyword: String) : SearchEvent()
    data class UpdateSchedule(val schedule: Schedule) : SearchEvent()
    data class LoadRecommendKeywords(val query: String) : SearchEvent()
}

사용자의 액션이나 UI 상호작용을 기반으로 발생하는 이벤트를 정의합니다.

SideEffect

sealed class SearchSideEffect : SideEffect {
    data class ShowToast(val message: String) : SearchSideEffect()
    data class NavigateToDetail(val itemId: String) : SearchSideEffect()
}

화면전환, Toast 메시지 표시와 같은 일회성 작업을 처리합니다. 상태와 독립적으로 동작합니다.

ViewModel

@HiltViewModel
class SearchViewModel @Inject constructor(
    ...
) : MVIBaseViewModel<SearchEvent, SearchState, SearchSideEffect>(SearchState()) {

    override suspend fun reduceState(current: SearchState, event: SearchEvent): SearchState {
        return when (event) {
            is SearchEvent.UpdateKeyword -> current.copy(keyword = event.keyword)
            is SearchEvent.UpdateSchedule -> current.copy(schedule = event.schedule)
            is SearchEvent.LoadRecommendKeywords -> {
                val keywords = getRecommendKeywords(event.query)
                current.copy(recommendKeywords = keywords)
            }
        }
    }

    fun updateKeyword(keyword: String) {
        postEvent(SearchEvent.UpdateKeyword(keyword))
    }

    fun loadRecommendKeywords(query: String) {
        postEvent(SearchEvent.LoadRecommendKeywords(query))
    }

    fun showToast(message: String) {
        postEffect(SearchSideEffect.ShowToast(message))
    }
}

상태 업데이트 로직과 이벤트 처리를 담당합니다.

View

State와 Event 처리

@Composable
fun Content() {
    val viewModel = hiltViewModel<SearchViewModel>()
    val state by viewModel.state.collectAsStateWithLifecycle()

    SearchScreen(
        state = state,
        onKeywordChange = { keyword -> viewModel.updateKeyword(keyword) },
        onLoadRecommendations = { query -> viewModel.loadRecommendKeywords(query) }
    )
}

SideEffect 처리

val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(viewModel.sideEffect) {
    viewModel.sideEffect.flowWithResumed(lifecycleOwner.lifecycle) { sideEffect ->
        when (sideEffect) {
            is SearchSideEffect.ShowToast -> Toast.makeText(context, sideEffect.message, Toast.LENGTH_SHORT).show()
            is SearchSideEffect.NavigateToDetail -> navigateToDetailScreen(sideEffect.itemId)
        }
    }
}

State 처리:

SideEffect 처리:

전환 이후

MVI 패턴을 적용하고 얻은 장단점을 한번 알아보겠습니다.

장점

  1. 구조화된 상태 관리 단일 상태 객체로 화면 상태를 관리하여 코드 가독성과 유지보수성이 크게 향상되었습니다.
  2. 확장성 새로운 기능 추가 시 기존 코드를 최소한으로 수정하면서 State, Event만 확장하면 됩니다.
  3. 예측 가능한 데이터 흐름 상태 변화가 명확한 규칙에 따라 발생하기 때문에 디버깅이 쉬워지고, 데이터 흐름을 직관적으로 이해할 수 있습니다.

단점

  1. Boilerplate 코드 증가 초기 설정과 Contract 정의로 인해 작성해야 할 코드양이 늘어났습니다.
  2. 간단한 화면에 과도한 설계 적용 단순한 UI에도 MVI 패턴을 적용해야 할 경우, 설계가 과도하게 느껴질 수 있습니다.
  3. 기존 코드 리팩토링 어려움 기존 MVVM 구조에서 리팩토링 시 구조적으로 달라지는 부분이 많아 로직 변경이 필요했습니다.

마무리

MVI를 도입하면서 기존 MVVM 방식에서 발생했던 상태 관리와 유지보수 문제를 효과적으로 해결 할 수 있었습니다. 특히, 복잡한 UI 상태를 단일 상태 객체로 관리하고, 데이터 흐름을 단방향으로 설계함으로써 가독성과 예측 가능성을 크게 향상시켰습니다. 물론 초기 도입 단계에서는 Boilerplate 코드 증가와 팀 적응 등의 단점도 존재했지만, 이를 극복한 후 얻은 장점들은 훨씬 더 크다고 느꼈습니다.

현재 여기어때 Android 앱은 Compose와 MVI 패턴을 기반으로 지속적으로 리팩토링을 진행 중이며, Firebase Performance Monitoring을 활용해 성능 수치를 분석하고 있습니다. 이러한 전환을 통해 앱 성능 향상을 확인할 수 있었고, 추후 이 데이터를 기반으로 공유할 기회가 있기를 기대합니다.

이번 경험이 여러분의 프로젝트에도 도움이 되길 바랍니다. 감사합니다!